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
This commit is contained in:
Rin
2026-02-18 11:14:38 +08:00
committed by Neko Ayaka
parent f70f9fac46
commit 1384161f42
4 changed files with 180 additions and 5 deletions
@@ -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()
})
})
@@ -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<void> {
@@ -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
}
@@ -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')
})
})
@@ -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.
`
}