数据截至 (上游 commit 0a27a45390b4)
内置工具:agent 的手脚(shell/python/编辑/浏览)
30 秒导读: 上一章讲了"代码块怎么被识别成工具调用"(02-tool-system), 这一章讲这些工具具体做什么。它们是 agent 伸向真实世界的手脚——跑命令、写文件、点网页。 全章的重头戏是
gptme/tools/patch.py:一个把"模型给的旧代码"精确落到真实文件上的三级容错引擎。
本章不重复"工具是怎么被解析/注册的"(那是 02-tool-system 的事), 只讲每个工具落到现实的那一下:它解决什么、代码在哪、有什么坑。
1. 先建立直觉:内置工具是"手脚",不是"大脑"
模型本身只会说话——输出文本。它没法自己读文件、跑命令、翻网页。 gptme 的内置工具就是补上这一截:把模型说的那段话,翻译成对真实世界的一个动作。
按"动作落到哪里"分,内置工具大致是四族:
| 工具族 | 落到哪里 | 代表工具 |
|---|---|---|
| 执行 | 操作系统进程 | shell、ipython、tmux |
| 编辑落地 | 磁盘上的文件 | patch、save/append、morph、patch_many、patch_anchored |
| 感知 | 屏幕 / 图像 / 检索 | read、vision、screenshot、rag |
| 外部世界 | 浏览器 / 整台电脑 | browser、computer |
四族里,编辑落地这一支工程含量最高。原因在下一节。
2. 为什么"编辑落地"最难
难点从来不是"让模型写出新代码"。
难点是:模型手里那份"旧代码"几乎从不和磁盘上的真实文件一字不差。
它可能少了两个空格、把 tab 记成空格、记错了缩进、或者干脆凭记忆重构了一段。 如果编辑工具要求"旧代码必须逐字节匹配才肯改",模型会反复失败。
所以真正的编辑工具都在做同一件事:在"必须安全(别改错地方)"和"必须宽容(别老失败)"之间找平衡。 gptme 给了五种不同取舍的编辑工具,它们的定位:
| 工具 | 一句话定位 | 何时用 |
|---|---|---|
patch | 冲突标记 + 三级容错的默认编辑器 | 已有文件的定点小改 |
save / append | 整文件写 / 追加 | 新文件、大重写 |
morph | 调用专用小模型快速套改 | 改动散落全文、上下文标记会很啰嗦 |
patch_many | 多文件原子补丁 | 跨文件改名/改签名这类批量改 |
patch_anchored | 哈希锚点两步编辑(抗行号漂移) | 需要精确定位、默认关闭的实验特性 |
下面先深挖默认的 patch,再对比其余四个。
3. 深挖 patch:三级容错的编辑引擎
patch 用一种改良版 git 冲突标记来表达"把这段旧代码换成那段新代码"。
格式很朴素——模型输出一个 patch 代码块,里面是:
<<<<<<< ORIGINAL
print("Hello world")
=======
name = input("What is your name? ")
print(f"Hello {name}")
>>>>>>> UPDATED
<<<<<<< ORIGINAL 到 ======= 之间是要被替换掉的旧内容,======= 到 >>>>>>> UPDATED
之间是新内容。这三个标记就是 patch.py:66-68 里的常量 ORIGINAL / DIVIDER / UPDATED。
整个引擎分三步走:解析格式 → 匹配定位 → 容错降级。
3.1 解析:把冲突块拆成 (原文, 新文)
Patch._from_codeblock(patch.py:215-260)负责把上面那段文本拆开。它的关键处理有三处,都是踩过坑才加的:
- 支持多补丁。 用
re.split(f"(?={re.escape(ORIGINAL)})", ...)(patch.py:220)在每个<<<<<<< ORIGINAL前切一刀,一个代码块里可以塞多个 hunk,一次改好几处。 - 区分"新内容为空"和"格式错误"。 删除一段代码时,新内容是空的。代码特意用
after_divider.startswith(UPDATED[1:])(patch.py:242)判断:=======后面紧跟>>>>>>> UPDATED就是合法的空替换,而不是缺了标记。 - 防"多一个
======="。 用带负向前瞻的正则\n=======(?!=)(patch.py:254)只匹配恰好 7 个等号, 这样 RST 标题下划线那种一长串===============不会被误判成多余的分隔符。
外层还有 Patch.from_codeblock(patch.py:262-279):它识别 # ... / // ... 这类占位符行
(正则 patch.py:266),把一个带占位符的大 hunk 按占位符切成多个小替换,原文和新文的占位符数量对不上就直接报错。
3.2 匹配:精确优先,不唯一就拒绝
真正落地的是 Patch.apply(patch.py:101-136)。它先走快路径——精确子串匹配:
# 示意,非源码;重点看:先精确匹配,不唯一/无变化都报错
if self.original in content:
count = content.count(self.original)
if count > 1:
raise ValueError("original chunk is not unique ...") # 多处命中 → 拒绝
new = content.replace(self.original, self.updated, 1)
if new == content:
raise ValueError("patch did not change the file ...") # 新旧相同 → 拒绝
return new
这里两个"拒绝"是安全阀:命中多处(patch.py:106-109)说明上下文不够、可能改错地方,宁可让模型补更多上下文;
新旧完全一样(patch.py:111-115)说明这个补丁是空操作。安全永远优先于宽容。
3.3 降级:精确失败后,容忍空白行差异
精确匹配失败——最常见就是模型把空行的空白字符记错了——不立刻放弃,而是走
_find_relaxed_match(patch.py:138-165)做宽松匹配。
它的思路很朴素:把原文和文件都按行切开,拿原文当一个"窗口"在文件里逐行滑动,
用 _lines_match_relaxed(patch.py:167-185)逐行比对。规则只放宽一处:
两行如果都是"只含空白的空行",就算匹配(
patch.py:179);否则仍要求逐字节相等。
也就是说,它只原谅"空行里的空格/tab 差异",不原谅有实义内容的行。找到唯一窗口就用文件里的真实那段
去替换(patch.py:130),而不是用模型给的原文——这样替换才落在真实字节上。
宽松匹配同样坚持唯一性:命中多个窗口(patch.py:158-163)照样拒绝。
三级容错连起来是这样一条降级链:
patch 应用一个 hunk
│
▼
① 精确子串匹配 ── 命中且唯一 ──► 替换,完成
│ (未命中)
▼
② 宽松匹配(容忍空白行差异) ── 命中且唯一 ──► 用文件真实片段替换,完成
│ (仍未命中)
▼
③ 报错:"original chunk not found"
若开了 GPTME_PATCH_RECOVERY,把整份文件内容回灌进错误信息
3.4 自愈:GPTME_PATCH_RECOVERY 回灌文件
第三级不是干巴巴地失败。若环境变量 GPTME_PATCH_RECOVERY 打开(patch.py:122-127),
错误信息里会附上整份文件的当前内容:
# 示意,非源码
file_contents = (
f"Here are the actual file contents:\n```original\n{content}\n```"
if get_config().get_env_bool("GPTME_PATCH_RECOVERY") else ""
)
raise ValueError(f"original chunk not found in file\n{file_contents}")
这一步是给模型"自愈"用的:它匹配失败往往就是因为记错了文件长什么样, 把真实内容塞回它眼前,下一轮它就能照着真内容重写补丁。这是 agent 循环里典型的"失败即反馈"设计 (参见 01-agent-loop 的回灌机制)。
3.5 多 hunk 与逐步失败报告
一个补丁块里的多个 hunk 由模块级 apply(patch.py:282-310)依次应用。
它的贴心之处是报告进度:第 i 个 hunk 失败时,消息会写清 Hunk i/N failed,
并注明"前面已成功应用了几个",还附上失败 hunk 的前 100 字预览(patch.py:297-307)。
只有一个 hunk 时则直接抛原始错误,不加噪音。
3.6 落盘前的两道关卡
execute_patch_impl(patch.py:322-377)在真正写文件前还有两处细节:
- 路径穿越防护(
patch.py:333-343):相对路径必须 resolve 后仍在 cwd 之内,否则报 "Path traversal detected";绝对路径视为用户有意为之,放行。save/morph/patch_many复用了同一模式。 - 大补丁提醒(
patch.py:356-359):补丁长度 > 1000 且比整个文件还大时,提示"下次写小点,或直接用 save"。
工具本身通过 ToolSpec(name="patch", block_types=["patch"], execute=execute_patch)(patch.py:406-428)注册,
execute_patch 走 execute_with_confirmation(patch.py:393)——落盘前先给用户看 diff 预览并确认。
确认机制本身见 05-hooks-extensibility。
4. 编辑落地的其余四种取舍
同样是"把新代码落到文件",另外四个工具在不同维度上做了取舍。