读懂 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,重复运行不变。
结论
已验证
- 一次工具往返等于两次 provider 请求:第一轮返回
toolCall,第二轮带着 toolResult 再请求。 - 决定"要不要执行"的是内容块类型,而不是
stopReason;length是唯一让整批调用作废的停止原因。 - 工具失败被降级为一条带
isError的 toolResult 进入上下文,循环不会因此中断。 turn_start在一次工具往返里出现两次,它标记的是"准备发下一次请求",不是用户交互开始。- 会话文件 append-only,
message_end是唯一写入触发点;没有对话内容时连文件都不创建。 - 上下文由投影生成:compaction 与
context_edit都不改写历史条目,只改变哪些条目、以什么内容进入下一次请求。 - 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,补齐"能力从哪来"的另一半;顺带跑一次并行工具分支。