/** * Outbound-request history sanitization shared by every provider call that * sends conversation history: the agent loop's model calls and the * compactor's summary calls. Each transform derives a sanitized projection of * the outbound copy only — durable history keeps the rich originals, and every * transform is idempotent so each send re-derives the same projection. */ import { stripHistoricalWebSearchResults } from "../daemon/web-search-history.js"; import type { ContentBlock, Message, ToolResultContent, } from "../providers/types.js"; /** Number of most-recent AX tree snapshots to keep in conversation history. */ const MAX_AX_TREES_IN_HISTORY = 2; /** Regex that matches the `...` markers. */ const AX_TREE_PATTERN = /[\s\S]*?<\/ax-tree>/g; const AX_TREE_PLACEHOLDER = ""; /** * Escapes any literal `` occurrences inside AX tree content so * that the non-greedy compaction regex (`AX_TREE_PATTERN`) does not stop * prematurely when the user happens to be viewing XML/HTML source that * contains the closing tag. The escaped content does not need to be * unescaped because compaction replaces the entire block with a placeholder. */ export function escapeAxTreeContent(content: string): string { return content.replace(/<\/ax-tree>/gi, "</ax-tree>"); } /** * Returns a shallow copy of `messages` where all but the most recent * `MAX_AX_TREES_IN_HISTORY` `` blocks have been replaced with a * short placeholder. This keeps the conversation context small so that * TTFT does not grow linearly with step count in computer-use sessions. * * Counting is per-block, not per-message — a single user message can * contain multiple tool_result blocks each with their own AX tree snapshot. */ export function compactAxTreeHistory(messages: Message[]): Message[] { // Collect (messageIndex, blockIndex) for every tool_result block with const axBlocks: Array<{ msgIdx: number; blockIdx: number }> = []; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; if (msg.role !== "user") { continue; } for (let j = 0; j < msg.content.length; j++) { const block = msg.content[j]; if ( block.type === "tool_result" && typeof block.content === "string" && block.content.includes("") ) { axBlocks.push({ msgIdx: i, blockIdx: j }); } } } if (axBlocks.length <= MAX_AX_TREES_IN_HISTORY) { return messages; } // Build a set of "msgIdx:blockIdx" keys for blocks that should be stripped const toStrip = new Set( axBlocks .slice(0, -MAX_AX_TREES_IN_HISTORY) .map((b) => `${b.msgIdx}:${b.blockIdx}`), ); return messages.map((msg, idx) => { // Quick check: does this message have any blocks to strip? const hasStripTarget = msg.content.some((_, j) => toStrip.has(`${idx}:${j}`), ); if (!hasStripTarget) { return msg; } return { ...msg, content: msg.content.map((block, j) => { if ( toStrip.has(`${idx}:${j}`) && block.type === "tool_result" && typeof block.content === "string" ) { return { ...block, content: block.content.replace( AX_TREE_PATTERN, AX_TREE_PLACEHOLDER, ), }; } return block; }), }; }); } /** * Full-wrapper matcher for the `` block. Both ends are * required so a user message that merely opens with the tag (someone pasting * prompt markup into chat) is never mistaken for an injected block, the same * discipline `stripUserTextBlocksByPrefix` uses in `strip-injections.ts`. */ const CHANNEL_CAPABILITIES_OPEN = "\n"; const CHANNEL_CAPABILITIES_CLOSE = "\n"; /** Whether a text block is a complete `…` block. */ function isChannelCapabilitiesBlock(text: string): boolean { return ( text.startsWith(CHANNEL_CAPABILITIES_OPEN) && text.endsWith(CHANNEL_CAPABILITIES_CLOSE) ); } /** * Drop `` blocks that repeat the last one still standing, * so a conversation carries one copy per distinct set of capabilities instead * of one per turn. * * Runtime injection prepends this block to the turn-starting user message and * then freezes it into history: live, because injection splices into the * conversation's own message array, and across restarts, because `loadFromDb` * rehydrates it from message metadata. Nothing ever removes it, so an N-turn * channel conversation ships N copies. Unlike ``, which at least * carries a fresh timestamp, this block is a pure function of the channel * (`buildChannelCapabilityBlock`): every copy after the first is byte-identical * to the one above it and tells the model nothing it has not already read. * * ## Why "same as the last retained copy" and not "not the current turn" * * Prompt caching only pays off when the bytes a provider marked in turn N are * still at the same position in turn N+1, an invariant this repo enforces end * to end in `prompt-cache-cross-turn-stability.test.ts`. Any rule of the form * "keep it on the current turn, collapse it above" necessarily re-renders one * message every turn (the turn that just ended), which is a full cache miss on * the whole message prefix, every turn, for every conversation. * * Comparing against the last RETAINED copy avoids that entirely: whether a * given occurrence survives depends only on the messages above it, never on * how many turns come later. A message's rendering is therefore fixed the * moment it is written, and the prefix stays byte-stable as the conversation * grows. Capabilities that genuinely change mid-conversation (the same * conversation resumed from a different client) still differ from the retained * copy, so the new block is kept and the model sees the change. * * The cost is placement: the surviving copy sits at the top of the conversation * rather than next to the newest message. It is the same bytes either way. * * Idempotent, like every other transform in this module: re-running it over an * already-deduplicated history retains exactly the same occurrences. */ export function dedupeChannelCapabilityBlocks(history: Message[]): Message[] { let lastRetained: string | null = null; let changed = false; const next = history.map((message) => { if (message.role !== "user") { return message; } let messageChanged = false; const content = message.content.filter((block) => { if (block.type !== "text" || !isChannelCapabilitiesBlock(block.text)) { return true; } if (block.text === lastRetained) { messageChanged = true; return false; } lastRetained = block.text; return true; }); // A user row is never only injections in practice, but guard anyway: an // empty content array is not a message any provider will accept, and // dropping the row would break tool_use/tool_result pairing and the // row-to-history index mapping `summarizeUpToMessage` relies on. if (!messageChanged || content.length === 0) { return message; } changed = true; return { ...message, content }; }); return changed ? next : history; } /** * Index of the last user message carrying `tool_result` blocks — the * "current turn" boundary {@link stripOldMediaBlocks} keeps intact while * stripping media from older tool results. Returns -1 when no user message * has tool results. * * Targets the last user message with tool_results (not just the last user * message) because a plain-text user message may follow the tool-result turn; * using the last user message unconditionally would leave the most recent tool * screenshots unprotected from stripping. */ export function lastToolResultUserMessageIndex(history: Message[]): number { for (let i = history.length - 1; i >= 0; i--) { if ( history[i].role === "user" && history[i].content.some((b) => b.type === "tool_result") ) { return i; } } return -1; } /** * Strip image contentBlocks from all tool_result blocks except those in the * most recent user message that contains tool_result blocks. This prevents * screenshots from accumulating in the context window — each image is seen * once by the LLM on the turn it was captured, then replaced with a text * placeholder on subsequent turns. */ function stripOldMediaBlocks(history: Message[]): Message[] { const lastToolResultUserIdx = lastToolResultUserMessageIndex(history); return history.map((msg, idx) => { // Keep the most recent tool-result user message intact (current turn) if (idx === lastToolResultUserIdx || msg.role !== "user") { return msg; } // Check if any tool_result blocks carry embedded media (image or audio). const isMedia = (cb: ContentBlock) => cb.type === "image" || cb.type === "file"; const hasMedia = msg.content.some( (b) => b.type === "tool_result" && (b as ToolResultContent).contentBlocks?.some(isMedia), ); if (!hasMedia) { return msg; } // Strip media from tool_result blocks, replacing with a text marker. The // model already saw/heard the media in the turn it was captured; resending // the bytes every turn (a 12 MB audio clip isn't optimized like images) // bloats the request until compaction. return { ...msg, content: msg.content.map((b) => { if (b.type !== "tool_result") { return b; } const tr = b as ToolResultContent; if (!tr.contentBlocks?.some(isMedia)) { return b; } return { ...tr, contentBlocks: undefined, content: (tr.content || "") + "\n[Media (image/audio) was captured and shown previously — binary data removed to save context.]", }; }), }; }); } /** * Sanitize the outbound history immediately before a provider call, bundling * the pre-send transforms applied to every request that carries conversation * history: * - {@link stripOldMediaBlocks} drops accumulated screenshot/audio bytes from * older tool results — the model saw the media on the turn it was captured. * Beyond context bloat, unstripped history can carry enough images to cross * Anthropic's many-image threshold, where a stricter per-image dimension * cap applies and a single large screenshot rejects the whole request. * - {@link compactAxTreeHistory} collapses all but the most recent few * `` snapshots so TTFT does not grow linearly with step count. * - {@link stripHistoricalWebSearchResults} converts historical * `web_search_tool_result` blocks to text summaries; Anthropic's opaque * `encrypted_content` tokens expire / are route-scoped, and replaying a stale * one is rejected with `Invalid encrypted_content in search_result block`. * - {@link dedupeChannelCapabilityBlocks} keeps one `` * block per distinct set of capabilities instead of the one-per-turn history * accumulates; every repeat is byte-identical to the copy above it. * * Transforms the outbound copy only — the durable history keeps the rich * originals and each send re-derives the sanitized projection (every transform * is idempotent). Both the agent loop's model calls and the compactor's * summary calls funnel through this bundle, so their request prefixes stay * byte-aligned (the summary call reuses the agent's warm prompt cache) and * oversized media and expired web-search tokens are guaranteed to be removed * from every request. */ export function preModelCallSanitize(history: Message[]): Message[] { const mediaStripped = stripOldMediaBlocks(history); const axCompacted = compactAxTreeHistory(mediaStripped); const webSearchStripped = stripHistoricalWebSearchResults(axCompacted).messages; return dedupeChannelCapabilityBlocks(webSearchStripped); }