03 · 文件工具:读、写、Patch
三种文件操作:read_file 读取、write_file 创建覆写、patch_file 精确修改。patch 比整文件覆写更安全。
从"看"到"改"
前两节给 agent 装上了搜索和列文件的能力。但搜索和列文件都是"看"——agent 只能观察代码仓库,不能修改它。
这一节要做的是从"看"到"改"的跨越。三个工具对应三种文件操作:
- read_file(第 1 章已实现):读取文件内容
- write_file(新增):创建或覆写整个文件
- patch_file(新增):对已有文件做精确的局部修改
三个工具形成递进关系:read 看、write 全量改、patch 精确改。
write_file:创建和覆写
write_file 做的事情很直接——把一段内容写入指定路径的文件。如果文件已存在就覆盖,如果不存在就创建(包括中间目录)。
// src/tools/write-file.ts
import { writeFile, mkdir } from "node:fs/promises";
import { resolve, dirname, relative } from "node:path";
import type { AgentState, Tool } from "../types";
/**
* 写文件工具
*
* 安全措施:
* 1. 不允许写入工作目录之外的文件(路径遍历防护)
* 2. 如果目标目录不存在,自动创建(但不递归创建超过一层的目录)
* 3. 不允许覆盖 .git 目录下的文件
*/
export const writeFileTool: Tool = {
name: "write_file",
description:
"创建或覆盖文件。写入完整文件内容。" +
"如果要修改文件的一小部分,优先使用 patch_file 工具。" +
"写入前会自动创建不存在的目录。",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "要写入的文件路径(相对于工作目录)",
},
content: {
type: "string",
description: "文件内容",
},
},
required: ["path", "content"],
},
async execute(
args: Record<string, unknown>,
state: AgentState,
): Promise<string> {
const filePath = resolve(state.workingDir, String(args.path));
const content = String(args.content ?? "");
/** 安全检查:不允许写入工作目录之外的文件 */
if (!filePath.startsWith(state.workingDir)) {
return "错误:不能写入工作目录之外的文件。";
}
/** 不允许覆盖 .git 目录 */
if (filePath.includes("/.git/") || filePath.endsWith("/.git")) {
return "错误:不允许修改 .git 目录下的文件。";
}
try {
/** 自动创建目标目录(如果不存在) */
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf-8");
const relativePath = relative(state.workingDir, filePath);
const lineCount = content.split("\n").length;
return `已写入 ${relativePath}(${lineCount} 行)`;
} catch (error: unknown) {
return `写入文件出错:${String(error)}`;
}
},
};这个工具看起来简单,但有几个重要的安全设计:
路径遍历防护。 如果模型传入 path: "../../etc/passwd",不做检查的话就会写到工作目录之外。filePath.startsWith(state.workingDir) 这一行确保所有写操作都在工作目录范围内。resolve 会把相对路径转成绝对路径,所以 ../../etc/passwd 会被解析成实际路径后再做比较。
.git 目录保护。 即使路径在工作目录内,也不允许修改 .git 目录下的文件。Git 仓库的内部数据(提交历史、配置、hooks)不应该被 agent 工具随意修改。
自动创建目录。 如果写入 src/tools/new-tool.ts 但 src/tools/ 目录不存在,mkdir 会自动创建。recursive: true 确保中间的每一层目录都会被创建。
description 里引导模型行为。 注意 description 里专门写了一句"如果要修改文件的一小部分,优先使用 patch_file 工具"。这句话直接告诉模型:创建新文件用 write_file,修改已有文件用 patch_file。这种引导比事后纠正有效得多。
patch_file:精确的局部修改
write_file 的问题在于:如果你只想改一个函数的某一行,也得把整个文件重写一遍。这不仅浪费 token,还有风险——模型生成的完整文件可能和原文件在无关的地方出现差异。
patch_file 解决这个问题。它的工作方式是字符串替换:找到文件中的 old_content,替换为 new_content。只改需要改的部分,不动其他内容。
// src/tools/patch-file.ts(核心逻辑)
export const patchFileTool: Tool = {
name: "patch_file",
description:
"对已有文件做局部修改。找到文件中的 old_content,替换为 new_content。" +
"比 write_file 更安全,因为只改一小部分,不会影响文件的其他内容。" +
"如果 old_content 在文件中出现多次,会报错——请提供更长的上下文来精确定位。",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "文件路径(相对于工作目录)",
},
old_content: {
type: "string",
description: "要被替换的原始文本(需要和文件中的内容完全一致)",
},
new_content: {
type: "string",
description: "替换后的新文本",
},
},
required: ["path", "old_content", "new_content"],
},
// ...
};参数设计很关键。old_content 要求和文件中的内容完全一致——包括空格、缩进、换行。这是有意为之的。模糊匹配看起来更宽容,但实际上更容易改错位置。精确匹配确保"改的就是你想改的那段代码"。
patch 的四重安全机制
patch_file 的 execute 方法实现了四层安全检查,每一层解决一个具体问题:
async execute(
args: Record<string, unknown>,
state: AgentState,
): Promise<string> {
const filePath = resolve(state.workingDir, String(args.path));
const oldContent = String(args.old_content ?? "");
const newContent = String(args.new_content ?? "");
/** 第一层:路径安全检查(和 write_file 相同) */
if (!filePath.startsWith(state.workingDir)) {
return "错误:不能修改工作目录之外的文件。";
}
if (filePath.includes("/.git/") || filePath.endsWith("/.git")) {
return "错误:不允许修改 .git 目录下的文件。";
}
/** 第二层:old_content 不能为空 */
if (!oldContent) {
return "错误:old_content 不能为空。";
}
try {
const content = await readFile(filePath, "utf-8");
/** 第三层:检查 old_content 是否存在于文件中 */
const firstIndex = content.indexOf(oldContent);
if (firstIndex === -1) {
return formatNotFoundError(content, oldContent, filePath, state);
}
/** 第四层:如果出现多次,要求模型提供更精确的上下文 */
const secondIndex = content.indexOf(oldContent, firstIndex + 1);
if (secondIndex !== -1) {
return (
`错误:old_content 在文件中出现了多次,请提供更长的上下文来精确定位。\n` +
`文件:${relative(state.workingDir, filePath)}`
);
}
/** 执行替换 */
const newFileContent = content.replace(oldContent, newContent);
await writeFile(filePath, newFileContent, "utf-8");
/** 计算变更的行号范围,帮助定位修改位置 */
const beforeLines = content.slice(0, firstIndex).split("\n").length;
const oldLines = oldContent.split("\n").length;
const newLines = newContent.split("\n").length;
const relativePath = relative(state.workingDir, filePath);
return [
`已修改 ${relativePath}`,
`位置:第 ${beforeLines}-${beforeLines + oldLines - 1} 行`,
`${oldLines} 行 -> ${newLines} 行`,
].join("\n");
} catch (error: unknown) {
if (isNodeError(error) && error.code === "ENOENT") {
return `错误:文件不存在 ${args.path}`;
}
return `修改文件出错:${String(error)}`;
}
}逐层看每个检查解决什么问题:
第一层(路径安全):和 write_file 一样,防止路径遍历攻击。不管工具多安全,如果写到了工作目录之外,一切都是空谈。
第二层(非空检查):如果 old_content 为空字符串,content.replace("", newContent) 会在每个字符之间插入 newContent——这显然不是我们想要的。提前拦截。
第三层(存在性检查):模型生成的 old_content 可能和文件实际内容有细微差异(多了个空格、少了换行)。如果找不到匹配,不能静默失败——必须告诉模型"找不到",并尽可能给出有用的提示。
第四层(唯一性检查):old_content 在文件中可能出现多次(比如两个一模一样的 return null;)。如果允许替换,会把所有出现的地方都改掉,这通常不是模型的本意。报错并要求提供更长的上下文,强制模型精确定位。
模糊匹配提示:帮模型自我修正
第三层检查中,当 old_content 完全找不到时,patch_file 不是简单返回一个"找不到"的错误,而是尝试给出更有帮助的提示:
/**
* 当 old_content 找不到时,给出有帮助的提示
* 帮助模型调整搜索内容而不是盲目重试
*/
function formatNotFoundError(
fileContent: string,
oldContent: string,
filePath: string,
state: AgentState,
): string {
const relativePath = relative(state.workingDir, filePath);
/** 尝试模糊匹配:如果 old_content 的第一行在文件中存在 */
const firstLine = oldContent.split("\n")[0];
const fuzzyIndex = fileContent.indexOf(firstLine);
if (fuzzyIndex !== -1 && firstLine.length > 3) {
const lineNum = fileContent.slice(0, fuzzyIndex).split("\n").length;
return (
`错误:未在 ${relativePath} 中找到完全匹配的内容。\n` +
`但第一行 "${firstLine}" 在第 ${lineNum} 行附近有部分匹配。\n` +
`请使用 read_file 读取该位置附近的代码,确认准确内容后再重试。`
);
}
return `错误:未在 ${relativePath} 中找到指定的 old_content。请先用 read_file 确认文件内容。`;
}这段代码做的事情是:提取 old_content 的第一行,在文件中搜索是否有部分匹配。如果第一行找到了,说明模型记住了大致内容但细节有出入——比如缩进不同、某个变量名记错了。
这时候返回的提示包含三部分信息:
- "未找到完全匹配"——告诉模型直接重试不会成功
- "第一行 xxx 在第 N 行附近有部分匹配"——指出大致位置
- "请使用 read_file 读取"——给出明确的下一步操作
模型收到这个提示后,会真的去调用 read_file 读取那一行的精确内容,然后带着正确的 old_content 重新调用 patch_file。这个过程完全自动,不需要用户介入。
为什么 patch 比 write 更安全
做完 write_file 和 patch_file,比较一下它们的适用场景:
| write_file | patch_file | |
|---|---|---|
| 适用场景 | 创建新文件、大规模重写 | 修改已有文件的一小部分 |
| 影响范围 | 整个文件 | 只有 old_content 到 new_content 的部分 |
| 出错风险 | 高(任何位置的生成错误都会影响文件) | 低(只改指定的那一段) |
| 可验证性 | 需要对比整个文件 | 只看替换的那一段是否正确 |
| token 开销 | 整个文件内容都要放在参数里 | 只需要 old 和 new 两段文本 |
最关键的区别是可验证性。用 patch_file 修改后,你可以清楚地看到"改了什么":old_content 是什么、new_content 是什么、在第几行。用 write_file 覆写后,你需要对比整个旧文件和新文件才能发现差异。
在实际使用中,90% 的文件修改应该用 patch_file。write_file 只在创建新文件或文件确实需要大面积重写时使用。
这也解释了为什么 write_file 的 description 里要写"如果要修改文件的一小部分,优先使用 patch_file"。模型读了这句话,大多数修改场景就会选择 patch_file,降低出错概率。
返回值中的行号信息
patch_file 的返回值包含变更的行号范围:
已修改 src/tools/search.ts
位置:第 42-45 行
3 行 -> 5 行这个信息有两个用途:
给模型提供反馈。 模型知道自己改了哪里,可以在后续操作中引用这个位置。比如模型改完代码后可能想跑一下相关的测试,它知道测试文件也在同一目录下。
给用户审计依据。 用户看到"第 42-45 行被修改了",可以自己去查看对应的代码,验证修改是否正确。这是 agent 可信度的重要基础——用户需要能追溯 agent 做了什么。
注册文件工具
更新工具注册表:
// src/tools/index.ts
import { readFileTool } from "./read-file";
import { writeFileTool } from "./write-file";
import { patchFileTool } from "./patch-file";
// ... 其他工具
export const tools: Tool[] = [
searchTool,
globTool,
readFileTool,
writeFileTool,
patchFileTool,
// ... 其他工具
];三个文件工具按读写顺序排列:先读(read_file),再全量写(write_file),最后精确改(patch_file)。它们和两个搜索工具一起,构成了 agent 操作代码仓库的基础能力。
文件工具的安全边界
做完三个文件工具,可以看到它们共享同一套安全模式:
路径限制。 所有工具都检查 filePath.startsWith(state.workingDir),确保操作范围不超出工作目录。
.git 保护。 写操作(write_file、patch_file)额外检查不修改 .git 目录。读操作(read_file)不限制——读 .git/config 通常不会造成问题。
错误信息可操作。 所有错误信息不只是说"出错了",而是指出具体问题并建议下一步操作。比如"文件不存在"告诉模型路径可能错了,"出现多次"告诉模型需要更精确的定位。
这些安全措施不是过度防御。agent 的工具会被模型自主调用,用户不一定在每一步都监督。安全边界越扎实,agent 自主运行时出问题的概率越低。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。