Appearance
功能规格说明:文件系统工具
规格文件:docs/specs/core/fs-tools.md创建日期:2024-12-19
用户场景与测试 (必填)
用户故事:读取和分析文件(优先级:P1)
作为 AI 代理,我希望读取本地文件系统中文件的内容,以便理解代码库、分析日志或查看图片和笔记本。
优先级原因:这是任何代码相关任务所需的基本能力。
独立测试:可以通过在各种文件类型(文本、图片、.ipynb)上调用 Read 工具并验证输出格式和内容来测试。
验收场景:
- 假设 文本文件存在,当 代理调用
Read时,则 它必须接收到带有行号和结构化元数据的内容。 - 假设 文件较大,当 代理使用
offset和limit时,则 它必须仅接收到指定的块。 - 假设 是图片文件,当 代理调用
Read时,则 它必须接收到适合多模态分析的图像数据和结构化元数据。 - 假设 文件自上次读取以来未发生变化,当 代理调用
Read时,则 它必须接收到表示文件未变的最小响应。
用户故事:精确代码修改(优先级:P1)
作为 AI 代理,我希望使用精确的字符串替换来修改文件,以便在不破坏文件结构的情况下安全地应用更改。
优先级原因:对于可靠地执行重构和错误修复至关重要。
独立测试:可以通过对文件应用 Edit 并验证内容是否正确更新以及缩进是否保留来测试;再取一个本对话中从未读取过的文件(含仅被 Grep 扫到过的)重复一次,验证无需先 Read 也能编辑。
验收场景:
- 假设 文件存在且
old_string唯一,当 代理调用Edit时,则 文件必须使用new_string更新——无论该文件此前是否已在对话中读取过。 - 假设
old_string不唯一,当 代理不使用replace_all调用Edit时,则 操作必须失败。 - 假设 一个
Edit工具调用,当 显示差异时,则 当删除和添加的行数匹配时,它必须显示已更改部分的词级高亮。 - 假设 文件使用 CRLF 换行符,当 代理使用 LF 的
old_string调用Edit时,则 通过在比较之前规范化换行符,匹配必须成功。 - 假设 文件尚未在对话中读取过(仅被
Grep、git diff或子代理报告看到过),当 代理调用Edit且old_string能唯一匹配磁盘内容时,则 编辑必须成功,不得返回"必须先读取文件"之类的拒绝。 - 假设 一个
Edit工具调用中old_string未找到,当 返回错误时,则 它必须包含尝试的字符串以帮助模型自我纠正。 - 假设 文件在上次完整读取后被外部进程(git checkout、编辑器原地保存、云同步、杀毒软件等)触碰导致 mtime 变化但内容未变,当 代理调用
Edit时,则 操作必须成功,且结果中不得出现"文件在盘上另有未被看到的改动"提示(内容可证明未变,提示无意义)。 - 假设 文件自上次读取后确实被修改(内容变化),当 代理调用
Edit且old_string仍能在当前内容中唯一匹配时,则 编辑必须按当前内容成功应用,并在结果中附加提示:文件在盘上另有未进入上下文的改动,涉及周边内容的后续编辑应先重新读取。 - 假设 文件自上次读取后确实被修改(内容变化)且分页读取过(提供了
offset或limit),当 代理调用Edit时,则 不存在"内容哈希兜底"的分支:一律按old_string能否唯一匹配当前内容判定——能匹配则成功(附第 8 条提示),否则失败并要求重新读取。
用户故事:原子写文件(优先级:P2)
作为用户,我希望代理写文件时先写同目录临时文件再原子重命名,以便其他并发读者(后台提取 fork、编辑器、其他会话)不会读到写了一半的文件。
优先级原因:Write/Edit 直接覆写目标文件时,写入过程中目标文件会短暂处于被截断状态;后台自动记忆提取 fork 与主会话可能并发读写同一记忆文件,读到半写内容会把坏数据带进上下文。对齐 Claude Code 的临时文件 + 重命名写入。
独立测试:调用 Write/Edit 写文件,验证写入经过同目录临时文件并重命名到目标路径;模拟写入失败,验证目标路径不残留部分内容、临时文件被清理。
验收场景:
- 假设 代理调用
Write或Edit写入文件,当 执行写入时,则 必须先把内容写入目标路径同目录下的临时文件、再重命名到目标路径 - 假设 写入过程中失败(临时文件写入失败或重命名失败),当 工具返回失败时,则 目标路径不得残留部分写入的内容,且必须清理临时文件
- 假设 目标文件已存在,当 以原子方式替换它时,则 原子写路径不变,且「不再要求先读取」与「陈旧检测」的语义必须与
Edit一致——只有Write没有"能干净应用"的判据(见边界情况)
用户故事:高效代码探索(优先级:P2)
作为 AI 代理,我希望用 glob 模式或正则表达式搜索模式,以便在大型项目中快速定位相关代码或资源。
优先级原因:通过避免手动遍历目录树来提高效率。默认形态下搜索直接在 Bash 工具里完成,省掉一次独立的工具往返(模型 → 工具结果 → 模型),也不必让搜索上下文走结构化渲染通道;只有内容搜索需要结构化输出(计数、files_with_matches 等)时才值得付出这次往返。
独立测试:默认工具集下在 Bash 里用 grep -rn / find 定位一处已知字符串,核对文件与行号;再以 --tools Grep,Glob 启动一次,验证两个工具重新出现并返回结构化结果。
验收场景:
- 假设 会话以默认工具集启动,当 列出可用工具时,则 不得包含
Grep与Glob——搜索通过 Bash 工具里的grep/find完成。 - 假设 代理在 Bash 里执行
grep -rn <pattern>,当 命令返回时,则 输出必须包含文件路径与行号,且递归时遵循.gitignore、不进入二进制文件。 - 假设 代理在 Bash 里执行
find <path> -name <pattern>,当 命令返回时,则 输出必须是匹配的文件路径列表,且-exec/-delete之类会改动文件系统的用法不得被自动放行。 - 假设 搜索以 Bash 形式执行,当 触发权限判定时,则 它必须与其它 Bash 命令走同一套规则(默认 allow 规则已覆盖
find/grep/rg),不得因为「这是搜索」而单独开一条豁免通道。 - 假设 以
--tools显式点名Grep或Glob,当 列出可用工具时,则 点名的工具必须注册:Grep返回匹配行或路径、遵循.gitignore和常见忽略模式、带匹配计数的结构化元数据;Glob返回**/*.ts之类的文件列表,包含被.gitignore忽略的文件(但排除.git目录),最多到指定的limit(默认 100)。 - 假设 搜索模式没有匹配结果,当 代理执行搜索时,则 默认路径下必须能从退出码(无匹配为 1)区分「确实没有匹配」,而非沉默的空输出。
- 假设 子代理(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「边界情况 · 运行时依赖自装与缓存」)。二进制路径每次使用时再解析(不在模块求值期定死——依赖是 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「边界情况 · worktree 里的.git仍然会被搜到」);find在 macOS/Linux/WSL 上指向内置的bfs,固定带-S dfs与-regextype findutils-default(后者让-regex的默认方言与 GNUfind一致,见 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。