第三部:Agent Loop 如何继续
从 Agent 的事件归约外壳进入 runLoop,沿两层循环、两类消息队列、工具预检与并行批次,一直追到一场 run 的停止、失败和 continuation 边界。

展开阅读路线与实验入口
第二部停在统一的 assistant event stream。到那个位置,我们已经知道一条请求怎样离开 Agent、穿过 provider,再以相同事件协议返回;还不知道这些事件怎样让系统继续工作。模型发出 tool call 后谁决定执行,用户中途又发来一句话会落在哪里,一个工具先结束会不会改变消息顺序,按下取消以后 prompt() 何时才真正结算,都由这一部回答。
阅读时先别把 “Agent Loop” 想成一个 while。v0.83.0 至少有三层不同职责:Agent 保存公开状态、队列与 active run;runAgentLoop() 建立一场运行的事件边界;私有 runLoop() 才用内外两层循环安排 turn。工具执行又有自己的预检、批次和 finalize 阶段。把这些层压成一段伪代码,正常路径还能说通,abort、并行工具和异步 listener 一来,所有权就会乱。
先带着五个观察点读代码
第一个是公开状态与运行快照。AgentState 可以被调用方读取和部分修改;一次 run 启动时,消息与工具被复制进 AgentContext。底层循环改的是运行内 context,公开 transcript 与 streaming 状态主要靠事件回到 processEvents() 更新。它是一层可观察运行时,不是可以从磁盘事件完整重放的 event-sourced aggregate。
第二个是 run 与 turn。一次 prompt() 通常对应一场 run,从 agent_start 到 agent_end;一场 run 能包含多次 assistant request。Pi 把“一次 assistant response 与它的工具结果”叫作 turn。模型要求工具、工具结果需要再问模型时,新的 turn 仍留在同一场 run 中。
第三个是两种排队。Steering 在完整 turn 后、下一次请求前进入 context;Follow-up 等 Agent 已经没有工具与 steering、原本就要停下时才进入。二者都不是抢占式中断,也都不是 enqueue 时立即写入 transcript。
第四个是工具的两条时间线。默认 parallel 不代表所有步骤并发:预检依模型原始顺序执行,允许的工具主体才并发。tool_execution_end 按真实完成时间发出,给模型的 ToolResult 在全部结算后恢复原始调用顺序。UI 与上下文各拿到它需要的顺序。
第五个是“结束”发生在哪一层。stream 可以用 error/aborted assistant message 正常结束协议;控制面可以在 turn 完成后 graceful stop;工具只能用批次 terminate hint 停止自动追问;Agent 外壳还会尝试把意外 throw 补成失败消息。agent_end 已经是最后一个 loop event,但异步 listeners 尚未结算,isStreaming 仍可能为 true。
flowchart LR
accTitle: 第三部的运行路径
accDescr: Agent 复制状态建立 run,runLoop 在两层循环中请求模型、执行工具并读取队列;事件返回 Agent 归约,最终在 listener 结算后进入 idle
CALL["prompt / continue"] --> OWN["Agent active run"]
OWN --> SNAP["AgentContext snapshot"]
SNAP --> LOOP["runAgentLoop / runLoop"]
LOOP --> MODEL["assistant stream"]
MODEL --> PREFLIGHT["tool preflight"]
PREFLIGHT --> BATCH["sequential or parallel batch"]
BATCH --> TURN["turn_end"]
TURN --> STEER["steering drain"]
STEER -->|"continue inner loop"| MODEL
STEER --> FOLLOW["natural stop then follow-up drain"]
FOLLOW -->|"continue outer loop"| MODEL
FOLLOW --> END["agent_end"]
MODEL --> EVENTS["message events"]
PREFLIGHT --> EVENTS
BATCH --> EVENTS
EVENTS --> REDUCE["Agent state reducer"]
END --> LISTEN["await listeners"]
LISTEN --> IDLE["finishRun / idle"]
六章沿控制流向前,不按文件目录分组
第 13 章先停在 Agent 外壳,交代哪些字段由事件归约,哪些配置仍可直接赋值,为什么 agent_end 与 idle 之间还隔着 listener settlement。没有这层,后面所有 UI 状态和持久化时机都会悬空。
第 14 章进入 runLoop()。内层循环处理工具结果与 steering,外层循环只在自然停止点接入 follow-up。本章也把 run、turn 和 provider request 三种计数拆开,后面不再用“一轮”含混代称。
第 15 章回到 PendingMessageQueue,重点不是 API 怎么调用,而是 drain point。all 与 one-at-a-time 控制每次注入多少条;一场 run 可以经过多个 drain point。assistant 尾部的 continue() 还有一个跳过首次 steering poll 的分支,用来守住这个粒度。
第 16 章从 tool call 到 execute 之间逐级检查。被 length 截断的整条响应先拒绝,正常调用再经过工具查找、arguments 兼容处理、克隆与 schema validation、before hook 和 abort guard。失败被写成模型可见 ToolResult,而不是把整场 run 一并炸掉。
第 17 章处理并行。这里会同时看生产实现和一个故意让第二个工具先结束的确定性测试:结束事件是 2、1,ToolResult 是 1、2。这个差异是设计结果,不是事件队列偶然抖动。
第 18 章收束停止边界,分别讨论自然耗尽、全批 terminate、shouldStopAfterTurn、stream error/aborted、wrapper catch 与 continuation。到章末,底层 Agent 路径闭合,下一部再切到 AgentHarness 的独立所有权。
本部不提前借用 Harness 的答案
固定版本里同时有 Agent 和 AgentHarness,两者都能直接调用低层 loop,但 Harness 没有包住一个 Agent 实例。它另有 snapshot、hooks、session 和 storage 责任。第三部只描述 Agent 这条已发布路径的行为,不拿 harness-v2.md 的设计目标解释当前实现,也不把 Coding Agent 的 AgentSession 重试、compaction 和 JSONL 持久化下放到底层 loop。
开始前仍用只读命令确认源码身份,再定位本部三个主文件:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
test "$(git -C "$repo" rev-parse HEAD)" = \
845d6ff1f6643aba440341cce877ce1c43ebbc39
test "$(git -C "$repo" describe --tags --exact-match HEAD)" = v0.83.0
git -C "$repo" show v0.83.0:packages/agent/src/agent.ts >/dev/null
git -C "$repo" show v0.83.0:packages/agent/src/agent-loop.ts >/dev/null
git -C "$repo" show v0.83.0:packages/agent/src/types.ts >/dev/null
三条读取与两个身份断言静默通过,就从第 13 章开始。涉及 unit test 的章节会标明依赖前提;默认实验不会调用真实模型,也不会读取用户级认证文件。
`Agent` 怎样把事件还原成状态
从可变状态、运行快照与事件归约器三处交叉阅读,解释 Agent 为什么既是底层循环的外壳,也是 UI 与会话层能稳定观察的状态边界。
第二部一路追到模型返回统一事件流。若直接跳进 runLoop(),很容易漏掉一个问题:流里的 partial message、工具开始与结束、最终错误,最后怎么变成调用方能随时读取的 agent.state?答案在 Agent 这层外壳里。
Agent 没有让底层循环直接持有公开状态。它在发起 run 时交出一份 context snapshot,循环只改这份运行内上下文;循环产生的事件再回到 processEvents(),由外壳更新 transcript、流式消息、pending tool ids 和错误。这个往返让模型调用、工具执行与 UI 观察落在同一套事件顺序上。
两组字段,更新方式并不相同
createMutableAgentState() 初始化系统提示词、模型、thinking level、工具与消息,也建立四个运行态字段。给 tools 或 messages 重新赋值时,setter 会复制顶层数组;之后通过 getter 取到的仍是内部数组,所以 state.messages.push(...) 会直接改内部 transcript。这里提供的是赋值时的浅复制,不是 immutable state,也不是深拷贝。
这组字段可以再分一下。
| 字段 | 所有者 | 主要更新入口 |
|---|---|---|
systemPrompt、model、thinkingLevel、tools | 调用方与 Agent 共享配置面 | 直接赋值,下一次创建循环配置或快照时读取 |
messages | Agent 的 transcript | 外部可替换;run 中主要由 message_end 追加 |
streamingMessage | 当前 run | message_start / message_update 设置,message_end 清空 |
pendingToolCalls | 当前 run | 工具 start 加入,end 删除 |
errorMessage | 最近一轮 | turn_end 从 assistant error message 提取 |
isStreaming | active run 生命周期 | runWithLifecycle() 开启,finishRun() 关闭 |
所以“事件还原状态”只描述运行态与 transcript 的主路径。直接修改 state.model 不会生成一个 model-changed event;订阅者也观察不到普通配置赋值。把这里叫作完整事件溯源,会多推导出源码并不具备的重放能力。
循环拿到的是快照,不是公开 state 的引用
prompt() 进入 runPromptMessages() 后,createContextSnapshot() 会复制当时的消息数组和工具数组,并带上当时的 system prompt。createLoopConfig() 也读取当前模型、thinking level、transport 和 hooks。正在运行时再替换公开数组,并不会自动替换这场 run 已持有的 AgentContext;只有循环在 turn 边界调用 prepareNextTurn,上层显式返回新的 context/model/thinking,才会改变下一次 provider 请求。
这也解释了为什么循环里的 assistant partial 不会因为改了 snapshot 就自然出现在 agent.state。streamAssistantResponse() 的确先把 partial 放进运行内 context.messages,但公开状态走的是事件:message_start 和 message_update 提供屏幕所需的临时消息,message_end 才把终态消息追加到 transcript。
flowchart LR
accTitle: Agent 状态与运行快照的往返
accDescr: 公开状态被复制成运行内上下文,底层循环修改快照并发出事件,Agent 再按事件更新公开运行态和消息历史
S["AgentState"] -->|"浅复制 messages / tools"| C["AgentContext snapshot"]
S -->|"读取 model / hooks / queues"| G["AgentLoopConfig"]
C --> L["runAgentLoop"]
G --> L
L --> E["AgentEvent"]
E --> R["processEvents reducer"]
R --> S
R --> A["await subscribers"]
先归约,再通知订阅者
processEvents() 的顺序很实在:先执行 switch,再逐个 await listeners。订阅者收到 message_end 时,消息已经进入 state.messages;收到 tool_execution_start 时,对应 id 已在 pendingToolCalls;收到 turn_end 时,errorMessage 已更新。UI 不需要再把同一事件自己归约一次,持久化层也可以在 listener 中读取已经前移的状态。
这里还有一个容易在 UI 中写错的时刻:agent_end 是底层循环最后发出的事件,但不是 Agent 已经 idle 的通知。agent_end 的 listeners 仍被 await;它们全部结算以后,控制流才进入 finally 中的 finishRun(),清掉 pending 状态、把 isStreaming 设为 false、resolve waitForIdle(),最后移除 activeRun。
如果一个 agent_end listener 正在把最终消息写入 session 文件,此时把输入框解锁会制造一个竞态:新 prompt 可能已经开始,旧 run 的持久化还没完成。Pi 的公开契约要求调用方以 prompt() resolve、waitForIdle() resolve 或 state.isStreaming === false 判断结算,不要在收到 agent_end 的 listener 开头就自行宣布空闲。
用只读切片核对归约覆盖面
下面的实验不会加载 provider,也不会读本机凭据。它把公开事件联合类型与 reducer 的 case 并排打印出来:
repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/agent/src/types.ts |
nl -ba | sed -n '415,437p'
git -C "$repo" show v0.83.0:packages/agent/src/agent.ts |
nl -ba | sed -n '529,576p'
输出里不会出现 agent_start、turn_start 或 tool_execution_update 的状态分支。这不代表它们被吞了。所有事件仍会传给 listeners,只是这三类事件不需要修改 Agent 自己保存的字段。相反,message_end 同时承担 transcript 落位,不能只当作一个渲染提示。
本章得到的 Agent 仍只是运行外壳。它能让一场 run 的状态可观察,却还没说明为什么一场 run 里可能连续发出多次 turn_start。下一章把底层 runLoop() 展开,会看到 Pi 用两层循环分别处理“工具或 steering 还要求继续”与“本来已经结束,但 follow-up 又来了”。