# Agent 循环：三台状态机

> v2 的循环由 human 层的 agent、turn、tool 三台状态机与 Agent 级的 AgentLoopService 组成：分工、step 门控、钩子、工具结果侧信道与并发调度。

- 作者：David（道雾轩）
- 专栏：Kimi Code 源码解读（https://daiw.org/manual/kimi-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/kimi-code/agent-loop
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

v0.29 时代，引擎的心脏是 `packages/agent-core/src/loop/`：一个刻意无状态的 `runTurn()` 函数。v0.43 里 v1 引擎已整体删除，循环由 `packages/agent-core-v2` 承担，形态也变了——**回合的推进交给 xstate 状态机，状态被显式建模**。

## 文件分工

| 位置 | 拥有什么 |
| --- | --- |
| `src/human/agent/machine.ts` | **agent 机**：输入队列、`notify`、`remind`、`steer`、后台工具、prompt gate |
| `src/human/agent/turn.ts` | **turn 机**：回合内的 step 推进、重试、recovery、中止 |
| `src/human/tool/machine.ts` | **tool 机**：单个工具调用的生命周期 |
| `src/agent/loop/loop.ts` | `IAgentLoopService` 接口：`submit`、`steer`、`cancel`、`notify` 与三个钩子 |
| `src/agent/loop/loopService.ts` | `AgentLoopService`（2260 行）：prompt 生命周期、step 门控、错误处理器、事件落盘 |
| `src/agent/loop/machine/` | 适配层：`engine.ts` 翻译状态机事件，`requester.ts` 接 LLM，`tools.ts` 接工具执行器 |

`human/` 是 v2 的纯内核。`packages/agent-core-v2/scripts/check-import-boundaries.mjs` 规定它不许 import `llm-adapter` 或任何 v2 业务域；反过来，只有 `llm-adapter`、`agent/loop/machine`、`session/agentLifecycle` 三个适配层能 import human 的实现，其余代码只能用消息、用量、finish reason 这类「词汇」模块。v1 那条「循环不许 import 宿主层」，在 v2 里成了 lint 强制的目录边界。

## turn 机的状态

```mermaid
stateDiagram-v2
  [*] --> gating
  gating --> thinking
  thinking --> acting: 有 tool call
  thinking --> done: 无 tool call
  thinking --> thinking: recovery 后重发
  thinking --> retrying: 可重试错误
  retrying --> thinking: 退避结束
  thinking --> failed: 不可重试
  acting --> draining: 工具全部有结果
  draining --> gating: 进入下一步
  thinking --> aborted: 中止
  acting --> aborted: 宽限期后收尾
```

- **继续与否看 tool call，不看 finish reason**：`llm.done` 的第一个 guard 检查累积出的消息里 `toolCalls.length > 0`，正好绕开 [kosong](https://daiw.org/manual/kimi-code/kosong) 一章提到的坑——有的 provider 带着工具调用也报 `completed`。
- **空响应当失败**：内容为空且没有工具调用，或只有 thinking 内容时，turn 机把它重新 `raise` 成 `llm.failed.remote`，走和网络错误相同的失败级联。
- **draining 是注入点**：工具收齐后 turn 机发出 `turn.drain`，agent 机把积压的 notification 与 reminder 一起送回，写进历史后再进入下一步。

## step 门控：请求在最后一刻才组装

turn 机有自己的 `gating` 状态，但现役引擎没给它传 `onBeforeStep`。真正的门控在适配层的 requester 里（`agent/loop/machine/requester.ts:55`）：

```ts
  const generate = async (
    _config: LlmRequestConfig,
    _content: LlmRequestContent,
    control: LlmRequestControl,
  ): Promise<void> => {
    const decision =
      options?.gate !== undefined
        ? await options.gate(control.signal)
        : ({ type: 'proceed' } as const);
```

`gate` 就是 `AgentLoopService.gate()`：检查 max-step，运行 `onWillBeginStep` 钩子（压缩、上下文注入、MCP、工具去重、goal 计数都挂在这里），通过后才调用 `IAgentLLMRequesterService.start()`。turn 机传来的 `_config`、`_content` 被有意忽略——请求消息和工具表由请求服务在门控之后，从上下文内存与工具注册表现取。

v1 靠「推迟 `buildMessages` 求值」保证工具表与消息来自同一状态（压缩会同时改动两者）；v2 用结构达到同样效果：压缩跑完之前，请求根本还不存在。

## 钩子与错误处理器

`IAgentLoopService.hooks` 是三个 `OrderedHookSlot`（`src/hooks.ts`）：`onWillBeginStep`、`onDidFinishStep`、`onBeforeSubmitPrompt`。注册时可用 `before` / `after` 指定顺序，例如上下文注入器登记为 `before: 'full-compaction'`，保证提醒先写入、再判断要不要压缩。

失败另走一条链：`registerLoopErrorHandler()` 登记 `{ id, match, handle }`。回合失败时，`loopService` 找第一个 `match` 的处理器；处理器可以调用 `retry()`，让循环从上下文内存重建历史后继续。[上下文溢出](https://daiw.org/manual/kimi-code/turn-convergence)就是这样交给压缩的。

## 回合结果的类型

v1 用 `LoopTurnStopReason` 联合类型表达结局，错误与 max-step 耗尽走异常。v2 把三种结局都做成了值（`loop.ts:101`）：

```ts
export type LoopRunResult =
  | {
      readonly type: 'completed';
      readonly steps: number;
      readonly truncated: boolean;
      readonly stopReason?: string;
    }
  | {
      readonly type: 'failed';
      readonly steps: number;
      readonly error: unknown;
    }
  | {
      readonly type: 'cancelled';
      readonly steps: number;
      readonly reason: unknown;
    };
```

max-step 耗尽是 `failed` 里的 `LoopError`（错误码 `loop.max_steps_exceeded`）；压缩依旧不是结局，而是钩子与错误处理器的事。

## 工具结果的侧信道

`ExecutableToolSuccessResult`（`src/tool/toolContract.ts:31`）：

```ts
export interface ExecutableToolSuccessResult {
  readonly output: ExecutableToolOutput;
  readonly isError?: false | undefined;
  readonly stopTurn?: boolean | undefined;
  readonly stopTurnReason?: string;
  readonly truncated?: boolean | undefined;
  readonly note?: string;
  readonly delivery?: ToolDelivery | undefined;
  readonly spill?: ToolResultSpill;
  readonly spillExempt?: true;
}
```

- **`note` 保留**：只给模型看，投影时由 `renderToolResultForModel()` 接在输出后面（见[上下文与投影](https://daiw.org/manual/kimi-code/context)）。
- **`message` 移除**：v1 那个「只给人看」的字段已不在契约里。
- **新增 `delivery`**：工具可随结果递送一条 user 消息，`kind` 目前只有 `'steer'`。`loopService` 在 `onDidExecuteTool` 钩子里取出它，以 `steerIfActive` 重新提交，正在跑的回合把它当作转向输入吸收。
- **新增 `spill`**：输出超过 `DEFAULT_TOOL_RESULT_MAX_CHARS`（5 万字符）时由截断服务落盘，结果里带 `outputPath`、`totalChars`。
- **`stopTurnReason`** 给 `stopTurn` 补上原因；执行描述上另有 `stopBatchAfterThis`，命中后同一批剩下的调用直接跳过。

## 并发模型

调度器搬到了 `src/agent/toolExecutor/toolScheduler.ts`，逻辑与 v1 一致：资源访问不冲突的任务并行，冲突的排队；`add()` 里那句 `void result.catch(() => undefined)` 也还在，失败任务不会变成 unhandled rejection。

冲突规则在 `src/tool/toolContract.ts` 的 `ToolAccesses` 里，同样没变：`all` 与一切冲突；读、搜索之间互不冲突；只要一方写就冲突；路径做反斜杠转换、折叠连续斜杠、**小写折叠**后，再按目录前缀判断包含。

<Callout type="warn">
  `normalizePath` 里仍是无条件的 `.toLowerCase()`，冲突判定按大小写不敏感处理。在大小写敏感的 Linux 上会偏保守（`./A.txt` 与 `./a.txt` 被判为冲突而串行），方向是安全的。
</Callout>

两处新东西：

1. **攒批**：agent 机为每个 tool call 各起一个 tool 机，`machine/tools.ts` 则等本 step 的调用全部到齐，再作为一批交给 `IAgentToolExecutorService.execute()`，调度器因此能看到整批的冲突关系。
2. **完成顺序与历史顺序分离**：结果按完成先后流回；写入回合历史时，turn 机的 `collectToolOutcomes()` 按 `pendingToolCalls`（provider 给出的顺序）逐个生成 tool 消息。

## 不变量

1. **每个 tool call 都有配对结果**：执行失败变成错误文本，中止变成 `aborted`，执行器没吐出结果的补一条 `produced no result`；取消回合时 `backfillAbortedToolResults()` 还会为未决调用补写 `tool.result` 事件。
2. **用量先于工具入账**：请求服务在流结束、交还 turn 机之前就调用 `usage.record()`，即使在工具执行阶段中止，已花费的 LLM 用量也已记下。
3. **取消会给 step 封口**：取消路径补一条 `finishReason: 'interrupted'` 的 `step.end`；进程崩溃留下的半截 step，由 `contextMemory/loopEventFold.ts` 折叠时补上「Tool execution was interrupted before its result was recorded」的结果。消费转录的代码（[transcript 包](https://daiw.org/manual/kimi-code/transcript)、回放）仍要能处理半截记录。

下一篇：[回合收敛与异常处理](https://daiw.org/manual/kimi-code/turn-convergence)——provider 层的各种失败怎么被吸收。
