读懂 pi 的 Agent 循环:一次工具调用要经过哪些状态

读 Agent 框架的源码时,最容易卡住的往往不是某个函数写得多复杂,而是"模型吐出的那段文本,究竟在哪一刻变成了真正执行的调用"。

这篇笔记用固定版本的 pi(commit 58c0b02)回答这一个问题,并跑了一个不依赖网络和密钥的最小 Demo。结论都基于这个版本的源码,不评估实现质量。

问题

一次工具调用在 agent loop 里经过哪些状态迁移,模型输出在哪个边界被转成可执行调用?

为什么读它

需要一个足够小、能自己跑的 Coding Agent 基线。pi 把 agent core 与 provider、UI 分开:core 只处理消息与事件,provider 只负责协议,会话持久化在 host 层。这种切分适合作为后续 Agent 开发的对照,也便于逐个边界验证。

整体调用链

sequenceDiagram
    participant H as Host
    participant L as runLoop
    participant P as Provider
    participant T as Tool

    H->>L: runAgentLoop
    loop 每个 turn
        L->>P: streamFunction
        P-->>L: AssistantMessage
        L->>T: prepare → execute
        T-->>L: AgentToolResult
        L->>L: 追加 toolResult
    end
    L-->>H: agent_end

一条总原则:循环只认识消息和事件;把消息变成请求、把事件变成磁盘记录,都是外层的责任。

上图画的是内存里的主循环。持久化发生在循环之外:host 层的 SessionManager 在每条 message_end 之后把消息追加进 JSONL,下一节展开。

边界一:模型调用

  • 循环内部全程使用 AgentMessage,直到发请求前才转换:transformContext → convertToLlm → normalizeContext,再交给 streamFunction(packages/agent/src/agent-loop.ts:390-417)。
  • streamFn 由宿主注入,agent core 不 import provider 兼容层;coding-agent 用 setDefaultStreamFn(streamSimple) 兜底(packages/coding-agent/src/core/sdk.ts:44-46)。
  • 请求档位与返回结果是两件事:thinkingLevel 由调用方在拿到结果后回填,而不是从 provider 响应里读(agent-loop.ts:419)。

边界二:工具执行

  • 识别只看内容块:message.content.filter(c => c.type === "toolCall"),与 stopReason 无关;唯一例外是 length——被 token 上限截断时整批调用直接判失败,不执行(agent-loop.ts:268-281)。
  • 串并行的选择权在工具:只要有一个工具声明 executionMode: "sequential",或全局配置为串行,整批就走串行(agent-loop.ts:526-533)。
  • 拦截在准备阶段:beforeToolCall 返回 block 会变成一条 error toolResult;返回 terminate 且全批都终止时,循环不再继续(agent-loop.ts:700-702)。
  • 失败不冒泡:工具抛错由 executePreparedToolCall 捕获成 isError: true 的结果,继续包装成 toolResult 回喂模型。

边界三:会话状态

  • 内存态:message_end 把消息 push 进 state.messages;tool_execution_start/end 维护 pendingToolCalls(packages/agent/src/agent.ts:575-602)。
  • 持久化在 host:agent-session 在每个 message_end 调 appendMessage(packages/coding-agent/src/core/agent-session.ts:1147-1167)。
  • 文件是 append-only 树:每行一个条目,带 id/parentId;新会话在第一条 user 或 assistant 消息出现前不落盘(session-manager.ts:1166-1196)。
  • 模型看到的历史是投影而非文件本身:buildContextEntries 沿叶子路径走,遇到最新 compaction 只保留 firstKeptEntryId 之后的条目(session-manager.ts:476-512);context_edit 以追加条目的方式改写旧消息在上下文中的呈现(session-manager.ts:519-540)。历史不可变,可见历史可变。

证据

以下是固定提交 58c0b02 下的源码位置,读者可以按同样的路径核对:

id 位置 证明什么
E1 packages/agent/src/agent-loop.ts:172-180 runLoop 是唯一的 turn 循环
E2 packages/agent/src/agent-loop.ts:390-417 请求边界:AgentMessage → Message → streamFn
E3 packages/agent/src/agent-loop.ts:268-281 toolCall 按内容块识别,length 全量失败
E4 packages/agent/src/agent-loop.ts:526-533 串并行由工具或全局配置决定
E5 packages/agent/src/agent-loop.ts:700-702 全批 terminate 才终止
E6 packages/agent/src/agent.ts:575-602 内存状态由事件归约
E7 packages/coding-agent/src/core/agent-session.ts:1147-1167 message_end → appendMessage
E8 packages/coding-agent/src/core/session-manager.ts:1172-1196 首次全量写、其后追加
E9 packages/coding-agent/src/core/session-manager.ts:1166-1170 无对话内容时不落盘
E10 packages/coding-agent/src/core/session-manager.ts:476-512 compaction 感知的上下文条目
E11 packages/coding-agent/src/core/session-manager.ts:519-540 context_edit 只改投影
E12 packages/coding-agent/src/core/sdk.ts:44-46 streamFn 由宿主注入

最小 Demo

Demo 不调用真实模型,而是用 pi 自带的 faux provider 脚本化两轮输出:第一轮返回一个 calculate 工具调用,第二轮返回最终答案。工具是纯函数,只接受数字和四则运算符,因此整条 transcript 完全可复现,结果哈希可以作为指纹。

运行后的事件顺序是固定的:

agent_start → turn_start → message_end user → message_end assistant
→ tool_execution_start calculate → tool_execution_end
→ message_end toolResult → turn_start → message_end assistant → agent_end

五项检查全部通过:工具真正执行、结果回喂给模型、没有悬挂的 toolCall、agent 回到 idle、模型被调用了两次。最后一项正是关键结论——一次工具往返就是两次 provider 请求。

结果文件的 SHA-256 是 7d7d0c124951707da2e14f0d42b493de9b857ac23d7a54c9faa755b00f25486a,重复运行不变。

结论

已验证

  1. 一次工具往返等于两次 provider 请求:第一轮返回 toolCall,第二轮带着 toolResult 再请求。
  2. 决定"要不要执行"的是内容块类型,而不是 stopReason;length 是唯一让整批调用作废的停止原因。
  3. 工具失败被降级为一条带 isError 的 toolResult 进入上下文,循环不会因此中断。
  4. turn_start 在一次工具往返里出现两次,它标记的是"准备发下一次请求",不是用户交互开始。
  5. 会话文件 append-only,message_end 是唯一写入触发点;没有对话内容时连文件都不创建。
  6. 上下文由投影生成:compaction 与 context_edit 都不改写历史条目,只改变哪些条目、以什么内容进入下一次请求。
  7. agent core 不认识任何 provider,streamFn 完全由宿主注入。

推断

  • AgentMessage 与 Message 分成两层,是为了让"运行时能执行什么"与"模型能看到什么"解耦;declareToolChanges 把工具增删做成 system 消息增量,context_edit 把历史修正做成追加条目,是同一思路的两次应用。
  • 新会话延迟落盘说明 session 文件被当作"对话证据"而不是"运行日志":没有对话就没有证据,不该留下空壳。

没回答的

  • 默认的并行执行分支只读了选择逻辑,没有实际运行,beforeToolCall 在并行下的并发语义与 abort 行为未验证。
  • compaction 的实际生成、进程崩溃后的恢复路径都未涉及,这次只验证了投影如何消费它们。

可复用的模式

「事件归约状态,投影生成上下文。」

运行态与持久层都只消费事件(message_end / tool_execution_start/end);上下文不是历史文件的直接拼接,而是按当前叶子路径与最新的压缩、编辑条目重新投影出来。好处是历史永远可审计,而模型看到的可以是裁剪后的版本。

  • 适用:需要长期保存且会被反复重放的会话、审计日志、迁移记录。
  • 边界:如果消费者只有一个且不需要回溯,投影层是额外复杂度。

下一步

读 packages/coding-agent/src/core/extensions/ 与 mcp-servers.ts,验证工具与提示词如何被注入到 context.tools,补齐"能力从哪来"的另一半;顺带跑一次并行工具分支。


读懂 pi 的 Agent 循环:一次工具调用要经过哪些状态
https://blog.fulequn.online/2026/10/pi-agent-loop/
作者
Fulequn
发布于
2026年10月22日
许可协议