Преглед изворни кода

fix(compact): enforce replay provenance

Tianyi Cui пре 1 месец
родитељ
комит
37e4e1585b

+ 2 - 2
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: b17dfc03c62c0453c45ca2810ef90bdce9ae9ac7
-compaction.zh.md: b904cdd3ad5bf63e968fc22fccb32c5220ab8763
+compaction.md: f1df5b83bd43136af60988dabd9dc68fe32467a2
+compaction.zh.md: 52540250d466f81f51cf7c681bb6f1436e29a12d

+ 1 - 1
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?, 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/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()` (which requires complete `rawOutput`), 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); unmarked `rawOutput` 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.

+ 1 - 1
docs/core-data-structures/compaction.zh.md

@@ -13,7 +13,7 @@
 | 事件 | 载荷 | 作用 |
 |---|---|---|
 | `compact/start` | `{ turn }` | 获取日志记录的锁;数字标识打开的自动轮次,`null` 标识独立手动尝试 |
-| `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/summary` | `{ summary, rawOutput?, llmStreamCall?, shadowedRange, shadowedSeqs, shadowedTokenCount, provider, model, maxTokens?, usage? }` | provenance:安全摘要投影、可选的完整 provider 输出与 usage、生成结果时恰好通过此上下文的 `ctx.llm.stream()` 发起一次调用所带的 `llmStreamCall: true` 标记(此时必须提供完整的 `rawOutput`)、被遮蔽的 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`。

+ 16 - 13
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:62`](../packages/compact/compact/src/types.ts)
+Source: [`packages/compact/compact/src/types.ts:65`](../packages/compact/compact/src/types.ts)
 
 #### `compact/prune` — log-only
 
@@ -261,7 +261,7 @@ Source: [`packages/compact/compact/src/types.ts:62`](../packages/compact/compact
 }
 ```
 
-Source: [`packages/compact/compact/src/types.ts:72`](../packages/compact/compact/src/types.ts)
+Source: [`packages/compact/compact/src/types.ts:75`](../packages/compact/compact/src/types.ts)
 
 #### `compact/start` — log-only
 
@@ -290,16 +290,6 @@ 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;
-   * 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
@@ -316,7 +306,20 @@ Source: [`packages/compact/compact/src/types.ts:19`](../packages/compact/compact
   maxTokens?: number
   /** Provider-reported token usage for the summarization request, when emitted. */
   usage?: TokenUsage
-}
+} & (
+  | {
+    /** Complete provider output before the backend's safe summary projection. */
+    rawOutput: ContentBlock[]
+    /** Identifies exactly one call through this context's `ctx.llm.stream()`. */
+    llmStreamCall: true
+  }
+  | {
+    /** Optional complete output from an unmarked template, remote, or other summarizer. */
+    rawOutput?: ContentBlock[]
+    /** An unmarked summary does not identify a call through this context's LLM seam. */
+    llmStreamCall?: never
+  }
+)
 ```
 
 Types: [ContentBlock](core-data-structures/core.md) · [TokenUsage](core-data-structures/llm-streaming.md)

+ 2 - 2
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: d1c1dfb509ae0750e1237532a829a35de5084c5e
-README.zh.md: a78887daad04d534ffed9cbd0357b76bdd908f5e
+README.md: 4241899788998a744801bb0406a15ecd70af6401
+README.zh.md: c1df4afaa1837a3b689da80cc1b49df43f60e513

+ 1 - 1
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?, 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`.
+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()` and requires complete `rawOutput`, while unmarked `rawOutput` does not identify the call path. The transaction preserves those fields on `compact/summary`.
 
 ## Config (`BasicCompactConfig`)
 

+ 1 - 1
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?, llmStreamCall?, provider, model, maxTokens?, usage? }`);`llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,而单有 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
+受保护的 `summarize()` 方法是唯一的子类钩子。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍由 `ctx.tokenMeter` 负责。钩子返回安全摘要,以及完整提供方输出、调用 envelope 和可用时的 usage(`{ summary, rawOutput?, llmStreamCall?, provider, model, maxTokens?, usage? }`);`llmStreamCall: true` 表示生成该结果时恰好通过此上下文的 `ctx.llm.stream()` 发起了一次调用,且必须提供完整的 `rawOutput`;未带标记的 `rawOutput` 并不能判定调用路径。事务会在 `compact/summary` 上保留这些字段。
 
 ## 配置(`BasicCompactConfig`)
 

+ 5 - 5
packages/compact/compact-basic/src/region.ts

@@ -43,7 +43,7 @@ interface PreparedCompaction extends SurfaceSelection {
   readonly input: SummarizationInput
 }
 
-interface SummarizedCompaction extends PreparedCompaction, SummaryResult {
+type SummarizedCompaction = PreparedCompaction & SummaryResult & {
   readonly checkpointMessage: UserMessage
 }
 
@@ -415,18 +415,18 @@ function commitCompactionBody(
     shadowedSeqs,
     shadowedTokenCount,
     summary,
-    rawOutput,
-    llmStreamCall,
     provider,
     model,
     maxTokens,
     usage,
     checkpointMessage,
   } = summarized
+  const callProvenance = summarized.llmStreamCall === true
+    ? { rawOutput: summarized.rawOutput, llmStreamCall: true as const }
+    : summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput }
   const summaryEvent = session.append('compact/summary', {
     summary,
-    ...rawOutput === undefined ? {} : { rawOutput },
-    ...llmStreamCall === undefined ? {} : { llmStreamCall },
+    ...callProvenance,
     shadowedRange: { start, end },
     shadowedSeqs: [...shadowedSeqs],
     shadowedTokenCount,

+ 15 - 12
packages/compact/compact-basic/src/summarizer.ts

@@ -85,24 +85,27 @@ export interface SummarizationInput {
 }
 
 /** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
-export interface SummaryResult {
+export type SummaryResult = {
   summary: ContentBlock[]
-  /**
-   * 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
   /** Provider-reported usage for this summarization request. */
   usage?: TokenUsage
-}
+} & (
+  | {
+    /** Complete provider output before the text-only summary projection. */
+    rawOutput: ContentBlock[]
+    /** Identifies exactly one call through this context's `ctx.llm.stream()`. */
+    llmStreamCall: true
+  }
+  | {
+    /** Optional complete output from an unmarked template, remote, or other summarizer. */
+    rawOutput?: ContentBlock[]
+    /** An unmarked result does not identify a call through this context's LLM seam. */
+    llmStreamCall?: never
+  }
+)
 
 /**
  * Run the default cache-reusing `ctx.llm.stream()` summarization call: replay

+ 11 - 2
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -1,9 +1,9 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, expectTypeOf, it, vi } from 'vitest'
 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 type { SummarizationInput, SummaryResult } from '@deepseek-ai/dsh-compact-basic/src/summarizer.ts'
 import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
 import {
   resolveCompactSpec,
@@ -1166,6 +1166,15 @@ async function summarizerHarness(
 }
 
 describe('default one-shot summarizer', () => {
+  it('requires complete raw output when a subclass marks one local LLM stream call', () => {
+    expectTypeOf<{
+      summary: ContentBlock[]
+      llmStreamCall: true
+      provider: string
+      model: string
+    }>().not.toExtend<SummaryResult>()
+  })
+
   it('uses configured model/default cap, forwards cancellation, and keeps only safe text', async () => {
     const { adapter, compact } = await summarizerHarness([
       { type: 'reasoning', text: 'private' },

+ 14 - 11
packages/compact/compact/src/types.ts

@@ -28,16 +28,6 @@ declare module '@deepseek-ai/dsh-session' {
      */
     'compact/summary': {
       summary: ContentBlock[]
-      /**
-       * 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
@@ -54,7 +44,20 @@ declare module '@deepseek-ai/dsh-session' {
       maxTokens?: number
       /** Provider-reported token usage for the summarization request, when emitted. */
       usage?: TokenUsage
-    }
+    } & (
+      | {
+        /** Complete provider output before the backend's safe summary projection. */
+        rawOutput: ContentBlock[]
+        /** Identifies exactly one call through this context's `ctx.llm.stream()`. */
+        llmStreamCall: true
+      }
+      | {
+        /** Optional complete output from an unmarked template, remote, or other summarizer. */
+        rawOutput?: ContentBlock[]
+        /** An unmarked summary does not identify a call through this context's LLM seam. */
+        llmStreamCall?: never
+      }
+    )
     /**
      * Marks the end of a compaction — log-only, releases the lock. Its owner
      * matches `compact/start`; `error` records an unsuccessful attempt.

+ 18 - 9
packages/support/llm-replay/src/index.ts

@@ -1,7 +1,7 @@
 /**
  * Keyless snapshot-test LLM replay. It derives one model-call script per
- * recorded session from `assistant/chunk` events and durable compaction
- * summaries, then binds fresh live sessions to parent/child scripts by
+ * recorded session from `assistant/chunk` events and explicitly marked local
+ * compaction calls, then binds fresh live sessions to parent/child scripts by
  * first-call order. Throw and hang cases require an explicit override because
  * a session log cannot reconstruct them alone.
  * @module @deepseek-ai/dsh-llm-replay
@@ -14,6 +14,7 @@ import type {} from '@deepseek-ai/dsh-compact'
 import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type {
+  ContentBlock,
   GenerateOptions,
   LlmModelInfo,
   LlmProviderInfo,
@@ -21,14 +22,15 @@ import type {
   ResolvedRetryPolicy,
   RetryPolicyConfig,
   StreamChunk,
+  TokenUsage,
 } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter, LlmError, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
 
 /**
  * One recorded model call. `throw` may replay prefix chunks before failing;
- * `hang` models cancellation. Chunk entries derive from ordinary model streams
- * and complete compaction outputs in JSONL; the other variants come from an
- * override sidecar.
+ * `hang` models cancellation. Derived chunk entries come from ordinary model
+ * streams and complete outputs of explicitly marked local compaction calls;
+ * an override sidecar can supply any variant.
  */
 export type ReplayEntry =
   | { kind: 'chunks'; chunks: StreamChunk[] }
@@ -205,16 +207,23 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] {
       close(currentKey, current)
       currentKey = undefined
       current = []
-      if (event.data.llmStreamCall === true) {
-        if (event.data.rawOutput === undefined) {
+      // JSONL decoding crosses an untyped durable boundary, so retain its wider
+      // shape even though current in-process producers enforce this correlation.
+      const persisted: {
+        readonly llmStreamCall?: true
+        readonly rawOutput?: ContentBlock[]
+        readonly usage?: TokenUsage
+      } = event.data
+      if (persisted.llmStreamCall === true) {
+        if (persisted.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()) {
+        for (const [index, block] of persisted.rawOutput.entries()) {
           chunks.push({ type: 'block-start', index, blockType: block.type })
           chunks.push({ type: 'block-end', index, block })
         }
-        if (event.data.usage !== undefined) chunks.push({ type: 'usage', usage: event.data.usage })
+        if (persisted.usage !== undefined) chunks.push({ type: 'usage', usage: persisted.usage })
         chunks.push({ type: 'finish', reason: { kind: 'stop' } })
         script.push({ kind: 'chunks', chunks })
       }

+ 20 - 16
packages/support/llm-replay/tests/llm-replay.spec.ts

@@ -259,23 +259,27 @@ describe('deriveReplayScript', () => {
     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',
-      },
-    }
+  it('rejects a persisted marked compact LLM call without its complete output', () => {
+    const [event] = parseSessionLog([
+      JSON.stringify({ type: 'session', version: 0, id: 'invalid-compact', createdAt: 0 }),
+      JSON.stringify({
+        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',
+        },
+      }),
+    ].join('\n'))
 
-    expect(() => deriveReplayScript([event])).toThrow(/LLM stream call without rawOutput/)
+    expect(() => deriveReplayScript(event === undefined ? [] : [event]))
+      .toThrow(/LLM stream call without rawOutput/)
   })
 
   it('derives a compact/summary stream when usage is unavailable', () => {