From 60133ae27624e8d234177efb4561ceb12c97897d Mon Sep 17 00:00:00 2001 From: Rin Date: Tue, 20 Jan 2026 04:09:11 +0800 Subject: [PATCH] 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 --- .../src/cognitive/conscious/brain.test.ts | 13 ++--- .../src/cognitive/conscious/brain.ts | 54 +++++++++++++++++-- services/minecraft/src/cognitive/container.ts | 8 +-- services/minecraft/src/cognitive/index.ts | 2 +- services/minecraft/src/cognitive/os/index.ts | 4 +- .../events/definitions/entity-moved.ts | 2 +- .../perception/events/definitions/index.ts | 3 -- .../events/definitions/player-joined.ts | 52 ------------------ .../events/definitions/system-message.ts | 2 +- .../src/cognitive/perception/pipeline.ts | 11 ++-- .../{os => perception}/rules/accumulator.ts | 0 .../rules/attention/movement.yaml | 0 .../rules/attention/punch.yaml | 0 .../{ => perception}/rules/danger/damage.yaml | 0 .../{os => perception}/rules/engine.ts | 6 +-- .../{os => perception}/rules/index.ts | 0 .../{os => perception}/rules/loader.ts | 0 .../{os => perception}/rules/matcher.ts | 0 .../{os => perception}/rules/rules.test.ts | 0 .../rules/social/player-joined.yaml | 0 .../{ => perception}/rules/social/teabag.yaml | 0 .../perception/rules/system-message.yaml | 18 +++++++ .../{os => perception}/rules/types.ts | 0 .../cognitive/reflex/reflex-manager.test.ts | 16 +++++- .../src/cognitive/reflex/reflex-manager.ts | 17 +++++- 25 files changed, 122 insertions(+), 86 deletions(-) delete mode 100644 services/minecraft/src/cognitive/perception/events/definitions/player-joined.ts rename services/minecraft/src/cognitive/{os => perception}/rules/accumulator.ts (100%) rename services/minecraft/src/cognitive/{ => perception}/rules/attention/movement.yaml (100%) rename services/minecraft/src/cognitive/{ => perception}/rules/attention/punch.yaml (100%) rename services/minecraft/src/cognitive/{ => perception}/rules/danger/damage.yaml (100%) rename services/minecraft/src/cognitive/{os => perception}/rules/engine.ts (97%) rename services/minecraft/src/cognitive/{os => perception}/rules/index.ts (100%) rename services/minecraft/src/cognitive/{os => perception}/rules/loader.ts (100%) rename services/minecraft/src/cognitive/{os => perception}/rules/matcher.ts (100%) rename services/minecraft/src/cognitive/{os => perception}/rules/rules.test.ts (100%) rename services/minecraft/src/cognitive/{ => perception}/rules/social/player-joined.yaml (100%) rename services/minecraft/src/cognitive/{ => perception}/rules/social/teabag.yaml (100%) create mode 100644 services/minecraft/src/cognitive/perception/rules/system-message.yaml rename services/minecraft/src/cognitive/{os => perception}/rules/types.ts (100%) diff --git a/services/minecraft/src/cognitive/conscious/brain.test.ts b/services/minecraft/src/cognitive/conscious/brain.test.ts index 3004b2176..1519153a0 100644 --- a/services/minecraft/src/cognitive/conscious/brain.test.ts +++ b/services/minecraft/src/cognitive/conscious/brain.test.ts @@ -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) }) diff --git a/services/minecraft/src/cognitive/conscious/brain.ts b/services/minecraft/src/cognitive/conscious/brain.ts index d92f9ac81..776c52740 100644 --- a/services/minecraft/src/cognitive/conscious/brain.ts +++ b/services/minecraft/src/cognitive/conscious/brain.ts @@ -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(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('signal:*', (event: TracedEvent) => { + // Conscious Signal Handler - signals must pass through Reflex first + // EventBus supports pattern wildcards like 'conscious:signal:*' + this.deps.eventBus.subscribe('conscious:signal:*', (event: TracedEvent) => { 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(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 } } diff --git a/services/minecraft/src/cognitive/container.ts b/services/minecraft/src/cognitive/container.ts index 188555bbb..816321c0f 100644 --- a/services/minecraft/src/cognitive/container.ts +++ b/services/minecraft/src/cognitive/container.ts @@ -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, }, }) diff --git a/services/minecraft/src/cognitive/index.ts b/services/minecraft/src/cognitive/index.ts index afe425b7e..385e51d75 100644 --- a/services/minecraft/src/cognitive/index.ts +++ b/services/minecraft/src/cognitive/index.ts @@ -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) diff --git a/services/minecraft/src/cognitive/os/index.ts b/services/minecraft/src/cognitive/os/index.ts index 8d20d834a..960483877 100644 --- a/services/minecraft/src/cognitive/os/index.ts +++ b/services/minecraft/src/cognitive/os/index.ts @@ -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 { diff --git a/services/minecraft/src/cognitive/perception/events/definitions/entity-moved.ts b/services/minecraft/src/cognitive/perception/events/definitions/entity-moved.ts index 65f975f38..24e8cbcfc 100644 --- a/services/minecraft/src/cognitive/perception/events/definitions/entity-moved.ts +++ b/services/minecraft/src/cognitive/perception/events/definitions/entity-moved.ts @@ -51,5 +51,5 @@ export const entityMovedEvent = definePerceptionEvent<[any], EntityMovedExtract> }), }, - routes: ['conscious', 'debug'], + routes: ['reflex', 'debug'], }) diff --git a/services/minecraft/src/cognitive/perception/events/definitions/index.ts b/services/minecraft/src/cognitive/perception/events/definitions/index.ts index 6d93fa6ca..264fdfa1d 100644 --- a/services/minecraft/src/cognitive/perception/events/definitions/index.ts +++ b/services/minecraft/src/cognitive/perception/events/definitions/index.ts @@ -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, diff --git a/services/minecraft/src/cognitive/perception/events/definitions/player-joined.ts b/services/minecraft/src/cognitive/perception/events/definitions/player-joined.ts deleted file mode 100644 index a69e28242..000000000 --- a/services/minecraft/src/cognitive/perception/events/definitions/player-joined.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { definePerceptionEvent } from '..' - -interface PlayerJoinedExtract { - playerId: string - displayName?: string -} - -const knownPlayerIds = new Set() - -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'], -}) diff --git a/services/minecraft/src/cognitive/perception/events/definitions/system-message.ts b/services/minecraft/src/cognitive/perception/events/definitions/system-message.ts index d1e606693..c8c0780bc 100644 --- a/services/minecraft/src/cognitive/perception/events/definitions/system-message.ts +++ b/services/minecraft/src/cognitive/perception/events/definitions/system-message.ts @@ -25,5 +25,5 @@ export const systemMessageEvent = definePerceptionEvent<[string, string], { mess }), }, - routes: ['conscious', 'reflex'], + routes: ['reflex'], }) diff --git a/services/minecraft/src/cognitive/perception/pipeline.ts b/services/minecraft/src/cognitive/perception/pipeline.ts index e476afc8e..861a29d26 100644 --- a/services/minecraft/src/cognitive/perception/pipeline.ts +++ b/services/minecraft/src/cognitive/perception/pipeline.ts @@ -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}` diff --git a/services/minecraft/src/cognitive/os/rules/accumulator.ts b/services/minecraft/src/cognitive/perception/rules/accumulator.ts similarity index 100% rename from services/minecraft/src/cognitive/os/rules/accumulator.ts rename to services/minecraft/src/cognitive/perception/rules/accumulator.ts diff --git a/services/minecraft/src/cognitive/rules/attention/movement.yaml b/services/minecraft/src/cognitive/perception/rules/attention/movement.yaml similarity index 100% rename from services/minecraft/src/cognitive/rules/attention/movement.yaml rename to services/minecraft/src/cognitive/perception/rules/attention/movement.yaml diff --git a/services/minecraft/src/cognitive/rules/attention/punch.yaml b/services/minecraft/src/cognitive/perception/rules/attention/punch.yaml similarity index 100% rename from services/minecraft/src/cognitive/rules/attention/punch.yaml rename to services/minecraft/src/cognitive/perception/rules/attention/punch.yaml diff --git a/services/minecraft/src/cognitive/rules/danger/damage.yaml b/services/minecraft/src/cognitive/perception/rules/danger/damage.yaml similarity index 100% rename from services/minecraft/src/cognitive/rules/danger/damage.yaml rename to services/minecraft/src/cognitive/perception/rules/danger/damage.yaml diff --git a/services/minecraft/src/cognitive/os/rules/engine.ts b/services/minecraft/src/cognitive/perception/rules/engine.ts similarity index 97% rename from services/minecraft/src/cognitive/os/rules/engine.ts rename to services/minecraft/src/cognitive/perception/rules/engine.ts index bfbe7cf4d..3d700ea72 100644 --- a/services/minecraft/src/cognitive/os/rules/engine.ts +++ b/services/minecraft/src/cognitive/perception/rules/engine.ts @@ -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 }, }) } } diff --git a/services/minecraft/src/cognitive/os/rules/index.ts b/services/minecraft/src/cognitive/perception/rules/index.ts similarity index 100% rename from services/minecraft/src/cognitive/os/rules/index.ts rename to services/minecraft/src/cognitive/perception/rules/index.ts diff --git a/services/minecraft/src/cognitive/os/rules/loader.ts b/services/minecraft/src/cognitive/perception/rules/loader.ts similarity index 100% rename from services/minecraft/src/cognitive/os/rules/loader.ts rename to services/minecraft/src/cognitive/perception/rules/loader.ts diff --git a/services/minecraft/src/cognitive/os/rules/matcher.ts b/services/minecraft/src/cognitive/perception/rules/matcher.ts similarity index 100% rename from services/minecraft/src/cognitive/os/rules/matcher.ts rename to services/minecraft/src/cognitive/perception/rules/matcher.ts diff --git a/services/minecraft/src/cognitive/os/rules/rules.test.ts b/services/minecraft/src/cognitive/perception/rules/rules.test.ts similarity index 100% rename from services/minecraft/src/cognitive/os/rules/rules.test.ts rename to services/minecraft/src/cognitive/perception/rules/rules.test.ts diff --git a/services/minecraft/src/cognitive/rules/social/player-joined.yaml b/services/minecraft/src/cognitive/perception/rules/social/player-joined.yaml similarity index 100% rename from services/minecraft/src/cognitive/rules/social/player-joined.yaml rename to services/minecraft/src/cognitive/perception/rules/social/player-joined.yaml diff --git a/services/minecraft/src/cognitive/rules/social/teabag.yaml b/services/minecraft/src/cognitive/perception/rules/social/teabag.yaml similarity index 100% rename from services/minecraft/src/cognitive/rules/social/teabag.yaml rename to services/minecraft/src/cognitive/perception/rules/social/teabag.yaml diff --git a/services/minecraft/src/cognitive/perception/rules/system-message.yaml b/services/minecraft/src/cognitive/perception/rules/system-message.yaml new file mode 100644 index 000000000..bd9ce62e5 --- /dev/null +++ b/services/minecraft/src/cognitive/perception/rules/system-message.yaml @@ -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 }}' diff --git a/services/minecraft/src/cognitive/os/rules/types.ts b/services/minecraft/src/cognitive/perception/rules/types.ts similarity index 100% rename from services/minecraft/src/cognitive/os/rules/types.ts rename to services/minecraft/src/cognitive/perception/rules/types.ts diff --git a/services/minecraft/src/cognitive/reflex/reflex-manager.test.ts b/services/minecraft/src/cognitive/reflex/reflex-manager.test.ts index c7208400c..be765b60a 100644 --- a/services/minecraft/src/cognitive/reflex/reflex-manager.test.ts +++ b/services/minecraft/src/cognitive/reflex/reflex-manager.test.ts @@ -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() }) diff --git a/services/minecraft/src/cognitive/reflex/reflex-manager.ts b/services/minecraft/src/cognitive/reflex/reflex-manager.ts index 7959edbf3..81d425649 100644 --- a/services/minecraft/src/cognitive/reflex/reflex-manager.ts +++ b/services/minecraft/src/cognitive/reflex/reflex-manager.ts @@ -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) }) @@ -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(),