Agent CLI深水区 - 编辑器Diff与补丁引擎 · AI Agent

Agent CLI 深水区 07:编辑器、Diff 与补丁引擎

为什么编辑不是简单 writeFile

让 Agent 改代码,最危险的不是“不会写”,而是:

  • 覆盖用户未保存内容。
  • 替换错位置。
  • 破坏编码和换行。
  • 大文件整体重写导致 diff 混乱。
  • 写入后没有验证。

所以成熟 Agent CLI 通常不会只有 write_file,还会有更精细的编辑工具。

三种编辑方式

方式说明适合
full write整个文件重写新文件、小文件
string replace用 old/new 替换精确小改
patch apply统一 diff / patch多处修改

入门最容易实现的是 string replace。

EditInput

代码块TS · 8 行收起展开
// EditInput = 模型调用 edit_file 时需要传入的参数。
// 这个工具只适合“小范围精确替换”,不适合整文件重写。
type EditInput = {
  path: string;        // 要编辑的文件路径,必须解析到 workspace 内。
  oldText: string;     // 当前文件里必须精确存在的旧文本,相当于编辑锚点。
  newText: string;     // 要替换成的新文本。
  replaceAll?: boolean;// 是否替换所有命中。默认 false,避免误改多个位置。
};

为什么要 oldText?

因为它是防误改的锚点。Agent 必须证明自己知道要替换哪段。

editFile 实现

代码块TS · 55 行收起展开
// editFile = 精确字符串替换版编辑工具。
// 职责:校验路径 -> 读取文件 -> 校验 oldText -> 生成 diff -> 请求批准 -> 写入。
export async function editFile(input: EditInput, ctx: ToolContext): Promise<ToolResult> {
  // 把用户/模型给的相对路径解析成绝对路径,并限制在工作区内。
  // 这一步防止 ../../ 逃逸到 workspace 外。
  const abs = resolveWorkspacePath(ctx.cwd, input.path);

  // 读取当前文件内容。注意真实系统还要检测编码、BOM、二进制文件。
  const original = await readFile(abs, "utf8");

  // 统计 oldText 出现次数。编辑工具的第一条安全线就是“锚点必须明确”。
  const count = countOccurrences(original, input.oldText);

  // 找不到 oldText,说明模型上下文过期或路径错了,必须拒绝。
  if (count === 0) {
    throw new AgentError("TOOL_INPUT_INVALID", "oldText not found.", {
      path: input.path
    });
  }

  // oldText 出现多次时默认拒绝,因为模型可能只想改其中一处。
  // 除非模型显式传 replaceAll=true,才允许全部替换。
  if (count > 1 && !input.replaceAll) {
    throw new AgentError("TOOL_INPUT_INVALID", "oldText appears multiple times.", {
      path: input.path,
      count
    });
  }

  // 在内存里生成新内容,还没有落盘。
  const updated = input.replaceAll
    ? original.split(input.oldText).join(input.newText)
    : original.replace(input.oldText, input.newText);

  // diff 必须由程序根据 old/new 生成,不能让模型自己描述“我改了什么”。
  const diff = createUnifiedDiff(original, updated, input.path);

  // 写入前让用户/权限系统看到真实 diff。
  await requireWriteApproval({
    path: input.path,
    diff
  });

  // 最后才写文件。真实系统建议用 atomic write,而不是普通 writeFile。
  await writeFile(abs, updated, "utf8");

  // 工具返回 diff 和替换次数,模型下一步可以据此决定是否跑测试。
  return {
    summary: `Edited ${input.path}`,
    content: diff,
    metadata: {
      replacements: input.replaceAll ? count : 1
    }
  };
}

这段代码的保护点:

  • 找不到 oldText 就拒绝。
  • oldText 多次出现就拒绝,除非明确 replaceAll。
  • 写入前生成 diff。
  • 写入前要求批准。
  • 返回 diff 给模型和用户。

为什么不能盲目 replace

坏写法:

代码块TS · 1 行收起展开
content.replace("config", "settings")

问题:

  • 可能替换注释。
  • 可能替换字符串。
  • 可能替换多个无关位置。
  • 模型以为改了一处,实际改了多处。

所以 Agent 编辑工具必须要求精确上下文。

Unified Diff

diff 的意义是让用户和 Agent 都看到“改了什么”。

简化实现:

代码块TS · 15 行收起展开
// createUnifiedDiff = 把修改前/后的文本转换成统一 diff。
// 这里是教学版:真实项目建议用 diff 库处理复杂行变更。
function createUnifiedDiff(oldText: string, newText: string, path: string) {
  // 按行比较。这里先假设输入已经处理好 CRLF/LF。
  const oldLines = oldText.split("\n");
  const newLines = newText.split("\n");

  return [
    // diff 头:a/ 表示旧文件,b/ 表示新文件。
    `--- a/${path}`,
    `+++ b/${path}`,
    // simpleLineDiff 负责产出以 +、-、空格开头的变更行。
    ...simpleLineDiff(oldLines, newLines)
  ].join("\n");
}

真实项目建议用成熟 diff 库。自己手写 diff 容易在复杂场景下错。

写入前后都要检查

写入前:

  • 路径安全。
  • oldText 唯一。
  • diff 可展示。
  • 用户批准。

写入后:

  • 文件能重新读取。
  • 内容包含 newText。
  • 可选:运行 formatter。
  • 可选:运行测试。
代码块TS · 11 行收起展开
// verifyEdit = 写入后的最小回读校验。
// 它不能证明语义正确,但能证明文件确实写进去了。
async function verifyEdit(abs: string, expected: string) {
  // 重新从磁盘读取,而不是相信内存里的 updated。
  const after = await readFile(abs, "utf8");

  // 如果新内容没出现,说明写入失败、写错文件或被其他进程覆盖。
  if (!after.includes(expected)) {
    throw new AgentError("TOOL_EXECUTION_FAILED", "Edit verification failed.");
  }
}

编码和换行

Windows 项目常见 CRLF。不要随便把整文件改成 LF。

代码块TS · 12 行收起展开
// detectLineEnding = 判断原文件主要使用哪种换行。
// Windows 项目常见 CRLF,Unix/macOS 项目常见 LF。
function detectLineEnding(text: string) {
  return text.includes("\r\n") ? "\r\n" : "\n";
}

// preserveLineEnding = 把更新后的文本换行统一回原文件风格。
// 目的:避免因为一次小修改导致全文件换行都变了,diff 爆炸。
function preserveLineEnding(original: string, updated: string) {
  const ending = detectLineEnding(original);
  return updated.replace(/\r?\n/g, ending);
}

写入前:

代码块TS · 1 行收起展开
const updatedWithOriginalEnding = preserveLineEnding(original, updated);

Patch Apply

更高级的编辑工具支持 patch:

代码块TS · 10 行收起展开
// PatchInput = 更高级的补丁输入。
// 它不是简单 oldText/newText,而是多个 hunk 组成的结构化 diff。
type PatchInput = {
  path: string; // 要应用 patch 的文件。
  hunks: Array<{
    oldStart: number;   // patch 认为旧文件从第几行开始匹配。
    oldLines: string[]; // 需要在旧文件中找到的上下文/删除行。
    newLines: string[]; // 应用后要变成的新行。
  }>;
};

patch apply 的难点:

  • 行号可能过期。
  • 上下文可能变了。
  • 多 hunk 之间会互相影响行号。
  • 失败时要精确告诉哪一段失败。

Patch 失败如何恢复

当 patch 失败,不要让模型继续猜。

返回:

代码块TEXT · 6 行收起展开
Patch failed at hunk 2.
Expected context:
...
Actual nearby content:
...
Please re-read the file before generating a new patch.

这样模型下一轮会先读文件,而不是继续用过期上下文。

Edit Tool 的审计

代码块TS · 12 行收起展开
// EditAudit = 编辑审计记录。
// 真实 Agent CLI 应该能回答:谁在什么时候改了哪个文件、改前改后是什么版本。
type EditAudit = {
  sessionId: string;                         // 哪个会话发起的编辑。
  path: string;                              // 被修改的文件。
  editType: "replace" | "write" | "patch";   // 编辑方式:精确替换、整文件写入、patch。
  oldHash: string;                           // 修改前文件 hash。
  newHash: string;                           // 修改后文件 hash。
  diff: string;                              // 真实 diff,用于审查和回放。
  approvedByUser: boolean;                   // 是否经过用户/策略批准。
  time: string;                              // 发生时间。
};

为什么要 hash?

因为 diff 可能很长,hash 能快速确认文件版本。

模型如何学会正确编辑

工具描述要写清楚:

代码块TEXT · 4 行收起展开
Use edit_file for small, precise replacements.
oldText must match the current file exactly.
If oldText is not unique, read the file again and include more surrounding context.
Do not use write_file for modifying existing large files unless replacing the whole file is intended.

工具描述不是随便写,它会直接影响模型调用方式。

编辑系统常见坑

后果修复
整文件重写diff 巨大,易覆盖用户改动小范围 edit
oldText 不唯一改错位置要求唯一或 replaceAll
不看 diff用户无法审查写前 diff preview
不保留换行全文件变更检测 CRLF/LF
patch 失败继续猜越改越乱要求 re-read
写完不验证以为成功实际失败read back check

读源码时看编辑引擎

重点找:

  1. 有哪些编辑工具。
  2. 是否区分 write/edit/patch。
  3. oldText 是否必须唯一。
  4. diff 在哪里生成。
  5. 用户批准发生在写前还是写后。
  6. 是否保留换行和编码。
  7. patch 失败如何反馈。
  8. 写入后是否验证。

编辑引擎决定 Agent 是“可靠协作”,还是“随机覆盖文件”。

从一次真实编辑看全链路

假设用户说:

src/config.ts 里的默认模型从 sonnet 改成 opus,顺便更新测试。

成熟 Agent CLI 不应该立刻写文件,而是走一条可审计链路:

编辑引擎链路 修改目标 QueryEngine Search ReadFile 生成编辑 EditEngine 预检查 Diff 预览 原子写入 测试汇报

这条链路的核心不是“模型会写代码”,而是每一步都有边界:

  • 搜索负责定位,不让模型凭记忆猜路径。
  • 读取负责建立当前事实,不让模型使用过期上下文。
  • 编辑引擎负责把意图转换成可验证变更。
  • 权限层负责在落盘前让人看见 diff。
  • 测试负责证明改动没有破坏行为。
  • Trace 负责让失败能回放。

文件状态模型

编辑系统至少要区分四种状态:

Edit Transaction 写文件前必须先证明版本没变。 Unknown Loaded Planned Preview Approved Written Verified Stale Rejected

read plan diff ok write check hash deny

关键点:LoadedWritten 之间,文件可能被用户或另一个进程改掉。
所以编辑工具不能只依赖路径和 oldText,还应该记录读取时的 hash。

代码块TS · 17 行收起展开
// LoadedFile = Agent 读到的文件快照。
// 重点是 hash:后续写入前要确认磁盘文件还是这个版本。
type LoadedFile = {
  path: string;                                // 文件路径。
  text: string;                                // 读取到的完整文本。
  hash: string;                                // 读取时的内容 hash,用作乐观锁。
  lineEnding: "\n" | "\r\n";                   // 原始换行风格。
  encoding: "utf8" | "utf8-bom" | "unknown";   // 原始编码信息,写回时要尽量保持。
};

// EditPlan = 尚未落盘的编辑计划。
// baseHash 表示“这个计划基于哪个文件版本生成”。
type EditPlan = {
  path: string;                    // 要修改的文件。
  baseHash: string;                // 生成计划时文件的 hash。
  operations: EditOperation[];     // 一个或多个编辑操作,例如 replace、insert、delete。
};

写入前重新读一次:

代码块TS · 18 行收起展开
// assertNotStale = 写入前的乐观锁检查。
// 如果用户或另一个工具在中途改了文件,就拒绝继续写。
async function assertNotStale(plan: EditPlan, abs: string) {
  // 写入前重新读当前磁盘内容。
  const current = await readFile(abs, "utf8");

  // 计算当前版本 hash。
  const currentHash = sha256(current);

  // 如果当前 hash 和计划里的 baseHash 不同,说明上下文过期。
  if (currentHash !== plan.baseHash) {
    throw new AgentError("FILE_STALE", "File changed after it was read.", {
      path: plan.path,
      expectedHash: plan.baseHash,
      actualHash: currentHash
    });
  }
}

这就是乐观锁。它和数据库里的 version 字段是同一个思想:我可以修改,但必须证明我修改的是刚才读到的那个版本。

oldText 精确匹配为什么还不够

oldText 唯一匹配只能防一类错,防不了全部错。

风险oldText 能否发现需要额外机制
找不到文本返回失败并要求重读
同一文本多处出现要求更多上下文
文件被用户改过不一定baseHash / mtime 检查
换行符被全文件转换不能行尾检测与保持
编码从 UTF-8 BOM 变无 BOM不能编码探测
目标是生成文件不能generated file guard
语义改错位置不能AST / 测试 / 人审
二进制文件被当文本不能binary detection

所以“可用编辑工具”通常是组合防线:

代码块TEXT · 10 行收起展开
path allowlist
+ binary check
+ file size limit
+ baseHash
+ oldText uniqueness
+ diff preview
+ approval
+ atomic write
+ read-back verification
+ optional formatter/test

原子写入

直接 writeFile(abs, updated) 有两个问题:

  • 写到一半进程崩了,文件可能损坏。
  • Windows 上杀进程、杀终端时更容易留下半文件。

更稳妥的写法:

代码块TS · 15 行收起展开
// atomicWriteText = 原子写入教学版。
// 思路:先写临时文件,再 rename 覆盖目标文件,降低半写入风险。
async function atomicWriteText(abs: string, text: string) {
  // 临时文件放在同目录,避免跨磁盘 rename 失败。
  const dir = dirname(abs);

  // 带 pid 是为了减少多个进程同时写临时文件时重名。
  const tmp = join(dir, `.${basename(abs)}.${process.pid}.tmp`);

  // 先把完整内容写到临时文件。
  await writeFile(tmp, text, "utf8");

  // rename 在同一文件系统内通常是原子替换。
  await rename(tmp, abs);
}

真实系统还要处理:

  • 目标文件权限。
  • tmp 文件清理。
  • 跨磁盘 rename 失败。
  • Windows 文件被占用。
  • antivirus / editor lock。

如果写失败,不要假装成功,要把错误归类:

代码块TS · 9 行收起展开
// WriteFailureKind = 写入失败分类。
// 分类越具体,模型越容易知道下一步是重试、重读,还是请求用户处理权限。
type WriteFailureKind =
  | "permission_denied" // 没有写权限。
  | "file_locked"      // 文件被编辑器、杀毒软件或其他进程占用。
  | "path_not_found"   // 目录或文件不存在。
  | "disk_full"        // 磁盘空间不足。
  | "stale_file"       // 文件版本变了,不能基于旧上下文写。
  | "unknown";         // 未分类错误,应该保留原始错误信息。

错误越具体,模型越容易下一步恢复。

Patch Engine 的核心对象

不要把 patch 当字符串处理。它应该被解析成结构。

代码块TS · 27 行收起展开
// Patch = 一个完整补丁,可能涉及多个文件。
type Patch = {
  files: FilePatch[]; // 每个 FilePatch 对应一个文件的增删改。
};

// FilePatch = 单个文件的补丁。
type FilePatch = {
  oldPath: string; // 修改前路径。新增文件时可能是 /dev/null。
  newPath: string; // 修改后路径。删除文件时可能是 /dev/null。
  hunks: Hunk[];   // 一个文件里可能有多处不连续修改。
  kind: "add" | "delete" | "modify" | "rename"; // 文件级操作类型。
};

// Hunk = patch 中一段连续的修改区域。
type Hunk = {
  oldStart: number; // 旧文件中这段从第几行开始。
  oldCount: number; // 旧文件中这段覆盖多少行。
  newStart: number; // 新文件中这段从第几行开始。
  newCount: number; // 新文件中这段覆盖多少行。
  lines: PatchLine[]; // 具体每一行是上下文、删除还是新增。
};

// PatchLine = hunk 内的行。
type PatchLine =
  | { kind: "context"; text: string } // 未改变的上下文行,用来定位。
  | { kind: "remove"; text: string }  // 旧文件中要删除的行。
  | { kind: "add"; text: string };    // 新文件中要新增的行。

Apply 的结果也要结构化:

代码块TS · 16 行收起展开
// ApplyResult = patch 应用结果。
// 成功时返回更新后的文本;失败时返回失败 hunk 和附近上下文。
type ApplyResult =
  | {
      ok: true;             // patch 应用成功。
      updatedText: string;  // 应用 patch 后的新文件内容。
      appliedHunks: number; // 成功应用了几个 hunk。
      warnings: string[];   // 非致命提示,例如行号发生漂移。
    }
  | {
      ok: false;            // patch 应用失败。
      failedHunkIndex: number; // 第几个 hunk 失败。
      reason: "context_not_found" | "overlap" | "malformed_patch"; // 失败原因。
      expected: string;     // patch 期望找到的上下文。
      nearby: string;       // 当前文件中相近位置的内容,帮助模型重读和修正。
    };

失败时最有价值的信息不是“失败了”,而是:

  • 第几个 hunk 失败。
  • 期望上下文是什么。
  • 当前文件附近内容是什么。
  • 是否建议重读文件。

这能把模型从“继续猜 patch”拉回“重新建立事实”。

Hunk 定位:行号只是提示,不是真理

Unified diff 里有行号,但行号可能过期。稳一点的做法:

  1. 先按 oldStart 附近尝试匹配。
  2. 匹配失败后,在有限窗口内搜索上下文。
  3. 仍失败则全文件搜索完整 remove/context 块。
  4. 多处匹配则拒绝,要求更精确上下文。

伪代码:

代码块TS · 23 行收起展开
// locateHunk = 在当前文件里找到某个 hunk 应该应用的位置。
// 返回值是 0-based 行号;找不到或多义时应该返回 null/失败。
function locateHunk(lines: string[], hunk: Hunk): number | null {
  // add 行是新内容,不应该参与“在旧文件里定位”。
  // context/remove 才是旧文件中应该存在的内容。
  const expected = hunk.lines
    .filter(line => line.kind !== "add")
    .map(line => line.text);

  // patch 里的 oldStart 是 1-based,这里转成数组下标 0-based。
  const hinted = hunk.oldStart - 1;

  // 先在 oldStart 附近搜索,允许上下文有少量行号漂移。
  const window = range(Math.max(0, hinted - 20), Math.min(lines.length, hinted + 20));

  // 如果附近能唯一匹配,直接使用。
  const local = findUniqueMatch(lines, expected, window);
  if (local !== null) return local;

  // 附近找不到,再全文件搜索;如果多处匹配,findUniqueMatch 应该拒绝。
  const global = findUniqueMatch(lines, expected, range(0, lines.length));
  return global;
}

这里的原则是:可以容忍轻微行号漂移,但不能容忍多义匹配。

三路合并的心智模型

Agent 编辑最好理解成三路合并:

版本含义
BaseAgent 读取时的文件
Current写入前磁盘上的文件
DesiredAgent 想写成的文件

如果 Base == Current,可以安全写。
如果 Base != Current,说明用户或其他工具改过文件。

此时有三种策略:

策略行为适合
fail fast拒绝写入,要求重读默认安全策略
rebase patch把 patch 尝试应用到 Current小范围非冲突修改
merge做三路合并并标冲突高级 IDE/编辑器场景

Agent CLI 默认应该 fail fast。
因为模型不是 Git,它很容易把用户刚改的东西“合并没了”。

编辑权限不是一个 yes/no

粗糙权限:

代码块TEXT · 1 行收起展开
Allow write? yes/no

成熟权限应该看风险:

风险项低风险高风险
文件数量1 个文件多文件批量
文件类型源码/测试配置/密钥/锁文件
改动大小几行大段重写
路径workspace 内workspace 外
操作replacedelete/rename
可恢复性git trackeduntracked 且无备份

可以设计风险评分:

代码块TS · 22 行收起展开
// scoreEditRisk = 根据变更规模和敏感度给编辑打风险分。
// 分数越高,越应该要求用户明确确认,甚至禁止自动执行。
function scoreEditRisk(change: PlannedChange): number {
  let risk = 0;

  // 一次改太多文件,说明影响面变大。
  if (change.files.length > 3) risk += 2;

  // 改动行数太多,diff 审查成本高,也更容易误伤。
  if (change.totalChangedLines > 80) risk += 2;

  // 命中密钥、配置、锁文件等敏感路径时提高风险。
  if (change.paths.some(isSensitivePath)) risk += 3;

  // workspace 外路径基本应该直接拒绝,这里给极高分。
  if (change.paths.some(isOutsideWorkspace)) risk += 10;

  // 删除操作比普通替换危险。
  if (change.kind === "delete") risk += 3;

  return risk;
}

风险高不一定禁止,但要升级提示。

编辑工具返回值要喂给模型

编辑完成后,工具返回值不是给用户看的摘要而已,它还是下一轮模型决策的事实来源。

代码块TS · 14 行收起展开
// EditToolResult = 编辑工具返回给 QueryEngine/模型的事实。
// 它应该足够详细,让模型知道下一步该不该跑测试、该不该继续改。
type EditToolResult = {
  summary: string;        // 一句话摘要,例如 Edited src/config.ts。
  diff: string;           // 真实 diff。
  changedFiles: string[]; // 本次改动涉及哪些文件。
  oldHash: string;        // 修改前版本。
  newHash: string;        // 修改后版本。
  verification: {
    readBack: boolean;                         // 是否写后回读。
    formatter?: "passed" | "failed" | "skipped"; // 是否跑了 formatter。
    tests?: "passed" | "failed" | "skipped";     // 是否跑了测试。
  };
};

如果只返回 ok,模型下一步不知道:

  • 改了哪些文件。
  • diff 是什么。
  • 有没有验证。
  • 是否还需要跑测试。
  • 失败时该读哪个文件。

编辑系统的测试用例

编辑引擎要写单元测试,不要只靠人工体验。

测试目标
oldText 找不到必须失败
oldText 多处出现默认失败
replaceAll=true多处替换才允许
CRLF 文件写后仍是 CRLF
UTF-8 BOM写后保留 BOM
文件 hash 变化写前拒绝
patch 行号漂移可在窗口内匹配
patch 多处匹配拒绝
binary file拒绝文本编辑
workspace 外路径拒绝
atomic write 失败不报告成功

一个最小测试例子:

代码块TS · 14 行收起展开
// 这个测试验证:文件被别人改过后,旧编辑计划不能继续写入。
it("rejects stale file before writing", async () => {
  // 先读取文件,生成一个基于当前 hash 的编辑计划。
  const loaded = await loadFile("config.ts");
  const plan = buildReplacePlan(loaded, "sonnet", "opus");

  // 模拟用户或其他进程在中途修改了同一个文件。
  await writeFile("config.ts", "user changed it", "utf8");

  // 应用旧计划必须失败,并且错误码应该明确是 FILE_STALE。
  await expect(applyEditPlan(plan)).rejects.toMatchObject({
    code: "FILE_STALE"
  });
});

Java 后端视角类比

如果你从 Java 后端转 Agent CLI,可以这样类比:

Agent 编辑概念Java 后端类比
baseHash数据库乐观锁 version
diff preview事务提交前审计日志
write approval高危操作二次确认
atomic write事务 commit
patch failureSQL update affected rows = 0
read-back verification写后查询确认
per-file lock按资源 key 加锁
audit record操作日志 / binlog

真正的难点不是“怎么写文件”,而是把文件修改做成一套小型事务系统。

源码验收门槛

编辑引擎的验收标准必须比普通 writeFile 高。因为它直接修改用户资产,错误一次就可能污染整个工作区。

验收项必须看到的源码证据不合格表现
写前快照读取内容、mtime/hash、编码、换行风格只拿路径和 newText
预览可审用户批准的是具体 diff 或 patch plan只显示“将修改文件”
stale 检测apply 前重新读取并比较 base hash用户中途改文件仍覆盖
原子写入temp file + rename 或平台等价机制写一半崩溃留下半截文件
写后验证read-back hash/diff 确认落盘内容调用 writeFile 成功就算完成
失败可恢复patch 失败返回邻近上下文和失败 hunk只说 replace failed
审计可回放记录 old/new hash、diff、approval、tool call id只能从 git diff 猜发生了什么

最小回归样例:同一个文件做三次测试:精确替换成功、oldText 不存在失败、中途被外部修改失败。合格实现必须只让第一种落盘,后两种保留原文件,并给模型足够上下文重新制定编辑方案。

读源码时的高阶问题

读到编辑模块时,不要只问“有没有 edit_file”。要追问:

  1. 文件读取后是否记录 hash?
  2. 写入前是否检测 stale?
  3. diff 是模型生成的,还是程序根据 old/new 生成的?
  4. 用户批准的是自然语言描述,还是实际 diff?
  5. patch 失败时是否返回附近上下文?
  6. 大文件、二进制、生成文件、锁文件如何处理?
  7. 是否支持 dry-run?
  8. 是否能撤销或回滚?
  9. 编辑 trace 是否可回放?
  10. 测试失败后是否保留已写文件,还是自动回滚?

这些问题决定这个 Agent 是“能帮你写代码”,还是“你要防着它写代码”。