# 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。

## 新手极简模型

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

```ts
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 主循环图：

```mermaid
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]
```

## 我们怎么学的

1. 先看 `runAgentLoop()` 和 `runAgentLoopContinue()`，区分“新 prompt”与“继续已有 context”。
2. 再看 `runLoop()` 开头，理解 `currentContext`、`config`、`firstTurn`、`pendingMessages`。
3. 拆内外两层 while。
4. 看 assistant response 完成后如何检查 tool call。
5. 看工具执行入口 `executeToolCalls()`，理解 sequential/parallel。
6. 看工具执行管线：prepare、validate、before hook、execute、after hook、finalize。
7. 回到 `runLoop()` 后半段，看 `turn_end`、`prepareNextTurn`、`shouldStopAfterTurn`、steering/followUp。

## runAgentLoop 与 runAgentLoopContinue

### runAgentLoop

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

它会：

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

核心语义：

```text
runAgentLoop = 有新的 user prompt
```

### runAgentLoopContinue

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

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

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

```ts
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 会拒绝。

核心语义：

```text
runAgentLoopContinue = 已有 user/toolResult，继续生成下一次 assistant
```

## runLoop 的状态变量

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

```ts
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：

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

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

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

### 内层 while

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

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

### 外层 while

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

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

内外循环结构图：

```mermaid
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 的顺序是：

```text
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 之后，才执行：

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

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

```text
{"path":
"README.md"
}
```

在 `toolcall_end` 之前：

- JSON 可能还没闭合。
- 参数不能可靠解析。
- schema validation 不能做。
- 后续 delta 可能继续补全或修正参数。

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

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

## 工具执行入口

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

```ts
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"`，整批工具都会串行。

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

```text
write(file)
read(file)
```

或者：

```text
edit(code)
bash(npm run check)
```

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

## sequential 与 parallel

### sequential

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

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

### parallel

并行模式下：

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

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

- 和 assistant message 里的 toolCall 顺序一一对应。
- provider 转换时更稳定，避免配对/校验问题。
- transcript 更可读。
- 同一次输入重放时顺序一致。
- 避免模型把完成顺序误解成调用顺序。

## 工具执行管线

完整管线：

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

### prepareToolCall

`prepareToolCall()` 做执行前检查。

步骤：

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

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

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

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

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

结论是当前顺序更合理：

- hook 拿到的是可信参数。
- 权限判断通常依赖参数，例如 path、command、cwd。
- 参数非法和权限禁止是不同错误语义。
- schema validation 成本通常很低。

### executePreparedToolCall

真正调用工具：

```ts
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` 可以覆盖：

- `content`
- `details`
- `isError`
- `terminate`

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

## terminate 规则

`terminate` 的判断是：

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

注意是 `every`，不是 `some`。

原因：

- 一批 tool calls 是同一个 assistant message 的并列意图。
- 必须先完整回填这一批结果。
- 避免某个工具单方面中断整批结果。
- 防止并行工具里一个先完成的 terminate 抢先结束。
- `terminate` 更像“这批工具已经完整处理，agent 不需要继续推理”的一致信号。

更精确地说：

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

## turn_end 之后

工具结果回填后，先发：

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

这表示当前 turn 完整结束：

```text
assistant response
+ tool executions
+ toolResult messages
```

然后进入 `prepareNextTurn`。

## prepareNextTurn

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

- 替换 context。
- 替换 model。
- 替换 thinking level。

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

## shouldStopAfterTurn

`shouldStopAfterTurn` 是优雅停止点。

它放在 `getSteeringMessages()` 前面：

```text
turn_end
-> prepareNextTurn
-> shouldStopAfterTurn
-> getSteeringMessages
```

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

如果把 `getSteeringMessages()` 放前面，可能出现：

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

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

## steer 与 followUp

### steer

`steer` 的语义：

```text
尽快插入下一次 assistant response 前
```

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

如果当前正在：

```ts
await executeToolCalls(...)
```

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

### followUp

`followUp` 的语义：

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

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

### steer / followUp / terminate 区别

| 信号 | 生效时机 | 作用 |
| --- | --- | --- |
| `steer` | 下一次 assistant response 前 | 在当前工作链中尽快修正方向，不中断正在执行的工具 |
| `followUp` | 内层 loop 没有工具和 steering，agent 准备结束时 | 接住后续用户消息或系统消息，让 agent 从准备停止处继续 |
| `terminate` | 工具结果回填后 | 阻止下一次模型请求，不是杀掉当前工具 |

steering/followUp 插入时机图：

```mermaid
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()` 是一个明确的状态机：

```text
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 为什么有执行和输出安全边界。
