diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 94a9857397..a9374b9cfd 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -13,7 +13,7 @@ This backend owns the compaction policy: - **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. -- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. +- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.compact`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. - **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. diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index cf34c2ad91..51adc19c60 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -138,6 +138,7 @@ export async function summarizeWithLlm( ...input.tools === undefined ? {} : { tools: [...input.tools] }, maxTokens: config.maxTokens, sessionId: agent.session.id, + compact: true, ...signal === undefined ? {} : { signal }, } for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index f4e451dcc3..97d0127465 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1094,6 +1094,7 @@ describe('default one-shot summarizer', () => { maxTokens: 321, signal: SIGNAL, sessionId: session.id, + compact: true, }) const instruction = adapter.lastOptions?.messages.at(-1)?.content[0] expect(instruction?.type === 'text' ? instruction.text : '').toContain('## Primary Request and Intent') diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index ad7916f918..1a392671d6 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -38,7 +38,7 @@ The plugin registers the single provider route `deepseek`. A request selects it ## App attribution -Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. +Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request with `GenerateOptions.compact` set (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests. ## Wire-format notes (verified live + against the official docs) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 237751fc39..976346104e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -182,6 +182,9 @@ export class DeepSeekAdapter extends LlmAdapter { ...options.sessionId !== undefined ? { 'x-deepseek-harness-session-id': String(options.sessionId) } : {}, + ...options.compact === true + ? { 'x-deepseek-harness-compact': '1' } + : {}, } // TODO(http): adopt the Cordis HTTP service when shared transport configuration diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ffceaacd2d..60c3282c3c 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -129,6 +129,8 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]).not.toHaveProperty('http-referer') expect(server.headers[0]).not.toHaveProperty('x-openrouter-title') expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories') + // A conversation request carries no compaction marker. + expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') }) it('streams raw chunks through ctx.llm.stream', async () => { @@ -159,6 +161,19 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session') }) + it('marks the auxiliary compaction call on the wire', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const ctx = await harness(server.url) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + compact: true, + }) + + expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') + }) + it('forwards thinking config onto the wire', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' }) diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 0cb8fa6935..797db4a10d 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -226,4 +226,11 @@ export interface GenerateOptions { * it; replay uses it to keep concurrent parent and child cursors independent. */ sessionId?: Branded<'SessionId'> + /** + * Marks the auxiliary compaction (summarization) call. The DeepSeek adapter + * forwards it as the `x-deepseek-harness-compact: 1` request header so the + * host can separate compaction traffic from conversation requests; it never + * enters the model-visible request body. Loop-built requests leave it unset. + */ + compact?: boolean }