Parallel tool-call execution by per-call safety
按单次调用安全性并行执行工具调用
An assistant message may contain several sibling `tool-call` blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together. Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema. The session l
English
Problem
An assistant message may contain several sibling tool-call blocks. Running them serially adds the latency of independent reads and web requests even though the model has already requested them together.
Concurrency is a host scheduling concern, not model-facing tool metadata. The loop needs to decide which calls may overlap without hardcoding tool names or exposing scheduler policy in the JSON schema.
The session log remains authoritative: every started call has an audit event, ordinary completion and cancellation pair calls with results, and model history observes committed results in the original call order regardless of completion order.
Decision
Each tool may provide an optional isConcurrencySafe(args) classifier. It is synchronous and pure: it examines only the current call's parsed arguments and performs no I/O or mutation. Only an explicit true opts in; a missing classifier, invalid arguments, a thrown classifier, or any other return value makes the call exclusive. The canonical type contract lives in the tool data structures.
The classifier is deliberately unary. Returning true is the tool's promise that this call may overlap with any sibling call that also returns true; the scheduler does not compare calls or prove that their resource accesses are compatible.
The unary classifier remains input-sensitive. A tool may classify a read-only operation as parallel and a mutating operation as exclusive. The interface cannot express relational rules such as "these writes are safe only when their paths differ," so a call whose safety depends on a sibling remains exclusive.
defineTool() validates arguments before invoking a typed classifier. Invalid arguments classify as exclusive and produce the ordinary argument error only if the call executes. ctx.tools.executionMode(exec) resolves the live tool definition and returns the tagged parallel or exclusive mode; unknown tools fail closed to exclusive.
A tagged mode, rather than a public boolean scheduler API, keeps resource-aware variants representable without changing the classifier contract.
Scheduling and ordering
The loop waits for the complete assistant message, parses every call once, creates a distinct ToolExecution for each call, and scans them in model order. Consecutive parallel calls form one group; every exclusive call forms a singleton group and an ordering barrier. Groups execute sequentially. Classification is lazy: the scheduler resolves the next call after each barrier and reclassifies every later call before replenishing a parallel pool. If a registry mutation makes that call exclusive, the current pool drains before the call starts as the next barrier.
For example:
[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)]
→ [read(A), read(B)]
→ [write(A)]
→ [read(C)]
read(A) and read(B) may overlap. write(A) starts after both finish, and read(C) starts after the write finishes.
Every group uses a rolling pool bounded by maxParallelToolCalls: the loop starts calls in model order up to the cap and starts another whenever one settles. An exclusive group is a pool of one. A cap of 1 preserves serial execution.
Only dispatch and the tool body overlap. tools/pre-execute and tools/post-execute run in model order because middleware may maintain ordering-sensitive state. tools/execute wrappers run around concurrent dispatches and therefore must be reentrant across distinct executions.
Each started call appends tool/call immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends tool/result and collects additionalContexts only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered.
An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls.
Code Mode remains outside this scheduler because the model emits one native run_code call. run_code and its internal dispatch queue remain serial; native sibling calls in mode: 'both' use the normal scheduler.
Safety contract
A tool that returns true promises that its body is safe to run at the same time as other parallel calls. It must not directly mutate the parent session or other parent-owned state; it returns its outputs to the loop, which commits them in model order.
Any shared state touched during execution must be concurrency-safe. This includes tool wrappers and providers: they may serialize internally or enforce their own capacity, but they must support concurrent dispatch without corrupting state.
Configuration and declarations
maxParallelToolCalls is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to 10; 1 preserves serial execution. Exact fields and defaults live in the generated configuration catalog.
The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model (parallel subagent Agent Note). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive.
Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces FS_STALE_VERSION.
Verification
Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration.
Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior.
Alternatives considered
Keep serial execution. This avoids new ordering and abort cases but retains unnecessary latency for independent sibling calls.
Use one tool-level boolean. A fixed supportsParallelToolCalls flag is smaller but cannot distinguish a tool's read-only and mutating operations. The argument-sensitive classifier preserves that distinction.
Use stateful classification. Giving the classifier a live agent, registry, or I/O access makes the decision depend on when it runs and creates a gap between classification and dispatch. Mutable authorization and stale-state checks remain execution-time responsibilities.
Use sibling-aware or resource-aware classification. The scheduler could compare calls pairwise or let each call declare resource read/write claims. This can parallelize non-conflicting writes, but it requires shared resource identity and conflict semantics across unrelated tools. The unary contract instead gives up that concurrency and fails closed when safety is relational.
Parallelize the complete tool pipeline. This keeps the loop on the public one-call API but runs pre- and post-execute middleware concurrently. Existing guards and hook bridges may carry ordered state, so only dispatch overlaps.
Expose staged methods or a scheduling waterfall. Public prepare / dispatch / finalize methods or a tools/execution-mode event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while executionMode(exec) leaves an insertion point for a policy hook.
Convert scheduler failures into tool results. AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as ABORTED results.
Start calls while the model streams. This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
Use fixed-size windows. Waiting for every call in one window before starting the next leaves capacity idle behind a slow call. The rolling pool preserves the cap without that delay.
Expose concurrency metadata to the model. The model can already emit sibling calls. Host scheduling metadata would enlarge requests without improving tool choice.
Consequences
The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races.
Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation.
Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress.
Concurrent external calls can compete for quota or process capacity. Providers own their capacity controls; the loop cap only limits calls from one agent step.
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes.
中文
问题
一条 assistant 消息可以包含多个并列的 tool-call 块。尽管模型已经同时请求了这些调用,串行执行仍会叠加各个独立读取和 Web 请求的延迟。
并发属于宿主调度范畴,不是面向模型的工具元数据。循环需要在不硬编码工具名称、不向 JSON Schema 暴露调度策略的前提下,判断哪些调用可以重叠执行。
会话日志仍是权威记录:每个已启动的调用都有审计事件,正常完成和取消都会使调用与结果配对;无论完成顺序如何,模型历史都按原始调用顺序观察已提交的结果。
决策
每个工具都可以提供可选的 isConcurrencySafe(args) 分类器。该分类器必须是同步纯函数:它只检查当前调用已解析的参数,不执行 I/O 或任何变更。只有显式返回 true 才表示选择并行;分类器缺失、参数无效、分类器抛错或返回任何其他值,都会使该调用按独占方式执行。规范类型约定见工具数据结构。
分类器有意设计为一元函数。返回 true 表示工具承诺:此调用可以与任何同样返回 true 的并列调用重叠执行。调度器不会比较调用,也不会证明它们的资源访问相容。
这个一元分类器仍然可以感知输入。工具可以将只读操作分类为并行,将变更操作分类为独占。该接口无法表达「仅当路径不同时,这些写入才安全」之类的关系规则,因此,安全性依赖并列调用的调用仍按独占方式执行。
defineTool() 先验证参数,再调用类型化分类器。无效参数会被归为独占,且只有该调用真正执行时才会产生常规参数错误。ctx.tools.executionMode(exec) 会解析当前有效的工具定义,并返回带标签的 parallel 或 exclusive 模式;未知工具将按安全侧原则归为独占。
使用带标签的模式,而不是公开布尔型调度器 API,使得以后可以表达感知资源的变体,无需改变分类器约定。
调度与顺序
循环会等待完整的 assistant 消息,对每个调用只解析一次,为每个调用创建独立的 ToolExecution,再按模型顺序扫描。连续的并行调用组成一组;每个独占调用单独组成一组,并构成顺序屏障。各组按顺序执行。分类采用惰性方式:每经过一个屏障,调度器都会解析下一个调用;补充并行池之前,还会重新分类每个后续调用。如果注册表变更使该调用变为独占,当前池会先完全排空,然后该调用才作为下一个屏障启动。
例如:
[parallel read(A), parallel read(B), exclusive write(A), parallel read(C)]
→ [read(A), read(B)]
→ [write(A)]
→ [read(C)]
read(A) 和 read(B) 可以重叠执行。write(A) 要等两者都完成后才启动,read(C) 则要等写入完成后才启动。
每组都使用一个由 maxParallelToolCalls 限制上限的滚动池:循环先按模型顺序启动调用,直到达到上限;每有一个调用结算,就再启动一个。独占组是容量为 1 的池。将上限设为 1 可保持串行执行。
只有派发和工具主体会重叠执行。tools/pre-execute 和 tools/post-execute 按模型顺序运行,因为中间件可能维护对顺序敏感的状态。tools/execute 包装层会包裹并发派发过程,因此必须能在不同执行之间重入。
每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 tool/call。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 tool/result 并收集 additionalContexts。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。
Code Mode 仍不使用此调度器,因为模型只会发出一个原生 run_code 调用。run_code 及其内部派发队列仍按串行方式执行;mode: 'both' 中的原生并列调用使用常规调度器。
安全约定
工具返回 true 即承诺:其主体可以与其他并行调用同时运行。它不得直接变更父会话或其他由父级拥有的状态;它将输出返回给循环,由循环按模型顺序提交。
执行期间触及的任何共享状态都必须支持并发。这也包括工具包装层和提供方:它们可以在内部串行化,也可以实施自身容量限制,但必须在并发派发时不破坏状态。
配置与声明
maxParallelToolCalls 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 10;1 保持串行执行。字段和默认值的精确定义见生成的配置目录。
当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责(并行 subagent Agent Note)。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。
文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 FS_STALE_VERSION。
验证
单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后的运行时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。
快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。
备选方案
保持串行执行。 这可以避免新的顺序和中止情形,但会保留独立并列调用所产生的不必要延迟。
使用一个工具级布尔值。 固定的 supportsParallelToolCalls 标志更简洁,但无法区分同一工具的只读操作和变更操作。感知参数的分类器保留了这项区分。
使用有状态的分类。 向分类器提供运行中的 agent、注册表或 I/O 访问,会使决策依赖分类器的运行时机,并在分类与派发之间留下缺口。可变授权和陈旧状态检查仍属于执行时职责。
使用感知并列调用或感知资源的分类。 调度器可以成对比较调用,或让每个调用声明资源读写要求。这样可以并行化不冲突的写入,却要求不相关工具共享资源标识和冲突语义。一元约定选择放弃这部分并发性,并在安全性取决于调用间关系时按安全侧原则处理。
并行执行完整的工具流水线。 这样可以让循环继续使用公开的单调用 API,但会并发运行 pre-execute 和 post-execute 中间件。现有防护和钩子桥可能承载有序状态,因此只允许派发重叠。
公开分阶段方法或调度 waterfall(瀑布式事件)。 公开的 prepare / dispatch / finalize 方法或 tools/execution-mode 事件,会在另一个消费方需要它之前扩大扩展接口。循环使用内部调度器视图,而 executionMode(exec) 为策略钩子保留了插入点。
将调度器故障转换为工具结果。 AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 ABORTED 结果。
在模型流式输出时启动调用。 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
使用固定大小的窗口。 如果在启动下一个窗口前等待当前窗口的每个调用,一个缓慢调用就会使容量闲置。滚动池在保持上限的同时避免了这项延迟。
向模型暴露并发元数据。 模型已可以发出并列调用。宿主调度元数据会扩大请求,却无助于工具选择。
影响
该设计遵循安全侧原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。
在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。
有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。
并发外部调用可能会争用配额或进程容量。提供方负责自身容量控制;循环上限只限制一个 agent 步骤中的调用数量。
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。