10. Coding 工具:read、edit、write、bash
Coding Agent 和普通工具 Agent 的差异,集中体现在写操作。读文件和搜索让模型观察世界;edit、write、bash 让模型改变世界并验证结果。这里的风险也最高:误改文件、覆盖用户改动、执行危险命令、输出过长、测试进程挂住,都会在真实仓库里发生。
先读再改
系统提示词和工具运行时都应该强化一个规则:编辑前必须读取目标文件。模型如果直接调用 edit 修改未读文件,运行时可以拒绝并返回错误 tool result:
Cannot edit src/config.ts because it has not been read in this session. Read the file first, then retry with exact oldText.
这个错误不是保守主义。edit 的可靠性依赖精确上下文。模型没读文件时,通常会猜测代码形状,导致替换失败或误改。
read 本身也有几个必须处理的细节。行偏移和行数限制让模型分段读大文件;默认截断可以取 2000 行或 50KB 先到为准,从头部保留。文件开头的 BOM 要剥离,否则模型看到的第一行和磁盘上的字节不一致,后续 edit 的精确匹配会莫名失败。如果模型支持图像输入,read 还可以识别图片文件并返回图像块(过大的图先缩放);不支持时返回明确说明,而不是二进制乱码。
Edit 不是“让模型给 patch”
教学项目推荐先实现精确替换型 edit:
type EditInput = {
path: string;
oldText: string;
newText: string;
};
运行时读取当前文件,检查 oldText 是否出现且只出现一次,然后替换为 newText。如果没有出现,返回可修正错误;如果出现多次,也返回错误并要求模型提供更多上下文。这样做比让模型直接输出 patch 更容易校验,也更容易让模型自我修正。
但纯精确匹配在生产里会有恼人的失败率,原因不是模型“记错了代码”,而是文本在旅途中被悄悄改写:模型输出经常把直引号变成弯引号、把普通连字符变成 Unicode 破折号;行尾空白在渲染中丢失;同一个字符有多种 Unicode 组合形式。成熟系统的对策是分层匹配:先精确匹配;失败后做规范化匹配——Unicode NFKC 归一、去每行行尾空白、弯引号还原为直引号、各类 Unicode 连字符和空格还原为 ASCII——再找唯一匹配。仍然失败才报错,并在错误里附上文件中最接近的片段,帮模型定位。
失败反馈要具体:
oldText was not found in src/parser.ts.
The file currently contains a similar line:
return parse(input, options);
Read the latest file contents and retry with exact oldText.
模型通常能根据这类反馈重新 read,然后发出正确 edit。
还有一个隐形杀手:换行符。Windows 仓库里的 CRLF 文件,如果工具内部统一按 LF 处理后原样写回 LF,一次 edit 就会把整个文件的每一行都标记为已修改,diff 面目全非。正确的做法是:读取时检测原始换行风格,匹配和替换在归一化的 LF 上进行,写回前恢复原始风格。BOM 同理,剥离后参与匹配,写回时按原样恢复。
生产版 edit 通常还支持一次提交多处替换(一个 edits 数组),运行时校验所有 oldText 互不重叠、各自唯一,然后原子地全部应用——要么全成功,要么全失败。半应用的 edit 是最难调试的状态。
Write 的边界
write 用于创建新文件或整体替换文件。它比 edit 危险,因为它不要求 oldText 匹配。建议策略:
- 创建新文件可以直接允许,父目录不存在时递归创建,但路径仍要在工作区内。
- 覆盖已有文件前要求文件已读,或要求用户确认。
- 对大文件写入给 UI 显示 diff。
- 写入后把修改摘要放入 tool result。
不要用 write 修小改动。小改动用 edit,降低覆盖风险。
Bash 的边界
bash 是最强也最危险的工具。最小实现也要有:
- cwd 限定,且在会话内保持一致。
- 超时。
- stdout/stderr 截断。
- exit code。
- AbortSignal 取消。
- 危险命令确认或拒绝。
超时和取消有一个共同的坑:杀进程要杀整棵进程树。npm test 会派生出测试进程、测试再派生浏览器;只杀直接子进程,孙进程会继续占着端口和文件锁。在不同平台上正确地杀进程树(Windows 和 POSIX 的机制完全不同)是这个工具最脏的部分,但绕不开。exit code 也要如实区分三种结局:正常退出的码、非零失败的码、被超时或取消杀掉时的“无退出码”。
命令输出给模型时要保留命令、退出码、截断说明和关键输出:
Command: npm run check
Exit code: 1
Output was truncated to the last 200 lines.
src/index.ts:42:10 - error TS2322 ...
输出截断方向和 read 相反:保留尾部(比如最后 2000 行或 50KB),因为编译错误和测试总结几乎总在末尾。流式转发输出给 UI 时要节流(比如每 100 毫秒最多刷新一次),否则快速滚动的日志会把界面刷死。
文件写队列
模型可能在一个 turn 中并行调用两个写工具。即使你的 loop 串行执行,也要为未来并行留下边界。对同一个真实路径,写操作必须进入同一队列:
edit src/a.ts -> waits for previous write to src/a.ts
write src/a.ts -> same queue
edit src/b.ts -> can run independently
队列 key 应该是解析后的真实路径。符号链接、相对路径和大小写差异都可能指向同一个文件。没有写队列时,两个工具会基于旧内容计算修改,最后一个落盘的覆盖前一个。
Diff 给人,摘要给模型
模型不需要完整 unified diff 才能继续,用户界面需要。edit 结果可以分两层:
- 给模型:修改成功,文件、行数、下一步建议。
- 给 UI:结构化 diff、旧内容、新内容、是否创建文件、第一处改动的行号(编辑器集成可以直接跳转)。
如果把完整 diff 都塞进上下文,Agent 很快会耗尽 token。模型真正需要的是“修改已完成”和“测试下一步应该跑什么”。
最后回顾第三章埋下的伏笔:这五个工具都应该通过 operations 接口访问文件系统和 shell,而不是直接调用 node:fs 和 child_process。本地实现之外,同一套工具可以运行在 SSH 远程、容器或测试用内存文件系统上——写队列、换行处理、截断策略全部复用。
练习
实现 edit、write、bash。
验收标准:
edit要求oldText精确出现一次;弯引号和行尾空白差异能通过规范化匹配恢复。- CRLF 文件编辑后仍是 CRLF,diff 只包含真正改动的行。
edit失败返回可修正 tool result(含最接近片段),不抛出未捕获异常。write覆盖已有文件时有明确策略。bash有超时、尾部截断、exit code 和取消;超时会清理整棵进程树。- 同一文件的写操作不会并行覆盖(按真实路径排队)。
- UI 事件能拿到 diff details,模型上下文只拿到短摘要。