03 Agent loop 学习记录
本节目标
本节目标是深入理解 packages/agent/src/agent-loop.ts。第一节已经知道 agent 是事件驱动循环,这一节进一步拆开:
runAgentLoop()和runAgentLoopContinue()的区别。runLoop()的内外循环。- steering 和 follow-up 的注入时机。
- assistant 完整生成后才执行工具的原因。
- 工具执行的 sequential/parallel 策略。
prepareToolCall()、beforeToolCall、executePreparedToolCall()、afterToolCall的顺序。terminate、prepareNextTurn、shouldStopAfterTurn如何影响下一轮。
本章一句话
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);
}
真实源码在这个骨架上增加:
- streaming:assistant 是一段一段流出来的。
- hooks:工具执行前后可以检查、阻止、修改结果。
- steering:下一次 assistant response 前可以插入控制消息。
- followUp:agent 准备结束后还能接住后续消息。
- abort/terminate:运行可以被外部中止,也可以在工具结果后停止下一次模型请求。
- parallel/sequential:一批工具可能并发,也可能因为副作用必须串行。
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(),区分“新 prompt”与“继续已有 context”。 - 再看
runLoop()开头,理解currentContext、config、firstTurn、pendingMessages。 - 拆内外两层 while。
- 看 assistant response 完成后如何检查 tool call。
- 看工具执行入口
executeToolCalls(),理解 sequential/parallel。 - 看工具执行管线:prepare、validate、before hook、execute、after hook、finalize。
- 回到
runLoop()后半段,看turn_end、prepareNextTurn、shouldStopAfterTurn、steering/followUp。
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 通常要求下一次请求最后一条是 user 或 toolResult。如果最后是 assistant,再继续请求模型,会变成 assistant 后面接 assistant,很多 provider 会拒绝。
核心语义:
runAgentLoopContinue = 已有 user/toolResult,继续生成下一次 assistant
runLoop 的状态变量
runLoop() 开头有几个关键变量:
let currentContext = initialContext;
let config = initialConfig;
let firstTurn = true;
let pendingMessages = (await config.getSteeringMessages?.()) || [];
含义:
currentContext:当前要发给模型的上下文。config:当前执行策略,包括 model、reasoning、hooks、queue 获取函数。firstTurn:避免第一轮重复发turn_start。pendingMessages:准备插入下一次 assistant response 前的消息。
一开始就调用 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 之前:
- JSON 可能还没闭合。
- 参数不能可靠解析。
- schema validation 不能做。
- 后续 delta 可能继续补全或修正参数。
因此只有完整 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 仍按原始顺序:
- 和 assistant message 里的 toolCall 顺序一一对应。
- provider 转换时更稳定,避免配对/校验问题。
- transcript 更可读。
- 同一次输入重放时顺序一致。
- 避免模型把完成顺序误解成调用顺序。
工具执行管线
完整管线:
prepareToolCall()
-> executePreparedToolCall()
-> finalizeExecutedToolCall()
-> emitToolExecutionEnd()
-> createToolResultMessage()
-> emitToolResultMessage()
prepareToolCall
prepareToolCall() 做执行前检查。
步骤:
按 name 找工具
-> prepareArguments
-> validateToolArguments
-> beforeToolCall
如果工具不存在,直接返回 error result。
如果参数校验失败,也返回 error result。
如果 beforeToolCall 返回 { block: true },则不执行工具,返回 blocked error result。
我们讨论过 hook 顺序:为什么是先参数校验,再权限/hook 判断。
结论是当前顺序更合理:
- hook 拿到的是可信参数。
- 权限判断通常依赖参数,例如 path、command、cwd。
- 参数非法和权限禁止是不同错误语义。
- schema validation 成本通常很低。
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 可以覆盖:
contentdetailsisErrorterminate
这是一层结果后处理。扩展可以裁剪输出、标记错误、或者要求工具批次后停止。
terminate 规则
terminate 的判断是:
return finalizedCalls.length > 0 &&
finalizedCalls.every((finalized) => finalized.result.terminate === true);
注意是 every,不是 some。
原因:
- 一批 tool calls 是同一个 assistant message 的并列意图。
- 必须先完整回填这一批结果。
- 避免某个工具单方面中断整批结果。
- 防止并行工具里一个先完成的 terminate 抢先结束。
terminate更像“这批工具已经完整处理,agent 不需要继续推理”的一致信号。
更精确地说:
terminate 不是停止执行这一个工具,
而是停止工具结果回填后的下一次模型请求。
turn_end 之后
工具结果回填后,先发:
await emit({ type: "turn_end", message, toolResults });
这表示当前 turn 完整结束:
assistant response
+ tool executions
+ toolResult messages
然后进入 prepareNextTurn。
prepareNextTurn
prepareNextTurn 可以修改下一次 provider request 的运行环境:
- 替换 context。
- 替换 model。
- 替换 thinking level。
它不是 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
常见误解
toolCall不是工具执行结果,只是 assistant message 里的调用意图。toolResult不是 UI 日志,它必须进入 context,下一次模型请求才知道工具发生了什么。steer不会中断正在执行的工具;它只是下一次 assistant response 前的插入消息。followUp不是普通 steering;它只在 agent 本来准备结束时才被检查。terminate不是停止某个工具,而是工具结果回填后不再发起下一次模型请求。- 并行执行不代表乱序回填;toolResult message 仍按 assistant 原始 toolCall 顺序生成。
自测题
- 用一句话说明 agent loop 和普通聊天的区别。
- 为什么不能在
toolcall_delta阶段就执行工具? - 为什么
toolResult必须回填 context,而不是只发给 UI? - 内层 loop 和外层 follow-up loop 分别解决什么问题?
steer、followUp、terminate的生效时机分别是什么?- 什么情况下整批 tool calls 会串行执行?
- 为什么
shouldStopAfterTurn要在getSteeringMessages()前面? - 为什么
terminate要求整批 tool calls 都返回terminate: true?
本节最终结论
runLoop() 是一个明确的状态机:
prompt
-> runAgentLoop
-> runLoop
-> streamAssistantResponse
-> executeToolCalls
-> toolResult 回填
-> turn_end
-> prepareNextTurn
-> shouldStopAfterTurn
-> steering/followUp
-> agent_end
Agent 持有状态、队列和 hooks;runLoop() 在正确的时间点调用这些能力。
本节完成度
已完成:
- 理解
runAgentLoop()和runAgentLoopContinue()。 - 理解
runLoop()的两层 while。 - 理解 steering 的注入时机。
- 理解为什么要等完整 assistant message 后才执行 tool call。
- 理解 sequential/parallel 工具执行。
- 理解工具执行的 prepare/execute/finalize 管线。
- 理解 before/after hook 的位置。
- 理解
terminate为什么要求整批一致。 - 理解
shouldStopAfterTurn为什么在 steering 前。 - 理解 steer 不会直接中断正在执行的工具。
未展开,后续章节继续:
- 默认工具如何定义和注册。
read/write/edit/bash的具体实现。- 文件修改队列和安全边界。
- 工具输出如何截断、渲染和回传模型。
AgentSession如何安装 tool hooks 和扩展 hooks。
下一节方向
下一节进入 04 默认工具:read、edit、write、bash。
目标是从工具定义看起:
- 工具 schema 如何描述参数。
- 工具如何执行。
- 工具结果如何组织。
- 文件修改为什么需要队列。
- bash 为什么有执行和输出安全边界。
Source: 03-agent-loop-notes.md