feat(minecraft): add context boundary system with auto-summarization to prevent unbounded conversation history growth

Introduces task context boundaries via enterContext()/exitContext() REPL functions. Active context messages remain in full; completed contexts are summarized and archived. Adds [CONTEXT_HISTORY] prefix message with archived summaries, auto-trim when active context exceeds 30 messages, and auto-collapse when >10 summaries accumulate. Provides history query runtime (recent/search/playerChats/
This commit is contained in:
Rin
2026-02-19 00:25:06 +08:00
parent 4ce0c427bc
commit 08afe0f2aa
8 changed files with 748 additions and 26 deletions
@@ -138,7 +138,7 @@ describe('brain no-action follow-up', () => {
const result = brain.forgetConversation()
expect(result.ok).toBe(true)
expect(result.cleared).toEqual(['conversationHistory', 'lastLlmInputSnapshot'])
expect(result.cleared).toEqual(['conversationHistory', 'lastLlmInputSnapshot', 'contextHistory'])
expect(brain.conversationHistory).toEqual([])
expect(brain.lastLlmInputSnapshot).toBeNull()
expect(brain.llmLogEntries).toHaveLength(1)
@@ -8,6 +8,7 @@ import type { EventBus, TracedEvent } from '../event-bus'
import type { PerceptionSignal } from '../perception/types/signals'
import type { ReflexManager } from '../reflex/reflex-manager'
import type { BotEvent, MineflayerWithAgents } from '../types'
import type { ActiveContextState, ArchivedContext } from './context-summary'
import type { PlannerGlobalDescriptor } from './js-planner'
import type { LLMAgent } from './llm-agent'
import type { LlmLogEntry, LlmLogEntryKind } from './llm-log'
@@ -16,7 +17,14 @@ import type { CancellationToken } from './task-state'
import { config } from '../../composables/config'
import { DebugService } from '../../debug'
import { ActionError } from '../../utils/errors'
import {
buildContextHistoryMessage,
collapseOldestContexts,
generateContextSummary,
} from './context-summary'
import { buildConsciousContextView } from './context-view'
import { createHistoryRuntime } from './history-query'
import { JavaScriptPlanner } from './js-planner'
import { createLlmLogRuntime } from './llm-log'
import {
@@ -90,7 +98,10 @@ interface LlmTraceEntry {
sourceId: string
attempt: number
model: string
messages: Message[]
// NOTICE: Full messages array is no longer stored to prevent O(turns²) memory growth.
// Use messageCount + estimatedTokens for diagnostics, or llmLog for detailed history.
messageCount: number
estimatedTokens: number
content: string
reasoning?: string
usage?: {
@@ -216,7 +227,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 MAX_CONVERSATION_HISTORY_MESSAGES = 40
const MAX_CONVERSATION_HISTORY_MESSAGES = 200
const MAX_ACTIVE_CONTEXT_MESSAGES = 30
const MAX_CONTEXT_SUMMARIES_IN_PREFIX = 10
const NO_ACTION_FOLLOWUP_BUDGET_DEFAULT = 3
const NO_ACTION_FOLLOWUP_BUDGET_MAX = 8
const NO_ACTION_STAGNATION_REPEAT_LIMIT = 2
@@ -263,6 +276,24 @@ export class Brain {
private currentInputEnvelope: RuntimeInputEnvelope | null = null
private readonly llmLogRuntime = createLlmLogRuntime(() => this.llmLogEntries)
private readonly patternRuntime = createPatternRuntime(PATTERN_CATALOG)
// Context boundary state
private archivedContexts: ArchivedContext[] = []
private activeContextState: ActiveContextState = {
label: null,
startTurnId: 0,
startedAt: Date.now(),
}
private activeContextStartIndex = 0
private cachedContextHistoryMessage: string | null = null
private readonly historyRuntime = createHistoryRuntime({
getConversationHistory: () => this.conversationHistory,
getArchivedContexts: () => this.archivedContexts,
getLlmLogEntries: () => this.llmLogEntries,
getCurrentTurnId: () => this.turnCounter,
})
private nextControlActionId = 0
private pendingControlActions: ControlActionQueueEntry[] = []
private activeControlAction: ControlActionQueueEntry | null = null
@@ -472,13 +503,207 @@ export class Brain {
public forgetConversation(): { ok: true, cleared: string[] } {
this.conversationHistory = []
this.lastLlmInputSnapshot = null
this.activeContextStartIndex = 0
this.activeContextState = { label: null, startTurnId: this.turnCounter, startedAt: Date.now() }
this.archivedContexts = []
this.cachedContextHistoryMessage = null
this.emitConversationUpdate(false, true)
return {
ok: true,
cleared: ['conversationHistory', 'lastLlmInputSnapshot'],
cleared: ['conversationHistory', 'lastLlmInputSnapshot', 'contextHistory'],
}
}
/**
* Enter a new task context boundary. Called by the LLM via REPL.
* If there's already an active context with messages, it is auto-exited first.
*/
public enterContext(label: string): { ok: true, label: string, turnId: number } {
const normalizedLabel = (typeof label === 'string' && label.trim()) ? label.trim() : 'unnamed'
// If the current active context has messages, auto-exit it first
const activeMessageCount = this.conversationHistory.length - this.activeContextStartIndex
if (activeMessageCount > 0) {
this.exitCurrentContext(undefined, 'auto_exit_on_enter')
}
this.activeContextState = {
label: normalizedLabel,
startTurnId: this.turnCounter,
startedAt: Date.now(),
}
this.activeContextStartIndex = this.conversationHistory.length
this.appendLlmLog({
turnId: this.turnCounter,
kind: 'scheduler',
eventType: 'system_alert',
sourceType: 'system',
sourceId: 'brain:context',
tags: ['context', 'enter'],
text: `Entered context: "${normalizedLabel}"`,
})
return { ok: true, label: normalizedLabel, turnId: this.turnCounter }
}
/**
* Exit the current task context, summarize it, and archive it.
* Called by the LLM via REPL or internally for auto-trim.
*/
public exitContext(summary?: string): { ok: true, summarized: string, messagesArchived: number } {
return this.exitCurrentContext(
typeof summary === 'string' && summary.trim() ? summary.trim() : undefined,
'explicit',
)
}
private exitCurrentContext(
providedSummary: string | undefined,
reason: 'explicit' | 'auto_exit_on_enter' | 'auto_trim',
): { ok: true, summarized: string, messagesArchived: number } {
const activeMessages = this.conversationHistory.slice(this.activeContextStartIndex)
const startTurnId = this.activeContextState.startTurnId
const endTurnId = this.turnCounter
const label = this.activeContextState.label || 'unnamed'
// Generate summary: prefer LLM-provided, fall back to heuristic
const summaryText = providedSummary || generateContextSummary({
messages: activeMessages,
label,
llmLogEntries: this.llmLogEntries,
startTurnId,
endTurnId,
})
const archived: ArchivedContext = {
label,
summary: summaryText,
startTurnId,
endTurnId,
messageCount: activeMessages.length,
archivedAt: Date.now(),
}
this.archivedContexts.push(archived)
// Collapse oldest contexts if prefix is too large
if (this.archivedContexts.length > MAX_CONTEXT_SUMMARIES_IN_PREFIX) {
const collapseCount = this.archivedContexts.length - MAX_CONTEXT_SUMMARIES_IN_PREFIX + 1
this.archivedContexts = collapseOldestContexts(this.archivedContexts, collapseCount)
}
// Invalidate the cached prefix message so it's rebuilt next turn
this.cachedContextHistoryMessage = null
// Clear the active context messages from conversation history
// Keep them in memory for history queries but mark the new start index
this.activeContextStartIndex = this.conversationHistory.length
// Reset active context state
this.activeContextState = {
label: null,
startTurnId: this.turnCounter,
startedAt: Date.now(),
}
this.appendLlmLog({
turnId: this.turnCounter,
kind: 'scheduler',
eventType: 'system_alert',
sourceType: 'system',
sourceId: 'brain:context',
tags: ['context', 'exit', reason],
text: `Exited context: "${label}" (${activeMessages.length} messages archived). Summary: ${summaryText}`,
metadata: {
label,
reason,
messagesArchived: activeMessages.length,
summary: summaryText,
startTurnId,
endTurnId,
},
})
return {
ok: true,
summarized: summaryText,
messagesArchived: activeMessages.length,
}
}
/**
* Build the [CONTEXT_HISTORY] prefix message from archived contexts.
* Caches the result until invalidated by exitContext().
*/
private getContextHistoryMessage(): string | null {
if (this.cachedContextHistoryMessage !== null)
return this.cachedContextHistoryMessage
const message = buildContextHistoryMessage(this.archivedContexts)
this.cachedContextHistoryMessage = message
return message
}
/**
* Auto-trim the active context if it exceeds the safety limit.
* Summarizes the oldest half and archives it.
*/
private autoTrimActiveContext(): void {
const activeMessageCount = this.conversationHistory.length - this.activeContextStartIndex
if (activeMessageCount <= MAX_ACTIVE_CONTEXT_MESSAGES)
return
this.deps.logger.log('INFO', `Brain: Auto-trimming active context (${activeMessageCount} messages > ${MAX_ACTIVE_CONTEXT_MESSAGES} limit)`)
// Split: archive the oldest half, keep the newest half as active
const halfPoint = this.activeContextStartIndex + Math.floor(activeMessageCount / 2)
const oldMessages = this.conversationHistory.slice(this.activeContextStartIndex, halfPoint)
const summaryText = generateContextSummary({
messages: oldMessages,
label: this.activeContextState.label ? `${this.activeContextState.label} (partial)` : '(auto-trimmed)',
llmLogEntries: this.llmLogEntries,
startTurnId: this.activeContextState.startTurnId,
endTurnId: this.turnCounter,
})
const archived: ArchivedContext = {
label: this.activeContextState.label ? `${this.activeContextState.label} (partial)` : '(auto-trimmed)',
summary: summaryText,
startTurnId: this.activeContextState.startTurnId,
endTurnId: this.turnCounter,
messageCount: oldMessages.length,
archivedAt: Date.now(),
}
this.archivedContexts.push(archived)
if (this.archivedContexts.length > MAX_CONTEXT_SUMMARIES_IN_PREFIX) {
const collapseCount = this.archivedContexts.length - MAX_CONTEXT_SUMMARIES_IN_PREFIX + 1
this.archivedContexts = collapseOldestContexts(this.archivedContexts, collapseCount)
}
// Move the start index forward
this.activeContextStartIndex = halfPoint
this.cachedContextHistoryMessage = null
this.appendLlmLog({
turnId: this.turnCounter,
kind: 'scheduler',
eventType: 'system_alert',
sourceType: 'system',
sourceId: 'brain:context',
tags: ['context', 'auto_trim'],
text: `Auto-trimmed active context: archived ${oldMessages.length} messages`,
metadata: {
archivedCount: oldMessages.length,
remainingActive: this.conversationHistory.length - halfPoint,
summary: summaryText,
},
})
}
public async injectDebugEvent(event: BotEvent): Promise<void> {
if (!this.runtimeMineflayer) {
throw new Error('Brain runtime is not initialized yet')
@@ -617,6 +842,9 @@ export class Brain {
setNoActionBudget: (value: number) => this.setNoActionFollowupBudget(value),
getNoActionBudget: () => this.getNoActionBudgetState(),
forgetConversation: () => this.forgetConversation(),
enterContext: (label: string) => this.enterContext(label),
exitContext: (summary?: string) => this.exitContext(summary),
history: this.historyRuntime,
}
}
@@ -1608,10 +1836,16 @@ export class Brain {
}
try {
// Build complete message history: system + conversation history + new user message
// Auto-trim active context if it exceeds the safety limit
this.autoTrimActiveContext()
// Build messages: system + [CONTEXT_HISTORY prefix] + active context messages + new user message
const contextHistoryMsg = this.getContextHistoryMessage()
const activeMessages = this.conversationHistory.slice(this.activeContextStartIndex)
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
...this.conversationHistory,
...(contextHistoryMsg ? [{ role: 'user' as const, content: contextHistoryMsg }] : []),
...activeMessages,
{ role: 'user', content: userMessage },
]
this.lastLlmInputSnapshot = {
@@ -1666,6 +1900,11 @@ export class Brain {
model: config.openai.model,
duration: Date.now() - traceStart,
})
// Store lightweight trace (no full messages clone to prevent O(turns²) memory)
const estimatedTokens = Math.ceil(messages.reduce((sum, m) => {
const c = typeof m.content === 'string' ? m.content.length : 0
return sum + c
}, 0) / 4)
this.llmTraceEntries.push({
id: ++this.llmTraceIdCounter,
turnId,
@@ -1675,7 +1914,8 @@ export class Brain {
sourceId: event.source.id,
attempt,
model: config.openai.model,
messages: this.cloneMessages(messages),
messageCount: messages.length,
estimatedTokens,
content,
reasoning,
usage: llmResult.usage,
@@ -1792,10 +2032,14 @@ export class Brain {
...(capturedReasoning && { reasoning: capturedReasoning }),
} as Message)
// Trim conversation history to prevent unbounded growth that would
// eventually exceed the LLM context window.
// Trim conversation history as an in-memory safety net.
// The active context boundary system handles LLM context window sizing;
// this only prevents unbounded memory growth for very long sessions.
if (this.conversationHistory.length > MAX_CONVERSATION_HISTORY_MESSAGES) {
this.conversationHistory = this.conversationHistory.slice(-MAX_CONVERSATION_HISTORY_MESSAGES)
const trimCount = this.conversationHistory.length - MAX_CONVERSATION_HISTORY_MESSAGES
this.conversationHistory = this.conversationHistory.slice(trimCount)
// Adjust the active context start index to account for removed messages
this.activeContextStartIndex = Math.max(0, this.activeContextStartIndex - trimCount)
}
const actionDefs = new Map(this.deps.taskExecutor.getAvailableActions().map(action => [action.name, action]))
@@ -1959,7 +2203,8 @@ export class Brain {
const p = event.payload as any
const tool = p.action?.tool || 'unknown'
if (p.status === 'success') {
parts.push(`[FEEDBACK] ${tool}: Success. ${typeof p.result === 'string' ? p.result : JSON.stringify(p.result)}`)
const resultText = typeof p.result === 'string' ? p.result : JSON.stringify(p.result)
parts.push(`[FEEDBACK] ${tool}: Success. ${truncateForPrompt(resultText, 200)}`)
}
else {
parts.push(`[FEEDBACK] ${tool}: Failed. ${p.error}`)
@@ -2009,9 +2254,22 @@ export class Brain {
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(`[ERROR_BURST] active=${this.errorBurstGuardState ? 'yes' : 'no'}`)
// Only include [ERROR_BURST] line when the guard is actually active
// (inactive state is the default and doesn't need to be stated every turn)
if (this.errorBurstGuardState) {
parts.push(`[ERROR_BURST] active=yes`)
}
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 via query.gaze() (lazy eval).')
// Context boundary status — reminds the model to use enterContext/exitContext
const activeLabel = this.activeContextState.label
const activeCount = this.conversationHistory.length - this.activeContextStartIndex
const archivedCount = this.archivedContexts.length
if (activeLabel) {
parts.push(`[CONTEXT] active="${activeLabel}" (${activeCount} messages); archived=${archivedCount}`)
}
else {
parts.push(`[CONTEXT] no active context (${activeCount} messages unmanaged); archived=${archivedCount}. Remember: call enterContext('label') when starting a task.`)
}
return parts.join('\n\n')
}
@@ -0,0 +1,182 @@
import type { Message } from '@xsai/shared-chat'
import type { LlmLogEntry } from './llm-log'
/**
* Archived context boundary produced by exitContext().
* Each entry represents a completed task block whose messages have been
* removed from the active conversation history and replaced by this summary.
*/
export interface ArchivedContext {
label: string
summary: string
startTurnId: number
endTurnId: number
messageCount: number
archivedAt: number
}
/**
* Tracks the currently active context boundary.
*/
export interface ActiveContextState {
label: string | null
startTurnId: number
startedAt: number
}
interface ContextSummaryInput {
messages: Message[]
label: string | null
llmLogEntries: readonly LlmLogEntry[]
startTurnId: number
endTurnId: number
}
const MAX_SUMMARY_ACTIONS = 5
const MAX_SUMMARY_LENGTH = 600
/**
* Generate a deterministic heuristic summary from a block of conversation
* messages and their associated llmLog entries. No LLM call is made.
*
* The summary captures:
* - Context label (if provided via enterContext)
* - The triggering player instruction (if any)
* - Action sequence with outcomes
* - Turn count
*/
export function generateContextSummary(input: ContextSummaryInput): string {
const { messages, label, llmLogEntries, startTurnId, endTurnId } = input
const parts: string[] = []
// Label
if (label) {
parts.push(`Task: ${label}`)
}
// Find the first player chat message in this context block
const playerInstruction = findPlayerInstruction(messages)
if (playerInstruction) {
parts.push(`Trigger: ${truncate(playerInstruction, 120)}`)
}
// Extract action outcomes from llmLog entries within this turn range
const actions = extractActionSummaries(llmLogEntries, startTurnId, endTurnId)
if (actions.length > 0) {
const actionLines = actions.slice(0, MAX_SUMMARY_ACTIONS).map(a => ` ${a}`)
parts.push(`Actions:\n${actionLines.join('\n')}`)
}
// Turn count
const turnCount = endTurnId - startTurnId + 1
parts.push(`Turns: ${turnCount} (${startTurnId}${endTurnId})`)
const result = parts.join('\n')
return truncate(result, MAX_SUMMARY_LENGTH)
}
/**
* Build the [CONTEXT_HISTORY] message content from archived context summaries.
* Returns null if there are no archived contexts.
*/
export function buildContextHistoryMessage(archives: readonly ArchivedContext[]): string | null {
if (archives.length === 0)
return null
const sections = archives.map((ctx, i) => {
const header = `[${i + 1}] ${ctx.label || 'unnamed'}`
return `${header}\n${ctx.summary}`
})
return `[CONTEXT_HISTORY] Completed task summaries (${archives.length}):\n\n${sections.join('\n\n')}`
}
/**
* Collapse the oldest N archived contexts into a single meta-summary.
* Used when the number of archived contexts exceeds the prefix limit.
*/
export function collapseOldestContexts(
archives: ArchivedContext[],
collapseCount: number,
): ArchivedContext[] {
if (collapseCount <= 0 || archives.length <= collapseCount)
return archives
const toCollapse = archives.slice(0, collapseCount)
const remaining = archives.slice(collapseCount)
const labels = toCollapse
.map(ctx => ctx.label || 'unnamed')
.join(', ')
const totalMessages = toCollapse.reduce((sum, ctx) => sum + ctx.messageCount, 0)
const totalTurns = toCollapse.reduce((sum, ctx) => sum + (ctx.endTurnId - ctx.startTurnId + 1), 0)
const collapsed: ArchivedContext = {
label: `(collapsed: ${labels})`,
summary: `Collapsed ${toCollapse.length} earlier contexts (${totalTurns} turns, ${totalMessages} messages). Topics: ${labels}.`,
startTurnId: toCollapse[0].startTurnId,
endTurnId: toCollapse[toCollapse.length - 1].endTurnId,
messageCount: totalMessages,
archivedAt: Date.now(),
}
return [collapsed, ...remaining]
}
// --- Helpers ---
function findPlayerInstruction(messages: Message[]): string | null {
for (const msg of messages) {
if (msg.role !== 'user' || typeof msg.content !== 'string')
continue
// Player chat events are formatted as "[EVENT] <player> whispered: ..." or "[EVENT] <player>: ..."
const match = msg.content.match(/\[EVENT\]\s*(?:<\w+>|\w+)\s*(?:whispered:|:)\s*(.+)/i)
if (match?.[1])
return match[1].trim()
}
return null
}
function extractActionSummaries(
entries: readonly LlmLogEntry[],
startTurnId: number,
endTurnId: number,
): string[] {
const summaries: string[] = []
for (const entry of entries) {
if (entry.turnId < startTurnId || entry.turnId > endTurnId)
continue
// Capture action queue results (success/failure)
if (entry.kind === 'scheduler' && entry.tags.includes('action_queue')) {
if (entry.tags.includes('success') || entry.tags.includes('failure')) {
const tool = entry.tags.find(t => !['scheduler', 'action_queue', 'success', 'failure'].includes(t)) ?? '?'
const status = entry.tags.includes('success') ? 'ok' : 'fail'
summaries.push(`${tool}: ${status}`)
}
}
// Capture direct repl_result action summaries
if (entry.kind === 'repl_result' && entry.metadata) {
const meta = entry.metadata as Record<string, unknown>
const actions = meta.actions as Array<{ tool: string, ok: boolean }> | undefined
if (actions && Array.isArray(actions)) {
for (const action of actions) {
if (action.tool === 'skip')
continue
summaries.push(`${action.tool}: ${action.ok ? 'ok' : 'fail'}`)
}
}
}
}
return summaries
}
function truncate(text: string, maxLength: number): string {
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 3)}...`
}
@@ -0,0 +1,207 @@
import type { Message } from '@xsai/shared-chat'
import type { ArchivedContext } from './context-summary'
import type { LlmLogEntry } from './llm-log'
/**
* Compact turn summary returned by history.turns().
*/
export interface TurnSummary {
turnId: number
eventType: string
actionCount: number
hasError: boolean
text: string
}
interface HistoryQueryDeps {
getConversationHistory: () => readonly Message[]
getArchivedContexts: () => readonly ArchivedContext[]
getLlmLogEntries: () => readonly LlmLogEntry[]
getCurrentTurnId: () => number
}
/**
* Creates the `history` runtime object exposed to the REPL sandbox.
* Provides search-oriented access to the full conversation history
* (both active context and archived contexts) without requiring all
* messages to be in the LLM prompt.
*/
export function createHistoryRuntime(deps: HistoryQueryDeps) {
return {
/**
* Last N user/assistant message pairs from the current active context.
*/
recent(n = 5): Array<{ role: string, content: string }> {
const history = deps.getConversationHistory()
const pairs: Array<{ role: string, content: string }> = []
// Walk backwards collecting user/assistant pairs
for (let i = history.length - 1; i >= 0 && pairs.length < n * 2; i--) {
const msg = history[i]
if (msg.role === 'user' || msg.role === 'assistant') {
pairs.unshift({
role: msg.role,
content: typeof msg.content === 'string' ? msg.content : String(msg.content),
})
}
}
return pairs.slice(-(n * 2))
},
/**
* Text search across ALL history (archived summaries + active messages).
* Returns matching messages with their role and a content snippet.
*/
search(query: string, maxResults = 10): Array<{ role: string, content: string, source: 'active' | 'archived' }> {
if (!query || typeof query !== 'string')
return []
const needle = query.toLowerCase()
const results: Array<{ role: string, content: string, source: 'active' | 'archived' }> = []
// Search archived context summaries
for (const ctx of deps.getArchivedContexts()) {
if (ctx.summary.toLowerCase().includes(needle) || (ctx.label && ctx.label.toLowerCase().includes(needle))) {
results.push({
role: 'context',
content: `[${ctx.label || 'unnamed'}] ${ctx.summary}`,
source: 'archived',
})
if (results.length >= maxResults)
return results
}
}
// Search active conversation history
for (const msg of deps.getConversationHistory()) {
const content = typeof msg.content === 'string' ? msg.content : String(msg.content)
if (content.toLowerCase().includes(needle)) {
results.push({
role: msg.role,
content: content.length > 300 ? `${content.slice(0, 297)}...` : content,
source: 'active',
})
if (results.length >= maxResults)
return results
}
}
return results
},
/**
* Last N turn summaries from llmLog (turnId, event type, action count, errors).
*/
turns(n = 10): TurnSummary[] {
const entries = deps.getLlmLogEntries()
const turnMap = new Map<number, TurnSummary>()
// Build turn summaries from turn_input entries
for (const entry of entries) {
if (entry.kind !== 'turn_input')
continue
turnMap.set(entry.turnId, {
turnId: entry.turnId,
eventType: entry.eventType,
actionCount: 0,
hasError: false,
text: entry.text,
})
}
// Enrich with repl_result data
for (const entry of entries) {
if (entry.kind !== 'repl_result')
continue
const turn = turnMap.get(entry.turnId)
if (!turn)
continue
const meta = entry.metadata as Record<string, unknown> | undefined
if (meta) {
turn.actionCount = typeof meta.actionCount === 'number' ? meta.actionCount : 0
turn.hasError = (typeof meta.errorCount === 'number' && meta.errorCount > 0)
|| entry.tags.includes('error')
}
}
// Also mark turns with repl_error
for (const entry of entries) {
if (entry.kind !== 'repl_error')
continue
const turn = turnMap.get(entry.turnId)
if (turn)
turn.hasError = true
}
const sorted = [...turnMap.values()].sort((a, b) => b.turnId - a.turnId)
return sorted.slice(0, Math.max(1, Math.floor(n)))
},
/**
* Last N player chat messages extracted from conversation history.
*/
playerChats(n = 5): string[] {
const history = deps.getConversationHistory()
const chats: string[] = []
for (let i = history.length - 1; i >= 0 && chats.length < n; i--) {
const msg = history[i]
if (msg.role !== 'user' || typeof msg.content !== 'string')
continue
const match = msg.content.match(/\[EVENT\]\s*(.+?:\s*.+)/)
if (match?.[1] && !match[1].startsWith('Perception Signal:')) {
chats.unshift(match[1])
}
}
return chats
},
/**
* List all archived context summaries.
*/
contexts(): Array<{ label: string, summary: string, turns: number, archivedAt: number }> {
return deps.getArchivedContexts().map(ctx => ({
label: ctx.label,
summary: ctx.summary,
turns: ctx.endTurnId - ctx.startTurnId + 1,
archivedAt: ctx.archivedAt,
}))
},
/**
* Get a specific archived context by label (partial match).
*/
context(label: string): { label: string, summary: string, turns: number } | null {
if (!label || typeof label !== 'string')
return null
const needle = label.toLowerCase()
const found = deps.getArchivedContexts().find(
ctx => ctx.label.toLowerCase().includes(needle),
)
if (!found)
return null
return {
label: found.label,
summary: found.summary,
turns: found.endTurnId - found.startTurnId + 1,
}
},
/**
* Total message count in the active conversation history.
*/
count(): number {
return deps.getConversationHistory().length
},
/**
* Current turn ID.
*/
currentTurn(): number {
return deps.getCurrentTurnId()
},
}
}
@@ -77,6 +77,9 @@ export interface RuntimeGlobals {
setNoActionBudget?: (value: number) => { ok: true, remaining: number, default: number, max: number }
getNoActionBudget?: () => { remaining: number, default: number, max: number }
forgetConversation?: () => { ok: true, cleared: string[] }
enterContext?: (label: string) => { ok: true, label: string, turnId: number }
exitContext?: (summary?: string) => { ok: true, summarized: string, messagesArchived: number }
history?: unknown
llmInput?: {
systemPrompt: string
userMessage: string
@@ -224,6 +227,9 @@ export class JavaScriptPlanner {
{ name: 'setNoActionBudget', kind: 'function', readonly: true },
{ name: 'getNoActionBudget', kind: 'function', readonly: true },
{ name: 'forget_conversation', kind: 'function', readonly: true },
{ name: 'enterContext', kind: 'function', readonly: true },
{ name: 'exitContext', kind: 'function', readonly: true },
{ name: 'history', kind: 'object', readonly: true },
{ name: 'llmMessages', kind: 'object', readonly: true },
{ name: 'llmSystemPrompt', kind: 'string', readonly: true },
{ name: 'llmUserMessage', kind: 'string', readonly: true },
@@ -286,6 +292,9 @@ export class JavaScriptPlanner {
'setNoActionBudget': this.sandbox.setNoActionBudget,
'getNoActionBudget': this.sandbox.getNoActionBudget,
'forget_conversation': this.sandbox.forget_conversation,
'enterContext': this.sandbox.enterContext,
'exitContext': this.sandbox.exitContext,
'history': this.sandbox.history,
}
if (includeBuiltins) {
@@ -457,6 +466,9 @@ export class JavaScriptPlanner {
this.sandbox.setNoActionBudget = globals.setNoActionBudget ?? null
this.sandbox.getNoActionBudget = globals.getNoActionBudget ?? null
this.sandbox.forget_conversation = globals.forgetConversation ?? null
this.sandbox.enterContext = globals.enterContext ?? null
this.sandbox.exitContext = globals.exitContext ?? null
this.sandbox.history = globals.history ?? null
this.sandbox.llmMessages = llmInput?.messages ?? []
this.sandbox.llmSystemPrompt = llmInput?.systemPrompt ?? ''
this.sandbox.llmUserMessage = llmInput?.userMessage ?? ''
@@ -2,7 +2,7 @@
You are an autonomous agent playing Minecraft.
# Self-Knowledge & Capabilities
1. **Stateful Existence**: You maintain a memory of the conversation, but it's crucial to be aware that old history messages are less relevant than recent.
1. **Stateful Existence**: You maintain a memory of the conversation organized into **task contexts**. Completed task contexts are summarized and archived; only the active context messages appear in your conversation history.
3. **Interruption**: The world is real-time. Events (chat, damage, etc.) may happen *while* you are performing an action.
- If a new critical event occurs, you may need to change your plans.
- Do not assume one feedback per tool call. For control actions, use `actionQueue` for live status.
@@ -18,11 +18,13 @@ 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`, `patterns`, `bot`, `mineflayer`, `currentInput`, `llmLog`, `actionQueue`, `noActionBudget`, `errorBurstGuard`.
- Globals refreshed every turn: `snapshot`, `self`, `environment`, `social`, `threat`, `attention`, `autonomy`, `event`, `now`, `query`, `patterns`, `bot`, `mineflayer`, `currentInput`, `llmLog`, `actionQueue`, `noActionBudget`, `errorBurstGuard`, `history`.
- Persistent globals: `mem` (cross-turn memory), `lastRun` (this run), `prevRun` (previous run), `lastAction` (latest action result), `log(...)`.
- Context management: `enterContext(label)`, `exitContext(summary?)` — see **Context Management** section below.
- History query: `history.recent(n)`, `history.search(query)`, `history.playerChats(n)`, `history.turns(n)`, `history.contexts()`, `history.context(label)`.
- 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). If you need text output, stringify `returnRaw` explicitly.
- `forget_conversation()` clears conversation memory (`conversationHistory` and `lastLlmInputSnapshot`) for prompt/debug reset workflows.
- `forget_conversation()` clears all conversation memory (history, context archives, snapshots) for full reset.
- Last script outcome is also echoed in the next turn as `[SCRIPT]` context (return value, action stats, and logs).
- Maximum tool calls per turn: 5.
- Global control-action queue capacity: 5 total (`1 executing + 4 pending`).
@@ -200,6 +202,73 @@ Common patterns:
- **Never** write manual mine-then-move loops. That is what the pathfinder already does internally.
- `collectBlocks` also uses pathfinding internally to reach and mine target blocks.
# Context Management (Mandatory)
You MUST use context boundaries to manage your conversation history. Without them, old messages accumulate and degrade your reasoning quality.
**Rules:**
1. When a player gives you a task (collect, craft, build, go somewhere, etc.), your FIRST line of code MUST be `enterContext('short task label')`.
2. When a task is done, failed, or interrupted, call `exitContext('brief outcome summary')` in the SAME turn as the final action.
3. For casual chat (greetings, questions, small talk) with NO multi-turn task, you do NOT need context boundaries.
4. If a new task arrives while you are mid-task, call `exitContext('interrupted: <reason>')` THEN `enterContext('new task label')` in the same turn.
**What happens:**
- `enterContext(label)`: marks the start of a task. All subsequent messages belong to this context.
- `exitContext(summary)`: archives the current context's messages into a compact summary. They disappear from your conversation history and become a one-line entry in `[CONTEXT_HISTORY]`.
- After `exitContext`, only the summary remains — you lose access to individual messages from that context.
**exitContext summary guidelines:**
- Include: what was requested, what you did, the outcome (success/failure/partial).
- Keep it under 2 sentences.
- Examples:
- `exitContext('Collected 5 oak logs for laggy_magpie and delivered them.')`
- `exitContext('Failed to craft iron pickaxe — no iron ingots available.')`
- `exitContext('Interrupted stone collection — player asked me to follow instead.')`
**Do NOT call exitContext:**
- In the middle of a multi-turn task (you need the history to reason).
- After a single trivial chat reply with no ongoing task.
**Retrieving archived context (via `history` global):**
- `history.contexts()` — list all archived context summaries.
- `history.search('keyword')` — text search across all history (archived + active).
- `history.recent(5)` — last 5 message pairs from the active context.
- `history.playerChats(3)` — last 3 player chat messages.
- `history.turns(10)` — last 10 turn summaries.
**Safety limits:** Active context auto-trims at 30 messages; context summaries collapse at 10 entries. Use `exitContext` proactively to avoid these.
**Example — task lifecycle:**
```js
// Turn 1: Player says 'get me some stone'
enterContext('collect stone for player')
const inv = query.inventory().summary(); inv
// Turn 2: check for pickaxe, craft if needed...
// Turn 3: collect stone...
// Turn 4: deliver and close context
await giveToPlayer({ player_name: 'Alex', item_name: 'stone', num: 4 })
exitContext('Collected 4 stone for Alex. Crafted wooden pickaxe first.')
await chat({ message: 'Here you go!', feedback: false })
```
**Example — task interrupted:**
```js
// Player says 'actually, follow me instead' while you were collecting stone
exitContext('Interrupted stone collection — player changed request.')
enterContext('follow player')
await goToPlayer({ player_name: 'Alex', closeness: 2 })
await followPlayer({ player_name: 'Alex', follow_dist: 2 })
exitContext('Following Alex as requested.')
```
**Example — task failed:**
```js
// After several failed attempts
exitContext('Failed to find diamonds — searched 3 cave branches with no results.')
await giveUp({ reason: 'No diamonds found after extensive search' })
await chat({ message: 'I searched everywhere nearby but couldn\'t find any diamonds.', feedback: false })
```
# Usage Convention (Important)
- Plan with `mem.plan`, execute in small steps, and verify each step before continuing.
- Prefer deterministic scripts: no random branching unless needed.
@@ -70,10 +70,8 @@ describe('mcpReplServer', () => {
id: 1,
turnId: 1,
content: 'await skip()',
messages: [
{ role: 'system', content: 'sys' },
{ role: 'user', content: 'u' },
],
messageCount: 2,
estimatedTokens: 10,
}]),
} as unknown as Brain
@@ -183,7 +181,7 @@ describe('mcpReplServer', () => {
expect(brain.getLlmTrace).toHaveBeenCalledWith(5, 3)
expect(text).toContain('await skip()')
expect(text).toContain('"role":"user"')
expect(text).not.toContain('"role":"system"')
expect(text).toContain('"messageCount":2')
expect(text).toContain('"estimatedTokens":10')
})
})
@@ -238,10 +238,6 @@ export class McpReplServer {
async ({ limit, turnId }) => {
const result = this.brain
.getLlmTrace(limit, turnId)
.map(entry => ({
...entry,
messages: entry.messages.filter(message => message.role !== 'system'),
}))
return {
content: [{ type: 'text', text: JSON.stringify(result) }],
}