π Pi Agent Study

03 Agent loop 学习记录

本节目标

本节目标是深入理解 packages/agent/src/agent-loop.ts。第一节已经知道 agent 是事件驱动循环,这一节进一步拆开:

本章一句话

Agent loop 的核心是:模型生成 assistant message;如果里面有 toolCall,本地执行工具,把 toolResult 作为新消息回填 context;然后模型基于更新后的 context 继续,直到没有工具、没有 steering、没有 follow-up。

新手极简模型

先把真实源码压缩成这个循环:

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);
}

真实源码在这个骨架上增加:

Agent loop 主循环图:

flowchart TD
  P[pending/steering messages] --> A[stream assistant response]
  A --> C{assistant 有 toolCall?}
  C -->|没有| F{有 follow-up?}
  C -->|有| T[execute tool calls]
  T --> R[append toolResult messages]
  R --> N[prepare next turn]
  N --> P
  F -->|有| P
  F -->|没有| E[agent_end]

我们怎么学的

runAgentLoop 与 runAgentLoopContinue

runAgentLoop

runAgentLoop() 用于带着新 prompt 开始一次 run。

它会:

复制 prompts 到 newMessages
把 prompts 加入 currentContext.messages
emit agent_start
emit turn_start
emit user message_start/message_end
进入 runLoop()

核心语义:

runAgentLoop = 有新的 user prompt

runAgentLoopContinue

runAgentLoopContinue() 用于沿着已有 context 继续。

它不会新增 prompt,只会从当前 context 继续跑。

它要求 context 最后一条不能是 assistant:

if (context.messages[context.messages.length - 1].role === "assistant") {
	throw new Error("Cannot continue from message role: assistant");
}

原因是 provider 通常要求下一次请求最后一条是 usertoolResult。如果最后是 assistant,再继续请求模型,会变成 assistant 后面接 assistant,很多 provider 会拒绝。

核心语义:

runAgentLoopContinue = 已有 user/toolResult,继续生成下一次 assistant

runLoop 的状态变量

runLoop() 开头有几个关键变量:

let currentContext = initialContext;
let config = initialConfig;
let firstTurn = true;
let pendingMessages = (await config.getSteeringMessages?.()) || [];

含义:

一开始就调用 getSteeringMessages(),是因为 steering 的语义是“尽快插入下一次 assistant response 前”。如果 run 开始时队列里已经有 steer 消息,就应该先进入上下文,再让模型生成 assistant。

两层 while

runLoop() 有两层 while:

while (true) {
  // 外层:agent 本来要停了,但 follow-up 可能让它继续

  while (hasMoreToolCalls || pendingMessages.length > 0) {
    // 内层:当前工作链还没结束
  }

  // 没有 tool call,也没有 steering,再看 follow-up
}

内层 while

内层处理当前连续推理链:

pending/steering message
-> assistant response
-> tool call
-> tool result
-> 下一次 assistant response

外层 while

外层处理 agent 本来准备停下之后的 follow-up:

没有更多 tool call
没有 pending/steering
-> 检查 follow-up
-> 有则作为下一轮 pending message
-> 没有则 agent_end

内外循环结构图:

flowchart TD
  O[outer while] --> I[inner while]
  I --> P[pending 或 steering]
  P --> A[assistant response]
  A --> C{有 toolCall?}
  C -->|有| T[execute tools]
  T --> R[toolResult 回填]
  R --> I
  C -->|没有| S{还有 pending/steering?}
  S -->|有| I
  S -->|没有| F{有 follow-up?}
  F -->|有| O
  F -->|没有| E[agent_end]

内层核心顺序

内层 while 的顺序是:

process pending messages
-> streamAssistantResponse()
-> if error/aborted: turn_end + agent_end
-> find toolCalls
-> executeToolCalls()
-> append toolResults
-> emit turn_end
-> prepareNextTurn()
-> shouldStopAfterTurn()
-> getSteeringMessages()

为什么等 assistant 完整生成后才执行工具

streamAssistantResponse() 返回完整 assistant message 之后,才执行:

const toolCalls = message.content.filter((c) => c.type === "toolCall");

原因是 stream 阶段的 toolcall_delta 只是参数片段:

{"path":
"README.md"
}

toolcall_end 之前:

因此只有完整 assistant message 结束后,才认为 tool call 定稿,可以安全验证和执行。

常见误解是把 toolcall_delta 当成“工具已经开始执行”。实际它只是 provider stream 里的参数片段;真正的执行点在完整 assistant message 被组装完成之后。

工具执行入口

executeToolCalls() 先判断这一批工具应该并行还是串行:

const hasSequentialToolCall = toolCalls.some(
	(tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential",
);

if (config.toolExecution === "sequential" || hasSequentialToolCall) {
	return executeToolCallsSequential(...);
}

return executeToolCallsParallel(...);

如果 config 指定 sequential,或者这一批里有任意一个工具声明 executionMode === "sequential",整批工具都会串行。

原因是有些工具有顺序依赖或副作用。例如:

write(file)
read(file)

或者:

edit(code)
bash(npm run check)

如果并行,读取可能早于写入,检查可能早于修改。

sequential 与 parallel

sequential

串行模式下,每个 tool call 完整走完再处理下一个:

emit tool_execution_start
prepareToolCall
executePreparedToolCall
finalizeExecutedToolCall
emit tool_execution_end
createToolResultMessage
emit toolResult message_start/message_end

parallel

并行模式下:

先按 assistant 原始顺序 prepare 每个 tool call
可执行的工具并发 execute/finalize
tool_execution_end 按完成顺序发
toolResult message 按 assistant 原始顺序发

为什么 toolResult message 仍按原始顺序:

工具执行管线

完整管线:

prepareToolCall()
-> executePreparedToolCall()
-> finalizeExecutedToolCall()
-> emitToolExecutionEnd()
-> createToolResultMessage()
-> emitToolResultMessage()

prepareToolCall

prepareToolCall() 做执行前检查。

步骤:

按 name 找工具
-> prepareArguments
-> validateToolArguments
-> beforeToolCall

如果工具不存在,直接返回 error result。

如果参数校验失败,也返回 error result。

如果 beforeToolCall 返回 { block: true },则不执行工具,返回 blocked error result。

我们讨论过 hook 顺序:为什么是先参数校验,再权限/hook 判断。

结论是当前顺序更合理:

executePreparedToolCall

真正调用工具:

prepared.tool.execute(
	prepared.toolCall.id,
	prepared.args,
	signal,
	(partialResult) => emit(tool_execution_update),
)

工具可以通过 callback 发 partial update。比如 bash 运行中输出片段,UI 可以实时显示。

如果工具执行 throw,loop 不直接崩溃,而是转成 error tool result。

finalizeExecutedToolCall

工具执行完成后,进入 afterToolCall

afterToolCall 可以覆盖:

这是一层结果后处理。扩展可以裁剪输出、标记错误、或者要求工具批次后停止。

terminate 规则

terminate 的判断是:

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

注意是 every,不是 some

原因:

更精确地说:

terminate 不是停止执行这一个工具,
而是停止工具结果回填后的下一次模型请求。

turn_end 之后

工具结果回填后,先发:

await emit({ type: "turn_end", message, toolResults });

这表示当前 turn 完整结束:

assistant response
+ tool executions
+ toolResult messages

然后进入 prepareNextTurn

prepareNextTurn

prepareNextTurn 可以修改下一次 provider request 的运行环境:

它不是 UI hook,而是下一轮执行状态的更新点。

shouldStopAfterTurn

shouldStopAfterTurn 是优雅停止点。

它放在 getSteeringMessages() 前面:

turn_end
-> prepareNextTurn
-> shouldStopAfterTurn
-> getSteeringMessages

原因是如果当前 turn 已经满足停止条件,就不应该再 drain steering queue。

如果把 getSteeringMessages() 放前面,可能出现:

当前 turn 已经应该停止
但先把 steering 消息取出来了
然后又决定停止

这会导致 steering 消息被消费但没有执行,或者为了不丢消息又被迫继续。

steer 与 followUp

steer

steer 的语义:

尽快插入下一次 assistant response 前

它不会立刻打断正在执行的工具。

如果当前正在:

await executeToolCalls(...)

runLoop() 不会去 drain steering queue。工具要么完成,要么被外部 abort signal 中止。steer 本身只是入队。

followUp

followUp 的语义:

等 agent 原本已经没有工具和 steering,准备停止时,再作为新一轮继续

它只在内层 while 结束后才检查。

steer / followUp / terminate 区别

| 信号 | 生效时机 | 作用 |

| --- | --- | --- |

| steer | 下一次 assistant response 前 | 在当前工作链中尽快修正方向,不中断正在执行的工具 |

| followUp | 内层 loop 没有工具和 steering,agent 准备结束时 | 接住后续用户消息或系统消息,让 agent 从准备停止处继续 |

| terminate | 工具结果回填后 | 阻止下一次模型请求,不是杀掉当前工具 |

steering/followUp 插入时机图:

flowchart TD
  A[assistant response 完成] --> C{有 toolCall?}
  C -->|有| T[execute tool calls]
  T --> R[append toolResult]
  R --> N[prepareNextTurn]
  N --> S{shouldStopAfterTurn?}
  S -->|否| G[getSteeringMessages]
  G --> I[进入下一次 inner loop]
  S -->|是| E[agent_end]
  C -->|没有| F{getFollowUpMessages?}
  F -->|有| I
  F -->|没有| E

常见误解

自测题

本节最终结论

runLoop() 是一个明确的状态机:

prompt
-> runAgentLoop
-> runLoop
-> streamAssistantResponse
-> executeToolCalls
-> toolResult 回填
-> turn_end
-> prepareNextTurn
-> shouldStopAfterTurn
-> steering/followUp
-> agent_end

Agent 持有状态、队列和 hooks;runLoop() 在正确的时间点调用这些能力。

本节完成度

已完成:

未展开,后续章节继续:

下一节方向

下一节进入 04 默认工具:read、edit、write、bash

目标是从工具定义看起:

Source: 03-agent-loop-notes.md