# 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。
- 工具输出如何回填给模型。

## 我们怎么学的

1. 先看 `packages/coding-agent/src/core/tools/index.ts`，确认默认工具集合。
2. 看 `AgentSession._buildRuntime()` 和 `_refreshToolRegistry()`，理解工具注册链路。
3. 看 `tool-definition-wrapper.ts`，区分 `ToolDefinition` 与 `AgentTool`。
4. 依次拆 `read.ts`、`write.ts`、`edit.ts`、`bash.ts` 的执行路径。
5. 回到 agent loop，理解工具 result 如何变成 `ToolResultMessage` 并进入下一轮模型请求。

## 工具注册链路

默认工具不是直接散落在 loop 里，而是先以 `ToolDefinition` 的形式进入 coding-agent 的工具注册系统。

```text
createAllToolDefinitions()
  -> _baseToolDefinitions
  -> _refreshToolRegistry()
  -> wrapToolDefinition()
  -> _toolRegistry
  -> setActiveToolsByName()
  -> agent.state.tools
  -> runLoop 执行 tool calls
```

```mermaid
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.ts`
- `packages/coding-agent/src/core/agent-session.ts`
- `packages/coding-agent/src/core/tools/tool-definition-wrapper.ts`

## 工具就是本地函数

给新手的最小理解：

```text
工具 = 暴露给模型选择的本地函数
```

模型不会自己读文件、写文件或跑 shell。它只会在 assistant message 里生成 `toolCall`，Pi 再在本地执行对应工具函数。

一个工具最少要讲清四件事：

- `name`：模型在 `toolCall` 里使用的稳定工具名。
- `description`：告诉模型工具用途和边界。
- `schema`：参数结构，决定参数如何校验。
- `execute`：真正运行在本地的函数。

闭环：

```text
工具 schema 给模型
-> 模型生成 toolCall
-> Pi 本地 execute
-> 结果包装成 ToolResultMessage
-> 回填 Context.messages
```

## ToolDefinition 与 AgentTool

`AgentTool` 是 agent core 关心的最小执行接口：

```text
name
description
parameters
prepareArguments
executionMode
execute
```

它只关心：

- 模型能不能看到这个工具。
- 参数 schema 是什么。
- 参数如何准备。
- 工具如何执行。
- 这一批工具是否需要串行。

`ToolDefinition` 是 coding-agent 产品层工具定义，信息更多：

```text
label
promptSnippet
promptGuidelines
renderCall
renderResult
sourceInfo
extension context
```

它服务于：

- TUI 展示。
- system prompt 构建。
- 工具使用指导。
- extension runtime context。
- 工具来源管理。
- registry 展示。

所以两层设计可以记成：

```text
ToolDefinition = 产品层完整工具定义
AgentTool      = agent core 执行工具接口
wrap           = 把 ToolDefinition 适配成 AgentTool
```

一句话：

```text
ToolDefinition 给 pi 用，AgentTool 给 agent loop 用。
```

## read

`read` 的 schema 很小：

```ts
{
	path: string;
	offset?: number;
	limit?: number;
}
```

执行路径：

```text
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

因为模型上下文有限，文件可能很大，不能一次性全塞进去。

可以理解成：

```text
offset/limit = 主动分页
truncateHead = 被动保护
```

如果文件没读完，截断提示会进入 tool result：

```text
[Showing lines 1-2000 of 5000. Use offset=2001 to continue.]
```

这不是 UI 装饰，而是让模型下一轮知道可以继续读。

### 为什么 read 保留 head

文件开头通常更适合理解结构：

- 源码开头有 imports、类型、常量、类/函数定义上下文。
- Markdown 开头有标题、说明、结构。
- 配置文件开头通常有整体结构。

所以 `read` 使用 `truncateHead()` 保留开头，并提示下一次 offset。

## write

`write` 的 schema 只有：

```ts
{
	path: string;
	content: string;
}
```

执行路径：

```text
write.execute()
  -> resolveToCwd(path, cwd)
  -> dirname(absolutePath)
  -> withFileMutationQueue(absolutePath)
  -> mkdir(parentDir)
  -> writeFile(absolutePath, content)
  -> 返回 "Successfully wrote N bytes..."
```

`write` 的描述很直接：

```text
Creates the file if it doesn't exist, overwrites if it does.
```

所以：

```text
write = 全量写入
edit  = 精确修改
```

`write` 不会读旧内容，不会做 diff，也不会检查是不是只想改一小段。它收到什么 `content`，就把整个文件变成什么。

因此已有文件的小改用 `write` 风险大：模型如果漏掉旧文件的一段内容，就等于删掉了。

适合：

- 新建文件。
- 整文件重写。
- 生成完整配置、示例或文档文件。

不适合：

- 已有文件的小改。
- 没读完整旧内容就覆盖。
- 需要保留用户局部编辑的文件。

## file mutation queue

`write` 和 `edit` 都使用 `withFileMutationQueue()`。

它解决的是文件资源层的并发写问题：

```text
同一个 absolutePath 的 mutation 串行
不同文件仍可并行
```

这和 agent loop 的 sequential/parallel 不冲突：

```text
sequential = agent 调度层顺序
fileMutationQueue = 文件资源层锁
```

即使 agent loop 允许 parallel：

```text
write a.ts
edit a.ts
```

也会被同一个文件队列排队，避免互相覆盖。

另一个细节是，`write` 和 `edit` 不用 abort listener 直接 reject 来释放队列，而是在每个 `await` 后检查 `signal.aborted`。这样可以确保底层 filesystem 操作 settle 之后，队列才释放。

## edit

`edit` 是“比 write 更安全的局部修改工具”。

schema：

```ts
{
	path: string;
	edits: [
		{
			oldText: string;
			newText: string;
		}
	]
}
```

执行路径：

```text
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` 不会找到第一处就替换，因为那样很容易改错代码。

例如文件中有很多：

```ts
return null;
```

如果模型只给这一行作为 `oldText`，pi 不知道它想改哪一处。

所以 `edit` 要求：

```text
找不到：失败
不唯一：失败
重叠：失败
结果没变化：失败
```

这会逼模型提供更多上下文。

一句话：

```text
edit 宁愿失败，也不要猜模型想改哪一处。
```

适合：

- 已有文件的精确局部修改。
- 保留文件其他内容不变。
- 需要 diff/patch 核对修改的场景。

不适合：

- `oldText` 不唯一。
- 同一批 edits 互相重叠。
- 想整文件重写却拆成大量小 edit。

### 多个 edits 基于原始文件

多个 edits 不是逐个增量匹配：

```text
edit 1 改完
再基于改完后的文件跑 edit 2
```

而是：

```text
所有 oldText 都先在原始文件中定位
确认不重叠
再从后往前应用
```

从后往前应用是为了避免前面的替换改变后面文本的 index。

### 保留文件风格

`edit` 会：

```text
stripBom()
detectLineEnding()
normalizeToLF()
restoreLineEndings()
```

内部统一用 LF 做匹配和 diff，写回时恢复原来的换行风格，并保留 BOM。

## bash

`bash` 的核心不是“执行命令”，而是把一个可能长时间运行的命令变成可观察、可截断、可中止的工具执行。

schema：

```ts
{
	command: string;
	timeout?: number;
}
```

执行路径：

```text
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：

```text
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。

所以：

```text
partial update = 给 UI/JSON 观察进度
final ToolResultMessage = 给模型继续推理
```

### bash 输出截断

`bash` 使用 `OutputAccumulator`。它不会把无限输出都留在内存里：

```text
内存里保留 tail
超过 2000 行 / 50KB 后标记 truncation
完整输出写到 /tmp/pi-bash-xxxx.log
最终 tool result 提示 Full output 路径
```

`bash` 更适合保留 tail，因为命令输出尾部通常更重要：

- 测试失败信息通常在最后。
- 编译错误汇总常在最后。
- 日志最新内容在最后。
- 命令最终状态也在最后。

### bash 失败会给模型什么

如果 exit code 非 0，`bash.execute()` 会抛错：

```ts
if (exitCode !== 0 && exitCode !== null) {
	throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
}
```

agent loop 捕获异常后，会转成 error tool result：

```ts
{
	content: [{ type: "text", text: error.message }],
	isError: true
}
```

所以模型看到的不是只有 “bash failed”，而是：

```text
...已有 stdout/stderr 尾部输出...

Command exited with code N
```

timeout 类似：

```text
...已有输出...

Command timed out after N seconds
```

abort 类似：

```text
...已有输出...

Command aborted
```

适合：

- 运行检查命令。
- 观察 stdout/stderr、exit code、timeout。
- 用有边界的短命令收集仓库状态。

不适合：

- 无边界长期命令。
- 高风险删除、重置、清理命令。
- 需要人工交互确认的命令。

## 工具输出如何回传模型

工具输出回传模型的链路是：

```text
模型生成 assistant toolCall
  -> agent loop 执行 tool.execute()
  -> tool 返回 AgentToolResult
  -> createToolResultMessage()
  -> 追加到 currentContext.messages
  -> 下一轮 streamAssistantResponse(currentContext)
  -> provider 把 toolResult 转成 OpenAI/Anthropic 等 API payload
  -> 模型看到工具结果，继续生成
```

核心点：

```text
tool.execute() 的返回值
不是直接塞给模型
而是先包装成 ToolResultMessage
```

结构大致是：

```ts
{
	role: "toolResult",
	toolCallId: "call_xxx",
	toolName: "read",
	content: [{ type: "text", text: "..." }],
	isError: false
}
```

然后这一条消息进入 `Context.messages`。下一次调用模型时，provider 会把它翻译成对应厂商的 tool result 格式。

## 顺序与稳定性

一次模型同时调用 `read`、`edit`、`bash` 时，要分两层看。

agent loop 层：

```text
保证一批 tool call 的调度和回填稳定
```

工具内部层：

```text
保证具体资源操作稳定
```

如果并行执行完成顺序是：

```text
bash 先结束
read 后结束
edit 最后结束
```

回填给模型的 `ToolResultMessage` 仍按 assistant 原始 tool call 顺序：

```text
result A: read
result B: edit
result C: bash
```

工具自身再保证资源安全：

- `read`：只读，通过路径解析、access、offset/limit、截断提示保证结果可控。
- `edit`：进入 `withFileMutationQueue(path)`，要求唯一、不重叠，避免误改。
- `bash`：partial update 只进事件流，最终结果才进上下文。

一句话：

```text
loop 管 tool call 的生命周期；
tool 管自己资源的安全边界。
```

## 本节完成度

本节已经完成：

- 理解 `ToolDefinition -> AgentTool` 的两层设计。
- 理解默认工具如何进入 `agent.state.tools`。
- 理解 `read` 的分页、图片、截断。
- 理解 `write` 的覆盖写入和 mutation queue。
- 理解 `edit` 的唯一匹配、多 edit、diff/patch。
- 理解 `bash` 的 partial update、输出截断、失败回传。
- 理解工具输出如何回填给模型。

下一节建议进入 `Session`：

```text
03/04 看的是一次 prompt 怎么跑；
05 看的是多轮对话、历史、恢复和压缩怎么维持。
```

## 自测题

- 为什么 `ToolDefinition` 和 `AgentTool` 要分层？
- 一个工具的四个基本组成是什么？
- 为什么 `edit` 要求 `oldText` 唯一？
- 为什么 `write` 不适合已有文件的小改？
- 为什么 `bash` 失败也要把 stdout/stderr 和 exit code 回传给模型？
- `tool_execution_update` 和最终 `ToolResultMessage` 分别服务谁？
