两层循环为什么缺一不可
沿 runAgentLoop 与 runLoop 的控制流拆开 run、turn 和 provider request,解释内层工具推进与外层 follow-up 恢复为何不能合并成一个条件。
上一章看到 Agent 用事件更新状态。事件里同时有 agent_start 和 turn_start,这两个名字已经暗示:一场 run 不等于一轮 turn,更不等于一次 provider request。只要模型要求调用工具,Pi 就会把工具结果送回模型;同一个 prompt() 尚未返回,第二次 provider request 已经开始。
AgentEvent 对 turn 的定义很窄:一次 assistant response,加上它触发的工具调用与结果。run 则从 agent_start 到 agent_end,可以覆盖多个 turn。用户最初提交的 prompt 属于 run 的新消息,但它不单独构成一个 turn;第一次 turn_start 在发 prompt 的 message events 之前已经发出,随后那次 assistant response 才完成这一轮。
入口只负责把 run 架起来
runAgentLoop() 复制初始 context,把新 prompts 接到运行内消息列表,然后依次发 agent_start、第一次 turn_start 和 prompt 的 message start/end。它没有自己写工具循环,而是把 currentContext、本次新增消息数组和配置交给 runLoop()。runAgentLoopContinue() 走同一个 runLoop(),区别是它不追加 prompt,也不重发已有末尾消息的 message events。
这层拆分决定了返回值的语义。新 prompt run 的 newMessages 从 prompts 开始;continuation 的数组从空开始,只返回这次新产生的 assistant 与 tool results。它们都在共享的 context 上继续推理,但“这次调用新增了什么”并不相同。后面读 session 持久化时,这个差别会再次出现。
内层循环回答:这一轮之后还必须继续吗
runLoop() 先读取一次 steering queue,随后进入外层 while (true)。每次外层迭代把 hasMoreToolCalls 设为 true,于是内层至少运行一次。内层每次迭代做这些事:
- 除第一次外发出新的
turn_start。 - 把 pending messages 作为 message events 注入 context。
- 请求一次 assistant response。
- 若响应是 error/aborted,立刻以
turn_end、agent_end结束。 - 执行这一条 assistant message 中的工具批次,把 tool results 接入 context。
- 发
turn_end,应用下一轮 snapshot,检查 graceful stop,再读取 steering。
内层条件是 hasMoreToolCalls || pendingMessages.length > 0。其中 hasMoreToolCalls 不是简单地看 assistant 有没有 tool call。执行完工具批次以后,Pi 读取批次的 terminate 结论:只要批次没有要求终止,就让工具结果推动下一轮模型请求。与此同时,即便工具批次选择终止,只要 steering 已经排入 pendingMessages,内层仍会继续,让用户的新方向进入下一次请求。
flowchart TD
accTitle: runLoop 的两层继续条件
accDescr: 内层由工具结果或 steering 驱动,内层耗尽后外层才读取 follow-up;没有 follow-up 时发 agent_end
START["run start"] --> INIT["initial steering poll"]
INIT --> TURN["inner: start one turn"]
TURN --> INJECT["inject pending messages"]
INJECT --> MODEL["one provider request"]
MODEL --> TERMINAL{"error / aborted?"}
TERMINAL -->|yes| END["turn_end + agent_end"]
TERMINAL -->|no| TOOLS["execute tool batch"]
TOOLS --> TE["turn_end + prepare/stop checks"]
TE --> STEER["poll steering"]
STEER --> INNER{"tools need model or steering exists?"}
INNER -->|yes| TURN
INNER -->|no| FOLLOW["poll follow-up"]
FOLLOW --> HAS{"follow-up exists?"}
HAS -->|yes| TURN
HAS -->|no| END2["agent_end"]
外层循环回答:本来要停了,是否又收到任务
内层只有在“不再需要用工具结果追问模型,而且没有 steering”时退出。此刻 Agent 按当前上下文已经能正常停止,runLoop() 才调用 getFollowUpMessages()。有 follow-up,就把它放进 pendingMessages,回到外层顶部,再进入一轮内层循环;没有才真正 break,并发出 agent_end。
如果把 follow-up 也塞进内层末尾,代码表面上可以少一个 while,语义会变得含糊:系统无法区分“用户正在纠正当前工作”与“当前工作已完成,接着做另一件事”。两者都能产生新的 user message,却位于不同的调度点。Pi 用循环结构把这个产品语义写进控制流,而不是只在队列名字里留一条注释。
反过来,steering 也不能等到外层才取。模型调用了工具,工具结果本来会立即触发下一次请求;如果此时用户已经输入纠正消息,steering 必须在那次请求前与工具结果一起进入 context。它仍不会掐断正在执行的工具批次,只是在完整 turn_end 之后抢在下一次 provider request 前注入。
三种计数不要混用
读日志或写 UI 指标时,可以用下面的关系校准:
- 一次
prompt()/continue()对应一场 run,正常情况下各有一个agent_start和agent_end。 - 一个
turn_start对应后续一次 assistant response;该 turn 可以有零个或多个工具调用。 - 一次 provider request 通常对应一个 assistant response,但 provider 适配器内部的 transport retry 不会额外产生 Agent turn。
- steering 或 follow-up 会增加 turn 数量,不会新建外层
Agent.prompt()调用。
这不是文档约定的推测。直接提取固定版本的循环骨架即可核对轮询位置:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/agent/src/agent-loop.ts |
nl -ba | sed -n '155,275p' |
grep -E 'while \(|turn_start|streamAssistantResponse|getSteeringMessages|getFollowUpMessages|agent_end'

同一份 agent-loop.ts 同时暴露新 run、continue、共享 runLoop()
,以及串行和并行工具批次入口。图中行号证明这些职责共处一条控制链;它没有执行 Provider
或工具,异常、abort 与队列时序仍要结合后续测试阅读。
预期顺序是:初始 steering poll,外层 while,内层 while,assistant request,turn 后 steering poll,内层结束后 follow-up poll,最后 agent_end。这个实验只能证明控制流位置;具体排队一次取几条,还要看 Agent 外壳里的 PendingMessageQueue。下一章就处理这两个队列为什么名字相似、落点却不同。