Files
analysis_claude_code/learn-pi-agent/s04_evented_tool_loop

s04: Evented Tool Loop — 请求、执行、送回去

provider 点菜core 后厨,结果回桌。 Pi 边界:工具执行边界 —— provider 只发请求,执行永远在 core结果回到消息流成为历史。

上一节s03s04下一节s05


问题

s02 给了 provider 工具说明s03 给了事件流。但到现在为止provider 还不能真的用上这些工具——它只会说文本,没法告诉 core"去把那个工具跑一下"。

而且就算能请求往往也不是一趟就够provider 用完一个工具,看了结果,可能还要再用一个。这需要一个来回多次的循环。

s04 要补上这一环:让 provider 能请求执行工具core 执行后把结果送回去,循环到 provider 不再请求为止。


解决方案

事件里多出一种 tool_callprovider 用它说"我要调用某个工具"。core 收到后做三步:

tool_call  →  registry.run()  →  ToolResultMessage  →  进 messages

工具结果和 user / assistant 消息平级,也存进 messages。这样 provider 下一轮就能看到"刚才那个工具返回了什么",决定是接着用工具,还是收尾。

[U1 升级] 一轮推进的函数从 runOneTurn 变成 runEventedToolLoop:原来跑一趟就结束,现在套了一个循环。这是受控升级——单轮没法表达"来回多趟",所以是替换。

这一节还顺手立两条保护:循环有轮次上限provider 一直请求也不会死循环R5工具执行抛错也不崩错误会被包成一条结果消息送回去R4


工作原理

先定义请求和结果。 provider 的请求叫 ToolCall结果叫 ToolResultMessage。

export type ToolCall = {
  id: string;
  name: string;
  input: Record<string, string>;
};

export type ToolResultMessage = {
  role: "toolResult";
  toolCallId: string;
  content: string;
};

工具结果也是一种消息,并入 AgentMessage停止原因也多一个 toolUse,表示"provider 还想用工具,先别停"。

export type StopReason = "stop" | "toolUse" | "error";
export type AgentMessage = UserMessage | AssistantMessage | ToolResultMessage;

事件里加 tool_call。 s03 的三种事件都在,多出来的是 tool_call

export type ProviderEvent =
  | { type: "message_start" }
  | { type: "text_delta"; text: string }
  | { type: "tool_call"; call: ToolCall }
  | { type: "message_end"; stopReason: StopReason };

registry 学会执行。 run(call) 拿到工具就跑 handler碰到没注册过的名字不抛错返回一句说明。

run(call: ToolCall): string {
  const tool = this.tools.get(call.name);
  if (!tool) {
    return `unknown tool: ${call.name}`;
  }
  return tool.handler(call.input);
}

核心是循环。 runEventedToolLoop 每一轮:构造输入 → 收事件 → 遇到 tool_call 就执行、把结果存成消息 → 一轮事件收完后,看还有没有 tool_call。有就再来一轮没有就收尾。轮次超过上限会主动停下。

while (true) {
  turns += 1;
  if (turns > MAX_TURNS) { /* 主动停止 */ }

  // ... 收事件,遇 tool_call 就 registry.run + 存 ToolResultMessage ...

  if (!sawToolCall || stopReason !== "toolUse") {
    // 存下 assistant 消息,结束
    return assistant;
  }
}

工具执行包在 try/catch 里handler 抛错,错误变成 error: ... 这条结果消息循环继续——provider 会看到这个错误,自己决定怎么办。

这一节真正建立的是工具执行边界provider 只发请求,执行永远发生在 core 这边;而且工具结果不丢,它回到消息流里,成为对话历史的一部分。后面 s05 会在"执行"这个动作的前后加插口,但"请求在 provider、执行在 core"这条分隔,从这里立起来。


试一下

运行:

npm run s04

输出类似:

s04: Evented Tool Loop

[user]
现在几点?

message_start
tool_call: current_time
tool_result: 2026-01-01T00:00:00Z
message_end: toolUse
message_start
text_delta: 工具结果是2026-01-01T00:00:00Z
message_end: stop

[assistant]
content: 工具结果是2026-01-01T00:00:00Z
stopReason: stop

[state]
user -> toolResult -> assistant

观察重点:第一轮 provider 请求 current_timecore 执行后结果进了 messagestool_result);第二轮 provider 看到结果,输出文本并结束;[state] 里能看到 user -> toolResult -> assistant 这条链。


接入主线

s04 在 s03 上累积。相对 s03 的变更:

组件 s03 s04
StopReason stop | error stop | toolUse | errorR1 加 toolUse
AgentMessage User | Assistant User | Assistant | ToolResultMessageR1
新增类型 ToolCall / ToolResultMessage
ProviderEvent 三种 tool_callR1message_start 保留)
ToolRegistry register / getSpecs run(call)R2
一轮推进 runOneTurn(单趟) runEventedToolLoopU1 升级,带循环)
事件收集 collectAssistantMessage(单函数) 被循环内联收集取代(要处理 tool_call
保护 MAX_TURNS 终止R5、工具错误捕获R4

焊接点:循环内 tool_callregistry.run(call) → 结果存成 ToolResultMessage 进 messages → 下一轮 provider 输入tools 始终取 registry.getSpecs(),和能执行的工具是同一份来源。


接下来

现在 provider 一请求,工具就直接执行了——没有给 core 留任何介入的余地。

下一节会在执行这个动作的前后留两个插口:执行前可以拦下来,执行后可以改写结果。

进入下一节:s05


Pi 源码溯源:工具循环的并发与终止

教学版的 runEventedToolLoop 是串行循环、MAX_TURNS 兜底。Pi 的工具循环(packages/agent)要复杂得多——最关键的是并发执行

源码在哪

  • packages/agent/src/agent-loop.ts:373executeToolCalls(工具执行总入口)
  • packages/agent/src/agent-loop.ts:451executeToolCallsParallel(并发执行)
  • packages/agent/src/types.ts:29ToolExecutionMode

一个 stop 可以带多个工具调用

教学版一轮只处理一个 tool_call。Pi 里模型一次回复可以带多个 tool callagent-loop.ts:207

if (toolCalls.length > 0) {
  const batch = await executeToolCalls(currentContext, message, config, signal, emit);
  toolResults.push(...batch.messages);
  hasMoreToolCalls = !batch.terminate;
}

一次 batch 里所有工具一起处理,结果一起回写。

sequential vs parallel

教学版串行。Pi 有两种执行模式(types.ts:29

type ToolExecutionMode = "sequential" | "parallel";

并发版 executeToolCallsParallelagent-loop.ts:451)用 Promise.all 同时跑多个工具,但事件顺序保持——prepare 阶段(参数校验 + beforeHooks05是顺序的execute 阶段才并发,结果按完成时间发事件。哪个工具该并发、哪个该独占,由工具自己的 executionMode 决定s02 提过 AgentTool 有这个字段)。

工具能主动终止整个循环

教学版的终止条件是"provider 不再发 tool_call"或"撞 MAX_TURNS"。Pi 多一个:工具结果能带 terminatetypes.ts

interface AgentToolResult<T> {
  content: (TextContent | ImageContent)[];
  details: T;
  terminate?: boolean;   // 这个工具要求停止整个 agent
}

只有 batch 里所有工具都 terminate 才真停——防止单个工具误杀整个会话。教学版的 MAX_TURNS 是被动兜底Pi 的 terminate 是工具主动喊停。

执行前后有插口s05 的预告)

教学版直接 registry.run。Pi 的每个工具执行经过 prepareToolCall(参数校验 + beforeToolCall hookexecutefinalizeExecutedToolCallafterToolCall hook。这就是 s05 的 hook 真实位置——它长在并发执行的 prepare/finalize 两侧。

一句话

教学版的循环立的是"请求 → 执行 → 回写 → 再来"。Pi 把它扩成 batch 并发 + 工具主动 terminate + 执行前后插口,但循环主干(收 tool_call、执行、结果回 messages、看是否继续和教学版一致。