题记:编辑工具的错误返回,不该只有一份讣告,还应该留下一张施工单。

1. 一个 false,究竟什么也没做,还是已经做了一半?⌗

假设一个 Agent 提交了两个互不依赖的文件修改:一个文件里的旧文本重复出现,无法确定该改哪里;另一个文件的上下文唯一,修改没有问题。

工具可以选择整批拒绝。这很整齐,但 Agent 得重新提交本来就能完成的工作。

也可以选择能改多少改多少。这很积极,但如果返回的只是一句“失败”,Agent 就必须先调查现场:哪些已经落盘?哪些还没开始?整个补丁重发,会不会再次修改已经成功的文件?

问题不是允许不允许部分成功,而是工具有没有把部分成功变成一种可继续的状态。

这次补丁执行器的改动把三件事放到了一起:

  • 按解析后的文件路径分组,隔离准备阶段的失败。
  • 同一路径的更新仍然链式计算,不把半个文件的结果写出去。
  • 返回成功清单、失败原因,以及只包含失败文件操作的 remaining_patch。

它没有给批量编辑套上一层更大的事务。相反,它缩小了失败的牵连范围,再把恢复所需的信息补齐。

2. 原子性不是越大越好⌗

旧实现先为整份补丁构建执行动作。只要某个文件在这个阶段抛错,整个准备过程就结束,其他文件也不会进入写入阶段。

新实现先解析补丁,再按 resolveLocalPath(op.path) 的结果分组。下面是省略细节的流程:

整份补丁
   │
   ├─ 语法解析失败 ──────────► 返回错误,不进入文件处理
   │
   ▼
按文件路径分组
   │
   ├─ A:准备成功 ──► 待执行动作
   ├─ B:准备失败 ──► failures + 剩余操作
   └─ C:准备成功 ──► 待执行动作
                         │
                         ▼
                  写入前审核、执行写入
                         │
                         ▼
              changed + failures + remaining_patch

关键不只是多了一个 try/catch,而是它放在了每个文件组的准备过程外面。

同一个文件内部,多个更新仍然按顺序作用于内存中的结果:第二次更新基于第一次更新后的文本,最终才形成写入动作。如果后面的更新匹配失败,这个文件组不会提交动作,前面算出来的修改也不会落盘。

边界这次实现的行为
补丁语法整份解析;语法错误直接返回
同一路径的多个更新链式计算;准备失败则该组不写
不同路径的准备过程一组失败,不丢弃其他组已准备好的动作
跨文件业务一致性不推断,不提供保证

这是一种有条件的选择:文件是执行器能直接识别的隔离单元,却不一定是业务上的独立单元。

例如,接口定义和调用代码放在两个文件里,修改可能必须成对出现。按文件隔离不会替 Agent 判断这种依赖。如果任务要求跨文件全有或全无,就不能把这个接口当作跨文件事务使用。

所以准确的结论不是“按文件提交总比整批提交好”,而是:对彼此独立的编辑,没必要让一个文件的匹配失败取消其他文件的进展。跨文件一致性则需要另一个明确的契约。

3. 剩余补丁不是重试按钮⌗

新的部分失败结果可以概括为下面这个示意结构,字段来自实现,内容为说明而简化:

{
  success: false,
  files_changed: 1,
  changed: [/* 已完成操作及文件摘要 */],
  failures: [/* 失败路径、错误、提示 */],
  remaining_patch: "*** Begin Patch\n...\n*** End Patch"
}

这里最值得注意的是:success: false 与 files_changed: 1 可以同时成立。

false 表示请求未全部完成,不表示文件系统毫无变化。调用者如果把所有失败都理解成“零副作用”,接下来就会在错误的世界模型里行动。

remaining_patch 解决的也不是“如何自动重试”。它只是把失败文件组的原操作重新序列化成合法补丁:包含补丁头尾,以及新增、删除、更新、移动目标和 hunk 等信息;已经成功的文件不会再被塞进去。

这让后续动作从:

读错误 → 回忆原请求 → 对照文件状态 → 剔除成功项 → 重建补丁

变成:

读失败原因 → 修正失败项的前提或上下文 → 提交剩余补丁

可直接续做,不等于原样重发就能成功。 如果原因是旧文本有两个匹配位置,不改上下文、不改变相关前提,剩余补丁依旧会失败。工具交还的是剩余工作,不是一个凭空消除歧义的咒语。

对 Agent 而言,这种返回值比一句“请重试”更有用:它减少了模型必须自行重建的状态,也避免把已经完成的操作再次混进请求。

4. 锚点不是地址,报错别装成它是⌗

另一半改动发生在补丁匹配器里。

这个补丁格式允许用 @@ <某行文本> 提供定位提示。但实现中的锚点不是语法树节点,也不是“第几个同名方法”的地址:它按行文本匹配,且只有在文件中唯一时,才能缩小搜索范围。

测试构造了两个类,它们都有相同的 render() { 和相同的方法体。此时,把 render() { 写成锚点,不能区分两个候选。

旧逻辑会忽略歧义锚点,继续搜索。真正的问题是,失败时的提示仍可能建议用包围块的那一行作锚点——而两个候选的包围行恰好也一样。照着建议做一遍,还是回到原地。工具在认真地指路,只是指向了旋转门。

新逻辑记录锚点的全部匹配位置,并在最终匹配失败的错误里说明:这个锚点匹配了多行,已经被忽略。提示也明确了条件:

  • 增加未修改的上下文行。
  • 锚点行只有在整个文件中唯一时,才能缩小搜索范围。
  • 如果候选共享相同的包围行,用那一行当锚点无法区分它们。

这里没有把“重复锚点”升级成一律拒绝。

另一个已有测试保留了相反情形:方法名重复,但要替换的方法体文本唯一,更新仍然可以成功。锚点无效,不等于补丁必然没有足够的定位证据。

同样需要说清楚:新增的“锚点被忽略”说明位于错误路径。正文上下文足够唯一、补丁直接成功时,代码并没有额外返回一条锚点警告。这次改动修的是失败诊断,不是完整的匹配过程审计。

5. 测试检查的不是报错有多漂亮,而是磁盘里留下什么⌗

新增的执行器测试用了一个很小的场景:失败文件含两行相同的 old,成功文件含一行 before。补丁分别试图把它们改为 new 和 after。

测试的断言组合比单独检查一个布尔值更重要:

检查项测试规定的结果
整体结果success 为 false
完成数量files_changed 为 1
失败说明包含匹配到两个位置的信息
成功文件磁盘内容已经是 after
失败文件两行 old 保持原样
剩余补丁包含失败文件,不包含成功文件

随后,测试把失败文件改成只有一行 old,再提交返回的剩余补丁,断言执行成功,磁盘内容变成 new。

这一步证明的是续做路径确实接得上执行器,而不只是返回了一个看起来像补丁的字符串。

另一个已有测试守住文件内边界:同一路径先有可匹配的更新,后有无法匹配的更新,最终文件保持原样。实现靠准备阶段不落盘做到这一点,不是写完半个文件再倒回去。

匹配器测试则同时检查歧义锚点说明、候选位置与提示文案,并验证补上更完整的类与方法上下文后能够定位。

这些是测试代码规定的行为,不是本文另行运行的性能基准;它们也没有证明所有新增、删除、移动组合都具备同样完整的恢复覆盖。

6. 别把局部恢复写成万能保证⌗

读到这里,很容易给这套设计贴上“支持部分成功与安全重试”的标签。前半句要限定范围,后半句更不能直接成立。

源码里,按文件捕获的是动作构建阶段的错误。后面仍有独立的写入前审核,审核拒绝会直接返回;真正执行写入时,也没有在每个动作外套上同样的错误收集循环。

因此,写入中途发生文件系统错误,并不属于这次新增剩余补丁机制已经覆盖的完整恢复协议。按源路径分组也没有自动解决移动目标重叠等跨路径依赖。

这并不抹掉改动的价值,只是决定了该怎样描述它:

对准备阶段可以隔离的文件失败,保留其他文件的可执行动作,并把未完成的操作结构化交还。

相较于整批准备、一错全退,它保住了独立工作的进展;相较于简单地吞掉错误继续写,它又把进展与欠账一起交还给调用者。

真正值得借鉴的不是“失败了也尽量多做”,而是让失败后的接口仍然描述真实状态。 哪些做完了,哪些没有,为什么没有,下一步拿什么继续——这四件事,比把返回值包装成一个漂亮的成功或失败更重要。


代码依据:落地版本 e1f515c7 的 src/runtime/maxclaw-direct-tools.ts、src/shared/codex-patch.ts 及对应测试文件。文中的流程图与返回结构为根据源码整理的说明,测试场景与断言来自测试代码。