From 08afe0f2aaccbac94a54083417362d94c40b09d7 Mon Sep 17 00:00:00 2001 From: Rin Date: Wed, 18 Feb 2026 03:02:07 +0800 Subject: [PATCH] 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/ --- .../src/cognitive/conscious/brain.test.ts | 2 +- .../src/cognitive/conscious/brain.ts | 282 +++++++++++++++++- .../cognitive/conscious/context-summary.ts | 182 +++++++++++ .../src/cognitive/conscious/history-query.ts | 207 +++++++++++++ .../src/cognitive/conscious/js-planner.ts | 12 + .../conscious/prompts/brain-prompt.md | 75 ++++- .../src/debug/mcp-repl-server.test.ts | 10 +- .../minecraft/src/debug/mcp-repl-server.ts | 4 - 8 files changed, 748 insertions(+), 26 deletions(-) create mode 100644 services/minecraft/src/cognitive/conscious/context-summary.ts create mode 100644 services/minecraft/src/cognitive/conscious/history-query.ts diff --git a/services/minecraft/src/cognitive/conscious/brain.test.ts b/services/minecraft/src/cognitive/conscious/brain.test.ts index 58efcc105..2889a89c7 100644 --- a/services/minecraft/src/cognitive/conscious/brain.test.ts +++ b/services/minecraft/src/cognitive/conscious/brain.test.ts @@ -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) diff --git a/services/minecraft/src/cognitive/conscious/brain.ts b/services/minecraft/src/cognitive/conscious/brain.ts index d20a49e67..272f7217a 100644 --- a/services/minecraft/src/cognitive/conscious/brain.ts +++ b/services/minecraft/src/cognitive/conscious/brain.ts @@ -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 { 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') } diff --git a/services/minecraft/src/cognitive/conscious/context-summary.ts b/services/minecraft/src/cognitive/conscious/context-summary.ts new file mode 100644 index 000000000..6106f681f --- /dev/null +++ b/services/minecraft/src/cognitive/conscious/context-summary.ts @@ -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] whispered: ..." or "[EVENT] : ..." + 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 + 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)}...` +} diff --git a/services/minecraft/src/cognitive/conscious/history-query.ts b/services/minecraft/src/cognitive/conscious/history-query.ts new file mode 100644 index 000000000..1ed48b7ca --- /dev/null +++ b/services/minecraft/src/cognitive/conscious/history-query.ts @@ -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() + + // 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 | 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() + }, + } +} diff --git a/services/minecraft/src/cognitive/conscious/js-planner.ts b/services/minecraft/src/cognitive/conscious/js-planner.ts index 2f4df688d..3e6c672f8 100644 --- a/services/minecraft/src/cognitive/conscious/js-planner.ts +++ b/services/minecraft/src/cognitive/conscious/js-planner.ts @@ -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 ?? '' diff --git a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.md b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.md index a64efd15d..0ee0c8255 100644 --- a/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.md +++ b/services/minecraft/src/cognitive/conscious/prompts/brain-prompt.md @@ -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: ')` 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. diff --git a/services/minecraft/src/debug/mcp-repl-server.test.ts b/services/minecraft/src/debug/mcp-repl-server.test.ts index df3a36aa3..ff6ecb3e7 100644 --- a/services/minecraft/src/debug/mcp-repl-server.test.ts +++ b/services/minecraft/src/debug/mcp-repl-server.test.ts @@ -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') }) }) diff --git a/services/minecraft/src/debug/mcp-repl-server.ts b/services/minecraft/src/debug/mcp-repl-server.ts index 04e49a914..d23ae8dcb 100644 --- a/services/minecraft/src/debug/mcp-repl-server.ts +++ b/services/minecraft/src/debug/mcp-repl-server.ts @@ -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) }], }