refactor(minecraft): linearize perception event flow, and more refactors
- Change brain retry behavior to abort on error instead of throwing - Add JSON parsing utilities with better error messages for malformed LLM responses - Treat JSON syntax errors as recoverable for retry logic - Update conscious signal subscription to use 'conscious:signal:*' pattern - Move RuleEngine and rule utilities from cognitive/os to cognitive/perception/rules - Update tests to reflect new abort behavior (returns cleanup comments and rename debug source fix(minecraft): fix system message regression
This commit is contained in:
@@ -16,7 +16,7 @@ vi.mock('../../debug', () => {
|
||||
})
|
||||
|
||||
describe('brain decide retry', () => {
|
||||
it('retries up to 2 times for recoverable errors', async () => {
|
||||
it('retries up to 3 total attempts for recoverable errors then aborts', async () => {
|
||||
const logger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
@@ -28,7 +28,7 @@ describe('brain decide retry', () => {
|
||||
const neuri = {
|
||||
handleStateless: vi.fn(async (_messages: any, fn: any) => {
|
||||
call++
|
||||
if (call <= 2) {
|
||||
if (call <= 3) {
|
||||
const err: any = new Error('overloaded')
|
||||
err.status = 503
|
||||
throw err
|
||||
@@ -55,12 +55,12 @@ describe('brain decide retry', () => {
|
||||
; (brain as any).bot = { bot: { chat: vi.fn() } }
|
||||
|
||||
const res = await (brain as any).decide('sys', 'user')
|
||||
expect(res?.thought).toBe('ok')
|
||||
expect(res).toBeNull()
|
||||
expect(neuri.handleStateless).toHaveBeenCalledTimes(3)
|
||||
expect((brain as any).bot.bot.chat).not.toHaveBeenCalled()
|
||||
expect((brain as any).bot.bot.chat).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not retry auth/badarg errors; sends chat then throws', async () => {
|
||||
it('does not retry auth/badarg errors; sends chat then aborts', async () => {
|
||||
const logger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
@@ -87,7 +87,8 @@ describe('brain decide retry', () => {
|
||||
const chat = vi.fn()
|
||||
; (brain as any).bot = { bot: { chat } }
|
||||
|
||||
await expect((brain as any).decide('sys', 'user')).rejects.toThrow('unauthorized')
|
||||
const res = await (brain as any).decide('sys', 'user')
|
||||
expect(res).toBeNull()
|
||||
expect(neuri.handleStateless).toHaveBeenCalledTimes(1)
|
||||
expect(chat).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -29,6 +29,45 @@ function toErrorMessage(err: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getJsonErrorPosition(err: unknown): number | null {
|
||||
const msg = toErrorMessage(err)
|
||||
const match = msg.match(/position\s+(\d+)/i)
|
||||
if (!match)
|
||||
return null
|
||||
|
||||
const pos = Number.parseInt(match[1], 10)
|
||||
return Number.isFinite(pos) ? pos : null
|
||||
}
|
||||
|
||||
function extractJsonCandidate(input: string): string {
|
||||
const trimmed = input.trim()
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
|
||||
if (fenced?.[1])
|
||||
return fenced[1].trim()
|
||||
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start >= 0 && end > start)
|
||||
return trimmed.slice(start, end + 1)
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function parseLLMResponseJson<T>(response: string): T {
|
||||
const candidate = extractJsonCandidate(response)
|
||||
try {
|
||||
return JSON.parse(candidate) as T
|
||||
}
|
||||
catch (err) {
|
||||
const pos = getJsonErrorPosition(err)
|
||||
const window = 120
|
||||
const snippet = (typeof pos === 'number')
|
||||
? candidate.slice(Math.max(0, pos - window), Math.min(candidate.length, pos + window))
|
||||
: candidate.slice(0, Math.min(candidate.length, 240))
|
||||
throw new Error(`Failed to parse LLM JSON response: ${toErrorMessage(err)}; snippet=${JSON.stringify(snippet)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorStatus(err: unknown): number | undefined {
|
||||
const anyErr = err as any
|
||||
const status = anyErr?.status ?? anyErr?.response?.status ?? anyErr?.cause?.status
|
||||
@@ -60,6 +99,9 @@ function isLikelyAuthOrBadArgError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function isLikelyRecoverableError(err: unknown): boolean {
|
||||
if (err instanceof SyntaxError)
|
||||
return true
|
||||
|
||||
const status = getErrorStatus(err)
|
||||
if (status === 429)
|
||||
return true
|
||||
@@ -78,6 +120,7 @@ function isLikelyRecoverableError(err: unknown): boolean {
|
||||
|| msg.includes('overloaded')
|
||||
|| msg.includes('temporarily')
|
||||
|| msg.includes('try again')
|
||||
|| (msg.includes('in json') && msg.includes('position'))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,9 +213,9 @@ export class Brain {
|
||||
}
|
||||
}
|
||||
|
||||
// Perception Signal Handler - subscribe to all signal events
|
||||
// EventBus supports pattern wildcards like 'signal:*'
|
||||
this.deps.eventBus.subscribe<PerceptionSignal>('signal:*', (event: TracedEvent<PerceptionSignal>) => {
|
||||
// Conscious Signal Handler - signals must pass through Reflex first
|
||||
// EventBus supports pattern wildcards like 'conscious:signal:*'
|
||||
this.deps.eventBus.subscribe<PerceptionSignal>('conscious:signal:*', (event: TracedEvent<PerceptionSignal>) => {
|
||||
void handleSignal(event.payload)
|
||||
})
|
||||
|
||||
@@ -530,7 +573,7 @@ export class Brain {
|
||||
return null
|
||||
|
||||
// TODO: use toolcall instead of outputing json directly
|
||||
return JSON.parse(response) as LLMResponse
|
||||
return parseLLMResponseJson<LLMResponse>(response)
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
@@ -559,7 +602,8 @@ export class Brain {
|
||||
catch (chatErr) {
|
||||
this.log('ERROR', 'Brain: Failed to send error message to chat', { error: chatErr })
|
||||
}
|
||||
throw err
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
import type { Neuri } from 'neuri'
|
||||
|
||||
import type { EventBus, RuleEngine } from './os'
|
||||
import type { EventBus } from './os'
|
||||
import type { RuleEngine } from './perception/rules'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { asClass, asFunction, createContainer, InjectionMode } from 'awilix'
|
||||
@@ -11,8 +12,9 @@ import { ChatAgentImpl } from '../agents/chat'
|
||||
import { PlanningAgentImpl } from '../agents/planning'
|
||||
import { TaskExecutor } from './action/task-executor'
|
||||
import { Brain } from './conscious/brain'
|
||||
import { createEventBus, createRuleEngine } from './os'
|
||||
import { createEventBus } from './os'
|
||||
import { PerceptionPipeline } from './perception/pipeline'
|
||||
import { createRuleEngine } from './perception/rules'
|
||||
import { ReflexManager } from './reflex/reflex-manager'
|
||||
|
||||
export interface ContainerServices {
|
||||
@@ -60,7 +62,7 @@ export function createAgentContainer(options: {
|
||||
eventBus,
|
||||
logger: useLogg('ruleEngine').useGlobalConfig(),
|
||||
config: {
|
||||
rulesDir: new URL('./rules', import.meta.url).pathname,
|
||||
rulesDir: new URL('./perception/rules', import.meta.url).pathname,
|
||||
slotMs: 20,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ export function CognitiveEngine(options: CognitiveEngineOptions): MineflayerPlug
|
||||
reflexManager.init(botWithAgents)
|
||||
brain.init(botWithAgents)
|
||||
|
||||
// Ensure RuleEngine is instantiated (Awilix is lazy). It subscribes to raw:* during construction init.
|
||||
// Ensure perception rules engine is instantiated (Awilix is lazy).
|
||||
void container.resolve('ruleEngine')
|
||||
|
||||
// Initialize perception pipeline (raw events + detectors)
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
* - EventBus is the only mutable container
|
||||
*/
|
||||
|
||||
// NOTE: RuleEngine and rule utilities moved to cognitive/perception/rules.
|
||||
|
||||
// EventBus
|
||||
export { createEventBus, EventBus } from './event-bus'
|
||||
// Rules module
|
||||
export * from './rules'
|
||||
|
||||
// Tracer utilities
|
||||
export {
|
||||
|
||||
@@ -51,5 +51,5 @@ export const entityMovedEvent = definePerceptionEvent<[any], EntityMovedExtract>
|
||||
}),
|
||||
},
|
||||
|
||||
routes: ['conscious', 'debug'],
|
||||
routes: ['reflex', 'debug'],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { armSwingEvent } from './arm-swing'
|
||||
import { damageTakenEvent } from './damage-taken'
|
||||
import { entityMovedEvent } from './entity-moved'
|
||||
import { itemCollectedEvent } from './item-collected'
|
||||
import { playerJoinedEvent } from './player-joined'
|
||||
import { sneakToggleEvent } from './sneak-toggle'
|
||||
import { soundHeardEvent } from './sound-heard'
|
||||
import { systemMessageEvent } from './system-message'
|
||||
@@ -15,7 +14,6 @@ export const allEventDefinitions = [
|
||||
soundHeardEvent,
|
||||
damageTakenEvent,
|
||||
itemCollectedEvent,
|
||||
playerJoinedEvent,
|
||||
]
|
||||
|
||||
export {
|
||||
@@ -23,7 +21,6 @@ export {
|
||||
damageTakenEvent,
|
||||
entityMovedEvent,
|
||||
itemCollectedEvent,
|
||||
playerJoinedEvent,
|
||||
sneakToggleEvent,
|
||||
soundHeardEvent,
|
||||
systemMessageEvent,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { definePerceptionEvent } from '..'
|
||||
|
||||
interface PlayerJoinedExtract {
|
||||
playerId: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
const knownPlayerIds = new Set<string>()
|
||||
|
||||
export const playerJoinedEvent = definePerceptionEvent<[any], PlayerJoinedExtract>({
|
||||
id: 'player_joined',
|
||||
modality: 'system',
|
||||
kind: 'player_joined',
|
||||
|
||||
mineflayer: {
|
||||
event: 'playerJoined',
|
||||
filter: (ctx, player) => {
|
||||
if (!player)
|
||||
return false
|
||||
if (ctx.isSelf(player))
|
||||
return false
|
||||
|
||||
const playerId = ctx.entityId(player)
|
||||
if (knownPlayerIds.has(playerId))
|
||||
return false
|
||||
|
||||
knownPlayerIds.add(playerId)
|
||||
return true
|
||||
},
|
||||
extract: (ctx, player) => ({
|
||||
playerId: ctx.entityId(player),
|
||||
displayName: player?.username,
|
||||
}),
|
||||
},
|
||||
|
||||
saliency: {
|
||||
threshold: 1,
|
||||
key: 'system:player_joined',
|
||||
},
|
||||
|
||||
signal: {
|
||||
type: 'social_presence',
|
||||
description: extracted => `Player ${extracted.displayName || 'unknown'} joined the game`,
|
||||
metadata: extracted => ({
|
||||
playerId: extracted.playerId,
|
||||
displayName: extracted.displayName,
|
||||
action: 'joined',
|
||||
}),
|
||||
},
|
||||
|
||||
routes: ['conscious', 'reflex'],
|
||||
})
|
||||
@@ -25,5 +25,5 @@ export const systemMessageEvent = definePerceptionEvent<[string, string], { mess
|
||||
}),
|
||||
},
|
||||
|
||||
routes: ['conscious', 'reflex'],
|
||||
routes: ['reflex'],
|
||||
})
|
||||
|
||||
@@ -32,11 +32,10 @@ export class PerceptionPipeline {
|
||||
this.eventRegistry = new EventRegistry({
|
||||
logger: this.deps.logger,
|
||||
onSignal: (signal) => {
|
||||
this.deps.eventBus.emit({
|
||||
type: `signal:${signal.type}`,
|
||||
payload: signal,
|
||||
source: { component: 'perception', id: 'event-registry' },
|
||||
})
|
||||
// NOTICE: In the linear architecture, Perception does not emit signal:* directly.
|
||||
// Raw events are emitted and the rules layer derives signals. Reflex then forwards
|
||||
// selected signals to conscious.
|
||||
void signal
|
||||
},
|
||||
onRawEvent: (event) => {
|
||||
const eventType = `raw:${event.modality}:${event.kind}`
|
||||
@@ -158,7 +157,7 @@ export class PerceptionPipeline {
|
||||
|
||||
/**
|
||||
* Emit a raw perception event to the EventBus
|
||||
* This bridges the perception system to the rule engine
|
||||
* This bridges the perception system to the rules layer
|
||||
*/
|
||||
private emitRawToEventBus(raw: RawPerceptionEvent): void {
|
||||
const eventType = `raw:${raw.modality}:${raw.kind}`
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import type { EventBus, TracedEvent } from '../index'
|
||||
import type { EventBus, TracedEvent } from '../../os'
|
||||
import type {
|
||||
AccumulatorsState,
|
||||
ParsedRule,
|
||||
@@ -226,7 +226,7 @@ export class RuleEngine {
|
||||
this.deps.eventBus.emitChild(event, {
|
||||
type: `signal:${result.signal.type}`,
|
||||
payload: result.signal,
|
||||
source: { component: 'ruleEngine', id: rule.name },
|
||||
source: { component: 'perception', id: rule.name },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,7 @@ export class RuleEngine {
|
||||
this.deps.eventBus.emitChild(sourceEvent, {
|
||||
type: `signal:${signal.type}`,
|
||||
payload: signal,
|
||||
source: { component: 'ruleEngine', id: rule.name },
|
||||
source: { component: 'perception', id: rule.name },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
name: system-message
|
||||
version: 1
|
||||
|
||||
trigger:
|
||||
modality: system
|
||||
kind: system_message
|
||||
|
||||
accumulator:
|
||||
threshold: 1
|
||||
window: 1s
|
||||
|
||||
signal:
|
||||
type: system_message
|
||||
description: '{{ message }}'
|
||||
confidence: 1.0
|
||||
metadata:
|
||||
message: '{{ message }}'
|
||||
position: '{{ position }}'
|
||||
@@ -81,6 +81,11 @@ describe('reflexManager', () => {
|
||||
|
||||
handler(signalEvent)
|
||||
|
||||
// Should forward to conscious by default for higher-level signals
|
||||
expect(eventBus.emitChild).toHaveBeenCalledWith(signalEvent, expect.objectContaining({
|
||||
type: 'conscious:signal:social_gesture',
|
||||
}))
|
||||
|
||||
// TODO: Ideally we assert that tick() was called.
|
||||
// Since tick is internal/called via runtime, we might need to inspect side effects or spy on runtime.
|
||||
// For now, ensure it doesn't crash.
|
||||
@@ -119,7 +124,7 @@ describe('reflexManager', () => {
|
||||
reflex.init(bot)
|
||||
|
||||
const handler = eventBus.subscribe.mock.calls[0][1]
|
||||
handler({
|
||||
const chatEvent = {
|
||||
type: 'signal:chat_message',
|
||||
payload: {
|
||||
type: 'chat_message',
|
||||
@@ -130,13 +135,20 @@ describe('reflexManager', () => {
|
||||
},
|
||||
source: { component: 'ruleEngine', id: 'test' },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
handler(chatEvent)
|
||||
|
||||
const snap = reflex.getContextSnapshot()
|
||||
expect(snap.social.lastSpeaker).toBe('alice')
|
||||
expect(snap.social.lastMessage).toBe('hi')
|
||||
expect(reflex.getMode()).toBe('social')
|
||||
|
||||
// Should forward chat to conscious
|
||||
expect(eventBus.emitChild).toHaveBeenCalledWith(chatEvent, expect.objectContaining({
|
||||
type: 'conscious:signal:chat_message',
|
||||
}))
|
||||
|
||||
reflex.destroy()
|
||||
})
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export class ReflexManager {
|
||||
|
||||
public init(bot: MineflayerWithAgents): void {
|
||||
this.bot = bot
|
||||
// Subscribe to all signals from RuleEngine
|
||||
// Subscribe to all signals produced by the perception rules
|
||||
this.unsubscribe = this.deps.eventBus.subscribe('signal:*', (event) => {
|
||||
this.onSignal(event as TracedEvent<PerceptionSignal>)
|
||||
})
|
||||
@@ -148,9 +148,24 @@ export class ReflexManager {
|
||||
// Trigger behavior selection
|
||||
this.runtime.tick(bot, 0, this.deps.perception)
|
||||
|
||||
// Forward signals to conscious layer (Brain) ONLY when Reflex decides.
|
||||
if (this.shouldForwardToConscious(signal)) {
|
||||
this.deps.eventBus.emitChild(event, {
|
||||
type: `conscious:signal:${signal.type}`,
|
||||
payload: signal,
|
||||
source: { component: 'reflex', id: 'reflexManager' },
|
||||
})
|
||||
}
|
||||
|
||||
this.emitReflexState()
|
||||
}
|
||||
|
||||
private shouldForwardToConscious(signal: PerceptionSignal): boolean {
|
||||
// Keep conscious layer focused on higher-level / decision-relevant signals.
|
||||
// entity_attention (e.g. movement/punch attention) is handled by Reflex behaviors.
|
||||
return signal.type !== 'entity_attention'
|
||||
}
|
||||
|
||||
private emitReflexState(): void {
|
||||
DebugService.getInstance().emitReflexState({
|
||||
mode: this.runtime.getMode(),
|
||||
|
||||
Reference in New Issue
Block a user