并行工具为什么仍要保留原始顺序
拆开工具批次的顺序预检、并发执行、完成事件与 ToolResult 写回,解释实时观测和模型上下文为何采用不同排序。
通过预检的多个工具,默认会并发执行。若只盯着 Promise.all,很容易得出一个过度简化的结论:所有步骤一起开始,谁先完成谁先写回。Pi 实际保留了两种顺序。实时事件按完成先后出现,给模型的 ToolResult 则按 assistant message 中的 tool call 原始顺序出现。
这两种顺序服务不同对象。UI 需要尽快知道哪个命令已经结束,不该等最慢的工具;provider 上下文需要稳定地把每个 tool result 对回原始 tool call,不能因为一次文件读取快慢变化就重排 transcript。
并发是批次决定,不是每条调用各走各的
executeToolCalls() 先扫描 assistant message 中的全部 tool calls。只要全局配置是 sequential,或者其中任意一个已注册工具标记 executionMode: "sequential",整批走顺序执行。只有配置允许且批次中没有 sequential 工具,才进入 parallel 路径。
这是一种保守的相容规则。假设一条消息同时包含 read 与修改共享数据库的工具,后者要求 sequential;只让后者排队、其余继续并发仍可能改变它观察到的外部状态。Pi 没有尝试推断工具间依赖,而是把 sequential 标记提升为整批约束。反过来,工具标 parallel 也不能覆盖全局 sequential。
顺序路径很直接:每条调用先发 start,完成预检,必要时执行与 after hook,发 end,再立即发对应 ToolResult 的 message start/end,然后才处理下一条。它的完成顺序、事件顺序和历史顺序天然一致。
parallel 路径分成四段
并行路径第一段仍是顺序预检。源码依次为每条 tool call 发 tool_execution_start,再 await prepareToolCall()。未知工具、校验错误、block 或已 abort 会得到 immediate outcome;这类调用当场发 end,并以已完成结果占住原始数组位置。允许执行的调用不会立即 await,而是保存一个 async closure。
第二段在预检循环结束后才开始。finalizedCalls.map(...) 依原始顺序调用每个 closure,函数一被调用就进入 executePreparedToolCall();各自返回 Promise,交给同一个 Promise.all。工具主体由此并发。严格说,它们的 JavaScript 调用起点仍有同步的先后,但在第一个异步等待点后可以交错推进。
第三段发生在每个 closure 内。工具完成后先运行 afterToolCall 形成 finalized outcome,再立即发 tool_execution_end。第二个工具先完成,它的 end 就先出现,不必等待第一个。Agent 收到这些事件后会及时从 pendingToolCalls 删除对应 id。
第四段等 Promise.all 全部结算。JavaScript 的 Promise.all 保留输入数组位置,因此 orderedFinalizedCalls 恢复 assistant message 的 source order。Pi 此时才逐条创建 ToolResult message、发 message events,并把 messages 数组交回 runLoop()。
sequenceDiagram
accTitle: 两个并行工具的两种排序
accDescr: 模型先给出慢工具一和快工具二;预检保持原序,执行并发,工具二先发结束事件,但 ToolResult 仍按一再二写回
participant L as Agent loop
participant T1 as tool-1 slow
participant T2 as tool-2 fast
participant H as transcript
L->>L: preflight tool-1
L->>L: preflight tool-2
L->>T1: execute tool-1
L->>T2: execute tool-2
T2-->>L: result-2
L-->>L: tool_execution_end(2)
T1-->>L: result-1
L-->>L: tool_execution_end(1)
Note over L: Promise.all => [result-1, result-2]
L->>H: ToolResult(1)
L->>H: ToolResult(2)
Update 事件也属于运行中的时间线
工具可以通过 onUpdate 报告 partial result。executePreparedToolCall() 把每次 emit 包成 Promise 收集起来;工具 Promise 结算后停止接收新 update,并等待此前所有 update event 完成,再返回最终结果。迟到的 callback 会被忽略,避免已完成工具又把 UI 改回 running。
单个工具内部,已接收的 updates 会先于它的 final outcome 结束;多个并行工具之间,updates 与 ends 可以交错。它们是观测事件,不会像 ToolResult 一样追加进 AgentContext.messages。若上层需要持久化进度,必须自己订阅 tool update;不能从最终 transcript 反推出每一个中间百分比。
after hook 位于工具主体和 end 之间。它能替换 content、details、usage、isError 或 terminate,end event 与后续 ToolResult 都看到 finalize 后的值。hook 抛错则把这次工具改成错误结果,不让异常直接穿透整场 run。
仓库测试直接制造了反序完成
Pi 的测试让 tool-1 等待一个 promise,让 tool-2 先完成。断言很有区分度:tool_execution_end ids 是 [tool-2, tool-1],toolResult message 与 turn_end.toolResults 则都是 [tool-1, tool-2]。
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/agent/test/agent-loop.test.ts |
nl -ba | sed -n '586,679p'
# 可选:固定 checkout 已安装依赖时执行该 mock 测试
npm --prefix "$repo/packages/agent" test -- \
test/agent-loop.test.ts \
-t 'completion order but persist tool results in source order'
保留原始顺序不等于批次一定继续。每个 finalize 结果还可能带 terminate;只有整批所有结果都为 true,工具驱动的下一轮才停止。除此之外,run 还可能因模型 error、abort、graceful stop 或自然耗尽结束。下一章把这些停止路径放到一张表里,同时说明 continue() 实际重新建立了什么。