上一篇:Git 与 Diff Tools:状态、补丁解析与变更摘要
下一篇:Approval 与 Sandbox:写入审批和工作区边界
English version: Patch Editing for AI Coding Agents: Apply Unified Diff Safely
文章介绍
Day 10 我们实现了 git_status 和 git_diff,让 agent 能观察当前工作区变化。
今天进入真正的代码编辑执行层:实现 apply_patch。
Coding agent 修改代码有两种常见方式:
- 直接整文件覆盖。
- 生成 unified diff,再由 harness 应用 patch。
Mini harness 选择第二种。原因是 patch 更适合审查、回滚和失败恢复:模型要说明“哪几行变了”,工具负责验证这些 hunk 是否能应用到当前文件。

今天要解决什么
今天完成五个交付:
- 新增
apply_patch工具。 - 支持多文件 unified diff。
- 使用
diff包的parsePatch和applyPatch,不手写 hunk 匹配。 - 在写文件前先完整准备所有变更,避免只写入一半。
- 对解析失败、hunk 冲突、不支持的 binary/rename/copy 返回可恢复错误。
Demo 命令:
npm run dev -- "apply a small patch to a fixture file"
手动 dry-run:
npm run dev -- --tool apply_patch --tool-input '{"patch":"diff --git a/demos/day-11/fixture.txt b/demos/day-11/fixture.txt\n--- a/demos/day-11/fixture.txt\n+++ b/demos/day-11/fixture.txt\n@@ -1,3 +1,3 @@\n Day 11 fixture\n-status: pending\n+status: patched\n Keep this file small.","dryRun":true}'
它在 Cursor/Codex 里对应哪一层
今天实现的是 code editing execution layer。
在 Cursor、Codex、Claude Code 这类 coding agent 中,模型本身不应该直接“随意写磁盘”。更稳的结构是:
Model proposes a patch
Harness validates paths
Patch engine matches hunks
Harness writes files
Git tools verify resulting diff
也就是说,模型负责意图和 patch 内容,harness 负责执行边界。
Day 11 的 apply_patch 还没有加入审批。审批会放在 Day 12。今天先把 patch 执行能力做出来,并让错误足够清晰,方便模型下一轮修正。
设计思路
1. Patch 比整文件覆盖更适合 agent
整文件覆盖的问题是 blast radius 太大。
如果模型只想改一个函数,却返回整个文件内容,harness 很难判断:
- 哪些行是模型真正要改的?
- 哪些行只是模型重写时顺手格式化了?
- 用户刚刚改过但模型没看到的行是否会被覆盖?
- 文件很大时,是否有内容在模型输出里被截断?
Unified diff 把变更限定在 hunk 里:
@@ -1,3 +1,3 @@
Day 11 fixture
-status: pending
+status: patched
Keep this file small.
应用 patch 时,工具会检查上下文行是否仍然匹配当前文件。匹配不上就失败,而不是硬写。
2. 核心 patch 逻辑交给成熟库
Day 10 已经引入 diff 包。Day 11 继续复用它:
import { applyPatch, parsePatch } from "diff";
流程是:
patch text
-> parsePatch()
-> validate file paths
-> read source files
-> applyPatch()
-> write pending results
这里最重要的是:hunk 匹配不是我们手写的。applyPatch 会处理 unified diff 的上下文匹配、行号偏移和换行风格转换。
3. 写入前先准备所有文件
多文件 patch 最怕半成功:
file A applied
file B conflict
file C never tried
如果 file A 已经写入磁盘,这次工具调用就留下了中间状态。
所以 apply_patch 的实现分两步:
prepare phase:
parse all patches
read all source files
apply all hunks in memory
collect pending writes/deletes
commit phase:
only if prepare phase has no error
write files or delete files
这不是完整事务系统,但已经避免了最常见的“一半文件已经被写入”的问题。
4. 错误要可恢复
Patch 冲突不应该让整个 agent run 崩溃。
工具返回:
{
"applied": false,
"dryRun": false,
"error": "Patch hunk failed to apply cleanly: demos/day-11/fixture.txt",
"files": []
}
模型看到这个结果后,可以选择:
- 重新读取目标文件。
- 根据当前内容生成新的 patch。
- 向用户解释为什么不能自动应用。
这就是 recoverable error。
5. Day 11 支持文本 patch,不支持复杂 Git 操作
今天支持:
- 修改文件。
- 新建文件。
- 删除文件。
- 多文件 patch。
- dry-run。
- 可选
fuzzFactor。
今天暂不支持:
- binary patch。
- rename patch。
- copy patch。
- 文件权限 mode 变更。
这些能力要么需要更严格的安全策略,要么和 Day 12 approval/sandbox 更相关。
实现步骤
1. 新增 patch tool
文件:apps/mini-harness/src/tools/patch.ts
注册函数:
export function registerPatchTools(registry: ToolRegistry, workspaceRoot: string): void {
const root = resolve(workspaceRoot);
for (const tool of createPatchTools(root)) {
registry.register(tool);
}
}
工具 schema:
inputSchema: {
type: "object",
properties: {
patch: { type: "string" },
dryRun: { type: "boolean" },
fuzzFactor: { type: "number" },
},
required: ["patch"],
}
2. 解析 patch
const parsed = parsePatch(patchText);
如果解析失败,直接返回:
{
"applied": false,
"error": "Patch parse failed: ..."
}
这里不 throw,是因为对 agent 来说,patch 格式错误是可修复输入错误。
3. 校验 patch 类型
当前版本会拒绝:
patch.isBinary
patch.isRename
patch.isCopy
原因是这些不是简单文本 hunk 写入。后续可以扩展,但 Day 11 先保持边界清晰。
4. 校验 workspace path
每个 patch path 都会先去掉 Git 的 a/、b/ 前缀:
path.replace(/^[ab]\//, "")
然后 resolve 到 workspace root 内:
const absolutePath = resolve(workspaceRoot, inputPath);
const relativePath = relative(workspaceRoot, absolutePath);
如果路径逃出 workspace,直接拒绝:
Path is outside workspace: ...
5. 应用 hunk
核心调用:
const patchedContent = applyPatch(sourceContent, patch, {
fuzzFactor,
autoConvertLineEndings: true,
});
如果返回 false,说明 hunk 无法匹配当前文件:
{
"applied": false,
"error": "Patch hunk failed to apply cleanly: ..."
}
6. 注册到默认工具表
文件:apps/mini-harness/src/tools/registry.ts
现在默认 registry 里有:
registerLocalFileTools(registry, workspaceRoot);
registerGitTools(registry, workspaceRoot);
registerPatchTools(registry, workspaceRoot);
这意味着 agent loop、manual tool dispatch、transcript logging、context report 都能看到 apply_patch。
7. 更新 mock provider
文件:apps/mini-harness/src/model/provider.ts
当任务包含 patch、apply_patch、edit、modify 等关键词时,mock provider 会返回一个 dry-run patch:
TOOL_CALL {"name":"apply_patch","input":{"patch":"...","dryRun":true}}
这样 Day 11 demo 可重复运行,不会反复改写 fixture 文件。
Demo
运行:
npm run dev -- "apply a small patch to a fixture file"
你会看到类似:
[mini-harness] Step 1 [model]: Model requested tool: apply_patch
[mini-harness] Step 1 [tool]: Tool completed: apply_patch
[mini-harness] Step 2 [final]: Model returned a final answer.
手动 dry-run 返回:
{
"applied": true,
"dryRun": true,
"fileCount": 1,
"files": [
{
"path": "demos/day-11/fixture.txt",
"operation": "modify",
"additions": 1,
"deletions": 1,
"hunks": 1
}
]
}
如果你把 patch 里的上下文改错,会得到 recoverable error,而不是进程崩溃。
当前系统能力变化
Day 11 后,mini harness 已经具备最小编辑闭环:
read_file/search_text
-> git_status/git_diff
-> model proposes unified diff
-> apply_patch
-> git_diff verifies result
这一步让 agent 从“理解代码”进入“能修改代码”。
遇到的问题
1. 为什么 demo 默认 dry-run
Demo 要能重复运行。如果每次都真实写 fixture,第二次运行时原 patch 就会因为内容已经变化而冲突。
所以 mock provider 使用 dryRun: true。真实模型或手动命令可以把它改成 false,工具就会写文件。
2. 为什么不支持 rename/copy
Rename/copy patch 涉及多路径操作、删除旧文件、创建新文件、甚至文件交换。这个能力可以做,但它需要更严格的写入审批和错误恢复策略。
Day 11 先聚焦文本 hunk 应用。Day 12 加入 approval/sandbox 后,再扩展更危险的写入操作会更合理。
3. 为什么路径越界直接失败
Patch 工具是写文件能力,不能允许 ../ 或绝对路径逃出 workspace。
后续审批机制不能替代路径边界。路径边界是工具层的基本安全约束。
明天做什么
Day 12 会加入 Approval and Sandbox。
到那时,apply_patch 这种写入工具会进入更严格的执行策略:
model proposes patch
-> harness classifies write action
-> approval gate
-> workspace sandbox
-> apply_patch
这会让 mini harness 更接近真实 coding agent 的安全模型。
Comments