Why compaction exists
An agent conversation grows without bound: every durable context message (workspace instructions, time readings, tool results, goal-round prompts) stays in derived history until something removes it. But a model has a finite context window. Compaction is the family of policies that trade an older span of history for one condensed summary checkpoint, so the recent tail and the working surface fit inside the window.
The compaction family lives under packages/compaction/ and is split along the capability-seam pattern: a Service Definition, a concrete backend, an optional model-free pruner, and a human command.
| Package | Role | ctx key |
|---|---|---|
@deepseek-ai/dsh-compaction | Service Definition: abstract CompactionEngine + compaction/* events + CompactionResult | ctx.compaction |
@deepseek-ai/dsh-compaction-basic | Service Provider: token-pressure policy + llm.stream() summarization | registers ctx.compaction |
@deepseek-ai/dsh-compaction-tool-result-pruner | Opt-in model-free tool-result pruning | ctx.toolResultPruner |
@deepseek-ai/dsh-command-compact | The human /compact command over compactNow() | registers on ctx.commands |
The service seam: ctx.compaction
packages/compaction/compaction/src/index.ts defines the abstract CompactionEngine with three abstract operations:
| Member | Semantics |
|---|---|
compactIfNeeded(agent, trigger, signal) | Consider automatic compaction for trigger: 'pressure' | 'context-overflow'; returns a CompactionResult or null when no safe range exists. |
compactNow(agent, signal) | Explicitly compact one useful balanced older span even below automatic pressure; writes nothing when no useful span exists. |
compactRegion(start, end, agent, signal?) | Forcibly summarize surface nodes [start, end] (surface-position span) into one replacement node. Throws if compaction is already in progress or the range is invalid. |
A multi-round summarization request is a direct ctx.llm.stream() call (not a loop step), so per-call interception happens at llm/stream. The compaction/* events extend SessionEventMap — they are session events, not Cordis Events.
Why the seam depends on sessions and llm
Unlike most Service Definitions in this codebase, @deepseek-ai/dsh-compaction deliberately depends on @deepseek-ai/dsh-session and @deepseek-ai/dsh-llm: the contract verbs are defined over a Session, and its output is the ContentBlock vocabulary. This deviation from the "Service Definition depends only on cordis" guidance is intentional and recorded in the compaction capability-seam Agent Note.
What a successful compaction writes
SurfaceEventType is a closed union — only user/message, assistant/message, and tool/result may carry surfaceOp. A compaction/* event therefore cannot appear on the surface. A successful compaction instead:
- appends
compaction/start(log-only) — acquires the lock; - summarizes the range;
- appends
compaction/summary(log-only) with the summary, range, shadowed seqs, token count, and provider/model call envelope; - appends a single
user/messagewhosesourceiscompactCheckpointSource(compactionId, sourceCommandId?)andsurfaceOp: { op: 'replace', start, end }— the only surface mutation; - appends
compaction/end(log-only) — releases the lock.
The mutation sits inside the lock bracket: compaction/end is the last event, so the lock is never released before the surface change lands. A crash between start and end leaves a detectable orphaned lock rather than a falsely-completed one. The shadowed events remain in the raw log, so replay is deterministic, but deriveMessages() renders only the summary plus the retained tail.
The compaction lock is a durable marker
Compaction is serialized by one log-recorded lock shared by all entry points. The lock is the durable bracket (compaction/start with no matching compaction/end), not a WeakSet or a wrapper mutex. Tail inspection finds the latest unmatched compaction/start and the newest session/end-seed: an unmatched start after that boundary is live and reports busy; an older unmatched start is stale evidence from a prior process lifecycle and does not block.
Checkpoint marker
compactCheckpointSource(id) / isCompactCheckpointSource() / CompactionCheckpointSource live on the @deepseek-ai/dsh-compaction/checkpoint subpath (and are re-exported from the root). The constructor requires the owning CompactionId, so backends cannot write an uncorrelated marker. The leaf imports no cordis and declares no module augmentation — which is exactly why a client/wire program can name the checkpoint source while the package root cannot (it would drag in dsh-session's Context merge, TS2717).
The basic backend: token-pressure policy
BasicCompactionEngine in packages/compaction/compaction-basic/src/index.ts is the shipped provider. It owns the compaction policy:
- Measurement —
ctx.tokenMeterprices the latest canonical logged envelope and current surface at one consumed-log revision, so step-boundary pressure includes the real system prompt, tools, routing, buffered context, and steering. - Routed policy — capacity resolves from the adapter that owns the latest durable provider/model route;
modelPoliciesoverlays per-exact-pair overrides. - Model-free pruning — before range selection, the optional
ctx.toolResultPrunerrewrites oversized tool results; compaction-basic then remeasures and skips summarization when pressure becomes safe. - Retention — compact the oldest whole surface units while keeping a recent tail and balanced tool-call/result cuts (via the seam's tool-pairing boundary helpers).
- Convergence — retry head-checkpoint compaction up to
compactionRetries; reject a summary that does not shrink its source. - Summarization — one direct
llm/streamcall that replays the conversation's own system prompt, tools, and shadowed-region messages verbatim (reusing the provider's warm prefix cache) and appends the compaction instruction as the final user message. It setsGenerateOptions.purpose = 'compaction', which the DeepSeek adapter forwards asx-deepseek-harness-compact: 1without touching the body. Only returned text enters the checkpoint — reasoning and tool calls are excluded. - Overflow recovery — provider-confirmed
CONTEXT_WINDOW_EXCEEDEDbypasses normal pressure; it prunes, then attempts one maximal balanced head reduction, authorized to retry wheneversurface.replaceGenerationadvances.
Policy config (BasicCompactionConfig)
Every setting is optional; modelPolicies applies partial overrides to exact { provider, model } pairs.
| Key | Default | Meaning |
|---|---|---|
thresholdRatio | 0.8 | Compact at floor(routedContextWindow × ratio) |
retainRatio | 0.16 | Recent surface kept verbatim as a window fraction (excl. with retainTokens) |
retainTokens | — | Absolute recent-surface budget kept verbatim (excl. with retainRatio) |
summarizationProvider | '' | Summarizer provider; empty pair resolves the latest request target then AgentOptions |
summarizationModel | '' | Summarizer model |
maxTokens | 8192 | Generation cap for the summarization call |
compactionRetries | 1 | Extra attempts when pressure stays above threshold |
maxOverflowRetries | 1 | Max retries after canonical overflow; 0 disables recovery |
modelPolicies | [] | Exact { provider, model, ...partialPolicy } overrides |
auto | true | Register step-boundary pressure + overflow-recovery listeners |
The config-catalog entry for @deepseek-ai/dsh-compaction-basic lives in docs/config-catalog.md with the source pinned at packages/compaction/compaction-basic/src/types.ts.
How compaction triggers
Two triggers reach compactIfNeeded:
- Pressure — a serial
agent/pre-steplistener checks token pressure before request derivation. - Overflow — canonical provider overflow enters through
agent/request-errorand authorizes a retry only after durable surface progress.
The model-facing result is a checkpoint message framed by the preamble plus <compacted-summary>…</compacted-summary> tags:
This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context.
Treat the captured context as established background and build on it without restating it.
Continue the task directly from the messages that follow, without acknowledging this checkpoint.The model-free pruner
ctx.toolResultPruner (@deepseek-ai/dsh-compaction-tool-result-pruner) rewrites over-budget tool/result surface nodes to a bounded head, a fixed omission marker, and a bounded tail — \n\n[... tool result middle pruned ...]\n\n — while retaining the full original in the append-only log. pruneSession(session) replaces each over-budget result with a new tool/result carrying { surfaceOp: { op: 'replace', start, end }, sourceEventSeqs }. Text slicing never splits a UTF-16 surrogate pair. Config:
| Key | Default | Meaning |
|---|---|---|
thresholdChars | 8192 | Prune when combined text exceeds this many Unicode code points |
headChars | 4096 | Leading code points retained |
tailChars | 1024 | Trailing code points retained |
measureContent() counts code points; pruneContent() returns the bounded replacement or null. The second pass therefore emits no replacement. Compact-basic reads it through optional ctx.get('toolResultPruner'), so each package stays independently composable.
The /compact human command
@deepseek-ai/dsh-command-compact registers one global command through ctx.commands, so every composed command adapter discovers and executes it without a model turn. The invoking agent is the exact target, and the UI's cancellation signal is forwarded through the seam.
| Input | Result |
|---|---|
/compact | Summarize one useful balanced older span, then report replaced item count and estimated tokens |
/compact (no history) | No compactable history yet. |
/compact <anything> | Usage: /compact (no arguments) |
Every resolved invocation records the log-only command/run / command/done pair; on success command/done.sourceEventSeq names the compaction/summary event. Expected ManualCompactionError codes (busy · cancelled · changed · summary · commit · persistence) become stable direct errors. /compact is idle-only: it reports busy when a turn already has right of way.
The CompactionResult shape
A successful operation returns a CompactionResult (from packages/compaction/compaction/src/types.ts) that lets callers reconstruct exactly what happened and where:
| Field | Meaning |
|---|---|
compactionId | Stable identity shared by this compaction's complete durable lifecycle (compaction/start … compaction/end) |
sourceCommandId? | The human command that initiated it, when manual |
startSeq / summarySeq / endSeq | Seqs of the three appended compaction/* events |
summary | The summary content blocks produced by the backend |
shadowedRange | The surface-boundary pair that was shadowed — a surface-position span, not a numeric seq interval (after a prior replace lands a fresh high-seq summary node at an older range's position, start can be greater than end) |
shadowedSeqs | The authoritative set of shadowed nodes, in surface order |
shadowedTokenCount | Estimated token count of the shadowed content under the token-meter's fixed estimator |
Callers get both the raw summary and the bookkeeping seqs alongside the shadowed range and token accounting, which is what the Web UI and /compact presentation use to fold the checkpoint into the transcript without parsing summary text.
A trigger-timing timeline
When does compaction actually run? Both triggers dispatch through a single serialized lock, but they enter through different seams:
| Trigger | Entry seam | Notes |
|---|---|---|
pressure | serial agent/pre-step listener | runs before request derivation; only compact if floor(routedContextWindow × thresholdRatio) is exceeded |
context-overflow | agent/request-error listener | canonical provider CONTEXT_WINDOW_EXCEEDED; bypasses normal pressure, prunes, then one maximal balanced head reduction |
agent/pre-step ──► tokenMeter prices current surface
│ under threshold?
▼ no
┌─────────────┐
│ compaction │ lock = compaction/start
│ serialize │ summarize → compaction/summary
│ │ replace surface (user/message)
│ │ compaction/end (release)
└─────────────┘
▼
derivation proceeds with a smaller derived historyA backend summarizes via a direct ctx.llm.stream() call and must forward the operation's signal into GenerateOptions.signal, so an abort or fiber dispose tears down the in-flight summarization. On the compactIfNeeded path, automatic compaction keeps whole-surface equality inside its active turn; the manual compactNow path only revalidates its selected span, so idle injected context between start/end stays visible after the checkpoint.
Compaction summary
The seam defines the what; the backend, pruner, and command supply the how and the when. A successful cycle trades many retained-history tokens for one framed summary checkpoint, in exchange for invalidating KV-cache reuse from the first shadowed token. There is deliberately no model-facing compaction tool — only the human /compact command and programmatic compactNow()/compactRegion() calls.
Further reading
- Context Sources — the durable context messages that compaction eventually shadows.
- Token Meter — the
ctx.tokenMeterservice that prices pressure. - The LLM Layer — where the summarization
llm/streamcall runs and theGenerateOptions.purposeattribution. packages/compaction/compaction/src/types.ts— thecompaction/*event payloads andCompactionResult.docs/config-catalog.md— entries for@deepseek-ai/dsh-compaction-basicand@deepseek-ai/dsh-compaction-tool-result-pruner..agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.mdand2026-07-30-queued-manual-compaction.md— design decisions behind the seam and manual compaction.