feat(minecraft): add error-burst guard to prevent LLM error loops with mandatory giveUp+chat recovery

Add ErrorBurstGuardState interface tracking threshold/windowTurns/errorTurnCount/recentTurnIds/recentErrorSummary/suggestedCooldownSeconds/triggeredAtTurnId, implement error-burst detection with ERROR_BURST_THRESHOLD=3/WINDOW_TURNS=5/COOLDOWN_SECONDS=45 constants, add isErrorLlmLogEntry/describeErrorLlmLogEntry/collectRecentErrorTurns helpers to identify repl_error/repl_result with errorCount/feedback
This commit is contained in:
Rin
2026-02-18 11:14:42 +08:00
committed by Neko Ayaka
parent 604167c93e
commit 9a35b853a9
6 changed files with 359 additions and 3 deletions
@@ -97,6 +97,32 @@ function createReadonlyAction(name: string = 'querySnapshot') {
} as any } as any
} }
function createGiveUpAction() {
return {
name: 'giveUp',
description: 'Give up action',
execution: 'sync',
schema: z.object({
reason: z.string(),
cooldown_seconds: z.number(),
}),
perform: () => () => 'gave up',
} as any
}
function createChatAction() {
return {
name: 'chat',
description: 'Chat action',
execution: 'sync',
schema: z.object({
message: z.string(),
feedback: z.boolean().optional(),
}),
perform: () => () => 'chat sent',
} as any
}
describe('brain no-action follow-up', () => { describe('brain no-action follow-up', () => {
it('forgets conversation only', () => { it('forgets conversation only', () => {
const brain: any = new Brain(createDeps('await skip()')) const brain: any = new Brain(createDeps('await skip()'))
@@ -265,6 +291,79 @@ inv;
expect(deps.reflexManager.refreshFromBotState).toHaveBeenCalledTimes(1) expect(deps.reflexManager.refreshFromBotState).toHaveBeenCalledTimes(1)
expect(brain.enqueueEvent).toHaveBeenCalledTimes(1) expect(brain.enqueueEvent).toHaveBeenCalledTimes(1)
}) })
it('activates error-burst guard and enqueues guard alert after repeated errors', async () => {
const brain: any = new Brain(createDeps('const broken = ;'))
const enqueueSpy = vi.fn(async () => undefined)
brain.enqueueEvent = enqueueSpy
await brain.processEvent({} as any, createPerceptionEvent())
await brain.processEvent({} as any, createPerceptionEvent())
await brain.processEvent({} as any, createPerceptionEvent())
const guardEvent = enqueueSpy.mock.calls
.map((call: any[]) => call[1])
.find((event: any) => event?.source?.id === 'brain:error_burst_guard')
expect(guardEvent).toMatchObject({
type: 'system_alert',
source: { type: 'system', id: 'brain:error_burst_guard' },
payload: {
reason: 'error_burst_guard',
threshold: 3,
windowTurns: 5,
},
})
expect(brain.errorBurstGuardState?.errorTurnCount).toBeGreaterThanOrEqual(3)
})
it('includes mandatory give-up and chat instructions when error-burst guard is active', () => {
const brain: any = new Brain(createDeps('await skip()'))
brain.errorBurstGuardState = {
threshold: 3,
windowTurns: 5,
errorTurnCount: 3,
recentTurnIds: [7, 6, 5, 4, 3],
recentErrorSummary: ['turn=7 repl_error: parse failed'],
suggestedCooldownSeconds: 45,
triggeredAtTurnId: 8,
}
const message = brain.buildUserMessage(
createPerceptionEvent(),
'[PERCEPTION] Self: healthy\nEnvironment: clear',
)
expect(message).toContain('[ERROR_BURST_GUARD] active')
expect(message).toContain('await giveUp({ reason: "..."')
expect(message).toContain('await chat({ message: "..."')
})
it('clears error-burst guard when giveUp and chat both succeed in one turn', async () => {
const deps: any = createDeps('await giveUp({ reason: "stuck", cooldown_seconds: 45 }); await chat("I got stuck after repeated errors.")')
deps.taskExecutor.getAvailableActions = vi.fn(() => [createGiveUpAction(), createChatAction()])
deps.taskExecutor.executeActionWithResult = vi.fn(async (action: any) => action.tool === 'giveUp' ? 'gave up' : 'chat sent')
const brain: any = new Brain(deps)
brain.errorBurstGuardState = {
threshold: 3,
windowTurns: 5,
errorTurnCount: 3,
recentTurnIds: [7, 6, 5, 4, 3],
recentErrorSummary: ['turn=7 repl_error: parse failed'],
suggestedCooldownSeconds: 45,
triggeredAtTurnId: 8,
}
await brain.processEvent({} as any, createPerceptionEvent())
expect(brain.errorBurstGuardState).toBeNull()
const clearedEntry = brain.getLlmLogs().find((entry: any) =>
entry.sourceId === 'brain:error_burst_guard'
&& entry.tags.includes('guard_cleared'),
)
expect(clearedEntry).toBeTruthy()
})
}) })
function createFeedbackEvent() { function createFeedbackEvent() {
@@ -176,6 +176,16 @@ interface NoActionBudgetState {
max: number max: number
} }
interface ErrorBurstGuardState {
threshold: number
windowTurns: number
errorTurnCount: number
recentTurnIds: number[]
recentErrorSummary: string[]
suggestedCooldownSeconds: number
triggeredAtTurnId: number
}
function truncateForPrompt(value: string, maxLength = 220): string { function truncateForPrompt(value: string, maxLength = 220): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...` return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}...`
} }
@@ -208,6 +218,10 @@ const ACTION_QUEUE_RECENT_HISTORY_LIMIT = 20
const NO_ACTION_FOLLOWUP_BUDGET_DEFAULT = 3 const NO_ACTION_FOLLOWUP_BUDGET_DEFAULT = 3
const NO_ACTION_FOLLOWUP_BUDGET_MAX = 8 const NO_ACTION_FOLLOWUP_BUDGET_MAX = 8
const NO_ACTION_STAGNATION_REPEAT_LIMIT = 2 const NO_ACTION_STAGNATION_REPEAT_LIMIT = 2
const ERROR_BURST_GUARD_SOURCE_ID = 'brain:error_burst_guard'
const ERROR_BURST_THRESHOLD = 3
const ERROR_BURST_WINDOW_TURNS = 5
const ERROR_BURST_COOLDOWN_SECONDS = 45
function getEventPriority(event: BotEvent): number { function getEventPriority(event: BotEvent): number {
if (event.type === 'perception') { if (event.type === 'perception') {
@@ -258,6 +272,8 @@ export class Brain {
private noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT private noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT
private noActionFollowupLastSignature: string | null = null private noActionFollowupLastSignature: string | null = null
private noActionFollowupStagnationCount = 0 private noActionFollowupStagnationCount = 0
private errorBurstGuardState: ErrorBurstGuardState | null = null
private errorBurstGuardSuppressUntilTurnId = 0
constructor(private readonly deps: BrainDeps) { constructor(private readonly deps: BrainDeps) {
this.debugService = DebugService.getInstance() this.debugService = DebugService.getInstance()
@@ -569,6 +585,7 @@ export class Brain {
llmLog: this.llmLogRuntime, llmLog: this.llmLogRuntime,
actionQueue: this.getActionQueueSnapshot(), actionQueue: this.getActionQueueSnapshot(),
noActionBudget: this.getNoActionBudgetState(), noActionBudget: this.getNoActionBudgetState(),
errorBurstGuard: this.errorBurstGuardState ? { ...this.errorBurstGuardState } : null,
setNoActionBudget: (value: number) => this.setNoActionFollowupBudget(value), setNoActionBudget: (value: number) => this.setNoActionFollowupBudget(value),
getNoActionBudget: () => this.getNoActionBudgetState(), getNoActionBudget: () => this.getNoActionBudgetState(),
forgetConversation: () => this.forgetConversation(), forgetConversation: () => this.forgetConversation(),
@@ -661,6 +678,189 @@ export class Brain {
} }
} }
private isErrorLlmLogEntry(entry: LlmLogEntry): boolean {
if (entry.kind === 'repl_error')
return true
if (entry.kind === 'repl_result') {
const errorCount = Number((entry.metadata as Record<string, unknown> | undefined)?.errorCount ?? 0)
return Number.isFinite(errorCount) && errorCount > 0
}
if (entry.kind === 'feedback') {
const tags = new Set(entry.tags.map(tag => tag.toLowerCase()))
return tags.has('error') || tags.has('failure')
}
return false
}
private describeErrorLlmLogEntry(entry: LlmLogEntry): string {
return `${entry.kind}: ${truncateForPrompt(entry.text, 140)}`
}
private collectRecentErrorTurns(windowTurns = ERROR_BURST_WINDOW_TURNS): {
recentTurnIds: number[]
errorTurnIds: number[]
summaries: string[]
} {
const turnIds: number[] = []
const seen = new Set<number>()
for (let index = this.llmLogEntries.length - 1; index >= 0; index--) {
const entry = this.llmLogEntries[index]
if (!entry || entry.kind !== 'turn_input')
continue
if (seen.has(entry.turnId))
continue
seen.add(entry.turnId)
turnIds.push(entry.turnId)
if (turnIds.length >= windowTurns)
break
}
const entriesByTurnId = new Map<number, LlmLogEntry[]>()
for (const turnId of turnIds)
entriesByTurnId.set(turnId, [])
for (const entry of this.llmLogEntries) {
const bucket = entriesByTurnId.get(entry.turnId)
if (!bucket)
continue
bucket.push(entry)
}
const errorTurnIds: number[] = []
const summaries: string[] = []
for (const turnId of turnIds) {
const turnEntries = entriesByTurnId.get(turnId) ?? []
const errors = turnEntries.filter(entry => this.isErrorLlmLogEntry(entry))
if (errors.length === 0)
continue
errorTurnIds.push(turnId)
const evidence = errors.slice(0, 2).map(entry => this.describeErrorLlmLogEntry(entry)).join(' | ')
summaries.push(`turn=${turnId} ${evidence}`)
}
return {
recentTurnIds: turnIds,
errorTurnIds,
summaries,
}
}
private maybeActivateErrorBurstGuard(
bot: MineflayerWithAgents,
event: BotEvent,
turnId: number,
): void {
if (this.errorBurstGuardState)
return
if (turnId <= this.errorBurstGuardSuppressUntilTurnId)
return
const { recentTurnIds, errorTurnIds, summaries } = this.collectRecentErrorTurns(ERROR_BURST_WINDOW_TURNS)
if (errorTurnIds.length < ERROR_BURST_THRESHOLD)
return
const recentErrorSummary = summaries.slice(0, ERROR_BURST_WINDOW_TURNS)
this.errorBurstGuardState = {
threshold: ERROR_BURST_THRESHOLD,
windowTurns: ERROR_BURST_WINDOW_TURNS,
errorTurnCount: errorTurnIds.length,
recentTurnIds,
recentErrorSummary,
suggestedCooldownSeconds: ERROR_BURST_COOLDOWN_SECONDS,
triggeredAtTurnId: turnId,
}
this.appendLlmLog({
turnId,
kind: 'scheduler',
eventType: 'system_alert',
sourceType: 'system',
sourceId: ERROR_BURST_GUARD_SOURCE_ID,
tags: ['scheduler', 'error_burst', 'guard_triggered', 'error'],
text: `Error burst guard activated (${errorTurnIds.length}/${Math.max(recentTurnIds.length, ERROR_BURST_WINDOW_TURNS)} recent turns contain errors)`,
metadata: {
threshold: ERROR_BURST_THRESHOLD,
windowTurns: ERROR_BURST_WINDOW_TURNS,
errorTurnIds,
recentErrorSummary,
},
})
if (event.source.type === 'system' && event.source.id === ERROR_BURST_GUARD_SOURCE_ID)
return
void this.enqueueEvent(bot, {
type: 'system_alert',
payload: {
reason: 'error_burst_guard',
threshold: ERROR_BURST_THRESHOLD,
windowTurns: ERROR_BURST_WINDOW_TURNS,
errorTurnCount: errorTurnIds.length,
recentErrorSummary,
guidance: 'Too many recent errors. Call giveUp(...) and send one chat explanation.',
},
source: { type: 'system', id: ERROR_BURST_GUARD_SOURCE_ID },
timestamp: Date.now(),
}).catch(err => this.deps.logger.withError(err).error('Brain: Failed to enqueue error-burst guard alert'))
}
private clearErrorBurstGuardState(turnId: number, reason: 'resolved' | 'manual'): void {
if (!this.errorBurstGuardState)
return
this.appendLlmLog({
turnId,
kind: 'scheduler',
eventType: 'system_alert',
sourceType: 'system',
sourceId: ERROR_BURST_GUARD_SOURCE_ID,
tags: ['scheduler', 'error_burst', 'guard_cleared', reason],
text: `Error burst guard cleared (${reason})`,
metadata: {
guard: { ...this.errorBurstGuardState },
},
})
this.errorBurstGuardSuppressUntilTurnId = turnId + ERROR_BURST_WINDOW_TURNS
this.errorBurstGuardState = null
}
private updateErrorBurstGuardCompletion(
turnId: number,
actions: Array<{
action: ActionInstruction
ok: boolean
}>,
): void {
if (!this.errorBurstGuardState)
return
const hasGiveUp = actions.some(item => item.action.tool === 'giveUp' && item.ok)
const hasChat = actions.some(item => item.action.tool === 'chat' && item.ok)
if (hasGiveUp && hasChat) {
this.clearErrorBurstGuardState(turnId, 'resolved')
return
}
if (hasGiveUp || hasChat) {
this.appendLlmLog({
turnId,
kind: 'scheduler',
eventType: 'system_alert',
sourceType: 'system',
sourceId: ERROR_BURST_GUARD_SOURCE_ID,
tags: ['scheduler', 'error_burst', 'guard_pending'],
text: 'Error burst guard still pending: this turn must include both giveUp and chat actions',
})
}
}
private appendLlmLog(entry: { private appendLlmLog(entry: {
turnId: number turnId: number
kind: LlmLogEntryKind kind: LlmLogEntryKind
@@ -1300,6 +1500,9 @@ export class Brain {
if (this.isPlayerChatEvent(event)) if (this.isPlayerChatEvent(event))
this.resetNoActionFollowupBudget('player_chat') this.resetNoActionFollowupBudget('player_chat')
const turnId = ++this.turnCounter
this.maybeActivateErrorBurstGuard(bot, event, turnId)
// 0. Build Context View // 0. Build Context View
const snapshot = this.deps.reflexManager.getContextSnapshot() const snapshot = this.deps.reflexManager.getContextSnapshot()
const view = buildConsciousContextView(snapshot) const view = buildConsciousContextView(snapshot)
@@ -1313,7 +1516,6 @@ export class Brain {
// 2. Prepare System Prompt (static) // 2. Prepare System Prompt (static)
const systemPrompt = generateBrainSystemPrompt(this.deps.taskExecutor.getAvailableActions()) const systemPrompt = generateBrainSystemPrompt(this.deps.taskExecutor.getAvailableActions())
const turnId = ++this.turnCounter
this.currentInputEnvelope = { this.currentInputEnvelope = {
id: turnId, id: turnId,
turnId, turnId,
@@ -1521,6 +1723,7 @@ export class Brain {
tags: ['repl', 'error', 'empty_response'], tags: ['repl', 'error', 'empty_response'],
text: 'No LLM response after retries', text: 'No LLM response after retries',
}) })
this.maybeActivateErrorBurstGuard(bot, event, turnId)
return return
} }
@@ -1611,6 +1814,14 @@ export class Brain {
logs: runResult.logs.slice(-5), logs: runResult.logs.slice(-5),
}, },
}) })
this.updateErrorBurstGuardCompletion(
turnId,
runResult.actions.map(item => ({
action: item.action,
ok: item.ok,
})),
)
this.maybeActivateErrorBurstGuard(bot, event, turnId)
if (runResult.actions.length === 0 || runResult.actions.every(item => item.action.tool === 'skip')) { if (runResult.actions.length === 0 || runResult.actions.every(item => item.action.tool === 'skip')) {
this.debugService.emit('debug:repl_result', { this.debugService.emit('debug:repl_result', {
@@ -1664,6 +1875,7 @@ export class Brain {
code: result, code: result,
}, },
}) })
this.maybeActivateErrorBurstGuard(bot, event, turnId)
this.debugService.emit('debug:repl_result', { this.debugService.emit('debug:repl_result', {
source: 'llm', source: 'llm',
code: result, code: result,
@@ -1721,6 +1933,19 @@ export class Brain {
parts.push(`[STATE] giveUp active (${remainingSec}s left). reason=${this.giveUpReason ?? 'unknown'}`) parts.push(`[STATE] giveUp active (${remainingSec}s left). reason=${this.giveUpReason ?? 'unknown'}`)
} }
if (this.errorBurstGuardState) {
const guard = this.errorBurstGuardState
parts.push(`[ERROR_BURST_GUARD] active. errors=${guard.errorTurnCount}/${guard.windowTurns}; threshold=${guard.threshold}; cooldown=${guard.suggestedCooldownSeconds}s`)
if (guard.recentErrorSummary.length > 0) {
const condensed = guard.recentErrorSummary
.slice(0, 3)
.map(summary => truncateForPrompt(summary, 180))
.join(' || ')
parts.push(`[ERROR_BURST_GUARD] recent=${condensed}`)
}
parts.push(`[MANDATORY] Too many recent errors. This turn must include BOTH: await giveUp({ reason: "...", cooldown_seconds: ${guard.suggestedCooldownSeconds} }) and await chat({ message: "...", feedback: false }). Explain what failed and what you will do next.`)
}
if (this.lastReplOutcome) { if (this.lastReplOutcome) {
const ageMs = Date.now() - this.lastReplOutcome.updatedAt const ageMs = Date.now() - this.lastReplOutcome.updatedAt
const returnValue = truncateForPrompt(this.lastReplOutcome.returnValue ?? 'undefined') const returnValue = truncateForPrompt(this.lastReplOutcome.returnValue ?? 'undefined')
@@ -1737,8 +1962,9 @@ export class Brain {
parts.push(`[ACTION_QUEUE] executing=${runningLabel}; pending=${queueSnapshot.counts.pending}; total=${queueSnapshot.counts.total}/${queueSnapshot.capacity.total}`) parts.push(`[ACTION_QUEUE] executing=${runningLabel}; pending=${queueSnapshot.counts.pending}; total=${queueSnapshot.counts.total}/${queueSnapshot.capacity.total}`)
const noActionBudget = this.getNoActionBudgetState() const noActionBudget = this.getNoActionBudgetState()
parts.push(`[NO_ACTION_BUDGET] remaining=${noActionBudget.remaining}; default=${noActionBudget.default}; max=${noActionBudget.max}; stagnation=${this.noActionFollowupStagnationCount}/${NO_ACTION_STAGNATION_REPEAT_LIMIT}`) parts.push(`[NO_ACTION_BUDGET] remaining=${noActionBudget.remaining}; default=${noActionBudget.default}; max=${noActionBudget.max}; stagnation=${this.noActionFollowupStagnationCount}/${NO_ACTION_STAGNATION_REPEAT_LIMIT}`)
parts.push(`[ERROR_BURST] active=${this.errorBurstGuardState ? 'yes' : 'no'}`)
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, query, bot, mineflayer, currentInput, llmLog, actionQueue, noActionBudget, mem, lastRun, prevRun, lastAction. Helpers: setNoActionBudget(n), getNoActionBudget(). Player gaze is available in environment.nearbyPlayersGaze when needed.') parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, query, bot, mineflayer, currentInput, llmLog, actionQueue, noActionBudget, errorBurstGuard, mem, lastRun, prevRun, lastAction. Helpers: setNoActionBudget(n), getNoActionBudget(). Player gaze is available in environment.nearbyPlayersGaze when needed.')
return parts.join('\n\n') return parts.join('\n\n')
} }
@@ -1747,6 +1973,9 @@ export class Brain {
if (Date.now() >= this.giveUpUntil) if (Date.now() >= this.giveUpUntil)
return false return false
if (event.source.type === 'system' && event.source.id === ERROR_BURST_GUARD_SOURCE_ID)
return false
if (event.type !== 'perception') if (event.type !== 'perception')
return true return true
@@ -59,6 +59,7 @@ describe('javaScriptPlanner', () => {
default: 3, default: 3,
max: 8, max: 8,
}, },
errorBurstGuard: null,
setNoActionBudget: (value: number) => ({ setNoActionBudget: (value: number) => ({
ok: true, ok: true,
remaining: Math.max(0, Math.min(8, Math.floor(value))), remaining: Math.max(0, Math.min(8, Math.floor(value))),
@@ -214,6 +215,7 @@ describe('javaScriptPlanner', () => {
expect(names).toContain('llmLog') expect(names).toContain('llmLog')
expect(names).toContain('actionQueue') expect(names).toContain('actionQueue')
expect(names).toContain('noActionBudget') expect(names).toContain('noActionBudget')
expect(names).toContain('errorBurstGuard')
expect(names).toContain('setNoActionBudget') expect(names).toContain('setNoActionBudget')
expect(names).toContain('getNoActionBudget') expect(names).toContain('getNoActionBudget')
expect(names).toContain('forget_conversation') expect(names).toContain('forget_conversation')
@@ -239,6 +241,22 @@ describe('javaScriptPlanner', () => {
expect(planned.actions).toHaveLength(0) expect(planned.actions).toHaveLength(0)
}) })
it('exposes error-burst guard runtime global to scripts', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const guardedGlobals = {
...globals,
errorBurstGuard: {
threshold: 3,
windowTurns: 5,
errorTurnCount: 3,
},
} as any
const planned = await planner.evaluate('return errorBurstGuard.errorTurnCount', actions, guardedGlobals, executeAction)
expect(planned.returnValue).toBe('3')
expect(planned.actions).toHaveLength(0)
})
it('exposes llm input globals to scripts', async () => { it('exposes llm input globals to scripts', async () => {
const planner = new JavaScriptPlanner() const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`) const executeAction = vi.fn(async action => `ok:${action.tool}`)
@@ -69,6 +69,7 @@ export interface RuntimeGlobals {
bot?: unknown bot?: unknown
actionQueue?: unknown actionQueue?: unknown
noActionBudget?: unknown noActionBudget?: unknown
errorBurstGuard?: unknown
currentInput?: unknown currentInput?: unknown
llmLog?: unknown llmLog?: unknown
setNoActionBudget?: (value: number) => { ok: true, remaining: number, default: number, max: number } setNoActionBudget?: (value: number) => { ok: true, remaining: number, default: number, max: number }
@@ -218,6 +219,7 @@ export class JavaScriptPlanner {
{ name: 'llmLog', kind: 'object', readonly: true }, { name: 'llmLog', kind: 'object', readonly: true },
{ name: 'actionQueue', kind: 'object', readonly: true }, { name: 'actionQueue', kind: 'object', readonly: true },
{ name: 'noActionBudget', kind: 'object', readonly: true }, { name: 'noActionBudget', kind: 'object', readonly: true },
{ name: 'errorBurstGuard', kind: 'object', readonly: true },
{ name: 'setNoActionBudget', kind: 'function', readonly: true }, { name: 'setNoActionBudget', kind: 'function', readonly: true },
{ name: 'getNoActionBudget', kind: 'function', readonly: true }, { name: 'getNoActionBudget', kind: 'function', readonly: true },
{ name: 'forget_conversation', kind: 'function', readonly: true }, { name: 'forget_conversation', kind: 'function', readonly: true },
@@ -251,6 +253,7 @@ export class JavaScriptPlanner {
llmLog: globals.llmLog ?? null, llmLog: globals.llmLog ?? null,
actionQueue: globals.actionQueue ?? null, actionQueue: globals.actionQueue ?? null,
noActionBudget: globals.noActionBudget ?? null, noActionBudget: globals.noActionBudget ?? null,
errorBurstGuard: globals.errorBurstGuard ?? null,
llmMessages: globals.llmInput?.messages ?? [], llmMessages: globals.llmInput?.messages ?? [],
llmSystemPrompt: globals.llmInput?.systemPrompt ?? '', llmSystemPrompt: globals.llmInput?.systemPrompt ?? '',
llmUserMessage: globals.llmInput?.userMessage ?? '', llmUserMessage: globals.llmInput?.userMessage ?? '',
@@ -416,6 +419,7 @@ export class JavaScriptPlanner {
const currentInput = deepFreeze(toStructuredClone(globals.currentInput ?? null)) const currentInput = deepFreeze(toStructuredClone(globals.currentInput ?? null))
const actionQueue = deepFreeze(toStructuredClone(globals.actionQueue ?? null)) const actionQueue = deepFreeze(toStructuredClone(globals.actionQueue ?? null))
const noActionBudget = deepFreeze(toStructuredClone(globals.noActionBudget ?? null)) const noActionBudget = deepFreeze(toStructuredClone(globals.noActionBudget ?? null))
const errorBurstGuard = deepFreeze(toStructuredClone(globals.errorBurstGuard ?? null))
const query = globals.mineflayer ? createQueryRuntime(globals.mineflayer) : undefined const query = globals.mineflayer ? createQueryRuntime(globals.mineflayer) : undefined
this.sandbox.prevRun = this.sandbox.lastRun ?? null this.sandbox.prevRun = this.sandbox.lastRun ?? null
@@ -433,6 +437,7 @@ export class JavaScriptPlanner {
this.sandbox.llmLog = globals.llmLog ?? null this.sandbox.llmLog = globals.llmLog ?? null
this.sandbox.actionQueue = actionQueue this.sandbox.actionQueue = actionQueue
this.sandbox.noActionBudget = noActionBudget this.sandbox.noActionBudget = noActionBudget
this.sandbox.errorBurstGuard = errorBurstGuard
this.sandbox.setNoActionBudget = globals.setNoActionBudget ?? null this.sandbox.setNoActionBudget = globals.setNoActionBudget ?? null
this.sandbox.getNoActionBudget = globals.getNoActionBudget ?? null this.sandbox.getNoActionBudget = globals.getNoActionBudget ?? null
this.sandbox.forget_conversation = globals.forgetConversation ?? null this.sandbox.forget_conversation = globals.forgetConversation ?? null
@@ -18,7 +18,7 @@ You are an autonomous agent playing Minecraft.
- Tool functions (listed below) execute actions and return results. - Tool functions (listed below) execute actions and return results.
- Control actions are queued globally and return enqueue receipts immediately; inspect `actionQueue` for execution progress. - Control actions are queued globally and return enqueue receipts immediately; inspect `actionQueue` for execution progress.
- Use `await` on tool calls when later logic depends on the result. - Use `await` on tool calls when later logic depends on the result.
- Globals refreshed every turn: `snapshot`, `self`, `environment`, `social`, `threat`, `attention`, `autonomy`, `event`, `now`, `query`, `bot`, `mineflayer`, `currentInput`, `llmLog`, `actionQueue`, `noActionBudget`. - Globals refreshed every turn: `snapshot`, `self`, `environment`, `social`, `threat`, `attention`, `autonomy`, `event`, `now`, `query`, `bot`, `mineflayer`, `currentInput`, `llmLog`, `actionQueue`, `noActionBudget`, `errorBurstGuard`.
- Persistent globals: `mem` (cross-turn memory), `lastRun` (this run), `prevRun` (previous run), `lastAction` (latest action result), `log(...)`. - Persistent globals: `mem` (cross-turn memory), `lastRun` (this run), `prevRun` (previous run), `lastAction` (latest action result), `log(...)`.
- Budget helpers: `setNoActionBudget(n)` and `getNoActionBudget()` control/inspect eval-only no-action follow-up budget. - Budget helpers: `setNoActionBudget(n)` and `getNoActionBudget()` control/inspect eval-only no-action follow-up budget.
- Cross-turn result access: use `prevRun.returnRaw` for typed values (arrays/objects); `prevRun.returnValue` is stringified for display/logging. - Cross-turn result access: use `prevRun.returnRaw` for typed values (arrays/objects); `prevRun.returnValue` is stringified for display/logging.
@@ -104,6 +104,7 @@ Heuristic composition examples (encouraged):
- `actionQueue.counts` / `actionQueue.capacity`: current usage and hard limits. - `actionQueue.counts` / `actionQueue.capacity`: current usage and hard limits.
- `actionQueue.recent`: recently finished/failed/cancelled control actions. - `actionQueue.recent`: recently finished/failed/cancelled control actions.
- `noActionBudget`: current eval-only follow-up budget state (`remaining`, `default`, `max`). - `noActionBudget`: current eval-only follow-up budget state (`remaining`, `default`, `max`).
- `errorBurstGuard`: repeated-error guard state when active (`threshold`, `windowTurns`, `errorTurnCount`, `recentErrorSummary`, `suggestedCooldownSeconds`), otherwise `null`.
Examples: Examples:
- `const recentErrors = llmLog.query().errors().latest(5).list()` - `const recentErrors = llmLog.query().errors().latest(5).list()`
@@ -198,6 +199,7 @@ Common patterns:
- Turn A: `const value = ...; value` - Turn A: `const value = ...; value`
- Turn B: construct tool params/messages from confirmed returned value. - Turn B: construct tool params/messages from confirmed returned value.
- If you hit repeated failures with no progress, call `await giveUp({ reason, cooldown_seconds })` once instead of retry-spamming. - If you hit repeated failures with no progress, call `await giveUp({ reason, cooldown_seconds })` once instead of retry-spamming.
- If `[ERROR_BURST_GUARD]` appears, treat it as mandatory safety policy for this turn: call `giveUp(...)` and send one concise `chat(...)` explanation of what failed.
- Treat `environment.nearbyPlayersGaze` as a weak hint, not a command. Never move solely because someone looked somewhere unless they also gave a clear instruction. - Treat `environment.nearbyPlayersGaze` as a weak hint, not a command. Never move solely because someone looked somewhere unless they also gave a clear instruction.
- Use `followPlayer` to set idle auto-follow and `clearFollowTarget` before independent exploration. - Use `followPlayer` to set idle auto-follow and `clearFollowTarget` before independent exploration.
- Some relocation actions (for example `goToCoordinate`) automatically detach auto-follow so exploration does not keep snapping back. - Some relocation actions (for example `goToCoordinate`) automatically detach auto-follow so exploration does not keep snapping back.
@@ -214,3 +216,4 @@ Common patterns:
- **Chat Feedback**: `chat` feedback is optional; keep `feedback: false` for normal conversation. Use `feedback: true` only for diagnostic verification of a sent chat. - **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. - **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. - **Follow Mode**: If `autonomy.followPlayer` is set, reflex will follow that player while idle. Only clear it when the current mission needs independent movement.
- **Error Burst Guard**: If `[ERROR_BURST_GUARD]` is present, do not continue normal retries. Immediately call `giveUp` and then `chat` once with a clear failure explanation and next-step suggestion.
@@ -28,9 +28,11 @@ describe('generateBrainSystemPrompt', () => {
expect(prompt).toContain('setNoActionBudget(n)') expect(prompt).toContain('setNoActionBudget(n)')
expect(prompt).toContain('getNoActionBudget()') expect(prompt).toContain('getNoActionBudget()')
expect(prompt).toContain('noActionBudget') expect(prompt).toContain('noActionBudget')
expect(prompt).toContain('errorBurstGuard')
expect(prompt).toContain('Never return function references as values') expect(prompt).toContain('Never return function references as values')
expect(prompt).toContain('query.inventory().summary()') expect(prompt).toContain('query.inventory().summary()')
expect(prompt).toContain('Default no-action follow-up budget is 3 and max is 8') expect(prompt).toContain('Default no-action follow-up budget is 3 and max is 8')
expect(prompt).toContain('do not stay in repeated evaluation-only turns') expect(prompt).toContain('do not stay in repeated evaluation-only turns')
expect(prompt).toContain('Error Burst Guard')
}) })
}) })