一层循环是最直观的写法:有工具调用就继续,没有就退出。pi 多出来的那一层不是为了跑工具,而是为了容纳「人在 agent 跑完之后又想追加一句」这件事,且不需要重新起一次 run、不需要重建上下文。
由此派生出全章最需要分清的一对概念,它们的区别完全由「在哪一层被轮询」决定:
同一条用户消息,进 steering 队列还是 follow-up 队列,决定的不是它「多久被处理」,而是它「会不会打断当前这批工具执行完之后的收尾判断」。这个区别在 UI 上通常表现为:跑着的时候敲回车是 steering,跑完之前敲回车是 follow-up。
一层循环这个对照不是本书凭空设的靶子,但也只能说到这一步:锚定 SHA 之后 13 天,pi 的开发方发表了一篇面向普通读者的解释文章 Earendil《What is a Harness?》(2026-08-20);它讲 agentic loop 的那一节,循环的出口就落在模型自己的判断上——搜完资料、写完表格、发完邮件,「The model reviews this final work and decides the job is done.」——这篇解释只画出了一层循环,它的示例没有描绘「再回头问一句还有没有排队的活」这一层。它没有统计现实中的 agent 实现,不能据此说多数实现都只有一层;pi 有两层循环,仍由上文的锚定代码证明。那是通用 harness 的入门解释,不是 pi 的架构说明,在那个语境下省略这一层完全合理;本章的价值恰恰在于把它补回来。(此处只用作「上游对外怎么讲」的证据,不构成对代码行为的断言。)
循环主体在 runLoop(),被 agentLoop() 与 agentLoopContinue() 共用。packages/agent/src/agent-loop.ts:155
let pendingMessages = (await config.getSteeringMessages?.()) || []; # ① 循环开始前先捞一次
while (true) { # ② 外层:只为 follow-up 存在
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) { # ③ 内层:工具 + steering
… 注入 pendingMessages,清空队列 …
const message = await streamAssistantResponse(…); # ④ 一次 LLM 调用
if (message.stopReason === "error" || message.stopReason === "aborted") {
… emit turn_end / agent_end …
return; # ⑤ 唯一的非正常出口
}
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit) # ⑥ 截断保护
: await executeToolCalls(…);
hasMoreToolCalls = !executedToolBatch.terminate;
… 把 toolResults 推入 context …
}
… emit turn_end;config.prepareNextTurn?.() 可换模型 / 换上下文 …
if (await config.shouldStopAfterTurn?.({…})) { # ⑦ 优雅停机钩子
… emit agent_end …
return;
}
pendingMessages = (await config.getSteeringMessages?.()) || []; # ⑧ 每 turn 轮询 steering
}
const followUpMessages = (await config.getFollowUpMessages?.()) || []; # ⑨ 仅此一处轮询 follow-up
if (followUpMessages.length > 0) { pendingMessages = followUpMessages; continue; }
break;
}
await emit({ type: "agent_end", messages: newMessages });
shouldStopAfterTurn 的九行调用压成一行、并用 … 代替了参数列表与 emit 调用。要逐字核对请开原文。① 循环开始前的那次 steering 轮询不是冗余。注释写的是「user may have typed while waiting」——用户在提交 prompt 到循环真正启动之间敲的字不会丢。agent-loop.ts:167
⑥ stopReason === "length" 时整批工具调用作废,而不是执行能解析的那几个。原因写在 failToolCallsFromTruncatedMessage 的文档注释里:流式 tool-call 参数是用「best-effort JSON salvage parser」收尾的,所以一个被截断的消息可以产出参数能解析、能通过 schema 校验、但内容悄悄不完整的 tool call。agent-loop.ts:377
每个被作废的调用都会得到一条明确的错误结果,让模型知道要重发,而不是静默丢弃。agent-loop.ts:396
terminate 的判定是「全票通过」制。只有当这一批 tool result 每一条都标了 terminate: true,循环才提前收工;混合批次照常继续。agent-loop.ts:582–584
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}
⑦ shouldStopAfterTurn 是 turn 边界上的优雅停机。它在 turn_end 之后、轮询任何队列之前运行;返回 true 只是不再发起下一次 LLM 调用,不中止流、不取消正在跑的工具、不改 stop reason。agent 包的 README 示例拿它实现「这一轮完了先压缩再继续」:agent/README.md:299。但锚点上的 coding-agent 并没有用这个钩子,CLI 的自动压缩是在 agent_end 之后检查的:agent-session.ts:1952(见 第 06 章)。agent-loop.ts:248–257
循环有两个公开入口。agentLoop() 带新 prompt 起跑;agentLoopContinue() 从现有上下文续跑(用于报错后重试),要求最后一条消息是 user 或 toolResult。agent-loop.ts:64–76
两者构造 currentContext 的方式不同:
// runAgentLoop:messages 是新数组,调用方的数组不受影响
const currentContext: AgentContext = {
...context,
messages: [...context.messages, ...prompts],
};
// runAgentLoopContinue:浅拷贝,messages 仍是调用方那个数组的引用
const currentContext: AgentContext = { ...context };
push 往 currentContext.messages 追加消息,流式期间还会把 partial assistant message 先塞进去再原地替换。后果:直接调用低层 API 时,agentLoopContinue() 会就地修改你传进去的 messages 数组,agentLoop() 不会。agent-loop.ts:104–107 / agent-loop.ts:136
Agent 类的使用者碰不到这个差异,因为它每次进循环前都 slice() 一份快照。agent.ts:437–443 但 packages/agent/README.md 的 "Low-Level API" 一节示范的正是直接把同一个 context 对象喂给两个函数,且没有提这处不对称。此条记入 漂移记录。
README 说低层 stream 是 observational,「不会等你的异步处理完成再继续」。代码里这句话对应一个具体事实:runLoop 确实 await emit(...),但 agentLoop() 传进去的 sink 只是把事件 push 进一个 EventStream,而 push 是同步的、无返回值的入队。event-stream.ts:21
所以 await 等到的是「事件已入队」,不是「消费者已处理」——没有背压。Agent 类走的是另一条路:它把 processEvents 直接当 sink,里面先归约状态再逐个 await 订阅者,这才形成真正的屏障。agent.ts:588–590
这解释了 README 里那句「如果你需要消息处理成为工具 preflight 之前的屏障,请用 Agent 类」——屏障不是 Agent 额外加的,而是低层入口把 sink 换成了无背压的队列。
一层循环也能表达 follow-up:把队列检查并进 hasMoreToolCalls 的条件即可。pi 没这么做,付出的代价是多一层缩进,换来的是一个可命名的边界——「agent 本该停下的那一刻」在代码里是一个具体位置(内层 while 的出口),而不是一个散布在布尔表达式里的隐含状态。
这个边界的价值在扩展系统里兑现:agent_settled 之类的语义、以及 follow-up 与 steering 的差异化投递(deliverAs: "steer" | "followUp" | "nextTurn"),都需要指着这一行说「就是这里」。extensions/types.ts:397 详见 第 12 章。
循环里一共挂了五个可选回调:getSteeringMessages、getFollowUpMessages、prepareNextTurn、shouldStopAfterTurn,加上工具侧的 beforeToolCall / afterToolCall。它们的共同点是都不改变循环的控制流形状:没有一个能让循环跳过一个 turn、重排 tool call、或者回退到上一个 turn。
能改的只有三类:喂进去什么(消息、上下文、模型、thinking level)、要不要继续(shouldStopAfterTurn、terminate)、结果长什么样(afterToolCall)。控制流本身是硬编码的。
锚点之后回调的名单在锚点之后变了。466db0f(2026-09-21)删除了 shouldStopAfterTurn,由 finishTurn 取代,随 agent 0.87.0 发布:CHANGELOG.md:46。这不是改名:finishTurn 在 assistant 与工具结果定稿之后、turn_end 之前运行,决定在 turn_end 之后才生效;error / aborted 的响应也会调用它,只是返回值被忽略:CHANGELOG.md:60、agent-loop.ts:252。同一版本还新增了在每次 provider 请求前运行的 prepareRequest:CHANGELOG.md:64。本章对 ⑦ 与五个回调的描述按锚点版本 ac4ac9e 成立。
这是刻意的:CONTRIBUTING.md 写着「即便是给扩展用的 hook point 也要充分考虑和讨论,以免加进无法维护的臃肿和复杂交互」。CONTRIBUTING.md:11 代价是某些形态的扩展写不出来(比如「让模型重试上一个 turn 而不留痕」);收益是循环的行为可以被一页纸讲完——本节就是证据。
上游正在写一个替代设计,把 run 变成可崩溃恢复的持久化操作(lanes / records / checkpoints)。本章描述的循环不是那个设计,也不会平滑演化成那个设计:harness-v2 把「循环」拆成了可单步驱动的 action 序列,控制流形状完全不同。
截至锚定 SHA,跑在你机器上的仍是本章这个循环。判定依据与证据见 第 08 章。
pendingMessages、hasMoreToolCalls、已执行到第几个 tool)全在栈上,没有任何一处落盘。工具已经产生的副作用(文件已改、命令已跑)留在磁盘上,但 agent 不知道它做过。这正是 harness-v2 要解决的问题,也是它值得一整章的原因。shouldStopAfterTurn 返回 true、或外部 abort。跑飞的循环要靠上层拦。bash 命令期间敲的 steering 消息,要等它跑完才生效。想真正打断只能 abort()。error / aborted 的出口不走 shouldStopAfterTurn。⑤ 处直接 return。agent-loop.ts:196–200 依赖该钩子做收尾(例如落盘、flush)的实现,在报错路径上不会被调用;这类工作必须挂在 agent_end 订阅者上。检查过但未发现问题的地方:并行工具批次的事件顺序与持久化顺序不一致(事件按完成顺序、消息按 assistant 源顺序),这是文档明说的行为,且 executeToolCallsParallel 的实现与之一致——先并发拿结果,再按原顺序生成 toolResult 消息。agent-loop.ts:540–548
stopReason === "length" 当成整批 tool call 的毒丸。流式参数的容错解析会让截断的调用看起来是合法的,这是最难查的一类 bug——工具执行了,参数少了一半,而且没有任何错误。
适用条件:你用流式 tool call 且做了 JSON 容错。如果参数只在完整消息上一次性解析,这个坑不存在。场景模型在一个 assistant message 里发了 3 个 tool call,其中第 2 个的 execute() 返回了 terminate: true,另外两个正常返回。循环接下来做什么?
B。shouldTerminateToolBatch 要求 every(...)——一批里全部标了 terminate 才提前收工,混合批次照常继续。agent-loop.ts:582–584
场景用户在 agent 正在跑一个耗时 3 分钟的 bash 命令时,输入了一条 steering 消息。模型什么时候能看到它?
C。steering 的轮询点在 turn 末尾(⑧),注入点在下一个 turn 开头。工具执行期间不检查队列。想真正打断只能 abort()。agent-loop.ts:259
场景在锚点版本上,你在 shouldStopAfterTurn 里写了「把会话落盘」。某次 run 中 provider 报错,assistant message 的 stopReason 是 error。落盘会发生吗?
A。stopReason 为 error 或 aborted 时,循环在 ⑤ 处 emit turn_end + agent_end 后直接 return,不到达 shouldStopAfterTurn。收尾工作应挂 agent_end 订阅者。agent-loop.ts:196–200
锚点之后agent 0.87.0 起这个钩子不存在了,题目只对锚点版本成立。把这段逻辑原样搬进 finishTurn 的读者会得到相反的结果:error 时 finishTurn 照样被调用(返回值被忽略),落盘会发生。即便如此,B 的「在 turn_end 之后」仍然不对——finishTurn 在 turn_end 之前运行。上游的迁移示例专门加了一行判断,来保留旧钩子「只在正常响应时调用」的行为:CHANGELOG.md:55。
ac4ac9e(2026-08-07 快照)。