Automatic compaction fires mid-conversation, right after the loop warmed the provider's KV cache with the last routed request. The default summarizer then issued a separate request whose prefix shared nothing with that warm request — a bespoke summarizer system prompt followed by the older history flattened to one rendered transcript string — so a differing first token invalidated the entire cached prefix and every compaction re-processed the whole replayed history twice. Move the compaction directive from the FRONT (a fresh system prompt) to the END (a trailing user message), and replay the last routed request's own system prompt, tools, message prefix, and shadowed-region messages verbatim via session.requestHeader() + deriveEventMessage. The auxiliary call is now a genuine prefix-extension of the warm request, so the provider reuses the cached tokens up to the trailing instruction. SummarizationInput carries the replayed prefix instead of a flat string; the now-unused renderTranscript/renderContentBlocks path is removed with its spec. Cache reuse is best-effort (head compaction guarantees a hit; a mid-range compaction or a differently-routed summarizer forgoes it), correctness is not.
6.2 KiB
Agent Note: The summarization call replays the conversation prefix for KV-cache reuse
Status: implemented
English | 中文
Problem
Automatic compaction fires mid-conversation, right after the loop has warmed the provider's KV cache with the last routed request (system + tools + messagePrefix + derived history). The default summarizer then issued a separate auxiliary request whose prefix shared nothing with that warm request: a bespoke summarizer system prompt followed by the older history flattened to a single rendered transcript string. A provider caches on the request's leading token sequence, so a first token that differs — a different system prompt — invalidates the entire cached prefix. Every compaction therefore paid full prompt-processing cost for the whole replayed history twice: once for the conversation request that tripped pressure, and again for the summarization call, defeating the cache exactly when the conversation is largest.
Decision
The summarization directive moves from the front of the request (a fresh system prompt) to the end of the conversation (the final user message). The auxiliary call now reproduces the last routed request's prefix verbatim and appends one trailing instruction, so it is a genuine prefix-extension of the warm request and the provider reuses the cached tokens.
SummarizationInput carries the replayed prefix, not a rendered string
summarize() (and the internal summarizeWithLlm) take a SummarizationInput — { system?, tools?, messages } — instead of a flat transcript string. region.ts builds it from session.requestHeader() (the durable system, tools, and messagePrefix) plus the shadowed region mapped through session.deriveEventMessage, which yields byte-identical Message objects to what deriveMessages() folded into the routed request. summarizeWithLlm forwards system and tools onto GenerateOptions and sends [...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]. tools ride along even though the summarizer never calls one: dropping them would shorten the token sequence and break alignment with the cached request.
The instruction is a trailing user message
COMPACTION_INSTRUCTION opens "You are now acting as a compaction engine…" and directs the model to condense the conversation ABOVE. It keeps the prior checkpoint's structured headings and adds two rules the front-loaded system prompt did not need in its new position: do not mention the summarization request, and output only the checkpoint text without calling a tool. The shadowed region always ends on a tool-pairing-balanced boundary, so appending a user message after it is a valid message ordering for OpenAI-compatible and DeepSeek adapters.
Cache reuse is best-effort, correctness is not
Auto-compaction always anchors at the surface head, so the shadowed region is the head of the routed request and the replayed prefix matches it exactly — the guaranteed-hit case. Manual mid-range compactRegion still replays the true prefix and stays correct, but forgoes reuse because its shadowed region is not the request head. A configured summarizationProvider/summarizationModel that differs from the conversation's route also forgoes reuse; that is the deployment's explicit trade-off, not a defect. Target resolution (configured override → latest routed header → agent options, else throw) is unchanged.
Alternatives considered
- Keep the summarizer system prompt but reuse the rest — rejected: the system slot is the very first token region a provider caches on, so a distinct summarizer system prompt invalidates the whole prefix regardless of what follows. Only moving the directive off the front recovers the cache.
- Send only the shadowed region without the
system/tools/messagePrefixhead — rejected: a shorter or differently-headed sequence still diverges from the cached request at the first token, so it caches no better while losing the framing the summary needs. - Omit
toolsfrom the summarization request (the model never calls one) — rejected: tool schemas are part of the cached token sequence; omitting them misaligns every following token and defeats reuse. - A dedicated
assistant/chunk-emitting summarization sub-session for snapshot replay — out of scope here; the replay gap predates this change and is tracked in the compaction-seam note.
Consequences
dsh-compact-basicownsSummarizationInput; the protectedsummarize(input, agent, signal?)hook signature changed (acceptable pre-release), andregion.tsgainedbuildSummarizationInputfoldingderiveEventMessageover the shadowed seqs behind the header prefix.- Dead render surface removed. The old flattening path (
renderTranscript/renderContentBlocksand its spec indsh-compact) had no remaining consumer and was deleted with its export. - README model experience for
dsh-compact-basicnow documents the auxiliary request as the replayed prefix plus a trailing compaction-instruction message, and its KV-cache effect as reuse of the warm conversation prefix. - The framed checkpoint output is unchanged, so the landed
user/messageand every conversation-request snapshot are unaffected; only the auxiliary request's shape changed.
Testing
- Unit:
compact-basic.spec.tsasserts the auxiliary call forwardssystem/tools/leading messages and appends the compaction instruction as the final message, and thatcompactRegionreplays the latest routed header prefix. Existing content assertions read the summarizer input through the replayed messages rather than a transcript string. - Loop:
compact-loop-repro.spec.tsclassifies the summarization request by the compaction instruction in its trailing user message, and the overflow-recovery tests continue to pin conversation-vs-summary request counts across the real loop. - Snapshot gap unchanged: the summarization call still emits no
assistant/chunkevents, so it remains outside keyless replay; the pre-existing gap is owned by the compaction-seam note.