diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml new file mode 100644 index 0000000000..e6b0fa166a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-21-compaction-summary-prefix-cache-reuse.md: 490eb57a5891bf9cd0799c5d49d25d4e9838041f +2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: 02412ff07e87e12c7e7de00b5c69e1282433f735 diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md new file mode 100644 index 0000000000..490eb57a58 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md @@ -0,0 +1,45 @@ +# Agent Note: The summarization call replays the conversation prefix for KV-cache reuse + +Status: implemented + +English | [中文](2026-07-21-compaction-summary-prefix-cache-reuse.zh.md) + +## 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`/`messagePrefix` head** — 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 `tools` from 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](../feature/2026-06-18-compaction-capability-seam.md). + +## Consequences + +- **`dsh-compact-basic`** owns `SummarizationInput`; the protected `summarize(input, agent, signal?)` hook signature changed (acceptable pre-release), and `region.ts` gained `buildSummarizationInput` folding `deriveEventMessage` over the shadowed seqs behind the header prefix. +- **Dead render surface removed.** The old flattening path (`renderTranscript` / `renderContentBlocks` and its spec in `dsh-compact`) had no remaining consumer and was deleted with its export. +- **README model experience** for `dsh-compact-basic` now 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/message` and every conversation-request snapshot are unaffected; only the auxiliary request's shape changed. + +## Testing + +- **Unit:** `compact-basic.spec.ts` asserts the auxiliary call forwards `system`/`tools`/leading messages and appends the compaction instruction as the final message, and that `compactRegion` replays 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.ts` classifies 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/chunk` events, so it remains outside keyless replay; the pre-existing gap is owned by the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md). diff --git a/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md new file mode 100644 index 0000000000..02412ff07e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 摘要调用回放对话前缀以复用 KV 缓存 + +Status: implemented + +[English](2026-07-21-compaction-summary-prefix-cache-reuse.md) | 中文 + +## Problem + +自动压缩(compaction)在对话中途触发,恰好在循环用最后一个已路由请求(`system` + `tools` + `messagePrefix` + 派生历史)预热了提供方的 KV 缓存之后。随后默认摘要器发出一个*独立的*辅助请求,其前缀与那个已预热请求没有任何共享部分:一个专门的摘要器 `system` 提示词,后接被拍平成单个渲染后 transcript(文本记录)字符串的较早历史。提供方基于请求起始的 token 序列做缓存,因此第一个 token 只要不同(即一个不同的系统提示词),整个已缓存前缀就会失效。于是每次压缩都要为整段回放的历史付出两次完整的提示词处理成本:一次用于触发压力的对话请求,另一次用于摘要调用,恰好在对话最大时让缓存失去作用。 + +## Decision + +摘要指令从请求的**前端**(一个全新的 `system` 提示词)移到对话的**末尾**(最后一条 `user` 消息)。辅助调用现在逐字复现最后一个已路由请求的前缀,并追加一条尾部指令,因此它是已预热请求的真正前缀扩展,提供方会复用已缓存的 token。 + +### `SummarizationInput` 携带回放的前缀,而非渲染后的字符串 + +`summarize()`(以及内部的 `summarizeWithLlm`)接受一个 `SummarizationInput`(`{ system?, tools?, messages }`)而不是一个扁平的 transcript 字符串。`region.ts` 用 `session.requestHeader()`(持久的 `system`、`tools` 和 `messagePrefix`)加上经 `session.deriveEventMessage` 映射的被遮蔽区域来构建它,后者产出与 `deriveMessages()` 折叠进已路由请求的内容字节级一致的 `Message` 对象。`summarizeWithLlm` 把 `system` 和 `tools` 转发到 `GenerateOptions`,并发送 `[...input.messages, { role: 'user', content: COMPACTION_INSTRUCTION }]`。`tools` 会一同带上,即便摘要器从不调用任何工具:丢弃它们会缩短 token 序列,破坏与已缓存请求的对齐。 + +### 指令是一条尾部 user 消息 + +`COMPACTION_INSTRUCTION` 以 "You are now acting as a compaction engine…" 开头,指示模型浓缩*上方的对话*。它保留先前检查点的结构化标题,并在其新位置上新增了两条前置系统提示词此前不需要的规则:不要提及摘要请求,以及只输出检查点文本而不调用任何工具。被遮蔽区域总是结束在工具配对平衡的边界上,因此在其后追加一条 `user` 消息,对 OpenAI 兼容适配器和 DeepSeek 适配器而言是合法的消息排序。 + +### 缓存复用是尽力而为,正确性不是 + +自动压缩总是锚定在表层头部,因此被遮蔽区域就是已路由请求的头部,回放的前缀与之完全匹配,这就是保证命中的情形。手动的中段 `compactRegion` 仍然回放真实的前缀并保持正确,但会放弃复用,因为它的被遮蔽区域不是请求头部。配置的 `summarizationProvider`/`summarizationModel` 若与对话的路由不同,也会放弃复用;这是部署方明确的权衡,而非缺陷。目标解析(配置的覆盖值 → 最新的已路由 header → agent(智能体)选项,否则抛出)保持不变。 + +## Alternatives considered + +- **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。 +- **只发送被遮蔽区域而不带 `system`/`tools`/`messagePrefix` 头部**——否决:更短或头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 +- **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 +- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。 + +## Consequences + +- **`dsh-compact-basic`** 拥有 `SummarizationInput`;受保护的 `summarize(input, agent, signal?)` 钩子签名发生变化(发布前可接受),并且 `region.ts` 新增了 `buildSummarizationInput`,它在 header 前缀之后对被遮蔽的 seq 折叠 `deriveEventMessage`。 +- **移除无用的渲染表面。** 旧的拍平路径(`renderTranscript` / `renderContentBlocks` 及其在 `dsh-compact` 中的 spec)已无消费方,连同其导出一并删除。 +- **README 的 Model Experience** 现在把 `dsh-compact-basic` 的辅助请求记述为回放的前缀加上一条尾部压缩指令消息,并把其 KV 缓存效果记述为复用已预热的对话前缀。 +- **带框架的检查点输出未改变**,因此落地的 `user/message` 和每个对话请求快照都不受影响;只有辅助请求的形状发生了变化。 + +## Testing + +- **单元:** `compact-basic.spec.ts` 断言辅助调用转发 `system`/`tools`/前导消息,并把压缩指令作为最后一条消息追加,且 `compactRegion` 回放最新的已路由 header 前缀。现有的内容断言通过回放的消息而非 transcript 字符串来读取摘要器输入。 +- **循环:** `compact-loop-repro.spec.ts` 依据摘要请求尾部 user 消息中的压缩指令对其分类,溢出恢复测试则继续在真实循环中固定对话请求与摘要请求的数量。 +- **快照缺口未变:** 摘要调用仍然不发出 `assistant/chunk` 事件,因此它仍处于无密钥回放之外;这一既有缺口归 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 所有。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index 93a14c9f93..5b7381be1d 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -31,7 +31,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold. -`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. +`compactIfNeeded(agent, trigger, signal)` takes an explicit `'pressure' | 'context-overflow'` trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any `llm/stream` routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the [summary prefix-cache Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md). ### Automatic pressure runs after successful durable step work diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index dd7b7dc1d6..a0f65a1fae 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -422,7 +422,7 @@ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionTrigger](../core-data-structures/compaction.md) -Source: [`packages/compact/compact/src/index.ts:40`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:39`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 2948518398..66442d2fd1 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-compact-basic -The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call (interceptable at `llm/stream`). +The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with reusable `ctx.tokenMeter` pressure, token-budget retention, and summarization as a direct one-shot `ctx.llm.stream()` call that replays the conversation prefix to reuse the provider's KV cache (interceptable at `llm/stream`). This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. @@ -13,7 +13,7 @@ This backend owns the compaction policy: - **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. -- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. +- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. - **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. @@ -96,30 +96,16 @@ Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces t Replacing rather than append-only. Each checkpoint invalidates reuse from the first replaced history token; the unchanged request prefix before that range remains reusable. -### Auxiliary summarizer user message +### Auxiliary summarizer request #### What the model sees -The summarization model receives exactly `Summarize this conversation history:` followed by a blank line, the data-dependent [`renderTranscript()`](../compact/README.md) output, another blank line, and `Summary:`. The conversation model never sees this private request or its reasoning; only returned text is stored. +The summarization model receives the conversation replayed verbatim — the same system prompt, tool schemas, and messages the last routed request sent for the shadowed region — followed by one final user message: the compaction instruction below. The conversation model never sees this private request or its reasoning; only returned text is stored. -#### Token effect - -This is a separate model call with data-dependent input and `maxTokens`-capped output. Convergence retries can pay this cost more than once. - -#### KV Cache effect - -Independent of the conversation request cache. An auxiliary call can reuse an exact transcript prefix, while a different selected range or rendering invalidates reuse from its first changed token. - -### Auxiliary summarizer system prompt - -#### What the model sees - -The summarization model receives the checkpoint-writing instruction below. - -##### Auxiliary summarizer system prompt +##### Compaction instruction (final user message) ```markdown -You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context. +You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context. Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section. @@ -150,17 +136,18 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t Rules: - Preserve exact file paths, commands, error strings, identifiers, and function signatures. - Capture user feedback and explicit instructions faithfully, especially corrections. -- Do NOT mention this summarization process or that the context was compacted. -- If the transcript already contains a block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. +- Do NOT mention this summarization request or that the context was compacted. +- Output only the checkpoint text: do not call any tool or take any other action. +- If the conversation already contains a block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure. ``` #### Token effect -Fixed auxiliary input cost plus the data-dependent transcript on every summarization attempt. +This is a separate model call: the replayed conversation prefix plus the fixed instruction as input, with `maxTokens`-capped output. Convergence retries can pay this cost more than once. #### KV Cache effect -Prefix-stable for auxiliary calls while this instruction and the summarizer route are unchanged. Changing either starts a different prefix; transcript changes occur after the instruction. +The replayed system prompt, tools, and shadowed-region messages match the conversation's last routed request byte-for-byte, so the provider's warm prefix cache is reused up to the trailing instruction; only that instruction, and the summary output, is uncached. Routing the summarizer to a different provider/model, or compacting a non-head range, forgoes this reuse. ## Known Limitations and Deferred Work diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 1bf6bc5459..10c64d10ca 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -22,6 +22,7 @@ import { } from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' +import type { SummarizationInput } from './summarizer.ts' import type { BasicCompactConfig, ModelCompactPolicyConfig, @@ -205,15 +206,17 @@ export class BasicCompactService extends CompactService { } /** - * Summarize a rendered region through a direct one-shot `ctx.llm.stream()` - * call. Override this sole hook for a template or remote summarizer. - * @param text - plain-text conversation region to condense. + * Summarize the replayed conversation region through a direct one-shot + * `ctx.llm.stream()` call whose prefix reuses the conversation's own system + * prompt, tools, and messages so the provider's KV cache is not invalidated. + * Override this sole hook for a template or remote summarizer. + * @param input - replayed conversation prefix (system, tools, and leading messages) to condense. * @param agent - supplies routed-model history, fallback model, and session id. * @param signal - optional cancellation forwarded to the adapter. * @returns safe text summary blocks and exact auxiliary-call provenance. */ protected async summarize( - text: string, + input: SummarizationInput, agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { @@ -221,7 +224,7 @@ export class BasicCompactService extends CompactService { const config = target === undefined ? this.config : resolveTargetPolicy(this.config, target) - return summarizeWithLlm(this.ctx, config, text, agent, signal) + return summarizeWithLlm(this.ctx, config, input, agent, signal) } /** @@ -327,7 +330,7 @@ export class BasicCompactService extends CompactService { const session = agent.session return compactSurfaceRegion({ meter: this.ctx.tokenMeter, - summarize: (text, owner, abort) => this.summarize(text, owner, abort), + summarize: (input, owner, abort) => this.summarize(input, owner, abort), }, session, start, end, agent, signal) } } diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index ac1ee260b1..86ae804e82 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -5,20 +5,20 @@ */ import { - renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore, } from '@deepseek-ai/dsh-compact' import type { CompactionResult } from '@deepseek-ai/dsh-compact' +import type { Message } from '@deepseek-ai/dsh-llm' import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import { frameSummary } from './summarizer.ts' -import type { SummaryResult } from './summarizer.ts' +import type { SummarizationInput, SummaryResult } from './summarizer.ts' interface RegionDependencies { readonly meter: TokenMeterService - summarize(text: string, agent: Agent, signal?: AbortSignal): Promise + summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise } /** @@ -122,8 +122,8 @@ export async function compactSurfaceRegion( throw new Error('compaction: selected surface changed before summarization began') } const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0) - const text = renderTranscript(session.events, shadowedSeqs) - const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal) + const summarizationInput = buildSummarizationInput(session, shadowedSeqs) + const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal) const currentMeasurement = dependencies.meter.measure(session) if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) { @@ -173,6 +173,34 @@ export async function compactSurfaceRegion( } } +/** + * Reconstruct the last routed request's cacheable prefix for the shadowed + * region: its system prompt and tool schemas, then the request-only message + * prefix followed by the region's own derived messages in surface order. The + * summarizer appends only the compaction instruction after this, so the call + * is a genuine prefix of the conversation and reuses the provider's KV cache. + * @param session - session supplying the request header and per-node projection. + * @param shadowedSeqs - the surface-node seqs, in order, being compacted. + * @returns the replayed conversation prefix to condense. + */ +function buildSummarizationInput( + session: Session, + shadowedSeqs: readonly number[], +): SummarizationInput { + const header = session.requestHeader() + const events = session.events + const regionMessages = shadowedSeqs + // shadowedSeqs are current surface seqs, so each is a valid log index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map(seq => session.deriveEventMessage(events[seq]!)) + .filter((message): message is Message => message !== null) + return { + ...header?.system === undefined ? {} : { system: header.system }, + ...header?.tools === undefined ? {} : { tools: header.tools }, + messages: [...header?.messagePrefix ?? [], ...regionMessages], + } +} + /** Inspect the current turn boundary and latest compaction bracket once. */ function inspectTurnTail( events: readonly SessionEvent[], diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 247224eb75..cf34c2ad91 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -6,7 +6,7 @@ import type { Context } from 'cordis' import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' interface SummaryConfig { @@ -19,9 +19,15 @@ interface SummaryConfig { const SUMMARY_OPEN_TAG = '' const SUMMARY_CLOSE_TAG = '' -/** Fixed structure required from the auxiliary summarization call. */ -const SUMMARIZE_SYSTEM_PROMPT = [ - 'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.', +/** + * The summarization directive, delivered as the FINAL user message after the + * replayed conversation rather than as a distinct summarizer system prompt. + * Keeping the conversation's own system prompt, tools, and message prefix in + * front of it makes the auxiliary call a genuine prefix of the last routed + * request, so the provider's KV cache is reused instead of invalidated. + */ +const COMPACTION_INSTRUCTION = [ + 'You are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context.', '', 'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.', '', @@ -52,14 +58,30 @@ const SUMMARIZE_SYSTEM_PROMPT = [ 'Rules:', '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.', '- Capture user feedback and explicit instructions faithfully, especially corrections.', - '- Do NOT mention this summarization process or that the context was compacted.', - `- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`, + '- Do NOT mention this summarization request or that the context was compacted.', + '- Output only the checkpoint text: do not call any tool or take any other action.', + `- If the conversation already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`, ].join('\n') /** Framing that makes the replacement user message established context. */ const CHECKPOINT_PREAMBLE = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.' +/** + * The replayed conversation surface the summarizer condenses. Reproducing the + * last routed request's system prompt, tools, and leading messages verbatim + * lets the auxiliary call reuse the provider's warm prefix cache; the trailing + * compaction instruction is then the only novel input. + */ +export interface SummarizationInput { + /** The conversation's own system prompt, reused for prefix-cache alignment; absent for a system-less request. */ + readonly system?: string + /** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */ + readonly tools?: readonly ToolSchema[] + /** The request prefix followed by the shadowed region, in surface order, that precedes the compaction instruction. */ + readonly messages: readonly Message[] +} + /** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */ export interface SummaryResult { summary: ContentBlock[] @@ -69,10 +91,12 @@ export interface SummaryResult { } /** - * Run the default direct `ctx.llm.stream()` summarization call. + * Run the default cache-reusing `ctx.llm.stream()` summarization call: replay + * the conversation prefix, then append the compaction instruction as the final + * user message so the provider's warm prefix cache is reused. * @param ctx - context providing the LLM service. * @param config - resolved backend configuration. - * @param text - rendered transcript region to summarize. + * @param input - replayed conversation prefix (system, tools, and leading messages) to condense. * @param agent - supplies routed-model history, fallback model, and session id. * @param signal - optional cancellation forwarded to the adapter. * @returns safe text-only summary blocks and exact call provenance. @@ -80,7 +104,7 @@ export interface SummaryResult { export async function summarizeWithLlm( ctx: Context, config: SummaryConfig, - text: string, + input: SummarizationInput, agent: Agent, signal?: AbortSignal, ): Promise { @@ -102,14 +126,16 @@ export async function summarizeWithLlm( } const assembler = new BlockAssembler() + const messages: Message[] = [ + ...input.messages, + { role: 'user', content: [{ type: 'text', text: COMPACTION_INSTRUCTION }] }, + ] const options: GenerateOptions = { provider: target.provider, model: target.model, - messages: [{ - role: 'user', - content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }], - }], - system: SUMMARIZE_SYSTEM_PROMPT, + messages, + ...input.system === undefined ? {} : { system: input.system }, + ...input.tools === undefined ? {} : { tools: [...input.tools] }, maxTokens: config.maxTokens, sessionId: agent.session.id, ...signal === undefined ? {} : { signal }, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 01e0e62da9..9e9c8437db 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -3,6 +3,7 @@ import { Context } from 'cordis' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' +import type { SummarizationInput } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { resolveCompactSpec, @@ -16,6 +17,7 @@ import type { GenerateOptions, LlmFailure, LlmModelContext, + Message, StreamChunk, } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -67,6 +69,21 @@ function agent(session: Session, model?: string): Agent { return { session, options: model === undefined ? {} : { provider: model, model } } as Agent } +/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */ +function summarizedText(input: SummarizationInput): string { + const collect = (blocks: readonly ContentBlock[]): string => + blocks.map(block => + block.type === 'text' ? block.text + : block.type === 'tool-result' ? collect(block.content) + : '').join('\n') + return input.messages.map(message => collect(message.content)).join('\n') +} + +/** A minimal replayed prefix carrying one user message of the given text. */ +function promptInput(text: string): SummarizationInput { + return { messages: [{ role: 'user', content: [{ type: 'text', text }] }] } +} + /** Closed two-message turns followed by one open turn for durable compaction events. */ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session { const session = new Session(SessionId(`conversation-${turns}`)) @@ -182,14 +199,14 @@ class TestCompactService extends BasicCompactService { summaryModel = 'summary-model' error: unknown mutateDuringSummary: (() => void) | undefined - calls: Array<{ text: string; signal: AbortSignal | undefined }> = [] + calls: Array<{ input: SummarizationInput; signal: AbortSignal | undefined }> = [] override async summarize( - text: string, + input: SummarizationInput, _agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - this.calls.push({ text, signal }) + this.calls.push({ input, signal }) this.mutateDuringSummary?.() if (this.error !== undefined) throw this.error return { @@ -720,8 +737,8 @@ describe('optional model-free tool-result pruning', () => { expect(await compactIfNeeded(compact, session)).not.toBeNull() expect(compact.calls).toHaveLength(1) - expect(compact.calls[0]!.text).toContain('tool result middle pruned') - expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300)) + expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') + expect(summarizedText(compact.calls[0]!.input)).not.toContain('result 1 '.repeat(300)) }) it('retains the original compact-basic behavior without the optional plugin', async () => { @@ -758,7 +775,7 @@ describe('compaction region transaction', () => { expect(result.shadowedSeqs).toEqual(before.slice(0, 4)) expect(result.shadowedTokenCount).toBeGreaterThan(0) expect(compact.calls[0]).toMatchObject({ signal: SIGNAL }) - expect(compact.calls[0]?.text).toContain('fixture user 1') + expect(summarizedText(compact.calls[0]!.input)).toContain('fixture user 1') const summary = session.events.findLast(event => event.type === 'compact/summary') expect(summary?.data).toMatchObject({ shadowedSeqs: result.shadowedSeqs, @@ -776,6 +793,25 @@ describe('compaction region transaction', () => { expect(replay.deriveMessages()).toEqual(session.deriveMessages()) }) + it('replays the latest routed header prefix so the summarizer reuses the cache', async () => { + const compact = service() + const session = conversation(3) + const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] + const messagePrefix: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'SESSION PREFIX' }] }] + session.append('request/header', { + header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools, messagePrefix }, + reason: 'resume', + }) + const nodes = session.surface.nodes + await compact.compactRegion(nodes[0]!, nodes[1]!, agent(session, MODEL), SIGNAL) + + const { input } = compact.calls[0]! + expect(input.system).toBe('CONVERSATION SYSTEM') + expect(input.tools).toEqual(tools) + expect(input.messages[0]).toEqual(messagePrefix[0]) + expect(summarizedText(input)).toContain('fixture user 1') + }) + it.each([ ['start missing', 9_001, undefined, /start seq 9001 not found/], ['end missing', undefined, 9_002, /end seq 9002 not found/], @@ -989,11 +1025,11 @@ class ScriptedAdapter extends LlmAdapter { class ExposedCompactService extends BasicCompactService { runSummarize( - text: string, + input: SummarizationInput, owner: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - return this.summarize(text, owner, signal) + return this.summarize(input, owner, signal) } } @@ -1025,7 +1061,7 @@ describe('default one-shot summarizer', () => { maxTokens: 321, }) const session = conversation(1) - const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL) + const output = await compact.runSummarize(promptInput('transcript'), agent(session, 'fallback'), SIGNAL) expect(output).toEqual({ summary: [{ type: 'text', text: 'public summary' }], @@ -1040,7 +1076,68 @@ describe('default one-shot summarizer', () => { signal: SIGNAL, sessionId: session.id, }) - expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent') + const instruction = adapter.lastOptions?.messages.at(-1)?.content[0] + expect(instruction?.type === 'text' ? instruction.text : '').toContain('## Primary Request and Intent') + }) + + it('replays the conversation prefix and appends the instruction as the final message', async () => { + const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) + const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] + const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] } + await compact.runSummarize({ + system: 'REPLAYED SYSTEM', + tools, + messages: [prefix], + }, agent(conversation(1), MODEL)) + + expect(adapter.lastOptions?.system).toBe('REPLAYED SYSTEM') + expect(adapter.lastOptions?.tools).toEqual(tools) + const messages = adapter.lastOptions?.messages ?? [] + expect(messages[0]).toEqual(prefix) + const last = messages.at(-1)?.content[0] + const lastText = last?.type === 'text' ? last.text : '' + expect(lastText).toContain('Condense the conversation ABOVE') + expect(lastText).toContain('## Primary Request and Intent') + }) + + it('applies the routed model policy without changing the replayed prefix', async () => { + const { ctx, compact } = await summarizerHarness( + [{ type: 'text', text: 'unused default summary' }], + undefined, + MODEL, + { + auto: false, + maxTokens: 111, + modelPolicies: [{ + provider: MODEL, + model: MODEL, + summarizationProvider: 'policy-summary', + summarizationModel: 'policy-summary', + maxTokens: 222, + }], + }, + ) + const policyAdapter = new ScriptedAdapter([{ type: 'text', text: 'policy summary' }]) + ctx.llm.registerAdapter(['policy-summary'], policyAdapter) + const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'warm prefix' }] } + + const output = await compact.runSummarize({ + system: 'WARM SYSTEM', + messages: [prefix], + }, agent(conversation(1), 'fallback')) + + expect(output).toMatchObject({ + provider: 'policy-summary', + model: 'policy-summary', + maxTokens: 222, + }) + expect(policyAdapter.lastOptions).toMatchObject({ + provider: 'policy-summary', + model: 'policy-summary', + maxTokens: 222, + system: 'WARM SYSTEM', + }) + expect(policyAdapter.lastOptions?.messages[0]).toEqual(prefix) }) it('resolves the latest routed provider/model before the AgentOptions pair', async () => { @@ -1050,7 +1147,7 @@ describe('default one-shot summarizer', () => { header: { config: { provider: 'routed', model: 'routed' } }, reason: 'initial', }) - const output = await compact.runSummarize('history', agent(session, 'fallback')) + const output = await compact.runSummarize(promptInput('history'), agent(session, 'fallback')) expect(output.provider).toBe('routed') expect(output.model).toBe('routed') expect(adapter.lastOptions?.provider).toBe('routed') @@ -1084,7 +1181,7 @@ describe('default one-shot summarizer', () => { await ctx.plugin(LlmService) void new TokenMeterService(ctx) const compact = new ExposedCompactService(ctx, { auto: false }) - await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less'))))) + await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less'))))) .rejects.toThrow(/no provider\/model available for summarization/) }) @@ -1092,7 +1189,7 @@ describe('default one-shot summarizer', () => { const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) const session = new Session(SessionId('headerless-summary')) - await expect(compact.runSummarize('history', agent(session, MODEL))).resolves.toMatchObject({ + await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({ provider: MODEL, model: MODEL, }) @@ -1109,7 +1206,7 @@ describe('default one-shot summarizer', () => { session: new Session(SessionId(`incomplete-${String(options.model)}`)), options, } as Agent - await expect(compact.runSummarize('history', owner)) + await expect(compact.runSummarize(promptInput('history'), owner)) .rejects.toThrow(/no provider\/model available for summarization/) }) @@ -1124,7 +1221,7 @@ describe('default one-shot summarizer', () => { const { compact } = await summarizerHarness([], finish) let thrown: unknown try { - await compact.runSummarize('history', agent(conversation(1), MODEL)) + await compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)) } catch (error: unknown) { thrown = error } @@ -1136,7 +1233,7 @@ describe('default one-shot summarizer', () => { it('rejects empty or reasoning-only successful output', async () => { const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }]) - await expect(compact.runSummarize('history', agent(conversation(1), MODEL))) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) }) @@ -1304,7 +1401,7 @@ describe('automatic listener and loader composition', () => { expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' }) expect(session.events.some(event => event.type === 'compact/summary')).toBe(true) expect(compact.calls).toHaveLength(1) - expect(compact.calls[0]!.text).toContain('tool result middle pruned') + expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned') }) it('retries from a durable prune when later overflow summarization throws', async () => { diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index bb4618b035..4b3dd7a6b3 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -81,7 +81,12 @@ class OverflowRecoveryAdapter extends LlmAdapter { } override async * stream(options: GenerateOptions): AsyncIterable { - if (options.system?.includes('You are a compaction engine')) { + // The cache-reusing summarizer replays the conversation prefix and marks + // its call only by the compaction instruction in the trailing user message. + const trailing = options.messages.at(-1)?.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') ?? '' + if (trailing.includes('acting as a compaction engine')) { this.summaryRequests.push(options) yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'block-end', index: 0, block: { type: 'text', text: 'RECOVERY CHECKPOINT' } } diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 6e33b6e570..ed785b41ff 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c | Package | Role | |---|---| -| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) | +| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers | | `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | @@ -63,7 +63,7 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and #### What the model sees -A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite. +A successful implementation replaces an older surface range with one user-role summary checkpoint — a `user/message` carrying `surfaceOp: { op: 'replace', start, end }`; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite. #### Token effect @@ -73,20 +73,6 @@ Zero direct tokens from this interface. A backend trades many retained history t A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request. -### Transcript supplied to a compaction consumer - -#### What the model sees - -`renderTranscript()` joins entries with one blank line and renders them exactly as `User: `, `Assistant: `, `Tool result (call ): `, `Tool error (call ): `, `[Context: ]`, or `[Steering: ]`. Non-text blocks render exactly as `[reasoning: ]`, `[tool-call: ()]`, `[tool-result: ]`, `[tool-result]`, or `[]`. - -#### Token effect - -Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript. - -#### KV Cache effect - -No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference. - ## Known Limitations and Deferred Work - **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index f4f666bfef..2a9d7955af 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -12,7 +12,6 @@ import type { Session } from '@deepseek-ai/dsh-session' import type { CompactionResult } from './types.ts' export type { CompactionResult } from './types.ts' -export { renderContentBlocks, renderTranscript } from './render.ts' export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts' /** Why automatic policy is asking a backend to consider compaction. */ diff --git a/packages/compact/compact/src/render.ts b/packages/compact/compact/src/render.ts deleted file mode 100644 index 48006d13b7..0000000000 --- a/packages/compact/compact/src/render.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Pure shared transcript projection for summarization and recall, so both - * render the same log span byte-for-byte under replay. - * @module @deepseek-ai/dsh-compact/render - */ - -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session' - -/** - * Render text directly, reasoning as a tagged span, and every other block as a - * type-tagged placeholder. Tool results recurse into nested content; empty - * blocks contribute nothing and rendered blocks join with newlines. - * - * @param blocks - the content blocks to render. - * @returns the newline-joined plain-text rendering; empty string when nothing renders. - */ -export function renderContentBlocks(blocks: readonly ContentBlock[]): string { - const parts: string[] = [] - for (const block of blocks) { - switch (block.type) { - case 'text': - if (block.text) parts.push(block.text) - break - case 'reasoning': - if (block.text) parts.push(`[reasoning: ${block.text}]`) - break - case 'tool-call': - parts.push(`[tool-call: ${block.name}(${block.arguments})]`) - break - case 'tool-result': { - const inner = renderContentBlocks(block.content) - parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]') - break - } - // ContentBlockMap is merge-extensible — render an unknown block as a - // bare type-tagged placeholder so a plugin-added block type is still - // signalled to the reader rather than dropped. - default: - parts.push(`[${(block as ContentBlock).type}]`) - } - } - return parts.join('\n') -} - -/** - * Render message-producing events as a role-labeled transcript. `seqs` are - * walked in caller-supplied surface order, which may differ from numeric log - * order after replacement; non-surface and unknown merged events are skipped. - * - * @param events - the session log the seqs index into (`session.events`). - * @param seqs - the surface-node seqs to render, in surface order. - * @returns the transcript, entries joined by blank lines; empty string when nothing renders. - */ -export function renderTranscript(events: readonly SessionEvent[], seqs: readonly number[]): string { - const lines: string[] = [] - - for (const seq of seqs) { - const event = events[seq] - if (!event) continue - - switch (event.type) { - case 'user/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`User: ${text}`) - break - } - case 'assistant/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`Assistant: ${text}`) - break - } - case 'tool/result': { - const text = renderContentBlocks(event.data.content) - const label = event.data.isError ? 'Tool error' : 'Tool result' - if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`) - break - } - case 'context/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`[Context: ${text}]`) - break - } - case 'steering/message': { - const text = renderContentBlocks(event.data.content) - if (text) lines.push(`[Steering: ${text}]`) - break - } - default: - break - } - } - - return lines.join('\n\n') -} diff --git a/packages/compact/compact/tests/render.spec.ts b/packages/compact/compact/tests/render.spec.ts deleted file mode 100644 index 3b4bb41ac2..0000000000 --- a/packages/compact/compact/tests/render.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { CallId } from '@deepseek-ai/dsh-llm' - -function session(): Session { - return new Session(SessionId('render-spec')) -} - -describe('renderContentBlocks', () => { - it('renders text blocks verbatim and skips empty ones', () => { - expect(renderContentBlocks([ - { type: 'text', text: 'hello' }, - { type: 'text', text: '' }, - { type: 'text', text: 'world' }, - ])).toBe('hello\nworld') - }) - - it('wraps reasoning, skipping empty reasoning', () => { - expect(renderContentBlocks([ - { type: 'reasoning', text: 'think' }, - { type: 'reasoning', text: '' }, - ])).toBe('[reasoning: think]') - }) - - it('renders tool-call as a name(args) placeholder', () => { - expect(renderContentBlocks([ - { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' }, - ])).toBe('[tool-call: read({"filePath":"a"})]') - }) - - it('renders tool-result with nested content, and bare when empty', () => { - expect(renderContentBlocks([ - { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] }, - { type: 'tool-result', toolCallId: CallId('c2'), content: [] }, - ])).toBe('[tool-result: ok]\n[tool-result]') - }) - - it('renders an unknown (merge-extended) block type as a bare type tag', () => { - const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock - expect(renderContentBlocks([unknown])).toBe('[image]') - }) - - it('returns the empty string for no blocks', () => { - expect(renderContentBlocks([])).toBe('') - }) -}) - -describe('renderTranscript', () => { - it('renders each surface event type with its label, in the seq order given', () => { - const s = session() - const user = s.append('user/message', { - content: [{ type: 'text', text: 'fix the bug' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, - turn: 0, step: 0, - content: [{ type: 'text', text: 'looking' }], - }, { surfaceOp: 'append' }) - const result = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c1'), - content: [{ type: 'text', text: 'exit 0' }], - isError: false, - }, { surfaceOp: 'append' }) - const context = s.append('context/message', { - content: [{ type: 'text', text: 'file changed' }], - source: { kind: 'plugin', plugin: 'fs' }, - }, { surfaceOp: 'append' }) - const steering = s.append('steering/message', { - turn: 0, - content: [{ type: 'text', text: 'stop that' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - - expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([ - 'User: fix the bug', - 'Assistant: looking', - 'Tool result (call c1): exit 0', - '[Context: file changed]', - '[Steering: stop that]', - ].join('\n\n')) - }) - - it('labels an error tool result "Tool error"', () => { - const s = session() - const result = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c9'), - content: [{ type: 'text', text: 'boom' }], - isError: true, - }, { surfaceOp: 'append' }) - expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom') - }) - - it('renders NON-log-order seqs in the order given (surface order after a replace)', () => { - const s = session() - const first = s.append('user/message', { - content: [{ type: 'text', text: 'first' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const second = s.append('user/message', { - content: [{ type: 'text', text: 'second' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first') - }) - - it('skips events that render to nothing, non-message events, and seqs with no event', () => { - const s = session() - const empty = s.append('user/message', { - content: [{ type: 'text', text: '' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, - turn: 0, step: 0, - content: [{ type: 'text', text: '' }], - }, { surfaceOp: 'append' }) - const emptyResult = s.append('tool/result', { - turn: 0, step: 0, callId: CallId('c3'), - content: [{ type: 'text', text: '' }], - isError: false, - }, { surfaceOp: 'append' }) - const emptyContext = s.append('context/message', { - content: [{ type: 'text', text: '' }], - source: { kind: 'plugin', plugin: 'fs' }, - }, { surfaceOp: 'append' }) - const emptySteering = s.append('steering/message', { - turn: 0, - content: [{ type: 'text', text: '' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - // A log-only (non-surface) event type: contributes nothing to a transcript. - const lock = s.append('compact/start', { turn: 0 }) - expect(renderTranscript(s.events, [ - empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999, - ])).toBe('') - }) -})