From 7539231ecaf4bd5cfa4ca99fdbeb3c69f70fd881 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:21:29 +0800 Subject: [PATCH] compact: the summary's provenance records its call envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compact/summary gains { model, maxTokens? } — the envelope the summarize call actually used, reported by the backend that made the call: summarize() now returns { summary, model, maxTokens? } instead of bare blocks, so an overriding backend (template or remote summarizer) reports its own envelope honestly and compactRegion logs it. 'Which model wrote this summary' becomes answerable from the log alone, and the one-shot summarize request — outside the loop's header-event fold by design — is reconstructable from log + code (the reconstructability RFC's scope statement). --- docs/core-data-structures/compaction.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/persistence-catalog/log-events.md | 4 ++-- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/src/index.ts | 19 ++++++++++++++++--- .../compact-basic/tests/compact-basic.spec.ts | 15 +++++++++++---- .../tests/compact-loop-repro.spec.ts | 4 ++-- packages/compact/compact/src/types.ts | 9 +++++++++ .../compact/compact/tests/compact.spec.ts | 1 + 9 files changed, 44 insertions(+), 14 deletions(-) diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 5023d14e93..78eb48d38e 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -11,7 +11,7 @@ Compaction extends [`SessionEventMap`](session.md) with three event types via de | Event | Payload | Role | |---|---|---| | `compact/start` | `{ turn }` | acquires the log-recorded lock | -| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | provenance: the summary blocks, the shadowed surface-boundary pair (`start`/`end` seqs — a position span, not a numeric interval), the shadowed seqs in surface order, and the estimated token count | +| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount, model, maxTokens? }` | provenance: the summary blocks, 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 (`model`, plus its generation cap when one applied) — logged so the one-shot request is reconstructable from log + code (the reconstructability RFC) | | `compact/end` | `{ turn, error? }` | releases the lock (`error` set when summarization threw) | 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/event-producer-consumer.md b/docs/event-producer-consumer.md index 9df8a34ff8..13269b2a24 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:35`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/support/llm-replay) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:35`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/persistence-catalog/log-events.md b/docs/persistence-catalog/log-events.md index 0b8d4837da..5d6f23f6a8 100644 --- a/docs/persistence-catalog/log-events.md +++ b/docs/persistence-catalog/log-events.md @@ -47,7 +47,7 @@ Marks the end of a compaction — log-only, releases the lock. `error` set if su 'compact/end': { turn: number; error?: string } ``` -Source: [`packages/compact/compact/src/types.ts:37`](../../packages/compact/compact/src/types.ts) +Source: [`packages/compact/compact/src/types.ts:46`](../../packages/compact/compact/src/types.ts) #### `compact/start` — log-only @@ -64,7 +64,7 @@ Source: [`packages/compact/compact/src/types.ts:23`](../../packages/compact/comp Provenance record of a completed summarization — log-only, no surfaceOp. The summary content is in `data.summary`; the actual surface replacement is performed by a subsequent `user/message` event that shadows the compacted range. ```ts persistence-catalog -'compact/summary': { summary: ContentBlock[]; shadowedRange: { start: number; end: number }; shadowedSeqs: number[]; shadowedTokenCount: number } +'compact/summary': { summary: ContentBlock[]; shadowedRange: { start: number; end: number }; shadowedSeqs: number[]; shadowedTokenCount: number; model: string; maxTokens?: number } ``` Types: [ContentBlock](../core-data-structures/core.md) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 65baeb4451..87ad90d57f 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -17,7 +17,7 @@ The abstract contract states only WHAT compaction does; this backend owns every - **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface. - **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`. -`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. +`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly. ## Config (`BasicCompactConfig`) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index d4a993471e..86481175c9 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -287,8 +287,15 @@ export class BasicCompactService extends CompactService { * * Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears * down the in-flight summarization rather than orphaning the model call. + * + * Returns the summary blocks TOGETHER with the call envelope it actually + * used (`model`, `maxTokens`) — the caller logs the envelope on the + * `compact/summary` provenance event, so an overriding subclass (template + * or remote summarizer) reports its own envelope honestly. */ - async summarize(text: string, agent: Agent, signal?: AbortSignal): Promise { + async summarize( + text: string, agent: Agent, signal?: AbortSignal, + ): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> { const assembler = new BlockAssembler() const options: GenerateOptions = { model: this.config.summarizationModel || agent.options.model || '', @@ -318,7 +325,11 @@ export class BasicCompactService extends CompactService { throw new Error('summarization produced no text summary content') } - return summary + return { + summary, + model: options.model, + ...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}, + } } // ---- Core API (implements the abstract contract) ---- @@ -449,7 +460,7 @@ export class BasicCompactService extends CompactService { try { // --- Extract text and summarize --- const text = this._extractText(session, shadowedSeqs) - const summary = await this.summarize(text, agent, signal) + const { summary, model, maxTokens } = await this.summarize(text, agent, signal) // Estimate token count of the shadowed content for provenance. let shadowedTokenCount = 0 @@ -471,6 +482,8 @@ export class BasicCompactService extends CompactService { shadowedRange: { start, end }, shadowedSeqs, shadowedTokenCount, + model, + ...maxTokens !== undefined ? { maxTokens } : {}, }) // --- Surface replacement --- diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index d00b093493..990d60190a 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -58,13 +58,13 @@ class TestCompactService extends BasicCompactService { return blocks.length * 10 } - override async summarize(text: string, agent: Agent): Promise { + override async summarize(text: string, agent: Agent): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> { const model = this.config.summarizationModel || agent.options.model || '' this.summarizeCalls.push({ text, model }) if (this.summarizeError) throw this.summarizeError const summary = this.mockSummaryQueue.shift() ?? this.mockSummary this.summaryOutputs.add(summary) - return summary + return { summary, model } } } @@ -402,6 +402,9 @@ describe('BasicCompactService.compactRegion', () => { expect(startEvent).toBeDefined() expect(summaryEvent).toBeDefined() expect(endEvent).toBeDefined() + // The provenance record carries the summarize call's envelope, so "which + // model wrote this summary" is answerable from the log alone. + expect(summaryEvent?.type === 'compact/summary' && summaryEvent.data.model).toBe('test-model') // compact/* events are log-only — no surfaceOp (type system enforces this). const startRaw = startEvent as unknown as { surfaceOp?: unknown } @@ -1003,8 +1006,12 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') const svc = new BasicCompactService(ctx, cfg({ auto: false, maxTokens: 512 })) - const summary = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') + const { summary, model, maxTokens } = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) + // The returned envelope reports what the call actually used — the caller + // logs it on compact/summary (the reconstructability RFC). + expect(model).toBe('test-model') + expect(maxTokens).toBe(512) // The fixed system prompt and maxTokens flow through. expect(adapter.lastOptions!.system).toContain('compaction engine') expect(adapter.lastOptions!.system).toContain('## Next Step') @@ -1035,7 +1042,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { ]) const svc = new BasicCompactService(ctx, cfg({ auto: false })) - const summary = await summarize(svc, 'User: hi', 'test-model') + const { summary } = await summarize(svc, 'User: hi', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'PUBLIC SUMMARY' }]) }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 319e30a73c..f2ca3ccdd0 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -41,8 +41,8 @@ class ReproCompactService extends BasicCompactService { return blocks.length * TOKENS_PER_BLOCK } - override async summarize(): Promise { - return [{ type: 'text', text: 'CHECKPOINT SUMMARY' }] + override async summarize(): Promise<{ summary: ContentBlock[]; model: string }> { + return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], model: 'stub' } } } diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index ba886685d5..ba9834910f 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -32,6 +32,15 @@ declare module '@deepseek-ai/dsh-session' { shadowedRange: { start: number; end: number } shadowedSeqs: number[] shadowedTokenCount: number + /** + * The model that wrote the summary — the summarize call's envelope, + * reported by the backend that made the call, logged so the one-shot + * request is reconstructable from log + code and "which model wrote + * this summary" has a durable answer (the reconstructability RFC). + */ + model: string + /** The generation cap the summarize call sent, when one applied. */ + maxTokens?: number } /** Marks the end of a compaction — log-only, releases the lock. `error` set if summarization failed. */ 'compact/end': { turn: number; error?: string } diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 8729ad7ab8..93c4e806ce 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -39,6 +39,7 @@ class StubCompactService extends CompactService { shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, + model: 'stub', }) const endEvent = session.append('compact/end', { turn: 0 }) return {