# 回合循环与 TurnMachine

> 一个回合从入队、准备到收尾的全过程：runRegularTurnLoop 每轮做什么，何时 continue、何时 break，TurnMachine 记下哪些阶段；重复工具调用检测与“长程优先”原则在代码里的样子，自动化与闲时回合为何在请求边界藏起写工具。

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

用户的一条输入被 AgentRuntime 受理之后，就进入一个回合（turn）：先冻结本轮模型、写入用户消息，再在 `runRegularTurnLoop` 里反复“整理上下文、请求模型、执行工具”，直到模型只回文字且没有谁要求续跑，最后结算用量、发出 `TurnComplete`。这一篇讲这副骨架，以及回合里那台记录阶段的小状态机 TurnMachine；一次模型请求内部的流式消费、工具边流边执行与断流恢复，留给[下一篇](https://daiw.org/manual/zcode/model-step)。

输入怎样受理、排队，见[输入受理、命令队列与引导](https://daiw.org/manual/zcode/prompt-admission)。回合相关的方法都挂在 AgentRuntime 原型上，接口声明是 `AgentRuntimeTurnMethods`（`apps/zcode-cli/packages/core/src/runtime/internal-turn-methods.ts:82`），装配方式见[AgentRuntime：端口、依赖与方法装配](https://daiw.org/manual/zcode/agent-runtime)。`apps/zcode-cli/AGENTS.md:12` 要求单个源文件默认不超过 400 行，但入口 `turn.ts` 有 872 行、单步 `turn-model-step.ts` 有 803 行，是两处明显的例外。下表路径相对 `apps/zcode-cli/packages/core/src`：

| 文件 | 职责 |
| --- | --- |
| `runtime/methods/turn.ts` | 入口 `executeTurn`、`executeTurnCommand`：准备、调用循环、收尾与失败收口 |
| `runtime/methods/turn-loop.ts` | `runRegularTurnLoop`：每轮的压缩、MCP、工具清单、提醒、投影，再调一次模型步 |
| `runtime/methods/turn-loop-state.ts` | 循环状态、快速回填熔断、自动化与闲时回合判定 |
| `runtime/methods/turn-stop.ts`、`turn-step-finish.ts` | 纯文本收尾与 Stop 钩子续跑；`step-finish` 落盘 |
| `runtime/methods/turn-tools.ts`、`turn-tool-usage.ts`、`turn-tool-warnings.ts` | 把工具批次交给执行器并提交结果；工具用量；异常提醒 |
| `runtime/helpers/model-anomaly.ts` | 重复调用与调用预算检测 |
| `agent/turn-machine.ts`、`agent/turn-state.ts` | TurnMachine 与阶段定义 |
| `agent/message-history.ts`、`agent/message-history-usage.ts` | 跨回合的规范历史与条目类型；持久化用量基线 |
| `agent/tool-part-order.ts` | 冷恢复时挑选、排序工具 part |

## 两个入口，一条路

`executeTurn`（`apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:70`）并不直接开跑，而是把输入包成一条 `mode: "prompt"`、优先级 `next` 的运行时命令放进命令队列（`turn.ts:76`）；受理流程 `admitPrompt` 生成的也是 `prompt` 命令，只是多带一份开跑预留（`apps/zcode-cli/packages/core/src/runtime/methods/prompt-admission.ts:110`）。两条路最后都由队列的 `runRuntimeCommand` 调到 `executeTurnCommand`（`apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-queue.ts:205`），这才是回合真正的起点（`turn.ts:93`）。它先在任何 `await` 之前冻结本轮事实（`turn.ts:100`）：

```ts
  // 普通 Turn 过去在异步初始化完成后才读取 Session Selection/输出样式，
  // 初始化期间发生的切模会越过 admission 边界，错误影响已经开始的 Turn。
  // 这里在任何 await 之前冻结本轮事实；后续配置变化只作用于下一轮。
  const admittedModelSelection = options?.intent?.modelSelection ?? this.getSessionModelSelection();
  const admittedOutputStyle = this.config.outputStyle;
  const compactInstructions = parseCompactCommand(input);
  const rewindCommand = parseRewindCommand(input);
  const turnId = startReservation?.turnId ?? createTurnId();
```

输入如果是 `/compact [instructions]`、`/fork` 或 `/rewind`（`apps/zcode-cli/packages/core/src/runtime/helpers/commands.ts:5`、`commands.ts:17`），会在上下文初始化和 SessionStart 钩子之后转去 `executeManualCompact` 或 `executeRewindCommand`（`turn.ts:240`、`turn.ts:258`），不进常规循环，见[上下文压缩](https://daiw.org/manual/zcode/compaction)与[检查点、回退与分叉](https://daiw.org/manual/zcode/rewind-fork)。

## 准备阶段

常规回合进入循环前依次经过下面这些阶段。有名字的阶段会写 `turn.phase.started` 与 `turn.phase.completed` 日志，其中六个同时作为本地首字耗时（local TTFT）的分段上报（`turn.ts:141`）。

| 阶段 | 做什么 | 出处 |
| --- | --- | --- |
| 建模型 | 按冻结的选择 `createTurnModel`；失败直接发 `TurnError`（`turnPhase` 为 `model_creation`），避免已受理的输入没有终态 | `turn.ts:190`、`turn.ts:198` |
| `context_initialization` | 首轮构造完整上下文，之后每轮按本轮模型重建前缀 | `turn.ts:216` |
| `session_start_hooks` | SessionStart 钩子，每个运行时只跑一次 | `turn.ts:228`、`apps/zcode-cli/packages/core/src/runtime/methods/hooks.ts:27` |
| 活动回合 | `beginActiveTurn` 登记可接受引导的活动回合，TurnMachine 进入 `processing_input` | `turn.ts:268`、`turn.ts:279` |
| `session_persistence` | 确保会话已落库，应用提交里的模型选择、模式与 Plan 开关，读取当前目标 | `turn.ts:281`、`turn.ts:284` |
| `turn_started_event` | 先生成用户消息 ID 再发 `TurnStarted`，事件带 `automationId` 或 `offPeakTaskId` 等来源 | `turn.ts:296`、`turn.ts:304` |
| `target_accounting` | 目标模式计账；有活动目标时另起每 15 秒一次的心跳 | `turn.ts:68`、`turn.ts:338` |
| `user_prompt_hooks` | UserPromptSubmit 钩子；钩子要求拦截时，以钩子给的理由直接完成回合，不请求模型 | `turn.ts:363`、`turn.ts:372` |
| 写入输入 | 注入钩子上下文、引用会话提醒、跨日提醒，解析附件，把用户消息写进历史并落库，首条消息落库后立即异步生成标题，最后追加插件引用提醒 | `turn.ts:420`、`turn.ts:548` |

两个细节：目标续跑这类运行时注入的输入以 `model-only` 写入，模型看得到，界面快照不把它渲染成用户气泡（`turn.ts:477`）；自定义命令展开后的长提示词只进模型历史，落库、标题和恢复快照用的仍是用户原始输入（`turn.ts:512`）。上下文与提醒的构造见[系统提示词、上下文与提醒](https://daiw.org/manual/zcode/context-builder)，钩子见[生命周期 Hooks 与工作区信任](https://daiw.org/manual/zcode/hooks)。

准备的最后一步是 `setCacheMiss()` 并装配循环状态（`turn.ts:556`、`turn.ts:561`）。`RegularTurnLoopState`（`apps/zcode-cli/packages/core/src/runtime/methods/turn-loop-state.ts:76`）装着本轮固定使用的模型、各类计数器、回合禁用工具表、自动化与闲时身份，以及一份本回合的请求历史。

### 两份历史

运行时持有一份跨回合的规范历史 `messageHistory`（`MessageHistoryImpl`，`apps/zcode-cli/packages/core/src/agent/message-history.ts:135`）。回合开始时从它浅拷贝出 `turnRequestState.entries`（`turn.ts:595`），之后每次请求都从这份回合内历史投影。写入分两种（`apps/zcode-cli/packages/core/src/runtime/methods/turn-output-token-continuation.ts:90`、`turn-output-token-continuation.ts:98`）：`commitTurnRequestEntries` 同时写两份，用于助手消息、工具结果和各类提醒；`appendTurnRequestEntries` 只写回合内这份，用于两类条目——输出截断后的续写提示，它带 `queryScope: "output_token_continuation"`，按约定不得进入规范历史或落库（`message-history.ts:55`）；以及已由各自落库路径写进规范历史的后台通知、子 Agent 消息和引导（如 `apps/zcode-cli/packages/core/src/runtime/methods/background-notifications.ts:178`）。回合结束，请求历史随循环状态一起释放（`turn-loop-state.ts:116`）。

已提交的助手条目还带着 provider 返回的 tokens，这份数据不发给 provider（`message-history.ts:53`），而是作为本地估算当前上下文的基线（`apps/zcode-cli/packages/core/src/agent/message-history-usage.ts:11`），压缩判断和下一篇的输出预算都用它。

## TurnMachine：只记录阶段的状态机

阶段定义在 `apps/zcode-cli/packages/core/src/agent/turn-state.ts:25`，合法转移表在 `turn-state.ts:230`。`TurnMachineImpl`（`apps/zcode-cli/packages/core/src/agent/turn-machine.ts:68`）的方法不改自身，而是返回新的 `TurnState`，调用方每次再包一台新机器，所以循环里到处是 `state.turnMachine = new TurnMachineImpl(state.turnMachine.aggregateResults())` 这样的写法；非法转移抛 `InvalidTurnPhase`（`turn-machine.ts:92`）。运行时实际走到的转移如下：

```mermaid
stateDiagram-v2
  [*] --> idle: create
  idle --> processing_input: start
  processing_input --> completing: UserPromptSubmit 钩子拦截
  processing_input --> awaiting_model_response: startModelRequest
  awaiting_model_response --> streaming: receiveModelResponse
  streaming --> scheduling_tools: scheduleTools
  scheduling_tools --> executing_tools: startToolExecution
  executing_tools --> aggregating_results: aggregateResults
  streaming --> aggregating_results: 续写、引导、Stop 钩子续跑、重发
  aggregating_results --> awaiting_model_response: 下一次 startModelRequest
  streaming --> completing: 纯文本收尾
  aggregating_results --> completing: 工具要求停回合
  completing --> [*]
```

几点值得注意：

- **它不驱动循环。** 循环靠模型步返回的 `"continue"`、`"output_continuation"`、`"break"` 推进（`apps/zcode-cli/packages/core/src/runtime/methods/turn-model-step.ts:86`），状态机只断言顺序，顺带记下本次请求的模型与可记录消息、工具调用与结果。
- **一半接口没人用。** 16 个方法里，`addStreamingContent`、`queuePendingInput`、`drainPendingInputs`、`requestPermission`、`resolvePermission`、`fail`、`getNextPhase`、`isComplete` 在非测试代码里都没有调用方，`awaiting_permission` 与 `error` 两个阶段因此到不了：`startToolExecution` 只在某个调用处于 `waiting_permission` 时才转去 `awaiting_permission`（`turn-machine.ts:166`），而 `scheduleTools` 把所有调用都置为 `scheduled`（`turn-machine.ts:159`）。审批等待发生在执行器与权限代理内部，见[权限模式与规则](https://daiw.org/manual/zcode/permission)。
- **被动压缩会换一台新机器。** 模型报上下文超窗、被动压缩成功后，循环用 `TurnMachineImpl.create(...).start()` 重建状态机（`turn-model-step.ts:793`）。从代码看这有个副作用：`TurnComplete.duration` 按状态机的 `startedAt` 计算（`turn.ts:655`），发生过被动压缩的回合，时长从压缩成功那一刻算起。
- **`TurnError.turnPhase` 从代码看恒为 `processing_input`。** 失败事件取的是 `turn.ts` 里局部变量 `turnMachine` 的阶段（`turn.ts:774`），而循环里的机器只在成功时才赋回这个变量（`turn.ts:620`），循环内抛出的错误记下的永远是循环开始前的阶段。

## 循环的一轮

`runRegularTurnLoop`（`apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:43`）是一个 `while (true)`，每轮按固定顺序做完准备，再调一次 `runModelBackedTurnStep`：

```mermaid
flowchart TD
  A["中止检查"] --> B{"非首步且不在续写中？"}
  B -->|是| C["吸收后台通知与子 Agent 消息"]
  B -->|否| D["microcompact"]
  C --> D
  D --> E{"需要自动压缩？"}
  E -->|熔断| X["抛出 ModelContextExceeded"]
  E -->|压缩或跳过| G["MCP 初始化与工具清单"]
  G --> H["plan、Todo、输出风格等提醒"]
  H --> I["provider 投影与缓存锚点"]
  I --> J["runModelBackedTurnStep"]
  J -->|continue 或 output_continuation| A
  J -->|break| K["退出循环，由 turn.ts 收尾"]
```

| 步骤 | 条件与行为 | 出处 |
| --- | --- | --- |
| 中止检查 | 每轮开头，以及每个耗时步骤之后，`throwIfTurnAborted` | `turn-loop.ts:48` |
| 吸收运行时命令 | 非首步且不在输出续写中：取出队列里优先级为 `now` 或 `next` 的后台任务通知与子 Agent 消息，并入本轮请求，遇到 `control-only-turn` 即停；吸收到了就清零重复调用计数 | `turn-loop.ts:55`、`apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-active-loop.ts:25`、`apps/zcode-cli/packages/core/src/runtime/command-queue.ts:131` |
| microcompact | 首步用 `PreRequest` 阶段，之后用 `MidTurn`；只有配置 `compact.microcompact.enabled` 显式为 `true` 才真正动手，缺省不启用 | `turn-loop.ts:67`、`apps/zcode-cli/packages/core/src/runtime/methods/microcompact.ts:111` |
| 自动压缩 | 先算快速回填，再判断是否压缩；熔断则抛错 | `turn-loop.ts:77` |
| MCP | 首次调用时等待 MCP 启动完成并注册工具，之后直接返回 | `turn-loop.ts:106`、`apps/zcode-cli/packages/core/src/runtime/methods/mcp.ts:126` |
| 工具清单 | `getTools(model)` 减去本回合禁用表；自动化建任务达上限后给空清单 | `turn-loop.ts:114`、`apps/zcode-cli/packages/core/src/runtime/methods/config.ts:136` |
| 提醒 | plan 退出提醒（一次性）、plan 模式提醒、Todo 提醒（仅当 `TodoWrite` 可见）、输出风格提醒（仅首步）；续写中跳过前三种 | `turn-loop.ts:120`、`turn-loop.ts:138`、`turn-loop.ts:157` |
| provider 投影 | 带缓存标记投影出请求消息，另算一份去掉续写提示的“可记录”投影 | `turn-loop.ts:172`、`turn-loop.ts:178` |
| 发请求 | 状态机 `startModelRequest`，然后 `runModelBackedTurnStep`，返回 `break` 才退出 | `turn-loop.ts:187`、`turn-loop.ts:205` |

提醒的内容与触发细则归[系统提示词、上下文与提醒](https://daiw.org/manual/zcode/context-builder)，这里只看循环怎么放它们：都作为 system reminder 附件用 `commitTurnRequestEntries` 写进两份历史；Todo 提醒另外落一条合成用户通知（`turn-loop.ts:148`），输出风格提醒只进内存历史、不落库（`turn-loop.ts:162`）。

缓存锚点在投影之后统一设置：先清掉所有非 system 消息上的缓存标记，再只给最后一条非 system 消息打上 `{ type: "ephemeral" }`（`apps/zcode-cli/packages/core/src/runtime/helpers/provider-request-messages.ts:292`）。注释解释了为什么要等投影完：投影会调整合成条目与真实用户消息的相对位置，先打标记会让合成条目抢走缓存锚点（`turn-loop.ts:170`）。提醒要不要投影成对话中段的 system 消息，取决于模型能力或配置强制（`apps/zcode-cli/packages/core/src/runtime/helpers/runtime-provider-request-messages.ts:17`）。

### 快速回填熔断

压缩阈值与策略见[上下文压缩](https://daiw.org/manual/zcode/compaction)。循环自己只加了一道熔断，防止“压缩完没几步又满了”无限重复，判断函数在 `turn-loop-state.ts:154`：

```ts
export function evaluateRapidRefill(
  tracking: CompactLoopTracking | undefined,
): RapidRefillDecision {
  const toolTurnsSinceCompact = tracking?.toolTurnsSinceCompact ?? 0;
  const consecutiveRapidRefills =
    tracking && toolTurnsSinceCompact < RAPID_REFILL_TOOL_TURN_THRESHOLD
      ? tracking.consecutiveRapidRefills + 1
      : 0;

  return {
    consecutiveRapidRefills,
    shouldBlock: consecutiveRapidRefills >= MAX_CONSECUTIVE_RAPID_REFILLS,
    toolTurnsSinceCompact,
  };
}
```

两个阈值都是 3（`turn-loop-state.ts:21`）。回合里第一次压缩前没有跟踪记录，连续计数为 0；每次压缩成功，记下连续计数并把“压缩后的工具批次数”清零（`turn-loop-state.ts:170`），此后每完成一个工具批次加一（`turn-loop-state.ts:180`）。于是，压缩后不到 3 个工具批次就又需要压缩，记一次快速回填；前两次照常压缩，连续第 3 次就被拦下（接连快速回填时，回合里的第 4 次压缩不会发生）：`autoCompactIfNeeded` 返回 `rapid_refill_blocked`（`apps/zcode-cli/packages/core/src/runtime/methods/compact.ts:230`），循环抛出 `ModelContextExceeded`，提示可能有文件或工具输出过大，应分块读取或新开会话（`apps/zcode-cli/packages/core/src/runtime/helpers/model-errors.ts:52`）。模型报超窗后的被动压缩也受这道熔断约束（`turn-model-step.ts:753`）。

## 什么时候停

`runModelBackedTurnStep` 只有三种返回值（`turn-model-step.ts:86`），循环只在 `break` 或抛错时退出（`turn-loop.ts:215`）：

| 结局 | 触发条件 | 出处 |
| --- | --- | --- |
| `break` | 本步没有要执行的工具调用，没有待消费的引导，Stop 钩子也不要求续跑 | `apps/zcode-cli/packages/core/src/runtime/methods/turn-stop.ts:218` |
| `break` | 某个工具结果带 `turnControl.stopTurnAfterResult`：Plan 模式下退出计划被拒且没有修改意见，或声明了 `stopTurnOnSuccess` 的终态工具成功 | `apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:424`、`apps/zcode-cli/packages/core/src/tool/executor/turn-control.ts:44`、`turn-control.ts:152` |
| `break` | 自动化建任务达上限后，那一步纯文本回复 | `turn-stop.ts:176` |
| `continue` | 一个工具批次执行完、结果写回历史 | `turn-tools.ts:477` |
| `continue` | 纯文本边界上有待消费的引导，以 user 身份并入历史，继续同一回合 | `turn-stop.ts:195` |
| `continue` | Stop 钩子要求续跑且带回了上下文，每回合最多 3 次 | `turn-stop.ts:208`、`hooks.ts:10` |
| `output_continuation` | 输出因 token 上限被截断，自动续写 | `turn-model-step.ts:660` |
| `continue` | 断流恢复、Start Plan 繁忙重试、被动压缩成功 | `turn-model-step.ts:277`、`turn-model-step.ts:289`、`turn-model-step.ts:429` |
| 抛错 | 用户取消、不可恢复的模型错误、续写耗尽、快速回填熔断 | 见[下一篇](https://daiw.org/manual/zcode/model-step) |

纯文本收尾的判断在 `finishModelStepWithoutToolCalls` 里，引导优先于 Stop 钩子（`turn-stop.ts:195`）：

```ts
  if (await drainInlineGuideForNextRequest(this, state)) {
    // 正常 text-only 是可续跑边界：assistant 已持久化，guide 以 user role 进入历史，
    // 保持同一 active turn 继续下一次 provider request，不改投 future queue。
    state.turnMachine = new TurnMachineImpl(state.turnMachine.aggregateResults());
    return "continue";
  }
  const stopHookResult = await this.runStopHooks(
    state.modelResponse,
    state.toolCallCount,
    state.turnTraceContext,
    state.turnAbortSignal,
    state.stopHookContinuationCount > 0,
  );
  if (this.shouldContinueAfterStopHooks(stopHookResult, state.stopHookContinuationCount)) {
    state.stopHookContinuationCount += 1;
    const hookEntry = this.injectHookAdditionalContextIntoMessageHistory(
      HookEventName.Stop,
      stopHookResult.additionalContexts,
    );
    appendTurnRequestEntries(state.turnRequestState, hookEntry ? [hookEntry] : []);
    state.turnMachine = new TurnMachineImpl(state.turnMachine.aggregateResults());
    return "continue";
  }
```

Stop 钩子续跑要求 `stopShouldContinue` 为真、带回了额外上下文、且次数少于 3（`hooks.ts:120`），与 README 的描述一致：空的 `continue: true` 会被忽略，重复续跑有上限（`apps/zcode-cli/README.md:194`）。真正 `break` 之前，还没消费的引导退回队列（原因码 `guide.noToolBoundary`），活动回合不再接受引导，并记下稳定分叉边界需要的消息 ID（`turn-stop.ts:218`）。

## 重复工具调用检测与“长程优先”

`apps/zcode-cli/AGENTS.md:11` 把这一条写进了工作规范：

> 长程任务优先：核心 agent loop 默认面向可持续运行的复杂任务设计，不用 tool call 次数做硬停止。资源与安全边界应由 token/context limit 自动 compact、用户取消、权限拒绝、工具超时、输出截断、provider retry 上限等明确条件承担。

代码里，工具调用的次数与重复只会换来提醒，不会让回合停下。每个工具批次结束后，`handleToolCallAnomalyWarnings` 跑两个检测（`apps/zcode-cli/packages/core/src/runtime/methods/turn-tool-warnings.ts:14`）。重复调用检测在 `apps/zcode-cli/packages/core/src/runtime/helpers/model-anomaly.ts:34`：

```ts
  for (const toolCall of toolCalls) {
    const signature = buildRepeatedToolCallSignature(toolCall.name, toolCall.input);
    if (state.repeatedToolCallSignature === signature) {
      state.repeatedToolCallStreakCount += 1;
    } else {
      state.repeatedToolCallSignature = signature;
      state.repeatedToolCallStreakCount = 1;
    }

    if (state.repeatedToolCallStreakCount !== threshold) {
      continue;
    }

    const warningInjected = state.anomalyWarningsInjected < maxBudgetWarningsPerTurn;
    if (warningInjected) {
      state.anomalyWarningsInjected += 1;
    }
```

- **签名**：工具名的 JSON 加上输入的“稳定 JSON”，对象键排序后再序列化，键的顺序不同也算同一调用（`model-anomaly.ts:111`）。
- **连击**：计数跨批次累积，同一批里的并行调用也逐个计入；只有连击数恰好等于阈值时报一次，同一串连击再长也不重复报。
- **注入上限**：每回合最多注入 `maxBudgetWarningsPerTurn` 条提醒（两种检测共用），超出后只发 `ModelAnomalyWarning` 事件、不再往上下文里塞（`model-anomaly.ts:47`）。提醒以 `model_anomaly` 来源的 system reminder 写进历史，开头一句是 `You have called ${toolName} with the same input ${observedCount} times in a row.`（`model-anomaly.ts:98`）。
- **清零**：吸收到运行时命令或引导时，连击计数清零（`turn-loop.ts:61`、`apps/zcode-cli/packages/core/src/runtime/methods/turn-guide-drain.ts:63`）。

默认值：`repeatedToolCallWarningThreshold` 与 `maxBudgetWarningsPerTurn` 都是 3（`apps/zcode-cli/packages/contracts/src/config/index.ts:345`）；按总次数报警的 `toolCallWarningThreshold` 没有默认值，也就是默认关闭（`index.ts:370`）。三项都能在配置的 `modelAnomalyGuard` 段里调整（`apps/zcode-cli/packages/adapters/src/config/schema.ts:221`）。

AGENTS.md 列出的“明确条件”在代码里各有落点：

| 条件 | 代码里的边界 | 出处 |
| --- | --- | --- |
| 上下文上限 | 自动压缩、被动压缩，加上快速回填熔断（3 与 3） | `turn-loop-state.ts:21` |
| 用户取消 | 每轮开头与每个耗时步骤后的中止检查 | `turn-loop.ts:48` |
| 权限拒绝、工具超时 | 执行器内部，见[执行器：调度、审批、超时与结果](https://daiw.org/manual/zcode/tool-executor) | — |
| 输出截断 | 自动续写最多 3 次，之后报错 | `turn-output-token-continuation.ts:18` |
| provider 重试上限 | 适配层默认重试 10 次；越过重试边界后，core 的断流恢复每回合最多 10 次 | `apps/zcode-cli/packages/adapters/src/model/retry-policy.ts:13`、`apps/zcode-cli/packages/core/src/runtime/methods/streaming-recovery.ts:14` |

反过来，按次数硬停的东西只剩名义：`TurnResultType` 里有 `error_max_turns`、`error_max_budget`、`error_max_tool_calls`（`turn-state.ts:149`），非测试代码里没有任何地方产出它们；运行时配置有 `maxTurns` 字段（`apps/zcode-cli/packages/core/src/runtime/types.ts:131`），创建子 Agent 时会填上（缺省 4，`apps/zcode-cli/packages/core/src/runtime/methods/subagent.ts:269`），但回合循环并不读取它。从代码看，子 Agent 定义里的 `maxTurns` 目前不会让子 Agent 停下，子 Agent 的其余机制见[子 Agent](https://daiw.org/manual/zcode/subagents)。站内对照：Kimi Code 的循环有 max-step 上限，耗尽即 `loop.max_steps_exceeded`（见[Agent 循环：三台状态机](https://daiw.org/manual/kimi-code/agent-loop)）；OpenCode 的 `agent.steps` 默认无穷大，到上限也只是追加一句提示（见[主循环 SessionPrompt](https://daiw.org/manual/opencode/prompt-loop)）。

## 自动化与闲时回合：在请求边界藏起写工具

定时任务派发的“自动化回合”和闲时任务派发的“闲时回合”，每次请求模型前都会按身份扩充禁用表（`turn-loop.ts:221`）：

```ts
function buildTurnDisallowedTools(state: RegularTurnLoopState): Set<string> | null {
  const tools = new Set(state.toolDisallowlist ?? []);
  if (isAutomationMutationRestrictedTurn(state)) {
    // 定时任务执行轮只应运行任务 prompt，不能反过来管理自己的定义。
    // 保留 CronList 供只读查询；所有 mutation 在 provider 请求边界统一隐藏。
    for (const toolName of AUTOMATION_MUTATION_TOOL_NAMES) {
      tools.add(toolName);
    }
  }
  if (isOffPeakCreateRestrictedTurn(state)) {
    // 闲时执行轮禁止再创建闲时任务（防递归自我派生）；OffPeakList 只读保留。
    // 注意 automation 执行轮不进此分支——cron turn 放行 OffPeakCreate。
    for (const toolName of OFF_PEAK_MUTATION_TOOL_NAMES) {
      tools.add(toolName);
    }
  }
  return tools.size > 0 ? tools : null;
```

| 回合 | 对模型隐藏 | 仍然保留 | 出处 |
| --- | --- | --- | --- |
| 自动化回合 | `CronCreate`、`CronUpdate`、`CronDelete` | `CronList`，以及 `OffPeakCreate` | `turn-loop-state.ts:23` |
| 闲时回合 | `OffPeakCreate`、`SendMessage`、`Workflow` | `OffPeakList` | `turn-loop-state.ts:34` |

闲时回合连 `SendMessage` 与 `Workflow` 也要藏，是因为它们会在本轮 `modelExecution` 之外重新拉起子 Agent，按父会话常驻的模型选择建模型（`turn-loop-state.ts:26`），执行器一侧的说法是“落到用户套餐”（`apps/zcode-cli/packages/core/src/tool/handlers/off-peak.ts:37`）；而闲时回合用的是本轮专属的执行期模型，不改写会话的模型选择（`turn.ts:282`）。

为什么放在请求边界，而不是只靠执行时拒绝？一是模型根本看不见这些工具，不会白白浪费步骤去试；二是入口元数据未必总能带进循环——自动化派发到一个正在运行的会话、或重试恢复时，`automationId` 可能缺失，但 `queryId` 仍以 `automation-` 开头（`turn-loop.ts:111`）。所以身份判断用了三重信号：显式的 `automationId` 或 `offPeakTaskId`，`queryId` 前缀 `automation-` 或 `offpeak-`，以及禁用表里的哨兵工具名（`turn-loop-state.ts:130`、`turn-loop-state.ts:145`）；最后一条也覆盖了自动化输入作为引导并入别的回合时带进来的禁用表（`turn-guide-drain.ts:57`）。执行边界还有第二层：执行器把 `automationTurn`、`offPeakTurn` 交给处理函数，`CronCreate` 等在自动化回合里直接以 `PermissionDenied` 拒绝（`apps/zcode-cli/packages/core/src/tool/handlers/cron.ts:39`），闲时回合同理（`off-peak.ts:39`）。

自动化还有一处特别收口：保留的定时任务达到全局上限 20 个时，执行器把 `CronCreate` 的错误结果改写成“不要再列出、删除或重试”的说明，并请求停回合（`turn-control.ts:14`、`turn-control.ts:20`）。循环据此把本回合切成纯文本：下一次请求不带任何工具（`turn-loop.ts:114`），模型仍幻觉出工具调用就一律忽略，文本为空时用固定的中英文兜底句（`turn-model-step.ts:455`、`turn-model-step.ts:735`）。调度器与任务定义见[定时任务与闲时任务](https://daiw.org/manual/zcode/cron-offpeak)。

## 把工具调用交给执行器

模型步结束时若有要执行的工具调用，就交给 `executeToolCallsForModelStep`（`turn-tools.ts:43`）。其中部分只读工具可能已经在流式期间执行完（下一篇细说），这个函数把两类调用合成一个完整批次：

1. 为每个调用准备 part：流式期间已执行的复用原 part，其余先落一条 `pending` 工具 part（带声明序号 `declarationIndex`），并写 `tool_call_closed` 账本事件（`turn-tools.ts:80`）。
2. `scheduleTools` 把注册表元数据转成调度依赖，只读要求 `readOnly` 且副作用范围为 `none`（`apps/zcode-cli/packages/core/src/runtime/methods/tools.ts:38`）；状态机进入 `scheduling_tools`、`executing_tools`（`turn-tools.ts:145`）。
3. 其余调用经 `executeTools` 交给执行器，回合的中止信号、自动化与闲时身份、子 Agent 模型覆盖一并传入；每个批次开始时回调 `onBatchStart`，把 part 改成 `running`（`turn-tools.ts:180`、`tools.ts:63`）。批次划分、审批与超时都在执行器内部。
4. 结果按声明顺序而非完成顺序排列（`turn-tools.ts:242`）；逐个落 `completed` 或 `error` part、写工具用量、把工具结果提交进两份历史、打文件变更检查点、写恢复锚点与 `tool_result_committed` 账本（`turn-tools.ts:281`）。
5. 收尾：检查停回合标记，跑异常检测，落 `step-finish` 并完成助手消息（`apps/zcode-cli/packages/core/src/runtime/methods/turn-step-finish.ts:8`），尝试吸收引导，给压缩跟踪计一个工具批次（`turn-tools.ts:424`、`turn-tools.ts:471`）。

整个过程守着一条不变式：历史里每个工具调用都要有配对的结果。用户在工具执行中途停止时，循环不在结果生成前直接抛出，而是让执行器为每个调用生成取消结果（`turn-tools.ts:142`）；打检查点时遇到取消，也要先把同批其余结果提交完再抛（`turn-tools.ts:367`、`turn-tools.ts:420`）。冷恢复时，`selectToolPartsForHistory` 对同一调用只取最后一条 part，再按声明序号排序（`apps/zcode-cli/packages/core/src/agent/tool-part-order.ts:3`），因为只读工具可能先落库，断流恢复又会另建 part。工具用量写入失败只记警告，不能挡住结果回灌与续跑（`apps/zcode-cli/packages/core/src/runtime/methods/turn-tool-usage.ts:48`）。

## 收尾与失败

成功时，`turn.ts` 依次结算目标计账（`turn.ts:625`）、固定稳定分叉边界（`turn.ts:632`）、发 `TurnComplete`（`turn.ts:647`，带最后一步的文本、token 数、用量汇总、工具调用数、写入历史的轮数与缓存统计）、写回合用量（`turn.ts:668`）、回合号加一并重建投影（`turn.ts:687`），最后调度项目记忆抽取，单轮执行策略可以跳过（`turn.ts:699`，见[项目记忆](https://daiw.org/manual/zcode/memory)）。

失败时先由 `createTurnFailureError` 归类（`apps/zcode-cli/packages/core/src/runtime/helpers/turn-errors.ts:88`）。用户取消算正常结束：目标暂停，未消费的引导退回队列（原因码 `guide.turnInterrupted`），发的是 `resultType` 为 `cancelled` 的 `TurnComplete`，而不是 `TurnError`（`turn.ts:731`、`turn-errors.ts:139`）；其余错误发 `TurnError`。两种情况下只要还有排队输入，队列的自动排空都会被关掉（内部抢占式的取消除外），排队的输入等用户显式继续（`turn.ts:742`）。无论成败，`finally` 都会释放开跑预留、结束活动回合、通知浏览器控制端回合结束（`turn.ts:823`）。这些事件怎样投影到界面与数据库，见[会话事件流与持久化投影](https://daiw.org/manual/zcode/session-events)。

下一篇：[一次模型请求：流式、工具并发与恢复](https://daiw.org/manual/zcode/model-step)——`runModelBackedTurnStep` 内部：流式事件怎样被消费，只读工具怎样边流边执行，截断、断流与取消怎样收场。
