执行器:调度、审批、超时与结果
一批工具调用怎样按并发安全分组、哪些并行哪些串行;单个调用怎样走完校验、PreToolUse、权限审批、执行、PostToolUse 与结果投影;超时的默认值与取消语义;错误和大结果怎样变成给模型的内容,又怎样投影给界面;执行事件与遥测。
上一篇的契约回答“这个工具是什么”,执行器回答“这一次调用怎么跑”。每个 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和一次模型请求:流式、工具并发与恢复;本篇只讲执行器内部。
执行器对外只有四个方法: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 | 后台任务追踪与工作流展示,分别见后台任务与通知和动态工作流(三) |
怎么用
执行器没有专门的命令,影响它的旋钮是这几个:
| 旋钮 | 默认 | 出处 |
|---|---|---|
配置 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 与工作区信任 |
在界面上看到的是它发出的事件:工具行从“排队”到“运行”到“完成”或“失败”,需要审批时弹出确认,大输出只显示预览并给出落盘路径。
一批调用怎样分组
一次模型回复里可能有多个工具调用。scheduleTools 先从注册表取每个调用的旗标(tools.ts:42):依赖一律为空,副作用范围优先取 permission.sideEffectScope;readOnly 只有在声明只读且范围为 none 时才算真,所以声明了只读、范围却是 session 的 TodoWrite、Agent 在调度眼里不算只读。然后交给 ToolScheduler,判定能否并发的规则在 apps/zcode-cli/packages/core/src/tool/scheduler.ts:85:
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)。
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)。从代码看,这道拒绝在闲时执行轮里不会生效。
流式输出期间还有一条旁路:只读、并发安全、非破坏、无需审批、无需用户交互且范围为 none 的调用,会在模型还在输出时就单独调度执行(apps/zcode-cli/packages/core/src/runtime/methods/streaming-tool-coordinator.ts:339);它同样经过 executeTools,走的是同一条流水线。何时触发、怎样合并结果,见一次模型请求。
单个调用的流水线
executeToolCall 是一次调用的全部生命周期(apps/zcode-cli/packages/core/src/tool/executor/call-runner.ts:65):
逐段看:
| 阶段 | 做什么 | 出处 |
|---|---|---|
| 查找 | 别名换成规范名;空名或查不到时直接返回错误并补发 ToolCallError,否则界面上的工具行会一直停在“输入中” | call-runner.ts:121 |
| 投影与归一化 | 用当前模型投影契约(校验与模型看到的是同一份 schema),JSON 字符串入参先解析,再跑 zod | call-runner.ts:101、call-runner.ts:166 |
| 准入校验 | JSON Schema 不通过就返回 InputValidationError,格式见上一篇 | 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)。
权限在流水线里的位置。判定细节归权限模式与规则,这里只看顺序(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):
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):
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)。之后按预算分三路:
- 字节数不超过
maxModelBytes与maxInlineBytes中较小的那个(result-serialization.ts:78),原样交给模型。 - 超限且策略为
artifact:全文写进 artifact,模型只拿到一个信封,里面是原始大小、落盘路径和前 2000 个字符的预览(apps/zcode-cli/packages/core/src/tool/result-persistence-format.ts:5)。工具可以用formatPersistedModelContent换成自己的写法,Bash 和 TaskOutput 就是这样。 - 策略为
truncate,或落盘失败、没有 artifact 存储:按预览方向保留开头或结尾,再接一行[Tool output truncated by resultBudget: ...]说明原始字节数和上限(result-serialization.ts:200)。落盘失败不会把一次成功的调用变成失败(result-serialization.ts:322)。
通用信封的格式(result-persistence-format.ts:29):
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,见会话事件流与持久化投影。
执行事件与遥测
一次调用在会话事件流里留下的痕迹:
| 事件 | 何时发 | 出处 |
|---|---|---|
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 | 改文件的工具成功后记录工作区检查点,见检查点、回退与分叉 | 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。遥测的采集与上报见遥测、调试与提示词轨迹。
下一篇:读、写、改、搜——Read、Write、Edit 与搜索的实现:先读后写的约束、编辑匹配策略、文件状态跟踪与多媒体读取。