Codex round 1 CBR-002: `resolveConfig` rejected only `summarizationMaxTokens + retainTokens > threshold` (allowing equality), but `compactIfNeeded` declines only when the estimate is `< threshold`. At exact equality the post-compaction history sits at the threshold and re-triggers on the very next check. Make the bound strict (`>=` rejects), so post-compaction history is guaranteed strictly below the threshold. Updated the boundary test (the sum-equals-threshold case is now rejected, not accepted) and added an "accepts just below the threshold" case; nudged one unrelated config that incidentally sat at the equality boundary.
@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 estimation —
estimateContentTokens(): char/4 with per-block structural overhead (text/reasoning=ceil(len/4) + 4,tool-callfrom name + arguments,tool-resultrecursive,image= 85, unknown blocks via JSON length). - Retention policy —
compactIfNeeded()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-stepuser/messageor inter-stepsteering/message) whose total reachesretainTokens; 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'sassistant/messagetool-calls from theirtool/results. When the only compactable content left is an un-splittable open tail step, it declines (returnsnull) 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 pasteduser/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 convergence —
resolveConfig()rejects (throws) any config wheresummarizationMaxTokens + 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. - Summarization —
summarize(): actx.llm.stream()call assembled viaBlockAssembler(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 thecompact/summaryprovenance event. - Surface mutation —
compactRegion()appends thecompact/start→compact/summary→compact/endlog records and the singleuser/messagereplace node carrying the framed summary (see the interface README). - Auto-compaction — an
agent/pre-requestlistener delegates tocompactIfNeeded()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-requestis 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-assembledmessagesarray. The listener owns no threshold logic of its own (the single token-pressure check lives incompactIfNeeded()). - Failure handling — the
compact/start … compact/endbracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (acompact/startwith nocompact/end), records provenance, and prevents a concurrent compaction. Two failure paths: a crash (the loop dies mid-summarization) leaves a danglingcompact/startthat 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) appendscompact/endwith itserrorfield set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns aboutcompact/*.
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.