/** * Auto-compaction: pre-flight summarize-fold of older history when the * running token estimate breaches a configurable fraction of the model * context window. * * Cost / quality model: * - Compaction cost is one extra inference call (the summarize step) per * fold. Default threshold 0.5 of contextWindow means a Qwen 1M session * compacts ~once per 500K tokens of accumulated transcript. * - Frozen prefix (system prompt + identity + persona + skills) is NEVER * touched by compaction — caller-managed, designed to stay cache-warm. * This module only operates on the `history` array (user/assistant pairs). * - The summary is inserted as a single user-role message wrapped in * `...` so the brain * can recognize it as historical context, not a fresh request. */ import type { BrainMessage } from './types' export interface CompactionOpts { /** Fraction of contextWindow (0-1) that triggers compaction. */ threshold: number /** Model context window in tokens. */ contextWindow: number /** * Number of recent turns to keep verbatim AFTER the summary. * Each "turn" is 2 messages (user + assistant), so keepRecent: 8 retains * the last 16 messages. */ keepRecent: number } export const DEFAULT_COMPACTION_OPTS: CompactionOpts = { threshold: 0.5, contextWindow: 1_000_000, keepRecent: 8, } /** * System prompt fed to the summarizer sub-call. Tuned to extract durable * facts and decisions while dropping pleasantries; output is plain text with * no preamble so the wrapper tag is the only structure around it. */ export const SUMMARY_SYSTEM_PROMPT = `You are summarizing a conversation between an operator and an AI agent so the agent can keep working with context that fits in its budget. Produce a tight, factual recap (3-8 sentences) preserving: - Key facts the operator stated about themselves, their goals, and constraints. - Decisions that were made and why. - Tool outputs the agent referenced or relied on (URLs visited, balances read, files written). - In-progress goals or tasks the agent is mid-flight on. Drop pleasantries, repeated clarifications, verbose tool args, and redundant agent prose. Output the summary text only. Do not add a preamble like "Here is the summary:" — start with the first fact.` /** * Heuristic token estimator: ~3.5 chars per token (slightly conservative for * Qwen-style tokenizers on mixed content). Used as a fallback when the brain * has no `usage.promptTokens` from the prior turn yet. */ export function estimateTokens(messages: readonly BrainMessage[]): number { let total = 0 for (const m of messages) { const text = typeof m.content === 'string' ? m.content : '' total += Math.ceil(text.length / 3.5) // Rough overhead for tool_calls metadata (id, name, args JSON). if (m.toolCalls) { for (const tc of m.toolCalls) { total += Math.ceil((tc.name?.length ?? 0) / 3.5) const argStr = typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args ?? {}) total += Math.ceil(argStr.length / 3.5) } } } return total } /** * Decide whether the next infer() call should compact first. * * Three conditions must hold: * 1. History has more messages than `keepRecent * 2 + 4` (otherwise there's * nothing meaningful to fold — keep is what we'd already keep). * 2. Either the prior turn's `usage.promptTokens` (canonical) or the * heuristic estimate exceeds `threshold * contextWindow`. * * Returns the trigger token count (the larger of usage and estimate) for the * caller to log via the compaction event, or null when no compaction needed. */ export function shouldCompact( history: readonly BrainMessage[], lastTurnPromptTokens: number | null, opts: CompactionOpts, ): number | null { if (history.length < opts.keepRecent * 2 + 4) return null const estimate = estimateTokens(history) // We take the max of the prior turn's authoritative usage and the heuristic // estimate. Usage alone misses messages appended since the last turn (the // pending user msg), so the estimate is the conservative cap. const tokens = Math.max(lastTurnPromptTokens ?? 0, estimate) const limit = opts.threshold * opts.contextWindow return tokens > limit ? tokens : null } export type SummarizeFn = (older: readonly BrainMessage[]) => Promise /** * Fold older messages into a single `` user * message and return the new history (summary + last `keepRecent * 2` * messages verbatim). * * If the history is already short enough that there are no "older" messages * to fold, returns the input unchanged. * * Thrown errors from `summarize` propagate — caller decides whether to skip * compaction this turn (best-effort) or fail the turn. */ export async function compactHistory( history: readonly BrainMessage[], opts: CompactionOpts, summarize: SummarizeFn, ): Promise { const recentCount = opts.keepRecent * 2 if (history.length <= recentCount) return [...history] const recent = history.slice(-recentCount) const older = history.slice(0, -recentCount) if (older.length === 0) return [...history] const summary = await summarize(older) const wrapped = `\n${summary.trim()}\n` return [{ role: 'user', content: wrapped }, ...recent] }