fix(compact): enforce replay provenance

This commit is contained in:
Tianyi Cui
2026-08-08 15:28:35 +08:00
parent 8dcfe5c406
commit 37e4e1585b
13 changed files with 107 additions and 76 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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 })
}
@@ -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', () => {