π Pi Agent Study
Chapter 03 · agent loop

03 · Agent loop

从一次 prompt 到多轮工具循环

这一章深入 packages/agent/src/agent-loop.ts:看 runLoop() 如何把 assistant、tool call、tool result、steering 和 follow-up 串成一个可持续推进的状态机。

这一章看什么

目标是能解释 agent loop 如何把一次用户输入扩展成多轮模型调用和工具执行。

循环

  • runAgentLoop()
  • runAgentLoopContinue()
  • runLoop()

工具

  • executeToolCalls()
  • prepareToolCall()
  • finalizeExecutedToolCall()

控制

  • terminate
  • prepareNextTurn
  • shouldStopAfterTurn
  • steer/followUp
本章一句话 Agent loop 就是:模型先决定下一步;如果决定调用工具,本地执行工具并把 toolResult 放回上下文;然后模型基于新上下文继续,直到没有工具、没有队列消息、没有 follow-up。

先记住极简版

真实源码很长,但新手先把 agent loop 理解成一个“模型 - 工具 - 回填”的循环。

while (true) {
  const assistant = await model(context);
  context.messages.push(assistant);

  if (!assistant.toolCalls.length) break;

  const results = await executeTools(assistant.toolCalls);
  context.messages.push(...results);
}

真实源码多了什么

  • streaming:assistant 是流式生成的。
  • hooks:工具前后可以校验、拦截、改结果。
  • steering:下一次 assistant 前插入控制消息。
  • follow-up:agent 准备结束后还能接后续消息。
  • abort/terminate:可以优雅停止或结束下一次模型请求。
  • parallel/sequential:工具可能并行,也可能必须串行。
01context用户消息、历史消息、工具结果都在这里。
02assistant模型生成文本,也可能生成 toolCall。
03toolCall?没有工具就准备结束;有工具才进入执行。
04execute本地工具按串行或并行规则运行。
05toolResult结果不是日志,而是新的上下文消息。
06next/end有新上下文就继续请求模型,否则结束。

入口函数

新 prompt 和 continuation 是两种不同入口。

函数
何时用
关键约束
runAgentLoop
有新的 user prompt。它会把 prompt 加入 context,并发出 user message 事件。
进入 runLoop 前,用户消息已经在上下文里。
runAgentLoopContinue
沿着已有 context 继续,比如工具结果之后继续模型生成。
最后一条不能是 assistant,通常要是 user 或 toolResult。

runLoop 的两层 while

内层处理当前工作链,外层处理 agent 准备停下后的 follow-up。

while (true) {
  let hasMoreToolCalls = true;

  while (hasMoreToolCalls || pendingMessages.length > 0) {
    // pending/steering
    // assistant response
    // tool calls
    // tool results
  }

  const followUpMessages = await getFollowUpMessages();
  if (followUpMessages.length > 0) {
    pendingMessages = followUpMessages;
    continue;
  }

  break;
}

顺序

  • 先处理 pending/steering。
  • 再流式生成 assistant。
  • assistant 完整后检查 toolCall。
  • 执行工具并回填 toolResult。
  • turn_end 后再决定是否停止或继续。
核心判断 工具不是看到 toolcall_delta 就执行,而是等完整 assistant message 出来后再执行。

内层 loop

  • 处理当前这条工作链。
  • 先吸收 pending/steering。
  • 再生成 assistant。
  • 如果有 toolCall,就执行工具并回填 toolResult。
  • 只要还有工具结果或 steering,就继续请求模型。

外层 follow-up loop

  • 只在内层 loop 已经没有活干时检查。
  • 如果用户或上层系统追加 follow-up,就把它变成下一轮 pending。
  • 如果没有 follow-up,才真正发出 agent_end。
  • 它让 agent 能“刚要停下时”接住新输入。

工具执行管线

一批 tool calls 先决定串行或并行,再进入 prepare/execute/finalize。

01toolCalls从 assistant message 中取出。
02mode判断 sequential 或 parallel。
03prepare找工具、准备参数。
04validate按 schema 校验参数。
05before执行前 hook 可 block。
06execute调用工具,可发 update。
07after执行后 hook 可改结果。
08toolResult生成消息并回填。
为什么必须等 assistant message 完整 stream 中的 toolcall_delta 只是片段,参数可能还没闭合,也还不能做 schema validation。 等 streamAssistantResponse() 产出完整 message 后,loop 才知道这一批 tool calls 的最终顺序、名称和参数。

before / after hook

hook 的位置体现了安全和扩展边界。

beforeToolCall

  • 发生在参数校验之后。
  • hook 拿到的是可信参数。
  • 可以返回 { block: true } 阻止执行。
  • 适合做权限、策略、用户确认。

afterToolCall

  • 发生在工具执行之后。
  • 可以改 contentdetails
  • 可以改 isError
  • 可以设置 terminate
顺序为什么是 validate 再 hook 权限判断经常依赖 path、command 等参数。先校验能让 hook 处理结构可信的数据,而不是半坏 JSON。

terminate 是整批一致信号

它不是停止某个工具,而是停止工具结果回填后的下一次模型请求。

return finalizedCalls.length > 0 &&
  finalizedCalls.every(
    (finalized) => finalized.result.terminate === true
  );

为什么是 every

  • 一批 tool calls 是同一个 assistant message 的并列意图。
  • 必须先完整回填这一批结果。
  • 避免某个工具单方面中断整批。
  • 防止并行工具里一个先完成就抢先结束。

turn_end 之后的控制

这一段决定下一轮是否继续、是否换上下文、是否吸收队列消息。

顺序

  • turn_end
  • prepareNextTurn
  • shouldStopAfterTurn
  • getSteeringMessages
  • getFollowUpMessages

语义

  • steer 插入下一次 assistant response 前。
  • steer 不会中断正在执行的工具。
  • followUp 等 agent 本来准备停止时再继续。
  • shouldStopAfterTurn 在 steering 前,避免消费后又停止。
控制信号
什么时候生效
它解决什么问题
steer
下一次 assistant response 之前进入上下文。
在当前工作链中尽快修正方向,但不打断正在执行的工具。
followUp
内层 loop 没有工具和 steering,agent 准备停止时才检查。
让已经准备结束的 agent 接住后续用户消息或系统消息。
terminate
工具结果回填后,阻止下一次模型请求。
让工具或 hook 明确表示这批结果已经足够,不需要模型继续推理。

常见误解

这几处最容易把 agent loop 看成普通聊天或普通日志流水线。

toolCall 不是工具执行

它只是 assistant message 里的意图。只有 loop 取出完整 toolCall、校验参数、跑 hook 后,工具才会被执行。

toolResult 不是调试日志

它会被追加进 context,下一次模型请求会看到它。模型能继续推理,靠的是这一步回填。

steer 不是强制中断

steer 会尽快插入下一次 assistant 前,但不会中断已经开始的工具执行。要中止运行,需要外部 abort signal。

parallel 不等于乱序回填

工具可以并发执行,但 toolResult message 仍按 assistant 原始 toolCall 顺序回填。

terminate 不是杀掉工具

terminate 的含义是工具结果已经回填后,不再发起下一次模型请求。

自测题

能答出这些问题,才算真正理解 03 章主线。

概念题

  • 用一句话说明 agent loop 和普通聊天的区别。
  • 为什么 toolCall 不等于工具已经执行?
  • 为什么 toolResult 必须回填到 context?
  • inner loop 和 outer follow-up loop 分别负责什么?

源码题

  • 为什么要等完整 assistant message 后才执行工具?
  • 什么时候整批 tool calls 会从 parallel 变成 sequential?
  • steer、followUp、terminate 的生效时机分别是什么?
  • 为什么 shouldStopAfterTurn 要在 getSteeringMessages 前面?

03 完成度

核心循环已经读完,下一节进入默认工具实现。

已经完成

  • 理解新 prompt 与 continue 的区别。
  • 理解 runLoop 的内外循环。
  • 理解 assistant 完整后才执行工具。
  • 理解 sequential/parallel 工具执行。
  • 理解 before/after hook。
  • 理解 terminate、steer、followUp。

下一章继续

  • read 工具。
  • write/edit 文件修改。
  • bash 执行边界。
  • 看工具输出如何回传模型。