# 执行器：调度、审批、超时与结果

> 一批工具调用怎样按并发安全分组、哪些并行哪些串行；单个调用怎样走完校验、PreToolUse、权限审批、执行、PostToolUse 与结果投影；超时的默认值与取消语义；错误和大结果怎样变成给模型的内容，又怎样投影给界面；执行事件与遥测。

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

上一篇的契约回答“这个工具是什么”，执行器回答“这一次调用怎么跑”。每个 AgentRuntime 持有一个 `ToolExecutorImpl`（`apps/zcode-cli/packages/core/src/tool/executor/impl.ts:16`），由 `createRuntimeToolExecutor` 一次注入注册表、权限服务、钩子执行器，以及文件系统、执行、HTTP、子 Agent、工作流等二十多个端口（`apps/zcode-cli/packages/core/src/runtime/helpers/runtime-tools.ts:148`）。模型给出工具调用后，回合循环经运行时方法 `scheduleTools` 与 `executeTools` 进入执行器（`apps/zcode-cli/packages/core/src/runtime/methods/tools.ts:38`、`tools.ts:63`）。回合何时调用它、结果怎样写回历史，见[回合循环与 TurnMachine](https://daiw.org/manual/zcode/turn-loop)和[一次模型请求：流式、工具并发与恢复](https://daiw.org/manual/zcode/model-step)；本篇只讲执行器内部。

执行器对外只有四个方法：`execute` 跑单个调用，`executeBatch` 并发跑一组，`executeSchedule` 按分组逐组跑并产出批次事件，`trackExternalBackgroundTask` 把不是本回合启动的后台任务（恢复中的工作流 run）纳入追踪（`apps/zcode-cli/packages/core/src/tool/executor/types.ts:140`）。代码住在 `apps/zcode-cli/packages/core/src/tool/executor`：

| 文件 | 职责 |
| --- | --- |
| `../scheduler.ts` | 分组：哪些调用可以放在一起并发 |
| `impl.ts`、`batch-runner.ts` | 门面与默认值；逐组执行、回合停止后取消剩余调用 |
| `call-runner.ts` | 单个调用的完整流水线 |
| `validation.ts`、`hook-flow.ts`、`permission-*.ts`、`approval-gate.ts` | 输入输出校验、钩子、权限判定与审批闸 |
| `timeout.ts` | 超时解析、可暂停的 deadline、取消 |
| `result-serialization.ts`、`result-content-projection.ts`、`../result-persistence-format.ts` | 结果预算、截断、落盘 |
| `result-display.ts`、`display-text.ts`、`bash-result-display.ts` | 给界面的 display 投影 |
| `errors.ts`、`turn-control.ts` | 错误结果、回合控制 |
| `events.ts`、`telemetry.ts`、`model-status-sink.ts` | 会话事件、遥测、工具内部模型请求的状态 |
| `background-task*.ts`、`workflow-*.ts` | 后台任务追踪与工作流展示，分别见[后台任务与通知](https://daiw.org/manual/zcode/background-tasks)和[动态工作流（三）](https://daiw.org/manual/zcode/dwf-tools) |

## 怎么用

执行器没有专门的命令，影响它的旋钮是这几个：

| 旋钮 | 默认 | 出处 |
| --- | --- | --- |
| 配置 `toolConcurrency.maxConcurrency`，或环境变量 `ZCODE_MAX_TOOL_CONCURRENCY` | 一组最多并发 10 个 | `apps/zcode-cli/packages/contracts/src/config/index.ts:343`、`apps/zcode-cli/packages/adapters/src/config/env-config.adapter.ts:56` |
| 环境变量 `BASH_DEFAULT_TIMEOUT_MS`、`BASH_MAX_TIMEOUT_MS` | 120000、600000 毫秒 | `apps/zcode-cli/packages/core/src/tool/bash-timeout-policy.ts:6`、`bash-timeout-policy.ts:18` |
| 调用入参 `timeout`（Bash）、`timeout_ms`（`js`） | 只有声明 `allowCallOverride` 的工具认 | `apps/zcode-cli/packages/core/src/tool/executor/timeout.ts:196` |
| PreToolUse、PermissionRequest、PostToolUse、PostToolUseFailure 钩子 | 默认关闭 | [生命周期 Hooks 与工作区信任](https://daiw.org/manual/zcode/hooks) |

在界面上看到的是它发出的事件：工具行从“排队”到“运行”到“完成”或“失败”，需要审批时弹出确认，大输出只显示预览并给出落盘路径。

## 一批调用怎样分组

一次模型回复里可能有多个工具调用。`scheduleTools` 先从注册表取每个调用的旗标（`tools.ts:42`）：依赖一律为空，副作用范围优先取 `permission.sideEffectScope`；`readOnly` 只有在声明只读**且**范围为 `none` 时才算真，所以声明了只读、范围却是 `session` 的 TodoWrite、Agent 在调度眼里不算只读。然后交给 `ToolScheduler`，判定能否并发的规则在 `apps/zcode-cli/packages/core/src/tool/scheduler.ts:85`：

```ts
  private canRunInParallel(tool: ToolDependency): boolean {
    const hasToolName = typeof tool.toolName === "string" && tool.toolName.length > 0;
    const hasSafetyMetadata =
      tool.readOnly !== undefined ||
      tool.destructive !== undefined ||
      tool.concurrentSafe !== undefined ||
      tool.sideEffectScope !== undefined;

    if (!hasToolName && !hasSafetyMetadata) {
      return true;
    }

    const readOnly = tool.readOnly ?? (hasToolName ? this.readOnlyTools.has(tool.toolName!) : false);
    if (tool.destructive) return false;
    if (tool.concurrentSafe === true) return true;
    if (tool.concurrentSafe === false) return false;
    if (readOnly) return true;
    return tool.sideEffectScope === "none";
  }
```

内置工具都声明了 `concurrentSafe`，所以实际规则只剩两条：破坏性的一律串行，其余看 `concurrentSafe`。后面两个兜底分支只对注册表里查不到的名字起作用：名字在一张写死的只读名单里就并发（`scheduler.ts:233`），否则串行。

分组保持模型给出的顺序（`scheduler.ts:154`）：连续的可并发调用攒成一组，满 10 个另起一组；遇到一个不可并发的调用，先把手里的组交出去，它自己单独成组。模型依次调用 `Read a`、`Read b`、`Edit c`、`WebFetch d`、`Bash e`、`Read f`，得到五组：`[a, b]`、`[c]`、`[d]`、`[e]`、`[f]`。调度器还带着拓扑排序和环检测，但运行时从不填依赖（`tools.ts:49`），这套机制目前闲置。

按这条规则，40 个内置条目里有 13 个总是独占一组：Write、Edit、Bash、TodoWrite、CronCreate、CronUpdate、CronDelete（唯一声明了破坏性的内置工具）、OffPeakCreate、EnterPlanMode、ExitPlanMode、`submit_result`、`js`、SaveWorkflow。其余都可以并发，包括 Agent：一次派出的多个子 Agent 会同时跑。MCP 工具在注解声明只读或幂等时可并发，声明破坏性时串行。

`executeToolSchedule` 逐组执行，组内 `Promise.all`（`apps/zcode-cli/packages/core/src/tool/executor/batch-runner.ts:56`）。任何结果带了“停止回合”的控制信号，后面各组都不再执行，每个调用拿到一条合成的取消结果：“Tool cancelled because a previous tool result requested a turn stop.”（`batch-runner.ts:16`、`batch-runner.ts:92`）。旧版本还会在前一组的非并发安全工具失败后跳过后续所有组，注释说这会让 TodoWrite 之类的本地失败误截断后面的 Agent 调用，已经改成继续执行、各自由权限与 handler 决定结果（`batch-runner.ts:130`）。

<Callout type="warn">
  `executeToolSchedule` 把每组交给 `executeBatch` 时只转发了 `automationTurn`，没有转发 `offPeakTurn`（`batch-runner.ts:80`），而回合里的工具恰好都走这条路径。回合过滤仍会在闲时执行轮把 OffPeakCreate、SendMessage 挡在清单之外，但 Bash 拒绝 `run_in_background` 只靠这个标记（`apps/zcode-cli/packages/core/src/tool/handlers/bash.ts:139`）。从代码看，这道拒绝在闲时执行轮里不会生效。
</Callout>

流式输出期间还有一条旁路：只读、并发安全、非破坏、无需审批、无需用户交互且范围为 `none` 的调用，会在模型还在输出时就单独调度执行（`apps/zcode-cli/packages/core/src/runtime/methods/streaming-tool-coordinator.ts:339`）；它同样经过 `executeTools`，走的是同一条流水线。何时触发、怎样合并结果，见[一次模型请求](https://daiw.org/manual/zcode/model-step)。

## 单个调用的流水线

`executeToolCall` 是一次调用的全部生命周期（`apps/zcode-cli/packages/core/src/tool/executor/call-runner.ts:65`）：

```mermaid
flowchart TD
  L["按名查注册表，别名归一"] -->|"查不到"| NF["ToolNotFound 结果"]
  L --> N["按模型投影契约，归一化入参"]
  N --> V["JSON Schema 准入校验"]
  V -->|"失败"| VE["InputValidationError"]
  V --> SV["validateInput 与 resolveInput"]
  SV -->|"业务失败"| BF["tool_use_error 结果"]
  SV --> PRE["PreToolUse 钩子"]
  PRE -->|"拒绝"| DN["PermissionDenied 结果"]
  PRE --> PM["权限判定"]
  PM -->|"deny"| DN
  PM -->|"ask"| AP["审批：broker 与 PermissionRequest 钩子竞速"]
  AP -->|"拒绝"| DN
  AP --> ST["ToolCallStarted，开始计时"]
  PM -->|"allow"| ST
  ST --> H["handler：超时与取消"]
  H --> OK["输出校验、序列化、PostToolUse、display"]
  OK --> R["ToolCallResult"]
  H -->|"抛错、超时、取消"| F["PostToolUseFailure、错误结果、ToolCallError"]
```

逐段看：

| 阶段 | 做什么 | 出处 |
| --- | --- | --- |
| 查找 | 别名换成规范名；空名或查不到时直接返回错误并补发 `ToolCallError`，否则界面上的工具行会一直停在“输入中” | `call-runner.ts:121` |
| 投影与归一化 | 用当前模型投影契约（校验与模型看到的是同一份 schema），JSON 字符串入参先解析，再跑 zod | `call-runner.ts:101`、`call-runner.ts:166` |
| 准入校验 | JSON Schema 不通过就返回 `InputValidationError`，格式见[上一篇](https://daiw.org/manual/zcode/tool-contract) | `call-runner.ts:172` |
| 工具自校验与解析 | `validateInput` 做语义校验，`resolveInput` 把入参换成执行事实；都在钩子之前，免得先弹一次注定失败的确认窗 | `call-runner.ts:187`、`call-runner.ts:207` |
| PreToolUse | 钩子可以拒绝、要求确认、改写入参或追加上下文；改写后的入参重新校验 | `call-runner.ts:232`、`call-runner.ts:268` |
| 权限 | 权限服务判定 allow、deny、ask；ask 时发出审批请求并等待 | `call-runner.ts:289` |
| 开始 | 发 `ToolCallStarted`，带上按执行入参重算的只读与副作用范围 | `call-runner.ts:329` |
| 执行 | 建执行上下文，带超时跑 handler；handler 以返回值表达的业务失败转成异常，走统一的失败出口 | `call-runner.ts:442`、`call-runner.ts:451` |
| 收尾 | 校验输出，序列化，跑 PostToolUse，追加钩子上下文，生成 display，挂回合控制，发 `ToolCallResult`，再交给后台任务追踪 | `call-runner.ts:456` 至 `call-runner.ts:543` |

`resolveInput` 的位置是刻意的。注释列了三条理由：此后钩子、项目权限规则、权限事件载荷、审批预览和 handler 读到的都是同一份归一化输入，策略不会被绕开，跨客户端版本可见，确认的内容与执行的内容逐字节相同，不存在“批准 A 跑 B”（`apps/zcode-cli/packages/core/src/tool/types.ts:308`）。

**权限在流水线里的位置**。判定细节归[权限模式与规则](https://daiw.org/manual/zcode/permission)，这里只看顺序（`apps/zcode-cli/packages/core/src/tool/executor/permission-flow.ts:41`）：先由权限服务结合项目规则给出结论，再叠加 PreToolUse 的决定，再过一道记忆文件的特殊规则（`permission-flow.ts:86`、`permission-flow.ts:93`）。PreToolUse 的 allow 可以免掉一次普通确认，但抹不掉 `alwaysAsk` 声明出来的确认；它的 ask 则能把 allow 升级成确认（`apps/zcode-cli/packages/core/src/tool/executor/hook-flow.ts:201`、`hook-flow.ts:219`）。结论是 ask 时，工具自己的 `prepareApproval` 只能把它收窄成放行或补一张预览，`prepareApproval` 抛错时照样询问、只是没有预览，不会变成静默放行（`apps/zcode-cli/packages/core/src/tool/executor/approval-gate.ts:61`）。随后发出 `PermissionRequested`，让客户端的应答与 PermissionRequest 钩子链竞速，先到者生效（`permission-flow.ts:184`）。注释记下了原来串行等待的后果：同步钩子阻塞期间确认窗已经渲染，应答却还没登记，用户每次点击都被静默丢弃，“确认窗永久死亡”（`permission-flow.ts:179`）。没有注入交互端口时执行器用默认的拒绝 broker，理由是 “No permission client configured”（`impl.ts:26`、`apps/zcode-cli/packages/core/src/permission/broker.ts:28`）。

## 超时与取消

计时从权限通过之后才开始（`call-runner.ts:348`）：

```ts
  const timeoutMs = resolveTimeoutMs(entry, executionInput, deps.defaultTimeoutMs, {
    model,
  });
  const executionAbortController = new AbortController();
  const unlinkParentAbort = linkAbortSignal(options?.signal, executionAbortController);
  // 可暂停的 deadline：本次调用内部的模型请求在准入闸门前排队时暂停计时。排队的两端
  // 以本 toolCallId 的 ModelNetworkStatus 会话事件到达，所以在事件出口拦一层即可，handler 无感。
  const deadline = new ToolDeadline(timeoutMs);
  const emitEvent =
    deps.emitEvent === undefined
      ? undefined
      : async (event: SessionEvent): Promise<void> => {
          observeToolAdmissionClock(event, canonicalToolCall.id, deadline);
          await deps.emitEvent(event);
        };
```

所以等用户审批的时间不算进工具超时。审批本身有没有时限，取决于 `permissionTimeoutMs`：这个字段在整个仓库里没有赋值处，core 层的审批等待因此不计时，只会随回合中止而结束（`broker.ts:101`）。

超时时长由 `resolveTimeoutMs` 决定（`timeout.ts:181`）：

```ts
export function resolveTimeoutMs(
  entry: ToolEntry,
  input: unknown,
  defaultTimeoutMs: number,
  context?: ToolExecutionModelContext,
): number | undefined {
  const policy = entry.timeout;
  if (policy?.kind === "none") {
    return undefined;
  }

  const defaultMs = policy?.defaultMs ?? entry.metadata.timeoutMs ?? defaultTimeoutMs;
  const entryResolvedMs = entry.resolveTimeoutBudgetMs?.(input, context);
  const requestedMs =
    entryResolvedMs ??
    (policy?.allowCallOverride && isRecord(input) && typeof input.timeout_ms === "number"
      ? input.timeout_ms
      : policy?.allowCallOverride && isRecord(input) && typeof input.timeout === "number"
        ? input.timeout
        : defaultMs);
  const cappedMs = policy?.maxMs === undefined ? requestedMs : Math.min(requestedMs, policy.maxMs);
  const cleanupGraceMs = Math.max(0, Math.trunc(policy?.cleanupGraceMs ?? 0));
  return Math.max(1, Math.trunc(cappedMs)) + cleanupGraceMs;
}
```

优先级是：`kind: "none"` 不计时；工具自己的 `resolveTimeoutBudgetMs`；允许覆盖时读入参的 `timeout_ms` 或 `timeout`；契约的 `defaultMs`；`metadata.timeoutMs`；最后才是执行器默认的 300000 毫秒（`impl.ts:32`）。结果不超过 `maxMs`，再加上清理宽限。宽限是给工具自己的超时和适配层清理留的余量：Bash 的命令超时由它自己执行，执行器的看门狗晚 6 秒才动手。常见工具的数字：

| 工具 | 默认 | 上限 | 调用覆盖 | 清理宽限 | 出处 |
| --- | --- | --- | --- | --- | --- |
| Bash | 120000 | 600000 | `timeout` | 6000 | `bash.ts:493` |
| `js` | 30000 | 120000 | `timeout_ms` | 2000 | `apps/zcode-cli/packages/core/src/tool/handlers/node-repl.ts:399` |
| Read | 30000；读 PDF 指定页时 150000 | 150000 | 否 | 无 | `apps/zcode-cli/packages/core/src/tool/handlers/read.ts:510`、`apps/zcode-cli/packages/core/src/tool/handlers/read-pdf.ts:51` |
| WebFetch | 60000 | 60000 | 否 | 无 | `apps/zcode-cli/packages/core/src/tool/handlers/webfetch.ts:239` |
| WebSearch | 60000 | 120000 | 声明允许，但入参没有超时字段 | 无 | `apps/zcode-cli/packages/contracts/src/tools/websearch.ts:147` |
| MCP 工具 | 服务描述里的 `timeoutMs`，缺省 30000 | 无 | 否 | 无 | `apps/zcode-cli/packages/core/src/mcp/index.ts:124` |
| Agent、TaskOutput、`submit_result`、`escalate` | 不计时 | | | | `apps/zcode-cli/packages/core/src/tool/handlers/agent.ts:267` 等 |

其余内置工具多是 30000 毫秒，SendMessage、RespondToCoordinator、TaskStop 为 10000，ReadSessionContext 为 300000，EvalWorkflowSnippet 为 660000。

**可暂停的 deadline**。工具内部也会发模型请求（WebSearch 本身就是一次模型调用，WebFetch 要让模型回答问题），这些请求要在进程级的准入闸门前排队。`ToolDeadline` 在排队期间暂停计时，拿到放行再续，剩余时长守恒；注释说超时守的是“provider 挂了”，不是“我们自己的队列长”，否则限流时 WebSearch、WebFetch 会被逐个逼成超时，模型再补搜，越限流越吵（`timeout.ts:13`）。暂停与恢复的信号来自带本调用 ID 的 `ModelNetworkStatus` 事件（`timeout.ts:81`）。为了让每个工具内部请求都有这条事件，执行器交给 handler 的模型会套一层默认的状态出口；原先只有 WebSearch 自己设了，实测 18 次 WebFetch 排队 20 到 45 秒后按 60 秒超时被取消，错误里记的排队时长却是 0（`apps/zcode-cli/packages/core/src/tool/executor/model-status-sink.ts:14`）。超时错误的上下文里会带上累计排队时长 `queuedMs`，用来区分“慢”和“等”。

**取消**。每次调用有自己的 `AbortController`，与回合的中止信号相连（`timeout.ts:206`）。调用开始前回合已中止，直接返回取消结果（`call-runner.ts:157`）；handler 运行中被中止，`executeWithTimeout` 立即以工具声明的 `cancellation.userVisibleMessage` 结束（`timeout.ts:152`）；超时则先用超时错误中止 handler 的信号，再以 `Tool execution timed out after <毫秒数>ms` 结束（`timeout.ts:133`）。执行器不会等 handler 真正退出，清理靠 handler 响应中止信号自己完成，契约里的 `cleanup` 级别只被记进错误上下文，并不强制。取消和超时都会触发 PostToolUseFailure 钩子，前者带 `isInterrupt: true`（`hook-flow.ts:177`）。

## 错误怎样变成给模型的结果

失败一律收口成 `success: false` 的 `ToolExecutionResult`，由 `createErrorResult` 组装（`apps/zcode-cli/packages/core/src/tool/executor/errors.ts:6`）。它只为两类错误生成专门写给模型的 `modelContent`：首次参数校验失败，和工具以返回值表达的业务失败（包成 `<tool_use_error>`）。其余错误给模型的就是投影后的错误消息：沿 `cause` 链最多展开 12 层，取第一条不属于包装层的消息，空白压缩成单个空格，最长 500 个字符（`apps/zcode-cli/packages/core/src/errors/error-payload.ts:216`、`error-payload.ts:363`）。空工具名、回合控制和钩子上下文则是之后另外补进结果的。常见情形：

| 情形 | 模型看到的内容 | 出处 |
| --- | --- | --- |
| 工具不存在 | `Tool not found: <name>` | `call-runner.ts:128` |
| 首次参数校验失败 | `<tool_use_error>InputValidationError: ...</tool_use_error>` | `apps/zcode-cli/packages/core/src/tool/executor/validation.ts:71` |
| 工具自校验、解析或 handler 的业务失败 | `<tool_use_error>` 包着的 message，错误码另存 | `errors.ts:20` |
| PreToolUse 拒绝 | 钩子给的理由，缺省为 `Blocked by PreToolUse hook` | `call-runner.ts:246` |
| 权限拒绝 | 权限服务或审批给出的理由 | `permission-flow.ts:128` |
| 超时、取消 | `Tool execution timed out after ...ms`、工具的取消提示语 | `timeout.ts:137`、`timeout.ts:157` |
| 输出不符合 schema | `Tool output failed runtimeOutputSchema validation`，不可恢复 | `validation.ts:15` |
| 前一个结果要求停止回合 | 合成的取消结果 | `batch-runner.ts:16` |

写进历史时，失败结果会被标成错误结果；成功结果若输出里有 `isError`，或是被打断的 Bash，也按错误处理（`apps/zcode-cli/packages/core/src/runtime/helpers/tool-result.ts:67`）。钩子追加的上下文以 `[Hook additional context]` 开头、逐条编号接在结果后面（`hook-flow.ts:234`），失败结果和提前拒绝的结果也会带上，以免钩子明明给了诊断、模型却看不到（`call-runner.ts:688`）。

少数结果还会改变回合走向，由 `turn-control.ts` 统一挂上：

| 情形 | 效果 | 出处 |
| --- | --- | --- |
| `stopTurnOnSuccess` 的工具成功（`submit_result`） | 本回合结束 | `apps/zcode-cli/packages/core/src/tool/executor/turn-control.ts:152` |
| Plan 模式下 ExitPlanMode 被拒且没有修改意见 | 本回合结束，等用户继续讨论 | `turn-control.ts:44` |
| ExitPlanMode 或工作流确认被拒并附了修改意见 | 给模型一句“未获批准”，意见作为真实用户消息引导进当前回合 | `turn-control.ts:60`、`turn-control.ts:107` |
| CronCreate 撞上全局 20 个任务的上限 | 换成固定提示，取消同批后续调用，回合只剩一次纯文本收尾 | `turn-control.ts:14`、`turn-control.ts:20` |

## 大结果：预算、截断与落盘

AGENTS.md 要求“大体积 tool 结果不应直接回灌模型上下文”（`apps/zcode-cli/AGENTS.md:65`），落实在 `serializeOutput`（`apps/zcode-cli/packages/core/src/tool/executor/result-serialization.ts:46`）。它先用工具的 `formatModelContent` 把输出变成模型内容；空输出换成 `(<工具名> completed with no output)`，免得模型把静默成功误读成结果缺失（`result-serialization.ts:57`）。之后按预算分三路：

1. 字节数不超过 `maxModelBytes` 与 `maxInlineBytes` 中较小的那个（`result-serialization.ts:78`），原样交给模型。
2. 超限且策略为 `artifact`：全文写进 artifact，模型只拿到一个信封，里面是原始大小、落盘路径和前 2000 个字符的预览（`apps/zcode-cli/packages/core/src/tool/result-persistence-format.ts:5`）。工具可以用 `formatPersistedModelContent` 换成自己的写法，Bash 和 TaskOutput 就是这样。
3. 策略为 `truncate`，或落盘失败、没有 artifact 存储：按预览方向保留开头或结尾，再接一行 `[Tool output truncated by resultBudget: ...]` 说明原始字节数和上限（`result-serialization.ts:200`）。落盘失败不会把一次成功的调用变成失败（`result-serialization.ts:322`）。

通用信封的格式（`result-persistence-format.ts:29`）：

```ts
export function formatPersistedOutputEnvelope(input: PersistedOutputEnvelopeInput): string {
  const preview = previewFirstChars(input.content, input.previewChars);
  return [
    PERSISTED_OUTPUT_OPEN_TAG,
    `Output too large (${input.formatBytes(input.originalBytes)}). Full output saved to: ${input.persistedPath}`,
    "",
    `Preview (first ${input.formatBytes(input.previewChars)}):`,
    preview.preview,
    preview.hasMore ? "..." : undefined,
    PERSISTED_OUTPUT_CLOSE_TAG,
  ]
    .filter((line): line is string => line !== undefined)
    .join("\n");
}
```

预览在 2000 个字符内找最后一个换行，换行落在后半段才在那里截断（`result-persistence-format.ts:50`）。

各工具的预算：

| 工具 | 给模型的上限（字节） | 超限策略 | 预览保留 |
| --- | --- | --- | --- |
| 未声明预算的条目 | 100000 | 截断 | 开头 |
| Bash | 30000 | 落盘 | 结尾 |
| Read | 262144 | 截断 | 开头 |
| Write、Edit | 100000 | 截断 | 开头 |
| WebFetch、Glob | 100000 | 落盘 | 开头 |
| Grep | 20000 | 落盘 | 开头 |
| WebSearch | 10000 | 截断 | 开头 |
| Agent | 120000 | 落盘 | 开头 |
| `js` | 65536 | 落盘 | 结尾 |
| TaskOutput、GetWorkflowRun | 400000，或超过 100000 个字符 | 落盘 | 开头 |
| 普通 MCP 工具 | 50000 | 截断 | 开头 |

数字分别来自 `result-serialization.ts:33`、`bash.ts:480`、`read.ts:501`、`apps/zcode-cli/packages/core/src/tool/handlers/edit.ts:285`、`webfetch.ts:226`、`apps/zcode-cli/packages/core/src/tool/handlers/grep.ts:170`、`websearch.ts:138`、`agent.ts:254`、`node-repl.ts:392`、`apps/zcode-cli/packages/core/src/tool/handlers/task-output.ts:31` 与 `mcp/index.ts:145`。WebSearch 声明给模型 20000 字节，但它的 `maxInlineBytes` 只有 10000，取小者后实际上限是 10000。官方 Computer Use 的截图帧另有保护：图片块与紧随其后的坐标引用必须原样保留、不得截断或重排，文本仍受 256 KiB 的预算约束（`result-serialization.ts:101`）。

**落盘到哪里**。artifact 存储由 bootstrap 创建，根目录是存储目录下的 `cli/artifacts`（`apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:354`），存储目录默认 `~/.zcode`（`apps/zcode-cli/packages/contracts/src/config/index.ts:302`）。每个会话一个子目录，文件名是调用 ID 加 `tool-result-` 前缀的 UUID，对外的 URI 形如 `zcode-artifact://<会话>/<artifact ID>`（`apps/zcode-cli/packages/adapters/src/storage/index.ts:68`）。契约里的 `retention`（`session`、`project`、`temporary`）会随写入请求传下去，但 Node 版存储并不读它，一律写进会话目录。成功结果里的图片、视频等媒体块在写入会话库之前也会各自落成 artifact，库里的工具记录只存附件引用（`apps/zcode-cli/packages/core/src/runtime/helpers/tool-result-media-persistence.ts:22`）。

## 结果的两个去向：模型与界面

同一个结果在执行器里投影两次。给模型的是 `modelContent`：经过预算、落盘信封和钩子上下文，回合方法把它作为工具结果写进下一次请求。给界面的是 `ToolCallResult` 事件（`apps/zcode-cli/packages/core/src/tool/executor/events.ts:51`），载荷包括序列化后的文本、display、性能数据，以及是否截断、原始与实际字节数、预算策略、落盘路径。

display 是一个判别联合，共 16 种（`apps/zcode-cli/packages/contracts/src/tools/tool-result-metadata.ts:234`），由 `createToolResultDisplay` 按工具名分派（`apps/zcode-cli/packages/core/src/tool/executor/result-display.ts:94`）：Bash 的 `bash_output`、Write 与 Edit 的 `file_diff`、MCP 的 `mcp_tool`、Computer Use 的 `cua`、`node_repl` 的截图，子 Agent 与后台任务的四种，以及工作流相关的七种（含 ListModels）。display 不经过结果预算，每种自己限长：`file_diff` 最多 8 个 hunk、160 行（`result-display.ts:37`），`bash_output` 最多 150000 字节（`apps/zcode-cli/packages/core/src/tool/executor/bash-result-display.ts:16`），而且只在 Bash 输出被截断或落盘时才生成，因为给模型的信封会把正文再缩一遍，还藏起了截断的事实。

结果随后由回合方法写进会话：成功的工具记录保存文本输出和 display、序列化信息等元数据；失败记录除了面向界面的错误消息，还另存一份模型当时真正收到的 `modelContent`，冷恢复时才能精确重放（`apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:334`）。事件怎样投影成消息与 part，见[会话事件流与持久化投影](https://daiw.org/manual/zcode/session-events)。

## 执行事件与遥测

一次调用在会话事件流里留下的痕迹：

| 事件 | 何时发 | 出处 |
| --- | --- | --- |
| `ToolCallScheduled` | 调度后每个调用一条，带分组下标与整批的分组 | `tools.ts:124` |
| `PermissionRequested`、`PermissionResolved`、`PermissionDenied` | 权限流程 | `events.ts:136`、`events.ts:172`、`events.ts:197` |
| `ToolCallStarted` | 权限通过、handler 之前，带重算后的只读与副作用范围 | `events.ts:20` |
| `ToolCallProgress` | 由 handler 自己发，比如 Bash 的增量输出 | `bash.ts:369` |
| `ModelNetworkStatus` | 工具内部模型请求的排队、放行等状态 | `model-status-sink.ts:21` |
| `ToolCallResult`、`ToolCallError` | 成功或失败收口；查找失败与校验失败也会发 `ToolCallError` | `events.ts:51`、`events.ts:89` |
| `ToolBatchComplete` | 每组结束，带成功与失败计数 | `tools.ts:104` |
| `CheckpointCreated` | 改文件的工具成功后记录工作区检查点，见[检查点、回退与分叉](https://daiw.org/manual/zcode/rewind-fork) | `tools.ts:169` |

`ToolCallStarted` 带上重算后的旗标是有用意的：动态工作流的 driver 据此在 handler 写下第一个字节之前就知道“这一笔要改工作区”，及时关掉导入缓存（`call-runner.ts:327`）。

遥测方面，每次调用开一个工具 span（`apps/zcode-cli/packages/core/src/tool/executor/telemetry.ts:5`）。模型编出来的未注册工具名不进远端 trace，统一记成 `unknown`，业务错误里仍保留真名供模型自修（`call-runner.ts:77`）。span 记录权限结论（无需、批准、拒绝）、输出字节数与是否截断，失败时记下阶段（查找、校验、权限、handler、序列化、PostToolUse）和错误类别（`call-runner.ts:668`）。执行器还汇总两项性能数据：从查找到 PostToolUse 的总耗时 `totalMs`，以及等审批的 `permissionWaitMs`（`call-runner.ts:489`）；它们随 `ToolCallResult` 进本地存储，不进模型可见的工具输出（`apps/zcode-cli/packages/core/src/tool/types.ts:411`）。日志事件是 `tool.call.started`、`tool.call.completed`、`tool.call.failed` 与 `tool.call.not_found`。遥测的采集与上报见[遥测、调试与提示词轨迹](https://daiw.org/manual/zcode/telemetry-debug)。

下一篇：[读、写、改、搜](https://daiw.org/manual/zcode/file-tools)——Read、Write、Edit 与搜索的实现：先读后写的约束、编辑匹配策略、文件状态跟踪与多媒体读取。
