DSH / Atlas
2026-06-18implementedfeature

Compaction as a capability seam (abstract contract + basic backend)

压缩作为能力 seam(抽象约定 + 基础后端)

A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (`max-tokens`) or degrades. **Compaction** is the mitigation: replace a run of older history with a concise summary, keeping recent context intact. The [session surface](../architecture/2026-06-18-session-surface

English

Problem

A long-running agent conversation grows without bound. As the event log accumulates turns, the derived message history eventually approaches the model's context window — the model then truncates mid-response (max-tokens) or degrades. Compaction is the mitigation: replace a run of older history with a concise summary, keeping recent context intact.

The session surface was built as the foundation for exactly this — an ordered projection over the event log with a surfaceOp: { op: 'replace', start, end } operation purpose-built to shadow a range of entries and insert a replacement, with sourceEventSeqs listing every source event so replay can validate that the replacement cites every event it removes. What remained was the plugin that decides what to compact and produces the summary.

Two forces shape the design. First, compaction policy and reusable token measurement vary independently: measurement belongs to the LLM-family ctx.tokenMeter service, while summarization can be a model call, a template, or a remote service. Second, SurfaceEventType is closed to the message-producing event types (user/message, assistant/message, tool/result); only those may carry surfaceOp. A bespoke compaction/* event therefore cannot itself appear on the surface — the compiler and Session's always-on append/seed boundary reject surfaceOp on it.

Decision

Compaction is a capability seam with separate Service Definition and Service Provider roles

Per the capability-seams Agent Note, compaction ships as separate packages so the contract, the algorithm, and (later) the consumer API evolve independently:

  1. Interface@deepseek-ai/dsh-compaction: an abstract CompactionEngine owning the ctx.compaction key, the CompactionResult vocabulary, the compaction/* session events, the manual failure taxonomy, and the canonical checkpoint message source. It declares compactIfNeeded(), compactNow(), and compactRegion() as abstract — the contract states what compaction does, not how.
  2. Implementation@deepseek-ai/dsh-compaction-basic: a concrete BasicCompactionEngine that consumes ctx.tokenMeter and owns the tail→head retention walk, summarization via ctx.llm.stream(), the surface replacement, the lock, pre-step pressure, and canonical context-overflow recovery. summarize() is its sole subclass hook; pricing and replay stay with the meter.
  3. Model-free companion@deepseek-ai/dsh-compaction-tool-result-pruner: a concrete optional service that rewrites oversized current tool/result nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement CompactionEngine.
  4. Human consumer@deepseek-ai/dsh-command-compact registers argument-free /compact through ctx.commands and calls the backend-independent compactNow() operation. It is direct human control, not a model-facing tool.

The contract depends on dsh-session and dsh-llm — a deliberate deviation

The capability-seams Agent Note states the Service Definition package "depends only on cordis" (true of dsh-shell, whose vocabulary is self-contained). Compaction cannot honor that: its verbs act on an agent-owned Session (compactRegion(start, end, agent)) and its output uses the content vocabulary (CompactionResult.summary: ContentBlock[]). There is no way to express the contract without naming Session/SessionEvent (from dsh-session) and ContentBlock (from dsh-llm).

This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." dsh-session and dsh-llm are themselves interface/vocabulary packages, not implementations; dsh-compaction still imports no backend. The seam's real invariant — consumers and implementations evolve independently behind an abstract service — holds intact.

Three abstract operations, algorithm in the backend

Putting the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making all three operations abstract puts every how decision in the backend and keeps the interface a statement of what. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold.

compactIfNeeded(agent, trigger, signal) takes an explicit 'pressure' | 'context-overflow' trigger and cancellation. It reads only the latest durable routed request; no header means no work, while any routed provider/model target uses the singleton estimator. compactNow(agent, signal) requires an idle agent and performs one useful balanced reduction even below pressure, returning null without writes when none exists. compactRegion(start, end, agent, signal?) uses agent.session as its single session identity and keeps an optional signal for explicit callers. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options, and records the provider/model pair after any llm/stream routing. It replays the routed request's prefix and appends the compaction directive as a trailing user message so the provider's warm KV cache is reused — see the summary prefix-cache Agent Note. The result carries llmStreamCall: true because it consumed exactly one call through this context's LLM service; a subclass sets that marker only under the same condition, since retained rawOutput alone does not identify the call path. The call sets the provider-neutral GenerateOptions.purpose to compaction; adapters may map that purpose to model-hidden transport metadata, and the DeepSeek adapter sends x-deepseek-harness-compact: 1.

Automatic pressure runs after successful durable step work

Successful-call pressure runs at the next agent/pre-step, after the preceding response, tool results, buffered context, and steering are durable and before the next request is derived. dsh-compaction-basic measures the canonical logged request through ctx.tokenMeter, so the next request sees any replacement without a speculative envelope override. Once pressure qualifies, optional ctx.toolResultPruner rewriting runs before summary selection; compaction-basic remeasures the durable surface and skips summarization if pruning restores safe pressure.

Canonical provider context overflow takes a separate path. The failed step closes and agent/request-error receives the original request error. Compact-basic owns its per-agent overflow count, prunes before forcing one useful balanced reduction, and returns { kind: 'retry' } only if session.surface.replaceGeneration increases, including pruning-only progress when no summary range exists. The loop then closes the failed turn, opens a new numbered retry turn, and reconstructs its request from the durable log. No replacement, a recovery failure before any replacement, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. If pruning already advanced the generation before later summary work fails, recovery retries from that durable pruned surface unless cancellation or disposal wins. The complete lifecycle decision is in the after-call recovery Agent Note.

assistant/message → tool/result/context/steering → step/end
claim the next batch → await waterfall agent/pre-step  ⟵ pressure compaction before the next request
enter → next step/start

provider overflow → step/end
await waterfall agent/request-error  ⟵ forced compaction between attempts
retry → next numbered step/start      ⟵ derives from the replacement surface

Retention is turn-agnostic; tool-pairing balance is the only structural guard

Auto-compaction checks after every successful step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an assistant/message + a tool/result per step, so the surface grows within a turn. The next pre-step check can compact early closed tool pairs before continuation opens another step, and provider-confirmed overflow remains the backstop when a request crosses the limit first.

compactIfNeeded retains the smallest tail of whole surface units whose estimated size reaches the resolved retained-token budget and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. dsh-compaction exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while replaceGeneration is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. compactRegion rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.

A runaway turn thus compacts exactly like any other history: its early closed steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (null) and retries once that step closes.

Some single-unit overflow remains out of scope. Summary range selection cannot split an indivisible unit. The optional pruner can repair a closed tool pair when removable text-bearing tool-result content is the bulk and the pruned remainder fits. Envelope-only pressure, an oversized indivisible non-tool node such as a pasted user/message, and a tool unit whose non-prunable remainder is still oversized remain outside compaction; bounding those units is a separate concern.

Head-anchoring: one auto checkpoint, always at the head

Auto-compaction always starts at the surface head, merging the prior checkpoint with newly compacted history so only one automatic checkpoint remains. shadowedRange is therefore positional rather than a numeric sequence interval: a newer summary sequence may occupy an older surface position. shadowedSeqs records the authoritative surface order. Manual mid-range compaction may leave multiple checkpoints.

Approximate convergence invariant

resolveConfig supplies usable defaults: threshold ratio 0.8, retained-tail ratio 0.16, empty summarization provider/model overrides, maxTokens: 8192, compactionRetries: 1, maxOverflowRetries: 1, and auto: true. Optional exact provider/model policies partially override the top-level defaults; pressure scales ratios against capacity from the route-owning LLM adapter, while retainTokens can replace ratio retention. Retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, compactIfNeeded() re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow needs no capacity metadata and bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. The ownership split is specified by the routed model context and compaction policy Agent Note.

Surface replacement: compaction/* events are log-only; one user/message carries the summary

Because SurfaceEventType is closed, the summary cannot ride on a compaction/* event. The backend instead appends a single user/message with source: COMPACT_CHECKPOINT_SOURCE and surfaceOp: { op: 'replace', start, end } whose content is the (framed) summary and whose sourceEventSeqs covers the shadowed entries and the bookkeeping events. The interface exports that source and isCompactCheckpointSource() so consumers recognize a persisted or cloned checkpoint without depending on backend package identity. The compaction/* events record the lock, summary, selected range, shadowed seqs, token count, and model call without joining the surface. The surface mutation sits inside the lock — compaction/end is the last event appended:

compaction/start    → log-only. Acquires the lock.
[summarize older range via the backend]
compaction/summary  → log-only. Records the raw summary, local-call marker, range, shadowed seqs, and token count.
user/message     → canonical checkpoint source + surfaceOp { op:'replace', start, end }.
                   THE surface mutation (framed summary).
                   deriveMessages() renders it as a user-role message.
compaction/end      → log-only. Releases the lock (carries `error` on a recoverable failure).

deriveMessages() then yields [summary_as_user_message, ...retained_entries]. Reusing user/message is honest rather than a workaround: a summary genuinely is user-role context.

Checkpoint framing + incremental merge (backend-private)

The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on compaction/summary. Framing is backend policy; the seam promises that one replacement user message carries the possibly framed summary and uses the canonical checkpoint source.

Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy

The compaction/start … compaction/end bracket is justified by two roles:

  1. Crash-detectable orphan plus recorded summary inputs (primary). Summarization is a slow model call persisted after compaction/start. A crash mid-summarization leaves a compaction/start with no matching compaction/end — a detectable orphan. Releasing the lock last (rather than first) converts the crash window from silent corruption into that detectable orphan.
  2. Prevents concurrent compaction. Every automatic, manual, and explicit-range entry point refuses a live unmatched compaction/start. The bracket is the single lock; no process-local mutex duplicates it.

The lock excludes another compaction, not unrelated facts. Its markers are time points rather than an exclusive container, so durable inbox splices may appear between a standalone manual start and end. Automatic work requires whole-surface stability inside its turn. Manual work revalidates only the selected positional span, letting append-only context outside it remain visible after replacement.

The lifecycle boundary makes crash state unambiguous:

  • Current lifecycle: a dangling compaction/start after the newest session/end-seed is the live durable lock and reports busy.
  • Later lifecycle: a newer constructor-written session/end-seed proves that the older unmatched start is stale, so resume, fork, and adoption do not remain wedged by a dead writer.
  • Recoverable failure: once start lands, the backend makes exactly one compaction/end { error } attempt. Summary or stability failure leaves the conversation surface unchanged while preserving the failed attempt in the log. If the close append fails, the unmatched start remains intentionally blocking.

compaction/end keeps its error? field (mirroring tool/result's self-contained error — one event tells success from failure without correlating a sibling). There is no separate compaction/error event.

Core session repair stays compaction-agnostic — deliberately. interruptedTurnClosers is never taught about compaction/*. The general session/end-seed lifecycle boundary supplies the evidence the compaction owner needs; the compaction invariant and backend interpret it without adding plugin-specific repair to core.

Alternatives considered

  • The full algorithm as concrete interface methods — rejected because it recouples the contract to one retention strategy. All three operations are abstract; reusable measurement is a separate LLM-family service and summarize() is basic's sole hook.
  • Compaction on agent/request or a compaction-specific loop callback — rejected because the former observes a provisional request and the latter couples generic lifecycle to compaction policy. Pre-step replay of the prior durable request plus canonical overflow recovery covers successful and rejected calls.
  • A compact boolean or untyped request metadata map — rejected because multiple auxiliary call kinds would become mutually exclusive flags, while an open bag would discard compiler-checked vocabulary. One typed purpose discriminant extends with additional call kinds without adding another GenerateOptions field.
  • A separate compaction/error event — rejected: compaction/end keeps an error? field, mirroring tool/result's self-contained error — one event tells success from failure without correlating a sibling.
  • Teaching core turn-repair about compaction/* — rejected: the general end-seed boundary already distinguishes prior-lifecycle history, and patching core for every future xxx/start … xxx/end pair is exactly the coupling the capability-seam architecture exists to avoid.

Consequences

  • Packages: packages/compaction/compaction supplies the interface, compaction-basic supplies the backend, compaction-tool-result-pruner supplies optional deterministic rewriting, and command-compact supplies human /compact. packages/llm/token-meter owns replay-aware measurement independently.
  • Automatic extension points: agent/pre-step (@mode waterfall) handles pressure before request derivation and agent/request-error (@mode waterfall) handles final request failures after the failed step closes. The pre-step payload carries the claimed batch, turn, step, and signal (see the payload-object events decision), with no compaction-only prompt/prefix payload.
  • SessionEventMap gains compaction/start / compaction/summary / compaction/end by declaration merging (merge-extensible); SurfaceEventType is not touched. These are session events, not cordis Events, so the event-taxonomy gate needs no entry.
  • dsh-compaction owns COMPACT_CHECKPOINT_SOURCE, isCompactCheckpointSource(source), toolPairingBalancedBefore(session, seq), and toolPairingBalancedAfter(session, seq). The marker identifies replacement summaries across backend implementations. The cached surface-edge checks prevent compactRegion and compactIfNeeded from splitting a tool-call/result pair, validate current membership by seq, answer both edges from one per-cut balance sequence, and reject stale or missing seqs and orphan results.
  • dsh-session validates positional replacement, complete cited source-event coverage, and content-only single-node tool/result rewrites through its one surface manager. Its invariant companion treats fresh appended tool results as executions that require an open step and pending call, while the compaction companion owns numeric-turn versus standalone-null bracket relations.
  • Wiring: examples/tui-agent/cordis.yml loads zero-config dsh-token-meter, dsh-compaction-tool-result-pruner, dsh-compaction-basic, then dsh-command-compact; service-wide defaults make the composition usable without repeated numeric policy.

Testing

  • Unit: Real Loader and invariant plugins cover whole-unit retention, pruning configuration and replay, rich-block ordering, metadata preservation, convergence, both compaction/end outcomes, open-tail refusal, pruning-only and summarized overflow recovery, generation proof, caps, and original-error preservation.
  • Loop: Tests pin pre-step after the preceding step/end and before the next step/start, actual agent/request routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition.
  • Manual: Maintenance serialization, marker ordering, injection retention, live/stale orphan classification, cancellation, close/flush failures, command mapping, and the queued TUI journey are pinned without a model key.
  • With-key e2e: A real model and bash session with lowered limits triggers compaction, records a complete compaction/start…end pair, shrinks the surface, and finishes the task.
  • Snapshot: The assembled context-overflow scenario derives the auxiliary call from compaction/summary only when llmStreamCall: true proves that the local LLM service consumed it; canonical reconstructed blocks pin the complete recovery without provider delta partitioning.

中文

问题

长时间运行的 agent(智能体)对话会无限增长。随着事件日志不断累积轮次,派生出的消息历史最终逼近模型的上下文窗口,模型随即在响应中途停止生成(max-tokens),或表现退化。上下文压缩(context compaction) 是对此的缓解手段:用一段简洁的摘要替换一批较早的历史,保持近期上下文完整。

会话接口面正是为此而构建的基础设施:一份建立在事件日志之上的有序投影,带有专门设计的 surfaceOp: { op: 'replace', start, end } 操作,用于遮蔽一段条目并插入替换内容,sourceEventSeqs 列出每个来源事件,使回放可以验证替换是否引用了它移除的每个事件。剩下的是那个决定压缩什么、并产出摘要的插件。

两股力量塑造了设计。第一,压缩策略与可复用的 token 测量独立变化:测量归 LLM(大语言模型)系列的 ctx.tokenMeter 服务所有,摘要生成则可以使用模型调用、模板或远程服务。第二,SurfaceEventType 封闭为产生消息的事件类型(user/messageassistant/messagetool/result);只有这些类型可以携带 surfaceOp。因此一个专用的 compaction/* 事件不能出现在 surface 上,编译器与 Session 始终启用的 append/seed 边界都会拒绝在其上附加 surfaceOp

决策

压缩是一个能力 seam,Service Definition 与 Service Provider 角色分离

遵循能力 seam Agent Note,压缩以独立包发布,使约定、算法和(后续的)消费方 API 各自独立演进:

  1. 接口@deepseek-ai/dsh-compaction:抽象 CompactionEngine,拥有 ctx.compaction 键、CompactionResult 词汇、compaction/* 会话事件、手动失败分类体系以及规范的检查点消息来源。它将 compactIfNeeded()compactNow()compactRegion() 声明为抽象方法——约定说明压缩做什么,而非怎么做
  2. 实现@deepseek-ai/dsh-compaction-basic:具体的 BasicCompactionEngine,消费 ctx.tokenMeter,并拥有尾→头保留遍历、通过 ctx.llm.stream() 生成摘要、surface 替换、锁、步骤前压力处理和规范的上下文溢出恢复。summarize() 是其唯一的子类钩子;计价与回放仍归 meter 所有。
  3. 无模型配套服务@deepseek-ai/dsh-compaction-tool-result-pruner:一个具体的可选服务,在后端选择摘要范围之前,重写当前过大的 tool/result 节点。它不是第二种压缩实现,也不实现 CompactionEngine
  4. 面向用户的消费方@deepseek-ai/dsh-command-compact 通过 ctx.commands 注册无参数 /compact,并调用后端无关的 compactNow() 操作。它是供用户直接控制的命令,不是面向模型的工具。

约定依赖 dsh-sessiondsh-llm——有意为之的偏离

能力 seam Agent Note 规定 Service Definition 包「仅依赖 cordis」(对 dsh-shell 成立,因为其词汇是自包含的)。压缩无法遵守这一点:它的动词作用于 agent 拥有的 SessioncompactRegion(start, end, agent)),其输出使用内容词汇(CompactionResult.summary: ContentBlock[])。不引用 Session/SessionEvent(来自 dsh-session)和 ContentBlock(来自 dsh-llm),约定就无法表达。

这不是耦合异味,而是约定的领域所在。「仅 cordis」的指导原则一直是「接口仅依赖约定真正需要命名的东西,绝不依赖实现」的简写。dsh-sessiondsh-llm 本身是接口/词汇包,不是实现;dsh-compaction 仍然不导入任何后端。seam 的真正不变式——消费方和实现在抽象服务背后独立演进——完好无损。

三个抽象操作,算法在后端

将完整算法(保留遍历、token 求和、文本提取)作为接口上的具体方法,会将约定重新耦合到一种策略:想要不同保留策略或事件排序的后端必须与继承来的具体代码对抗。将三个操作都设为抽象,把所有怎么做的决策放在后端,并让接口保持为做什么的声明。token 测量根本不是压缩钩子;单例服务使多个消费方能够共享逐会话的回放折叠。

compactIfNeeded(agent, trigger, signal) 接受显式的 'pressure' | 'context-overflow' 触发原因与取消信号。它只读取最新的持久化已路由请求;没有 header 就不执行工作,任何已路由的提供方/模型目标都使用单例估算器。compactNow(agent, signal) 要求 agent 处于 idle,即使未达到压力也进行一次有效的平衡缩减;不存在这种范围时返回 null,且不写入任何内容。compactRegion(start, end, agent, signal?)agent.session 作为唯一会话身份,并为显式调用方保留可选 signal。默认摘要器依次从显式配置、最新记录的已路由目标和 agent 选项解析目标,并在任何 llm/stream 路由后记录提供方/模型对。它回放已路由请求的前缀,并将压缩指令追加为尾部 user 消息,从而复用提供方的热 KV Cache;见摘要前缀缓存 Agent Note。该结果携带 llmStreamCall: true,因为生成它时恰好通过此上下文的 LLM 服务发起了一次调用;只有满足相同条件时,子类才设置该标记,因为单有保留的 rawOutput 并不能判定调用路径。该调用将提供方无关的 GenerateOptions.purpose 设为 compaction;适配器可以将此用途映射为对模型隐藏的传输元数据,DeepSeek 适配器会发送 x-deepseek-harness-compact: 1

成功的持久步骤工作完成后运行自动压力检查

成功调用的压力检查在下一个 agent/pre-step 运行;此时前一响应、工具结果、缓冲上下文与 steering(中途引导)已经持久化,而下一个请求尚未派生。dsh-compaction-basic 通过 ctx.tokenMeter 测量规范的已记录请求,因此下一个请求无需推测性覆盖信封即可看到任何替换。压力达到条件后,可选的 ctx.toolResultPruner 重写在摘要范围选择前运行;compaction-basic 重新测量持久 surface,如果修剪恢复到安全压力便跳过摘要生成。

规范的提供方上下文溢出走另一条路径。失败步骤先关闭,agent/request-error 接收原始请求错误。compaction-basic 自行持有按 agent 计的溢出次数,在强制执行一次有效且平衡的缩减前先修剪,且仅当 session.surface.replaceGeneration 增加时才返回 { kind: 'retry' };这包括没有摘要范围时仅修剪取得的进展。随后循环关闭失败轮次,开启新的编号重试轮次,并从持久日志重建请求。没有替换、任何替换前的恢复失败、取消、耗尽的上限或无关错误都会保留原始提供方失败。如果修剪已经推进 generation,而后续摘要工作失败,恢复会从该持久的已修剪 surface 重试,除非取消或 dispose(资源释放)先发生。完整生命周期决策见调用后恢复 Agent Note

assistant/message → tool/result/context/steering → step/end
claim the next batch → await waterfall agent/pre-step  ⟵ pressure compaction before the next request
enter → next step/start

provider overflow → step/end
await waterfall agent/request-error  ⟵ forced compaction between attempts
retry → next numbered step/start      ⟵ derives from the replacement surface

保留是轮次无关的;工具配对平衡是唯一的结构守卫

自动压缩在每个成功的步骤之后检查,而非每轮一次。这对失控轮次存活至关重要:工具密集型的 ReAct 轮次每步追加一个 assistant/message + 一个 tool/result,因此 surface 会在一轮之内增长。下一个 pre-step 检查可以在后续执行开启另一个步骤之前压缩早期已关闭的工具对;如果请求率先越过限制,由提供方确认的溢出仍是兜底机制。

compactIfNeeded 保留估算大小达到解析后保留 token 预算的最小完整 surface 单元尾部,压缩更早的节点。一个单元是一个完整的已关闭步骤或一条无步骤消息。如果 token 截断点落在步骤内部,保留范围会扩展直到切割点满足工具配对平衡。平衡按 surface 顺序检查,而非日志序号,因为替换摘要在旧的 surface 位置拥有新的序号。dsh-compaction 导出前后边缘辅助函数;只要 replaceGeneration 不变,其逐会话缓存就只折叠新增的 surface 尾部节点,面对仅日志增长时不读取事件,并在替换后重建当前成员关系与平衡。compactRegion 拒绝将工具调用与其结果拆分的边界。进行中的轮次不享受特殊保留。

因此失控轮次的压缩方式与其他历史完全相同:其早期已关闭步骤被摘要,近期步骤保持原样。当唯一可压缩的内容只剩一个不可拆分的开放尾部步骤(其工具调用尚无结果)时,压缩拒绝执行(返回 null)并在该步骤关闭后重试。

部分单一单元溢出仍不在范围内。 摘要范围选择无法拆分不可分割的单元。当可移除的文本型工具结果内容占据大部分空间,且修剪后的剩余内容不再超限时,可选修剪器可以修复一个已关闭的工具对。仅信封压力、粘贴的 user/message 等不可分割的超大非工具节点,以及不可修剪余量仍然过大的工具单元,依旧不属于压缩范围;限制这些单元是另一个关注点。

头部锚定:一个自动检查点,始终在头部

自动压缩始终从 surface 头部开始,将先前的检查点与新压缩的历史合并,因此只保留一个自动检查点。shadowedRange 因此是位置性的而非数值序号区间:一个较新的摘要序号可能占据较旧的 surface 位置。shadowedSeqs 记录权威的 surface 顺序。手动的中间范围压缩可能留下多个检查点。

近似收敛不变式

resolveConfig 提供可用默认值:阈值比例 0.8、保留尾部比例 0.16、空的摘要提供方/模型覆盖、maxTokens: 8192compactionRetries: 1maxOverflowRetries: 1 以及 auto: true。可选的精确提供方/模型策略会部分覆盖顶层默认值;压力根据拥有该路由的 LLM 适配器所报告容量缩放比例,而 retainTokens 可以替代按比例保留。保留量必须低于最终阈值。收敛仍然是动态的,因为提供方输出上限可能被隐藏或显式的推理(reasoning)token 消耗,摘要大小也不可预测。如果压力仍高于阈值,compactIfNeeded() 会按配置的重试次数再次压缩头部检查点,但每次提交的摘要必须小于其遮蔽的内容。溢出不需要容量元数据,并会绕过阈值和保留尾部策略,执行一次最大且平衡的头部缩减,留下最新的不可分割单元。所有权划分由已路由模型上下文与压缩策略 Agent Note规定。

Surface 替换:compaction/* 事件仅存在于日志;一条 user/message 承载摘要

由于 SurfaceEventType 是封闭的,摘要不能搭载在 compaction/* 事件上。后端改为追加单条 user/message,带有 source: COMPACT_CHECKPOINT_SOURCEsurfaceOp: { op: 'replace', start, end };其 content 是(带框架的)摘要,sourceEventSeqs 覆盖被遮蔽的条目簿记事件。接口导出该来源和 isCompactCheckpointSource(),使消费方无需依赖后端包身份,即可识别持久化或克隆得到的检查点。compaction/* 事件记录锁、摘要、选中区间、被遮蔽的 seq、token 数和模型调用,但不加入 surface。surface 变更位于锁内部compaction/end 是最后追加的事件:

compaction/start    → log-only. Acquires the lock.
[summarize older range via the backend]
compaction/summary  → log-only. Records the raw summary, local-call marker, range, shadowed seqs, and token count.
user/message     → canonical checkpoint source + surfaceOp { op:'replace', start, end }.
                   THE surface mutation (framed summary).
                   deriveMessages() renders it as a user-role message.
compaction/end      → log-only. Releases the lock (carries `error` on a recoverable failure).

deriveMessages() 随后产出 [summary_as_user_message, ...retained_entries]。复用 user/message 是诚实的而非变通:摘要确实 user 角色的上下文。

检查点框架 + 增量合并(后端私有)

基础后端将摘要包装为既定的检查点上下文,并标记以便下一轮增量合并。原始摘要保留在 compaction/summary 上。框架是后端策略;seam 承诺由一条替换 user 消息承载可能带框架的摘要,并使用规范的检查点来源。

通过日志记录的锁实现阻塞,加上崩溃/可恢复失败的分类

compaction/start … compaction/end 事件对承担两项职责:

  1. 可检测的崩溃孤儿 + 已记录的摘要输入(首要)。摘要生成是一次慢速模型调用,持久化在 compaction/start 之后。摘要生成中途崩溃会留下一个没有匹配 compaction/endcompaction/start——一个可检测的孤儿。最后释放锁(而非最先)将崩溃窗口从静默损坏转变为可检测的孤儿。
  2. 防止并发压缩。 每个自动、手动和显式范围入口点都会拒绝活动的未匹配 compaction/start。该标记对就是唯一的锁;没有进程本地 mutex 重复承担同一职责。

该锁只排除另一项压缩,不排除无关事实。其标记是时间点,而不是排他的容器,因此持久 inbox splice 可以出现在独立手动 start 与 end 之间。自动工作要求其轮次内的整个 surface 保持稳定。手动工作只重新验证所选位置 span,使其外部的仅追加上下文在替换后保持可见。

生命周期边界使崩溃状态含义明确:

  • 当前生命周期: 最新 session/end-seed 之后悬空的 compaction/start 是活动的持久锁,并报告 busy。
  • 后续生命周期: 构造函数写入的较新 session/end-seed 证明更早的未匹配 start 已陈旧,因此恢复、fork 和接手不会被已死的写入方持续卡住。
  • 可恢复失败: start 落地后,后端会恰好尝试一次 compaction/end { error }。摘要或稳定性失败会保持会话 surface 不变,同时在日志中保留失败尝试。如果追加闭合事件失败,未匹配 start 会继续有意阻塞。

compaction/end 保留其 error? 字段(与 tool/result 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件)。没有单独的 compaction/error 事件。

核心会话修复保持对压缩无感知——这是有意为之。 interruptedTurnClosers 从不被教导 compaction/*。通用 session/end-seed 生命周期边界提供压缩所有者所需的证据;压缩不变量与后端负责解释它,无需向核心添加插件专属修复。

曾考虑的替代方案

  • 完整算法作为接口的具体方法——否决,因为它将约定重新耦合到一种保留策略。三个操作都是抽象的;可复用测量属于单独的 LLM 系列服务,summarize() 是 basic 唯一的钩子。
  • agent/request 或压缩专属的 loop 回调上执行压缩——否决,因为前者观察的是临时请求,后者会将通用生命周期耦合到压缩策略。对先前持久请求进行 pre-step 回放,再加上规范溢出恢复,即可覆盖成功和被拒绝的调用。
  • compact 布尔值或无类型的请求元数据 map——否决,因为多个辅助调用种类会变成互斥标志,而开放 map 会丢弃由编译器检查的词汇。一个类型化的 purpose 判别字段可以扩展其他调用种类,而无需再为 GenerateOptions 添加字段。
  • 单独的 compaction/error 事件——否决:compaction/end 保留 error? 字段,与 tool/result 的自包含错误一致——一个事件即可区分成功与失败,无需关联兄弟事件。
  • 教导核心轮次修复识别 compaction/*——否决:通用 end-seed 边界已经能够区分先前生命周期的历史;为每个未来的 xxx/start … xxx/end 事件对修补核心模块,恰好是能力 seam 架构存在的意义所要避免的耦合。

后果

  • packages/compaction/compaction 提供接口,compaction-basic 提供后端,compaction-tool-result-pruner 提供可选的确定性重写,command-compact 提供面向用户的 /compactpackages/llm/token-meter 独立拥有回放感知的测量。
  • 自动扩展点agent/pre-step@mode waterfall)在请求派生前处理压力,agent/request-error@mode waterfall)处理失败步骤关闭后的最终请求失败。pre-step 的 payload 携带已领取批次、轮次、步骤与 signal(参见 payload-object 事件决策),不携带压缩专属的提示词/前缀 payload。
  • SessionEventMap 通过可合并扩展的声明合并获得 compaction/start / compaction/summary / compaction/endSurfaceEventType 未被触及。这些是会话事件,不是 cordis Events,因此事件分类门禁无需新增条目。
  • dsh-compaction 拥有 COMPACT_CHECKPOINT_SOURCEisCompactCheckpointSource(source)toolPairingBalancedBefore(session, seq)toolPairingBalancedAfter(session, seq)。该标记用于跨后端实现识别替换摘要。带缓存的 surface 边缘检查会防止 compactRegioncompactIfNeeded 拆分工具调用/结果对,按 seq 校验当前成员关系,从每个切割点的一条平衡序列回答两侧边缘,并拒绝陈旧或缺失的 seq 与孤立结果。
  • dsh-session 通过唯一的 surface 管理器校验位置替换、引用的来源事件是否覆盖完整,以及仅内容的单节点 tool/result 重写。其不变式配套插件将新追加的工具结果视为执行,要求存在已打开的步骤与待处理调用,而压缩配套组件负责维护数字轮次 owner 与独立 null owner 事件对之间的关系。
  • 接线examples/tui-agent/cordis.yml 依次加载零配置的 dsh-token-meterdsh-compaction-tool-result-prunerdsh-compaction-basic,然后加载 dsh-command-compact;服务级默认值使组合无需重复数值策略即可使用。

测试

  • 单元测试: 使用真实 Loader 和 invariant 插件覆盖完整单元保留、修剪配置与回放、富块顺序、元数据保留、收敛、compaction/end 的两种结果、开放尾部拒绝、仅修剪与带摘要的溢出恢复、generation 证明、上限和原始错误保留。
  • 循环测试: 测试固定 pre-step 发生在前一个 step/end 之后、下一个 step/start 之前,使用实际 agent/request 路由,关闭失败步骤,分配新的重试编号,并覆盖完整的抛出/带内溢出 → 压缩 → 重建重试组合。
  • 手动测试: 无需模型密钥即可固定 maintenance 串行化、标记顺序、注入保留、活动/陈旧未匹配标记分类、取消、闭合/flush 失败、命令映射以及排队 TUI 流程。
  • 带密钥 e2e: 真实模型和 bash 会话在降低的限制下触发压缩,记录完整的 compaction/start…end 对,缩小 surface,并完成任务。
  • 快照: 组装后的上下文溢出场景仅在 llmStreamCall: true 证明本地 LLM 服务执行了辅助调用时,才从 compaction/summary 派生该调用;规范重建的块在不固定提供方增量切分的情况下固定完整恢复过程。