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:
Rin
2026-02-18 11:12:45 +08:00
committed by Neko Ayaka
parent b493d8ed76
commit 60133ae276
25 changed files with 122 additions and 86 deletions
@@ -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,
},
})
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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}`
@@ -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(),