Files
deepseek-harness/packages/compact/compact-basic
Hypatia May d6da8ca29a fix(compact): decide step-alignment from surface tool-pairing, fire compaction pre-step (CBR-001)
Codex round 1 CBR-001: a head-anchored compaction checkpoint was
mis-classified by the log-position step-alignment scan, so a second
auto-compaction over a checkpoint-headed surface silently failed.

Root cause: `isStepAlignedStart/End` scanned the LOG by seq, but a
`replace` op lands a checkpoint at a high log seq whose SURFACE position
is the head — its log neighbours (the open step's assistant/message) are
not its surface neighbours, so the forward scan wrongly reported mid-step.

Fix, per the agreed direction:
- Replace the two log-position predicates with one surface-anchored
  helper `isToolPairingBalanced(nodes, events, beforeSeq)` in
  `dsh-session` (renamed step-boundary.ts → tool-pairing.ts). A cut is
  balanced when no unanswered tool-call precedes it on the surface; a
  region is collapsible iff both edges are balanced cuts. The open-tail
  and free-node cases fall out of the same counter. It also throws on a
  corrupt surface (a tool/result with no matching call).
- Move compaction off the in-step seam to a new "pre-step" seam fired
  after turn/start and before step/start, so a compaction's log-only
  compact/* records and its replacement node land cleanly OUTSIDE any
  step (the honest structure crash-safety relies on). Renamed the event
  agent/pre-request → agent/pre-step and switched its dispatch from
  parallel → serial (listeners mutate the surface as a side effect;
  serial isolates them so concurrent appends can't interleave). Extended
  the catalog generator to accept @mode serial.

Regression coverage: a real-loop test driving an auto-compaction asserts
the landed checkpoint is a balanced cut on both sides; unit tests pin the
checkpoint case, the mid-step injection case, multi-call steps, and the
corrupt-surface guard. Proven red on the old log-position logic.
2026-06-26 13:51:01 +08:00
..

@deepseek-ai/dsh-compact-basic

The basic compaction backend: a BasicCompactService implementing the @deepseek-ai/dsh-compact seam with a char/4 token heuristic, token-budget retention, and ctx.llm.stream() summarization.

This is the implementation tier of the compaction capability — see the interface package for the seam and the capability-seam RFC for the design.

What it owns

The abstract contract states only WHAT compaction does; this backend owns every HOW decision:

  • Token estimationestimateContentTokens(): char/4 with per-block structural overhead (text/reasoning = ceil(len/4) + 4, tool-call from name + arguments, tool-result recursive, image = 85, unknown blocks via JSON length).
  • Retention policycompactIfNeeded() walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step user/message or inter-step steering/message) whose total reaches retainTokens; everything older is compacted. Retention is turn-agnostic — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is step-alignment: the compacted region always ends on a step boundary, so it never splits a step's assistant/message tool-calls from their tool/results. When the only compactable content left is an un-splittable open tail step, it declines (returns null) and retries once an older step closes. Single-unit overflow is out of scope, by design: if one retained unit (a single closed step, or a large pasted user/message) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. compactRegion() enforces step-alignment strictly, throwing on a boundary that would split a step.
  • Single-pass convergenceresolveConfig() rejects (throws) any config where summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction.
  • Summarizationsummarize(): a ctx.llm.stream() call assembled via BlockAssembler (the single model-call surface) with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder ([image], [tool-call: name(args)], …) so the summarizer is told what existed rather than silently dropping it.
  • Checkpoint framing — the raw summary is not landed directly. compactRegion() wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus <compacted-summary>…</compacted-summary> tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the compact/summary provenance event.
  • Surface mutationcompactRegion() appends the compact/startcompact/summarycompact/end log records and the single user/message replace node carrying the framed summary (see the interface README).
  • Auto-compaction — an agent/pre-request listener delegates to compactIfNeeded() before every model call (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-request is an awaited surface-mutation checkpoint that fires BEFORE the loop derives the request history, so compaction mutates the surface 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()).
  • 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.

Config (BasicCompactConfig)

Key Default Meaning
contextWindow 128000 Context window size in tokens.
thresholdRatio 0.8 Compact when estimated usage exceeds this fraction of the window.
retainTokens 20480 Tokens of recent context to keep intact.
summarizationModel '' Model for summarization (empty → use the agent's model).
summarizationMaxTokens 2048 Max tokens for the summary response.
auto true Register the agent/pre-request auto-compaction listener. Set false for manual-only.

Usage

import type { Context } from 'cordis'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'

export const name = 'compact-basic'
export const inject = ['llm']

export function apply(ctx: Context): void {
  ctx.plugin(BasicCompactService, { contextWindow: 128000, retainTokens: 20480 })
}

Loading the plugin registers ctx.compact. With auto: true (the default) it compacts automatically under token pressure; a consumer (a future /compact tool) can also call ctx.compact.compactIfNeeded(...) or ctx.compact.compactRegion(...) directly.