---
url: /wave-agent/specs/core/fs-tools.md
description: Read、Write、Edit 文件工具，搜索默认走 Bash 的内嵌 grep/find
---

# 功能规格说明：文件系统工具

**规格文件**：`docs/specs/core/fs-tools.md`
**创建日期**：2024-12-19

## 用户场景与测试 *(必填)*

### 用户故事：读取和分析文件（优先级：P1）

作为 AI 代理，我希望读取本地文件系统中文件的内容，以便理解代码库、分析日志或查看图片和笔记本。

**优先级原因**：这是任何代码相关任务所需的基本能力。

**独立测试**：可以通过在各种文件类型（文本、图片、.ipynb）上调用 `Read` 工具并验证输出格式和内容来测试。

**验收场景**：

1. **假设** 文本文件存在，**当** 代理调用 `Read` 时，**则** 它必须接收到带有行号和结构化元数据的内容。
2. **假设** 文件较大，**当** 代理使用 `offset` 和 `limit` 时，**则** 它必须仅接收到指定的块。
3. **假设** 是图片文件，**当** 代理调用 `Read` 时，**则** 它必须接收到适合多模态分析的图像数据和结构化元数据。
4. **假设** 文件自上次读取以来未发生变化，**当** 代理调用 `Read` 时，**则** 它必须接收到表示文件未变的最小响应。

***

### 用户故事：精确代码修改（优先级：P1）

作为 AI 代理，我希望使用精确的字符串替换来修改文件，以便在不破坏文件结构的情况下安全地应用更改。

**优先级原因**：对于可靠地执行重构和错误修复至关重要。

**独立测试**：可以通过对文件应用 `Edit` 并验证内容是否正确更新以及缩进是否保留来测试；再取一个本对话中从未读取过的文件（含仅被 `Grep` 扫到过的）重复一次，验证无需先 `Read` 也能编辑。

**验收场景**：

1. **假设** 文件存在且 `old_string` 唯一，**当** 代理调用 `Edit` 时，**则** 文件必须使用 `new_string` 更新——无论该文件此前是否已在对话中读取过。
2. **假设** `old_string` 不唯一，**当** 代理不使用 `replace_all` 调用 `Edit` 时，**则** 操作必须失败。
3. **假设** 一个 `Edit` 工具调用，**当** 显示差异时，**则** 当删除和添加的行数匹配时，它必须显示已更改部分的词级高亮。
4. **假设** 文件使用 CRLF 换行符，**当** 代理使用 LF 的 `old_string` 调用 `Edit` 时，**则** 通过在比较之前规范化换行符，匹配必须成功。
5. **假设** 文件尚未在对话中读取过（仅被 `Grep`、`git diff` 或子代理报告看到过），**当** 代理调用 `Edit` 且 `old_string` 能唯一匹配磁盘内容时，**则** 编辑必须成功，不得返回"必须先读取文件"之类的拒绝。
6. **假设** 一个 `Edit` 工具调用中 `old_string` 未找到，**当** 返回错误时，**则** 它必须包含尝试的字符串以帮助模型自我纠正。
7. **假设** 文件在上次完整读取后被外部进程（git checkout、编辑器原地保存、云同步、杀毒软件等）触碰导致 mtime 变化但内容未变，**当** 代理调用 `Edit` 时，**则** 操作必须成功，且结果中不得出现"文件在盘上另有未被看到的改动"提示（内容可证明未变，提示无意义）。
8. **假设** 文件自上次读取后确实被修改（内容变化），**当** 代理调用 `Edit` 且 `old_string` 仍能在当前内容中唯一匹配时，**则** 编辑必须按当前内容成功应用，并在结果中附加提示：文件在盘上另有未进入上下文的改动，涉及周边内容的后续编辑应先重新读取。
9. **假设** 文件自上次读取后确实被修改（内容变化）且分页读取过（提供了 `offset` 或 `limit`），**当** 代理调用 `Edit` 时，**则** 不存在"内容哈希兜底"的分支：一律按 `old_string` 能否唯一匹配当前内容判定——能匹配则成功（附第 8 条提示），否则失败并要求重新读取。

***

### 用户故事：原子写文件（优先级：P2）

作为用户，我希望代理写文件时先写同目录临时文件再原子重命名，以便其他并发读者（后台提取 fork、编辑器、其他会话）不会读到写了一半的文件。

**优先级原因**：`Write`/`Edit` 直接覆写目标文件时，写入过程中目标文件会短暂处于被截断状态；后台自动记忆提取 fork 与主会话可能并发读写同一记忆文件，读到半写内容会把坏数据带进上下文。对齐 Claude Code 的临时文件 + 重命名写入。

**独立测试**：调用 `Write`/`Edit` 写文件，验证写入经过同目录临时文件并重命名到目标路径；模拟写入失败，验证目标路径不残留部分内容、临时文件被清理。

**验收场景**：

1. **假设** 代理调用 `Write` 或 `Edit` 写入文件，**当** 执行写入时，**则** 必须先把内容写入目标路径同目录下的临时文件、再重命名到目标路径
2. **假设** 写入过程中失败（临时文件写入失败或重命名失败），**当** 工具返回失败时，**则** 目标路径不得残留部分写入的内容，且必须清理临时文件
3. **假设** 目标文件已存在，**当** 以原子方式替换它时，**则** 原子写路径不变，且「不再要求先读取」与「陈旧检测」的语义必须与 `Edit` 一致——只有 `Write` 没有"能干净应用"的判据（见边界情况）

***

### 用户故事：高效代码探索（优先级：P2）

作为 AI 代理，我希望用 glob 模式或正则表达式搜索模式，以便在大型项目中快速定位相关代码或资源。

**优先级原因**：通过避免手动遍历目录树来提高效率。默认形态下搜索直接在 Bash 工具里完成，省掉一次独立的工具往返（模型 → 工具结果 → 模型），也不必让搜索上下文走结构化渲染通道；只有内容搜索需要结构化输出（计数、`files_with_matches` 等）时才值得付出这次往返。

**独立测试**：默认工具集下在 Bash 里用 `grep -rn` / `find` 定位一处已知字符串，核对文件与行号；再以 `--tools Grep,Glob` 启动一次，验证两个工具重新出现并返回结构化结果。

**验收场景**：

1. **假设** 会话以默认工具集启动，**当** 列出可用工具时，**则** 不得包含 `Grep` 与 `Glob`——搜索通过 Bash 工具里的 `grep` / `find` 完成。
2. **假设** 代理在 Bash 里执行 `grep -rn <pattern>`，**当** 命令返回时，**则** 输出必须包含文件路径与行号，且递归时遵循 `.gitignore`、不进入二进制文件。
3. **假设** 代理在 Bash 里执行 `find <path> -name <pattern>`，**当** 命令返回时，**则** 输出必须是匹配的文件路径列表，且 `-exec` / `-delete` 之类会改动文件系统的用法不得被自动放行。
4. **假设** 搜索以 Bash 形式执行，**当** 触发权限判定时，**则** 它必须与其它 Bash 命令走同一套规则（默认 allow 规则已覆盖 `find` / `grep` / `rg`），不得因为「这是搜索」而单独开一条豁免通道。
5. **假设** 以 `--tools` 显式点名 `Grep` 或 `Glob`，**当** 列出可用工具时，**则** 点名的工具必须注册：`Grep` 返回匹配行或路径、遵循 `.gitignore` 和常见忽略模式、带匹配计数的结构化元数据；`Glob` 返回 `**/*.ts` 之类的文件列表，包含被 `.gitignore` 忽略的文件（但排除 `.git` 目录），最多到指定的 `limit`（默认 100）。
6. **假设** 搜索模式没有匹配结果，**当** 代理执行搜索时，**则** 默认路径下必须能从退出码（无匹配为 1）区分「确实没有匹配」，而非沉默的空输出。
7. **假设** 子代理（Explore / Plan / general-purpose）被启动，**当** 它构造自己的工具集时，**则** 必须与主线遵守同一套默认工具集口径（同样拿不到 `Grep` / `Glob`），并使用「通过 Bash 的 `grep` / `find` 搜索」的提示词。

***

### 边界情况

* **大文件**：读取超出内存限制或令牌窗口的文件。文件大小超过 256KB 且未显式提供 `limit` 参数时，工具直接返回错误。通过 `offset` 和 `limit` 分页读取。如果估计令牌超过 `maxTokens`，工具返回错误并建议使用 offset/limit 或 grep/jq。
* **二进制文档**：尝试读取 PDF、DOCX 或其他不支持的二进制格式。工具必须阻止此操作并返回错误。
* **不匹配分析**：当未找到 `old_string` 时，`Edit` 工具必须提供详细的不匹配报告，包括尝试的字符串（截断为 200 个字符）以帮助模型自我纠正。
* **不要求先读取**：`Edit` 与 `Write` 不再检查目标文件是否在当前对话中读取过——仅 `Grep` 扫到过的文件可以直接 `Edit`，已存在但从未读过的文件也可以直接覆盖。对齐 Claude Code 2.1.285 对新模型的运行时语义：读前守卫只在「该路径的读取需要询问或已被拒绝」时才生效，而 wave 的 `Read` 不是受限工具（默认自动允许），该条件恒成立，守卫等价于不存在。防呆由下述陈旧检测与 `old_string` 匹配失败承担。
* **陈旧检测**：`Edit` 与 `Write` 在写入前比较文件当前 mtime 与 `readFileState` 记录的 mtime，且只对「本对话读过、且盘上确实变新」的文件生效（从未读过的文件没有记录，不做检测）。
  * `Edit`：mtime 变新时不再直接拒绝，而是按**当前**内容尝试——完整读取（未提供 `offset`/`limit`）且当前内容哈希与记录一致，视为内容未变、静默成功（避免 git checkout / 编辑器原地保存 / 云同步 / 杀毒软件造成的误报）；`old_string` 能在当前内容中唯一匹配（或 `replace_all` 下匹配到）则按当前内容成功应用，并在结果中附「文件在盘上另有未进入上下文的改动，涉及周边内容的后续编辑应先重新读取」的提示；两者都不满足才失败并要求重新读取。分页读取不再另列规则，走同一条路径。
  * `Write`：整体覆盖没有「能干净应用」这一判据，因此只有完整读取且哈希一致才放行，否则保持拒绝并要求重新读取（与 Claude Code 的 `Write` 一致——它不做陈旧恢复）。
* **CRLF 规范化**：`Edit` 工具必须在匹配前将 `\r\n` 规范化为 `\n`，这样模型可以在 `old_string` 中使用 LF 换行符，无论文件的实际换行符如何。
* **最小 old_string**：`Edit` 工具提示应引导模型使用明显唯一的最小 `old_string`（通常为 2-4 行相邻文本），减少在长模板字面量文本上的复现错误。
* **文件权限**：尝试写入没有适当权限的只读文件或目录。
* **原子写**：`Write`/`Edit` 及记忆文件写入必须走「同目录临时文件 + 重命名」的原子路径，避免并发读者观察到被截断的内容；写失败时必须清理临时文件且不破坏目标文件的既有内容。

## 假设

* 代理具有访问工作区目录所需的系统级权限。
* `ripgrep`（`rg`）二进制由 `@vscode/ripgrep` 依赖提供（通过 `optionalDependencies` 按平台分发），供 `Grep` 工具使用。npm 安装的 `wave-code` 自带该依赖；宿主自带的那份 CLI 不带 `node_modules`，由 CLI 在**启动期**按需从 npmmirror 下载到共享 `~/.wave/cli/node_modules/@vscode/`（机制见 [stdio-transport.md](../ui/stdio-transport.md)「边界情况 · 运行时依赖自装与缓存」）。二进制路径**每次使用时再解析**（不在模块求值期定死——依赖是 CLI 自己在启动期装进来的，晚于模块求值），**解析锚点有两个**：CLI 自身位置优先、共享 `~/.wave/cli/node_modules` 兜底（前者让 npm 这类会把依赖提升到可解析位置的安装方式直接命中、无需下载，后者让 pnpm 全局安装、嵌套安装同样能找到已下载的副本），且**解析前先做文件系统存在性判定**（候选 `node_modules` 里没有该包目录就直接判缺失，绝不 `require` 一个尚不存在的包——那次失败会按路径把「不存在」缓存进进程，包随后落盘也再解析不到）：平台二进制包缺失时 `Grep` 返回"ripgrep is not available"（文件搜索同理报错），既不使宿主进程在加载时崩溃，也不因下载失败阻断 CLI 启动——桌面端与插件端的 grep 运行在 CLI 子进程中，宿主自身不消费该路径。
* `PermissionManager` 已正确配置以处理文件系统访问级别。
* **默认工具集不含搜索工具**：`Grep` 与 `Glob` 不是默认注册的工具，恢复途径只有 CLI 的 `--tools` 点名（`--allowedTools` 是权限 allow 列表，不影响注册——这一点与 Claude Code 不同，后者两种参数任一出现都会把两个工具都放回来）。不提供按平台或按配置的回退开关：摘除是全平台一致的行为。
* **搜索命令的内嵌化**：Bash 工具所在 shell 里的 `grep` / `find` / `rg` 由 wave 注入，不是系统命令。`grep` 在所有受支持平台（含 Windows 的 Git Bash）上都指向内置的 `ugrep`，固定带 `--ignore-files --hidden -I` 与 `.git` 等 VCS 目录排除（只按目录排除，与 Claude Code 的影子一致：worktree 里的 `.git` 是指针文件，会作为普通文件被搜到，见 [bash-tools.md](./bash-tools.md)「边界情况 · worktree 里的 `.git` 仍然会被搜到」）；`find` 在 macOS/Linux/WSL 上指向内置的 `bfs`，固定带 `-S dfs` 与 `-regextype findutils-default`（后者让 `-regex` 的默认方言与 GNU `find` 一致，见 [bash-tools.md](./bash-tools.md)「边界情况 · `find -regex` 的 dialect」），在 Windows 的 Git Bash 上不注入——bfs 没有 Windows 构建，而 msys 自带的 GNU find 本身就是 `find` 影子要复现的那一套。`rg` 全平台注入，但仅在系统原本没有 `rg` 时生效，且指向 wave 自带的 ripgrep 二进制。
* **内嵌二进制的分发与回落**：`ugrep` / `bfs` 与 `ripgrep` 一样走运行时依赖自装机制（下载到共享 `~/.wave/cli/node_modules`），不由任何宿主的安装包直接携带。任一二进制缺失（下载失败、私有化环境未带上、平台不受支持）时，对应命令必须回落为系统同名命令并照常执行——搜索能力可以退化（丢掉 `.gitignore` 语义等），但不得让命令失败或阻断会话。`grep` 收到它不支持的那批参数（`-Z`、`-z`、`--null`、`--config` 等）时同样回落系统 `grep`。
