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 index b8ab8490d0..2ede6629d7 100644 --- 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 @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md -2026-07-21-compaction-summary-prefix-cache-reuse.md: d05d25cfa7c3984ce0ce75c38068a91a0e07dfe8 -2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: f31c33e680b3db60a6fb758a522addca63224fea +2026-07-21-compaction-summary-prefix-cache-reuse.md: fb6524846f837b783423b3ed0e043cd4e1f7849d +2026-07-21-compaction-summary-prefix-cache-reuse.zh.md: e6072313b7089b9dda14c298fcb94db786220501 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 index d05d25cfa7..fb6524846f 100644 --- 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 @@ -29,7 +29,7 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th - **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` head** — rejected: a 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). +- **A dedicated `assistant/chunk`-emitting summarization sub-session for snapshot replay** — rejected: the durable `compact/summary` event records the successful local call's position and complete output, while its explicit call marker prevents replay from treating template or remote output as a local stream. ## Consequences @@ -42,4 +42,4 @@ Auto-compaction always anchors at the surface head, so the shadowed region is th - **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). +- **Snapshot:** keyless replay reconstructs one canonical successful stream from a marked `compact/summary`; the [compaction-seam note](../feature/2026-06-18-compaction-capability-seam.md) owns the durable marker contract. 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 index f31c33e680..e6072313b7 100644 --- 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 @@ -29,7 +29,7 @@ Status: implemented - **保留摘要器系统提示词但复用其余部分**——否决:system 槽位正是提供方最先做缓存的 token 区域,因此一个不同的摘要器系统提示词无论后面跟着什么都会使整个前缀失效。只有把指令移离前端才能恢复缓存。 - **只发送被遮蔽区域而不带 `system`/`tools` 头部**——否决:头部不同的序列在第一个 token 处仍然与已缓存请求分叉,因此缓存效果并不更好,反而丢失了摘要所需的框架。 - **从摘要请求中省略 `tools`**(模型从不调用任何工具)——否决:工具 schema 是已缓存 token 序列的一部分;省略它们会让后续每个 token 失去对齐,破坏复用。 -- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——此处超出范围;该回放缺口早于本次改动,记录在 [compaction-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中。 +- **为快照回放专门建立一个发出 `assistant/chunk` 的摘要子会话**——否决:持久的 `compact/summary` 事件会记录成功本地调用的位置和完整输出,而显式调用标记可防止回放把模板或远程输出当作本地流。 ## 后果 @@ -42,4 +42,4 @@ Status: implemented - **单元:** `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) 所有。 +- **快照:** 无密钥回放会从带标记的 `compact/summary` 重建一条规范成功流;[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.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml index 9e195a4b1d..1917b118da 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md -2026-06-18-compaction-capability-seam.md: 8dcbe74429a620027a570124383442b969c12196 -2026-06-18-compaction-capability-seam.zh.md: 27b63f29c2e6f35637185b47c882ae42e5d41088 +2026-06-18-compaction-capability-seam.md: 390d091fd6e6f7fa1694ba78b90522b70948fe92 +2026-06-18-compaction-capability-seam.zh.md: d72793e6adde911a8623ac1c6b65df899381923e 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 8dcbe74429..390d091fd6 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 @@ -33,7 +33,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 all three operations 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. `compactNow(agent, signal)` requires an idle agent and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit 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). The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`. +`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. `compactNow(agent, signal)` requires an idle agent and performs one useful balanced reduction even below pressure, returning `null` without writes when none exists. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for explicit 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). The result carries `llmStreamCall: true` because it consumed exactly one call through this context's LLM service; a subclass sets that marker only under the same condition, since retained `rawOutput` alone does not identify the call path. The call sets the provider-neutral `GenerateOptions.purpose` to `compaction`; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends `x-deepseek-harness-compact: 1`. ### Automatic pressure runs after successful durable step work @@ -76,7 +76,7 @@ Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` e ``` compact/start → log-only. Acquires the lock. [summarize older range via the backend] -compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count. +compact/summary → log-only. Provenance: raw summary, local-call marker, range, shadowed seqs, token count. user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary). deriveMessages() renders it as a user-role message. @@ -131,4 +131,4 @@ The lifecycle boundary makes crash state unambiguous: - **Loop:** Tests pin pre-step after the preceding `step/end` and before the next `step/start`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition. - **Manual:** Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key. - **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task. -- **Snapshot gap:** The summarization call is session-associated and logs `compact/summary`, but ordinary transcript replay does not derive its auxiliary response; keyless assembled coverage therefore needs an explicit replay override. +- **Snapshot:** The assembled context-overflow scenario derives the auxiliary call from `compact/summary` only when `llmStreamCall: true` proves that the local LLM service consumed it; canonical reconstructed blocks pin the complete recovery without provider delta partitioning. diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md index 27b63f29c2..d72793e6ad 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md @@ -33,7 +33,7 @@ Status: implemented 早期草案将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法。这会将契约重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将三个操作都设为抽象,把所有*怎么做*的决策放在后端,并让接口保持为*做什么*的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。 -`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 要求 agent 处于 idle,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。 +`compactIfNeeded(agent, trigger, signal)` 接受显式的 `'pressure' | 'context-overflow'` 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。`compactNow(agent, signal)` 要求 agent 处于 idle,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 `null`,且不写入任何内容。`compactRegion(start, end, agent, signal?)` 将 `agent.session` 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 `llm/stream` 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV cache;见[摘要前缀缓存 Agent Note](../bug-fix/2026-07-21-compaction-summary-prefix-cache-reuse.md)。该结果携带 `llmStreamCall: true`,因为生成它时恰好通过此上下文的 LLM 服务发起了一次调用;只有满足相同条件时,子类才设置该标记,因为单有保留的 `rawOutput` 并不能判定调用路径。该调用将提供方无关的 `GenerateOptions.purpose` 设为 `compaction`;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 `x-deepseek-harness-compact: 1`。 ### 成功的持久步骤工作完成后运行自动压力检查 @@ -76,7 +76,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface ``` compact/start → log-only. Acquires the lock. [summarize older range via the backend] -compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count. +compact/summary → log-only. Provenance: raw summary, local-call marker, range, shadowed seqs, token count. user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary). deriveMessages() renders it as a user-role message. @@ -131,4 +131,4 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab - **循环测试:** 测试固定 pre-step 发生在前一个 `step/end` 之后、下一个 `step/start` 之前,使用实际 `agent/request` 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。 - **手动测试:** 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。 - **带密钥 e2e:** 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 `compact/start…end` 对,缩小 surface,并完成任务。 -- **快照缺口:** 摘要调用与会话关联并记录 `compact/summary`,但普通 transcript(文本记录)回放不会派生其辅助响应;因此,要实现无密钥的组装态覆盖,就必须显式提供回放 override。 +- **快照:** 组装后的上下文溢出场景仅在 `llmStreamCall: true` 证明本地 LLM 服务执行了辅助调用时,才从 `compact/summary` 派生该调用;规范重建的块在不固定提供方增量切分的情况下固定完整恢复过程。 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 202a27ed1e..e5e6b3d67e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md -2026-06-19-acp-snapshot-tests.md: c7b95bd68027705b99d850d596405e56eea0dfca -2026-06-19-acp-snapshot-tests.zh.md: 43d43262684920cae5feedb5f2eb109db00f9f8c +2026-06-19-acp-snapshot-tests.md: c6e81dc4a51083ed37ad6f837fe7dbb08c212e23 +2026-06-19-acp-snapshot-tests.zh.md: 5a579c3373442dc4cfc7d661ea2f2f398b804dc7 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index c7b95bd680..c6e81dc4a5 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -24,7 +24,7 @@ Every committed session-format fixture uses the canonical packed physical layout ### Replay derives the model script from the log -`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` groups recorded chunks by `(turn, step)` and serves one group per model call. The loop makes one stream call per step, so the grouping is exact and includes error finish chunks without special handling. +`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` splits recorded `assistant/chunk` events at terminal `finish` chunks and uses `(turn, step)` changes to reject an unterminated prior call. A `compact/summary` with `llmStreamCall: true` contributes one call at its durable log position: replay reconstructs canonical block boundaries from `rawOutput`, retains recorded usage when present, and supplies a terminal `stop`. The marker distinguishes that local call from template or remote summaries whose retained `rawOutput` did not consume this context's adapter. ### The in-memory replay entry honors the full LLM contract @@ -36,7 +36,7 @@ Every committed session-format fixture uses the canonical packed physical layout | { kind: 'hang' } ``` -Logs derive chunk entries. Pre-stream throws and hangs have no reconstructable chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons. +Logs derive chunk entries from finished assistant streams and explicitly marked compaction calls. Pre-stream throws, hangs, and external summarizer calls have no reconstructable local chunk representation, so those scenarios provide `replay.override.json`. A throw entry may include prefix chunks for mid-stream failure. Explicit overrides avoid inferring adapter behavior from lossy turn-end reasons or provider output alone. ### Positional replay, one in-flight stream @@ -74,12 +74,13 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log ## Alternatives considered - **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral expected output. +- **A compulsory replay override for every compaction summary** — rejected: the durable summary event already fixes a successful local call's position, complete output, and optional usage. An explicit local-call marker preserves that single-source fixture without inventing a call for template or remote summarizers. - **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. - **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam. - **Copying both request-header sidecars beside every class pin** — rejected: prompt and tool-schema composition vary independently, so a change to one shared component would churn byte-identical files across unrelated class pins. Explicit per-component sources retain one structural pin per class without duplicating content. ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here until it can move to a transport-neutral headless suite without losing coverage. +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition, including an assembled context-overflow recovery whose marked compaction summary supplies the auxiliary call. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here until it can move to a transport-neutral headless suite without losing coverage. This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 43d4326268..5a579c3373 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -24,7 +24,7 @@ Status: implemented ### 回放从日志推导模型脚本 -`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 按 `(turn, step)` 对已录制的分片分组,每次模型调用服务一组。agent loop(智能体循环)每个步骤发起一次流调用,因此分组精确对应,错误结束分片也无需特殊处理。 +`llm-replay` 短路了提供方无关的 `llm/stream` waterfall(瀑布式事件)。`deriveReplayScript()` 在终止的 `finish` 分片处切分已记录的 `assistant/chunk` 事件,并用 `(turn, step)` 变化拒绝前一条未终止的调用。携带 `llmStreamCall: true` 的 `compact/summary` 会在其持久日志位置贡献一次调用:回放根据 `rawOutput` 重建规范块边界,保留已记录的 usage(如有),并提供终止的 `stop`。该标记将这次本地调用与模板摘要或远程摘要区分开;后两者即使保留了 `rawOutput`,也未使用此上下文的适配器。 ### 内存中的回放条目遵守完整的 LLM 契约 @@ -36,7 +36,7 @@ Status: implemented | { kind: 'hang' } ``` -日志推导出分片条目。流开始前的抛出和挂起没有可重建的分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因推断适配器行为。 +日志从已结束的 assistant 流和显式标记的压缩(compaction)调用推导分片条目。流开始前的抛出、挂起和外部摘要器调用没有可重建的本地分片表示,因此这些场景提供 `replay.override.json`。throw 条目可以包含前缀分片以模拟流中途失败。显式覆盖避免了从有损的轮次结束原因或单独的提供方输出推断适配器行为。 ### 位置式回放,单个在途流 @@ -74,12 +74,13 @@ Status: implemented ## 曾考虑的替代方案 - **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。 +- **为每个压缩摘要强制提供回放 override**——否决:持久摘要事件已经固定成功本地调用的位置、完整输出与可选 usage。显式的本地调用标记保留了这份单一来源 fixture,而不会为模板摘要器或远程摘要器凭空构造调用。 - **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。 - **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 - **在每个类别 pin 旁复制两个请求头伴随文件**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。 ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,直至它能够在不损失覆盖的情况下迁移到传输无关的 headless 套件。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与回放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖,其中包括一个组装后的上下文溢出恢复场景,其带标记的压缩摘要提供辅助调用。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,直至它能够在不损失覆盖的情况下迁移到传输无关的 headless 套件。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用回放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4a9bafeb83..afc8cd302a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -891,7 +891,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:731`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:735`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/docs/core-data-structures/compaction.i18n.yaml b/docs/core-data-structures/compaction.i18n.yaml index a0c3f27f88..c5cd2a0b38 100644 --- a/docs/core-data-structures/compaction.i18n.yaml +++ b/docs/core-data-structures/compaction.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/core-data-structures/compaction.md -compaction.md: fe64ffe2707ab4a41f1db186965b944d525684c3 -compaction.zh.md: ea694f30dcc50ef48847ffbd5efdeed36aa234c5 +compaction.md: b17dfc03c62c0453c45ca2810ef90bdce9ae9ac7 +compaction.zh.md: b904cdd3ad5bf63e968fc22fccb32c5220ab8763 diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index fe64ffe270..b17dfc03c6 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -13,7 +13,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de | Event | Payload | Role | |---|---|---| | `compact/start` | `{ turn }` | acquires the log-recorded lock; a number identifies the open automatic turn, while `null` identifies a standalone manual attempt | -| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note) | +| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance: the safe summary projection, optional complete provider output and usage, an `llmStreamCall: true` marker when producing the result consumed exactly one call through this context's `ctx.llm.stream()`, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, the estimated token count, and the summarize call's envelope (`provider`, `model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability Agent Note); `rawOutput` alone does not identify the call path | | `compact/end` | `{ turn, error? }` | releases the lock with the same numeric-or-null owner (`error` records an unsuccessful attempt) | The lock brackets the **whole** operation: `compact/start` is appended first, then summarization, the `compact/summary` provenance record, and the `user/message` replacement all land, and only then `compact/end`. Releasing the lock last turns a crash mid-operation into a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished. diff --git a/docs/core-data-structures/compaction.zh.md b/docs/core-data-structures/compaction.zh.md index ea694f30dc..b904cdd3ad 100644 --- a/docs/core-data-structures/compaction.zh.md +++ b/docs/core-data-structures/compaction.zh.md @@ -13,7 +13,7 @@ | 事件 | 载荷 | 作用 | |---|---|---| | `compact/start` | `{ turn }` | 获取日志记录的锁;数字标识打开的自动轮次,`null` 标识独立手动尝试 | -| `compact/summary` | `{ summary, rawOutput?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note) | +| `compact/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、生成结果时恰好通过此上下文的 `ctx.llm.stream()` 发起一次调用所带的 `llmStreamCall: true` 标记、被遮蔽的 surface 边界对(`start`/`end` seq——位置跨度,而非数值区间)、按 surface 顺序排列的被遮蔽 seq、估算 token 数,以及摘要调用的 envelope(`provider`、`model`,若有生成上限则还包括该上限)——写入日志后,该一次性请求可由日志 + 代码重建(见可重建性 Agent Note);单有 `rawOutput` 并不能判定调用路径 | | `compact/end` | `{ turn, error? }` | 使用相同的数字或 `null` 归属值释放锁(`error` 记录失败尝试) | 锁括住**整个**操作:先追加 `compact/start`,然后执行摘要生成、写入 `compact/summary` 来源记录与 `user/message` 替换,最后才追加 `compact/end`。最后释放锁意味着操作中途崩溃会表现为可检测的遗留锁(有 `compact/start` 而无匹配的 `compact/end`),而非一个虚假声称压缩已完成的 `compact/end`。 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 48732dbbb0..808a03b6f4 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -237,7 +237,7 @@ Source: [`packages/ui/commands/src/index.ts:139`](../packages/ui/commands/src/in 'compact/end': { turn: number | null; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:62`](../packages/compact/compact/src/types.ts) #### `compact/prune` — log-only @@ -261,7 +261,7 @@ Source: [`packages/compact/compact/src/types.ts:54`](../packages/compact/compact } ``` -Source: [`packages/compact/compact/src/types.ts:64`](../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:72`](../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -290,8 +290,16 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact */ 'compact/summary': { summary: ContentBlock[] - /** Complete provider output before the backend's safe summary projection. */ + /** + * Complete provider output before the backend's safe summary projection; + * this alone does not identify the call path. + */ rawOutput?: ContentBlock[] + /** + * Present only when producing the summary consumed exactly one call + * through this context's `ctx.llm.stream()`. + */ + llmStreamCall?: true shadowedRange: { start: number; end: number } shadowedSeqs: number[] shadowedTokenCount: number diff --git a/examples/headless-agent/compaction.cordis.snapshot.yml b/examples/headless-agent/compaction.cordis.snapshot.yml index 42fc5306ac..13515cf5cc 100644 --- a/examples/headless-agent/compaction.cordis.snapshot.yml +++ b/examples/headless-agent/compaction.cordis.snapshot.yml @@ -13,7 +13,6 @@ thresholdRatio: 0.99 retainTokens: 20 maxTokens: 32 - compactionRetries: 1 - insert: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl index 855e66ac14..38056019e2 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl @@ -19,7 +19,7 @@ {"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} {"type":"compact/start","seq":19,"time":1786123401715,"data":{"turn":1}} -{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} +{"type":"compact/summary","seq":20,"time":1786123401725,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} {"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"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.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"6d2afb13-a37b-48d6-9ea5-fc8734127377"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} {"type":"compact/end","seq":22,"time":1786123401725,"data":{"turn":1}} {"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl index 4d798adc37..62cc357cc7 100644 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl @@ -18,7 +18,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/start","seq":19,"time":0,"data":{"turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/summary","seq":20,"time":0,"data":{"summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":264,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"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.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compact/end","seq":22,"time":0,"data":{"turn":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index 8c8b85f323..062021a4c1 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/compact/compact-basic/README.md -README.md: 0c7b009255dc2d41dc81cf2c7ff745e02ef28b9a -README.zh.md: 4af584a059c99725882afd6206bdf9c984c7d4e3 +README.md: d1c1dfb509ae0750e1237532a829a35de5084c5e +README.zh.md: a78887daad04d534ffed9cbd0357b76bdd908f5e diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 0c7b009255..d1c1dfb509 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -21,7 +21,7 @@ This backend owns the compaction policy: - **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. - **Failure handling** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after cleanup and durability. -The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`); the transaction preserves those fields on `compact/summary`. +The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the safe summary plus the complete provider output, call envelope, and usage when available (`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`); `llmStreamCall: true` means producing that result consumed exactly one call through this context's `ctx.llm.stream()`, while `rawOutput` alone does not identify the call path. The transaction preserves those fields on `compact/summary`. ## Config (`BasicCompactConfig`) diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index 4af584a059..a78887daad 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -21,7 +21,7 @@ - **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。 - **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。 -受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, provider, model, maxTokens?, usage? }`);事务会在 `compact/summary` 上保留这些字段。 +受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`);`llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,而单有 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。 ## 配置(`BasicCompactConfig`) diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index 2132081557..67516a830d 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -416,6 +416,7 @@ function commitCompactionBody( shadowedTokenCount, summary, rawOutput, + llmStreamCall, provider, model, maxTokens, @@ -425,6 +426,7 @@ function commitCompactionBody( const summaryEvent = session.append('compact/summary', { summary, ...rawOutput === undefined ? {} : { rawOutput }, + ...llmStreamCall === undefined ? {} : { llmStreamCall }, shadowedRange: { start, end }, shadowedSeqs: [...shadowedSeqs], shadowedTokenCount, diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index ba3dad5592..08aa810433 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -87,8 +87,16 @@ export interface SummarizationInput { /** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */ export interface SummaryResult { summary: ContentBlock[] - /** Complete provider output before the text-only summary projection. */ + /** + * Complete provider output before the text-only summary projection; this + * alone does not identify the call path. + */ rawOutput?: ContentBlock[] + /** + * Present only when producing the summary consumed exactly one call through + * this context's `ctx.llm.stream()`. + */ + llmStreamCall?: true provider: string model: string maxTokens?: number @@ -162,6 +170,7 @@ export async function summarizeWithLlm( return { summary, rawOutput, + llmStreamCall: true, provider: options.provider, model: options.model, maxTokens: config.maxTokens, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index a8efad741b..bd04335964 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -868,6 +868,7 @@ describe('compaction region transaction', () => { rawOutput: compact.rawOutput, usage: compact.usage, }) + expect(summary?.data).not.toHaveProperty('llmStreamCall') const head = session.deriveMessages()[0]! expect(head.content[0]?.type).toBe('text') expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('') @@ -1187,6 +1188,7 @@ describe('default one-shot summarizer', () => { { type: 'text', text: 'public summary' }, { type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' }, ], + llmStreamCall: true, provider: MODEL, model: MODEL, maxTokens: 321, @@ -1300,6 +1302,7 @@ describe('default one-shot summarizer', () => { await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL) expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({ summary: [{ type: 'text', text: 'routed summary' }], + llmStreamCall: true, provider: 'routed-summary-provider', model: 'routed-summary-model', }) diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 4a856a6848..4ed12d1e5f 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -28,8 +28,16 @@ declare module '@deepseek-ai/dsh-session' { */ 'compact/summary': { summary: ContentBlock[] - /** Complete provider output before the backend's safe summary projection. */ + /** + * Complete provider output before the backend's safe summary projection; + * this alone does not identify the call path. + */ rawOutput?: ContentBlock[] + /** + * Present only when producing the summary consumed exactly one call + * through this context's `ctx.llm.stream()`. + */ + llmStreamCall?: true shadowedRange: { start: number; end: number } shadowedSeqs: number[] shadowedTokenCount: number diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 3f3e349a84..e120b5007b 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/support/llm-replay/README.md -README.md: 46d391970f320708914d11f0868cbbc5361ae196 -README.zh.md: a67b078a1396968dc3ddecb0e616a832c4faaf3a +README.md: a6c087778b8e64124590fb6be7a652b58e1b6343 +README.zh.md: 241edb9c9b2400519155ccd7161eb7f34b9f5bbe diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 46d391970f..a6c087778b 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -8,7 +8,7 @@ Its consumers are the ACP and headless `stream-json` snapshot suites plus the We ## How the fixture works -The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each agent-loop `stream()` call's chunk sequence. A successful compaction summarizer is logged differently: when `compact/summary` carries its complete `rawOutput`, replay reconstructs a canonical successful stream at that event's position using one `block-start`/`block-end` pair per block, the recorded usage when present, and a terminal `stop`. Exact provider delta partitioning is not part of the durable compaction result. A summary without `rawOutput` does not imply an LLM call because template and remote summarizers may produce it without the local adapter. +The fixture IS the persisted session log (`/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each agent-loop `stream()` call's chunk sequence. A successful compaction summarizer is logged differently: when `compact/summary` carries `llmStreamCall: true` and its complete `rawOutput`, replay reconstructs a canonical successful stream at that event's position using one `block-start`/`block-end` pair per block, the recorded usage when present, and a terminal `stop`. Exact provider delta partitioning is not part of the durable compaction result. `rawOutput` without the marker does not imply a local LLM call because template and remote summarizers may retain complete output without using this context's adapter. Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` and `compact/summary` events plus the line-0 session header. @@ -59,7 +59,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s - `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. - `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and complete compaction outputs in a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and explicitly marked local compaction outputs in a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. - Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape @@ -77,4 +77,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **First-call-order script binding assumes sequential delegation** — a cut that runs sibling subagents concurrently would bind live sessions to recorded scripts non-deterministically; a stronger keying is deferred until such a scenario exists (`XXX(concurrent-subagents)`). -- **Only ordinary loop chunks and completed compaction outputs are derivable** — a pure pre-chunk throw or a cancel/hang scenario needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs. +- **Only ordinary loop chunks and marked local compaction outputs are derivable** — a pure pre-chunk throw, a cancel/hang, or an unmarked external summarizer call needs the `replay.override.json` sidecar. Replacement and patch forms affect only the primary session; child scripts still derive from their logs. diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index a67b078a13..241edb9c9b 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -8,7 +8,7 @@ ## fixture 的工作方式 -fixture 就是持久化的会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 agent-loop `stream()` 调用的分片序列。压缩(compaction)摘要器成功时,日志记录方式有所不同:当 `compact/summary` 携带完整的 `rawOutput` 时,回放会在该事件的位置重建一条规范成功流,其中每个块各使用一对 `block-start`/`block-end`,带上已记录的 usage(如有),并以 `stop` 终止。提供方增量的精确切分不属于持久压缩结果。不带 `rawOutput` 的摘要并不意味着发生了 LLM 调用,因为模板摘要器和远程摘要器可能不经本地适配器生成该摘要。 +fixture 就是持久化的会话日志(`/session.jsonl`)。其 `assistant/chunk` 事件包含每个 `StreamChunk`,因此按 `(turn, step)` 分组即可重建每次 agent-loop `stream()` 调用的分片序列。压缩(compaction)摘要器成功时,日志记录方式有所不同:当 `compact/summary` 携带 `llmStreamCall: true` 和完整的 `rawOutput` 时,回放会在该事件的位置重建一条规范成功流,其中每个块各使用一对 `block-start`/`block-end`,带上已记录的 usage(如有),并以 `stop` 终止。提供方增量的精确切分不属于持久压缩结果。不带该标记的 `rawOutput` 并不意味着发生了本地 LLM 调用,因为模板摘要器和远程摘要器即使未使用此上下文的适配器,也可能保留完整输出。 因此,录制就是「运行一次真实 agent 并收集 `.jsonl`」,由快照 harness 完成;该插件本身不录制。fixture 的 `request/header` 内容可能被标记化为 `{{system}}`/`{{tools}}`(harness 会在一个场景中固定该内容,并清除其余场景中的内容);回放不受影响,因为派生过程只读取 `assistant/chunk` 和 `compact/summary` 事件以及第 0 行的会话 header。 @@ -59,7 +59,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 - `loadSessionScripts(config)`:解析场景中有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 - `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。 -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志中的普通 loop 分片和完整压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志中的普通 loop 分片和显式标记的本地压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。 - 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 @@ -77,4 +77,4 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as ## 已知限制与暂缓事项 - **首次调用顺序脚本绑定假设串行委托**:并发运行同级 subagent 的 cut 会非确定性地将实时会话绑定到已记录脚本;在这种场景出现前暂不实现更强的键控(`XXX(concurrent-subagents)`)。 -- **只有普通 loop 分片和已完成的压缩输出才能派生**:在产生分片前直接抛出异常或取消/挂起的场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。 +- **只有普通 loop 分片和带标记的本地压缩输出才能派生**:在产生分片前直接抛出异常、取消/挂起,或未标记的外部摘要器调用场景需要 `replay.override.json` 伴随文件。替换和补丁两种形式都只影响主会话;子会话脚本仍从各自日志派生。 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 8733a4296c..abed4258af 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -177,8 +177,9 @@ export function parseSessionHeader(text: string): { id: string; createdAt: numbe * Reconstruct the per-`stream()` replay script from a recorded session log. * * Splits `assistant/chunk` events at every `finish`, using turn and step changes - * to detect an unterminated prior call. A complete `compact/summary.rawOutput` - * becomes a canonical successful stream at the summary's log position. A + * to detect an unterminated prior call. A `compact/summary` explicitly marked + * as one local LLM-stream call becomes a canonical successful stream from its + * complete `rawOutput` at the summary's log position. A * missing assistant terminator means the live stream threw, so derivation * rejects and the scenario must provide an explicit override. Multiple calls * may share one turn and step when the loop retries. @@ -204,7 +205,10 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { close(currentKey, current) currentKey = undefined current = [] - if (event.data.rawOutput !== undefined) { + if (event.data.llmStreamCall === true) { + if (event.data.rawOutput === undefined) { + throw new Error('llm-replay: compact/summary marks an LLM stream call without rawOutput') + } const chunks: StreamChunk[] = [] for (const [index, block] of event.data.rawOutput.entries()) { chunks.push({ type: 'block-start', index, blockType: block.type }) diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 9483a4c4f6..7baa6249b9 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -202,6 +202,7 @@ describe('deriveReplayScript', () => { data: { summary: rawOutput, rawOutput, + llmStreamCall: true, shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], shadowedTokenCount: 20, @@ -238,6 +239,45 @@ describe('deriveReplayScript', () => { expect(deriveReplayScript([event])).toEqual([]) }) + it('does not infer a local LLM call from external compact output', () => { + const block = { type: 'text' as const, text: 'remote summary' } + const event: SessionEvent<'compact/summary'> = { + type: 'compact/summary', + seq: 1, + time: 0, + data: { + summary: [block], + rawOutput: [block], + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'remote', + model: 'remote', + }, + } + + expect(deriveReplayScript([event])).toEqual([]) + }) + + it('rejects a marked compact LLM call without its complete output', () => { + const event: SessionEvent<'compact/summary'> = { + type: 'compact/summary', + seq: 1, + time: 0, + data: { + summary: [{ type: 'text', text: 'incomplete provenance' }], + llmStreamCall: true, + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'mock', + model: 'mock', + }, + } + + expect(() => deriveReplayScript([event])).toThrow(/LLM stream call without rawOutput/) + }) + it('derives a compact/summary stream when usage is unavailable', () => { const block = { type: 'text' as const, text: 'summary without usage' } const event: SessionEvent<'compact/summary'> = { @@ -247,6 +287,7 @@ describe('deriveReplayScript', () => { data: { summary: [block], rawOutput: [block], + llmStreamCall: true, shadowedRange: { start: 1, end: 1 }, shadowedSeqs: [1], shadowedTokenCount: 20, @@ -289,6 +330,27 @@ describe('deriveReplayScript', () => { ] expect(() => deriveReplayScript(events)).toThrow(/model call 1\/1 ended without a finish chunk/) }) + + it('rejects an unfinished call at a compact summary boundary', () => { + const events: SessionEvent[] = [ + chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }), + { + type: 'compact/summary', + seq: 2, + time: 0, + data: { + summary: [{ type: 'text', text: 'external checkpoint' }], + shadowedRange: { start: 1, end: 1 }, + shadowedSeqs: [1], + shadowedTokenCount: 20, + provider: 'external', + model: 'external', + }, + }, + ] + + expect(() => deriveReplayScript(events)).toThrow(/model call 1\/1 ended without a finish chunk/) + }) }) describe('loadReplayScript', () => {