Files
deepseek-harness/packages/compact/compact
Yichen Jiang 815365dbbe Merge remote-tracking branch 'origin/master' into worktree/provider-routed-llm-adapters
# Conflicts:
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/event-producer-consumer.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl
#	examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/text-turn/session.jsonl
#	examples/sandbox-acp-agent/cordis.yml
#	examples/sandbox-acp-agent/tests/snapshots/escalation-approved/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/escalation-rejected/session.jsonl
#	examples/sandbox-acp-agent/tests/snapshots/mode-switching/session.jsonl
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/properties.spec.ts
#	packages/core/session/README.md
#	packages/core/session/src/types.ts
#	packages/core/session/tests/derived-cache.spec.ts
#	packages/llm/llm-deepseek/src/index.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/convert.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/call-config.ts
#	packages/llm/llm/src/index.ts
#	packages/ui/acp-agent/src/index.ts
#	packages/ui/acp/tests/harness.ts
#	packages/ui/jsonrpc/README.md
#	packages/ui/jsonrpc/src/server.ts
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/src/index.ts
#	python/sdk/README.i18n.yaml
2026-07-14 22:17:50 +08:00
..

@deepseek-ai/dsh-compact

The compaction seam: an abstract CompactService (ctx.compact) defining WHAT compaction does — decide when history is too large and summarize an older range into a single surface node — without saying HOW.

This package is the interface tier of the compaction capability, split so each concern evolves (and swaps) independently:

Package Role
@deepseek-ai/dsh-compact (this) the interface: abstract service + compact/* events + CompactionResult + the shared transcript renderer (renderTranscript/renderContentBlocks)
@deepseek-ai/dsh-compact-basic a backend: chars-per-token estimation (charsPerToken, default 4) + token-budget retention + llm.stream() summarization
@deepseek-ai/dsh-tool-compact (deferred) the model-facing /compact tool over ctx.compact

Unlike the bash seam, this interface depends on @deepseek-ai/dsh-session and @deepseek-ai/dsh-llm — the contract's verbs are defined over a Session and its output is the ContentBlock vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the compaction capability-seam RFC.

Service API (ctx.compact)

Both methods are abstract — the backend owns the entire strategy (token estimation, retention policy, event sequencing, summarization).

Member Semantics
compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) Estimate the surface-derived history size; if over the backend's threshold, compact an older range via compactRegion, keeping recent context intact. Returns the CompactionResult, or null if nothing needed compacting. All parameters required — the loop's agent/pre-step checkpoint supplies the agent, assembled fullSystemPrompt, composed sessionPrefix (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn signal. A backend's summarization request is a direct ctx.llm.stream() call (not a loop step), so per-call interception happens at llm/stream.
compactRegion(session, start, end, agent, signal?) Forcibly summarize surface nodes [start, end] (inclusive seqs) into a single replacement node. Throws if a compaction is already in progress, if start/end aren't surface nodes, or if start is positioned after end on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order.

compactIfNeeded takes a required signal; compactRegion's is optional. A backend that summarizes via ctx.llm.stream() must forward it into the call's GenerateOptions.signal, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the compact/* events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.

Surface contract

SurfaceEventType is a closed union — only user/message, assistant/message, tool/result, context/message, and steering/message may carry surfaceOp. A compact/* event therefore cannot appear on the surface. A successful compaction instead:

  1. appends compact/start (log-only) — acquires the lock,
  2. summarizes the range,
  3. appends compact/summary (log-only) — provenance: summary, range, shadowed seqs, token count, and provider/model call envelope,
  4. appends a single user/message with surfaceOp: { op: 'replace', start, end } carrying the summary — the only surface mutation,
  5. appends compact/end (log-only) — releases the lock.

The surface mutation (step 4) sits inside the lock bracket: compact/end is the last event, so the lock is never released before the mutation lands. A crash between compact/start and compact/end therefore leaves a detectable orphaned lock (a compact/start with no matching compact/end) rather than a compact/end that falsely claims compaction finished while the surface was never shadowed.

deriveMessages() then renders the summary as a user-role message followed by the retained nodes. The shadowed events remain in the raw log, so replay is deterministic.

Blocking

Compaction is serialized via a log-recorded lock: compactRegion refuses to start if the last compact/start has no matching compact/end after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned compact/start on reload. The lock brackets the whole operation — summarization, the compact/summary provenance record, and the user/message surface replacement all happen before compact/end — so a session/event listener firing on compact/end never observes the lock free while the surface mutation is still pending. compact/end is appended even when summarization throws, so a failure can never wedge the lock.

Events

The compact/* events extend SessionEventMap (merge-extensible) via declaration merging — they are session events, not cordis Events, and all three are log-only (no surfaceOp). Per-event payloads and semantics are in the generated persistence log event catalog.

Implementing a backend

Subclass CompactService, implement compactIfNeeded and compactRegion, and load the subclass as a plugin — it registers as ctx.compact. A tokenizer-, template-, or model-backed implementation can live as a sibling package without changing callers.

Model Experience

Conversation history, when a backend is invoked

What the model sees: A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.

Token effect: Zero direct tokens from this interface. A backend trades many retained history tokens for one summary and leaves the recent tail unchanged.

Transcript supplied to a compaction consumer

What the model sees: renderTranscript() joins entries with one blank line and renders them exactly as User: <content>, Assistant: <content>, Tool result (call <callId>): <content>, Tool error (call <callId>): <content>, [Context: <content>], or [Steering: <content>]. Non-text blocks render exactly as [reasoning: <text>], [tool-call: <name>(<arguments>)], [tool-result: <content>], [tool-result], or [<block-type>].

Token effect: Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript.

Known Limitations and Deferred Work

  • No model-facing consumer tier yet@deepseek-ai/dsh-tool-compact (the /compact tool) is deferred; compaction is reachable only via direct ctx.compact calls or a backend's auto listener.
  • Single-unit overflow is out of contract — one retained unit (a closed step or a large pasted user/message) alone exceeding the budget cannot be compacted; the call may go out over-budget.
  • A session prefix that alone approaches the window is a configuration error no backend fixes — compaction shrinks derived history, never the prefix.
  • Request context injected by downstream agent/request listeners sits outside pressure accountingcompactIfNeeded counts prefix, derived history, and system prompt only.