From ce96104a774120c72097d8a89755ec1eb3cfb001 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 18:02:15 +0800 Subject: [PATCH 1/6] feat(compact): prune tool results before summarization (round 1) --- docs/agent-lifecycle.md | 2 +- docs/architecture.md | 3 +- docs/capability-seams.md | 5 + docs/config-catalog.md | 16 ++ docs/cordis-catalog/services.md | 14 ++ docs/core-data-structures/compaction.md | 2 +- docs/module-graph.md | 7 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 8 +- ...ction-pressure-and-overflow-recovery.zh.md | 8 +- .../2026-06-18-compaction-capability-seam.md | 15 +- docs/tool-catalog.md | 4 +- examples/coding-agent/README.md | 3 +- examples/coding-agent/composition.md | 5 +- examples/coding-agent/cordis.yml | 4 + examples/coding-agent/tests/harness.ts | 2 + packages/compact/README.md | 5 +- packages/compact/compact-basic/README.md | 9 +- packages/compact/compact-basic/package.json | 7 + packages/compact/compact-basic/src/index.ts | 24 +- .../compact-basic/tests/compact-basic.spec.ts | 147 +++++++++++ .../tests/loader-composition.spec.ts | 10 +- packages/compact/compact-basic/tsconfig.json | 3 +- packages/compact/tool-result-prune/README.md | 50 ++++ .../compact/tool-result-prune/package.json | 40 +++ .../compact/tool-result-prune/src/config.ts | 77 ++++++ .../compact/tool-result-prune/src/index.ts | 157 ++++++++++++ .../compact/tool-result-prune/src/types.ts | 40 +++ .../tests/loader-composition.spec.ts | 67 +++++ .../tests/tool-result-prune.spec.ts | 237 ++++++++++++++++++ .../compact/tool-result-prune/tsconfig.json | 15 ++ .../cordis/tool-cordis/src/api-catalog.ts | 17 ++ .../core/tools/tests/gen-tool-catalog.spec.ts | 5 + packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 11 + .../invariants/tests/invariants.spec.ts | 45 ++++ pnpm-lock.yaml | 31 +++ python/sdk-runtime/package.json | 1 + scripts/gen-doc-graphs.ts | 12 +- scripts/gen-tool-catalog.ts | 29 ++- tsconfig.build.json | 1 + tsconfig.json | 1 + 42 files changed, 1093 insertions(+), 52 deletions(-) create mode 100644 packages/compact/tool-result-prune/README.md create mode 100644 packages/compact/tool-result-prune/package.json create mode 100644 packages/compact/tool-result-prune/src/config.ts create mode 100644 packages/compact/tool-result-prune/src/index.ts create mode 100644 packages/compact/tool-result-prune/src/types.ts create mode 100644 packages/compact/tool-result-prune/tests/loader-composition.spec.ts create mode 100644 packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts create mode 100644 packages/compact/tool-result-prune/tsconfig.json diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index c6010d4139..274d6d9c87 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -55,7 +55,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 9750ea598c..d2289ff731 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages add services (`ctx | `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.toolResultPrune` | [`compact/tool-result-prune`](../packages/compact/tool-result-prune/README.md) | optional model-free tool-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 | @@ -106,7 +107,7 @@ Each step renders one prompt assembly. Plugins contribute ordered sections, tool Post-tool context follows all results, preserving call/result adjacency. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering while the step signal remains open. Leftover steering becomes next-turn input. `agent/turn-stop` is terminal through close and flush: later steering is discarded, while ordinary queued prompts survive. -When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. It also consumes canonical context overflow at `agent/request-error`, but authorizes retry only after a tool-balanced compaction advances `surface.replaceGeneration`. The same turn signal owns both summarization paths. +When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. Once pressure or canonical context overflow qualifies, it runs optional `ctx.toolResultPrune` rewriting before summary selection and remeasures the replayed surface. Overflow recovery authorizes retry after either pruning or tool-balanced summary compaction advances `surface.replaceGeneration`. The same turn signal owns both paths. ### Failure Boundaries diff --git a/docs/capability-seams.md b/docs/capability-seams.md index eb59f27f32..816fc0ea3b 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_tool_result_prune["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"] @@ -127,6 +129,7 @@ flowchart LR pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks pkg_token_meter --> svc_tokenMeter + pkg_tool_result_prune --> svc_toolResultPrune pkg_tools --> svc_tools pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -173,6 +176,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 @@ -195,6 +199,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` | [`tool-result-prune`](../packages/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), [`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), [`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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7adaf791ad..cb266a8119 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -930,6 +930,22 @@ export interface Config { Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) +## `@deepseek-ai/dsh-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/tool-result-prune/src/types.ts:4`](../packages/compact/tool-result-prune/src/types.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 951881a8a7..e2fc41b943 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -272,6 +272,20 @@ Types: [Message](../core-data-structures/core.md) 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 +measureContent(blocks: readonly ContentBlock[]): number +pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null +pruneSession(session: Session): PruneResult +``` + +Types: [ContentBlock](../core-data-structures/core.md) + +Source: [`packages/compact/tool-result-prune/src/index.ts:39`](../../packages/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 6d3ae30071..3a008a0b95 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -58,6 +58,6 @@ export 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/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. 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, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/module-graph.md b/docs/module-graph.md index 990692b6e5..fbe9afd59b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -45,6 +45,7 @@ flowchart TD subgraph group_compact["packages/compact"] pkg_compact["compact"] pkg_compact_basic["compact-basic"] + pkg_tool_result_prune["tool-result-prune"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -168,6 +169,8 @@ flowchart TD pkg_skill_local --> pkg_skill pkg_compact --> pkg_llm pkg_compact --> pkg_session + pkg_tool_result_prune --> pkg_llm + pkg_tool_result_prune --> pkg_session pkg_web_fetch_local --> pkg_timeout pkg_web_fetch_local --> pkg_web pkg_web_search_deepseek --> pkg_web @@ -185,6 +188,7 @@ flowchart TD pkg_compact_basic --> pkg_llm pkg_compact_basic --> pkg_session pkg_compact_basic --> pkg_token_meter + pkg_compact_basic --> pkg_tool_result_prune pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session @@ -407,6 +411,7 @@ flowchart TD | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tool-result-prune`](../packages/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) | @@ -415,7 +420,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | -| [`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), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter), [`tool-result-prune`](../packages/compact/tool-result-prune) | | [`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) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index 7cfb11d29d..bd13a336f1 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/docs/rfc/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: 7d68bc32d3860bf5edd94c4eda76922c91ae6af2 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 2315bd4d9ca9b93eb9a8d4850f917e6aa1bc6476 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 99dc7625b8e185464d7a8a1ea8eda5baf0674df7 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 4f84d4435341005c058e32582e6d26b2a9f29bc1 diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index 7d68bc32d3..99dc7625b8 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -32,9 +32,9 @@ 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 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 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. 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. @@ -44,7 +44,7 @@ The default summarizer still resolves explicit configuration, then the latest lo Lifecycle tests pin post-step ordering after durable tool/context/steering work, content-less and max-token successes, final-adapter dispatch/iterator/in-band boundaries, retry numbering, attempt reset, cancellation, disposal, synthetic tool results, and original error identity. -Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, below-threshold forced overflow, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. +Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, pressure-gated pruning, pruning-only relief, summarization from pruned input, optional-plugin fallback, pruning-only and summarized overflow recovery, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. ## Alternatives considered @@ -56,7 +56,7 @@ Compact tests pin low-friction service-wide defaults, actual routed-model select ## Consequences -Pressure now describes the actual completed routed request, including durable tool results and request-only prefix fields, rather than a provisional next-call guess. 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. +Pressure describes the actual completed routed request, including durable tool results and request-only prefix fields, rather than a provisional next-call guess. 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. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 2315bd4d9c..4f84d44353 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -32,9 +32,9 @@ 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 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 @@ -44,7 +44,7 @@ Status: implemented 生命周期测试固定 post-step 位于持久工具、上下文与 steering 工作之后,覆盖无内容与达到 token 上限的成功、最终适配器分发/迭代器/带内边界、重试编号、尝试重置、取消、销毁、合成工具结果与原始错误身份。 -压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、低于阈值的强制溢出、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 +压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、可选插件回退、仅剪枝与剪枝后摘要两类溢出恢复、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 ## 考虑过的替代方案 @@ -56,7 +56,7 @@ Status: implemented ## 后果 -压力现在描述实际完成的路由请求,包括持久工具结果与仅请求前缀字段,而不是对下一次调用的临时猜测。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有上限、受取消所有,并保持单调:只有模型可见的表层 generation 变化后才重试。 +压力描述实际完成的路由请求,包括持久工具结果与仅请求前缀字段,而不是对下一次调用的临时猜测。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有上限、受取消所有,并保持单调:只有模型可见的表层 generation 变化后才重试。 代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分单个不可分割的超大消息或工具单元。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index ecbf401428..0f210cbd8e 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -18,7 +18,8 @@ Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capabil 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-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 -The original pre-step placement used a provisional envelope and could not see final `agent/request` routing, tools, provider output, tool results, buffered context, or steering. The corrected lifecycle fires serial `agent/post-step(agent, turn, step, signal)` after those successful 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. +The original pre-step placement used a provisional envelope and could not see final `agent/request` routing, tools, provider output, tool results, buffered context, or steering. The corrected lifecycle fires serial `agent/post-step(agent, turn, step, signal)` after those successful 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, it invokes optional `ctx.toolResultPrune`, remeasures the durable surface, and summarizes only if pruning did not restore 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 RFC](../../implemented/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, 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 RFC](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` assistant/message → tool/result/context/steering @@ -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 `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, node)` and `toolPairingBalancedAfter(session, node)`, 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 instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. -- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node 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/coding-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-invariants`** treats fresh appended tool results as executions that require an open step and pending call, while provenance-backed replacements are turn-enclosed surface rewrites. Positional replacement and complete-source checks validate the rewritten node. +- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-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/tool-catalog.md b/docs/tool-catalog.md index 121bfd09f7..085b165a2f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -5,9 +5,9 @@ Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. -This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md). +This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any model-facing package is missing from the generator's boot manifest; service-only packages that share the prefix are explicitly excluded. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md). -Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +Scope: shipped model-facing product tools under `packages/*/tool-*`, each booted with its DEFAULT config. Runtime service packages such as `tool-result-prune` do not register `ctx.tools` schemas and are explicitly excluded. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. ## Tool Package Map diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 9e627324d4..869f9eb625 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -56,6 +56,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 + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins | +| `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`) | | `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio | @@ -66,7 +67,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 boot smokes run in the default e2e gate: `tests/keyless-smoke.e2e.ts` (the full real tree, dummy key, no prompt → no model call) and `tests/code-mode-keyless-smoke.e2e.ts` (the same guard for the Code Mode overlay). diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index c9a68810d2..5f35097603 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -3,7 +3,7 @@ # Coding Agent App Composition -The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. +The coding REPL 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_coding_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_coding_token_meter + plugin_coding_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-tool-result-prune"] + cfg --> plugin_coding_tool_result_prune plugin_coding_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_coding_compact_basic plugin_coding_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -58,6 +60,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-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/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 2e9aa17786..ba86b99e81 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -50,6 +50,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-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/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index cf79809f0c..6c06202a30 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -12,6 +12,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-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' @@ -68,6 +69,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 b5f5987571..e251bfe442 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](../../docs/rfc/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](../../docs/rfc/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`) | +| `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 RFC](../../docs/rfc/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/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 RFC](../../docs/rfc/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 2afd955f35..75be39aa1b 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,12 +9,13 @@ 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. +- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../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; a single unit larger than the budget remains 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 model and cap 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()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call 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. +- **Overflow recovery** — below-threshold overflow bypasses normal retention and first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including pruning-only progress on an otherwise indivisible surface; 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. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error. `summarize()` 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, model, maxTokens? }`), which is logged on `compact/summary`. @@ -49,15 +50,15 @@ 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-tool-result-prune`](../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 ### Conversation history -**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. +**What the model sees**: 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. -**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. +**Token effect**: Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget. #### Conversation checkpoint preamble diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index d50fa45777..55ca301878 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-tool-result-prune": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-tool-result-prune": { + "optional": true + } + }, "dependencies": { "schemastery": "^3.18.0" }, @@ -43,6 +49,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-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 b694b08194..91e8ad0cea 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 } 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-tool-result-prune' import { resolveConfig } from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' @@ -111,9 +113,9 @@ export class BasicCompactService extends CompactService { 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 +144,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,15 +154,25 @@ 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) + if (trigger === 'pressure' && measurement.totalTokens < threshold) return null + + // 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 measurement = meter.measure(agent.session) const range = selectCompactableRange(agent.session, measurement, 0) if (range === null) return null return this.compactRegion(agent.session, range.start, range.end, agent, signal) } - const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) - let measurement = meter.measure(agent.session) 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 3d049608b7..190fb026e7 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -9,6 +9,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-tool-result-prune' import type { Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal @@ -94,6 +95,42 @@ 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: { model: MODEL } }, + reason: 'initial', + }) + session.append('assistant/message', { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }], + }, { 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' }] summaryModel = 'summary-model' @@ -401,6 +438,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('rejects an agent that does not own the exact target session before mutation', async () => { const compact = service() @@ -875,6 +984,44 @@ describe('automatic listener and loader composition', () => { expect(session.surface.nodes.some(node => node.seq === retainedSeq)).toBe(true) }) + 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('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..7294224452 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-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-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-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..5dd00b83f1 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": "../tool-result-prune" } ] } diff --git a/packages/compact/tool-result-prune/README.md b/packages/compact/tool-result-prune/README.md new file mode 100644 index 0000000000..e6d50f229c --- /dev/null +++ b/packages/compact/tool-result-prune/README.md @@ -0,0 +1,50 @@ +# @deepseek-ai/dsh-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. + +`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-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. + +## 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/tool-result-prune/package.json b/packages/compact/tool-result-prune/package.json new file mode 100644 index 0000000000..6b7ac89742 --- /dev/null +++ b/packages/compact/tool-result-prune/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-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/tool-result-prune/src/config.ts b/packages/compact/tool-result-prune/src/config.ts new file mode 100644 index 0000000000..a2d33ac76e --- /dev/null +++ b/packages/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/tool-result-prune/src/index.ts b/packages/compact/tool-result-prune/src/index.ts new file mode 100644 index 0000000000..287b0f2500 --- /dev/null +++ b/packages/compact/tool-result-prune/src/index.ts @@ -0,0 +1,157 @@ +/** + * Replay-safe, model-free tool-result pruning service. + * + * @module @deepseek-ai/dsh-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. + */ + pruneSession(session: Session): PruneResult { + const candidates: SnapshotCandidate[] = [] + for (const node of [...session.surface.nodes]) { + const event = session.events[node.seq] + /* v8 ignore next -- surface seqs are validated contiguous log references. */ + if (event?.type === 'tool/result') candidates.push({ seq: node.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/tool-result-prune/src/types.ts b/packages/compact/tool-result-prune/src/types.ts new file mode 100644 index 0000000000..f9dd846f35 --- /dev/null +++ b/packages/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/tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/tool-result-prune/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..fef9326d2a --- /dev/null +++ b/packages/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-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('tool-result-prune real Loader composition', () => { + it('loads and resolves the flat YAML plugin shape', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-tool-result-prune-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-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-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/tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts new file mode 100644 index 0000000000..235ecdab52 --- /dev/null +++ b/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts @@ -0,0 +1,237 @@ +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-tool-result-prune' +import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-tool-result-prune' + +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: '{}' }], + }, { 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.some(node => node.seq === originalSeq)).toBe(false) + }) + + 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/tool-result-prune/tsconfig.json b/packages/compact/tool-result-prune/tsconfig.json new file mode 100644 index 0000000000..e021fa336e --- /dev/null +++ b/packages/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/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 89103923e2..e257991832 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -230,6 +230,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'estimateMessage(message: Message): number', ], }, + { + key: 'toolResultPrune', + summary: 'Deterministic head/middle/tail pruning for current tool-result surface nodes.', + methods: [ + 'measureContent(blocks: readonly ContentBlock[]): number', + 'pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null', + 'pruneSession(session: Session): PruneResult', + ], + }, { key: 'tools', summary: 'Tool registry and execution pipeline.', @@ -793,6 +802,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/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 1ba64f6326..8657dca3ca 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -69,6 +69,11 @@ describe('gen-tool-catalog assertManifestComplete', () => { // is unlisted, so the guard must fire and name them. expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/) expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/) + try { + assertManifestComplete([]) + } catch (error) { + expect(String(error)).not.toContain('tool-result-prune') + } }) }) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 43e2b93bef..5ef2139db2 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -31,7 +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). +- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call, while a provenance-backed single-node `replace` is a turn-enclosed surface rewrite of an already-executed result. A `tool/call` may still have no result when the execution pipeline throws. - **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. Agent status (per agent): diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 14134984ba..f33f7b4cec 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -232,6 +232,17 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr break } case 'tool/result': { + // A replacement rewrites an already-executed result whose recorded + // turn/step can be closed. Surface provenance above validates the rewrite; + // only fresh appends consume an open step's pending call. + if (se.surfaceOp !== undefined && se.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 af84c569b9..986cd93e7c 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() @@ -487,6 +500,38 @@ describe('surface invariants', () => { // no throw — well-formed replace op }) + it('treats a provenance-backed tool-result replacement as a turn-enclosed rewrite', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('tool/call', { + turn: 1, + step: 1, + callId: CallId('rewrite'), + name: 'echo', + arguments: '{}', + }) + const original = session.append('tool/result', { + turn: 1, + step: 1, + callId: CallId('rewrite'), + content: [{ type: 'text', text: 'original' }], + isError: false, + }, { 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' } } }) + + 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('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => { const { ctx } = await setup() const session = ctx.sessions.create() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 334a95346d..b0935bde01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-result-prune': + specifier: workspace:^ + version: link:../tool-result-prune '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -263,6 +266,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/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: @@ -2112,6 +2140,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/tool-result-prune '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 297a76d8d5..a436f859fc 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-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-doc-graphs.ts b/scripts/gen-doc-graphs.ts index e532eaa3c3..db6acf2c43 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -94,6 +94,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['compact-basic'], note: 'Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements.', }, + { + key: 'toolResultPrune', + pkg: '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', @@ -415,7 +423,7 @@ const APP_EXAMPLES = [ title: 'Coding Agent App Composition', label: 'examples/coding-agent', config: 'examples/coding-agent/cordis.yml', - summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', + summary: 'The coding REPL 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: 'cordis', @@ -851,7 +859,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/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3e3a0ce81b..4a0efe66a7 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -1,8 +1,9 @@ /** * Generate `docs/tool-catalog.md` from schemas collected by booting each tool * plugin. Runtime registration is the source of truth for computed schemas; - * the manifest is checked against every on-disk `tool-*` package. `--check` - * verifies the committed artifact. Rationale and ownership live in + * the manifest is checked against every on-disk model-facing `tool-*` package; + * non-model service packages with that prefix are explicitly excluded. + * `--check` verifies the committed artifact. Rationale and ownership live in * `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`. */ @@ -38,6 +39,9 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' +/** `tool-*` leaves that are runtime services, not contributors to `ctx.tools`. */ +const NON_MODEL_TOOL_PACKAGES = new Set(['tool-result-prune']) + /** * Tool package plus its hand-maintained boot recipe. The caller mounts the * prompt and registry; each recipe supplies only package-specific seams and @@ -77,9 +81,10 @@ interface ToolPackage { } /** - * The boot manifest: every shipped tool package (a `tool-*` leaf under - * `packages/`). Ordered by package name (the render order); the completeness - * guard proves it is exhaustive against the on-disk glob. + * The boot manifest: every shipped model-facing tool package (a `tool-*` leaf + * under `packages/`, excluding {@link NON_MODEL_TOOL_PACKAGES}). Ordered by + * package name (the render order); the completeness guard proves it is + * exhaustive against the filtered on-disk glob. */ const TOOL_PACKAGES: ToolPackage[] = [ { @@ -256,8 +261,9 @@ interface CatalogPackage { export type ToolCatalog = CatalogPackage[] /** - * Assert the boot manifest covers every shipped tool package on disk (a - * `tool-*` leaf under `packages/`). + * Assert the boot manifest covers every shipped model-facing tool package on + * disk (a `tool-*` leaf under `packages/`, excluding explicit service-only + * entries in {@link NON_MODEL_TOOL_PACKAGES}). * Booting has no source declaration to enumerate, so this glob restores the * "a new tool cannot be silently undocumented" guarantee: an unlisted package * fails the generator (and the freshness gate) until it is added to @@ -266,7 +272,10 @@ export type ToolCatalog = CatalogPackage[] * `scanRoot` defaults to the repo root; a test may point it at a fixture tree. */ export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void { - const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort() + const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }) + .map(p => basename(p)) + .filter(dir => !NON_MODEL_TOOL_PACKAGES.has(dir)) + .sort() const listed = new Set(packages.map(p => p.dir)) const missing = onDisk.filter(dir => !listed.has(dir)) if (missing.length > 0) { @@ -339,9 +348,9 @@ export function render(catalog: ToolCatalog): string { '', 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', '', - 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', + 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any model-facing package is missing from the generator\'s boot manifest; service-only packages that share the prefix are explicitly excluded. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', '', - 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + 'Scope: shipped model-facing product tools under `packages/*/tool-*`, each booted with its DEFAULT config. Runtime service packages such as `tool-result-prune` do not register `ctx.tools` schemas and are explicitly excluded. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', '## Tool Package Map', '', diff --git a/tsconfig.build.json b/tsconfig.build.json index 40d57030cb..a099fc3a23 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -38,6 +38,7 @@ { "path": "./packages/code-runtime/code-runtime-worker" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, + { "path": "./packages/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 f39c097809..1decda2db0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,6 +60,7 @@ { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, + { "path": "./packages/compact/tool-result-prune" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, { "path": "./packages/web/web-search-perplexity" }, From 171bae5c20ee23abfafaf7a244e13ce1a2692649 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 18:28:31 +0800 Subject: [PATCH 2/6] fix(compact): harden pruning integration (round 2) --- docs/architecture.md | 4 +- docs/core-data-structures/compaction.md | 2 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 6 +- ...ction-pressure-and-overflow-recovery.zh.md | 6 +- packages/compact/compact-basic/README.md | 4 +- packages/compact/compact-basic/src/index.ts | 19 +++- .../compact-basic/tests/compact-basic.spec.ts | 45 +++++++++ packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 73 ++++++++++---- .../invariants/tests/invariants.spec.ts | 96 +++++++++++++++---- packages/ui/acp/README.md | 2 +- packages/ui/acp/acp-feature-support.md | 2 +- packages/ui/acp/src/index.ts | 7 +- packages/ui/acp/tests/load.spec.ts | 47 +++++++++ packages/ui/acp/tests/stream-update.spec.ts | 74 +++++++++++++- packages/ui/stdio/README.md | 2 +- packages/ui/stdio/src/index.ts | 4 + packages/ui/stdio/tests/stdio.spec.ts | 37 +++++++ 19 files changed, 372 insertions(+), 64 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d2289ff731..ff45d38389 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,11 +107,11 @@ Each step renders one prompt assembly. Plugins contribute ordered sections, tool Post-tool context follows all results, preserving call/result adjacency. Steering drains before `agent/post-step`, which observes durable output, results, context, and steering while the step signal remains open. Leftover steering becomes next-turn input. `agent/turn-stop` is terminal through close and flush: later steering is discarded, while ordinary queued prompts survive. -When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. Once pressure or canonical context overflow qualifies, it runs optional `ctx.toolResultPrune` rewriting before summary selection and remeasures the replayed surface. Overflow recovery authorizes retry after either pruning or tool-balanced summary compaction advances `surface.replaceGeneration`. The same turn signal owns both paths. +When loaded, `dsh-compact-basic` consumes that post-step checkpoint for `ctx.tokenMeter` pressure under the actual routed header. Once pressure or canonical context overflow qualifies, it runs optional `ctx.toolResultPrune` rewriting before summary selection and remeasures the replayed surface. Overflow recovery authorizes retry after either pruning or tool-balanced summary compaction advances `surface.replaceGeneration`, including when later summary work fails after a prune. The same turn signal owns both paths, and cancellation still wins. ### Failure Boundaries -The turn is the containment boundary. `LlmService` preserves and privately tags errors from final adapter selection, dispatch, and iteration. Those errors and terminal in-band error/aborted finishes close the failed step before `agent/request-error`; retry reconstructs the next numbered step from the log, while decline or failed recovery preserves the provider error. Attempts count consecutive failures and reset after success. +The turn is the containment boundary. `LlmService` preserves and privately tags errors from final adapter selection, dispatch, and iteration. Those errors and terminal in-band error/aborted finishes close the failed step before `agent/request-error`; retry reconstructs the next numbered step from the log, while decline or recovery failure before any replacement preserves the provider error. Attempts count consecutive failures and reset after success. Prompt, middleware, result, tool, post-step, and continuation failures remain ordinary `agent/error` failures. Cancellation and disposal beat recovery. Durable undispatched tool calls receive synthetic `ABORTED` results, preventing dangling replay. `cancel()` clears queues and aborts active work; disposal awaits quiescence before unregistering. diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 3a008a0b95..e3774b87cd 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -58,6 +58,6 @@ export 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`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/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. 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. +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/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, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index bd13a336f1..c8eef0f1c9 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/docs/rfc/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: 99dc7625b8e185464d7a8a1ea8eda5baf0674df7 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 4f84d4435341005c058e32582e6d26b2a9f29bc1 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: deedb81f8cf75ab80b70e2ef3148ba76d1278886 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 51d28fa243f522eedcac29002670575889d48369 diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index 99dc7625b8..deedb81f8c 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -34,9 +34,9 @@ If cancellation lands after assistant tool calls are durable but before all call 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 model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. -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. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. +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 still resolves explicit configuration, then the latest logged route, then agent options. Because direct `llm/stream` middleware may reroute that auxiliary call, `compact/summary.model` records the final mutable `GenerateOptions.model` observed after dispatch rather than the pre-waterfall candidate. @@ -44,7 +44,7 @@ The default summarizer still resolves explicit configuration, then the latest lo Lifecycle tests pin post-step ordering after durable tool/context/steering work, content-less and max-token successes, final-adapter dispatch/iterator/in-band boundaries, retry numbering, attempt reset, cancellation, disposal, synthetic tool results, and original error identity. -Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, pressure-gated pruning, pruning-only relief, summarization from pruned input, optional-plugin fallback, pruning-only and summarized overflow recovery, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. +Compact tests pin low-friction service-wide defaults, actual routed-model selection, unlisted-model measurement, unified pressure-and-retention decisions, pressure-gated pruning, pruning-only relief, summarization from pruned input, optional-plugin fallback, pruning-only and summarized overflow recovery, prune-then-summary-failure progress, newest tool-pair retention, non-shrinking rejection, generation proof, caps, disabled listeners, single downstream delegation, and auxiliary summary routing provenance. Real-loop composition covers both thrown and in-band overflow: the failed step closes, compaction lands between attempts, and the next numbered request is reconstructed from the replacement surface. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 4f84d44353..51d28fa243 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -34,9 +34,9 @@ Status: implemented 对于 `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' }`。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 +对于规范化溢出,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.model` 记录分发后最终可变的 `GenerateOptions.model`,而不是 waterfall 之前的候选值。 @@ -44,7 +44,7 @@ Status: implemented 生命周期测试固定 post-step 位于持久工具、上下文与 steering 工作之后,覆盖无内容与达到 token 上限的成功、最终适配器分发/迭代器/带内边界、重试编号、尝试重置、取消、销毁、合成工具结果与原始错误身份。 -压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、可选插件回退、仅剪枝与剪枝后摘要两类溢出恢复、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 +压缩测试固定低摩擦服务级默认值、实际路由模型选择、未列出模型计量、统一压力与保留决策、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、可选插件回退、仅剪枝与剪枝后摘要两类溢出恢复、剪枝后摘要失败的持久进展、最新工具配对保留、非缩小拒绝、generation 证明、上限、禁用监听器、单次下游委托与辅助摘要路由来源。真实循环组合同时覆盖抛出式和带内溢出:失败 step 关闭,压缩落在两次尝试之间,下一个编号请求从替换表层重建。 ## 考虑过的替代方案 diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 75be39aa1b..7acc1d0351 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -15,8 +15,8 @@ This backend owns the compaction policy: - **Summarization** — a direct `llm/stream` call uses the configured model and cap 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()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call 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 first prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including pruning-only progress on an otherwise indivisible surface; 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. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error. +- **Overflow recovery** — below-threshold overflow bypasses normal retention and first 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. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress. `summarize()` 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, model, maxTokens? }`), which is logged on `compact/summary`. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 91e8ad0cea..34567f7e07 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -100,15 +100,28 @@ 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() } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 190fb026e7..094a22a11e 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1022,6 +1022,51 @@ describe('automatic listener and loader composition', () => { 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/support/invariants/README.md b/packages/support/invariants/README.md index 5ef2139db2..d80905e6ec 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -31,7 +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. -- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call, while a provenance-backed single-node `replace` is a turn-enclosed surface rewrite of an already-executed result. A `tool/call` may still have no result when the execution pipeline throws. +- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call. A replacement exemption applies only to a provenance-backed rewrite of one current `tool/result` node whose complete data is identical except for `content`; it must still be turn-enclosed. A `tool/call` may still have no result when the execution pipeline throws. - **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. Agent status (per agent): diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index f33f7b4cec..0e12bfa246 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -7,6 +7,7 @@ * @module @deepseek-ai/dsh-invariants */ +import { isDeepStrictEqual } from 'node:util' import type { Context } from 'cordis' import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm' @@ -50,13 +51,14 @@ interface SessionTrace { pendingCalls: Set /** Every seq seen so far — validates `sourceEventSeqs` references. */ knownSeqs: Set - /** - * The seqs currently on the surface linked list, in linked-list order - * (head to tail). A replace reorders this relative to seq order (the new - * node takes the replaced range's position), so range validation is - * positional, not by seq comparison. - */ - surface: number[] + /** Current surface nodes in linked-list order, with immutable event identity. */ + surface: SurfaceTraceNode[] +} + +/** Immutable identity retained only while an event is on the current surface. */ +interface SurfaceTraceNode { + seq: number + event: SessionEvent } /** One accepted event's deferred mutation of a live session trace. */ @@ -70,8 +72,9 @@ interface SessionTraceTransition { | { kind: 'clear' } /** The event's mutation of the derived surface order. */ surface: - | { kind: 'none' | 'append' } - | { kind: 'replace'; start: number; count: number } + | { kind: 'none' } + | { kind: 'append'; node: SurfaceTraceNode } + | { kind: 'replace'; start: number; count: number; node: SurfaceTraceNode } /** The committed event sequence to add to the known-sequence set. */ seq: number } @@ -85,6 +88,18 @@ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step: } } +/** Compare future-safe tool-result data while deliberately excluding content. */ +function sameToolResultDataExceptContent( + original: SessionEvent<'tool/result'>['data'], + replacement: SessionEvent<'tool/result'>['data'], +): boolean { + const originalRest = { ...original } as Record + const replacementRest = { ...replacement } as Record + delete originalRest['content'] + delete replacementRest['content'] + return isDeepStrictEqual(originalRest, replacementRest) +} + /** Validate one candidate event without mutating the committed session trace. */ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition { // seq is strictly monotonic — the spine of replay equivalence. lastSeq @@ -139,14 +154,14 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr // positional range — every shadowed node must appear in sourceEventSeqs. if (se.surfaceOp !== undefined) { if (se.surfaceOp === 'append') { - surface = { kind: 'append' } + surface = { kind: 'append', node: { seq: event.seq, event: se } } } else { const { start, end } = se.surfaceOp - const startIdx = trace.surface.indexOf(start) + const startIdx = trace.surface.findIndex(node => node.seq === start) if (startIdx === -1) { throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) } - const endIdx = trace.surface.indexOf(end) + const endIdx = trace.surface.findIndex(node => node.seq === end) if (endIdx === -1) { throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) } @@ -155,13 +170,18 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr } // Every node the replace shadows (surface positions [startIdx, endIdx] // inclusive) must appear in sourceEventSeqs — the provenance contract. - const shadowed = trace.surface.slice(startIdx, endIdx + 1) + const shadowed = trace.surface.slice(startIdx, endIdx + 1).map(node => node.seq) const recorded = new Set(se.sourceEventSeqs ?? []) const missing = shadowed.filter(seq => !recorded.has(seq)) if (missing.length > 0) { throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) } - surface = { kind: 'replace', start: startIdx, count: shadowed.length } + surface = { + kind: 'replace', + start: startIdx, + count: shadowed.length, + node: { seq: event.seq, event: se }, + } } } @@ -232,15 +252,26 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr break } case 'tool/result': { - // A replacement rewrites an already-executed result whose recorded - // turn/step can be closed. Surface provenance above validates the rewrite; - // only fresh appends consume an open step's pending call. + // Only a content-only rewrite of one CURRENT tool-result node may bypass + // open-step/pending-call checks. The trace retains immutable surface event + // identity, so this validation never indexes a mutable or stale session. if (se.surfaceOp !== undefined && se.surfaceOp !== 'append') { if (trace.openTurn === null) { throw new InvariantError( 'tool/result surface replacement appended outside any open turn', ) } + const { start, end } = se.surfaceOp + if (start !== end) { + throw new InvariantError('tool/result surface replacement must rewrite exactly one current node') + } + const original = trace.surface.find(node => node.seq === start)?.event + if (original?.type !== 'tool/result') { + throw new InvariantError('tool/result surface replacement must target a current tool/result') + } + if (!sameToolResultDataExceptContent(original.data, event.data)) { + throw new InvariantError('tool/result surface replacement may change only content') + } break } requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step) @@ -302,10 +333,14 @@ function applyTransition(trace: SessionTrace, transition: SessionTraceTransition case 'none': break case 'append': - trace.surface.push(transition.seq) + trace.surface.push(transition.surface.node) break case 'replace': - trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq) + trace.surface.splice( + transition.surface.start, + transition.surface.count, + transition.surface.node, + ) break /* v8 ignore next -- validateEvent produces this closed transition union */ default: diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index 986cd93e7c..c55f3f1147 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -478,6 +478,39 @@ describe('HMR safety', () => { }) describe('surface invariants', () => { + async function toolResultRewriteFixture() { + 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' } }) + 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() @@ -501,27 +534,7 @@ describe('surface invariants', () => { }) it('treats a provenance-backed tool-result replacement as a turn-enclosed rewrite', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - session.append('tool/call', { - turn: 1, - step: 1, - callId: CallId('rewrite'), - name: 'echo', - arguments: '{}', - }) - const original = session.append('tool/result', { - turn: 1, - step: 1, - callId: CallId('rewrite'), - content: [{ type: 'text', text: 'original' }], - isError: false, - }, { 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' } } }) + const { session, original } = await toolResultRewriteFixture() expect(() => session.append('tool/result', { ...original.data, @@ -532,6 +545,47 @@ describe('surface invariants', () => { })).not.toThrow() }) + 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 3650cf27d4..15733cb9d9 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -99,7 +99,7 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa **What the model sees**: When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, and streamed session updates are UI-only. -**Token effect**: Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. +**Token effect**: 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. ### Permission preset switches diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 29b0abc073..f1c3a918d5 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 c464ffd22b..66719d0ddd 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -904,7 +904,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 @@ -965,6 +966,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 f57767fb38..4c22681b79 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -162,6 +162,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(AgentId(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 2afa49e1d4..c2884e0b1f 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 b7d320880d..2327857b82 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -27,7 +27,7 @@ The plugin seeds display labels from the live agent registry, then tracks `agent **What the model sees**: Each non-empty terminal line outside an active question becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. -**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. +**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. 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. ### Terminal user-interaction answers diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 1e665381ce..aea10827c7 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -124,6 +124,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 7bb6a6f245..f757f62401 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -288,6 +288,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 From ba4a57c7d19c3119f1f3876a8472b758902ddc9d Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 18:51:26 +0800 Subject: [PATCH 3/6] refactor(compact): align pruning contracts (round 3) --- docs/architecture.md | 2 +- docs/capability-seams.md | 6 ++-- docs/config-catalog.md | 32 +++++++++---------- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/compaction.md | 2 +- docs/module-graph.md | 12 +++---- .../2026-06-18-session-surface.md | 2 +- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +-- ...mpaction-pressure-and-overflow-recovery.md | 6 ++-- ...ction-pressure-and-overflow-recovery.zh.md | 6 ++-- .../2026-06-18-compaction-capability-seam.md | 14 ++++---- docs/tool-catalog.md | 4 +-- examples/coding-agent/composition.md | 4 +-- examples/coding-agent/cordis.yml | 2 +- examples/coding-agent/tests/harness.ts | 2 +- packages/compact/README.md | 4 +-- packages/compact/compact-basic/README.md | 10 +++--- packages/compact/compact-basic/package.json | 6 ++-- packages/compact/compact-basic/src/index.ts | 2 +- .../compact-basic/tests/compact-basic.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 6 ++-- packages/compact/compact-basic/tsconfig.json | 2 +- .../README.md | 4 +-- .../package.json | 2 +- .../src/config.ts | 0 .../src/index.ts | 2 +- .../src/types.ts | 0 .../tests/loader-composition.spec.ts | 10 +++--- .../tests/tool-result-prune.spec.ts | 4 +-- .../tsconfig.json | 0 packages/compact/compact/README.md | 2 +- .../core/tools/tests/gen-tool-catalog.spec.ts | 5 --- pnpm-lock.yaml | 10 +++--- python/sdk-runtime/package.json | 2 +- scripts/gen-doc-graphs.ts | 2 +- scripts/gen-tool-catalog.ts | 29 ++++++----------- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 38 files changed, 97 insertions(+), 111 deletions(-) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/README.md (96%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/package.json (94%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/src/config.ts (100%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/src/index.ts (99%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/src/types.ts (100%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/tests/loader-composition.spec.ts (84%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/tests/tool-result-prune.spec.ts (98%) rename packages/compact/{tool-result-prune => compact-tool-result-prune}/tsconfig.json (100%) diff --git a/docs/architecture.md b/docs/architecture.md index ff45d38389..6c4fd39622 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.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.toolResultPrune` | [`compact/tool-result-prune`](../packages/compact/tool-result-prune/README.md) | optional model-free tool-result pruning | +| `ctx.toolResultPrune` | [`compact/compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | optional model-free tool-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 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 816fc0ea3b..4ce3619025 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -16,7 +16,7 @@ flowchart LR pkg_compact_basic["compact-basic"] pkg_token_meter["token-meter"] svc_tokenMeter["ctx.tokenMeter
Replay token measurement"] - pkg_tool_result_prune["tool-result-prune"] + 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"] @@ -104,6 +104,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_llm --> svc_llm @@ -129,7 +130,6 @@ flowchart LR pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks pkg_token_meter --> svc_tokenMeter - pkg_tool_result_prune --> svc_toolResultPrune pkg_tools --> svc_tools pkg_user_interaction --> svc_userInteraction pkg_web --> svc_web @@ -199,7 +199,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` | [`tool-result-prune`](../packages/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.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), [`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), [`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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cb266a8119..cb338a9c75 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -245,6 +245,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 @@ -930,22 +946,6 @@ export interface Config { Source: [`packages/fs/tool-fs/src/index.ts:22`](../packages/fs/tool-fs/src/index.ts) -## `@deepseek-ai/dsh-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/tool-result-prune/src/types.ts:4`](../packages/compact/tool-result-prune/src/types.ts) - ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e2fc41b943..2a5f37cacb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -284,7 +284,7 @@ pruneSession(session: Session): PruneResult Types: [ContentBlock](../core-data-structures/core.md) -Source: [`packages/compact/tool-result-prune/src/index.ts:39`](../../packages/compact/tool-result-prune/src/index.ts) +Source: [`packages/compact/compact-tool-result-prune/src/index.ts:39`](../../packages/compact/compact-tool-result-prune/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index e3774b87cd..5981ed2d0a 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -58,6 +58,6 @@ export 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`. Once pressure or canonical overflow qualifies, compact-basic invokes optional [`ctx.toolResultPrune`](../../packages/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. +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, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics. diff --git a/docs/module-graph.md b/docs/module-graph.md index fbe9afd59b..685bd810ce 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -45,7 +45,7 @@ flowchart TD subgraph group_compact["packages/compact"] pkg_compact["compact"] pkg_compact_basic["compact-basic"] - pkg_tool_result_prune["tool-result-prune"] + pkg_compact_tool_result_prune["compact-tool-result-prune"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -169,8 +169,8 @@ flowchart TD pkg_skill_local --> pkg_skill pkg_compact --> pkg_llm pkg_compact --> pkg_session - pkg_tool_result_prune --> pkg_llm - pkg_tool_result_prune --> 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 @@ -185,10 +185,10 @@ flowchart TD pkg_bash_local --> pkg_timeout 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 - pkg_compact_basic --> pkg_tool_result_prune pkg_hook_protocol --> pkg_bash pkg_hook_protocol --> pkg_session pkg_session_persistence_jsonl --> pkg_session @@ -411,7 +411,7 @@ flowchart TD | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs) | | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`skill`](../packages/skill/skill) | | [`compact`](../packages/compact/compact) | `compact` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-result-prune`](../packages/compact/tool-result-prune) | `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) | @@ -420,7 +420,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | -| [`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), [`tool-result-prune`](../packages/compact/tool-result-prune) | +| [`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) | | [`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) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 3a8805ddb6..38f1085b81 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -66,4 +66,4 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d - **`packages/session-persistence/session-persistence-jsonl`**: No changes required. - **`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 nodes — the new node 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. +The surface is the foundation for future history manipulation. A compaction or compact-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 nodes — the new node 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. diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index c8eef0f1c9..6f6ca74dd2 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/docs/rfc/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: deedb81f8cf75ab80b70e2ef3148ba76d1278886 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 51d28fa243f522eedcac29002670575889d48369 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: ea1e2b21c8036ab4d89c022792f252becc3826f6 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 594f396aaf891dccb9d6545e78039f0dbba51d1a diff --git a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index deedb81f8c..ea1e2b21c8 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/docs/rfc/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 @@ -58,6 +58,6 @@ Compact tests pin low-friction service-wide defaults, actual routed-model select Pressure describes the actual completed routed request, including durable tool results and request-only prefix fields, rather than a provisional next-call guess. 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 RFC supersedes only the pre-step automatic-trigger portion of the [compaction capability-seam RFC](../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/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 51d28fa243..594f396aaf 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/docs/rfc/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`。没有请求头的会话尚无已完成路由请求可供判断,因此不执行工作;任意持久记录的非空模型名都使用同一个估算器。操作性的计量或摘要失败会发出警告,并从最新持久表层继续:任何替换发生前使用完整历史;若剪枝已经落盘,则使用已剪枝表层。 ### 请求恢复只覆盖最终模型边界 @@ -58,6 +58,6 @@ Status: implemented 压力描述实际完成的路由请求,包括持久工具结果与仅请求前缀字段,而不是对下一次调用的临时猜测。可选的无模型剪枝会在选择摘要前移除可预测的工具输出体积,也能独立产生足以重试的进展。当成功 usage 锚点不存在时,规范化溢出提供兜底路径。恢复有上限、受取消所有,并保持单调:只有模型可见的表层 generation 变化后才重试。 -代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分单个不可分割的超大消息或工具单元。 +代价是成功 step 增加一个串行检查点,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 本 RFC 只取代[压缩能力接缝 RFC](../feature/2026-06-18-compaction-capability-seam.md) 中的 pre-step 自动触发部分。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 0f210cbd8e..67129e9bd7 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -18,7 +18,7 @@ Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capabil 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. **Model-free companion** — `@deepseek-ai/dsh-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`. +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 @@ -37,7 +37,7 @@ An earlier draft put the full algorithm (the retention walk, token-summing, text The original pre-step placement used a provisional envelope and could not see final `agent/request` routing, tools, provider output, tool results, buffered context, or steering. The corrected lifecycle fires serial `agent/post-step(agent, turn, step, signal)` after those successful 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, it invokes optional `ctx.toolResultPrune`, remeasures the durable surface, and summarizes only if pruning did not restore 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 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, 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 RFC](../../implemented/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 RFC](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md). ``` assistant/message → tool/result/context/steering @@ -57,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 node 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, by design.** Summary range selection cannot split an indivisible unit. The optional pruning companion can nevertheless repair a closed tool pair when text-bearing tool-result content is the removable 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 individual units is a separate concern; the harness can still break on them. This is named honestly rather than papered over. ### Head-anchoring: one auto checkpoint, always at the head @@ -95,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. @@ -111,12 +111,12 @@ Two failure paths, both documented: ## Consequences -- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `tool-result-prune` supplies optional deterministic rewriting. `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, node)` and `toolPairingBalancedAfter(session, node)`, 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 instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation. - **`dsh-invariants`** treats fresh appended tool results as executions that require an open step and pending call, while provenance-backed replacements are turn-enclosed surface rewrites. Positional replacement and complete-source checks validate the rewritten node. -- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy. +- **Wiring**: `examples/coding-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 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 085b165a2f..121bfd09f7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -5,9 +5,9 @@ Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered. -This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any model-facing package is missing from the generator's boot manifest; service-only packages that share the prefix are explicitly excluded. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md). +This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator's boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md). -Scope: shipped model-facing product tools under `packages/*/tool-*`, each booted with its DEFAULT config. Runtime service packages such as `tool-result-prune` do not register `ctx.tools` schemas and are explicitly excluded. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. +Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog's packages-only scope. ## Tool Package Map diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index 5f35097603..c86aaa6c45 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -25,7 +25,7 @@ flowchart LR bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] plugin_coding_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_coding_token_meter - plugin_coding_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-tool-result-prune"] + plugin_coding_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] cfg --> plugin_coding_tool_result_prune plugin_coding_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_coding_compact_basic @@ -60,7 +60,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-tool-result-prune` | +| `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/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index ba86b99e81..3b246fe825 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -52,7 +52,7 @@ # Prune oversized tool output without a model call before summary compaction. - id: tool-result-prune - name: '@deepseek-ai/dsh-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. diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index 6c06202a30..70ac92eafc 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -12,7 +12,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-tool-result-prune' +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' diff --git a/packages/compact/README.md b/packages/compact/README.md index e251bfe442..d19343dc60 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -6,7 +6,7 @@ A compaction capability family (see [capability seams](../../docs/rfc/implemente |---|---|---| | `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`) | -| `tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` | +| `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/`, and deterministic pruning at `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 RFC](../../docs/rfc/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. +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 RFC](../../docs/rfc/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 7acc1d0351..197fcd6ab6 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,8 +9,8 @@ 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. -- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../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; 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 model and cap 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. @@ -50,7 +50,7 @@ export function apply(ctx: Context): void { } ``` -Loading the plugin registers `ctx.compact`. Add [`dsh-tool-result-prune`](../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. +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 @@ -120,7 +120,7 @@ Rules: - **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 RFC](../../../docs/rfc/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 55ca301878..d242e3bed0 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -27,11 +27,11 @@ "@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-tool-result-prune": "^0.0.1", + "@deepseek-ai/dsh-compact-tool-result-prune": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-tool-result-prune": { + "@deepseek-ai/dsh-compact-tool-result-prune": { "optional": true } }, @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-result-prune": "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 34567f7e07..bd33e84355 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -13,7 +13,7 @@ import { CONTEXT_WINDOW_EXCEEDED_CODE } 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-tool-result-prune' +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' diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 094a22a11e..c2e09a5081 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -9,7 +9,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-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' import type { Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index 7294224452..2035627f64 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -9,7 +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-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' let root: string | undefined let context: Context | undefined @@ -33,7 +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-tool-result-prune', ToolResultPruneService], + ['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService], ['@deepseek-ai/dsh-compact-basic', BasicCompactService], ]) context.loader.internal = { @@ -58,7 +58,7 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-token-meter'", ' config:', ' contextWindow: 4096', - "- name: '@deepseek-ai/dsh-tool-result-prune'", + "- name: '@deepseek-ai/dsh-compact-tool-result-prune'", ' config:', ' thresholdChars: 100', ' headChars: 20', diff --git a/packages/compact/compact-basic/tsconfig.json b/packages/compact/compact-basic/tsconfig.json index 5dd00b83f1..47d552c3f0 100644 --- a/packages/compact/compact-basic/tsconfig.json +++ b/packages/compact/compact-basic/tsconfig.json @@ -14,6 +14,6 @@ { "path": "../../core/session" }, { "path": "../../core/agent" }, { "path": "../compact" }, - { "path": "../tool-result-prune" } + { "path": "../compact-tool-result-prune" } ] } diff --git a/packages/compact/tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md similarity index 96% rename from packages/compact/tool-result-prune/README.md rename to packages/compact/compact-tool-result-prune/README.md index e6d50f229c..9c405af12f 100644 --- a/packages/compact/tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -1,4 +1,4 @@ -# @deepseek-ai/dsh-tool-result-prune +# @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. @@ -28,7 +28,7 @@ All values are integers; the threshold is positive and head/tail are non-negativ ```ts import type { Context } from 'cordis' -import ToolResultPruneService from '@deepseek-ai/dsh-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' export function apply(ctx: Context): void { ctx.plugin(ToolResultPruneService) diff --git a/packages/compact/tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json similarity index 94% rename from packages/compact/tool-result-prune/package.json rename to packages/compact/compact-tool-result-prune/package.json index 6b7ac89742..81c81eb894 100644 --- a/packages/compact/tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-tool-result-prune", + "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, diff --git a/packages/compact/tool-result-prune/src/config.ts b/packages/compact/compact-tool-result-prune/src/config.ts similarity index 100% rename from packages/compact/tool-result-prune/src/config.ts rename to packages/compact/compact-tool-result-prune/src/config.ts diff --git a/packages/compact/tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts similarity index 99% rename from packages/compact/tool-result-prune/src/index.ts rename to packages/compact/compact-tool-result-prune/src/index.ts index 287b0f2500..c03fa81724 100644 --- a/packages/compact/tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -1,7 +1,7 @@ /** * Replay-safe, model-free tool-result pruning service. * - * @module @deepseek-ai/dsh-tool-result-prune + * @module @deepseek-ai/dsh-compact-tool-result-prune */ import { Context, Service } from 'cordis' diff --git a/packages/compact/tool-result-prune/src/types.ts b/packages/compact/compact-tool-result-prune/src/types.ts similarity index 100% rename from packages/compact/tool-result-prune/src/types.ts rename to packages/compact/compact-tool-result-prune/src/types.ts diff --git a/packages/compact/tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts similarity index 84% rename from packages/compact/tool-result-prune/tests/loader-composition.spec.ts rename to packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts index fef9326d2a..db4c29ebdb 100644 --- a/packages/compact/tool-result-prune/tests/loader-composition.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts @@ -6,7 +6,7 @@ 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-tool-result-prune' +import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' let root: string | undefined let context: Context | undefined @@ -18,12 +18,12 @@ afterEach(async () => { root = undefined }) -describe('tool-result-prune real Loader composition', () => { +describe('compact-tool-result-prune real Loader composition', () => { it('loads and resolves the flat YAML plugin shape', async () => { - root = await mkdtemp(join(tmpdir(), 'dsh-tool-result-prune-loader-')) + root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ - "- name: '@deepseek-ai/dsh-tool-result-prune'", + "- name: '@deepseek-ai/dsh-compact-tool-result-prune'", ' config:', ' thresholdChars: 100', ' headChars: 20', @@ -38,7 +38,7 @@ describe('tool-result-prune real Loader composition', () => { context.loader.internal = { version: 'v2', async import(specifier: string) { - if (specifier !== '@deepseek-ai/dsh-tool-result-prune') { + if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') { throw new Error(`unexpected Loader import: ${specifier}`) } return ToolResultPruneService diff --git a/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts similarity index 98% rename from packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts rename to packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index 235ecdab52..b72c894891 100644 --- a/packages/compact/tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -10,8 +10,8 @@ import ToolResultPruneService, { DEFAULTS, PRUNE_MARKER, resolveConfig, -} from '@deepseek-ai/dsh-tool-result-prune' -import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-tool-result-prune' +} from '@deepseek-ai/dsh-compact-tool-result-prune' +import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-compact-tool-result-prune' const SMALL: ToolResultPruneConfig = { thresholdChars: 50, diff --git a/packages/compact/tool-result-prune/tsconfig.json b/packages/compact/compact-tool-result-prune/tsconfig.json similarity index 100% rename from packages/compact/tool-result-prune/tsconfig.json rename to packages/compact/compact-tool-result-prune/tsconfig.json diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 40bbed7eba..1ee37e5b47 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -72,5 +72,5 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and ## 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/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 8657dca3ca..1ba64f6326 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -69,11 +69,6 @@ describe('gen-tool-catalog assertManifestComplete', () => { // is unlisted, so the guard must fire and name them. expect(() => { assertManifestComplete([]) }).toThrow(/not in the boot manifest/) expect(() => { assertManifestComplete([]) }).toThrow(/tool-bash/) - try { - assertManifestComplete([]) - } catch (error) { - expect(String(error)).not.toContain('tool-result-prune') - } }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0935bde01..5de2ae6d61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,9 +256,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter - '@deepseek-ai/dsh-tool-result-prune': + '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ - version: link:../tool-result-prune + version: link:../compact-tool-result-prune '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -266,7 +266,7 @@ 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/tool-result-prune: + packages/compact/compact-tool-result-prune: dependencies: schemastery: specifier: ^3.18.0 @@ -2140,9 +2140,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs - '@deepseek-ai/dsh-tool-result-prune': + '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ - version: link:../../packages/compact/tool-result-prune + version: link:../../packages/compact/compact-tool-result-prune '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a436f859fc..7b0f6b6a56 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -31,7 +31,7 @@ "@deepseek-ai/dsh-jsonrpc-demo": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-result-prune": "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-doc-graphs.ts b/scripts/gen-doc-graphs.ts index db6acf2c43..bfbaef1df6 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -96,7 +96,7 @@ const SERVICE_ROLES: ServiceRole[] = [ }, { key: 'toolResultPrune', - pkg: 'tool-result-prune', + pkg: 'compact-tool-result-prune', title: 'Model-free tool-result pruning', mode: 'core', consumers: ['compact-basic'], diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 4a0efe66a7..3e3a0ce81b 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -1,9 +1,8 @@ /** * Generate `docs/tool-catalog.md` from schemas collected by booting each tool * plugin. Runtime registration is the source of truth for computed schemas; - * the manifest is checked against every on-disk model-facing `tool-*` package; - * non-model service packages with that prefix are explicitly excluded. - * `--check` verifies the committed artifact. Rationale and ownership live in + * the manifest is checked against every on-disk `tool-*` package. `--check` + * verifies the committed artifact. Rationale and ownership live in * `docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md`. */ @@ -39,9 +38,6 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' -/** `tool-*` leaves that are runtime services, not contributors to `ctx.tools`. */ -const NON_MODEL_TOOL_PACKAGES = new Set(['tool-result-prune']) - /** * Tool package plus its hand-maintained boot recipe. The caller mounts the * prompt and registry; each recipe supplies only package-specific seams and @@ -81,10 +77,9 @@ interface ToolPackage { } /** - * The boot manifest: every shipped model-facing tool package (a `tool-*` leaf - * under `packages/`, excluding {@link NON_MODEL_TOOL_PACKAGES}). Ordered by - * package name (the render order); the completeness guard proves it is - * exhaustive against the filtered on-disk glob. + * The boot manifest: every shipped tool package (a `tool-*` leaf under + * `packages/`). Ordered by package name (the render order); the completeness + * guard proves it is exhaustive against the on-disk glob. */ const TOOL_PACKAGES: ToolPackage[] = [ { @@ -261,9 +256,8 @@ interface CatalogPackage { export type ToolCatalog = CatalogPackage[] /** - * Assert the boot manifest covers every shipped model-facing tool package on - * disk (a `tool-*` leaf under `packages/`, excluding explicit service-only - * entries in {@link NON_MODEL_TOOL_PACKAGES}). + * Assert the boot manifest covers every shipped tool package on disk (a + * `tool-*` leaf under `packages/`). * Booting has no source declaration to enumerate, so this glob restores the * "a new tool cannot be silently undocumented" guarantee: an unlisted package * fails the generator (and the freshness gate) until it is added to @@ -272,10 +266,7 @@ export type ToolCatalog = CatalogPackage[] * `scanRoot` defaults to the repo root; a test may point it at a fixture tree. */ export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void { - const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }) - .map(p => basename(p)) - .filter(dir => !NON_MODEL_TOOL_PACKAGES.has(dir)) - .sort() + const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort() const listed = new Set(packages.map(p => p.dir)) const missing = onDisk.filter(dir => !listed.has(dir)) if (missing.length > 0) { @@ -348,9 +339,9 @@ export function render(catalog: ToolCatalog): string { '', 'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the cordis [events](cordis-catalog/events.md) & [services](cordis-catalog/services.md) catalogs (the wiring a plugin listens to and calls) and [core-data-structures/](core-data-structures/core.md) (the types those signatures move) — this page is the *tools* the agent is offered.', '', - 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any model-facing package is missing from the generator\'s boot manifest; service-only packages that share the prefix are explicitly excluded. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', + 'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog RFC](rfc/implemented/process/2026-07-02-tool-schema-catalog.md).', '', - 'Scope: shipped model-facing product tools under `packages/*/tool-*`, each booted with its DEFAULT config. Runtime service packages such as `tool-result-prune` do not register `ctx.tools` schemas and are explicitly excluded. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', + 'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.', '', '## Tool Package Map', '', diff --git a/tsconfig.build.json b/tsconfig.build.json index a099fc3a23..6cdef1f6c9 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -38,7 +38,7 @@ { "path": "./packages/code-runtime/code-runtime-worker" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, - { "path": "./packages/compact/tool-result-prune" }, + { "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 1decda2db0..9cc7c5ce83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,7 +60,7 @@ { "path": "./packages/fs/tool-fs" }, { "path": "./packages/compact/compact" }, { "path": "./packages/compact/compact-basic" }, - { "path": "./packages/compact/tool-result-prune" }, + { "path": "./packages/compact/compact-tool-result-prune" }, { "path": "./packages/web/web" }, { "path": "./packages/web/web-search-exa" }, { "path": "./packages/web/web-search-perplexity" }, From 6e790b95f25287ea984c8b44cd2d312b34db6459 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:11:48 +0800 Subject: [PATCH 4/6] fix(compact): complete master retarget integration Declare the tool-result pruning plugin in the examples workspace so the repl Cordis configuration resolves through plain Node and the Loader metadata gate. Express the validated single-node surface rewrite without a non-null assertion or an unreachable defensive branch, preserving both the runtime contract and per-file 100% coverage. --- examples/package.json | 1 + packages/core/session/src/surface.ts | 26 ++++++++++++-------------- pnpm-lock.yaml | 15 +++++++++------ 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/examples/package.json b/examples/package.json index 76349b2ea4..a0206dd0f9 100644 --- a/examples/package.json +++ b/examples/package.json @@ -13,6 +13,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-hooks-claude": "workspace:*", diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 67e28fd1ee..90a2181d53 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -198,20 +198,18 @@ function assertToolResultRewrite( if (shadowedSeqs.length !== 1) { throw new Error('tool/result surface replacement must rewrite exactly one current node') } - const originalSeq = shadowedSeqs[0] - if (originalSeq === undefined) { - throw new Error('tool/result surface replacement must rewrite exactly one current node') - } - 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') + 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') + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 456dfeca62..a1f6000b38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,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 @@ -373,6 +376,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 @@ -385,9 +391,6 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter - '@deepseek-ai/dsh-compact-tool-result-prune': - specifier: workspace:^ - version: link:../compact-tool-result-prune '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2492,6 +2495,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 @@ -2609,9 +2615,6 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs - '@deepseek-ai/dsh-compact-tool-result-prune': - specifier: workspace:^ - version: link:../../packages/compact/compact-tool-result-prune '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill From a625ef4fe9295a205767fdcd675fc1d254898dbb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:47:56 +0800 Subject: [PATCH 5/6] docs(compact): qualify pruning cache reuse --- packages/compact/compact-tool-result-prune/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compact/compact-tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md index d639741c51..94f4e4f45e 100644 --- a/packages/compact/compact-tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -49,7 +49,7 @@ Each rewritten tool result has at most `thresholdChars` text code points. Prunin #### KV Cache effect -Replacing an earlier result invalidates the reusable request prefix from that result onward; subsequent requests reuse the new pruned prefix until another surface replacement occurs. +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 From 7aee299bdad88af70f9fe6b38a86e9da287c55f1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:02:28 +0800 Subject: [PATCH 6/6] docs(compact): correct pruning surface contracts --- docs/cordis-catalog/services.md | 2 ++ docs/core-data-structures/compaction.md | 2 +- packages/compact/compact-tool-result-prune/README.md | 2 ++ packages/compact/compact-tool-result-prune/src/index.ts | 2 ++ packages/compact/compact/README.md | 2 +- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- website/zh-CN/api/harness/tool-result-prune.md | 4 +++- 7 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 89cdaff0de..2707809145 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1147,6 +1147,8 @@ pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null * 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 ``` diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 6db80273c8..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 | |---|---|---| diff --git a/packages/compact/compact-tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md index 94f4e4f45e..c06eab5405 100644 --- a/packages/compact/compact-tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -8,6 +8,8 @@ This is a concrete companion to [`dsh-compact-basic`](../compact-basic/README.md `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. diff --git a/packages/compact/compact-tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts index 641840c67b..d4a2daecbc 100644 --- a/packages/compact/compact-tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -118,6 +118,8 @@ export class ToolResultPruneService extends Service { * 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[] = [] diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 8cfba13437..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. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index e775f371e0..58d58ee3bb 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -541,7 +541,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { 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 */', + 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 */', }, ], }, diff --git a/website/zh-CN/api/harness/tool-result-prune.md b/website/zh-CN/api/harness/tool-result-prune.md index c9ab9306ab..28042240b1 100644 --- a/website/zh-CN/api/harness/tool-result-prune.md +++ b/website/zh-CN/api/harness/tool-result-prune.md @@ -68,6 +68,8 @@ Replace an over-budget text middle while retaining rich-block order. Text slicin * 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 ``` @@ -78,4 +80,4 @@ Prune every over-budget tool result from one stable current-surface snapshot. Ea **Returns** landed replacements and aggregate Unicode-code-point savings. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact-tool-result-prune/src/index.ts#L122) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact-tool-result-prune/src/index.ts#L124)