From 1384161f423d8701b5723d4a10d1bb7fa409f3b0 Mon Sep 17 00:00:00 2001 From: Rin Date: Wed, 4 Feb 2026 20:31:41 +0800 Subject: [PATCH] feat(minecraft): add no-action follow-up system to prevent silent observation loops Add NO_ACTION_FOLLOWUP_SOURCE_ID constant, implement queueNoActionFollowup method that schedules system_alert event with no_actions reason when planner returns zero actions during observation mode, suppress follow-up if already in follow-up chain to prevent infinite loops, update brain-prompt with feedback loop guard guidance to avoid chat->feedback->chat cycles and clarify feedback:true usage for diagnostic verification only --- .../src/cognitive/conscious/brain.test.ts | 115 ++++++++++++++++++ .../src/cognitive/conscious/brain.ts | 34 ++++++ .../conscious/prompts/brain-prompt.test.ts | 21 ++++ .../conscious/prompts/brain-prompt.ts | 15 ++- 4 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 services/minecraft/src/cognitive/conscious/brain.test.ts create mode 100644 services/minecraft/src/cognitive/conscious/prompts/brain-prompt.test.ts diff --git a/services/minecraft/src/cognitive/conscious/brain.test.ts b/services/minecraft/src/cognitive/conscious/brain.test.ts new file mode 100644 index 000000000..480e27006 --- /dev/null +++ b/services/minecraft/src/cognitive/conscious/brain.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest' + +import { Brain } from './brain' + +function createReflexSnapshot() { + return { + self: { + health: 20, + food: 20, + holding: null, + location: { x: 0, y: 64, z: 0 }, + }, + environment: { + time: 'day', + weather: 'clear', + nearbyPlayers: [], + nearbyEntities: [], + lightLevel: 15, + nearbyPlayersGaze: [], + }, + social: {}, + threat: {}, + attention: {}, + autonomy: { + followPlayer: null, + followActive: false, + }, + } +} + +function createDeps(llmText: string) { + const logger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + withError: vi.fn(), + } as any + logger.withError.mockReturnValue(logger) + + return { + eventBus: { subscribe: vi.fn() }, + llmAgent: { + callLLM: vi.fn(async () => ({ text: llmText, reasoning: '', usage: {} })), + }, + logger, + taskExecutor: { + getAvailableActions: vi.fn(() => []), + executeActionWithResult: vi.fn(async () => 'ok'), + on: vi.fn(), + }, + reflexManager: { + getContextSnapshot: vi.fn(() => createReflexSnapshot()), + clearFollowTarget: vi.fn(), + }, + } as any +} + +function createPerceptionEvent() { + return { + type: 'perception', + payload: { + type: 'chat_message', + description: 'Chat from Alex: "hi"', + sourceId: 'Alex', + confidence: 1, + timestamp: Date.now(), + metadata: { username: 'Alex', message: 'hi' }, + }, + source: { type: 'minecraft', id: 'Alex' }, + timestamp: Date.now(), + } as any +} + +describe('brain no-action follow-up', () => { + it('queues exactly one synthetic follow-up on no-action result', async () => { + const brain: any = new Brain(createDeps('1 + 1')) + const enqueueSpy = vi.fn(async () => undefined) + brain.enqueueEvent = enqueueSpy + + await brain.processEvent({} as any, createPerceptionEvent()) + + expect(enqueueSpy).toHaveBeenCalledTimes(1) + const queuedEvent = (enqueueSpy.mock.calls[0] as any[])?.[1] + expect(queuedEvent).toMatchObject({ + type: 'system_alert', + source: { type: 'system', id: 'brain:no_action_followup' }, + payload: { reason: 'no_actions' }, + }) + }) + + it('does not chain follow-up from follow-up event source', async () => { + const brain: any = new Brain(createDeps('1 + 1')) + const enqueueSpy = vi.fn(async () => undefined) + brain.enqueueEvent = enqueueSpy + + await brain.processEvent({} as any, { + type: 'system_alert', + payload: { reason: 'seed' }, + source: { type: 'system', id: 'brain:no_action_followup' }, + timestamp: Date.now(), + }) + + expect(enqueueSpy).not.toHaveBeenCalled() + }) + + it('does not queue follow-up when script uses skip()', async () => { + const brain: any = new Brain(createDeps('await skip()')) + const enqueueSpy = vi.fn(async () => undefined) + brain.enqueueEvent = enqueueSpy + + await brain.processEvent({} as any, createPerceptionEvent()) + + expect(enqueueSpy).not.toHaveBeenCalled() + }) +}) diff --git a/services/minecraft/src/cognitive/conscious/brain.ts b/services/minecraft/src/cognitive/conscious/brain.ts index ead99c573..e7ed8bdb6 100644 --- a/services/minecraft/src/cognitive/conscious/brain.ts +++ b/services/minecraft/src/cognitive/conscious/brain.ts @@ -77,6 +77,8 @@ function truncateForPrompt(value: string, maxLength = 220): string { return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...` } +const NO_ACTION_FOLLOWUP_SOURCE_ID = 'brain:no_action_followup' + export class Brain { private debugService: DebugService private readonly planner = new JavaScriptPlanner() @@ -273,6 +275,35 @@ export class Brain { return JSON.parse(JSON.stringify(messages)) as Message[] } + private queueNoActionFollowup( + bot: MineflayerWithAgents, + triggeringEvent: BotEvent, + returnValue: string | undefined, + logs: string[], + ): void { + if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID) { + this.deps.logger.log('INFO', 'Brain: Suppressed no-action follow-up (already in follow-up chain)') + this.debugService.log('DEBUG', 'No-action follow-up suppressed (already follow-up source)') + return + } + + const followupEvent: BotEvent = { + type: 'system_alert', + payload: { + reason: 'no_actions', + returnValue: returnValue ?? 'undefined', + logs: logs.slice(-3), + }, + source: { type: 'system', id: NO_ACTION_FOLLOWUP_SOURCE_ID }, + timestamp: Date.now(), + } + + this.debugService.log('DEBUG', 'Scheduling one-hop no-action follow-up turn') + void this.enqueueEvent(bot, followupEvent).catch(err => + this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'), + ) + } + // --- Event Queue Logic --- private async enqueueEvent(bot: MineflayerWithAgents, event: BotEvent): Promise { @@ -485,6 +516,9 @@ export class Brain { durationMs: 0, timestamp: Date.now(), }) + if (runResult.actions.length === 0) { + this.queueNoActionFollowup(bot, event, runResult.returnValue, runResult.logs) + } this.deps.logger.log('INFO', 'Brain: Skipping turn (observing)') return } diff --git a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.test.ts b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.test.ts new file mode 100644 index 000000000..42c393958 --- /dev/null +++ b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' + +import { generateBrainSystemPrompt } from './brain-prompt' + +describe('generateBrainSystemPrompt', () => { + it('includes chat feedback loop guard guidance', () => { + const prompt = generateBrainSystemPrompt([ + { + name: 'chat', + description: 'Send a chat message', + execution: 'sync', + schema: z.object({ message: z.string(), feedback: z.boolean().optional() }), + perform: () => () => '', + }, + ] as any) + + expect(prompt).toContain('Feedback Loop Guard') + expect(prompt).toContain('chat->feedback->chat') + }) +}) diff --git a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts index cb0906fa5..0aec30b78 100644 --- a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts +++ b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.ts @@ -2,12 +2,16 @@ import type { Action } from '../../../libs/mineflayer/action' // Helper to extract readable type from Zod schema function getZodTypeName(def: any): string { - if (!def) return 'any' + if (!def) + return 'any' const type = def.type || def.typeName - if (type === 'string' || type === 'ZodString') return 'string' - if (type === 'number' || type === 'ZodNumber') return 'number' - if (type === 'boolean' || type === 'ZodBoolean') return 'boolean' + if (type === 'string' || type === 'ZodString') + return 'string' + if (type === 'number' || type === 'ZodNumber') + return 'number' + if (type === 'boolean' || type === 'ZodBoolean') + return 'boolean' if (type === 'array' || type === 'ZodArray') { const innerDef = def.element?._def || def.type?._def @@ -194,7 +198,8 @@ Common patterns: - **Chat Discipline**: Do not send proactive small-talk. Use \`chat\` only when replying to a player chat, reporting meaningful task progress/failure, or urgent safety status. - **No Harness Replies**: Never treat \`[PERCEPTION]\`, \`[FEEDBACK]\`, or other system wrappers as players. Only reply with \`chat\` to actual player \`chat_message\` events. - **No Self Replies**: Never reply to your own previous bot messages. -- **Chat Feedback**: \`chat\` feedback is optional; keep \`feedback: false\` for normal conversation. Use \`feedback: true\` only when your next step explicitly needs the chat acknowledgement in history. +- **Chat Feedback**: \`chat\` feedback is optional; keep \`feedback: false\` for normal conversation. Use \`feedback: true\` only for diagnostic verification of a sent chat. +- **Feedback Loop Guard**: Avoid chat->feedback->chat positive loops. After a diagnostic \`feedback: true\` check, usually continue with \`skip()\` unless the returned feedback is unexpected and needs action. - **Follow Mode**: If \`autonomy.followPlayer\` is set, reflex will follow that player while idle. Only clear it when the current mission needs independent movement. ` }