Skip to content

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.

PackageRolectx key
@deepseek-ai/dsh-compactionService Definition: abstract CompactionEngine + compaction/* events + CompactionResultctx.compaction
@deepseek-ai/dsh-compaction-basicService Provider: token-pressure policy + llm.stream() summarizationregisters ctx.compaction
@deepseek-ai/dsh-compaction-tool-result-prunerOpt-in model-free tool-result pruningctx.toolResultPruner
@deepseek-ai/dsh-command-compactThe 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:

MemberSemantics
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:

  1. appends compaction/start (log-only) — acquires the lock;
  2. summarizes the range;
  3. appends compaction/summary (log-only) with the summary, range, shadowed seqs, token count, and provider/model call envelope;
  4. appends a single user/message whose source is compactCheckpointSource(compactionId, sourceCommandId?) and surfaceOp: { op: 'replace', start, end }the only surface mutation;
  5. 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:

  • Measurementctx.tokenMeter prices 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; modelPolicies overlays per-exact-pair overrides.
  • Model-free pruning — before range selection, the optional ctx.toolResultPruner rewrites 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/stream call 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 sets GenerateOptions.purpose = 'compaction', which the DeepSeek adapter forwards as x-deepseek-harness-compact: 1 without touching the body. Only returned text enters the checkpoint — reasoning and tool calls are excluded.
  • Overflow recovery — provider-confirmed CONTEXT_WINDOW_EXCEEDED bypasses normal pressure; it prunes, then attempts one maximal balanced head reduction, authorized to retry whenever surface.replaceGeneration advances.

Policy config (BasicCompactionConfig)

Every setting is optional; modelPolicies applies partial overrides to exact { provider, model } pairs.

KeyDefaultMeaning
thresholdRatio0.8Compact at floor(routedContextWindow × ratio)
retainRatio0.16Recent surface kept verbatim as a window fraction (excl. with retainTokens)
retainTokensAbsolute recent-surface budget kept verbatim (excl. with retainRatio)
summarizationProvider''Summarizer provider; empty pair resolves the latest request target then AgentOptions
summarizationModel''Summarizer model
maxTokens8192Generation cap for the summarization call
compactionRetries1Extra attempts when pressure stays above threshold
maxOverflowRetries1Max retries after canonical overflow; 0 disables recovery
modelPolicies[]Exact { provider, model, ...partialPolicy } overrides
autotrueRegister 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:

  1. Pressure — a serial agent/pre-step listener checks token pressure before request derivation.
  2. Overflow — canonical provider overflow enters through agent/request-error and 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:

text
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:

KeyDefaultMeaning
thresholdChars8192Prune when combined text exceeds this many Unicode code points
headChars4096Leading code points retained
tailChars1024Trailing 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.

InputResult
/compactSummarize 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:

FieldMeaning
compactionIdStable identity shared by this compaction's complete durable lifecycle (compaction/startcompaction/end)
sourceCommandId?The human command that initiated it, when manual
startSeq / summarySeq / endSeqSeqs of the three appended compaction/* events
summaryThe summary content blocks produced by the backend
shadowedRangeThe 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)
shadowedSeqsThe authoritative set of shadowed nodes, in surface order
shadowedTokenCountEstimated 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:

TriggerEntry seamNotes
pressureserial agent/pre-step listenerruns before request derivation; only compact if floor(routedContextWindow × thresholdRatio) is exceeded
context-overflowagent/request-error listenercanonical provider CONTEXT_WINDOW_EXCEEDED; bypasses normal pressure, prunes, then one maximal balanced head reduction
text
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 history

A 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.tokenMeter service that prices pressure.
  • The LLM Layer — where the summarization llm/stream call runs and the GenerateOptions.purpose attribution.
  • packages/compaction/compaction/src/types.ts — the compaction/* event payloads and CompactionResult.
  • docs/config-catalog.md — entries for @deepseek-ai/dsh-compaction-basic and @deepseek-ai/dsh-compaction-tool-result-pruner.
  • .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md and 2026-07-30-queued-manual-compaction.md — design decisions behind the seam and manual compaction.