feat(minecraft): add budgeted no-action follow-up system with stagnation detection and player chat reset

Add NoActionBudgetState interface tracking remaining/default/max budget, implement budget system with DEFAULT=3/MAX=8 limits and STAGNATION_REPEAT_LIMIT=2, add setNoActionFollowupBudget/getNoActionBudgetState/resetNoActionFollowupBudget methods, track stagnation via buildNoActionSignature comparing returnValue+logs across turns, block follow-ups when budget exhausted or stagnated and emit brain
This commit is contained in:
Rin
2026-02-18 11:14:42 +08:00
committed by Neko Ayaka
parent e700a177ef
commit 0f536b9e4b
6 changed files with 268 additions and 15 deletions
@@ -141,7 +141,7 @@ inv;
expect(result.returnValue).toContain('oak_log') expect(result.returnValue).toContain('oak_log')
}) })
it('queues exactly one synthetic follow-up on no-action result', async () => { it('queues budgeted synthetic follow-up on no-action result', async () => {
const brain: any = new Brain(createDeps('1 + 1')) const brain: any = new Brain(createDeps('1 + 1'))
const enqueueSpy = vi.fn(async () => undefined) const enqueueSpy = vi.fn(async () => undefined)
brain.enqueueEvent = enqueueSpy brain.enqueueEvent = enqueueSpy
@@ -153,7 +153,11 @@ inv;
expect(queuedEvent).toMatchObject({ expect(queuedEvent).toMatchObject({
type: 'system_alert', type: 'system_alert',
source: { type: 'system', id: 'brain:no_action_followup' }, source: { type: 'system', id: 'brain:no_action_followup' },
payload: { reason: 'no_actions', returnValue: '2' }, payload: {
reason: 'no_actions',
returnValue: '2',
noActionBudget: { remaining: 2, default: 3, max: 8 },
},
}) })
}) })
@@ -172,7 +176,7 @@ inv;
expect(queuedEvent?.payload?.returnValue).toContain('oak_sapling') expect(queuedEvent?.payload?.returnValue).toContain('oak_sapling')
}) })
it('does not chain follow-up from follow-up event source', async () => { it('allows chained follow-up from follow-up event source while budget remains', async () => {
const brain: any = new Brain(createDeps('1 + 1')) const brain: any = new Brain(createDeps('1 + 1'))
const enqueueSpy = vi.fn(async () => undefined) const enqueueSpy = vi.fn(async () => undefined)
brain.enqueueEvent = enqueueSpy brain.enqueueEvent = enqueueSpy
@@ -184,7 +188,46 @@ inv;
timestamp: Date.now(), timestamp: Date.now(),
}) })
expect(enqueueSpy).not.toHaveBeenCalled() expect(enqueueSpy).toHaveBeenCalledTimes(1)
const queuedEvent = (enqueueSpy.mock.calls[0] as any[])?.[1]
expect(queuedEvent?.source?.id).toBe('brain:no_action_followup')
})
it('blocks no-action follow-up when budget is exhausted and emits budget alert', async () => {
const brain: any = new Brain(createDeps('1 + 1'))
brain.setNoActionFollowupBudget(0)
const enqueueSpy = vi.fn(async () => undefined)
brain.enqueueEvent = enqueueSpy
const bot = { bot: { chat: vi.fn() } }
await brain.processEvent(bot as any, {
type: 'system_alert',
payload: { source: 'budget-test' },
source: { type: 'system', id: 'budget-test' },
timestamp: Date.now(),
})
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_budget' },
payload: { reason: 'no_action_budget_exhausted' },
})
expect(bot.bot.chat).toHaveBeenCalledTimes(1)
})
it('resets no-action budget when player chat arrives', async () => {
const brain: any = new Brain(createDeps('await skip()'))
brain.setNoActionFollowupBudget(0)
await brain.processEvent({} as any, createPerceptionEvent())
expect(brain.getNoActionBudgetState()).toEqual({
remaining: 3,
default: 3,
max: 8,
})
}) })
it('does not queue follow-up when script uses skip()', async () => { it('does not queue follow-up when script uses skip()', async () => {
@@ -170,6 +170,12 @@ interface ControlActionQueueEntry {
error?: string error?: string
} }
interface NoActionBudgetState {
remaining: number
default: number
max: 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)}...`
} }
@@ -186,6 +192,7 @@ function stringifyForLog(value: unknown): string {
} }
const NO_ACTION_FOLLOWUP_SOURCE_ID = 'brain:no_action_followup' const NO_ACTION_FOLLOWUP_SOURCE_ID = 'brain:no_action_followup'
const NO_ACTION_BUDGET_ALERT_SOURCE_ID = 'brain:no_action_budget'
/** /**
* Priority tiers for event scheduling (lower = higher priority). * Priority tiers for event scheduling (lower = higher priority).
@@ -198,6 +205,9 @@ const EVENT_PRIORITY_NO_ACTION_FOLLOWUP = 3
const MAX_QUEUED_CONTROL_ACTIONS = 5 const MAX_QUEUED_CONTROL_ACTIONS = 5
const MAX_PENDING_CONTROL_ACTIONS = 4 const MAX_PENDING_CONTROL_ACTIONS = 4
const ACTION_QUEUE_RECENT_HISTORY_LIMIT = 20 const ACTION_QUEUE_RECENT_HISTORY_LIMIT = 20
const NO_ACTION_FOLLOWUP_BUDGET_DEFAULT = 3
const NO_ACTION_FOLLOWUP_BUDGET_MAX = 8
const NO_ACTION_STAGNATION_REPEAT_LIMIT = 2
function getEventPriority(event: BotEvent): number { function getEventPriority(event: BotEvent): number {
if (event.type === 'perception') { if (event.type === 'perception') {
@@ -245,6 +255,9 @@ export class Brain {
private actionQueueUpdatedAt = Date.now() private actionQueueUpdatedAt = Date.now()
private isActionWorkerRunning = false private isActionWorkerRunning = false
private completedControlActionsSinceLastFeedback = 0 private completedControlActionsSinceLastFeedback = 0
private noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT
private noActionFollowupLastSignature: string | null = null
private noActionFollowupStagnationCount = 0
constructor(private readonly deps: BrainDeps) { constructor(private readonly deps: BrainDeps) {
this.debugService = DebugService.getInstance() this.debugService = DebugService.getInstance()
@@ -547,10 +560,99 @@ export class Brain {
currentInput: this.currentInputEnvelope, currentInput: this.currentInputEnvelope,
llmLog: this.llmLogRuntime, llmLog: this.llmLogRuntime,
actionQueue: this.getActionQueueSnapshot(), actionQueue: this.getActionQueueSnapshot(),
noActionBudget: this.getNoActionBudgetState(),
setNoActionBudget: (value: number) => this.setNoActionFollowupBudget(value),
getNoActionBudget: () => this.getNoActionBudgetState(),
forgetConversation: () => this.forgetConversation(), forgetConversation: () => this.forgetConversation(),
} }
} }
private isPlayerChatEvent(event: BotEvent): boolean {
if (event.type !== 'perception')
return false
const signal = event.payload as PerceptionSignal
return signal.type === 'chat_message'
}
private getNoActionBudgetState(): NoActionBudgetState {
return {
remaining: this.noActionFollowupBudgetRemaining,
default: NO_ACTION_FOLLOWUP_BUDGET_DEFAULT,
max: NO_ACTION_FOLLOWUP_BUDGET_MAX,
}
}
private resetNoActionFollowupBudget(reason: 'player_chat' | 'manual'): NoActionBudgetState {
this.noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT
this.noActionFollowupLastSignature = null
this.noActionFollowupStagnationCount = 0
this.appendLlmLog({
turnId: this.turnCounter,
kind: 'scheduler',
eventType: 'system_alert',
sourceType: 'system',
sourceId: 'brain:no_action_budget',
tags: ['scheduler', 'no_action', 'budget_reset', reason],
text: `No-action follow-up budget reset (${reason})`,
metadata: {
budget: this.getNoActionBudgetState(),
},
})
return this.getNoActionBudgetState()
}
private setNoActionFollowupBudget(value: number): { ok: true } & NoActionBudgetState {
const normalizedRaw = Number(value)
const normalized = Number.isFinite(normalizedRaw)
? Math.floor(normalizedRaw)
: this.noActionFollowupBudgetRemaining
const clamped = Math.max(0, Math.min(NO_ACTION_FOLLOWUP_BUDGET_MAX, normalized))
this.noActionFollowupBudgetRemaining = clamped
this.noActionFollowupLastSignature = null
this.noActionFollowupStagnationCount = 0
this.appendLlmLog({
turnId: this.turnCounter,
kind: 'scheduler',
eventType: 'system_alert',
sourceType: 'system',
sourceId: 'brain:no_action_budget',
tags: ['scheduler', 'no_action', 'budget_set'],
text: `No-action follow-up budget set to ${clamped}`,
metadata: {
requested: value,
budget: this.getNoActionBudgetState(),
},
})
return {
ok: true,
...this.getNoActionBudgetState(),
}
}
private buildNoActionSignature(returnValue: string | undefined, logs: string[]): string {
const returnPart = truncateForPrompt(returnValue ?? 'undefined', 320)
const logsPart = logs.slice(-3).map(line => truncateForPrompt(line, 140)).join('|')
return `${returnPart}||${logsPart}`
}
private emitNoActionBudgetDebugChat(
bot: MineflayerWithAgents,
reason: 'no_action_budget_exhausted' | 'no_action_stagnated',
): void {
const message = reason === 'no_action_budget_exhausted'
? `[debug] no-action follow-up budget exhausted (remaining=0).`
: `[debug] no-action follow-up blocked due to stagnant eval loop.`
try {
bot.bot.chat(message)
}
catch (err) {
this.deps.logger.withError(err as Error).warn('Brain: Failed to send no-action budget debug chat')
}
}
private appendLlmLog(entry: { private appendLlmLog(entry: {
turnId: number turnId: number
kind: LlmLogEntryKind kind: LlmLogEntryKind
@@ -974,26 +1076,75 @@ export class Brain {
returnValue: string | undefined, returnValue: string | undefined,
logs: string[], logs: string[],
): void { ): void {
if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID) { const signature = this.buildNoActionSignature(returnValue, logs)
this.deps.logger.log('INFO', 'Brain: Suppressed no-action follow-up (already in follow-up chain)') const budgetBefore = this.noActionFollowupBudgetRemaining
if (signature === this.noActionFollowupLastSignature)
this.noActionFollowupStagnationCount++
else
this.noActionFollowupStagnationCount = 0
this.noActionFollowupLastSignature = signature
const stagnated = this.noActionFollowupStagnationCount >= NO_ACTION_STAGNATION_REPEAT_LIMIT
const exhausted = this.noActionFollowupBudgetRemaining <= 0
if (stagnated || exhausted) {
const reason: 'no_action_budget_exhausted' | 'no_action_stagnated' = exhausted
? 'no_action_budget_exhausted'
: 'no_action_stagnated'
this.appendLlmLog({ this.appendLlmLog({
turnId, turnId,
kind: 'scheduler', kind: 'scheduler',
eventType: triggeringEvent.type, eventType: triggeringEvent.type,
sourceType: triggeringEvent.source.type, sourceType: triggeringEvent.source.type,
sourceId: triggeringEvent.source.id, sourceId: triggeringEvent.source.id,
tags: ['scheduler', 'no_action', 'suppressed'], tags: ['scheduler', 'no_action', 'blocked', reason],
text: 'No-action follow-up suppressed: already follow-up source', text: `Blocked no-action follow-up: ${reason}`,
metadata: {
budgetBefore,
budgetAfter: this.noActionFollowupBudgetRemaining,
stagnationCount: this.noActionFollowupStagnationCount,
signature,
returnValue: returnValue ?? 'undefined',
},
}) })
if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_BUDGET_ALERT_SOURCE_ID) {
this.deps.logger.log('INFO', `Brain: Suppressed repeated no-action budget alert (${reason})`)
return
}
this.debugService.log('DEBUG', `No-action follow-up blocked: ${reason}`)
this.emitNoActionBudgetDebugChat(bot, reason)
const followupEvent: BotEvent = {
type: 'system_alert',
payload: {
reason,
returnValue: returnValue ?? 'undefined',
logs: logs.slice(-3),
noActionBudget: this.getNoActionBudgetState(),
guidance: 'No-action follow-up budget exhausted. Abandon this approach or call setNoActionBudget(n) for this scenario.',
},
source: { type: 'system', id: NO_ACTION_BUDGET_ALERT_SOURCE_ID },
timestamp: Date.now(),
}
void this.enqueueEvent(bot, followupEvent).catch(err =>
this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action budget alert'),
)
return return
} }
this.noActionFollowupBudgetRemaining = Math.max(0, this.noActionFollowupBudgetRemaining - 1)
const budgetAfter = this.noActionFollowupBudgetRemaining
const followupEvent: BotEvent = { const followupEvent: BotEvent = {
type: 'system_alert', type: 'system_alert',
payload: { payload: {
reason: 'no_actions', reason: 'no_actions',
returnValue: returnValue ?? 'undefined', returnValue: returnValue ?? 'undefined',
logs: logs.slice(-3), logs: logs.slice(-3),
noActionBudget: this.getNoActionBudgetState(),
}, },
source: { type: 'system', id: NO_ACTION_FOLLOWUP_SOURCE_ID }, source: { type: 'system', id: NO_ACTION_FOLLOWUP_SOURCE_ID },
timestamp: Date.now(), timestamp: Date.now(),
@@ -1006,12 +1157,16 @@ export class Brain {
sourceType: triggeringEvent.source.type, sourceType: triggeringEvent.source.type,
sourceId: triggeringEvent.source.id, sourceId: triggeringEvent.source.id,
tags: ['scheduler', 'no_action'], tags: ['scheduler', 'no_action'],
text: 'Scheduled one-hop no-action follow-up', text: 'Scheduled budgeted no-action follow-up turn',
metadata: { metadata: {
returnValue: returnValue ?? 'undefined', returnValue: returnValue ?? 'undefined',
budgetBefore,
budgetAfter,
stagnationCount: this.noActionFollowupStagnationCount,
signature,
}, },
}) })
this.debugService.log('DEBUG', 'Scheduling one-hop no-action follow-up turn') this.debugService.log('DEBUG', 'Scheduling budgeted no-action follow-up turn')
void this.enqueueEvent(bot, followupEvent).catch(err => void this.enqueueEvent(bot, followupEvent).catch(err =>
this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'), this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'),
) )
@@ -1134,6 +1289,8 @@ export class Brain {
this.resumeFromGiveUpIfNeeded(event) this.resumeFromGiveUpIfNeeded(event)
if (this.shouldSuppressDuringGiveUp(event)) if (this.shouldSuppressDuringGiveUp(event))
return return
if (this.isPlayerChatEvent(event))
this.resetNoActionFollowupBudget('player_chat')
// 0. Build Context View // 0. Build Context View
const snapshot = this.deps.reflexManager.getContextSnapshot() const snapshot = this.deps.reflexManager.getContextSnapshot()
@@ -1525,8 +1682,10 @@ export class Brain {
? `${queueSnapshot.executing.tool}#${queueSnapshot.executing.id}` ? `${queueSnapshot.executing.tool}#${queueSnapshot.executing.id}`
: 'none' : 'none'
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()
parts.push(`[NO_ACTION_BUDGET] remaining=${noActionBudget.remaining}; default=${noActionBudget.default}; max=${noActionBudget.max}; stagnation=${this.noActionFollowupStagnationCount}/${NO_ACTION_STAGNATION_REPEAT_LIMIT}`)
parts.push('[RUNTIME] Globals are refreshed every turn: snapshot, self, environment, social, threat, attention, autonomy, event, now, query, bot, mineflayer, currentInput, llmLog, actionQueue, mem, lastRun, prevRun, lastAction. 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, 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')
} }
@@ -54,6 +54,22 @@ describe('javaScriptPlanner', () => {
counts: { total: 0, executing: 0, pending: 0 }, counts: { total: 0, executing: 0, pending: 0 },
updatedAt: Date.now(), updatedAt: Date.now(),
}, },
noActionBudget: {
remaining: 3,
default: 3,
max: 8,
},
setNoActionBudget: (value: number) => ({
ok: true,
remaining: Math.max(0, Math.min(8, Math.floor(value))),
default: 3,
max: 8,
}),
getNoActionBudget: () => ({
remaining: 3,
default: 3,
max: 8,
}),
forgetConversation: () => ({ ok: true, cleared: ['conversationHistory', 'lastLlmInputSnapshot'] }), forgetConversation: () => ({ ok: true, cleared: ['conversationHistory', 'lastLlmInputSnapshot'] }),
} as any } as any
@@ -197,6 +213,9 @@ describe('javaScriptPlanner', () => {
expect(names).toContain('currentInput') expect(names).toContain('currentInput')
expect(names).toContain('llmLog') expect(names).toContain('llmLog')
expect(names).toContain('actionQueue') expect(names).toContain('actionQueue')
expect(names).toContain('noActionBudget')
expect(names).toContain('setNoActionBudget')
expect(names).toContain('getNoActionBudget')
expect(names).toContain('forget_conversation') expect(names).toContain('forget_conversation')
const mem = descriptors.find(d => d.name === 'mem') const mem = descriptors.find(d => d.name === 'mem')
@@ -211,6 +230,15 @@ describe('javaScriptPlanner', () => {
expect(planned.actions).toHaveLength(0) expect(planned.actions).toHaveLength(0)
}) })
it('exposes no-action budget runtime globals to scripts', async () => {
const planner = new JavaScriptPlanner()
const executeAction = vi.fn(async action => `ok:${action.tool}`)
const planned = await planner.evaluate('return { state: getNoActionBudget(), set: setNoActionBudget(6), now: noActionBudget }', actions, globals, executeAction)
expect(planned.returnValue).toContain('remaining: 3')
expect(planned.returnValue).toContain('remaining: 6')
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}`)
@@ -68,8 +68,11 @@ export interface RuntimeGlobals {
mineflayer?: Mineflayer | null mineflayer?: Mineflayer | null
bot?: unknown bot?: unknown
actionQueue?: unknown actionQueue?: unknown
noActionBudget?: unknown
currentInput?: unknown currentInput?: unknown
llmLog?: unknown llmLog?: unknown
setNoActionBudget?: (value: number) => { ok: true, remaining: number, default: number, max: number }
getNoActionBudget?: () => { remaining: number, default: number, max: number }
forgetConversation?: () => { ok: true, cleared: string[] } forgetConversation?: () => { ok: true, cleared: string[] }
llmInput?: { llmInput?: {
systemPrompt: string systemPrompt: string
@@ -214,6 +217,9 @@ export class JavaScriptPlanner {
{ name: 'currentInput', kind: 'object', readonly: true }, { name: 'currentInput', kind: 'object', readonly: true },
{ 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: 'setNoActionBudget', kind: 'function', readonly: true },
{ name: 'getNoActionBudget', kind: 'function', readonly: true },
{ name: 'forget_conversation', kind: 'function', readonly: true }, { name: 'forget_conversation', kind: 'function', readonly: true },
{ name: 'llmMessages', kind: 'object', readonly: true }, { name: 'llmMessages', kind: 'object', readonly: true },
{ name: 'llmSystemPrompt', kind: 'string', readonly: true }, { name: 'llmSystemPrompt', kind: 'string', readonly: true },
@@ -244,6 +250,7 @@ export class JavaScriptPlanner {
currentInput: globals.currentInput ?? null, currentInput: globals.currentInput ?? null,
llmLog: globals.llmLog ?? null, llmLog: globals.llmLog ?? null,
actionQueue: globals.actionQueue ?? null, actionQueue: globals.actionQueue ?? null,
noActionBudget: globals.noActionBudget ?? 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 ?? '',
@@ -261,6 +268,8 @@ export class JavaScriptPlanner {
expect: this.sandbox.expect, expect: this.sandbox.expect,
expectMoved: this.sandbox.expectMoved, expectMoved: this.sandbox.expectMoved,
expectNear: this.sandbox.expectNear, expectNear: this.sandbox.expectNear,
setNoActionBudget: this.sandbox.setNoActionBudget,
getNoActionBudget: this.sandbox.getNoActionBudget,
forget_conversation: this.sandbox.forget_conversation, forget_conversation: this.sandbox.forget_conversation,
} }
@@ -406,6 +415,7 @@ export class JavaScriptPlanner {
const llmInput = deepFreeze(toStructuredClone(globals.llmInput ?? null)) const llmInput = deepFreeze(toStructuredClone(globals.llmInput ?? null))
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 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
@@ -422,6 +432,9 @@ export class JavaScriptPlanner {
this.sandbox.currentInput = currentInput this.sandbox.currentInput = currentInput
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.setNoActionBudget = globals.setNoActionBudget ?? null
this.sandbox.getNoActionBudget = globals.getNoActionBudget ?? null
this.sandbox.forget_conversation = globals.forgetConversation ?? null this.sandbox.forget_conversation = globals.forgetConversation ?? null
this.sandbox.llmMessages = llmInput?.messages ?? [] this.sandbox.llmMessages = llmInput?.messages ?? []
this.sandbox.llmSystemPrompt = llmInput?.systemPrompt ?? '' this.sandbox.llmSystemPrompt = llmInput?.systemPrompt ?? ''
@@ -18,8 +18,9 @@ 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`. - Globals refreshed every turn: `snapshot`, `self`, `environment`, `social`, `threat`, `attention`, `autonomy`, `event`, `now`, `query`, `bot`, `mineflayer`, `currentInput`, `llmLog`, `actionQueue`, `noActionBudget`.
- 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.
- 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.
- `forget_conversation()` clears conversation memory (`conversationHistory` and `lastLlmInputSnapshot`) for prompt/debug reset workflows. - `forget_conversation()` clears conversation memory (`conversationHistory` and `lastLlmInputSnapshot`) for prompt/debug reset workflows.
- Last script outcome is also echoed in the next turn as `[SCRIPT]` context (return value, action stats, and logs). - Last script outcome is also echoed in the next turn as `[SCRIPT]` context (return value, action stats, and logs).
@@ -102,6 +103,7 @@ Heuristic composition examples (encouraged):
- `actionQueue.pending`: FIFO queued control actions waiting to run. - `actionQueue.pending`: FIFO queued control actions waiting to run.
- `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`).
Examples: Examples:
- `const recentErrors = llmLog.query().errors().latest(5).list()` - `const recentErrors = llmLog.query().errors().latest(5).list()`
@@ -115,7 +117,10 @@ Silent-eval pattern (strongly encouraged):
- Turn A: `let blocksToMine = someFunc(); blocksToMine` - Turn A: `let blocksToMine = someFunc(); blocksToMine`
- Turn B: inspect `[SCRIPT]` return / `llmLog`, then act: `await collectBlocks({ type: ..., num: ... })` - Turn B: inspect `[SCRIPT]` return / `llmLog`, then act: `await collectBlocks({ type: ..., num: ... })`
- Prefer this when a wrong action would be costly, dangerous, or hard to undo. - Prefer this when a wrong action would be costly, dangerous, or hard to undo.
- A `no_actions` follow-up after an eval-only turn is normal; treat it as the handoff turn for action/reporting. - A `no_actions` follow-up after an eval-only turn is normal; follow-ups are budgeted and can chain for multi-step reasoning.
- Default no-action follow-up budget is 3 and max is 8.
- Budget auto-resets when a player chat message is received.
- If budget is exhausted, either abandon this approach or explicitly adjust it with `setNoActionBudget(n)` for the current scenario.
Value-first rule (mandatory for read -> action flows): Value-first rule (mandatory for read -> action flows):
- If a request depends on observed world/query data, first run an evaluation-only turn and end with the concrete value expression. - If a request depends on observed world/query data, first run an evaluation-only turn and end with the concrete value expression.
@@ -125,9 +130,10 @@ Value-first rule (mandatory for read -> action flows):
- Do not re-query the same read value in the follow-up turn; use the persisted value to avoid TOCTOU drift. - Do not re-query the same read value in the follow-up turn; use the persisted value to avoid TOCTOU drift.
- Avoid acting on unresolved intermediate variables when a concrete returned value can be verified first. - Avoid acting on unresolved intermediate variables when a concrete returned value can be verified first.
- For explicit user tasks (e.g. "get X", "craft Y", "go to Z"), do not stay in repeated evaluation-only turns. - For explicit user tasks (e.g. "get X", "craft Y", "go to Z"), do not stay in repeated evaluation-only turns.
- After one evaluation turn, the next turn must either: - After a small number of evaluation turns, the next turn must either:
- call at least one action/chat tool toward completion, or - call at least one action/chat tool toward completion, or
- call `giveUp({ reason, cooldown_seconds })` with a concrete blocker. - call `giveUp({ reason, cooldown_seconds })` with a concrete blocker, or
- explicitly increase no-action budget for this scenario via `setNoActionBudget(n)`.
- Example (read -> chat report): - Example (read -> chat report):
- Turn A: `const inv = query.inventory().summary(); inv` - Turn A: `const inv = query.inventory().summary(); inv`
- Turn B: `const inv = prevRun.returnValue; const text = Array.isArray(inv) && inv.length ? inv.map(({ name, count }) => `${count} ${name}`).join(", ") : "nothing"; await chat({ message: `I have: ${text}`, feedback: false })` - Turn B: `const inv = prevRun.returnValue; const text = Array.isArray(inv) && inv.length ? inv.map(({ name, count }) => `${count} ${name}`).join(", ") : "nothing"; await chat({ message: `I have: ${text}`, feedback: false })`
@@ -25,8 +25,12 @@ describe('generateBrainSystemPrompt', () => {
expect(prompt).toContain('Silent-eval pattern') expect(prompt).toContain('Silent-eval pattern')
expect(prompt).toContain('Value-first rule') expect(prompt).toContain('Value-first rule')
expect(prompt).toContain('forget_conversation()') expect(prompt).toContain('forget_conversation()')
expect(prompt).toContain('setNoActionBudget(n)')
expect(prompt).toContain('getNoActionBudget()')
expect(prompt).toContain('noActionBudget')
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('do not stay in repeated evaluation-only turns') expect(prompt).toContain('do not stay in repeated evaluation-only turns')
}) })
}) })