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