04 默认工具学习记录
本节目标
本节目标是理解 coding agent 的默认工具如何从产品层定义进入 agent loop,以及 read、write、edit、bash 四个核心工具各自的边界。
重点问题:
ToolDefinition和AgentTool为什么分两层。- 默认工具如何注册、过滤、启用,并进入
agent.state.tools。 read如何把文件和图片变成模型可消费的 tool result。write为什么适合新文件或整文件重写。edit为什么要求oldText唯一匹配。bash如何处理流式输出、截断、timeout、abort 和 exit code。- 工具输出如何回填给模型。
我们怎么学的
- 先看
packages/coding-agent/src/core/tools/index.ts,确认默认工具集合。 - 看
AgentSession._buildRuntime()和_refreshToolRegistry(),理解工具注册链路。 - 看
tool-definition-wrapper.ts,区分ToolDefinition与AgentTool。 - 依次拆
read.ts、write.ts、edit.ts、bash.ts的执行路径。 - 回到 agent loop,理解工具 result 如何变成
ToolResultMessage并进入下一轮模型请求。
工具注册链路
默认工具不是直接散落在 loop 里,而是先以 ToolDefinition 的形式进入 coding-agent 的工具注册系统。
createAllToolDefinitions()
-> _baseToolDefinitions
-> _refreshToolRegistry()
-> wrapToolDefinition()
-> _toolRegistry
-> setActiveToolsByName()
-> agent.state.tools
-> runLoop 执行 tool calls
flowchart LR
D[ToolDefinition] --> W[wrapToolDefinition]
W --> A[AgentTool]
A --> S[tools schema 给模型]
S --> C[assistant toolCall]
C --> E[execute]
E --> R[ToolResultMessage]
R --> X[Context.messages]
关键文件:
packages/coding-agent/src/core/tools/index.tspackages/coding-agent/src/core/agent-session.tspackages/coding-agent/src/core/tools/tool-definition-wrapper.ts
工具就是本地函数
给新手的最小理解:
工具 = 暴露给模型选择的本地函数
模型不会自己读文件、写文件或跑 shell。它只会在 assistant message 里生成 toolCall,Pi 再在本地执行对应工具函数。
一个工具最少要讲清四件事:
name:模型在toolCall里使用的稳定工具名。description:告诉模型工具用途和边界。schema:参数结构,决定参数如何校验。execute:真正运行在本地的函数。
闭环:
工具 schema 给模型
-> 模型生成 toolCall
-> Pi 本地 execute
-> 结果包装成 ToolResultMessage
-> 回填 Context.messages
ToolDefinition 与 AgentTool
AgentTool 是 agent core 关心的最小执行接口:
name
description
parameters
prepareArguments
executionMode
execute
它只关心:
- 模型能不能看到这个工具。
- 参数 schema 是什么。
- 参数如何准备。
- 工具如何执行。
- 这一批工具是否需要串行。
ToolDefinition 是 coding-agent 产品层工具定义,信息更多:
label
promptSnippet
promptGuidelines
renderCall
renderResult
sourceInfo
extension context
它服务于:
- TUI 展示。
- system prompt 构建。
- 工具使用指导。
- extension runtime context。
- 工具来源管理。
- registry 展示。
所以两层设计可以记成:
ToolDefinition = 产品层完整工具定义
AgentTool = agent core 执行工具接口
wrap = 把 ToolDefinition 适配成 AgentTool
一句话:
ToolDefinition 给 pi 用,AgentTool 给 agent loop 用。
read
read 的 schema 很小:
{
path: string;
offset?: number;
limit?: number;
}
执行路径:
read.execute()
-> resolveReadPathAsync(path, cwd)
-> ops.access() 检查可读
-> detectImageMimeType()
-> 如果是图片:读 buffer,必要时 resize,返回 text + image
-> 如果是文本:读 buffer,utf-8 解码
-> 按 offset/limit 截取
-> truncateHead() 控制最大 2000 行 / 50KB
-> 返回 { content, details }
read 不只是 fs.readFile。它把本地文件安全地、可分页地、可被模型继续消费地转成 tool result。
适合:
- 看文件内容。
- 分页探索大文件。
- 读取图片让模型观察。
不适合:
- 一次塞入超大文件。
- 替代搜索做全仓库定位。
- 读取不需要进入模型上下文的临时输出。
为什么 read 要 offset/limit
因为模型上下文有限,文件可能很大,不能一次性全塞进去。
可以理解成:
offset/limit = 主动分页
truncateHead = 被动保护
如果文件没读完,截断提示会进入 tool result:
[Showing lines 1-2000 of 5000. Use offset=2001 to continue.]
这不是 UI 装饰,而是让模型下一轮知道可以继续读。
为什么 read 保留 head
文件开头通常更适合理解结构:
- 源码开头有 imports、类型、常量、类/函数定义上下文。
- Markdown 开头有标题、说明、结构。
- 配置文件开头通常有整体结构。
所以 read 使用 truncateHead() 保留开头,并提示下一次 offset。
write
write 的 schema 只有:
{
path: string;
content: string;
}
执行路径:
write.execute()
-> resolveToCwd(path, cwd)
-> dirname(absolutePath)
-> withFileMutationQueue(absolutePath)
-> mkdir(parentDir)
-> writeFile(absolutePath, content)
-> 返回 "Successfully wrote N bytes..."
write 的描述很直接:
Creates the file if it doesn't exist, overwrites if it does.
所以:
write = 全量写入
edit = 精确修改
write 不会读旧内容,不会做 diff,也不会检查是不是只想改一小段。它收到什么 content,就把整个文件变成什么。
因此已有文件的小改用 write 风险大:模型如果漏掉旧文件的一段内容,就等于删掉了。
适合:
- 新建文件。
- 整文件重写。
- 生成完整配置、示例或文档文件。
不适合:
- 已有文件的小改。
- 没读完整旧内容就覆盖。
- 需要保留用户局部编辑的文件。
file mutation queue
write 和 edit 都使用 withFileMutationQueue()。
它解决的是文件资源层的并发写问题:
同一个 absolutePath 的 mutation 串行
不同文件仍可并行
这和 agent loop 的 sequential/parallel 不冲突:
sequential = agent 调度层顺序
fileMutationQueue = 文件资源层锁
即使 agent loop 允许 parallel:
write a.ts
edit a.ts
也会被同一个文件队列排队,避免互相覆盖。
另一个细节是,write 和 edit 不用 abort listener 直接 reject 来释放队列,而是在每个 await 后检查 signal.aborted。这样可以确保底层 filesystem 操作 settle 之后,队列才释放。
edit
edit 是“比 write 更安全的局部修改工具”。
schema:
{
path: string;
edits: [
{
oldText: string;
newText: string;
}
]
}
执行路径:
edit.execute()
-> validateEditInput()
-> resolveToCwd(path, cwd)
-> withFileMutationQueue(path)
-> access(R_OK | W_OK)
-> readFile()
-> stripBom()
-> detectLineEnding()
-> normalizeToLF()
-> applyEditsToNormalizedContent()
-> restoreLineEndings()
-> writeFile()
-> generateDiffString()
-> generateUnifiedPatch()
-> 返回 success + diff/patch
oldText 必须唯一
edit 不会找到第一处就替换,因为那样很容易改错代码。
例如文件中有很多:
return null;
如果模型只给这一行作为 oldText,pi 不知道它想改哪一处。
所以 edit 要求:
找不到:失败
不唯一:失败
重叠:失败
结果没变化:失败
这会逼模型提供更多上下文。
一句话:
edit 宁愿失败,也不要猜模型想改哪一处。
适合:
- 已有文件的精确局部修改。
- 保留文件其他内容不变。
- 需要 diff/patch 核对修改的场景。
不适合:
oldText不唯一。- 同一批 edits 互相重叠。
- 想整文件重写却拆成大量小 edit。
多个 edits 基于原始文件
多个 edits 不是逐个增量匹配:
edit 1 改完
再基于改完后的文件跑 edit 2
而是:
所有 oldText 都先在原始文件中定位
确认不重叠
再从后往前应用
从后往前应用是为了避免前面的替换改变后面文本的 index。
保留文件风格
edit 会:
stripBom()
detectLineEnding()
normalizeToLF()
restoreLineEndings()
内部统一用 LF 做匹配和 diff,写回时恢复原来的换行风格,并保留 BOM。
bash
bash 的核心不是“执行命令”,而是把一个可能长时间运行的命令变成可观察、可截断、可中止的工具执行。
schema:
{
command: string;
timeout?: number;
}
执行路径:
bash.execute()
-> commandPrefix 拼接
-> spawnHook 调整 command/cwd/env
-> OutputAccumulator 开始收集输出
-> ops.exec(command, cwd, { onData, signal, timeout, env })
-> stdout/stderr chunk 进入 output.append()
-> scheduleOutputUpdate()
-> onUpdate({ partial output })
-> 命令结束 finishOutput()
-> exitCode 非 0 则抛错
-> 返回 { content, details }
partial update
bash 和其他工具最大不同是执行中会不断发 partial update:
tool_execution_start
tool_execution_update
tool_execution_update
tool_execution_end
tool_result message
partial update 是给 UI/事件流看的,不是给模型继续推理用的稳定结果。
真正回填给模型的是命令结束后的最终结果。
原因:
- 中间输出还不完整。
- 还不知道 exit code。
- 还不知道是否 timeout 或 abort。
- 还不知道最终错误在哪里。
- 同一个 tool call 只能稳定配对一个最终 tool result。
所以:
partial update = 给 UI/JSON 观察进度
final ToolResultMessage = 给模型继续推理
bash 输出截断
bash 使用 OutputAccumulator。它不会把无限输出都留在内存里:
内存里保留 tail
超过 2000 行 / 50KB 后标记 truncation
完整输出写到 /tmp/pi-bash-xxxx.log
最终 tool result 提示 Full output 路径
bash 更适合保留 tail,因为命令输出尾部通常更重要:
- 测试失败信息通常在最后。
- 编译错误汇总常在最后。
- 日志最新内容在最后。
- 命令最终状态也在最后。
bash 失败会给模型什么
如果 exit code 非 0,bash.execute() 会抛错:
if (exitCode !== 0 && exitCode !== null) {
throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
}
agent loop 捕获异常后,会转成 error tool result:
{
content: [{ type: "text", text: error.message }],
isError: true
}
所以模型看到的不是只有 “bash failed”,而是:
...已有 stdout/stderr 尾部输出...
Command exited with code N
timeout 类似:
...已有输出...
Command timed out after N seconds
abort 类似:
...已有输出...
Command aborted
适合:
- 运行检查命令。
- 观察 stdout/stderr、exit code、timeout。
- 用有边界的短命令收集仓库状态。
不适合:
- 无边界长期命令。
- 高风险删除、重置、清理命令。
- 需要人工交互确认的命令。
工具输出如何回传模型
工具输出回传模型的链路是:
模型生成 assistant toolCall
-> agent loop 执行 tool.execute()
-> tool 返回 AgentToolResult
-> createToolResultMessage()
-> 追加到 currentContext.messages
-> 下一轮 streamAssistantResponse(currentContext)
-> provider 把 toolResult 转成 OpenAI/Anthropic 等 API payload
-> 模型看到工具结果,继续生成
核心点:
tool.execute() 的返回值
不是直接塞给模型
而是先包装成 ToolResultMessage
结构大致是:
{
role: "toolResult",
toolCallId: "call_xxx",
toolName: "read",
content: [{ type: "text", text: "..." }],
isError: false
}
然后这一条消息进入 Context.messages。下一次调用模型时,provider 会把它翻译成对应厂商的 tool result 格式。
顺序与稳定性
一次模型同时调用 read、edit、bash 时,要分两层看。
agent loop 层:
保证一批 tool call 的调度和回填稳定
工具内部层:
保证具体资源操作稳定
如果并行执行完成顺序是:
bash 先结束
read 后结束
edit 最后结束
回填给模型的 ToolResultMessage 仍按 assistant 原始 tool call 顺序:
result A: read
result B: edit
result C: bash
工具自身再保证资源安全:
read:只读,通过路径解析、access、offset/limit、截断提示保证结果可控。edit:进入withFileMutationQueue(path),要求唯一、不重叠,避免误改。bash:partial update 只进事件流,最终结果才进上下文。
一句话:
loop 管 tool call 的生命周期;
tool 管自己资源的安全边界。
本节完成度
本节已经完成:
- 理解
ToolDefinition -> AgentTool的两层设计。 - 理解默认工具如何进入
agent.state.tools。 - 理解
read的分页、图片、截断。 - 理解
write的覆盖写入和 mutation queue。 - 理解
edit的唯一匹配、多 edit、diff/patch。 - 理解
bash的 partial update、输出截断、失败回传。 - 理解工具输出如何回填给模型。
下一节建议进入 Session:
03/04 看的是一次 prompt 怎么跑;
05 看的是多轮对话、历史、恢复和压缩怎么维持。
自测题
- 为什么
ToolDefinition和AgentTool要分层? - 一个工具的四个基本组成是什么?
- 为什么
edit要求oldText唯一? - 为什么
write不适合已有文件的小改? - 为什么
bash失败也要把 stdout/stderr 和 exit code 回传给模型? tool_execution_update和最终ToolResultMessage分别服务谁?
Source: 04-default-tools-notes.md