Browse Source

feat(spill): bound the durable copy of Code Mode sub-dispatch results

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.
Tianyi Cui 2 tháng trước cách đây
mục cha
commit
4987261d55

+ 6 - 0
.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

+ 31 - 0
.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.

+ 31 - 0
.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 的子调用输出渲染为预览 + 定位符文本,没有任何特殊处理。

+ 10 - 2
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`
 

+ 2 - 2
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

+ 26 - 0
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 (`<parent>:code:<n>`). */
+  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

+ 26 - 0
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 (`<parent>:code:<n>`). */
+  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

+ 3 - 2
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) |

+ 1 - 0
packages/core/scope/src/scoped-events.generated.ts

@@ -35,6 +35,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
   'subagent/end': null,
   'subagent/start': null,
   'system-prompt/assemble': args => (args[1] as Record<string, unknown>)['scope'],
+  'tools/code-dispatch-log': args => (args[0] as Record<string, unknown>)['agent'],
   'tools/execute': args => (args[0] as Record<string, unknown>)['agent'],
   'tools/post-execute': args => (args[0] as Record<string, unknown>)['agent'],
   'tools/pre-execute': args => (args[0] as Record<string, unknown>)['agent'],

+ 1 - 1
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).

+ 23 - 13
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 })

+ 55 - 0
packages/core/tools/src/index.ts

@@ -123,6 +123,19 @@ declare module 'cordis' {
      * @mode waterfall
      */
     'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
+    /**
+     * 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<ToolRegistry>, dispatch: CodeDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>
     /**
      * 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 (`<parent>:code:<n>`). */
+  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<ContentBlock[]> {
+    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

+ 3 - 1
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).

+ 89 - 38
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,37 +117,37 @@ 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
 
-  ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
-    // Delegate first so a downstream listener (e.g. a hook) settles the result;
-    // we bound whatever it accepted. A block passes through — spill only shapes
-    // accepted plain-text results, never corrective feedback.
-    const decision = await next()
-    // Skip `read` to avoid a read → spill → read again loop.
-    if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value')
-      || exec.parent !== undefined || exec.name === 'read') return decision
-
-    const content = decision.content ?? result.content
-    const text = flattenPlainText(content)
-    if (text === undefined) return decision
-    const totalBytes = Buffer.byteLength(text, 'utf8')
-    if (totalBytes <= maxInlineBytes) return decision
-
-    const sessionId = ownerSessionId(exec)
+  /**
+   * 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<string | undefined> {
     if (sessionId === undefined) {
-      ctx.logger.warn(`spill-policy: no session owner for ${exec.name} result; keeping the inline result`)
-      return decision
+      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 result')
-      return decision
+      ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline content')
+      return undefined
     }
-
     const save: SaveTextSpill = {
       owner: { sessionId },
-      source: { toolName: exec.name, callId: exec.callId, label: 'result' },
-      suggestedName: `${exec.name}.txt`,
+      source: { toolName, callId, label },
+      suggestedName: `${toolName}.txt`,
       content: text,
     }
     let ref: SpillRef
@@ -146,9 +155,9 @@ export function apply(ctx: Context, config: Config): void {
       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
+      // 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
@@ -160,22 +169,64 @@ export function apply(ctx: Context, config: Config): void {
     // 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 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 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
+    // 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<PostToolDecision> => {
+    // Delegate first so a downstream listener (e.g. a hook) settles the result;
+    // we bound whatever it accepted. A block passes through — spill only shapes
+    // accepted plain-text results, never corrective feedback.
+    const decision = await next()
+    // Skip `read` to avoid a read → spill → read again loop.
+    if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value')
+      || exec.parent !== undefined || exec.name === 'read') return decision
+
+    const content = decision.content ?? result.content
+    const text = flattenPlainText(content)
+    if (text === undefined) return decision
+    const totalBytes = Buffer.byteLength(text, 'utf8')
+    if (totalBytes <= maxInlineBytes) 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<ContentBlock[]> => {
+    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 })
 }

+ 91 - 0
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 })

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -165,6 +165,7 @@ export const LINK_MAP: Record<string, string> = {
   TaskSnapshot: 'tasks.md',
   TaskStart: 'tasks.md',
   TokenMeasurement: 'token-meter.md',
+  CodeDispatchLog: 'tools.md',
   PostToolDecision: 'tools.md',
   PreToolDecision: 'tools.md',
   ToolDefinition: 'tools.md',