apply_patch · Codex 的文件编辑格式
Codex 没有直接写工作区文件、替换字符串的工具,改文件统一走 apply_patch:一种以 *** Begin Patch 开头、按文件分段、不带行号的精简 diff。解析器永远以宽松模式运行,seek_sequence 分四级放宽匹配,写进 shell 命令的补丁也会被截下来按补丁处理;补丁先校验、再审批,最后在执行环境的文件系统里逐个 hunk 落盘,不保证原子性。
apply_patch · Codex 的文件编辑格式
Codex 的内置工具里找不到 write_file、edit_file 这类直接改工作区文件的工具。模型想改文件,要么在 shell 里自己动手,要么调用 apply_patch,后者是正路。它的实现分两处:格式的解析与应用在独立的 codex-apply-patch crate,审批、沙箱与事件在 codex-core 的 tools/handlers/apply_patch.rs。
用户看到的样子
模型改文件时,界面上出现一个文件变更条目,带着彩色 diff;补丁写到可写目录之外、或者审批策略是 untrusted 时,会先弹出审批,同样附带 diff(见沙箱、审批与安全)。hook 里按 apply_patch 匹配它,也可以写成 Edit 或 Write(见 Hooks)。这个工具出不出现由模型目录决定:ModelInfo.apply_patch_tool_type 有值且当前有执行环境时才注册,而这个类型在 0.158.0 里只有 Freeform 一种取值。
补丁长什么样
下面这段补丁取自 parser.rs 的单元测试,一次做了三件事:新建文件、删除文件、修改并改名另一个文件。
*** Begin Patch
*** Add File: path/add.py
+abc
+def
*** Delete File: path/delete.py
*** Update File: path/update.py
*** Move to: path/update2.py
@@ def f():
- pass
+ return 123
*** End Patch规则很少:整段夹在 *** Begin Patch 与 *** End Patch 之间;新建文件的每一行都以 + 开头;修改文件由若干块组成,每块以 @@ 或 @@ <一行上下文> 开头(第一块可以省略),那一行上下文(通常是类名或函数签名)用来缩小查找范围,块内每行以空格(上下文)、-(删除)或 +(新增)开头;块末可以加 *** End of File,表示这些旧行必须落在文件结尾。和 unified diff 最大的不同是没有行号,定位全靠上下文。它是 Responses API 的 custom 类型工具,格式用一份 Lark 语法描述、随请求发给模型,工具描述还特意叮嘱不要再包一层 JSON:
start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?
filename: /(.+)/
add_line: "+" /(.*)/ LF -> line
change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF(codex-rs/core/assets/tools/apply_patch.lark:1)
会话里有多个执行环境时,create_apply_patch_freeform_tool 会往语法里插一条可选的 *** Environment ID: <id> 行,放在 *** Begin Patch 之后,用来指明补丁作用于哪个环境。文件路径按所选环境的工作目录解析。
parser.rs 文件头也抄了一份语法,但与模型实际拿到的 apply_patch.lark 有出入:前者把 add_line 与 change_line 写成 /(.+)/,要求前缀后至少一个字符;后者是 /(.*)/,允许只有前缀的空行。以 .lark 文件为准,解析器也接受这种空行。
解析:永远宽松,逐行推进
parse_patch 有严格和宽松两种模式,但开关是写死的:
/// Currently, the only OpenAI model that knowingly requires lenient parsing is
/// gpt-4.1. While we could try to require everyone to pass in a strictness
/// param when invoking apply_patch, it is a pain to thread it through all of
/// the call sites, so we resign ourselves allowing lenient parsing for all
/// models. See [`ParseMode::Lenient`] for details on the exceptions we make for
/// gpt-4.1.
const PARSE_IN_STRICT_MODE: bool = false;(codex-rs/apply-patch/src/parser.rs:47)
解析器本来就容忍标记行前后的空白;宽松模式再多做一件事:整段如果被 <<EOF、<<'EOF' 或 <<"EOF" 与结尾的 EOF 包着,就剥掉这层 heredoc 外壳。ParseMode::Lenient 的注释解释了来由:GPT-4.1 会把以 <<'EOF' 开头的整段 heredoc 当作 apply_patch 的参数,直接放进不经 shell 的 argv,heredoc 标记于是成了补丁正文的一部分。
真正干活的是 StreamingPatchParser,一个按行推进的状态机:NotStarted 只接受 *** Begin Patch,之后在 StartedPatch、AddFile、DeleteFile、UpdateFile 之间切换,遇到 *** End Patch 进入 EndedPatch,每条错误都带行号,例如 Update file hunk for path 'test.py' is empty。它的 push_delta(codex-rs/apply-patch/src/streaming_parser.rs:139)逐字符攒行,遇到换行(顺手去掉行尾的 \r)才处理一行,每次都返回目前为止解析出的全部 hunk;非流式的 parse_patch 只是把整段文本一次喂进去,再调用 finish 检查结尾。流式接口是为模型边生成边展示准备的:ApplyPatchHandler 实现了注册表里的 create_diff_consumer,模型每吐出一段补丁,解析器就多推进几行,产出目前已知的文件变更,按至少 500 毫秒的间隔发 PatchApplyUpdated 事件。这条路径受仍在开发中的 apply_patch_streaming_events 特性控制,默认关闭。
定位:四级放宽的 seek_sequence
应用一个修改块时,compute_replacements 先用 @@ 后面那行上下文找到大致位置,再从那里往下找块里的旧行(上下文行加 - 行);带 *** End of File 的块从文件末尾找起;旧行以空行结尾却找不到时,去掉这个代表末尾换行的空行再试一次。所有替换算好之后按位置倒序应用,前面的替换不会挪动后面的下标。查找本身在 seek_sequence 里,严格程度逐级下降:
// Exact match first.
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
if lines[i..i + pattern.len()] == *pattern {
return Some(i);
}
}
// Then rstrip match.
for i in search_start..=lines.len().saturating_sub(pattern.len()) {
let mut ok = true;
for (p_idx, pat) in pattern.iter().enumerate() {
if lines[i + p_idx].trim_end() != pat.trim_end() {
ok = false;
break;
}
}
if ok {
return Some(i);
}
}(codex-rs/apply-patch/src/seek_sequence.rs:39)
第三级忽略首尾空白,第四级再把各种破折号、弯引号、不间断空格等 Unicode 标点归一成 ASCII,注释说这是在模仿 git apply 定位上下文时的模糊行为。四级都失败,模型会收到 Failed to find expected lines in <路径> 加上它写的旧行,或者 Failed to find context '<上下文>' in <路径>。换行符默认统一成 LF(ApplyPatchFileUpdateMode::NormalizeToLf),保留原文件换行风格的 apply_patch_preserve_line_endings 还在开发中。
模型把补丁写进 shell 命令时
内置的缺省基础指令(BaseInstructions 的默认值,codex-rs/protocol/src/prompts/base_instructions/default.md)里,示范写法仍是 {"command":["apply_patch","*** Begin Patch\n..."]} 这种命令调用形式,模型也常写成 apply_patch <<'EOF' ... EOF。所以补丁有三个入口:
exec_command 在起进程之前调用 intercept_apply_patch,由 maybe_parse_apply_patch 判断这条命令是不是补丁:
pub fn maybe_parse_apply_patch(argv: &[String], cwd: &PathUri) -> MaybeApplyPatch {
match argv {
// Direct invocation: apply_patch <patch>
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
// Shell heredoc form: (optional `cd <path> &&`) apply_patch <<'EOF' ...
_ => match parse_shell_script(argv, cwd) {
// ...
None => MaybeApplyPatch::NotApplyPatch,
},
}
}(codex-rs/apply-patch/src/invocation.rs:113)
shell 形式认 bash、zsh、sh 的 -c / -lc,PowerShell 的 -Command(前面可以有 -NoProfile)与 cmd 的 /c;脚本用 tree-sitter-bash 查询匹配,必须整段只有一条语句:apply_patch <<'EOF' ... EOF,或者 cd <目录> && apply_patch <<'EOF' ... EOF,命令名写成 applypatch 也认。前后多一句 echo 就不算,注释的理由是别的写法多半是模型出错。认出来的补丁走和 apply_patch 工具完全相同的校验、审批与应用流程,不会真的起进程。
认不出来的命令照常交给 shell。这时如果脚本里调用了 apply_patch,找到的其实是 Codex 自己:codex-arg0 启动时建一个临时目录,放一个指向当前可执行文件的 apply_patch 符号链接(Windows 上是带 --codex-run-as-apply-patch 参数的 apply_patch.bat),并把它加到 PATH 最前面。Unix 上以这个名字启动的 Codex 直接进入 codex-rs/apply-patch/src/standalone_executable.rs,补丁可以来自参数或 stdin;Windows 的 .bat 走隐藏参数,只接受参数形式。两者都在当前目录应用补丁,它们只是沙箱里 shell 的普通子进程,受同一个沙箱约束。
从校验到落盘
ApplyPatchHandler 拿到补丁后分三步走。先校验:verify_apply_patch_args_with_mode 在执行环境的文件系统上读出每个待改文件,算出 unified diff 与新内容,得到一个 ApplyPatchAction,这一步只读不写。再判定:assess_patch_safety 规定空补丁直接拒绝、untrusted 一律询问;否则只要所有目标路径(含改名的目的地)都在可写范围内、且有沙箱兜底(或者权限配置本来就不设沙箱),就自动批准;够不上自动批准时,审批策略为 never(或细粒度配置关掉了沙箱审批)就拒绝,其余情况询问用户。细节留给审批流程。最后执行:ToolOrchestrator 驱动 ApplyPatchRuntime,它不起子进程,而是在进程内调用 apply_patch_with_options,通过执行环境的文件系统接口写文件,沙箱由传入的 FileSystemSandboxContext 约束;写入被沙箱拒绝时,编排器照常按策略请求批准后重试。
应用是逐个 hunk 进行的,不是事务。中途失败时,前面已经写下的改动不会回滚,AppliedPatchDelta 把真正提交了的变更记下来(写失败时还会标记“不再精确”),ToolEmitter 据此更新整轮的 diff,让它与磁盘上的实际状态一致。成功时模型收到的是 Success. Updated the following files: 加上以 A、M、D 开头的文件清单,外面再包一层 Exit code、Wall time、Output 头部;界面那一路拿到的则是每个文件完整的 diff。
和《从 LLM 到 Coding Agent》对照
工具系统的抽象设计讲“一次执行,两个受众”,举的例子正是编辑文件:给模型一句“已编辑 foo.ts”,给人一块 diff。apply_patch 就是这么做的,模型只看到 A、M、D 清单,界面拿到完整的变更与 diff。OpenCode 源码解读一篇提到,OpenCode 给 GPT 系列模型提供的正是同一种补丁格式,匹配也分精确、忽略行尾空白、忽略首尾空白、标点归一四级放宽;两边的格式与匹配思路一致,可以对照着读。