跳转到内容

Tool Engineering 源码导读

内容状态
已校验
校验日期
2026/10/1

完成本阶段后,不需要逐行阅读源码,也应当能够:

  • 从模型生成 Tool Call 追踪到 Tool Result 回到下一轮 Context;
  • 区分模型工具协议、AgentTool 与产品层 ToolDefinition;
  • 解释参数预处理、schema 校验和前后置 hook 的顺序;
  • 解释并行工具为什么有两种事件顺序;
  • 解释工具异常为什么会变成错误 Tool Result;
  • 说明 read、edit、write、bash 的核心实现;
  • 解释文件 mutation queue 如何避免并发覆盖;
  • 解释不同工具为什么选择 head 或 tail 截断;
  • 说明 Pi 把哪些安全机制放在 Core,哪些策略留给宿主。

本阶段放大阶段 2 中的这一段:

Assistant Tool Call
→ 参数准备与校验
→ Tool 执行
→ Tool Result
→ 下一次模型调用

Tool 不只是一个可被模型调用的函数,而是一份跨越模型、运行时和产品 UI 的协议。

模型层
name + description + JSON schema
↓
Agent Runtime
参数校验 + hook + 调度 + abort + 事件 + ToolResult
↓
coding-agent 产品层
文件/Shell 实现 + cwd + ExtensionContext + TUI 渲染 + 输出治理

完整流水线是:

ToolDefinition
→ wrapToolDefinition()
→ AgentTool
→ 发送 name/description/schema 给模型
→ 模型流式生成 toolCall
→ Assistant Message 完成
→ prepareArguments()
→ validateToolArguments()
→ beforeToolCall()
→ execute()
→ onUpdate()
→ afterToolCall()
→ AgentToolResult
→ ToolResultMessage
→ 加入 Context
→ 下一轮模型读取结果
flowchart LR
    Provider["模型 Provider"]
    Assistant["AssistantMessage<br/>toolCall blocks"]
    Loop["pi-agent-core<br/>executeToolCalls()"]
    Hooks["beforeToolCall / afterToolCall"]
    Tool["coding-agent Tool<br/>read / edit / bash / ..."]
    Events["Tool Events"]
    Result["ToolResultMessage"]
    Context["下一轮 Context"]
    UI["TUI / JSON / RPC"]

    Provider --> Assistant
    Assistant --> Loop
    Loop --> Hooks
    Hooks --> Tool
    Tool --> Events
    Events --> UI
    Tool --> Result
    Result --> Context
    Context --> Provider

职责边界:

层 负责 不负责
pi-ai 模型可理解的 Tool schema、Tool Call 和 Tool Result 消息协议 执行本地工具
pi-agent-core 校验、hook、执行调度、事件、错误转换、结果回灌 文件系统和 Shell 的产品实现
coding-agent 内置工具、cwd、路径、渲染、输出截断、文件并发一致性 通用 Agent Loop

模型真正需要的只有:

name
description
parameters schema

模型据此生成:

{
"id": "call-1",
"name": "read",
"arguments": {
"path": "src/app.ts"
}
}

模型不知道 JavaScript execute() 函数,也不知道 TUI renderer。

定义位置:packages/agent/src/types.ts

它是通用 Agent Runtime 需要的执行接口:

AgentTool
├─ name
├─ label
├─ description
├─ parameters
├─ prepareArguments?
├─ executionMode?
├─ replay?(never / safe,重放意图)
└─ execute(toolCallId, params, signal, onUpdate)

AgentToolResult 定义在 types.ts:

AgentToolResult
├─ content
├─ details
├─ usage?
└─ terminate?

字段含义:

字段 用途
content 主要的模型可读文本或图片
details UI、日志和宿主使用的结构化元数据
usage 工具自身的可选 usage,不并入主模型 usage
terminate 提示 Loop 是否跳过 Tool 批次后的自动模型调用

当前工具声明增减由 Loop 的 declareToolChanges() 生成 SystemMessage 的 toolsAdded / toolsRemoved;不再从 ToolResult 的 addedToolNames 推断。replay 是重放策略声明,普通 Loop 的执行流程不会因此自动获得副作用去重保证。

定义位置:packages/coding-agent/src/core/extensions/types.ts

它是 coding-agent 产品层的完整定义。在 AgentTool 基础上增加:

promptSnippet
promptGuidelines
renderShell
renderCall()
renderResult()
ExtensionContext
renderer state

这些字段分别服务于:

  • System Prompt 中的工具介绍;
  • 对模型的工具使用建议;
  • TUI 中如何展示调用和结果;
  • Extension 如何访问 cwd、Session、Model 和 abort 等产品上下文。

pi-agent-core 不应该依赖 TUI 和 coding-agent 的 Extension 类型,因此只认识 AgentTool。

适配器:

  • wrapToolDefinition():packages/coding-agent/src/core/tools/tool-definition-wrapper.ts

它保留执行相关字段,去掉产品渲染字段:

ToolDefinition
→ name / label / description / schema
→ prepareArguments / executionMode / execute
→ AgentTool

AgentSession 仍保留原始 ToolDefinition registry,用于 Prompt 和 UI;Agent 只拿到适配后的 AgentTool[]。

入口:packages/coding-agent/src/core/tools/index.ts

当前 registry 的工具名称:

read
bash
powershell
edit
write
grep
find
ls

常见组合:

Factory 工具集合 用途
createCodingTools() read、bash、edit、write 默认 coding 能力
createReadOnlyTools() read、grep、find、ls 只读探索
createAllTools() 全部八种(含 powershell) 完整 registry

每个工具都有两个 factory:

createReadToolDefinition()
→ 产品层 ToolDefinition
createReadTool()
→ wrapToolDefinition(...)
→ AgentTool

这不是重复实现,而是为不同宿主提供不同粒度:

  • AgentSession 需要完整定义;
  • 直接使用 Agent 的 SDK 调用者只需要 AgentTool。

新版把 TUI 渲染拆入 core/tools/renderers/read.ts、edit.ts、write.ts、bash.ts 等;工具本体仍保留 execute。回查输出展示时应跟随 import,而不是在执行文件里寻找旧 renderCall/renderResult 行号。

// read.ts / edit.ts / write.ts 的共同形态(源码缩写)
return {
name, parameters,
constrainedSampling: { type: "json_schema", strict: "prefer" },
execute: async (...) => { /* 实际 I/O */ },
...readRenderers, // 不同工具使用自己的 renderer
};

strict: "prefer" 是声明侧采样偏好,不取消 Loop 参数校验。Extension 注册工具还要求 parameters 为非 null 对象,不能用数组替代 schema。

假设模型返回:

{
"id": "call-1",
"name": "read",
"arguments": {
"path": "src/app.ts",
"offset": 1,
"limit": 100
}
}

Provider 可能产生:

toolcall_start
toolcall_delta
toolcall_delta
toolcall_end

这些事件用于更新 partial Assistant Message 和 UI。此时参数可能是不完整 JSON,不能执行。

只有完整 Assistant Message 返回后,runLoop() 才从:

assistantMessage.content

提取所有 type === "toolCall" 的 block。

关键逻辑:

  • runLoop():packages/agent/src/agent-loop.ts
  • Tool Call 提取和调度:agent-loop.ts

入口:

  • executeToolCalls():agent-loop.ts

判断规则:

如果 config.toolExecution === "sequential"
OR
本批次任一 Tool.executionMode === "sequential"
→ 整批串行
否则
→ 整批并行

一个必须串行的工具会让整个 Assistant Message 中的 Tool 批次串行,避免它与其他工具交错执行。

每个调用首先产生:

tool_execution_start {
toolCallId,
toolName,
args
}

Agent.processEvents() 收到后,将 id 加入:

AgentState.pendingToolCalls

UI 因而可以立即显示“read 正在执行”。

入口:

  • prepareToolCall():agent-loop.ts

顺序:

按 name 查找 AgentTool
→ prepareArguments(raw arguments)
→ validateToolArguments(tool schema)
→ beforeToolCall(validated args)

不存在的 Tool、参数校验失败和 hook block 都会在此变成立即错误结果,不调用 execute()。

入口:

  • executePreparedToolCall():agent-loop.ts

调用:

tool.execute(
toolCallId,
validatedArgs,
AbortSignal,
onUpdate
)

执行期间 onUpdate(partialResult) 会转换为(工具提供的 partial update,不代表完成):

tool_execution_update

工具完成后返回 AgentToolResult。

入口:

  • finalizeExecutedToolCall():agent-loop.ts

afterToolCall 可以覆盖:

  • content;
  • details;
  • usage;
  • isError;
  • terminate。

覆盖是字段级替换,不做深度合并。

tool_execution_end
→ 从 pendingToolCalls 删除 id
→ createToolResultMessage()
→ message_start(toolResult)
→ message_end(toolResult)

关键逻辑:

  • createToolResultMessage():agent-loop.ts
  • emitToolResultMessage():agent-loop.ts

最终消息类似:

{
"role": "toolResult",
"toolCallId": "call-1",
"toolName": "read",
"content": [
{
"type": "text",
"text": "..."
}
],
"isError": false,
"timestamp": 0
}

所有 Tool Result 按 Tool Call 源顺序加入:

currentContext.messages
newMessages

下一轮模型收到:

user
assistant(toolCall)
toolResult

模型由此读取真实执行结果并作出下一次决策。

六、参数工程:为什么不能直接调用 execute(rawArgs)

Section titled “六、参数工程:为什么不能直接调用 execute(rawArgs)”

模型输出是不可信的结构化数据,可能出现:

  • JSON 类型错误;
  • 缺少必填字段;
  • 多出兼容旧版本的字段;
  • 某些模型把数组错误地编码成 JSON string;
  • 参数内容不满足工具自身的业务约束。

Pi 将处理拆成三层:

raw arguments
→ prepareArguments:兼容性修正
→ schema validation:结构校验
→ execute 内业务校验:语义校验

它是 schema 校验之前的兼容 shim,不是随意修复所有错误的地方。

edit 的实现:

  • prepareEditArguments():packages/coding-agent/src/core/tools/edit.ts

它处理两类真实 Provider 偏差:

  1. 将旧格式:
{
"path": "a.ts",
"oldText": "before",
"newText": "after"
}

转换为:

{
"path": "a.ts",
"edits": [
{
"oldText": "before",
"newText": "after"
}
]
}
  1. 某些模型把 edits 数组输出成 JSON 字符串时,尝试解析。

公共 schema 仍然只暴露新格式,兼容逻辑不会污染模型看到的 API。

validateToolArguments() 使用 Tool 的 TypeBox schema 验证准备后的参数。

例如 readSchema 要求:

path: string
offset?: number
limit?: number

如果模型传入:

{ "path": 123 }

工具不会开始读文件,而是生成错误 Tool Result。

Schema 只能证明“edits 是数组”,不能表达所有业务约束。

validateEditInput() 进一步要求:

edits.length > 0

后续 applyEditsToNormalizedContent() 还会检查:

  • oldText 不能为空;
  • oldText 必须能匹配;
  • 每个 oldText 必须唯一;
  • 多个 edit 不得重叠;
  • 替换后必须真的发生变化。

一个重要边界:hook 后不重新校验

Section titled “一个重要边界:hook 后不重新校验”

beforeToolCall 收到的是已经验证的 args。如果 hook 原地修改这个对象,Loop 不会再次 schema 校验。

这意味着:

beforeToolCall 属于可信宿主代码边界,Extension 必须为自己对参数的修改负责。

七、before/after hook:机制与策略的接口

Section titled “七、before/after hook:机制与策略的接口”

调用时机:

prepareArguments
→ schema validation
→ beforeToolCall
→ execute

它可以:

  • 观察 tool name、id 和参数;
  • 记录审计日志;
  • 根据策略阻止执行;
  • 在可信边界内修改参数对象。

阻止执行:

return {
block: true,
reason: "Command is not allowed"
};

Loop 会生成错误 Tool Result,模型能够看到拒绝原因。

调用时机:

execute 成功或失败
→ afterToolCall
→ tool_execution_end
→ ToolResultMessage

它可以:

  • 脱敏输出;
  • 添加审计元数据;
  • 将结果标记为 error;
  • 修改模型能够看到的 content;
  • 设置 terminate。

如果 after hook 自身抛出异常,最终结果会被替换为该异常对应的错误 Tool Result。

Pi 为什么不把权限策略写死在 Agent Core

Section titled “Pi 为什么不把权限策略写死在 Agent Core”

Agent Core 提供:

  • 参数 schema;
  • beforeToolCall;
  • afterToolCall;
  • AbortSignal;
  • Tool registry;
  • 生命周期事件。

但不决定:

  • 哪条 Bash 命令危险;
  • 哪个目录可读写;
  • 是否弹出确认框;
  • 是否必须运行在容器;
  • 是否允许网络访问。

这些选择依赖具体宿主,因此属于 policy。Pi 可以通过 Extension、沙箱或外部运行环境实现,而不是让通用 Loop 固定一种安全模型。

八、工具错误为什么要变成 Tool Result

Section titled “八、工具错误为什么要变成 Tool Result”

工具执行链将以下失败转换为:

AgentToolResult
→ ToolResultMessage(isError=true)

在 Core 的工具执行路径中,包括:

  • Tool 不存在;
  • 参数准备或 schema 校验失败;
  • before hook block;
  • execute() 抛出异常;
  • after hook 抛出异常;
  • 工具执行收到 abort 或抛出 aborted error。

Provider 返回 error/aborted stopReason 则是 Assistant turn 的终止路径,不会先变成普通 ToolResult。

原因是这些错误通常可以被模型恢复。

例如:

模型:read("missing.ts")
工具:ENOENT
模型看到错误 Tool Result
模型:先使用 find 查找正确文件

如果 Tool 抛错直接终止整个 Agent Run,模型就失去了自我修正的机会。

工具不必自己构造 ToolResultMessage;可以抛异常,也可以返回带明确错误标记的 AgentToolResult:

成功 → return AgentToolResult
失败 → throw Error 或 return { content, details, isError: true }

通用 Loop 统一完成:

  • Error 文本转换;
  • isError 标记;
  • tool_execution_end;
  • ToolResult Message;
  • 后续模型恢复。

这避免每个工具重复实现失败协议。

入口:

  • executeToolCallsSequential():agent-loop.ts

每个 Tool Call 完整完成后才处理下一个:

tool A: prepare → execute → finalize → result message
tool B: prepare → execute → finalize → result message

适合:

  • 有顺序依赖的操作;
  • 共享不可安全并发的资源;
  • Tool 明确声明 executionMode="sequential"。

入口:

  • executeToolCallsParallel():agent-loop.ts

真实逻辑不是简单的 Promise.all(tool.execute):

按源顺序逐个:
emit tool_execution_start
prepare + validate + before hook
若可执行,登记异步执行任务
若立即失败,立刻 finalize
然后:
并发运行登记的 execute
每个完成时 emit tool_execution_end
最后:
按 Assistant Tool Call 源顺序
emit ToolResult Message

先理解 finalizedCalls 中保存的两种东西

Section titled “先理解 finalizedCalls 中保存的两种东西”

源码定义:

type FinalizedToolCallEntry =
| FinalizedToolCallOutcome
| (() => Promise<FinalizedToolCallOutcome>);

因此数组元素可能是:

立即结果对象
= 工具已经在准备阶段失败或被 block
异步函数
= 工具已通过准备阶段,但 execute 还没有开始

这里保存的是函数:

finalizedCalls.push(async () => {
const executed = await executePreparedToolCall(...);
const finalized = await finalizeExecutedToolCall(...);
await emitToolExecutionEnd(finalized, emit);
return finalized;
});

push() 这个函数时不会执行函数体。它等价于先写下一张“稍后执行这个工具”的任务卡:

finalizedCalls.push(任务卡 A)

而不是:

finalizedCalls.push(正在运行的 Promise A)

这就是“登记异步执行任务”的准确含义。

第一阶段:按模型顺序做 preflight

Section titled “第一阶段:按模型顺序做 preflight”

假设 Assistant Message 依次包含:

A = slow read
B = 参数非法的 edit
C = fast read

第一个 for (const toolCall of toolCalls) 按 A、B、C 执行:

A:
emit start(A)
prepare A 成功
finalizedCalls.push(任务卡 A)
A.execute 尚未开始
B:
emit start(B)
schema validation 失败
emit end(B, error)
finalizedCalls.push(错误结果 B)
C:
emit start(C)
prepare C 成功
finalizedCalls.push(任务卡 C)
C.execute 尚未开始

此时数组保持 Assistant 源顺序:

finalizedCalls = [
任务卡 A,
错误结果 B,
任务卡 C
]

为什么 preflight 要按顺序 await?

  • before hook 的审计和阻止顺序是确定的;
  • 所有工具先完成 schema 与 policy 检查;
  • tool_execution_start 顺序与 Assistant Tool Call 顺序一致;
  • 发现 abort 时可以停止继续登记后面的调用。

第二阶段:Promise.all 真正启动工具

Section titled “第二阶段:Promise.all 真正启动工具”

关键源码:

const orderedFinalizedCalls = await Promise.all(
finalizedCalls.map((entry) =>
typeof entry === "function"
? entry()
: Promise.resolve(entry)
)
);

map() 执行时:

任务卡 A → 调用 entry() → A.execute 开始
错误结果 B → Promise.resolve(B),不执行工具
任务卡 C → 调用 entry() → C.execute 开始

A 和 C 因此并发运行。

假设 C 更快,实际完成事件可能是:

前面 preflight 已经产生:end(B, error)
然后并发执行产生: end(C)
最后产生: end(A)

tool_execution_end 按实际完成时间发出,让 UI 能立即显示已经结束的工具。

第三阶段:为什么 orderedFinalizedCalls 仍是 A、B、C

Section titled “第三阶段:为什么 orderedFinalizedCalls 仍是 A、B、C”

Promise.all() 的返回数组按输入 Promise 的位置排列,不按 Promise 完成时间排列。

输入位置: [Promise A, Promise B, Promise C]
完成时间: C → A
返回数组: [result A, result B, result C]

所以:

orderedFinalizedCalls = [
最终结果 A,
错误结果 B,
最终结果 C
]

最后一个 for 才创建并发送 ToolResult Message:

for (const finalized of orderedFinalizedCalls) {
const message = createToolResultMessage(finalized);
await emitToolResultMessage(message, emit);
messages.push(message);
}

最终进入 Context 的顺序稳定为:

toolResult A
toolResult B
toolResult C

注意 ToolResult Message 要等整个并行批次全部完成后才统一发送。tool_execution_end 是实时执行事件,message_end(toolResult) 才表示结果正式进入 Agent 消息历史。

第一遍 for:
按 A、B、C 做安检,只登记可执行任务,不启动 execute。
Promise.all:
同时执行通过安检的 A、C,谁先完成谁先发 execution_end。
第二遍 for:
等全部结束后,仍按 A、B、C 生成 ToolResult Message。

假设模型依次生成 A、B,但 B 更快:

Assistant source order: A, B
Completion order: B, A

实时事件:

tool_execution_end(B)
tool_execution_end(A)

这样 UI 可以及时展示 B 已完成。

消息历史:

toolResult(A)
toolResult(B)

这样 Context 顺序始终与 Assistant Message 的 Tool Call 顺序一致,不受机器调度和网络速度影响。

总结:

completion-order 用于实时观察,source-order 用于确定性消息历史。

steering 为什么不能插入并行批次

Section titled “steering 为什么不能插入并行批次”

即使用户在 B 完成后发出 steering,也必须等 A 完成并生成完整 Tool Result 批次:

assistant(tool A, tool B)
toolResult A
toolResult B
user steering

否则会破坏 Tool Call 与结果的协议连续性。

例如把 steering 插进两个结果之间:

assistant(tool A, tool B)
toolResult A
user steering
toolResult B

会产生三层后果:

  • Provider 协议层:部分 Provider 要求一条 Assistant Message 发出的全部 Tool Call 紧接着获得匹配结果;不完整或被其他消息切开的批次可能导致下一次模型请求被拒绝;
  • 模型推理层:模型可能先看到新的用户指令,却还没有看到 Tool B 的观察结果,从而基于不完整状态继续决策;
  • Session 与适配层:steering 究竟属于未完成的当前 Turn,还是下一 Turn,会产生歧义,增加恢复、重放、压缩和跨 Provider 消息转换的复杂度。

因此这里的“连续性”不是要求工具同时完成,而是要求同一条 Assistant Message 请求的整批 Tool Result 先闭环,再插入新的用户消息并开始下一 Turn。

AgentToolResult.terminate 是运行时提示:

这个 Tool Result 是否还需要自动调用模型处理?

判断位置:

  • shouldTerminateToolBatch():agent-loop.ts

条件:

至少存在一个 finalized call
AND
每个 result.terminate === true

为什么不是“任意一个为 true 就终止”?

假设一个批次:

tool A → terminate=true
tool B → terminate=false

B 的结果仍然需要模型读取。如果因为 A 而停止,B 的语义就被丢失。

所以只有整个批次一致表示“不需要模型继续”,Loop 才跳过自动下一轮。

问题入口:

  • withFileMutationQueue():packages/coding-agent/src/core/tools/file-mutation-queue.ts

Agent 默认可以并行执行多个 Tool Call。若两个 edit 同时修改同一文件:

Edit A 读取原文件
Edit B 读取同一个原文件
Edit A 写入结果 A
Edit B 写入结果 B

结果 B 会覆盖结果 A,形成 lost update。

Mutation queue 将同一文件的修改串行化:

File A:
edit 1 → edit 2 → write 3
File B:
write 1 → edit 2

不同文件仍然可以并行。

不同 Tool 同时修改同一文件会怎样

Section titled “不同 Tool 同时修改同一文件会怎样”

先区分两层:

Agent Loop:
决定多个 Tool Call 可以同时启动
具体 Tool:
决定访问同一资源时是否需要再次排队

Pi 的 edit 和 write 都调用同一个:

withFileMutationQueue(absolutePath, ...)

因此模型一次返回:

toolCall A = edit("app.ts")
toolCall B = write("app.ts")

虽然 executeToolCallsParallel() 会启动 A 和 B,但进入文件操作后会变成:

并行调度层:
A ──开始
B ──开始
文件 mutation queue:
A 取得 app.ts 的 queue
→ A 完整完成读/改/写
→ B 才取得同一个 queue
→ B 开始写入

所以“并行启动”不等于“同时写同一个文件”。

原文件:
alpha
beta
edit A:alpha → ALPHA
edit B:beta → BETA

queue 使它们依次执行:

edit A 读取原文件并写入 ALPHA
→ edit B 重新读取 A 修改后的文件
→ edit B 写入 BETA

最终两个修改都能保留:

ALPHA
BETA
edit A:alpha → ALPHA
edit B:alpha → Alpha

A 先完成后,B 再读取文件时已经找不到原来的 alpha。B 会失败并生成错误 Tool Result,而不是静默覆盖 A。

这得益于:

  • queue 保证 B 在 A 完成后重新读取;
  • edit 要求 oldText 在执行时仍然存在且唯一。

queue 只能保证执行顺序,不能自动合并业务语义:

edit A:修改 app.ts 一行
write B:完整覆盖 app.ts

如果 B 排在 A 后面,B 的完整写入仍会覆盖 A 的修改。这个结果是确定的,但不一定符合模型原意。

因此 mutation queue 解决的是:

防止两个底层文件操作交错产生不可预测的 lost update。

它不解决:

两个 Tool Call 在业务语义上是否互相冲突。

后者需要模型避免发出矛盾调用,或由 beforeToolCall、自定义调度策略进一步限制。

组合 是否自动协调
edit + edit,同一文件 是
edit + write,同一文件 是
write + write,同一文件 是
不同文件的 edit/write 不排队,可并行
read + edit read 不进入 mutation queue,读取时机可能在修改前或修改后
bash 修改文件 + edit/write 否,Bash 内部文件操作无法被 queue 感知
Extension Tool 修改文件 只有它主动使用同一 helper 或其他锁时才协调

如果某个自定义工具会修改无法安全并发的共享资源,可以:

  • 声明 executionMode: "sequential",让整个 Tool 批次串行;
  • 在 Tool 内部使用按资源 key 的 mutex/queue;
  • 通过 before hook 阻止冲突调用;
  • 把操作放进具备事务或锁能力的外部系统。

file-mutation-queue.ts 是 Pi 自己实现的轻量级进程内队列,不依赖第三方 mutex,也不是操作系统文件锁。核心是一张 Map:

const fileMutationQueues = new Map<string, Promise<void>>();

这里的 queue key 是“文件身份标识”。Map 用它判断两个操作是否针对同一个真实文件:

fileMutationQueues
"/project/src/a.ts" → a.ts 前面所有修改组成的 Promise 链
"/project/src/b.ts" → b.ts 前面所有修改组成的 Promise 链

新的 edit/write 进入时:

计算目标文件的 queue key
→ 用 key 查找该文件已有的修改队列
→ 相同 key:排在前一个操作后面
→ 不同 key:使用另一条队列,可以并行

例如:

edit("src/a.ts") ─┐
write("./src/a.ts") ├→ 同一个 key → edit 完成后再 write
edit("src/b.ts") ──┘ 不同 key → 可以与 a.ts 的操作并行

因此,queue key 的作用不是给 Tool Call 编号,而是把指向同一文件的不同路径写法归入同一条修改队列。

getMutationQueueKey():

resolve(filePath)
→ 如果文件存在:realpath()
→ 如果文件不存在:使用 resolved absolute path

对已存在文件使用 realpath(),使:

target.txt
alias.txt → target.txt

共享同一个 queue,避免通过 symlink 绕过串行化。

不能直接使用模型传入的原始 path,因为下面三种写法可能指向同一个文件:

src/a.ts
./src/a.ts
/project/src/a.ts

resolve() 先把相对路径和绝对路径统一;文件存在时,realpath() 再把符号链接解析为真实目标。文件尚不存在时无法调用 realpath(),所以退回到规范化后的绝对路径。

可以把每个文件的 Map value 理解为“前面所有修改何时完成”:

currentQueue = fileMutationQueues.get(key)
nextQueue = 新建一个等待 release 的 Promise
chainedQueue = currentQueue.then(() => nextQueue)
Map[key] = chainedQueue
await currentQueue
执行 fn()
finally:
releaseNext()
若自己是最后节点,删除 Map entry

registrationQueue 又保证“解析 key 并登记队列节点”本身按顺序发生,防止两个并发调用同时看到空队列。

这里的“锁”不是操作系统提供的文件锁,而是 withFileMutationQueue 用 Promise 维护的“同一个文件只能有一个修改流程占用”的队列位置。Node 文件系统 Promise 通常不能真正取消:AbortSignal 可以通知工具尽快停止后续步骤,但不能保证已经交给 writeFile() 的操作立刻撤销。

错误做法:

write A 已经进入底层文件系统
→ 收到 abort
→ 立即 reject 并释放 queue
→ write B 开始
→ 尚未停止的 write A 最后完成并覆盖 B

也就是说,abort 并不等于“底层写入瞬间消失”。如果 A 的 writeFile() 尚未 settle,就释放队列让 B 进入,A 和 B 的完成顺序可能反过来,最后完成的 A 反而会覆盖 B。

edit 和 write 工具的实现选择:

开始每个文件操作前检查 `signal.aborted`
→ 等待当前文件操作真正 settle
→ 操作完成后再次检查 `signal.aborted`
→ 然后才离开 withFileMutationQueue

因此,如果 abort 发生在写入过程中,当前写入可能仍然完成;随后工具检查到 abort,返回 aborted 错误,并在 finally 中释放 queue。下一个写入只能在前一个文件操作真正结束后开始,不会与它交错。文件一致性优先于“立即释放”,这是 edit/write 工具配合 helper 的结果,不是 mutation queue 自身监听 abort。

它保证的是:

  • 同一 Node 进程;
  • 使用该 helper 的 edit/write;
  • 同一路径或已解析到同一 realpath 的路径。

它不是:

  • 跨进程文件锁;
  • 数据库事务;
  • 操作系统级强制锁;
  • 对 Bash 内部文件修改的拦截。

例如 Bash 同时执行 sed -i,不会自动加入 mutation queue。

定义入口:

  • createReadToolDefinition():packages/coding-agent/src/core/tools/read.ts
  • execute():read.ts

Schema:

path: string
offset?: number // 1-indexed
limit?: number
检查 AbortSignal
→ resolveReadPathAsync(path, cwd)
→ 检查文件可读
→ 检测真实图片 MIME
→ 图片路径或文本路径

resolveReadPathAsync():

  • 相对路径基于 Session cwd;
  • 支持绝对路径;
  • 处理 ~ 和 @ 前缀;
  • 尝试 macOS screenshot 空格形式;
  • 尝试 NFD Unicode 文件名;
  • 尝试弯引号文件名。

这属于易用性兼容,不是目录沙箱。最终访问权限仍由操作系统和宿主策略决定。

根据文件内容检测 MIME,而不是只看扩展名
→ 读取 Buffer
→ processImage()
→ 可选自动缩放,优先采用 ctx.model.inputLimits.images.resize
→ 没有模型配置时使用 ReadToolOptions.resizeOptions 回退
→ 返回 text note + image content

如果当前 Model 不支持 image:

  • Tool 仍识别文件;
  • 返回说明文本;
  • 提示图片会从请求中省略。
Buffer → UTF-8 string
→ 根据 offset 定位
→ 根据用户 limit 预切片
→ truncateHead()
→ 添加可继续读取的 next offset

默认限制:

最多 2000 行
最多 50KB
先达到哪个限制就按哪个截断

为什么保留开头?

文件开头通常包含:

  • import;
  • 类型和类声明;
  • 配置头;
  • 模块结构。

对于大文件,模型可以根据提示继续:

[Showing lines 1-2000 of 2500. Use offset=2001 to continue.]

truncateHead() 不返回半行,因为半截代码或 JSON 行可能误导模型。

如果第一行本身超过限制,Tool 返回可执行的 Bash fallback:

sed -n '<line>p' file | head -c 51200

正常文本:

content = [{ type: "text", text: outputText }]
details = undefined 或 { truncation }

content 给模型使用,details.truncation 给 UI 展示精确截断信息。

定义入口:

  • createEditToolDefinition():packages/coding-agent/src/core/tools/edit.ts
  • execute():edit.ts
  • 核心替换算法:packages/coding-agent/src/core/tools/edit-diff.ts

Schema:

path: string
edits: Array<{
oldText: string
newText: string
}>

相较于让模型提供行号,oldText/newText:

  • 不易受前面代码增删导致的行号漂移影响;
  • 可以检测目标是否仍然存在;
  • 可以检测目标是否唯一;
  • 能在写入前生成 diff;
  • 能拒绝歧义修改。
prepareEditArguments()
→ schema validation
→ validateEditInput()
→ resolveToCwd()
→ withFileMutationQueue()
→ 检查 abort
→ 检查文件可读写
→ 读取原始 Buffer
→ stripBom()
→ detectLineEnding()
→ normalizeToLF()
→ applyEditsToNormalizedContent()
→ restoreLineEndings()
→ 写入文件
→ 生成 display diff 和 unified patch
→ 返回成功文本 + EditToolDetails

假设:

edit[0]: A → B
edit[1]: B → C

第二个 oldText=B 不会匹配第一个修改产生的新 B,因为所有 edit 都在原始内容上定位。

原因:

  • 结果不依赖 edits 数组的执行顺序;
  • 可以在写入前验证全部修改;
  • 任一 edit 失败时,不会部分应用其他 edit;
  • 多个替换可以按位置逆序应用,保持 offset 稳定。

每个 oldText 必须:

  • 非空;
  • 存在;
  • 只出现一次。

多个 edit 的匹配区间不能重叠:

previous.start + previous.length > current.start
→ reject

否则两个 newText 对同一原始字符的所有权不明确。

公开工具说明要求精确匹配,但实现为了处理模型常见的文本差异提供受控 fallback。

fuzzyFindText():edit-diff.ts

顺序:

先 content.indexOf(oldText)
→ 成功则使用完全精确匹配
失败后 normalizeForFuzzyMatch()
→ NFKC
→ 去除每行尾部空白
→ 智能引号转 ASCII
→ Unicode dash 转 "-"
→ 特殊空格转普通空格

Fuzzy 不是语义相似度,也不是让模型“差不多匹配”。它只处理确定性的 Unicode 和空白兼容问题。

如果使用 fuzzy 匹配,只重写真正触及的行;未修改行继续保留原始 bytes,避免整份文件被意外格式化。

写入前记录:

  • UTF-8 BOM;
  • 原始换行符是 LF 还是 CRLF。

替换在 LF-normalized 空间完成,最后恢复:

BOM + original line endings

因此修改一行不会顺便把整个 Windows 文件转换成 LF。

content:
"Successfully replaced N block(s)"
details:
diff
patch
firstChangedLine

其中:

  • diff 面向 TUI 阅读;
  • patch 是标准 unified patch,可验证可应用性;
  • firstChangedLine 用于编辑器定位。

TUI 还可以在 Tool 真正执行前通过 computeEditsDiff() 计算 preview。Preview 只用于显示,真正执行仍会重新读取和验证文件。

定义入口:

  • createWriteToolDefinition():packages/coding-agent/src/core/tools/write.ts
  • execute():write.ts

Schema:

path: string
content: string

主流程:

resolveToCwd(path)
→ withFileMutationQueue(path)
→ 检查 abort
→ mkdir(parent, recursive=true)
→ 检查 abort
→ writeFile(content)
→ 检查 abort
→ 返回 Successfully wrote to <path> 成功信息

write 与 edit 共享同一个 mutation queue,因此:

edit(file A)
write(file A)

不会并发写入。

职责区别:

edit write
精确替换现有文件的一部分 创建或完整覆盖文件
验证 oldText 唯一性 不读取旧内容
返回 diff 和 patch 返回写入成功信息
适合局部修改 适合新文件或完整重写

定义入口:

  • createBashToolDefinition():packages/coding-agent/src/core/tools/bash.ts
  • execute():bash.ts
  • 本地进程后端:createLocalBashOperations():bash.ts

Schema:

command: string
timeout?: number

BashTool 不直接固定 Node spawn,而是依赖 BashOperations:

Bash Tool
→ BashOperations.exec()
├─ local shell
└─ Extension 可替换为 SSH、容器或远端执行

这与 read/edit/write 的 Operations 接口一致,使工具协议与实际 I/O 后端解耦。

应用 commandPrefix
→ resolveSpawnContext()
├─ command
├─ cwd
├─ env
└─ spawnHook
→ 创建 OutputAccumulator
→ ops.exec()
├─ 解析 shell
├─ spawn child
├─ stdout/stderr → onData
├─ timeout → killProcessTree
├─ abort → killProcessTree
└─ 等待进程结束
→ finish output
→ 根据 exit code 返回或抛错

resolveSpawnContext() 先复制 Shell 环境,并删除旧的:

PI_SESSION_ID
PI_SESSION_FILE
PI_PROVIDER
PI_MODEL
PI_REASONING_LEVEL

如果允许暴露 Session 环境,再从当前 ExtensionContext 写入最新值。

这样避免继承父 Pi 进程中过期的 Session 信息。

两者都连接到同一个 onData:

child.stdout.on("data", onData)
child.stderr.on("data", onData)

模型看到的是按到达顺序合并的输出,而不是两个独立通道。

Chatty command 可能每秒产生数千个 chunk。如果每个 chunk 都刷新 TUI:

  • 事件数量暴涨;
  • 渲染成为性能瓶颈;
  • listener settlement 变慢。

Bash 使用约 100ms 的节流窗口:

onData
→ append OutputAccumulator
→ 标记 updateDirty
→ 最多每 100ms snapshot
→ onUpdate(partialResult)
→ tool_execution_update

最终完成时会强制 flush 最后一份 update。

新版默认 BashOperations 把信号终止转换为 128 + signal number;没有正常 exit code 也没有信号时按 1 处理。自定义 operations 若返回 null,执行器会抛出“Command terminated without an exit code”,不再把缺失退出码当成功。

exitCode === 0
→ 返回正常结果
exitCode !== 0
→ 返回 isError: true,保留 content/details/structuredContent
timeout
→ kill process tree
→ 抛出包含已有输出的 timeout Error
abort
→ kill process tree
→ 抛出包含已有输出的 aborted Error

通用 Agent Loop 再把异常转换成错误 Tool Result。模型既能看到失败原因,也能看到失败前已经产生的命令输出。

bash 可以执行任意 Shell 命令。Agent Core 本身不提供命令 allowlist 或确认弹窗。

安全策略需要由:

  • beforeToolCall;
  • Extension;
  • 容器或沙箱;
  • 受限用户;
  • 自定义 BashOperations;
  • Project Trust

提供。

工具输出会进入下一轮 Context。无限输出会:

  • 快速耗尽 Context Window;
  • 增加模型成本;
  • 淹没真正有用的信息;
  • 造成 UI 和内存压力。

Pi 使用两个独立限制:

DEFAULT_MAX_LINES = 2000
DEFAULT_MAX_BYTES = 50KB

先达到哪个限制,就按哪个截断。

read 使用:

  • truncateHead():packages/coding-agent/src/core/tools/truncate.ts

保留文件开头,并告诉模型下一个 offset。

bash 使用:

  • truncateTail():truncate.ts

命令尾部通常包含:

  • 最终错误;
  • 测试总结;
  • exit 前日志;
  • 编译失败位置。

因此 Bash 返回最后 2000 行或 50KB。

OutputAccumulator:

  • packages/coding-agent/src/core/tools/output-accumulator.ts

它同时做到:

内存中只保留有限 tail
AND
一旦超过限制,将完整 raw output 写入临时文件

最终结果包含:

content: 截断后的 tail + 提示
details.fullOutputPath: 完整输出路径

模型可以根据需要再使用 read 或 Bash 检查完整日志。

stdout chunk 边界不一定与 Unicode 字符边界一致。例如 € 的三个 bytes 可能分两次到达。

OutputAccumulator 使用 streaming TextDecoder:

chunk 1: 第一个 byte
chunk 2: 后两个 bytes
→ 正确解码为 €

直接对每个 chunk 独立 toString() 可能产生替换字符。

content
→ 模型需要理解的有限输出
details
→ truncation 统计、完整文件路径、diff、patch 等宿主元数据

这让模型上下文保持可控,同时 UI 和日志仍保留丰富信息。

十七、正常、失败、取消和截断路径

Section titled “十七、正常、失败、取消和截断路径”
Assistant toolCall
→ 参数准备
→ schema validation
→ before hook
→ execute
→ after hook
→ tool_execution_end(isError=false)
→ ToolResultMessage(isError=false)
→ 下一轮模型
Assistant toolCall
→ prepare/schema validation 失败
→ 不调用 execute
→ tool_execution_end(isError=true)
→ 错误 ToolResult
→ 模型修正参数
execute throw
→ createErrorToolResult(error.message)
→ after hook 仍可处理
→ ToolResult(isError=true)
→ 模型决定恢复方式
agent.abort()
→ signal.aborted
→ Provider、hook 或 Tool 响应 signal
→ Tool 抛出 aborted
→ 错误 ToolResult
→ 当前 Turn 完成

文件修改工具会等待已开始的底层文件操作 settle 后再释放 mutation queue。

read → 保留 head + continuation offset
bash → 保留 tail + 完整临时文件
→ 有限 ToolResult 进入 Context

如果 Assistant Message 的 stopReason === "length":

不执行该消息中的任何 Tool Call
→ 每个调用生成“参数可能被截断”的错误 ToolResult
→ 下一轮模型重新发出完整调用

这是在 schema validation 之前的批次级安全保护。残缺参数即使恰好合法,也不能执行。

  • Schema 负责结构和基础类型;
  • Tool execute 负责文件存在、唯一匹配等领域约束;
  • 优点是模型 API 清晰,复杂规则仍可提供精确错误;
  • 代价是测试必须同时覆盖两层。
  • 优点:模型可以自我修正;
  • 代价:必须控制错误文本,避免泄漏不应暴露的信息;
  • 宿主可通过 after hook 脱敏。
  • 执行阶段追求低延迟;
  • 消息阶段保持源顺序;
  • UI completion order 与 Context source order有意不同。
  • 同文件串行;
  • 不同文件并行;
  • 保留 Agent 批量修改吞吐量;
  • 不覆盖 Bash 和其他进程,需要宿主进一步隔离。
  • Read 给 continuation offset;
  • Bash 给 full output path;
  • 不仅减少 Context,还告诉模型怎样继续获取信息。

packages/agent/test/agent-loop.test.ts:

测试 证明内容
should handle tool calls and results Tool Call → Tool Result → 下一次模型
prepare tool arguments for validation prepareArguments 发生在 schema validation 前
not execute ... length-truncated 截断 Assistant Tool Call 不会执行
completion order but persist ... source order 并行双顺序
force sequential execution... Tool 可强制整个批次串行
every tool result sets terminate=true terminate 的批次一致性
afterToolCall ... terminating after hook 可以覆盖 terminate

packages/coding-agent/test/tools.test.ts:

测试组 证明内容
read limits/offset head 截断和可继续读取
edit not found/duplicate/overlap 精确修改的安全条件
multi-edit partial failure 所有 edit 先验证,避免部分应用
fuzzy matching 受控 Unicode/空白兼容
CRLF/BOM 保留原始文件格式
bash timeout/abort/nonzero 错误保留已有输出
chatty output update 节流
split UTF-8 streaming decoder 正确性
full output persistence 截断时保留完整日志

packages/coding-agent/test/file-mutation-queue.test.ts:

测试 证明内容
same file 同路径严格串行
different files 不同路径仍并行
symlink aliases realpath 统一 queue key
parallel edits 两个 edit 不发生 lost update
edit and write 不同修改工具共享同一 queue
aborted write/edit 底层操作 settle 前不释放锁

packages/coding-agent/test/edit-tool-legacy-input.test.ts 证明:

  • 旧字段不出现在公开 schema;
  • 旧参数可以折叠进 edits[];
  • JSON string 数组可以被兼容;
  • 准备后的参数能够通过正常 execute。

本文已经给出完整流程。需要核实实现时,按符号跳转,不要顺序通读大文件。

  1. AgentTool / AgentToolResult:packages/agent/src/types.ts
  2. executeToolCalls():packages/agent/src/agent-loop.ts
  3. prepareToolCall():agent-loop.ts
  4. executePreparedToolCall():agent-loop.ts
  5. finalizeExecutedToolCall():agent-loop.ts
  6. createToolResultMessage():agent-loop.ts
  1. ToolDefinition:packages/coding-agent/src/core/extensions/types.ts
  2. wrapToolDefinition():core/tools/tool-definition-wrapper.ts
  3. createCodingToolDefinitions():core/tools/index.ts
  4. createAllTools():core/tools/index.ts
  1. Read:
    • schema:read.ts
    • createReadToolDefinition():read.ts
    • execute():read.ts
  2. Edit:
    • prepareEditArguments():edit.ts
    • execute():edit.ts 中的 ToolDefinition 实现
    • applyEditsToNormalizedContent():edit-diff.ts
  3. Bash:
    • createLocalBashOperations():bash.ts
    • execute():bash.ts
    • OutputAccumulator:output-accumulator.ts
  1. executeToolCallsParallel():agent-loop.ts
  2. withFileMutationQueue():file-mutation-queue.ts
  3. truncateHead() / truncateTail():truncate.ts/168
  4. 对应的三组测试。

行号用于快速定位,后续代码变化时以符号名为准。

概念 A 概念 B 区别
Tool schema Tool implementation 前者给模型描述接口,后者由宿主实际执行
ToolDefinition AgentTool 前者包含 Prompt/UI/Extension 产品能力,后者是通用执行接口
Tool Call Tool Result 前者是模型请求,后者是宿主执行反馈
content details 前者主要给模型,后者主要给宿主和 UI
prepareArguments validation 前者做兼容转换,后者执行 schema 校验
schema validation business validation 前者验证结构,后者验证存在性、唯一性等语义
beforeToolCall afterToolCall 执行前阻止/审计与执行后改写/脱敏
parallel completion order Tool Result source order 前者服务实时 UI,后者保证确定性 Context
Tool throw Agent Run failure Tool throw 通常变成可恢复的错误 Tool Result
read truncation bash truncation read 保留 head,bash 保留 tail
mutation queue 全局文件锁 前者仅协调本进程内使用 helper 的修改
terminate abort 前者是完成 Tool 批次后的停止提示,后者取消当前运行

Pi 的 Tool Pipeline 由模型协议、通用 Agent Runtime 和 coding-agent 产品实现三层组成。模型根据 name、description 和 schema 生成 Tool Call;Agent Core 完成参数预处理、schema 校验、before hook、串并行执行、after hook和错误转换;结果被包装成 ToolResult Message 放回 Context。coding-agent 则实现 read、edit、bash 等具体工具,并处理文件并发、输出截断和 TUI 渲染。

Pi 不把 Tool 当作普通函数,而是把它设计成完整协议。产品层 ToolDefinition 包含模型描述、TypeBox schema、执行函数、Prompt 元数据和 TUI renderer,通过 wrapper 转换成 Core 能理解的 AgentTool。

模型完成 Assistant Message 后,Loop 才提取 Tool Call。每个调用先经过兼容性参数准备和 schema 校验,再经过 before hook;执行过程中可以通过 onUpdate 发送进度,完成后由 after hook修改或脱敏结果。任何可恢复失败都会转换成 isError=true 的 ToolResult Message,让模型在下一轮修正。

多个工具默认并行:完成事件按实际完成顺序发送,但 Tool Result 按 Assistant 源顺序进入 Context。文件 edit/write 额外通过按 realpath 分组的 mutation queue 避免同文件 lost update。输出治理上,read 保留文件 head 并给 continuation offset,bash 保留 tail,同时把完整日志写到临时文件。Core 提供 hook 和 abort 等机制,具体权限、确认和沙箱策略由宿主决定。

为什么 prepareArguments 必须在 validation 前?

Section titled “为什么 prepareArguments 必须在 validation 前?”

它的目的就是把 Provider 的兼容格式转换成公开 schema 格式。先校验会让本可兼容的输入直接失败。

为什么并行工具不按完成顺序写入 Context?

Section titled “为什么并行工具不按完成顺序写入 Context?”

完成顺序由 I/O 时延决定,不稳定。源顺序与 Assistant Tool Call 一一对应,能提供确定且可复现的消息历史。

为什么不直接把所有 Tool 强制串行?

Section titled “为什么不直接把所有 Tool 强制串行?”

read、grep、网络查询等独立操作可以并行,强制串行会显著增加延迟。Pi 只对声明 sequential 的批次和同文件 mutation 做必要串行化。

如果同一文本出现多次,运行时无法证明模型想修改哪一处。拒绝歧义比静默修改错误位置安全。

为什么 Bash 截断后还保存完整输出?

Section titled “为什么 Bash 截断后还保存完整输出?”

模型 Context 需要有界,但调试需要可恢复。截断负责控制成本,临时文件负责保留诊断能力。

尝试不看本文回答:

  1. ToolDefinition 为什么不能直接放进 pi-agent-core?
  2. Tool Call 在什么时机才会真正执行?
  3. prepareArguments、schema validation、business validation 的顺序是什么?
  4. before hook block 后模型能看到什么?
  5. Tool execute() 抛异常为什么通常不结束 Agent Run?
  6. 并行 Tool 的 completion order 和 source order分别服务谁?
  7. 一个 Tool 声明 sequential 为什么会影响整个批次?
  8. steering 为什么不能插入一批 Tool Result 中间?
  9. mutation queue 如何避免两个 edit 丢失修改?
  10. 为什么 abort 后文件 queue 不能立即释放?
  11. Read 和 Bash 为什么采用相反的截断方向?
  12. Edit 如何处理 CRLF、BOM 和 Unicode 文本差异?
  13. content 与 details 的边界是什么?
  14. terminate=true 与 abort 有什么本质区别?
  15. Pi Core 为什么不直接实现固定的命令权限弹窗?
  • 能画出 Tool Call 到 Tool Result 的完整流水线
  • 能解释三种 Tool 表示
  • 能说明参数处理的三层校验
  • 能解释 before/after hook
  • 能解释串行、并行和双顺序
  • 能解释错误 Tool Result 的恢复价值
  • 能说明 mutation queue 的 Promise 链
  • 能复述 read、edit、write、bash 的主流程
  • 能解释 head/tail 截断和完整输出保存
  • 能完成 30 秒和 2 分钟面试表达

本阶段回答:

一个 Tool Call 如何安全、确定地执行并返回结果

下一阶段将回到产品编排层:

AgentSession
→ 如何选择和注册这些 Tools
→ 如何把 Extension hooks 安装到 Agent
→ 如何持久化 Tool Result
→ 如何在 agent_end 后处理 retry、compaction 和 continuation

新增:工具可见性、嵌套调用与结构化结果

Section titled “新增:工具可见性、嵌套调用与结构化结果”

本节接上前面的执行流水线。模型直接调用 bash 与模型调用 codemode 后由脚本调用 bash, 最终都经过相同的参数准备、校验及 before/after hooks;区别在于调用入口和结果进入历史的方式。

// agent-loop.ts:runToolCall 缩写,省略类型和形参展开。
const prepared = await prepareToolCall(context, assistantMessage, call, hooks, signal, tools);
if (prepared.kind === "immediate") return preparedOutcome;
const executed = await executePreparedToolCall(prepared, signal, onUpdate);
return finalizeExecutedToolCall(context, assistantMessage, prepared, executed, hooks, signal);

runToolCall() 自己不发 Agent 事件、不追加消息。coding-agent 的 NestedToolCallRunner 在外围发出带 parentToolCallId 的执行事件,并把调用记录和 usage 汇总到最外层模型 ToolResult;不要把每个嵌套工具 事件理解成新增了一轮模型调用。顺序执行工具也有嵌套队列处理,已持有队列的父调用不会等待自己。

对象 当前含义
registry 全部注册工具,包含暂不可用和隐藏工具
active 当前激活工具;通常声明给模型,prepareLoadout 可进一步隐藏请求声明
callable active direct,以及注册的 codemode/deferred 工具;不含 model-only/hidden
content 最终发送模型的文本/图片结果
structuredContent / outputSchema 面向程序调用方的 JSON 结果与 schema,不自动发送模型
// 正常返回也能明确表示业务失败,而不丢结构化信息。
return {
content: [{ type: "text", text: "查询失败:服务暂不可用" }],
details: { requestId },
structuredContent: { ok: false },
isError: true,
};

after hook 若替换 content 却不提供 structuredContent,旧 structuredContent 会被清除。普通字段省略 仍保持原值,因此不能再笼统说“所有省略字段都保留”。

Bash 也有两种输出预算:模型正文仍使用原先的行数/字节 tail 截断;供 codemode 脚本使用的 structuredContent.output 最多返回 1 MiB,并携带 truncated、exit_code 等字段。完整输出仍可保存在 临时文件。脚本可以先筛选,再把少量有用内容交给模型。

源码回查:agent/src/agent-loop.ts:runToolCall、agent/src/types.ts:AgentToolResult、 coding-agent/src/core/nested-tool-calls.ts、core/extensions/types.ts:ToolExposure、core/tools/bash.ts。