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:
@@ -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.
|
||||
`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user