diff --git a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md
index dbeee097d2..1916dc1974 100644
--- a/.agents/notes/implemented/architecture/2026-06-18-session-surface.md
+++ b/.agents/notes/implemented/architecture/2026-06-18-session-surface.md
@@ -68,3 +68,5 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d
- **`packages/session-persistence/session-persistence`**: Abstract interface unchanged.
The surface is the foundation for future history manipulation. A compaction or tool-result-prune plugin appends one of the existing message-producing event types (a `user/message` carrying the summary, say) with `surfaceOp: { op: 'replace', start, end }` and `sourceEventSeqs` covering the shadowed entries — the new event takes the range's place on the surface while the plugin's own trace events (e.g. `compaction/start`, `compaction/end`) stay off it. Replay preserves the decision deterministically.
+
+A `tool/result` replacement may rewrite exactly one current `tool/result` and must preserve every data field except `content`. Session acceptance enforces this rule together with positional range and provenance validation, independent of optional diagnostic plugins.
diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
index a6344d276f..68b9d2d55b 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: f1a1868cd00007fb24efb21779dcc94c098b54e2
-2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: a4993de2830301610bb2a9b0d28e8bbdf0ed9c46
+2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: d470cceaff68229b3872d0ade93d5fabc2e10c3f
+2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: b7753fb226638b16b0f244b681cd2b9bcc9f25c2
diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
index f1a1868cd0..d470cceaff 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
+++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md
@@ -16,9 +16,9 @@ Successful calls are not the only pressure signal. A provider can reject a reque
`agent/pre-step` is narrowed to `(agent, turn, step, signal)`. It remains a generic serial checkpoint before `step/start`, but it carries no compaction-only prompt or prefix fields.
-The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A listener failure is an ordinary turn failure; it never enters model-request recovery.
+The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after assistant output, every dispatched or synthetic tool result, post-tool context, and steering are durable, but before `step/end`. This placement gives pressure policy the complete successful-call state without splitting an assistant tool call from its result. A propagated listener failure is an ordinary turn failure; it never enters model-request recovery. Compact-basic contains its expected operational failures as described below.
-`dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue with full history.
+`dsh-compact-basic` reads the exact latest routed model from the durable request header only to establish that a completed route exists, then asks the singleton `ctx.tokenMeter` to measure the canonical logged envelope and current surface. It does not fall back to `AgentOptions.model` for automatic pressure. A headerless session has no completed routed request to assess and produces no work; any durable non-empty model name uses the same estimator. Operational measurement or summarization failures warn and continue from the latest durable surface: full history before any replacement, or the pruned surface if pruning already landed.
### Request recovery is limited to the final model boundary
@@ -32,17 +32,17 @@ If cancellation lands after assistant tool calls are durable but before all call
`CompactService.compactIfNeeded(agent, trigger, signal)` accepts `trigger: 'pressure' | 'context-overflow'`. The interface gains no estimation methods or token types; `ctx.tokenMeter` remains the reusable accounting owner.
-For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`.
+For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. Below pressure it returns without pruning. Once pressure qualifies, optional `ctx.toolResultPrune` rewrites oversized current results and compact-basic remeasures through the same meter; safe pressure skips the model call, while remaining pressure selects and summarizes from the pruned surface. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`.
-For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry.
+For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`.
-`maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. Cancellation or disposal remains authoritative even if recovery work completes concurrently.
+`maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws before any replacement all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. A recovery throw after generation advances authorizes retry from durable progress; cancellation or disposal remains authoritative even if recovery work completes concurrently.
The default summarizer resolves explicit configuration, then the latest logged route, then agent options. Because direct `llm/stream` middleware may reroute that auxiliary call, `compact/summary.{provider, model}` records the final mutable `GenerateOptions` target observed after dispatch rather than the pre-waterfall candidate.
## Testing
-Unit tests cover final-adapter failure provenance and identity, closed-step retry numbering and reset, cancellation and disposal, post-step ordering, routed-envelope pressure, balanced overflow reduction, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through compaction to a reconstructed retry request.
+Unit tests cover final-adapter failure provenance and identity, closed-step retry numbering and reset, cancellation and disposal, post-step ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request.
## Alternatives considered
@@ -54,8 +54,8 @@ Unit tests cover final-adapter failure provenance and identity, closed-step retr
## Consequences
-Post-step pressure describes the completed routed request, including durable tool results and request-only prefix fields. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change.
+Post-step pressure describes the completed routed request, including durable tool results and request-only prefix fields. Optional model-free pruning removes predictable tool-output bulk before summary selection and can independently create retry-worthy progress. Canonical overflow supplies the backstop when no successful usage anchor exists. Recovery is bounded, cancellation-owned, and monotonic: it retries only after a visible surface generation change.
-The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window or split one indivisible oversized message/tool unit.
+The cost is one additional serial checkpoint on successful steps and adapter-maintained overflow classification. Provider wording and heuristic character density remain maintenance risks. Surface compaction still cannot repair an envelope that alone exceeds the window, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder remains oversized. The optional pruner can repair an otherwise indivisible tool pair when removable text-bearing tool-result content is the bulk.
This Agent Note supersedes only the pre-step automatic-trigger portion of the [compaction capability-seam Agent Note](../feature/2026-06-18-compaction-capability-seam.md). The service split, standalone token meter, balanced range contract, log-recorded lock, summary replacement, and sole `summarize()` subclass hook remain unchanged.
diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
index a4993de283..b7753fb226 100644
--- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md
@@ -16,9 +16,9 @@ Status: implemented
`agent/pre-step` 收窄为 `(agent, turn, step, signal)`。它仍是 `step/start` 之前的通用串行检查点,但不再携带压缩专用的提示词或前缀字段。
-循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。监听器失败属于普通 turn 失败,绝不会进入模型请求恢复。
+循环在 assistant 输出、所有已分发或合成的工具结果、工具后上下文与 steering 都持久化之后、`step/end` 之前,触发等待式串行 `agent/post-step(agent, turn, step, signal)`。该位置让压力策略看到完整的成功调用状态,同时不会拆开 assistant 工具调用与其结果。向外传播的监听器失败属于普通 turn 失败,绝不会进入模型请求恢复;compact-basic 会按下文所述在内部处理其预期的操作性失败。
-`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并继续使用完整历史。
+`dsh-compact-basic` 从持久请求头读取精确的最新实际路由模型,只用它确认已经存在完整路由,随后让单例 `ctx.tokenMeter` 计量规范日志信封与当前表层。自动压力不会回退到 `AgentOptions.model`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并从最新持久表层继续:任何替换发生前使用完整历史;若剪枝已经落盘,则使用已剪枝表层。
### 请求恢复只覆盖最终模型边界
@@ -32,17 +32,17 @@ Status: implemented
`CompactService.compactIfNeeded(agent, trigger, signal)` 接收 `trigger: 'pressure' | 'context-overflow'`。接口不增加估算方法或 token 类型;`ctx.tokenMeter` 继续作为可复用的核算所有者。
-对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。
+对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。
-对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。
+对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。
-`maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。
+`maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及在任何替换之前恢复抛错,都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。generation 增加后的恢复抛错会基于持久进展授权重试;即使恢复工作并发完成,取消或销毁仍具有最终优先级。
默认摘要器依次解析显式配置、最近记录的路由与 agent options。因为直接 `llm/stream` 中间件可以重新路由该辅助调用,`compact/summary.{provider, model}` 记录分发后最终可变的 `GenerateOptions` 目标,而不是 waterfall 之前的候选值。
## 测试
-单元测试覆盖最终适配器失败的来源与身份、已关闭 step 的重试编号与重置、取消与销毁、post-step 顺序、已路由信封压力、平衡溢出缩减、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证压缩后的重试请求从替换表层重建。
+单元测试覆盖最终适配器失败的来源与身份、已关闭 step 的重试编号与重置、取消与销毁、post-step 顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。
## 考虑过的替代方案
@@ -54,8 +54,8 @@ Status: implemented
## 后果
-Post-step 压力描述已完成的路由请求,包括持久工具结果与仅请求前缀字段。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。
+Post-step 压力描述已完成的路由请求,包括持久工具结果与仅请求前缀字段。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有明确上限、以取消为准,并保持单调:只有模型可见的表层 generation 变化后才重试。
-代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分单个不可分割的超大消息或工具单元。
+代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。
本 Agent Note 只取代[压缩能力接缝 Agent Note](../feature/2026-06-18-compaction-capability-seam.md) 中的 pre-step 自动触发部分。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。
diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
index a7b06a8379..30a0c83a31 100644
--- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
+++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
@@ -18,7 +18,8 @@ Per the [capability-seams Agent Note](../architecture/2026-06-13-capability-seam
1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*.
2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter.
-3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first.
+3. **Model-free companion** — `@deepseek-ai/dsh-compact-tool-result-prune`: 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 `CompactService`.
+4. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first.
### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation
@@ -34,9 +35,9 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text
### Automatic pressure runs after successful durable step work
-Successful-call pressure cannot run at pre-step because final `agent/request` routing, provider output, tool results, buffered context, and steering do not exist there. Serial `agent/post-step(agent, turn, step, signal)` fires after those facts are durable and before `step/end`. `dsh-compact-basic` measures the canonical logged request through `ctx.tokenMeter`, so the next request sees any replacement without a speculative envelope override.
+Successful-call pressure cannot run at pre-step because final `agent/request` routing, provider output, tool results, buffered context, and steering do not exist there. Serial `agent/post-step(agent, turn, step, signal)` fires after those facts are durable and before `step/end`. `dsh-compact-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.toolResultPrune` rewriting runs before summary selection; compact-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, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic forces one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases; the loop then opens a new numbered step and reconstructs its request from the durable log. No range, no replacement, recovery failure, cancellation, an exhausted cap, or an unrelated error preserves the original provider failure. The complete lifecycle decision is in the [after-call recovery Agent Note](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md).
+Canonical provider context overflow takes a separate path. The failed step closes, `agent/request-error` receives the original request error and consecutive retry count, and compact-basic prunes before forcing one useful balanced reduction. It returns retry only if `session.surface.replaceGeneration` increases, including pruning-only progress when no summary range exists; the loop then opens a new numbered step 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](../architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md).
```
assistant/message → tool/result/context/steering
@@ -56,7 +57,7 @@ Auto-compaction checks after **every successful** step, not once per turn. This
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.
-**Single-unit overflow is out of scope, by design.** If a single retained unit — one closed step, or a large free entry such as a pasted `user/message` — *alone* exceeds the budget, compaction cannot help and the next model call may go out over-budget. Bounding an individual unit's size is a separate concern (output truncation), handled elsewhere; compaction makes no promise about it, and the harness without such a mechanism can still break on a single oversized unit. This is named honestly rather than papered over.
+**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
@@ -94,8 +95,8 @@ The `compact/start … compact/end` bracket is justified, in order of what now d
Two failure paths, both documented:
-- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — the surface replacement never landed, so the full, uncompacted history derives correctly. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash cannot wedge future compaction.
-- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set and leaves the surface untouched. Post-step pressure warns and continues; overflow recovery delegates so the original provider error remains authoritative.
+- **Crash** (the loop dies mid-summarization): a dangling `compact/start`, no closer. Because `compact/*` are **log-only**, the orphan is **inert** — no summary replacement lands. The derived surface remains the durable surface present at `compact/start`: full history when pruning made no replacement, or the already-pruned history when it did. Generic turn-repair (`interruptedTurnClosers`) closes the turn with a synthetic `turn/end`; the orphan sits *before* that `turn/end`, so the turn-scoped in-progress check never sees it and a crash cannot wedge future compaction.
+- **Recoverable** (summarization throws but the loop survives): the backend appends `compact/end` with its **`error`** field set and lands no summary replacement. Post-step pressure warns and continues from the latest durable surface — full history if no replacement preceded the attempt, or the pruned surface if pruning already landed. Overflow recovery delegates only before any replacement; generation progress from earlier pruning authorizes a retry from that durable surface unless cancellation or disposal wins.
`compact/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 `compact/error` event.
@@ -110,16 +111,16 @@ Two failure paths, both documented:
## Consequences
-- **Packages**: `packages/compact/compact` supplies the interface and `compact-basic` supplies the backend. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred.
+- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `compact-tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred.
- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload.
- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/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-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, ordered event sequences, and rewrite generation.
-- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement entry at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged.
-- **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy.
+- **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject.
+- **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. `dsh-invariants` treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites.
+- **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy.
## Testing
-- **Unit:** Real Loader and invariant plugins cover whole-unit retention, convergence failure, both `compact/end` outcomes, head anchoring, open-tail refusal, inert crash orphans, forced below-threshold overflow, generation proof, caps, and original-error preservation.
+- **Unit:** Real Loader and invariant plugins cover whole-unit retention, pruning configuration and replay, rich-block ordering, metadata preservation, convergence, both `compact/end` outcomes, open-tail refusal, pruning-only and summarized overflow recovery, generation proof, caps, and original-error preservation.
- **Loop:** Tests pin post-step after durable tool results and before `step/end`, actual `agent/request` routing, closed failed steps, fresh retry numbering, and complete thrown/in-band overflow → compaction → reconstructed retry composition.
- **With-key e2e:** A real model and bash session with lowered limits triggers compaction, records a complete `compact/start…end` pair, shrinks the surface, and finishes the task.
- **Snapshot gap:** Runaway-turn compaction cannot yet replay because the summarization call records no `assistant/chunk` events or `sessionId`; interleaved summarization-call replay remains follow-up work.
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index 888b24043a..2134c8b355 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -64,7 +64,7 @@ sequenceDiagram
The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.
-`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative.
+`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.
SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.
diff --git a/docs/architecture.md b/docs/architecture.md
index 70f357d239..96c74801f7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -32,7 +32,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx
| `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events |
| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure |
| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
-| `ctx.compact` | [`compact/`](../packages/compact/README.md) | session-log compaction |
+| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction; optional model-free result pruning |
| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry + generic `task_*` control tools |
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
@@ -111,7 +111,7 @@ Each step assembles ordered prompt sections, tool schemas, and `{{name}}` variab
Tool-time context—including async `agent.inject()` notices and post-tool `additionalContexts`—settles, then follows recorded results. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering before signal closure. Leftovers become queued input. Terminal `agent/turn-stop` runs after continuation and steering folding, stays authoritative through turn close and flush, and discards later steering but preserves queued prompts.
-`dsh-compact-basic` handles pressure and canonical overflow at checkpoints; retry requires a balanced surface replacement ([decision](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)).
+Optional pruning precedes summaries; retry requires durable surface progress; cancellation wins ([decision](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)).
### Failure Boundaries
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 54df77275c..a7ec9b638b 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -16,6 +16,8 @@ flowchart LR
pkg_compact_basic["compact-basic"]
pkg_token_meter["token-meter"]
svc_tokenMeter["ctx.tokenMeter
Replay token measurement"]
+ pkg_compact_tool_result_prune["compact-tool-result-prune"]
+ svc_toolResultPrune["ctx.toolResultPrune
Model-free tool-result pruning"]
pkg_session["session"]
svc_sessions["ctx.sessions
In-memory session store"]
pkg_agent["agent"]
@@ -110,6 +112,7 @@ flowchart LR
pkg_code_runtime_worker --> svc_codeRuntime
pkg_compact --> svc_compact
pkg_compact_basic --> svc_compact
+ pkg_compact_tool_result_prune --> svc_toolResultPrune
pkg_fs --> svc_fs
pkg_fs_local --> svc_fs
pkg_fs_sandbox --> svc_fs
@@ -193,6 +196,7 @@ flowchart LR
svc_tasks --> pkg_tool_subagent
svc_tasks --> pkg_tool_tasks
svc_tokenMeter --> pkg_compact_basic
+ svc_toolResultPrune --> pkg_compact_basic
svc_tools --> pkg_acp
svc_tools --> pkg_agent_loop
svc_tools --> pkg_tool_ask_user
@@ -215,6 +219,7 @@ flowchart LR
| --- | --- | --- | --- | --- | --- | --- |
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |
+| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index e6934564ae..c2d31f93e3 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -303,6 +303,22 @@ export interface BasicCompactConfig {
Source: [`packages/compact/compact-basic/src/types.ts:8`](../packages/compact/compact-basic/src/types.ts)
+## `@deepseek-ai/dsh-compact-tool-result-prune`
+
+```ts config-catalog
+/** Character-budget policy for deterministic tool-result pruning. */
+export interface ToolResultPruneConfig {
+ /** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */
+ thresholdChars?: number
+ /** Maximum leading Unicode code points retained. Defaults to `4096`. */
+ headChars?: number
+ /** Maximum trailing Unicode code points retained. Defaults to `1024`. */
+ tailChars?: number
+}
+```
+
+Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts)
+
## `@deepseek-ai/dsh-fs-local`
```ts config-catalog
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 9a642f848d..d1cd96afa3 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -1120,6 +1120,43 @@ Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-da
Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-meter/src/index.ts)
+## `ctx.toolResultPrune` — `ToolResultPruneService`
+
+Deterministic head/middle/tail pruning for current tool-result surface nodes.
+
+```ts cordis-catalog
+/**
+ * Measure text content in Unicode code points; non-text blocks cost zero.
+ * @param blocks - tool-result content to measure.
+ * @returns total Unicode code points across text blocks.
+ */
+measureContent(blocks: readonly ContentBlock[]): number
+
+/**
+ * Replace an over-budget text middle while retaining rich-block order.
+ * Text slicing is by Unicode code point, not UTF-16 code unit, so a retained
+ * boundary cannot split a surrogate pair. Grapheme clusters may still split.
+ * @param blocks - original tool-result content.
+ * @returns pruned content, or `null` when the text is within budget.
+ */
+pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null
+
+/**
+ * Prune every over-budget tool result from one stable current-surface snapshot.
+ * Each replacement preserves the complete event data except for `content`,
+ * and points at the shadowed node for durable provenance and replay.
+ * @param session - session whose current surface is rewritten.
+ * @returns landed replacements and aggregate Unicode-code-point savings.
+ * @throws when the session rejects a replacement; replacements committed
+ * earlier in the pass remain durable.
+ */
+pruneSession(session: Session): PruneResult
+```
+
+Types: [ContentBlock](../core-data-structures/core.md) · [PruneResult](../core-data-structures/compaction.md) · [Session](../core-data-structures/session.md)
+
+Source: [`packages/compact/compact-tool-result-prune/src/index.ts:39`](../../packages/compact/compact-tool-result-prune/src/index.ts)
+
## `ctx.tools` — `ToolRegistry`
Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md
index a511b2f93d..bb302ff52b 100644
--- a/docs/core-data-structures/compaction.md
+++ b/docs/core-data-structures/compaction.md
@@ -6,7 +6,7 @@ Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact
## The `compact/*` session events
-Compaction extends [`SessionEventMap`](session.md) with three event types via declaration merging. All three are **log-only** — they record the compaction lock and its provenance, and never join the surface. `SurfaceEventType` is deliberately NOT extended (only message-producing events reach the model), so the summary itself rides on a separate `user/message` with `surfaceOp: { op: 'replace', start, end }` — the only surface mutation. See the Agent Note for why reusing `user/message` is honest rather than a workaround.
+Compaction extends [`SessionEventMap`](session.md) with three event types via declaration merging. All three are **log-only** — they record the compaction lock and its provenance, and never join the surface. `SurfaceEventType` is deliberately NOT extended (only message-producing events reach the model), so the summary itself rides on a separate `user/message` with `surfaceOp: { op: 'replace', start, end }` — the only surface mutation performed by summary compaction. See the Agent Note for why reusing `user/message` is honest rather than a workaround.
| Event | Payload | Role |
|---|---|---|
@@ -60,6 +60,36 @@ type CompactionTrigger = 'pressure' | 'context-overflow'
`CompactService` exposes `compactIfNeeded(agent, trigger, signal)` for automatic `pressure` or `context-overflow` policy, returning `null` when no safe work exists, and `compactRegion(...)` for an explicit inclusive surface range. Implementations must forward the supplied signal to summarization. The seam owns no pricing API: the singleton [`ctx.tokenMeter`](token-meter.md) directly owns estimation and replay, while `dsh-compact-basic` owns retention, event sequencing, routed summarization calls, and their configuration.
-Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Failed-request recovery runs through `agent/request-error` after the failed step closes, and authorizes a fresh numbered-step retry only when the surface replacement generation advances. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling.
+Pressure compaction runs at serial `agent/post-step`, after successful assistant output, tool results, buffered context, and steering are durable but before `step/end`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/compact/compact-tool-result-prune/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and authorizes a fresh numbered-step retry only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling.
The seam exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics.
+
+## Tool-result pruning outcomes
+
+The optional tool-result pruning service reports each durable content replacement and the aggregate Unicode-code-point reduction. Its public result types live in [`compact-tool-result-prune/src/types.ts`](../../packages/compact/compact-tool-result-prune/src/types.ts).
+
+```ts type-equiv
+/** Provenance and size accounting for one landed surface replacement. */
+interface PrunedEntry {
+ /** Full-fidelity tool-result event shadowed by the replacement. */
+ readonly originalSeq: number
+ /** Newly appended pruned tool-result event. */
+ readonly replacementSeq: number
+ /** Tool call shared by the original and replacement. */
+ readonly callId: CallId
+ /** Original text size in Unicode code points. */
+ readonly charsBefore: number
+ /** Replacement text size in Unicode code points. */
+ readonly charsAfter: number
+}
+```
+
+```ts type-equiv
+/** Aggregate outcome of one stable-surface pruning pass. */
+interface PruneResult {
+ /** Replacements in the snapshotted surface order. */
+ readonly pruned: readonly PrunedEntry[]
+ /** Total Unicode code points removed across replacements. */
+ readonly charsRemoved: number
+}
+```
diff --git a/docs/module-graph.md b/docs/module-graph.md
index d06f541cbc..0ed79d32f8 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -50,6 +50,7 @@ flowchart TD
subgraph group_compact["packages/compact"]
pkg_compact["compact"]
pkg_compact_basic["compact-basic"]
+ pkg_compact_tool_result_prune["compact-tool-result-prune"]
end
subgraph group_subagent["packages/subagent"]
pkg_subagent["subagent"]
@@ -180,6 +181,8 @@ flowchart TD
pkg_fs --> pkg_sandbox
pkg_compact --> pkg_llm
pkg_compact --> pkg_session
+ pkg_compact_tool_result_prune --> pkg_llm
+ pkg_compact_tool_result_prune --> pkg_session
pkg_web_fetch_local --> pkg_timeout
pkg_web_fetch_local --> pkg_web
pkg_web_search_deepseek --> pkg_web
@@ -204,6 +207,7 @@ flowchart TD
pkg_skill_local --> pkg_skill
pkg_compact_basic --> pkg_agent
pkg_compact_basic --> pkg_compact
+ pkg_compact_basic --> pkg_compact_tool_result_prune
pkg_compact_basic --> pkg_llm
pkg_compact_basic --> pkg_session
pkg_compact_basic --> pkg_token_meter
@@ -491,6 +495,7 @@ flowchart TD
| [`bash`](../packages/bash/bash) | `bash` | [`sandbox`](../packages/sandbox/sandbox) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) |
| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) |
@@ -504,7 +509,7 @@ flowchart TD
| [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs) |
| [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) |
| [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`home`](../packages/util/home), [`skill`](../packages/skill/skill) |
-| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
diff --git a/examples/package.json b/examples/package.json
index 0a8f6f64d8..687c624536 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -14,6 +14,7 @@
"@deepseek-ai/dsh-cli-demo": "workspace:*",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:*",
"@deepseek-ai/dsh-compact-basic": "workspace:*",
+ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-fs-policy": "workspace:*",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
diff --git a/examples/repl-agent/README.md b/examples/repl-agent/README.md
index 53cc68bb1a..f89e24618c 100644
--- a/examples/repl-agent/README.md
+++ b/examples/repl-agent/README.md
@@ -50,6 +50,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin |
| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice |
| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the configured terminal channel + a pre-created `main` agent. This leaf fixes `ui.mode` to `readline`; `tui-agent` owns the corresponding TUI leaf |
+| `token-meter`, `tool-result-prune`, `compact-basic` | replay-aware pressure, model-free oversized tool-result pruning, and LLM summary compaction. Pruning runs only after a compaction trigger qualifies and can avoid the summarization call |
| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix |
| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) |
| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend |
@@ -61,7 +62,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads
- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer.
- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted.
- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log.
-- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction.
+- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so automatic pruning or summary compaction fires mid-session. It verifies the world: a replayable surface replacement lands, summary brackets are complete when summarization is needed, the surface shrinks, and the agent still produces a correct final answer.
- `tests/todo-write.e2e.ts` — a real model drives the real `todo_write` tool and the test verifies the resulting `todo/write` session event.
These self-skip without `DEEPSEEK_API_KEY`. `tests/code-mode.e2e.ts` is the with-key Code Mode proof — a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. The keyless Loader smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` and `tests/code-mode-keyless-smoke.e2e.ts`.
diff --git a/examples/repl-agent/composition.md b/examples/repl-agent/composition.md
index af3f810585..6d298e7a0a 100644
--- a/examples/repl-agent/composition.md
+++ b/examples/repl-agent/composition.md
@@ -3,7 +3,7 @@
# REPL Agent App Composition
-The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.
+The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package.
```mermaid
flowchart LR
@@ -25,6 +25,8 @@ flowchart LR
bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"]
plugin_repl_token_meter["token-meter
@deepseek-ai/dsh-token-meter"]
cfg --> plugin_repl_token_meter
+ plugin_repl_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"]
+ cfg --> plugin_repl_tool_result_prune
plugin_repl_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"]
cfg --> plugin_repl_compact_basic
plugin_repl_subagent["subagent
@deepseek-ai/dsh-subagent"]
@@ -66,6 +68,7 @@ flowchart LR
| `bash` | `@deepseek-ai/dsh-bash-local` |
| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
+| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |
| `compact-basic` | `@deepseek-ai/dsh-compact-basic` |
| `subagent` | `@deepseek-ai/dsh-subagent` |
| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` |
diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml
index a8a211a795..6e78f7af98 100644
--- a/examples/repl-agent/cordis.yml
+++ b/examples/repl-agent/cordis.yml
@@ -51,6 +51,10 @@
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
+# Prune oversized tool output without a model call before summary compaction.
+- id: tool-result-prune
+ name: '@deepseek-ai/dsh-compact-tool-result-prune'
+
# Summarize an older range after measured pressure or a canonical provider overflow.
# Service-wide policy provides pressure, retention, and one overflow-retry default.
- id: compact-basic
diff --git a/examples/repl-agent/tests/harness.ts b/examples/repl-agent/tests/harness.ts
index eeba57fc61..edf611e89d 100644
--- a/examples/repl-agent/tests/harness.ts
+++ b/examples/repl-agent/tests/harness.ts
@@ -9,6 +9,7 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
+import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
@@ -63,6 +64,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
// backend, with a lower context window so a short real session crosses the threshold.
if (options.compact !== undefined) {
await ctx.plugin(TokenMeterService, options.tokenMeter)
+ await ctx.plugin(ToolResultPruneService)
await ctx.plugin(BasicCompactService, options.compact)
}
// Durable JSONL persistence is opt-in: only the resume e2e needs it, and the
diff --git a/packages/compact/README.md b/packages/compact/README.md
index 890bfa3260..be29e6093f 100644
--- a/packages/compact/README.md
+++ b/packages/compact/README.md
@@ -1,11 +1,12 @@
# compact/ — compaction capability family
-A three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages.
+A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
+| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` |
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
-The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement is a reusable LLM-family service rather than a `CompactService` method; a template- or model-backed compactor can replace `compact-basic` without changing the meter or callers.
+The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers.
diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md
index 6a67d2dfd4..d789bf2328 100644
--- a/packages/compact/compact-basic/README.md
+++ b/packages/compact/compact-basic/README.md
@@ -9,13 +9,14 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
-- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
+- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
+- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
-- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
-- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. A region failure records an error end and leaves the surface unchanged. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error.
+- **Overflow recovery** — below-threshold overflow bypasses normal retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
+- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
@@ -50,7 +51,7 @@ export function apply(ctx: Context): void {
}
```
-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.
+Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. 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.
## Model Experience
@@ -58,7 +59,7 @@ Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it c
#### What the model sees
-After a successful step crosses the threshold, the next request receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. Overflow recovery rebuilds the immediate retry from that replacement. This one checkpoint replaces the selected older range and is followed by the retained recent units.
+After a successful step crosses the threshold, oversized tool results are first rewritten when the optional pruner is loaded. If summarization remains necessary, the next request receives the checkpoint preamble below, a blank line, ``, the data-dependent summary, and ``. Overflow recovery rebuilds the immediate retry from whatever replacement advanced the surface. A checkpoint replaces the selected older range and is followed by the retained recent units.
##### Conversation checkpoint preamble
@@ -68,7 +69,7 @@ This is an automatically generated checkpoint condensing an earlier span of the
#### Token effect
-The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget.
+Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. The replacement reduces future input history rather than appending a second copy. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget.
#### KV Cache effect
@@ -144,7 +145,7 @@ Prefix-stable for auxiliary calls while this instruction and the summarizer rout
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
-- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix.
+- **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair.
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
-- **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds.
+- **Summarization failure preserves the latest durable surface** — before any replacement, the auto path logs a warning and proceeds with full over-budget history. If pruning already landed, a later summarization failure proceeds from that durable pruned surface. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule.
- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)).
diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json
index a0ee2b4036..01cc2ab6bf 100644
--- a/packages/compact/compact-basic/package.json
+++ b/packages/compact/compact-basic/package.json
@@ -27,8 +27,14 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
+ "@deepseek-ai/dsh-compact-tool-result-prune": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
+ "peerDependenciesMeta": {
+ "@deepseek-ai/dsh-compact-tool-result-prune": {
+ "optional": true
+ }
+ },
"dependencies": {
"schemastery": "^3.18.0"
},
@@ -43,6 +49,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
+ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts
index 5d325d57ba..caa1ed9c5c 100644
--- a/packages/compact/compact-basic/src/index.ts
+++ b/packages/compact/compact-basic/src/index.ts
@@ -12,6 +12,8 @@ import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
+// Type-only: makes the optional sibling service available to `ctx.get()`.
+import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
import { resolveConfig } from './config.ts'
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
import { summarizeWithLlm } from './summarizer.ts'
@@ -98,22 +100,35 @@ export class BasicCompactService extends CompactService {
|| retryAttempt >= this.config.maxOverflowRetries
|| signal.aborted) return next()
- let generation: number
+ const generation = agent.session.surface.replaceGeneration
let result: CompactionResult | null
try {
- generation = agent.session.surface.replaceGeneration
result = await this.compactIfNeeded(agent, 'context-overflow', signal)
} catch (recoveryError: unknown) {
const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
+ // A model-free prune can land before later summary work fails. That
+ // durable reduction is sufficient retry proof; do not discard it just
+ // because the optional second phase threw. Cancellation still wins.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
+ if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
+ ctx.logger.warn(
+ `context-overflow compaction failed after durable surface progress: ${message}; `
+ + 'retrying from the replacement surface',
+ )
+ return { action: 'retry' }
+ }
ctx.logger.warn(
- `context-overflow compaction failed: ${message}; preserving the original request error`,
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
+ `context-overflow compaction failed: ${message}; ${signal.aborted
+ ? 'cancellation prevents retry'
+ : 'preserving the original request error'}`,
)
return next()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
- if (signal.aborted || result === null
+ if (signal.aborted
|| agent.session.surface.replaceGeneration <= generation) return next()
- logResult(result, 'context overflow recovery')
+ if (result !== null) logResult(result, 'context overflow recovery')
return { action: 'retry' }
})
}
@@ -142,7 +157,7 @@ export class BasicCompactService extends CompactService {
* @param agent - agent whose latest durable routed request is measured.
* @param trigger - normal post-step pressure or context-overflow recovery.
* @param signal - live turn cancellation signal forwarded to summarization.
- * @returns the latest compaction result, or `null` when no check/work applies.
+ * @returns the latest summary compaction result, or `null` when no summary ran.
*/
override async compactIfNeeded(
agent: Agent,
@@ -152,22 +167,34 @@ export class BasicCompactService extends CompactService {
const model = routedModel(agent.session)
if (model === undefined) return null
const meter = this.ctx.tokenMeter
+ const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
+ let measurement = meter.measure(agent.session)
switch (trigger) {
- case 'context-overflow': {
- const measurement = meter.measure(agent.session)
- const range = selectCompactableRange(agent.session, measurement, 0)
- if (range === null) return null
- return this.compactRegion(range.start, range.end, agent, signal)
- }
+ case 'context-overflow':
+ break
case 'pressure':
+ if (measurement.totalTokens < threshold) return null
break
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
assertNever(trigger, 'compaction trigger')
}
- const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
- let measurement = meter.measure(agent.session)
+ // Pruning is optional so compact-basic remains independently composable.
+ // Once either trigger qualifies, land the model-free pass before choosing
+ // a summary range, then remeasure through the singleton replay fold.
+ const prune = this.ctx.get('toolResultPrune')
+ if (prune !== undefined) {
+ prune.pruneSession(agent.session)
+ measurement = meter.measure(agent.session)
+ }
+
+ if (trigger === 'context-overflow') {
+ const range = selectCompactableRange(agent.session, measurement, 0)
+ if (range === null) return null
+ return this.compactRegion(range.start, range.end, agent, signal)
+ }
+
if (measurement.totalTokens < threshold) return null
let result: CompactionResult | null = null
diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts
index 0a411440b3..9098ebef50 100644
--- a/packages/compact/compact-basic/tests/compact-basic.spec.ts
+++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts
@@ -10,6 +10,7 @@ import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@d
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
+import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
import type { Agent } from '@deepseek-ai/dsh-agent'
const SIGNAL = new AbortController().signal
@@ -97,6 +98,43 @@ function toolConversation(): Session {
return session
}
+/** One closed routed tool step followed by an open turn for rewrite events. */
+function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
+ const session = new Session(SessionId(`oversized-tool-${chars}`))
+ const callId = CallId('oversized')
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ if (withCompactablePrompt) {
+ session.append('user/message', {
+ content: [{ type: 'text', text: 'older history '.repeat(200) }],
+ source: { kind: 'user' },
+ }, { surfaceOp: 'append' })
+ }
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('request/header', {
+ header: { config: { provider: MODEL, model: MODEL } },
+ reason: 'initial',
+ })
+ session.append('assistant/message', {
+ turn: 1,
+ step: 1,
+ content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
+ provenance: { provider: MODEL, model: MODEL },
+ }, { surfaceOp: 'append' })
+ session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
+ session.append('tool/result', {
+ turn: 1,
+ step: 1,
+ callId,
+ content: [{ type: 'text', text: 'X'.repeat(chars) }],
+ isError: false,
+ meta: { presentation: 'preserved' },
+ }, { surfaceOp: 'append' })
+ session.append('step/end', { turn: 1, step: 1 })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
+ return session
+}
+
class TestCompactService extends BasicCompactService {
summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
summaryProvider = 'summary-provider'
@@ -416,6 +454,78 @@ describe('pressure measurement and retention', () => {
})
})
+describe('optional model-free tool-result pruning', () => {
+ const pruneConfig = { thresholdChars: 100, headChars: 20, tailChars: 10 }
+
+ it('does not prune a below-pressure session opportunistically', async () => {
+ const ctx = createContext(10_000)
+ const prune = new ToolResultPruneService(ctx, pruneConfig)
+ const compact = new TestCompactService(ctx, {
+ auto: false,
+ thresholdRatio: 0.8,
+ retainTokens: 100,
+ })
+ const session = oversizedToolResult()
+ const pruneSession = vi.spyOn(prune, 'pruneSession')
+
+ expect(await compactIfNeeded(compact, session)).toBeNull()
+ expect(pruneSession).not.toHaveBeenCalled()
+ expect(compact.calls).toHaveLength(0)
+ expect(session.surface.replaceGeneration).toBe(0)
+ })
+
+ it('skips LLM summarization when pruning alone clears pressure', async () => {
+ const ctx = createContext(1_000)
+ void new ToolResultPruneService(ctx, pruneConfig)
+ const compact = new TestCompactService(ctx, {
+ auto: false,
+ thresholdRatio: 0.5,
+ retainTokens: 50,
+ })
+ const session = oversizedToolResult()
+
+ expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(500)
+ expect(await compactIfNeeded(compact, session)).toBeNull()
+ expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(500)
+ expect(compact.calls).toHaveLength(0)
+ expect(session.surface.replaceGeneration).toBe(1)
+ })
+
+ it('summarizes the pruned surface when pruning is insufficient', async () => {
+ const ctx = createContext(2_000)
+ void new ToolResultPruneService(ctx, pruneConfig)
+ const compact = new TestCompactService(ctx, {
+ auto: false,
+ thresholdRatio: 0.5,
+ retainTokens: 50,
+ })
+ const session = toolConversation()
+
+ expect(await compactIfNeeded(compact, session)).not.toBeNull()
+ expect(compact.calls).toHaveLength(1)
+ expect(compact.calls[0]!.text).toContain('tool result middle pruned')
+ expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300))
+ })
+
+ it('retains the original compact-basic behavior without the optional plugin', async () => {
+ const ctx = createContext(2_000)
+ const compact = new TestCompactService(ctx, {
+ auto: false,
+ thresholdRatio: 0.5,
+ retainTokens: 50,
+ })
+ const session = oversizedToolResult(3_000, true)
+
+ expect(await compactIfNeeded(compact, session)).not.toBeNull()
+ expect(compact.calls).toHaveLength(1)
+ const original = session.events.find(event => event.type === 'tool/result')
+ expect(original?.type === 'tool/result' && original.data.content[0])
+ .toEqual({ type: 'text', text: 'X'.repeat(3_000) })
+ expect(session.events.filter(event =>
+ event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
+ })
+})
+
describe('compaction region transaction', () => {
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
const compact = service()
@@ -876,6 +986,89 @@ describe('automatic listener and loader composition', () => {
expect(session.surface.nodes).toContain(retainedSeq)
})
+ it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => {
+ const ctx = createContext(10_000)
+ void new ToolResultPruneService(ctx, {
+ thresholdChars: 100,
+ headChars: 20,
+ tailChars: 10,
+ })
+ const compact = new TestCompactService(ctx, {
+ thresholdRatio: 1,
+ retainTokens: 900,
+ })
+ const session = oversizedToolResult()
+
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
+ expect(session.surface.replaceGeneration).toBe(1)
+ expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
+ expect(compact.calls).toHaveLength(0)
+ })
+
+ it('continues overflow recovery with summarization on the pruned surface', async () => {
+ const ctx = createContext(10_000)
+ void new ToolResultPruneService(ctx, {
+ thresholdChars: 100,
+ headChars: 20,
+ tailChars: 10,
+ })
+ const compact = new TestCompactService(ctx, {
+ thresholdRatio: 1,
+ retainTokens: 900,
+ })
+ const session = toolConversation()
+
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
+ expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
+ expect(compact.calls).toHaveLength(1)
+ expect(compact.calls[0]!.text).toContain('tool result middle pruned')
+ })
+
+ it('retries from a durable prune when later overflow summarization throws', async () => {
+ const ctx = createContext(10_000)
+ const warnings: string[] = []
+ ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
+ void new ToolResultPruneService(ctx, {
+ thresholdChars: 100,
+ headChars: 20,
+ tailChars: 10,
+ })
+ const compact = new TestCompactService(ctx, {
+ thresholdRatio: 1,
+ retainTokens: 900,
+ })
+ compact.error = new Error('summary unavailable after prune')
+ const session = oversizedToolResult(3_000, true)
+
+ expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
+ expect(session.surface.replaceGeneration).toBe(1)
+ expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
+ expect(session.events.findLast(event => event.type === 'compact/end')?.data)
+ .toMatchObject({ error: 'summary unavailable after prune' })
+ expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface'))
+ })
+
+ it('lets cancellation win when summary throws after a durable prune', async () => {
+ const ctx = createContext(10_000)
+ const controller = new AbortController()
+ void new ToolResultPruneService(ctx, {
+ thresholdChars: 100,
+ headChars: 20,
+ tailChars: 10,
+ })
+ const compact = new TestCompactService(ctx, {
+ thresholdRatio: 1,
+ retainTokens: 900,
+ })
+ compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') }
+ compact.error = new Error('summary cancelled after prune')
+ const session = oversizedToolResult(3_000, true)
+
+ expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
+ .toEqual({ action: 'fail' })
+ expect(session.surface.replaceGeneration).toBe(1)
+ })
+
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
const ctx = createContext()
void new TestCompactService(ctx, {
diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts
index b13e8f8b67..2035627f64 100644
--- a/packages/compact/compact-basic/tests/loader-composition.spec.ts
+++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts
@@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
+import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
let root: string | undefined
let context: Context | undefined
@@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise {
const modules = new Map([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
+ ['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
])
context.loader.internal = {
@@ -50,12 +52,17 @@ async function loadYaml(lines: readonly string[]): Promise {
}
describe('real Loader composition', () => {
- it('loads the flat token-meter and compact-basic YAML shape', async () => {
+ it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-token-meter'",
' config:',
' contextWindow: 4096',
+ "- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
+ ' config:',
+ ' thresholdChars: 100',
+ ' headChars: 20',
+ ' tailChars: 10',
"- name: '@deepseek-ai/dsh-compact-basic'",
' config:',
' thresholdRatio: 0.5',
@@ -68,6 +75,7 @@ describe('real Loader composition', () => {
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(loaded.tokenMeter.contextWindow).toBe(4096)
+ expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
expect((loaded.compact as BasicCompactService).config).toMatchObject({
thresholdRatio: 0.5,
diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json
index 0103ad82a8..47d552c3f0 100644
--- a/packages/compact/compact-basic/tsconfig.json
+++ b/packages/compact/compact-basic/tsconfig.json
@@ -13,6 +13,7 @@
{ "path": "../../llm/token-meter" },
{ "path": "../../core/session" },
{ "path": "../../core/agent" },
- { "path": "../compact" }
+ { "path": "../compact" },
+ { "path": "../compact-tool-result-prune" }
]
}
diff --git a/packages/compact/compact-tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md
new file mode 100644
index 0000000000..c06eab5405
--- /dev/null
+++ b/packages/compact/compact-tool-result-prune/README.md
@@ -0,0 +1,60 @@
+# @deepseek-ai/dsh-compact-tool-result-prune
+
+The replay-safe model-free pruning service (`ctx.toolResultPrune`). It rewrites over-budget `tool/result` surface nodes to a bounded head, a fixed omission marker, and a bounded tail while retaining the full original event in the append-only session log.
+
+This is a concrete companion to [`dsh-compact-basic`](../compact-basic/README.md), not a compaction backend or model-facing tool. Compact-basic reads it through optional `ctx.get('toolResultPrune')`, so either package remains independently composable.
+
+## Service API
+
+`pruneSession(session)` scans one stable snapshot of the current surface. Every over-budget tool result is replaced by one newly appended `tool/result` carrying `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`. The replacement spreads the complete original data and changes only `content`, preserving `turn`, `step`, `callId`, error fields, `meta`, and later data additions. The original event remains available for persistence, replay, and exact-log inspection.
+
+The method throws synchronously when the session rejects a replacement. Replacements committed earlier in the pass remain durable.
+
+`measureContent(blocks)` counts Unicode code points in `text` blocks. `pruneContent(blocks)` returns the bounded replacement or `null` when content is already within the threshold. Non-text blocks are retained at their original relative positions; text slicing never splits a UTF-16 surrogate pair, though it can split a multi-code-point grapheme cluster.
+
+Every emitted result has exactly the configured head budget, fixed marker, and tail budget in text code points, is no larger than `thresholdChars`, and is strictly smaller than the triggering input. A second pass therefore emits no replacement.
+
+## Config
+
+Unrecognized keys fail at plugin construction. Resolved config is detached and deeply immutable.
+
+| Key | Required | Meaning |
+|---|---|---|
+| `thresholdChars` | no (default `8192`) | Prune when combined text exceeds this many Unicode code points. |
+| `headChars` | no (default `4096`) | Leading Unicode code points retained. |
+| `tailChars` | no (default `1024`) | Trailing Unicode code points retained. |
+
+All values are integers; the threshold is positive and head/tail are non-negative. `headChars + marker + tailChars` must fit within `thresholdChars`, so a valid configuration can prune every over-budget result without growth or repeated rewriting.
+
+## Usage
+
+```ts
+import type { Context } from 'cordis'
+import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
+
+export function apply(ctx: Context): void {
+ ctx.plugin(ToolResultPruneService)
+}
+```
+
+## Model Experience
+
+### Pruned tool result
+
+#### What the model sees
+
+Once a compaction trigger qualifies, future requests see the retained head, `\n\n[... tool result middle pruned ...]\n\n`, and retained tail in place of the removed text. Rich blocks keep their order. The model does not see a second copy of the original.
+
+#### Token effect
+
+Each rewritten tool result has at most `thresholdChars` text code points. Pruning itself makes no model call; compact-basic skips summarization when the remeasured request falls below pressure, otherwise the summarizer reads the pruned surface.
+
+#### KV Cache effect
+
+Replacing an earlier result invalidates reuse from the first changed token. The pruned prefix is eligible for reuse while its route, envelope, and preceding history remain identical.
+
+## Known Limitations and Deferred Work
+
+- **Character budgets are not token budgets** — provider token density varies, so `ctx.tokenMeter` remains the authority for deciding whether pruning relieved request pressure.
+- **Pruning is syntactic** — it retains the beginning and end without interpreting which middle lines are semantically important.
+- **Grapheme clusters can split** — code-point slicing protects surrogate pairs but does not perform locale-aware grapheme segmentation.
diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json
new file mode 100644
index 0000000000..81c81eb894
--- /dev/null
+++ b/packages/compact/compact-tool-result-prune/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@deepseek-ai/dsh-compact-tool-result-prune",
+ "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-llm": "^0.0.1",
+ "@deepseek-ai/dsh-session": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "dependencies": {
+ "schemastery": "^3.18.0"
+ },
+ "devDependencies": {
+ "@cordisjs/plugin-include": "workspace:^",
+ "@cordisjs/plugin-loader": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-session": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/compact/compact-tool-result-prune/src/config.ts b/packages/compact/compact-tool-result-prune/src/config.ts
new file mode 100644
index 0000000000..a2d33ac76e
--- /dev/null
+++ b/packages/compact/compact-tool-result-prune/src/config.ts
@@ -0,0 +1,77 @@
+/** Configuration resolution for deterministic tool-result pruning. */
+
+import { deepFreeze } from '@deepseek-ai/dsh-llm'
+import type { ResolvedConfig, ToolResultPruneConfig } from './types.ts'
+
+/** Fixed marker substituted for every removed middle span. */
+export const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
+
+/** Low-friction defaults for coding-agent tool output. */
+export const DEFAULTS: ResolvedConfig = deepFreeze({
+ thresholdChars: 8192,
+ headChars: 4096,
+ tailChars: 1024,
+})
+
+const CONFIG_KEYS: ReadonlySet = new Set([
+ 'thresholdChars',
+ 'headChars',
+ 'tailChars',
+])
+
+/**
+ * Count Unicode code points without splitting surrogate pairs.
+ * @param text - text to measure.
+ * @returns the Unicode code-point count.
+ */
+export function codePointLength(text: string): number {
+ return Array.from(text).length
+}
+
+/**
+ * Resolve and validate pruning budgets.
+ * @param config - raw plugin configuration.
+ * @returns a detached deeply immutable configuration.
+ */
+export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfig {
+ for (const key of Object.keys(config)) {
+ if (!CONFIG_KEYS.has(key)) {
+ throw new Error(
+ `ToolResultPruneConfig: unknown key "${key}" `
+ + '(allowed: thresholdChars, headChars, tailChars)',
+ )
+ }
+ }
+
+ const resolved: ResolvedConfig = {
+ thresholdChars: config.thresholdChars ?? DEFAULTS.thresholdChars,
+ headChars: config.headChars ?? DEFAULTS.headChars,
+ tailChars: config.tailChars ?? DEFAULTS.tailChars,
+ }
+ assertPositiveInteger('thresholdChars', resolved.thresholdChars)
+ assertNonNegativeInteger('headChars', resolved.headChars)
+ assertNonNegativeInteger('tailChars', resolved.tailChars)
+
+ const emittedChars = resolved.headChars
+ + codePointLength(PRUNE_MARKER)
+ + resolved.tailChars
+ if (emittedChars > resolved.thresholdChars) {
+ throw new Error(
+ `ToolResultPruneConfig: headChars + marker + tailChars (${emittedChars}) `
+ + `must be at most thresholdChars (${resolved.thresholdChars})`,
+ )
+ }
+ return deepFreeze(structuredClone(resolved))
+}
+
+function assertPositiveInteger(name: string, value: number): void {
+ if (!Number.isInteger(value) || value <= 0) {
+ throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a positive integer`)
+ }
+}
+
+function assertNonNegativeInteger(name: string, value: number): void {
+ if (!Number.isInteger(value) || value < 0) {
+ throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a non-negative integer`)
+ }
+}
diff --git a/packages/compact/compact-tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts
new file mode 100644
index 0000000000..d4a2daecbc
--- /dev/null
+++ b/packages/compact/compact-tool-result-prune/src/index.ts
@@ -0,0 +1,159 @@
+/**
+ * Replay-safe, model-free tool-result pruning service.
+ *
+ * @module @deepseek-ai/dsh-compact-tool-result-prune
+ */
+
+import { Context, Service } from 'cordis'
+import z from 'schemastery'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
+import type {
+ PrunedEntry,
+ PruneResult,
+ ResolvedConfig,
+ ToolResultPruneConfig,
+} from './types.ts'
+
+export { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
+export type {
+ PrunedEntry,
+ PruneResult,
+ ResolvedConfig,
+ ToolResultPruneConfig,
+} from './types.ts'
+
+declare module 'cordis' {
+ interface Context {
+ toolResultPrune: ToolResultPruneService
+ }
+}
+
+interface SnapshotCandidate {
+ readonly seq: number
+ readonly event: SessionEvent<'tool/result'>
+}
+
+/** Deterministic head/middle/tail pruning for current tool-result surface nodes. */
+export class ToolResultPruneService extends Service {
+ static Config: z = z.object({
+ thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars),
+ headChars: z.number().step(1).min(0).default(DEFAULTS.headChars),
+ tailChars: z.number().step(1).min(0).default(DEFAULTS.tailChars),
+ })
+
+ /** Resolved and immutable character budgets. */
+ readonly config: ResolvedConfig
+
+ constructor(ctx: Context, config: ToolResultPruneConfig = {}) {
+ super(ctx, 'toolResultPrune')
+ this.config = resolveConfig(config)
+ }
+
+ /**
+ * Measure text content in Unicode code points; non-text blocks cost zero.
+ * @param blocks - tool-result content to measure.
+ * @returns total Unicode code points across text blocks.
+ */
+ measureContent(blocks: readonly ContentBlock[]): number {
+ let chars = 0
+ for (const block of blocks) {
+ if (block.type === 'text') chars += codePointLength(block.text)
+ }
+ return chars
+ }
+
+ /**
+ * Replace an over-budget text middle while retaining rich-block order.
+ * Text slicing is by Unicode code point, not UTF-16 code unit, so a retained
+ * boundary cannot split a surrogate pair. Grapheme clusters may still split.
+ * @param blocks - original tool-result content.
+ * @returns pruned content, or `null` when the text is within budget.
+ */
+ pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null {
+ const totalChars = this.measureContent(blocks)
+ if (totalChars <= this.config.thresholdChars) return null
+
+ const removedStart = this.config.headChars
+ const removedEnd = totalChars - this.config.tailChars
+ const pruned: ContentBlock[] = []
+ let consumed = 0
+ let markerInserted = false
+
+ for (const block of blocks) {
+ if (block.type !== 'text') {
+ pruned.push(block)
+ continue
+ }
+
+ const points = Array.from(block.text)
+ const blockStart = consumed
+ const blockEnd = blockStart + points.length
+ const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
+ const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
+ const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart
+ const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : ''
+ if (marker.length > 0) markerInserted = true
+ const text = points.slice(0, headEnd).join('')
+ + marker
+ + points.slice(tailStart).join('')
+ if (text.length > 0) pruned.push({ ...block, text })
+ consumed = blockEnd
+ }
+
+ /* v8 ignore next -- totalChars > threshold and valid budgets guarantee a removed text span. */
+ if (!markerInserted) throw new Error('tool-result prune: failed to locate the removed text span')
+ const charsAfter = this.measureContent(pruned)
+ /* v8 ignore next -- config validation fixes the emitted head + marker + tail budget. */
+ if (charsAfter > this.config.thresholdChars || charsAfter >= totalChars) {
+ throw new Error('tool-result prune: replacement must be smaller and within threshold')
+ }
+ return pruned
+ }
+
+ /**
+ * Prune every over-budget tool result from one stable current-surface snapshot.
+ * Each replacement preserves the complete event data except for `content`,
+ * and points at the shadowed node for durable provenance and replay.
+ * @param session - session whose current surface is rewritten.
+ * @returns landed replacements and aggregate Unicode-code-point savings.
+ * @throws when the session rejects a replacement; replacements committed
+ * earlier in the pass remain durable.
+ */
+ pruneSession(session: Session): PruneResult {
+ const candidates: SnapshotCandidate[] = []
+ for (const seq of [...session.surface.nodes]) {
+ const event = session.events[seq]
+ /* v8 ignore next -- surface seqs are validated contiguous log references. */
+ if (event?.type === 'tool/result') candidates.push({ seq, event })
+ }
+
+ const pruned: PrunedEntry[] = []
+ let charsRemoved = 0
+ for (const { seq, event } of candidates) {
+ const content = this.pruneContent(event.data.content)
+ if (content === null) continue
+ const charsBefore = this.measureContent(event.data.content)
+ const charsAfter = this.measureContent(content)
+ const replacement = session.append('tool/result', {
+ ...event.data,
+ content,
+ }, {
+ surfaceOp: { op: 'replace', start: seq, end: seq },
+ sourceEventSeqs: [seq],
+ })
+ pruned.push({
+ originalSeq: seq,
+ replacementSeq: replacement.seq,
+ callId: event.data.callId,
+ charsBefore,
+ charsAfter,
+ })
+ charsRemoved += charsBefore - charsAfter
+ }
+ return { pruned, charsRemoved }
+ }
+}
+
+export default ToolResultPruneService
diff --git a/packages/compact/compact-tool-result-prune/src/types.ts b/packages/compact/compact-tool-result-prune/src/types.ts
new file mode 100644
index 0000000000..f9dd846f35
--- /dev/null
+++ b/packages/compact/compact-tool-result-prune/src/types.ts
@@ -0,0 +1,40 @@
+import type { CallId } from '@deepseek-ai/dsh-llm'
+
+/** Character-budget policy for deterministic tool-result pruning. */
+export interface ToolResultPruneConfig {
+ /** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */
+ thresholdChars?: number
+ /** Maximum leading Unicode code points retained. Defaults to `4096`. */
+ headChars?: number
+ /** Maximum trailing Unicode code points retained. Defaults to `1024`. */
+ tailChars?: number
+}
+
+/** Validated, detached, deeply immutable pruning configuration. */
+export interface ResolvedConfig {
+ readonly thresholdChars: number
+ readonly headChars: number
+ readonly tailChars: number
+}
+
+/** Provenance and size accounting for one landed surface replacement. */
+export interface PrunedEntry {
+ /** Full-fidelity tool-result event shadowed by the replacement. */
+ readonly originalSeq: number
+ /** Newly appended pruned tool-result event. */
+ readonly replacementSeq: number
+ /** Tool call shared by the original and replacement. */
+ readonly callId: CallId
+ /** Original text size in Unicode code points. */
+ readonly charsBefore: number
+ /** Replacement text size in Unicode code points. */
+ readonly charsAfter: number
+}
+
+/** Aggregate outcome of one stable-surface pruning pass. */
+export interface PruneResult {
+ /** Replacements in the snapshotted surface order. */
+ readonly pruned: readonly PrunedEntry[]
+ /** Total Unicode code points removed across replacements. */
+ readonly charsRemoved: number
+}
diff --git a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts
new file mode 100644
index 0000000000..db4c29ebdb
--- /dev/null
+++ b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts
@@ -0,0 +1,67 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import Loader from '@cordisjs/plugin-loader'
+import Include from '@cordisjs/plugin-include'
+import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
+
+let root: string | undefined
+let context: Context | undefined
+
+afterEach(async () => {
+ await context?.fiber.dispose()
+ context = undefined
+ if (root !== undefined) await rm(root, { recursive: true, force: true })
+ root = undefined
+})
+
+describe('compact-tool-result-prune real Loader composition', () => {
+ it('loads and resolves the flat YAML plugin shape', async () => {
+ root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-'))
+ const configPath = join(root, 'cordis.yml')
+ await writeFile(configPath, [
+ "- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
+ ' config:',
+ ' thresholdChars: 100',
+ ' headChars: 20',
+ ' tailChars: 10',
+ '',
+ ].join('\n'))
+
+ context = new Context()
+ context.baseUrl = pathToFileURL(root).href + '/'
+ await context.plugin(Loader)
+ context.loader.builtins.include = Include
+ context.loader.internal = {
+ version: 'v2',
+ async import(specifier: string) {
+ if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') {
+ throw new Error(`unexpected Loader import: ${specifier}`)
+ }
+ return ToolResultPruneService
+ },
+ } as unknown as NonNullable
+ await context.loader.create({
+ name: 'cordis:include',
+ config: { path: pathToFileURL(configPath).href },
+ })
+ await context.loader.await()
+
+ expect(context.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
+ expect(context.toolResultPrune.config).toEqual({
+ thresholdChars: 100,
+ headChars: 20,
+ tailChars: 10,
+ })
+ })
+
+ it('rejects stale config after plugin schema normalization', async () => {
+ context = new Context()
+ await expect(context.plugin(ToolResultPruneService, {
+ maxChars: 100,
+ } as never)).rejects.toThrow(/unknown key "maxChars"/)
+ })
+})
diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts
new file mode 100644
index 0000000000..bc382c8e4e
--- /dev/null
+++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts
@@ -0,0 +1,239 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
+import * as Invariants from '@deepseek-ai/dsh-invariants'
+import ToolResultPruneService, {
+ codePointLength,
+ DEFAULTS,
+ PRUNE_MARKER,
+ resolveConfig,
+} from '@deepseek-ai/dsh-compact-tool-result-prune'
+import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-compact-tool-result-prune'
+
+const MODEL = 'test-model'
+const SMALL: ToolResultPruneConfig = {
+ thresholdChars: 50,
+ headChars: 4,
+ tailChars: 3,
+}
+
+function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService {
+ return new ToolResultPruneService(new Context(), config)
+}
+
+function appendToolStep(
+ session: Session,
+ turn: number,
+ call: string,
+ content: ContentBlock[],
+ extra: Record = {},
+): number {
+ const callId = CallId(call)
+ session.append('turn/start', {
+ turn,
+ trigger: { kind: 'message', source: { kind: 'user' } },
+ })
+ session.append('step/start', { turn, step: 1 })
+ session.append('assistant/message', {
+ turn,
+ step: 1,
+ content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
+ provenance: { provider: MODEL, model: MODEL },
+ }, { surfaceOp: 'append' })
+ session.append('tool/call', { turn, step: 1, callId, name: 'bash', arguments: '{}' })
+ const result = session.append('tool/result', {
+ turn,
+ step: 1,
+ callId,
+ content,
+ isError: false,
+ ...extra,
+ }, { surfaceOp: 'append' })
+ session.append('step/end', { turn, step: 1 })
+ session.append('turn/end', { turn, reason: { kind: 'completed' } })
+ return result.seq
+}
+
+describe('tool-result pruning configuration', () => {
+ it('resolves detached immutable defaults and partial overrides', () => {
+ const raw = { thresholdChars: 100, headChars: 20, tailChars: 10 }
+ const resolved = resolveConfig(raw)
+ raw.headChars = 1
+ expect(resolved).toEqual({ thresholdChars: 100, headChars: 20, tailChars: 10 })
+ expect(Object.isFrozen(resolved)).toBe(true)
+ expect(DEFAULTS).toEqual({ thresholdChars: 8192, headChars: 4096, tailChars: 1024 })
+ expect(Object.isFrozen(DEFAULTS)).toBe(true)
+ })
+
+ it('rejects stale keys, invalid scalars, and an output budget above threshold', () => {
+ const bad = [
+ [{ thresholdChars: 0 }, /thresholdChars .* positive integer/],
+ [{ headChars: -1 }, /headChars .* non-negative integer/],
+ [{ tailChars: 1.5 }, /tailChars .* non-negative integer/],
+ [{ thresholdChars: 50, headChars: 20, tailChars: 20 }, /headChars \+ marker \+ tailChars/],
+ [{ threshold: 10 }, /unknown key "threshold"/],
+ ] as Array<[unknown, RegExp]>
+ for (const [config, pattern] of bad) {
+ expect(() => resolveConfig(config as ToolResultPruneConfig)).toThrow(pattern)
+ }
+ })
+})
+
+describe('ToolResultPruneService content transform', () => {
+ it('measures text code points only and skips content within threshold', () => {
+ const prune = service()
+ const blocks = [
+ { type: 'text', text: 'a😀b' },
+ { type: 'reasoning', text: 'not measured' },
+ ] satisfies ContentBlock[]
+ expect(prune.measureContent(blocks)).toBe(3)
+ expect(prune.pruneContent(blocks)).toBeNull()
+ expect(codePointLength('a😀b')).toBe(3)
+ })
+
+ it('keeps configured head and tail without splitting surrogate pairs', () => {
+ const prune = service()
+ const result = prune.pruneContent([{ type: 'text', text: '😀'.repeat(60) }])
+ expect(result).toEqual([{
+ type: 'text',
+ text: `${'😀'.repeat(4)}${PRUNE_MARKER}${'😀'.repeat(3)}`,
+ }])
+ expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
+ expect(result![0]).toMatchObject({ type: 'text' })
+ expect((result![0] as { text: string }).text).not.toContain('\uFFFD')
+ })
+
+ it('preserves non-text blocks and their relative ordering across removed text', () => {
+ const prune = service()
+ const reasoning: ContentBlock = { type: 'reasoning', text: 'private-rich-block' }
+ const call: ContentBlock = {
+ type: 'tool-call',
+ id: CallId('nested'),
+ name: 'nested',
+ arguments: '{}',
+ }
+ const result = prune.pruneContent([
+ { type: 'text', text: 'A'.repeat(40) },
+ reasoning,
+ { type: 'text', text: 'B'.repeat(30) },
+ call,
+ { type: 'text', text: 'C'.repeat(30) },
+ ])
+ expect(result).toEqual([
+ { type: 'text', text: `AAAA${PRUNE_MARKER}` },
+ reasoning,
+ call,
+ { type: 'text', text: 'CCC' },
+ ])
+ expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
+ })
+
+ it('supports zero-sized head and tail while still shrinking', () => {
+ const prune = service({
+ thresholdChars: codePointLength(PRUNE_MARKER),
+ headChars: 0,
+ tailChars: 0,
+ })
+ const result = prune.pruneContent([{ type: 'text', text: 'x'.repeat(100) }])
+ expect(result).toEqual([{ type: 'text', text: PRUNE_MARKER }])
+ expect(prune.measureContent(result!)).toBe(prune.config.thresholdChars)
+ })
+})
+
+describe('ToolResultPruneService session transaction', () => {
+ it('prunes a stable snapshot, preserves all data, and records provenance', () => {
+ const session = new Session(SessionId('preserve'))
+ const originalSeq = appendToolStep(session, 1, 'one', [{
+ type: 'text',
+ text: 'x'.repeat(100),
+ }], {
+ isError: true,
+ error: { name: 'ExitError', code: 'EXIT_1' },
+ meta: { diff: ['a', 'b'] },
+ futureField: { nested: true },
+ })
+ session.append('turn/start', {
+ turn: 2,
+ trigger: { kind: 'message', source: { kind: 'user' } },
+ })
+
+ const result = service().pruneSession(session)
+ expect(result.pruned).toHaveLength(1)
+ expect(result.charsRemoved).toBeGreaterThan(0)
+ const entry = result.pruned[0]!
+ expect(entry).toMatchObject({ originalSeq, callId: CallId('one'), charsBefore: 100 })
+ expect(entry.charsAfter).toBeLessThanOrEqual(50)
+
+ const original = session.events[originalSeq]!
+ const replacement = session.events[entry.replacementSeq]! as SurfaceEvent
+ expect(original).toMatchObject({
+ type: 'tool/result',
+ data: { content: [{ type: 'text', text: 'x'.repeat(100) }] },
+ })
+ expect(replacement).toMatchObject({
+ type: 'tool/result',
+ data: {
+ turn: 1,
+ step: 1,
+ callId: CallId('one'),
+ isError: true,
+ error: { name: 'ExitError', code: 'EXIT_1' },
+ meta: { diff: ['a', 'b'] },
+ futureField: { nested: true },
+ },
+ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq },
+ sourceEventSeqs: [originalSeq],
+ })
+ expect(session.surface.nodes).not.toContain(originalSeq)
+ })
+
+ it('prunes multiple results, skips short ones, and converges in one pass', () => {
+ const session = new Session(SessionId('multiple'))
+ appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
+ appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }])
+ appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
+ session.append('turn/start', {
+ turn: 4,
+ trigger: { kind: 'message', source: { kind: 'user' } },
+ })
+ const prune = service()
+ const first = prune.pruneSession(session)
+ const second = prune.pruneSession(session)
+ expect(first.pruned.map(entry => entry.callId)).toEqual([CallId('a'), CallId('c')])
+ expect(first.charsRemoved).toBe(
+ first.pruned.reduce((sum, entry) => sum + entry.charsBefore - entry.charsAfter, 0),
+ )
+ expect(second).toEqual({ pruned: [], charsRemoved: 0 })
+ })
+
+ it('replays to the identical pruned model messages', () => {
+ const session = new Session(SessionId('replay'))
+ appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
+ session.append('turn/start', {
+ turn: 2,
+ trigger: { kind: 'message', source: { kind: 'user' } },
+ })
+ service().pruneSession(session)
+ const replay = new Session(session.id, [...session.events])
+ expect(replay.deriveMessages()).toEqual(session.deriveMessages())
+ expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
+ })
+
+ it('runs under real invariants between closed steps but not outside a turn', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(Invariants)
+ const prune = new ToolResultPruneService(ctx, SMALL)
+ const session = ctx.sessions.create(SessionId('invariants'))
+ appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
+ expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/)
+ session.append('turn/start', {
+ turn: 2,
+ trigger: { kind: 'message', source: { kind: 'user' } },
+ })
+ expect(() => prune.pruneSession(session)).not.toThrow()
+ })
+})
diff --git a/packages/compact/compact-tool-result-prune/tsconfig.json b/packages/compact/compact-tool-result-prune/tsconfig.json
new file mode 100644
index 0000000000..e021fa336e
--- /dev/null
+++ b/packages/compact/compact-tool-result-prune/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": ["src"],
+ "references": [
+ { "path": "../../../vendor/cosmokit" },
+ { "path": "../../../vendor/cordis" },
+ { "path": "../../../vendor/schemastery" },
+ { "path": "../../llm/llm" },
+ { "path": "../../core/session" }
+ ]
+}
diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md
index c4a2dc7a5d..6e33b6e570 100644
--- a/packages/compact/compact/README.md
+++ b/packages/compact/compact/README.md
@@ -38,7 +38,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
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**,
+4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
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.
@@ -90,5 +90,5 @@ No conversation-cache invalidation. A consumer's auxiliary request can reuse onl
## 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 indivisible unit (a closed tool pair or a large pasted `user/message`) alone exceeding the budget cannot be compacted.
+- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index e94dbf7735..26ce375479 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -527,6 +527,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'toolResultPrune',
+ summary: 'Deterministic head/middle/tail pruning for current tool-result surface nodes.',
+ methods: [
+ {
+ signature: 'measureContent(blocks: readonly ContentBlock[]): number',
+ jsDoc: '/**\n * Measure text content in Unicode code points; non-text blocks cost zero.\n * @param blocks - tool-result content to measure.\n * @returns total Unicode code points across text blocks.\n */',
+ },
+ {
+ signature: 'pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null',
+ jsDoc: '/**\n * Replace an over-budget text middle while retaining rich-block order.\n * Text slicing is by Unicode code point, not UTF-16 code unit, so a retained\n * boundary cannot split a surrogate pair. Grapheme clusters may still split.\n * @param blocks - original tool-result content.\n * @returns pruned content, or `null` when the text is within budget.\n */',
+ },
+ {
+ signature: 'pruneSession(session: Session): PruneResult',
+ jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */',
+ },
+ ],
+ },
{
key: 'tools',
summary: 'Tool registry and execution pipeline.',
@@ -1221,6 +1239,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
},
+ {
+ name: 'PrunedEntry',
+ declaration: 'export interface PrunedEntry {\n readonly originalSeq: number;\n readonly replacementSeq: number;\n readonly callId: CallId;\n readonly charsBefore: number;\n readonly charsAfter: number;\n}',
+ },
+ {
+ name: 'PruneResult',
+ declaration: 'export interface PruneResult {\n readonly pruned: readonly PrunedEntry[];\n readonly charsRemoved: number;\n}',
+ },
{
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index ceae25f3a3..1b584ac3d9 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -32,7 +32,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
-- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
+- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
@@ -49,7 +49,7 @@ Durable values need one accepted representation, not a check followed by a secon
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
-- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
+- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
### Request-header reconstruction (`request-header.ts`)
@@ -79,7 +79,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
-- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface entries behind a summary checkpoint. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership and `replaceGeneration`.
+- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
## Model Experience
diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts
index cf03ae685d..90a2181d53 100644
--- a/packages/core/session/src/surface.ts
+++ b/packages/core/session/src/surface.ts
@@ -5,6 +5,7 @@
* @module @deepseek-ai/dsh-session/surface
*/
+import { isDeepStrictEqual } from 'node:util'
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
/** Runtime counterpart of the message-producing event union. */
@@ -187,11 +188,37 @@ function replacementRange(
}
}
+/** Restrict a tool-result replacement to one current result's content. */
+function assertToolResultRewrite(
+ event: SessionEvent,
+ shadowedSeqs: readonly number[],
+ events: readonly SessionEvent[],
+): void {
+ if (event.type !== 'tool/result') return
+ if (shadowedSeqs.length !== 1) {
+ throw new Error('tool/result surface replacement must rewrite exactly one current node')
+ }
+ for (const originalSeq of shadowedSeqs) {
+ const original = events[originalSeq]
+ if (original?.type !== 'tool/result') {
+ throw new Error('tool/result surface replacement must target a current tool/result')
+ }
+ const originalRest = { ...original.data } as Record
+ const replacementRest = { ...event.data } as Record
+ delete originalRest['content']
+ delete replacementRest['content']
+ if (!isDeepStrictEqual(originalRest, replacementRest)) {
+ throw new Error('tool/result surface replacement may change only content')
+ }
+ }
+}
+
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
function planSurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
expectedSeq: number,
+ events: readonly SessionEvent[],
): SurfacePlan | undefined {
if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
@@ -204,6 +231,7 @@ function planSurfaceEvent(
}
const range = replacementRange(state, surfaceOp)
assertProvenance(event, range.shadowedSeqs)
+ assertToolResultRewrite(event, range.shadowedSeqs, events)
return {
kind: 'replace',
seq: event.seq,
@@ -218,8 +246,9 @@ function applySurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
expectedSeq: number,
+ events: readonly SessionEvent[],
): SurfaceFoldReplacement | undefined {
- const plan = planSurfaceEvent(state, event, expectedSeq)
+ const plan = planSurfaceEvent(state, event, expectedSeq, events)
if (plan?.kind === 'append') {
state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
@@ -239,13 +268,13 @@ function applySurfaceEvent(
* Replay a complete session log through the canonical surface fold.
* @param events - session events in contiguous seq order.
* @returns detached current sequences and replacement history.
- * @throws when an event violates surface metadata, provenance, or range rules.
+ * @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
*/
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
const state = createFoldState()
const replacements: SurfaceFoldReplacement[] = []
for (const [index, event] of events.entries()) {
- const replacement = applySurfaceEvent(state, event, index)
+ const replacement = applySurfaceEvent(state, event, index, events)
if (replacement !== undefined) replacements.push(replacement)
}
return { nodes: [...state.nodes], replacements }
@@ -266,7 +295,7 @@ export class SurfaceManager implements SessionSurface {
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
- planSurfaceEvent(this._state, event, this.log.length)
+ planSurfaceEvent(this._state, event, this.log.length, this.log)
}
/** Monotonic count of folded positional replacements. */
@@ -285,7 +314,7 @@ export class SurfaceManager implements SessionSurface {
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
- applySurfaceEvent(this._state, this.log[i]!, i)
+ applySurfaceEvent(this._state, this.log[i]!, i, this.log)
this._lastProcessedSeq = i
}
}
diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md
index 139caf8584..aef85dbb02 100644
--- a/packages/support/invariants/README.md
+++ b/packages/support/invariants/README.md
@@ -4,7 +4,7 @@ Runtime event-contract assertions intended for development diagnostics. This pur
The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
-Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own.
+Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates complete provenance and positional replacement, restricts `tool/result` replacement to one current result's `content`, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own.
Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only.
@@ -31,8 +31,7 @@ Session log (per session):
- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
-- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal).
-- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance.
+- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call. A Session-validated replacement is a turn-enclosed rewrite, not another execution. A `tool/call` may still have no result when the execution pipeline throws.
Agent status (per agent):
diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts
index deabeeea17..91b4da4566 100644
--- a/packages/support/invariants/src/index.ts
+++ b/packages/support/invariants/src/index.ts
@@ -151,6 +151,16 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
break
}
case 'tool/result': {
+ // Session has already validated a provenance-backed content rewrite.
+ // It is durable turn work, not a second execution of the original call.
+ if (event.surfaceOp !== 'append') {
+ if (trace.openTurn === null) {
+ throw new InvariantError(
+ 'tool/result surface replacement appended outside any open turn',
+ )
+ }
+ break
+ }
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step)
// A result needs a prior matching call in the same step. (The converse
// does NOT hold: a call may have no result — a throwing tool-execution
diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts
index c6fe68d6d2..d2c21fe020 100644
--- a/packages/support/invariants/tests/invariants.spec.ts
+++ b/packages/support/invariants/tests/invariants.spec.ts
@@ -189,6 +189,19 @@ describe('session-log invariants', () => {
.toThrow(/no prior tool\/call/)
})
+ it('keeps fresh tool-result appends open-step and pending-call checked', async () => {
+ const { ctx } = await setup()
+ const session = ctx.sessions.create()
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ expect(() => session.append('tool/result', {
+ turn: 1,
+ step: 1,
+ callId: CallId('closed'),
+ content: [],
+ isError: false,
+ }, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
+ })
+
it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
@@ -465,6 +478,41 @@ describe('HMR safety', () => {
})
describe('surface contract under the invariants composition', () => {
+ async function toolResultRewriteFixture(openRewriteTurn = true) {
+ const { ctx } = await setup()
+ const session = ctx.sessions.create()
+ session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+ const unrelated = session.append('user/message', {
+ content: [{ type: 'text', text: 'request' }],
+ source: { kind: 'user' },
+ }, { surfaceOp: 'append' })
+ session.append('step/start', { turn: 1, step: 1 })
+ session.append('tool/call', {
+ turn: 1,
+ step: 1,
+ callId: CallId('rewrite'),
+ name: 'echo',
+ arguments: '{}',
+ })
+ const originalData = {
+ turn: 1,
+ step: 1,
+ callId: CallId('rewrite'),
+ content: [{ type: 'text' as const, text: 'original' }],
+ isError: true,
+ error: { name: 'ExitError', code: 'EXIT_1' },
+ meta: { presentation: { kind: 'terminal', output: 'full output' } },
+ futureField: { nested: ['preserve', 1] },
+ }
+ const original = session.append('tool/result', originalData, { surfaceOp: 'append' })
+ session.append('step/end', { turn: 1, step: 1 })
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ if (openRewriteTurn) {
+ session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
+ }
+ return { session, unrelated, original }
+ }
+
it('accepts well-formed surface metadata', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
@@ -487,6 +535,71 @@ describe('surface contract under the invariants composition', () => {
// no throw — well-formed replace op
})
+ it('treats a provenance-backed tool-result replacement as a turn-enclosed rewrite', async () => {
+ const { session, original } = await toolResultRewriteFixture()
+
+ expect(() => session.append('tool/result', {
+ ...original.data,
+ content: [{ type: 'text', text: 'pruned' }],
+ }, {
+ surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
+ sourceEventSeqs: [original.seq],
+ })).not.toThrow()
+ })
+
+ it('rejects a tool-result replacement outside a turn', async () => {
+ const { session, original } = await toolResultRewriteFixture(false)
+
+ expect(() => session.append('tool/result', {
+ ...original.data,
+ content: [{ type: 'text', text: 'pruned' }],
+ }, {
+ surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
+ sourceEventSeqs: [original.seq],
+ })).toThrow(/outside any open turn/)
+ })
+
+ it('rejects a tool-result replacement targeting an unrelated current node', async () => {
+ const { session, unrelated, original } = await toolResultRewriteFixture()
+ expect(() => session.append('tool/result', {
+ ...original.data,
+ content: [{ type: 'text', text: 'forged' }],
+ }, {
+ surfaceOp: { op: 'replace', start: unrelated.seq, end: unrelated.seq },
+ sourceEventSeqs: [unrelated.seq],
+ })).toThrow(/must target a current tool\/result/)
+ })
+
+ it('rejects a multi-node tool-result replacement even with complete provenance', async () => {
+ const { session, unrelated, original } = await toolResultRewriteFixture()
+ expect(() => session.append('tool/result', {
+ ...original.data,
+ content: [{ type: 'text', text: 'forged' }],
+ }, {
+ surfaceOp: { op: 'replace', start: unrelated.seq, end: original.seq },
+ sourceEventSeqs: [unrelated.seq, original.seq],
+ })).toThrow(/must rewrite exactly one current node/)
+ })
+
+ it.each([
+ ['callId', { callId: CallId('forged') }],
+ ['turn', { turn: 2 }],
+ ['step', { step: 2 }],
+ ['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }],
+ ['meta', { meta: { presentation: { kind: 'generic' } } }],
+ ['future data', { futureField: { nested: ['changed'] } }],
+ ])('rejects a content rewrite with altered %s', async (_label, altered) => {
+ const { session, original } = await toolResultRewriteFixture()
+ expect(() => session.append('tool/result', {
+ ...original.data,
+ ...altered,
+ content: [{ type: 'text', text: 'pruned' }],
+ }, {
+ surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
+ sourceEventSeqs: [original.seq],
+ })).toThrow(/may change only content/)
+ })
+
it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md
index 44748add2a..9841d1ce2e 100644
--- a/packages/ui/acp/README.md
+++ b/packages/ui/acp/README.md
@@ -114,7 +114,7 @@ When optional consumers are loaded, ACP form answers become the exact JSON shape
#### Token effect
-Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens.
+Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten.
#### KV Cache effect
diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md
index 0b108cae67..497973731e 100644
--- a/packages/ui/acp/acp-feature-support.md
+++ b/packages/ui/acp/acp-feature-support.md
@@ -82,7 +82,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `agent_thought_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` reasoning-delta. |
| `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. |
| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). |
-| `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. |
+| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts
index fe6b2630c7..540d95b203 100644
--- a/packages/ui/acp/src/index.ts
+++ b/packages/ui/acp/src/index.ts
@@ -1039,7 +1039,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* loaded transcript reconstructs the USER side of each turn without echoing
* a live `session/prompt` back to the client
* - `tool/call` → `tool_call` (pending)
- * - `tool/result` → `tool_call_update` (completed/failed)
+ * - appended `tool/result` → `tool_call_update` (completed/failed)
+ * - replacement `tool/result` → no update (context rewrite, not execution)
*
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
@@ -1100,6 +1101,10 @@ export function streamSessionEventUpdate(
return
}
case 'tool/result': {
+ // Replacements (for example model-free pruning) are transcript rewrites,
+ // not repeated tool executions. Re-presenting one would consume no
+ // pending call and could clobber the original terminal/diff completion.
+ if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta)
notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
return
diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts
index b8de5b8557..1d84727de4 100644
--- a/packages/ui/acp/tests/load.spec.ts
+++ b/packages/ui/acp/tests/load.spec.ts
@@ -161,6 +161,53 @@ describe('acp bridge — session/load replay', () => {
expect(meta.terminal_exit?.exit_code).toBe(0)
})
+ it('keeps one terminal completion live and on replay when a pruning replacement is logged', async () => {
+ live = await makeBridgeHarness({
+ storageDir,
+ withBash: true,
+ script: [toolCallResponse('c1', 'bash', { command: 'echo full', description: 'Print full output' }), textResponse('done')],
+ })
+ await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
+ const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
+ await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
+
+ const session = live.ctx.agents.get(SessionId(sessionId))!.session
+ const original = session.events.find(event => event.type === 'tool/result')
+ if (original?.type !== 'tool/result') throw new Error('expected original tool/result')
+ const liveCompletions = () => live!.updates.filter(update =>
+ update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
+ expect(liveCompletions()).toHaveLength(1)
+ expect((liveCompletions()[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
+ .toBe('full\n')
+
+ session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
+ session.append('tool/result', {
+ ...original.data,
+ content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
+ }, {
+ surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
+ sourceEventSeqs: [original.seq],
+ })
+ session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
+
+ // The replacement is durable but is not another live completion.
+ expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
+ expect(JSON.stringify(session.deriveMessages())).toContain('tool result middle pruned')
+ expect(liveCompletions()).toHaveLength(1)
+ await live.dispose()
+ live = undefined
+
+ loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
+ await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
+ await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
+
+ const replayed = loader.updates.filter(update =>
+ update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
+ expect(replayed).toHaveLength(1)
+ expect((replayed[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
+ .toBe('full\n')
+ })
+
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
// Stall persistence so transport closes while resume is pending. Whether the SDK rejects first
// or the bridge's post-await guard fires, no agent may survive for the dead connection.
diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts
index 415e9afb33..51a3e3115b 100644
--- a/packages/ui/acp/tests/stream-update.spec.ts
+++ b/packages/ui/acp/tests/stream-update.spec.ts
@@ -105,6 +105,22 @@ describe('streamSessionEventUpdate', () => {
expect((failed[0] as { status: string }).status).toBe('failed')
})
+ it('emits no execution update for a tool-result surface replacement', () => {
+ const replacement = {
+ ...evt('tool/result', {
+ turn: 1,
+ step: 1,
+ callId: CallId('c1'),
+ content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
+ isError: false,
+ }),
+ seq: 2,
+ surfaceOp: { op: 'replace', start: 1, end: 1 },
+ sourceEventSeqs: [1],
+ } as SessionEvent
+ expect(updatesFor(replacement)).toEqual([])
+ })
+
it('drops non-text tool-result content (text-only)', () => {
const update = updatesFor(evt('tool/result', {
turn: 1, step: 1, callId: CallId('c1'),
@@ -450,6 +466,16 @@ describe('terminal-card mapping (capability-gated)', () => {
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
+ const prunedResultEvent = {
+ ...resultEvent,
+ seq: 2,
+ data: {
+ ...resultEvent.data,
+ content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
+ },
+ surfaceOp: { op: 'replace', start: 1, end: 1 },
+ sourceEventSeqs: [1],
+ } as SessionEvent
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
const presenter = new ToolPresenter(registryOf(tool))
@@ -477,6 +503,27 @@ describe('terminal-card mapping (capability-gated)', () => {
})
})
+ it('live/replay translation preserves the original terminal completion across a pruning rewrite', () => {
+ const updates = termUpdates(
+ termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }),
+ true,
+ '/work/proj',
+ callEvent,
+ resultEvent,
+ prunedResultEvent,
+ )
+ expect(updates).toHaveLength(2)
+ expect(updates[1]).toEqual({
+ sessionUpdate: 'tool_call_update',
+ toolCallId: 'c1',
+ status: 'completed',
+ _meta: {
+ terminal_output: { terminal_id: 'c1', data: 'hi\n' },
+ terminal_exit: { terminal_id: 'c1', exit_code: 0 },
+ },
+ })
+ })
+
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
@@ -633,17 +680,38 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
// call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`,
// which presentResult narrows into a `diff` result card the bridge forwards as `{ type:
// 'diff' }` content blocks. The real tool is required because its result metadata is the contract.
- it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
+ it('live/replay translation keeps the applied diff when a pruning rewrite follows', async () => {
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
// The applied hunk the tool would compute and persist on the result meta.
const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
- const [, resultUpdate] = updatesWith(
+ const originalResult = evt('tool/result', {
+ turn: 1,
+ step: 1,
+ callId: CallId('e1'),
+ content: [{ type: 'text', text: 'ok' }],
+ isError: false,
+ meta,
+ })
+ const replacement = {
+ ...originalResult,
+ seq: 3,
+ data: {
+ ...originalResult.data,
+ content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
+ },
+ surfaceOp: { op: 'replace', start: 2, end: 2 },
+ sourceEventSeqs: [2],
+ } as SessionEvent
+ const updates = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
- evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
+ originalResult,
+ replacement,
)
+ expect(updates).toHaveLength(2)
+ const resultUpdate = updates[1]
expect(resultUpdate).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'e1',
diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md
index eda7d00b8b..07cc6a5b21 100644
--- a/packages/ui/stdio/README.md
+++ b/packages/ui/stdio/README.md
@@ -31,7 +31,7 @@ Each non-empty terminal line outside an active question becomes one text block,
#### Token effect
-Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens.
+Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line.
#### KV Cache effect
diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts
index 6f73d948bf..497f609c93 100644
--- a/packages/ui/stdio/src/index.ts
+++ b/packages/ui/stdio/src/index.ts
@@ -145,6 +145,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
inReasoning = false
output.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
+ // A surface replacement changes future model context; it is not another
+ // execution. Keep the original full-fidelity terminal presentation and
+ // suppress duplicate output during live delivery or log replay.
+ if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
const { content } = event.data
const text = content.filter(block => block.type === 'text').map(block => block.text).join('')
output.write(`\n [tool result] ${text}\n `)
diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts
index a3069462ff..478914849c 100644
--- a/packages/ui/stdio/tests/stdio.spec.ts
+++ b/packages/ui/stdio/tests/stdio.spec.ts
@@ -367,6 +367,43 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('[tool result] file.txt')
})
+ it('renders one full-fidelity result whether the event feed is live or replayed', async () => {
+ const { ctx, out } = await setup()
+ const session = makeSession('main')
+ const original = {
+ type: 'tool/result',
+ seq: 2,
+ time: 0,
+ data: {
+ turn: 1,
+ step: 1,
+ callId: 'c1',
+ content: [{ type: 'text', text: 'full terminal output' }],
+ isError: false,
+ meta: { terminal: { output: 'full terminal output' } },
+ },
+ surfaceOp: 'append',
+ } as SessionEvent
+ const replacement = {
+ ...original,
+ seq: 3,
+ data: {
+ ...original.data,
+ content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
+ },
+ surfaceOp: { op: 'replace', start: 2, end: 2 },
+ sourceEventSeqs: [2],
+ } as SessionEvent
+
+ // Stdio consumes the same session/event shape whether a host forwards a
+ // live append or replays a stored log through the rendering feed.
+ for (const event of [original, replacement]) ctx.emit('session/event', session, event)
+
+ expect(out.text().match(/\[tool result\]/g)).toHaveLength(1)
+ expect(out.text()).toContain('full terminal output')
+ expect(out.text()).not.toContain('tool result middle pruned')
+ })
+
it('renders a todo/write session event as a glyphed checklist', async () => {
const { ctx, out } = await setup()
const session = {} as Session
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0dae355ecc..7712cf7732 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -119,6 +119,9 @@ importers:
'@deepseek-ai/dsh-compact-basic':
specifier: workspace:*
version: link:../packages/compact/compact-basic
+ '@deepseek-ai/dsh-compact-tool-result-prune':
+ specifier: workspace:*
+ version: link:../packages/compact/compact-tool-result-prune
'@deepseek-ai/dsh-fs-local':
specifier: workspace:*
version: link:../packages/fs/fs-local
@@ -387,6 +390,9 @@ importers:
'@deepseek-ai/dsh-compact':
specifier: workspace:^
version: link:../compact
+ '@deepseek-ai/dsh-compact-tool-result-prune':
+ specifier: workspace:^
+ version: link:../compact-tool-result-prune
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
@@ -406,6 +412,31 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
+ packages/compact/compact-tool-result-prune:
+ dependencies:
+ schemastery:
+ specifier: ^3.18.0
+ version: 3.18.0
+ devDependencies:
+ '@cordisjs/plugin-include':
+ specifier: workspace:^
+ version: link:../../../vendor/include
+ '@cordisjs/plugin-loader':
+ specifier: workspace:^
+ version: link:../../../vendor/loader
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
+
packages/context/time-context:
dependencies:
schemastery:
@@ -2543,6 +2574,9 @@ importers:
'@deepseek-ai/dsh-compact-basic':
specifier: workspace:^
version: link:../../packages/compact/compact-basic
+ '@deepseek-ai/dsh-compact-tool-result-prune':
+ specifier: workspace:^
+ version: link:../../packages/compact/compact-tool-result-prune
'@deepseek-ai/dsh-fs':
specifier: workspace:^
version: link:../../packages/fs/fs
diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json
index ac056842df..6f34f2b15b 100644
--- a/python/sdk-runtime/package.json
+++ b/python/sdk-runtime/package.json
@@ -32,6 +32,7 @@
"@deepseek-ai/dsh-jsonrpc-demo": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
+ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index fd6fa051f5..97556dd019 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -58,6 +58,7 @@ export const LINK_MAP: Record = {
CodeRunResult: 'code-runtime.md',
CompactionResult: 'compaction.md',
CompactionTrigger: 'compaction.md',
+ PruneResult: 'compaction.md',
FileReadOutcome: 'filesystem.md',
FsDirEntry: 'filesystem.md',
FsEditOutcome: 'filesystem.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index afb0940a2a..c05aa1e54f 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -95,6 +95,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['compact-basic'],
note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.',
},
+ {
+ key: 'toolResultPrune',
+ pkg: 'compact-tool-result-prune',
+ title: 'Model-free tool-result pruning',
+ mode: 'core',
+ consumers: ['compact-basic'],
+ note: 'Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction.',
+ },
{
key: 'sessions',
pkg: 'session',
@@ -441,7 +449,7 @@ const APP_EXAMPLES = [
title: 'REPL Agent App Composition',
label: 'examples/repl-agent',
config: 'examples/repl-agent/cordis.yml',
- summary: 'The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.',
+ summary: 'The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, tool-result pruning, compaction, and both subagent transports on top of the stdio app package.',
},
{
id: 'tui',
@@ -909,7 +917,7 @@ function renderLifecycle(): string {
'',
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
'',
- '`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Recovery compacts between the closed failed step and a fresh retry step, and returns retry only when the surface replacement generation advances; otherwise the original request error remains authoritative.',
+ '`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
'',
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
'',
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 3233dcca26..7daf60e344 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -157,6 +157,8 @@
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" },
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionTrigger", "source": "packages/compact/compact/src/index.ts" },
+ { "doc": "docs/core-data-structures/compaction.md", "symbol": "PrunedEntry", "source": "packages/compact/compact-tool-result-prune/src/types.ts" },
+ { "doc": "docs/core-data-structures/compaction.md", "symbol": "PruneResult", "source": "packages/compact/compact-tool-result-prune/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" },
diff --git a/tsconfig.build.json b/tsconfig.build.json
index a6d4a6b4a0..9e87410441 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -43,6 +43,7 @@
{ "path": "./packages/code-runtime/code-runtime-worker" },
{ "path": "./packages/compact/compact" },
{ "path": "./packages/compact/compact-basic" },
+ { "path": "./packages/compact/compact-tool-result-prune" },
{ "path": "./packages/llm/llm-deepseek" },
{ "path": "./packages/llm/llm-pi-ai" },
{ "path": "./packages/bash/bash-local" },
diff --git a/tsconfig.json b/tsconfig.json
index d2c211cd3a..1fbd7e362c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -70,6 +70,7 @@
{ "path": "./packages/fs/tool-fs-search" },
{ "path": "./packages/compact/compact" },
{ "path": "./packages/compact/compact-basic" },
+ { "path": "./packages/compact/compact-tool-result-prune" },
{ "path": "./packages/web/web" },
{ "path": "./packages/web/web-search-exa" },
{ "path": "./packages/web/web-search-perplexity" },