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 {