feat(tools): live dispatch lifecycle + native-contract parallel sub-calls in Code Mode

The bridge replaces its serialization queue with a pool that reuses the
native concurrency contract: submissions classify through
registry.executionMode (fail-closed isConcurrencySafe), start strictly in
submission order, overlap up to the validated maxParallelSubCalls config
(default 10; 1 restores serial), and exclusive calls drain the pool, run
alone, and bar later calls. Each started sub-call logs a
tool/code-dispatch-start event at pool entry; the existing
tool/code-dispatch settles the pair (started ⇔ settles exactly once;
abandoned queued calls log neither). SDK prompt guidance now states the
true Promise.all contract — re-recorded across every code/both-mode
snapshot (plus the stale cordis-dynamic-toolchain fixture gaining the
required description arg).

Client: CodeSubCall widens to RunningToolCall | ToolResultNode — starts
land the running shape (rows wear the native running ring), settles
replace in place preserving start order, callTime pairs to the start
time. Fixture emits start/settle pairs; jsdom pins the running sub-row;
runtime specs pin in-place settlement and out-of-order completion.
This commit is contained in:
Tianyi Cui
2026-07-26 06:02:36 +08:00
parent 526651cb88
commit 8a79679489
34 changed files with 1921 additions and 1673 deletions

View File

@@ -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-06-15-code-mode.md: 33b8dc6a27c1cc12962f75e1211996dba6f81496
2026-06-15-code-mode.zh.md: db03ca10edbc7fa826ed991df26847aa9271b731
2026-06-15-code-mode.md: 4b088efed04d33c1eedd765a11ca05d6a59b36fe
2026-06-15-code-mode.zh.md: 655a722e9ff0b3be9a178da91b795e4440967351

View File

@@ -42,7 +42,7 @@ This note owns Code Mode's presentation, composition, isolation, and settlement
Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with one required parameter, `{ code: string }`. It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`:
1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, waits on the serialization queue, executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs `tool/code-dispatch` with the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline.
1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/code-dispatch-start`/`tool/code-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline.
2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime.
3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured logs and the completion value as canonical output; the registry renders that value into durable `tool/result.content`, which the result card reads directly. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles.
@@ -54,7 +54,7 @@ Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentat
### Observability: `tool/code-dispatch`
Each sub-dispatch appends a log-only `tool/code-dispatch` event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event.
Each sub-dispatch appends a log-only `tool/code-dispatch-start` event at pool entry and a `tool/code-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event.
### The code-runtime seam

View File

@@ -42,7 +42,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
`'code'``'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带一个必需参数 `{ code: string }`。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`
1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,等待序列化队列,以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并连同完整渲染后的结果内容记录 `tool/code-dispatch`。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。
1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生契约的分发池(调度设计由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch-start`/`tool/code-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。
2. **运行程序**`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。
3. **完全停稳后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的 `tool/result.content`,供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。
@@ -54,7 +54,7 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一
### 可观测性:`tool/code-dispatch`
每次子分发追加一个仅日志的 `tool/code-dispatch` 事件,包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。
每次子分发在进入分发池时追加一个仅日志的 `tool/code-dispatch-start` 事件,并以一个 `tool/code-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。
### code-runtime seam

View File

@@ -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-mode-live-parallel-dispatch.md: f0d13456d63779fb89b9af4cb09bc90c37356a21
2026-07-26-code-mode-live-parallel-dispatch.zh.md: 5554ab0456f13a2bbc6d5b18e515930f954c6682

View File

@@ -0,0 +1,32 @@
# Agent Note: Code Mode live dispatch lifecycle and native-contract parallelism
Status: implemented
English | [中文](2026-07-26-code-mode-live-parallel-dispatch.zh.md)
> Scope: the third PR of the Code Mode UI stack — the `tool/code-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](2026-07-26-code-dispatch-ui-foundation.md) and [chat sub-call rows](2026-07-26-code-mode-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md).
## Problem
Two gaps remained after the first two PRs. Sub-call rows appeared only when each dispatch *settled* — while one ran, the UI showed nothing for it, so a slow sub-call read as a stalled parent. And the bridge serialized every binding call ("even `Promise.all` executes one at a time"), a placeholder from before tools carried concurrency metadata: `isConcurrencySafe` now exists, the loop scheduler already runs native siblings in bounded pools, and a Code Mode program awaiting three independent reads paid 3× the latency the native path would.
## Decision
**One lifecycle pair, one scheduling contract, shared with native.**
- **Event pair**: `tool/code-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/code-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0.
- **Bridge scheduler**: submitted calls are classified at submission via `registry.executionMode` (the SAME fail-closed `isConcurrencySafe` contract the loop uses) and start strictly in submission order. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (a validated registry `Config` field, default 10 — the loop scheduler's own default; `1` restores serial dispatch); an exclusive call drains the pool, runs alone, and bars later calls. This is the loop's group semantics adapted to calls that arrive over time instead of in one parsed batch. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence before the outer result closes the turn.
- **Client**: `CodeSubCall` widens to `RunningToolCall | ToolResultNode` — a start event lands the running shape in the dispatch index (rows derive the running ring from the shape, exactly as for native in-flight calls), and its settle replaces the entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering.
- **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every code-mode snapshot.
## Alternatives considered
**Unrestricted parallelism (let `Promise.all` overlap everything).** Rejected: writes could race; the native scheduler exists precisely because the tool, not the caller, owns the safety claim. One concurrency vocabulary across native and Code Mode was the settled requirement.
**Emit the start event at submission instead of pool entry.** Rejected: a submission-time start would show queued-but-never-run calls as "running" and would force a third "abandoned" terminal event to reconcile the log. Start-at-entry keeps the invariant *started ⇔ settles exactly once* and needs no third event.
**Reuse the loop scheduler's implementation directly.** Rejected: the loop schedules a fully-parsed batch with model-order result commitment; the bridge schedules an open-ended stream of submissions whose results return to the program (not the transcript), so only the *contract* (classification, pool, barriers) is shared, not the machinery.
## Consequences
Programs get native-grade latency for independent reads with no new model-side API — `Promise.all` simply works better, and prompt guidance changed accordingly. The web UI shows per-sub-call running rings live (fixture emits start/settle pairs; jsdom pins the running shape; the runtime spec pins in-place settlement, out-of-order completion, and callTime pairing). PR6 (trajectory/waterfall spans) can now draw truthful spans from the pair's timing. The spill PR (next) inherits the settle event as its single bounding point.

View File

@@ -0,0 +1,32 @@
# Agent NoteCode Mode 的实时分发生命周期,以及复用原生契约的并行执行
Status: implemented
[English](2026-07-26-code-mode-live-parallel-dispatch.md) | 中文
> 范围Code Mode UI 堆叠 PRPull Request链的第三个 PR涵盖 `tool/code-dispatch-start` 事件、web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发契约的复用。构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.md)与 [chat 子调用行](2026-07-26-code-mode-chat-subcall-rows.md)之上;原生契约本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.md) 所有。
## 问题
前两个 PR 之后仍留有两个缺口。子调用行过去只在每次分发*结算*settle后才出现某次分发运行期间UI 对它毫无展示,于是一个慢的子调用看上去就像父调用卡住了。而桥接层过去把每一次绑定调用都串行化(「即使 `Promise.all` 也一次只执行一个」),这是工具尚未携带并发元数据时留下的占位实现:如今 `isConcurrencySafe` 已经存在agent loop智能体循环调度器早已在有界并发池中运行原生兄弟调用而一个等待三个独立读取的 Code Mode 程序,付出的延迟却是原生路径的 3 倍。
## 决策
**一对生命周期事件,一份调度契约,与原生共用。**
- **事件对**`tool/code-dispatch-start`(父/子 id、名称、规范化参数在调度器真正启动某个调用时才追加而非在提交时因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/code-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件都保持仅日志;模型上下文不受影响;格式保持 v0。
- **桥接层调度器**:已提交的调用在提交那一刻就经 `registry.executionMode` 分类(与 loop 所用完全相同的 fail-closed `isConcurrencySafe` 契约),并严格按提交顺序启动。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(经校验的注册表 `Config` 字段,默认值 10即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,并阻挡其后的调用。这是把 loop 的分组语义适配到另一种场景调用随时间陆续到达而非作为单个已解析的批次一次性到达。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳,之后外层结果才结束该轮次。
- **client 侧**`CodeSubCall` 拓宽为 `RunningToolCall | ToolResultNode`start 事件把运行中形状写入分发索引(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致),其结算事件则原位替换该条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。
- **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实契约(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 code-mode 快照都已重新录制。
## 曾考虑的替代方案
**不加限制的并行(让 `Promise.all` 重叠一切)。** 否决:写操作可能产生竞态;原生调度器之所以存在,正是因为安全性声明归工具所有,而不归调用方。原生与 Code Mode 使用同一套并发词汇,是已敲定的要求。
**在提交时而非入池时发出 start 事件。** 否决:提交即发 start 会把排了队却从未运行的调用显示成「运行中」,还得强行引入第三种「已放弃」终态事件才能使日志自洽。入池才发 start 保住了*已启动 ⇔ 恰好结算一次*这一不变式,且不需要第三种事件。
**直接复用 loop 调度器的实现。** 否决loop 调度的是一个完整解析好的批次,并按模型顺序提交结果;桥接层调度的则是一条开放式的提交流,其结果返回给程序,而不是进入 transcript文本记录。因此两者共享的只是*契约*(分类、池、屏障),而不是实现机制。
## 后果
程序不需要任何新的模型侧 API独立读取就获得了原生级的延迟`Promise.all` 直接变得更好用提示词指引也随之修改。web UI 实时显示每个子调用的运行指示环fixture测试前置数据发出成对的 start/settle 事件jsdom 锁定运行中形状;运行时 spec 锁定原位结算、乱序完成与 callTime 配对。PR6trajectory/waterfall 的 span现在可以依据这对事件的计时绘制如实的 span。spill PR下一个则继承结算事件作为自己唯一施加边界的位置。