From 4987261d554161b47e82f7e6809d45899eff9509 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:01:03 +0800 Subject: [PATCH] feat(spill): bound the durable copy of Code Mode sub-dispatch results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tools/code-dispatch-log waterfall (run via registry.shapeDispatchLog, contained — a throwing listener falls back to the unshaped content) lets listeners reshape the tool/code-dispatch event's content before the bridge appends it. dsh-spill-policy registers a second arm sharing the model-facing arm's exact replacement pipeline (same maxInlineBytes cap, preview + locator, within-cap invariant, best-effort fallbacks), with artifacts labeled dispatch under the sub-call id. The program's value is untouched; read sub-calls ARE bounded (a log copy is not model context, and read produces the biggest logs). Resolves the tools README's uncapped-dispatch-log Known Limitation. --- ...26-07-26-code-dispatch-log-spill.i18n.yaml | 6 + .../2026-07-26-code-dispatch-log-spill.md | 31 ++++ .../2026-07-26-code-dispatch-log-spill.zh.md | 31 ++++ docs/config-catalog.md | 12 +- docs/core-data-structures/tools.i18n.yaml | 4 +- docs/core-data-structures/tools.md | 26 +++ docs/core-data-structures/tools.zh.md | 26 +++ docs/event-producer-consumer.md | 5 +- .../core/scope/src/scoped-events.generated.ts | 1 + packages/core/tools/README.md | 2 +- packages/core/tools/src/code-mode.ts | 36 ++-- packages/core/tools/src/index.ts | 55 ++++++ packages/spill/spill-policy/README.md | 4 +- packages/spill/spill-policy/src/index.ts | 159 ++++++++++++------ .../spill-policy/tests/spill-policy.spec.ts | 91 ++++++++++ scripts/gen-cordis-catalog.ts | 1 + 16 files changed, 415 insertions(+), 75 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml new file mode 100644 index 0000000000..f00ecd5d2a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-26-code-dispatch-log-spill.md: 2668c195a43ae1f6011c09413338a23caf75401e +2026-07-26-code-dispatch-log-spill.zh.md: e084ae80d7fed864c7f296b1fd6db713acf7a2b0 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md new file mode 100644 index 0000000000..2668c195a4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md @@ -0,0 +1,31 @@ +# Agent Note: Spilling the durable copy of Code Mode sub-dispatch results + +Status: implemented + +English | [中文](2026-07-26-code-dispatch-log-spill.zh.md) + +> Scope: the fourth PR of the Code Mode UI stack — bounding the `tool/code-dispatch` event's content with the existing spill machinery. The [host foundation note](2026-07-26-code-dispatch-ui-foundation.md) accepted the unbounded log deliberately and named this PR as the payoff point; the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) settled the event pair this shaping hooks into. + +## Problem + +Since the full-content dispatch logging landed, a `run_code` program that reads a large file wrote the complete rendered text into the session log — uncapped and outside spill policy, while native results were bounded to `maxInlineBytes` before logging. The asymmetry was backwards: sub-calls (built for bulk data work) were precisely the calls most likely to carry huge results, and the JSONL grew by megabytes per such turn. + +## Decision + +**A log-shaping waterfall on the registry, and the spill policy as its first listener.** + +- **Seam**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via `registry.shapeDispatchLog`, contained: a throwing listener falls back to the unshaped content) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content. Only the durable copy is shapeable — the program already received the complete value across the worker boundary, and the model sees neither. +- **Policy**: `dsh-spill-policy` registers a second arm on the new seam sharing the exact replacement pipeline of its model-facing arm (same `maxInlineBytes` cap, same preview + locator + within-cap invariant, same best-effort fallbacks), with the artifact labeled `dispatch` under the sub-call id. UIs and replay read the full text through the spill artifact exactly as they do for spilled native results, so the native-parity rendering story survives bounding. +- **One deliberate asymmetry**: the model-facing arm skips `read` (the `read → spill → read again` loop); the dispatch-log arm bounds `read` sub-calls too — a log copy is not model context, so the loop cannot happen, and `read` is precisely the tool that produces huge logs. + +## Alternatives considered + +**Bound inside the bridge with a plain cap (no spill).** Rejected: truncation without a locator loses data replay/UIs may need, and re-introduces the "truncated summary" degraded render path the stack removed. + +**Spill inside the bridge directly (call `ctx.spillStore` from code-mode.ts).** Rejected: the registry would grow a hard dependency on the spill capability; the waterfall keeps the policy where every other spill decision lives, composable and disable-able (omitted `maxInlineBytes` still means a true no-op). + +**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute shapes the PROGRAM-facing result (nested calls deliberately skip it so programs get complete data); the durable copy needs its own decision point after the program has its value. + +## Consequences + +The session log is bounded again for Code Mode turns — the README's Known Limitations entry about uncapped dispatch logging is resolved and now points here. Old logs with oversized dispatch content still replay (the event shape is unchanged; only future appends shrink). The web UI renders spilled sub-call output as the preview + locator text through the identical native path, no special casing. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md new file mode 100644 index 0000000000..e084ae80d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md @@ -0,0 +1,31 @@ +# Agent Note:将 Code Mode 子分发结果的持久副本纳入 spill 机制 + +Status: implemented + +[English](2026-07-26-code-dispatch-log-spill.md) | 中文 + +> 范围:Code Mode UI 堆叠 PR(Pull Request)链的第四个 PR,即用既有的 spill 机制为 `tool/code-dispatch` 事件的内容施加边界。[宿主侧基础 Agent Note](2026-07-26-code-dispatch-ui-foundation.md)当初有意接受了不设上限的日志,并指明本 PR 就是兑现点;[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md)敲定了本次整形所挂接的事件对。 + +## 问题 + +自携带完整内容的分发日志落地以来,读取大文件的 `run_code` 程序过去会把完整的渲染文本写进会话日志,不设上限、位于 spill 策略之外;而原生结果在记录之前就已被限制在 `maxInlineBytes` 以内。这种不对称的方向完全反了:子调用(本就为批量数据工作而设计)恰恰是最可能携带巨大结果的调用,而每个这样的轮次都会让 JSONL 增长数 MB。 + +## 决策 + +**在注册表上增设一个日志整形 waterfall(瀑布式事件),spill 策略作为其第一个监听器。** + +- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由 `registry.shapeDispatchLog`,且故障被兜住:监听器抛出异常时回退到未整形的内容)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容。可整形的只有持久副本:程序已经跨 worker 边界收到了完整的值,而模型两者都看不到。 +- **策略**:`dsh-spill-policy` 在新 seam 上注册第二个分支,与其面向模型的分支共用一模一样的替换流水线(同样的 `maxInlineBytes` 上限、同样的预览 + 定位符 + 不超上限不变式、同样的尽力而为回退),产物以 `dispatch` 为标签,记在子调用 id 名下。UI 与回放通过 spill 产物读取全文,方式与读取被 spill 的原生结果完全相同,因此与原生同等保真的渲染在施加边界之后依然成立。 +- **一处有意的不对称**:面向模型的分支跳过 `read`(避免 `read → spill → read again` 循环);分发日志分支则连 `read` 子调用也施加边界:日志副本不是模型上下文,该循环因此不可能发生,而 `read` 恰恰是会产生巨大日志的那个工具。 + +## 曾考虑的替代方案 + +**在桥接层内部用普通上限施加边界(不做 spill)。** 否决:没有定位符的截断会丢失回放与 UI 可能需要的数据,还会重新引入本堆叠 PR 链已经移除的「截断摘要」降级渲染路径。 + +**直接在桥接层内做 spill(从 code-mode.ts 调用 `ctx.spillStore`)。** 否决:注册表会因此对 spill 能力产生硬依赖;waterfall 则把策略留在所有其他 spill 决策所在的地方,既可组合也可禁用(省略 `maxInlineBytes` 依然意味着真正的 no-op)。 + +**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 整形的是面向程序的那份结果(嵌套调用有意跳过它,好让程序拿到完整数据);持久副本需要一个属于自己的决策点,位于程序取得其值之后。 + +## 后果 + +对 Code Mode 轮次而言,会话日志重新有了边界:README 中关于分发日志不设上限的 Known Limitations 条目已经解决,现在指向本篇。携带超大分发内容的旧日志仍可回放(事件形状未变;只有今后的追加才会变小)。web UI 经由与原生完全相同的路径,把被 spill 的子调用输出渲染为预览 + 定位符文本,没有任何特殊处理。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51f57f7bde..eeaed0302a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1202,7 +1202,7 @@ export interface Config { } ``` -Source: [`packages/spill/spill-policy/src/index.ts:51`](../packages/spill/spill-policy/src/index.ts) +Source: [`packages/spill/spill-policy/src/index.ts:60`](../packages/spill/spill-policy/src/index.ts) ## `@deepseek-ai/dsh-storage-domain` @@ -1704,13 +1704,21 @@ export interface Config { * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. */ mode?: ToolPresentationMode + /** + * Concurrency cap for a `run_code` program's overlapping sub-calls + * (default 10, the loop scheduler's own default). Sub-calls follow the + * native scheduling contract — only calls whose tools classify + * concurrency-safe overlap; exclusive calls form barriers — so `1` + * restores strictly serial dispatch. Must be a positive integer. + */ + maxParallelSubCalls?: number } /** How the registry presents its tools to the model (see {@link Config.mode}). */ export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:529`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:562`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index c96584f032..19c6cb4612 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.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 -tools.md: 875bea18ff0c34ca97f9c144f4320d3b3a6aaa4a -tools.zh.md: 11f0b8d4a0f29304e6fdbde7c81be981bd940a2d +tools.md: 389c54bf625f762257a4830ed915d526230090ab +tools.zh.md: fba3453fa91be2544eb3ab94ca67aaf0452958b2 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 875bea18ff..389c54bf62 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -231,6 +231,32 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` +Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may reshape the durable event's copy of the content (the program's value and the model contract are untouched): + +```ts type-equiv +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. The complete `content` is what the program + * already received; only the `tool/code-dispatch` event's copy changes. + */ +interface CodeDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Deterministic sub-call id (`:code:`). */ + readonly subCallId: CallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} +``` + ```ts type-equiv /** * One pending tool call inside the registry pipeline. Parsed arguments cross diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 11f0b8d4a0..fba3453fa9 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -231,6 +231,32 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` +Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以改写持久事件所存的内容副本(程序取得的值与模型契约均不受影响): + +```ts type-equiv +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. The complete `content` is what the program + * already received; only the `tool/code-dispatch` event's copy changes. + */ +interface CodeDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Deterministic sub-call id (`:code:`). */ + readonly subCallId: CallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} +``` + ```ts type-equiv /** * One pending tool call inside the registry pipeline. Parsed arguments cross diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d9521f7d67..880cde9fd8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,11 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:133`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index a12b0a513e..728ee2a8e8 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -35,6 +35,7 @@ const scopedSubjectResolvers: Readonly (args[1] as Record)['scope'], + 'tools/code-dispatch-log': args => (args[0] as Record)['agent'], 'tools/execute': args => (args[0] as Record)['agent'], 'tools/post-execute': args => (args[0] as Record)['agent'], 'tools/pre-execute': args => (args[0] as Record)['agent'], diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 9c7e7a7857..5aaea1d296 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -189,5 +189,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. - **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. -- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The rendered `content` of every sub-call IS logged verbatim on `tool/code-dispatch`, uncapped and outside spill policy, so programs that read huge files grow the session log by the same bytes (spill integration for the logged copy is deferred work). +- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 20f0aa47d1..a01ab0f0eb 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -331,19 +331,29 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => for (const context of result.additionalContexts ?? []) { exec.deferContext(context) } - exec.agent?.session.append('tool/code-dispatch', { - parentCallId: exec.callId, - subCallId, - name, - // The SIBLING parse of the dispatched value: byte-identical JSON, - // but a separate object — a tool mutating its args cannot desync - // this record from what it actually received. - arguments: normalized.logged, - isError: result.isError, - // The registry deep-froze this projection at result finalization; - // append snapshots it again, so the log copy stays detached. - content: result.content, - }) + if (exec.agent !== undefined) { + // The durable copy may be reshaped (e.g. spilled to a preview + + // locator) by the log-shaping waterfall; the program's value and + // the model contract are untouched. + const logged = await registry.shapeDispatchLog({ + exec, agent: exec.agent, subCallId, name, isError: result.isError, + // The registry deep-froze this projection at result + // finalization; append snapshots the final copy again, so the + // log stays detached. + content: result.content, + }) + exec.agent.session.append('tool/code-dispatch', { + parentCallId: exec.callId, + subCallId, + name, + // The SIBLING parse of the dispatched value: byte-identical JSON, + // but a separate object — a tool mutating its args cannot desync + // this record from what it actually received. + arguments: normalized.logged, + isError: result.isError, + content: logged, + }) + } resolve(result.isError ? { isError: true, message: result.error.message } : { isError: false, value: result.value }) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 68a7cefcd4..0593e6ec51 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -123,6 +123,19 @@ declare module 'cordis' { * @mode waterfall */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise + /** + * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before + * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the unshaped content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ + 'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise /** * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. @@ -272,6 +285,28 @@ export type ToolExecutionMode = | { kind: 'parallel' } | { kind: 'exclusive' } +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. The complete `content` is what the program + * already received; only the `tool/code-dispatch` event's copy changes. + */ +export interface CodeDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Deterministic sub-call id (`:code:`). */ + readonly subCallId: CallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} + /** * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; @@ -932,6 +967,26 @@ export class ToolRegistry extends Service { } } + /** + * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch + * and return the content the bridge should log on `tool/code-dispatch`. + * Contained: a throwing listener falls back to the unshaped content — log + * shaping must never fail the dispatch or lose the settle event. + * @param dispatch - the sub-dispatch identity and its default logged content. + * @returns the (possibly reshaped) content for the durable event. + */ + async shapeDispatchLog(dispatch: CodeDispatchLog): Promise { + try { + return await this.ctx.waterfall( + scopeTarget(this, dispatch.agent), 'tools/code-dispatch-log', dispatch, + () => Promise.resolve(dispatch.content), + ) + } catch (error: unknown) { + this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${String(error)}; logging the unshaped content`) + return dispatch.content + } + } + /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index cf46ccafd6..3e89e22f9c 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -13,7 +13,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p ## Behavior 1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted). -2. Skip nested executions (`exec.parent` is present), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through). +2. Skip nested executions (`exec.parent` is present — their DURABLE copy is bounded by the dispatch-log arm below), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through). 3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched. 4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged. 5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap: @@ -28,6 +28,8 @@ This plugin registers **no service** and owns no storage or preview mechanics: p **Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. A successful replacement changes only `content`; the canonical programmatic value is preserved. +**The dispatch-log arm:** a second listener on `tools/code-dispatch-log` applies the same cap, replacement pipeline, and best-effort fallbacks to the DURABLE copy of each `run_code` sub-call result (artifact label `dispatch`, keyed by the sub-call id). The program's value is untouched — it already crossed the worker boundary whole — and `read` sub-calls are bounded too: a log copy is not model context, so the read-again loop cannot occur, and `read` is precisely the tool that produces huge logs ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). + ## Scope The policy sees only the FINAL formatted surface result—not a tool's internal resource or canonical value. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. `glob`/`grep` own item-level surface spill because their complete acquired values still exist before rendering; bash streams own acquisition-time spill. The generic policy prepends its waterfall listener, then delegates, so ordinary tool-owned asynchronous projections complete before generic byte bounding regardless of plugin load order. See the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md). diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index 26c501257c..470fd1cacd 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -10,18 +10,26 @@ * `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillStore`. * The policy only decides WHEN to spill and composes the notice. * + * A second arm applies the SAME cap to the durable log: the + * `tools/code-dispatch-log` waterfall bounds the `tool/code-dispatch` event's + * copy of an oversized `run_code` sub-call result (the program's value is + * untouched; UIs and replay read the full text through the spill artifact). + * * ## Deliberately narrow * * - Omitted `maxInlineBytes` ⇒ the plugin registers nothing (a true no-op). * - Plain-text results only: a result carrying any non-text block is left * untouched (the policy knows only the final formatted text, not tool * internals). - * - Nested composite calls are skipped; only their outer surface result may - * become model-facing and spillable. + * - Nested composite calls skip the MODEL-facing arm; their durable log copy + * is bounded by the dispatch-log arm instead. * - Accepted value replacements pass through for registry revalidation and * rendering; this presentation policy cannot also replace content in the * same mutually exclusive decision. - * - `read` is skipped to avoid a `read → spill → read again` loop. + * - `read` is skipped by the model-facing arm to avoid a + * `read → spill → read again` loop; the dispatch-log arm bounds `read` + * sub-calls too (a log copy is not model context, and `read` is precisely + * the tool that produces huge logs). * - Best-effort: no session owner, no `ctx.spillStore` backend, or a save * failure ⇒ log and return the original result. A spill failure must NEVER * turn a successful tool call into an `isError` or hide the inline result. @@ -42,6 +50,7 @@ import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention' import type { Omitted } from '@deepseek-ai/dsh-retention' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { SessionId } from '@deepseek-ai/dsh-session' +import type { CallId } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' import type { SpillPolicyExec } from './types.ts' @@ -108,6 +117,75 @@ export function apply(ctx: Context, config: Config): void { if (!Number.isInteger(maxInlineBytes) || maxInlineBytes < 0) { throw new Error(`spill-policy: maxInlineBytes must be a non-negative integer (got ${maxInlineBytes})`) } + // Narrowed once for the nested arms (closure narrowing does not survive awaits). + const cap: number = maxInlineBytes + + /** + * Spill `text` and build the bounded replacement (preview + notice), or + * return `undefined` when the policy must keep the original (no session + * owner, no backend, storage failure, or no within-cap replacement). + * Shared verbatim by the model-facing post-execute arm and the durable + * dispatch-log arm so both produce byte-identical projections. + */ + async function spillReplacement( + text: string, + totalBytes: number, + sessionId: SessionId | undefined, + toolName: string, + callId: CallId, + label: 'result' | 'dispatch', + ): Promise { + if (sessionId === undefined) { + ctx.logger.warn(`spill-policy: no session owner for ${toolName} ${label}; keeping the inline content`) + return undefined + } + const spillStore = ctx.get('spillStore') + if (!spillStore) { + ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline content') + return undefined + } + const save: SaveTextSpill = { + owner: { sessionId }, + source: { toolName, callId, label }, + suggestedName: `${toolName}.txt`, + content: text, + } + let ref: SpillRef + try { + ref = await spillStore.saveText(save) + } catch (error: unknown) { + // Best-effort: a storage failure (permissions, ENOSPC, backend down) must + // never fail the call or hide the content — keep the original inline. + ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ${String(error)}; keeping the inline content`) + return undefined + } + + // Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement + // (preview + blank line + notice) never exceeds the documented cap — a naive + // preview that spent the whole budget then appended the notice could be + // larger than the cap, and for a marginally-over result even larger than the + // original. The reservation uses a notice priced at the worst-case omission + // count (the full byte total): its digit count bounds the real count's, so + // the reserved size is a safe upper bound and the final notice is never + // longer than what we reserved. `\n\n` is the 2-byte join. + const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2 + const previewBudget = Math.max(0, cap - reserve) + const { text: previewText, omitted } = preview(text, previewBudget) + const notice = spillNotice(omitted, ref) + const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice + // Invariant: the policy NEVER emits a replacement larger than the cap. When + // the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root), + // there is no within-cap replacement, so keep the inline content — spilling + // would break the advertised cap. (A within-cap replacement is always + // smaller than the original, which is > cap by the entry condition, so this + // one check subsumes "not smaller than the original" too. The spill file + // already written is a harmless orphan; cleanup is deferred.) + if (Buffer.byteLength(replacedText, 'utf8') > cap) { + ctx.logger.warn(`spill-policy: spill notice for ${toolName} exceeds maxInlineBytes; keeping the inline content`) + return undefined + } + return replacedText + } ctx.on('tools/post-execute', async (exec, result, next): Promise => { // Delegate first so a downstream listener (e.g. a hook) settles the result; @@ -124,58 +202,31 @@ export function apply(ctx: Context, config: Config): void { const totalBytes = Buffer.byteLength(text, 'utf8') if (totalBytes <= maxInlineBytes) return decision - const sessionId = ownerSessionId(exec) - if (sessionId === undefined) { - ctx.logger.warn(`spill-policy: no session owner for ${exec.name} result; keeping the inline result`) - return decision - } - const spillStore = ctx.get('spillStore') - if (!spillStore) { - ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline result') - return decision - } - - const save: SaveTextSpill = { - owner: { sessionId }, - source: { toolName: exec.name, callId: exec.callId, label: 'result' }, - suggestedName: `${exec.name}.txt`, - content: text, - } - let ref: SpillRef - try { - ref = await spillStore.saveText(save) - } catch (error: unknown) { - // Best-effort: a storage failure (permissions, ENOSPC, backend down) must - // never fail the call or hide the result — keep the original inline. - ctx.logger.warn(`spill-policy: saveText failed for ${exec.name}: ${String(error)}; keeping the inline result`) - return decision - } - - // Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement - // (preview + blank line + notice) never exceeds the documented cap — a naive - // preview that spent the whole budget then appended the notice could be - // larger than the cap, and for a marginally-over result even larger than the - // original. The reservation uses a notice priced at the worst-case omission - // count (the full byte total): its digit count bounds the real count's, so - // the reserved size is a safe upper bound and the final notice is never - // longer than what we reserved. `\n\n` is the 2-byte join. - const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2 - const previewBudget = Math.max(0, maxInlineBytes - reserve) - const { text: previewText, omitted } = preview(text, previewBudget) - const notice = spillNotice(omitted, ref) - const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice - // Invariant: the policy NEVER emits a replacement larger than the cap. When - // the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root), - // there is no within-cap replacement, so keep the inline result — spilling - // would break the advertised context cap. (A within-cap replacement is - // always smaller than the original, which is > cap by the entry condition, - // so this one check subsumes "not smaller than the original" too. The spill - // file already written is a harmless orphan; cleanup is deferred.) - if (Buffer.byteLength(replacedText, 'utf8') > maxInlineBytes) { - ctx.logger.warn(`spill-policy: spill notice for ${exec.name} exceeds maxInlineBytes; keeping the inline result`) - return decision - } + const replacedText = await spillReplacement(text, totalBytes, ownerSessionId(exec), exec.name, exec.callId, 'result') + if (replacedText === undefined) return decision const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }] return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} } }, { prepend: true }) + + // The durable-log arm: bound the `tool/code-dispatch` event's copy of an + // oversized sub-call result the same way the model-facing arm bounds an + // outer result. The program's returned value is untouched (it already + // crossed the worker boundary whole); only the session log's copy shrinks + // to preview + locator, so replay and UIs read the full text through the + // spill artifact exactly as they do for spilled native results. + ctx.on('tools/code-dispatch-log', async (dispatch, next): Promise => { + const content = await next() + // `read` sub-calls spill too: the log copy is not model context, so the + // read → spill → read-again loop the post-execute arm avoids cannot + // happen here, and read is precisely the tool that produces huge logs. + const text = flattenPlainText(content) + if (text === undefined) return content + const totalBytes = Buffer.byteLength(text, 'utf8') + if (totalBytes <= maxInlineBytes) return content + + const replacedText = await spillReplacement( + text, totalBytes, ownerSessionId(dispatch.exec), dispatch.name, dispatch.subCallId, 'dispatch') + if (replacedText === undefined) return content + return [{ type: 'text', text: replacedText }] + }, { prepend: true }) } diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 120baf197e..33a9aa7cee 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -230,6 +230,97 @@ describe('read skip', () => { }) }) +describe('the durable dispatch-log arm', () => { + /** Boot code mode + the policy + the worker runtime; run one program via the real bridge. */ + async function runCodeWith(program: string, maxInlineBytes: number) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + await ctx.plugin(StubStore) + await ctx.plugin(SpillPolicy, { maxInlineBytes }) + await ctx.plugin(WorkerCodeRuntime, {}) + const events: { type: string; data: unknown }[] = [] + const agent = { + session: { + header: { id: SessionId('dispatch-spill'), cwd: '/workspace' }, + append: (type: string, data: unknown) => { events.push({ type, data }) }, + }, + } + ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) + ctx.tools.register(textTool('small_read', 'tiny')) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('parent-1'), + name: 'run_code', + arguments: { code: program, description: 'Drive dispatch-log spilling' }, + agent: agent as never, + }) + return { ctx, result, events, spill: ctx.spillStore as StubStore } + } + + it('bounds the tool/code-dispatch copy of an oversized sub-result while the program value stays whole', async () => { + const { result, events, spill } = await runCodeWith( + 'const blocks = await tools.huge_read({});\nreturn blocks[0].text.length', 200) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected success') + // The program received the COMPLETE text (length 2000), untouched by spill. + expect(result.value).toMatchObject({ result: 2_000 }) + // The durable settle event carries the bounded projection + locator. + const settle = events.find(event => event.type === 'tool/code-dispatch') + expect(settle).toBeDefined() + const logged = (settle!.data as { content: { type: string; text: string }[] }).content + expect(logged).toHaveLength(1) + const loggedText = logged[0]!.text + expect(Buffer.byteLength(loggedText, 'utf8')).toBeLessThanOrEqual(200) + expect(loggedText).toContain('Full formatted result stored at: /spill/huge_read.txt') + // The artifact holds the full text under the dispatch label and sub-call id. + const save = spill.saves.find(entry => entry.source.label === 'dispatch') + expect(save).toMatchObject({ + source: { toolName: 'huge_read', callId: 'parent-1:code:1', label: 'dispatch' }, + }) + expect(save?.content).toBe('H'.repeat(2_000)) + }) + + it('leaves a within-cap sub-result log untouched and saves nothing for it', async () => { + const { events, spill } = await runCodeWith( + 'return await tools.small_read({})', 200) + const settle = events.find(event => event.type === 'tool/code-dispatch') + expect((settle!.data as { content: { type: string; text: string }[] }).content) + .toEqual([{ type: 'text', text: 'tiny' }]) + expect(spill.saves.filter(entry => entry.source.label === 'dispatch')).toHaveLength(0) + }) + + it('a saveText failure keeps the complete content in the durable log (best-effort)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + await ctx.plugin(StubStore) + await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 }) + await ctx.plugin(WorkerCodeRuntime, {}) + ;(ctx.spillStore as StubStore).fail = true + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const events: { type: string; data: unknown }[] = [] + const agent = { + session: { + header: { id: SessionId('dispatch-spill-fail'), cwd: '/workspace' }, + append: (type: string, data: unknown) => { events.push({ type, data }) }, + }, + } + ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('parent-2'), + name: 'run_code', + arguments: { code: 'return (await tools.huge_read({}))[0].text.length', description: 'Fail the spill backend' }, + agent: agent as never, + }) + expect(result.isError).toBe(false) + const settle = events.find(event => event.type === 'tool/code-dispatch') + expect((settle!.data as { content: { text: string }[] }).content[0]!.text).toBe('H'.repeat(2_000)) + expect(warn).toHaveBeenCalled() + }) +}) + describe('nested-call skip', () => { it('leaves nested composite results complete and spillable only through their outer call', async () => { const { ctx, spill } = await setup({ maxInlineBytes: 10 }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b53d1eaa12..ca62d42ffb 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -165,6 +165,7 @@ export const LINK_MAP: Record = { TaskSnapshot: 'tasks.md', TaskStart: 'tasks.md', TokenMeasurement: 'token-meter.md', + CodeDispatchLog: 'tools.md', PostToolDecision: 'tools.md', PreToolDecision: 'tools.md', ToolDefinition: 'tools.md',