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:
@@ -141,7 +141,7 @@ inv;
|
||||
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 enqueueSpy = vi.fn(async () => undefined)
|
||||
brain.enqueueEvent = enqueueSpy
|
||||
@@ -153,7 +153,11 @@ inv;
|
||||
expect(queuedEvent).toMatchObject({
|
||||
type: 'system_alert',
|
||||
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')
|
||||
})
|
||||
|
||||
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 enqueueSpy = vi.fn(async () => undefined)
|
||||
brain.enqueueEvent = enqueueSpy
|
||||
@@ -184,7 +188,46 @@ inv;
|
||||
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 () => {
|
||||
|
||||
@@ -170,6 +170,12 @@ interface ControlActionQueueEntry {
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface NoActionBudgetState {
|
||||
remaining: number
|
||||
default: number
|
||||
max: number
|
||||
}
|
||||
|
||||
function truncateForPrompt(value: string, maxLength = 220): string {
|
||||
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_BUDGET_ALERT_SOURCE_ID = 'brain:no_action_budget'
|
||||
|
||||
/**
|
||||
* 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_PENDING_CONTROL_ACTIONS = 4
|
||||
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 {
|
||||
if (event.type === 'perception') {
|
||||
@@ -245,6 +255,9 @@ export class Brain {
|
||||
private actionQueueUpdatedAt = Date.now()
|
||||
private isActionWorkerRunning = false
|
||||
private completedControlActionsSinceLastFeedback = 0
|
||||
private noActionFollowupBudgetRemaining = NO_ACTION_FOLLOWUP_BUDGET_DEFAULT
|
||||
private noActionFollowupLastSignature: string | null = null
|
||||
private noActionFollowupStagnationCount = 0
|
||||
|
||||
constructor(private readonly deps: BrainDeps) {
|
||||
this.debugService = DebugService.getInstance()
|
||||
@@ -547,10 +560,99 @@ export class Brain {
|
||||
currentInput: this.currentInputEnvelope,
|
||||
llmLog: this.llmLogRuntime,
|
||||
actionQueue: this.getActionQueueSnapshot(),
|
||||
noActionBudget: this.getNoActionBudgetState(),
|
||||
setNoActionBudget: (value: number) => this.setNoActionFollowupBudget(value),
|
||||
getNoActionBudget: () => this.getNoActionBudgetState(),
|
||||
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: {
|
||||
turnId: number
|
||||
kind: LlmLogEntryKind
|
||||
@@ -974,26 +1076,75 @@ export class Brain {
|
||||
returnValue: string | undefined,
|
||||
logs: string[],
|
||||
): void {
|
||||
if (triggeringEvent.source.type === 'system' && triggeringEvent.source.id === NO_ACTION_FOLLOWUP_SOURCE_ID) {
|
||||
this.deps.logger.log('INFO', 'Brain: Suppressed no-action follow-up (already in follow-up chain)')
|
||||
const signature = this.buildNoActionSignature(returnValue, logs)
|
||||
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({
|
||||
turnId,
|
||||
kind: 'scheduler',
|
||||
eventType: triggeringEvent.type,
|
||||
sourceType: triggeringEvent.source.type,
|
||||
sourceId: triggeringEvent.source.id,
|
||||
tags: ['scheduler', 'no_action', 'suppressed'],
|
||||
text: 'No-action follow-up suppressed: already follow-up source',
|
||||
tags: ['scheduler', 'no_action', 'blocked', reason],
|
||||
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
|
||||
}
|
||||
|
||||
this.noActionFollowupBudgetRemaining = Math.max(0, this.noActionFollowupBudgetRemaining - 1)
|
||||
const budgetAfter = this.noActionFollowupBudgetRemaining
|
||||
|
||||
const followupEvent: BotEvent = {
|
||||
type: 'system_alert',
|
||||
payload: {
|
||||
reason: 'no_actions',
|
||||
returnValue: returnValue ?? 'undefined',
|
||||
logs: logs.slice(-3),
|
||||
noActionBudget: this.getNoActionBudgetState(),
|
||||
},
|
||||
source: { type: 'system', id: NO_ACTION_FOLLOWUP_SOURCE_ID },
|
||||
timestamp: Date.now(),
|
||||
@@ -1006,12 +1157,16 @@ export class Brain {
|
||||
sourceType: triggeringEvent.source.type,
|
||||
sourceId: triggeringEvent.source.id,
|
||||
tags: ['scheduler', 'no_action'],
|
||||
text: 'Scheduled one-hop no-action follow-up',
|
||||
text: 'Scheduled budgeted no-action follow-up turn',
|
||||
metadata: {
|
||||
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 =>
|
||||
this.deps.logger.withError(err).error('Brain: Failed to enqueue no-action follow-up'),
|
||||
)
|
||||
@@ -1134,6 +1289,8 @@ export class Brain {
|
||||
this.resumeFromGiveUpIfNeeded(event)
|
||||
if (this.shouldSuppressDuringGiveUp(event))
|
||||
return
|
||||
if (this.isPlayerChatEvent(event))
|
||||
this.resetNoActionFollowupBudget('player_chat')
|
||||
|
||||
// 0. Build Context View
|
||||
const snapshot = this.deps.reflexManager.getContextSnapshot()
|
||||
@@ -1525,8 +1682,10 @@ export class Brain {
|
||||
? `${queueSnapshot.executing.tool}#${queueSnapshot.executing.id}`
|
||||
: 'none'
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -54,6 +54,22 @@ describe('javaScriptPlanner', () => {
|
||||
counts: { total: 0, executing: 0, pending: 0 },
|
||||
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'] }),
|
||||
} as any
|
||||
|
||||
@@ -197,6 +213,9 @@ describe('javaScriptPlanner', () => {
|
||||
expect(names).toContain('currentInput')
|
||||
expect(names).toContain('llmLog')
|
||||
expect(names).toContain('actionQueue')
|
||||
expect(names).toContain('noActionBudget')
|
||||
expect(names).toContain('setNoActionBudget')
|
||||
expect(names).toContain('getNoActionBudget')
|
||||
expect(names).toContain('forget_conversation')
|
||||
|
||||
const mem = descriptors.find(d => d.name === 'mem')
|
||||
@@ -211,6 +230,15 @@ describe('javaScriptPlanner', () => {
|
||||
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 () => {
|
||||
const planner = new JavaScriptPlanner()
|
||||
const executeAction = vi.fn(async action => `ok:${action.tool}`)
|
||||
|
||||
@@ -68,8 +68,11 @@ export interface RuntimeGlobals {
|
||||
mineflayer?: Mineflayer | null
|
||||
bot?: unknown
|
||||
actionQueue?: unknown
|
||||
noActionBudget?: unknown
|
||||
currentInput?: 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[] }
|
||||
llmInput?: {
|
||||
systemPrompt: string
|
||||
@@ -214,6 +217,9 @@ export class JavaScriptPlanner {
|
||||
{ name: 'currentInput', kind: 'object', readonly: true },
|
||||
{ name: 'llmLog', 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: 'llmMessages', kind: 'object', readonly: true },
|
||||
{ name: 'llmSystemPrompt', kind: 'string', readonly: true },
|
||||
@@ -244,6 +250,7 @@ export class JavaScriptPlanner {
|
||||
currentInput: globals.currentInput ?? null,
|
||||
llmLog: globals.llmLog ?? null,
|
||||
actionQueue: globals.actionQueue ?? null,
|
||||
noActionBudget: globals.noActionBudget ?? null,
|
||||
llmMessages: globals.llmInput?.messages ?? [],
|
||||
llmSystemPrompt: globals.llmInput?.systemPrompt ?? '',
|
||||
llmUserMessage: globals.llmInput?.userMessage ?? '',
|
||||
@@ -261,6 +268,8 @@ export class JavaScriptPlanner {
|
||||
expect: this.sandbox.expect,
|
||||
expectMoved: this.sandbox.expectMoved,
|
||||
expectNear: this.sandbox.expectNear,
|
||||
setNoActionBudget: this.sandbox.setNoActionBudget,
|
||||
getNoActionBudget: this.sandbox.getNoActionBudget,
|
||||
forget_conversation: this.sandbox.forget_conversation,
|
||||
}
|
||||
|
||||
@@ -406,6 +415,7 @@ export class JavaScriptPlanner {
|
||||
const llmInput = deepFreeze(toStructuredClone(globals.llmInput ?? null))
|
||||
const currentInput = deepFreeze(toStructuredClone(globals.currentInput ?? null))
|
||||
const actionQueue = deepFreeze(toStructuredClone(globals.actionQueue ?? null))
|
||||
const noActionBudget = deepFreeze(toStructuredClone(globals.noActionBudget ?? null))
|
||||
const query = globals.mineflayer ? createQueryRuntime(globals.mineflayer) : undefined
|
||||
|
||||
this.sandbox.prevRun = this.sandbox.lastRun ?? null
|
||||
@@ -422,6 +432,9 @@ export class JavaScriptPlanner {
|
||||
this.sandbox.currentInput = currentInput
|
||||
this.sandbox.llmLog = globals.llmLog ?? null
|
||||
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.llmMessages = llmInput?.messages ?? []
|
||||
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.
|
||||
- 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.
|
||||
- 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(...)`.
|
||||
- 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.
|
||||
- `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).
|
||||
@@ -102,6 +103,7 @@ Heuristic composition examples (encouraged):
|
||||
- `actionQueue.pending`: FIFO queued control actions waiting to run.
|
||||
- `actionQueue.counts` / `actionQueue.capacity`: current usage and hard limits.
|
||||
- `actionQueue.recent`: recently finished/failed/cancelled control actions.
|
||||
- `noActionBudget`: current eval-only follow-up budget state (`remaining`, `default`, `max`).
|
||||
|
||||
Examples:
|
||||
- `const recentErrors = llmLog.query().errors().latest(5).list()`
|
||||
@@ -115,7 +117,10 @@ Silent-eval pattern (strongly encouraged):
|
||||
- Turn A: `let blocksToMine = someFunc(); blocksToMine`
|
||||
- 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.
|
||||
- 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):
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 `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):
|
||||
- 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 })`
|
||||
|
||||
@@ -25,8 +25,12 @@ describe('generateBrainSystemPrompt', () => {
|
||||
expect(prompt).toContain('Silent-eval pattern')
|
||||
expect(prompt).toContain('Value-first rule')
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user