内置工具: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-427)注册,
execute_patch 走 execute_with_confirmation(patch.py:393)——落盘前先给用户看 diff 预览并确认。
确认机制本身见 05-hooks-extensibility。
4. 编辑落地的其余四种取舍
同样是"把新代码落到文件",另外四个工具在不同维度上做了取舍。
4.1 save / append:整文件写
save 不做匹配,直接整文件覆盖(execute_save_impl,save.py:122-221)。定位是"新文件、大重写"。
它有两个值得学的细节:
- 占位符防呆:
check_for_placeholders(save.py:114-119)扫到# .../// ...这类偷懒占位行就直接拒存, 逼模型给完整内容——否则会把"省略号"当真内容写进文件 。 - 反向提醒用 patch:覆盖已有文件后,它复用
Patch(...).diff_minimal()(save.py:19,save.py:191-213) 算出到底改了几行;如果只改了一丁点却整文件重写,就吐一句"这么点改动建议用 patch 更省"。
save 的 diff 预览也是复用 Patch 类的(save.py:92-95),可见 patch.py 是整个编辑族的公共底座。
4.2 morph:把套改外包给专用小模型
patch 是"模型给精确旧文 + 新文";morph(morph.py)换一条路:模型只写带 // ... existing code ...
省略标记的粗略编辑意图,由一个叫 Morph Fast Apply 的专用快速模型去把它套进真实文件。
它把原文和编辑意图拼成 <code>...</code><update>...</update>(morph.py:142),
通过 OpenRouter 调 openrouter/morph/morph-v3-fast(morph.py:149)。适合改动散落全文、
用 patch 上下文标记会非常啰嗦的场景。
关键坑:它需要 OPENROUTER_API_KEY,所以 available=is_openrouter_available(morph.py:261),没配就不出现。
另有一道乐观锁:套改结果落盘前会重读文件,若内容自生成补丁以来变过就拒绝(morph.py:198),防覆盖他人改动。
4.3 patch_many:多文件原子补丁
跨文件改名、加参数改所有调用点这类活,用 patch 得发 N 次调用。patch_many(patch_many.py)让一次调用改多个文件,
而且原子:先在内存里逐个应用所有补丁,只要有一个失败就一个文件都不写(execute_patch_many_impl,patch_many.py:198-265)。
连落盘阶段某个文件写失败,也会把已写的文件回滚(patch_many.py:246-258)。
它支持两种写法:简单式(路径写在围栏头,每文件一个 hunk)和多 hunk 式(用 === PATH: ... === 头分隔,
patch_many.py:141-145),底层仍复用 patch.py 的 Patch 与 apply。
4.4 patch_anchored:哈希锚点,抗行号漂移
patch_anchored(patch_anchored.py)针对的是另一个痛点:行号会漂。它走两步编辑:
view_anchored <file>:把文件每一行前面加一个内容哈希锚点 (snapshot_text,定义在gptme/tools/_anchored.py:73,由patch_anchored.py:46调用), 渲染成<锚点>│ <行内容>。patch_anchored <file>:模型提交一个 JSON 操作数组{anchor, op, text, expected}(op是 replace/delete/insert_before/insert_after),锚点先对当前文件解析,再整批原子应用 (apply_operations,定义在gptme/tools/_anchored.py:111,patch_anchored.py:264调用)。
任何锚点解析不到、或 expected 守卫对不上,整批拒绝并重渲染文件让模型拿新锚点重试(patch_anchored.py:265-272)。
相邻行编辑会让周围锚点失效,所以设计上要求"每次 patch 前先重新 view"。这是默认关闭的实验特性
(disabled_by_default=True,patch_anchored.py:134、288)。
注意:锚点算法(
snapshot_text/apply_operations)独立在gptme/tools/_anchored.py;patch_anchored.py只负责工具注册与渲染并 import 这两个符号(patch_anchored.py:30)。 按符号名 grep 定义要落到_anchored.py。
5. 执行族:shell / ipython / tmux
编辑之外,agent 还要能跑东西。三个执行工具的分工:
| 工具 | 进程模型 | 适合 |
|---|---|---|
shell | 一个持久 bash 会话 + 后台任务 | 绝大多数命令 |
ipython | 一个持久 IPython REPL | Python 计算、调用注册函数 |
tmux | 真 tmux 会话,可看面板、发输入 | 长驻/交互式程序(dev server、train.py) |
5.1 shell:持久会话 + 允许清单 + 后台任务
shell 不是每条命令起一个新进程,而是维护一个 ShellSession(shell.py:254)——状态在命令间保留
(cd 换了目录、设了环境变量,下条命令还在)。会话按会话 id 缓存,见 get_shell(shell.py:967)。
命令用 split_commands(shell.py:1881)拆分。
两个精华设计:
- 允许清单免确认:
ls/cat/grep/rg等只读命令列在allowlist_commands(shell_validation.py:25-49),is_allowlisted(shell_validation.py:266)判定后自动放行,不打断用户;危险命令则有deny_groups(shell_validation.py:50)。 还能用check_with_shellcheck(shell_validation.py:372)在执行前静态查错。 - 后台任务:命令写成
bg npm run dev就转入后台,由shell.py:1571+的分发逻辑识别bg/jobs/output/kill, 实际管理在shell_background.py——BackgroundJob(shell_background.py:38)用后台线程持续读 stdout/stderr,execute_bg_command/execute_jobs_command/execute_output_command/execute_kill_command(shell_background.py:250/277/299/346) 分别负责启动、列表、取输出、杀进程。这解决了"跑 dev server 会 把 agent 卡死"的问题。
超时由 GPTME_SHELL_TIMEOUT 控制(默认 1200 秒),超长输出会做头尾截断以省 token(见 shell.py 顶部 docstring)。
工具注册为 block_types=["shell"](shell.py:1962-1969)。
5.2 ipython:持久 REPL + 可注册函数
ipython(python.py)持有一个跨调用复用的 InteractiveShell 实例(_get_ipython,python.py:187-199),
所以变量、import 都像真 REPL 一样留着。
它最妙的地方是函数注册:register_function(python.py:100-102)把 Python 函数塞进 registered_functions
(python.py:95),再 push 进 IPython 命名空间(python.py:199)。这就是 vision/screenshot 等工具
把自己的能力暴露成"可在 ipython 里直接调的函数"的机制——get_functions(python.py:335)会把可用函数列进提示词。
block_types 是 ipython/py(python.py:456-460)。
5.3 tmux:真交互式会话
tmux(tmux.py)用真实 tmux 会话跑长驻/交互程序,能截取面板内容、往里发按键。
只有装了 tmux 才可用(available=shutil.which("tmux") is not None,tmux.py:656),block_types=["tmux"](tmux.py:655)。
和 shell 后台任务的区别:tmux 面向需要看输出、需要交互的程序,bg 面向"扔后台不管它"的。
6. 感知与外部世界:read / vision / screenshot / rag / browser / computer
最后一组让 agent 能看和够到外面。逐个一句话:
| 工具 | 解决什么 | 代码锚点 | 关键坑 |
|---|---|---|---|
read | 无需 shell 的沙箱读文件/列目录,一次读多个文件 | read.py:234,block_types=["read"] | 给受限工具集用(如 --tools read,patch,save) |
vision | 让模型看图片(需支持视觉的模型) | vision.py:118,view_image 注册为函数 | 通过 ipython 函数暴露,非代码块 |
screenshot | 截屏(mac 用 screencapture,Linux 用 scrot) | screenshot.py:156,screenshot 函数 | mac 需授予屏幕录制权限 |
rag | 对文件做语义检索补充上下文 | rag.py:380,available=_has_gptme_rag | 需外部 gptme-rag CLI,没装则不可用 |
browser | 读网页/搜索/截图,多后端 | browser.py:1025,available=has_browser_tool | 见 6.1 |
computer | X11/macOS 控制整台电脑(键鼠屏) | computer.py:1371,disabled_by_default=True | 默认关闭,需 xdotool 等 |
注意 vision/screenshot/computer 用的是 functions=[ToolFunction.from_callable(...)] 而非 block_types——
它们把能力暴露成 ipython 里可调的函数,而不是独立代码块语言。
6.1 browser 的多后端
browser(browser.py)在启动时自动选后端(browser.py:116-117):有 Playwright 用 Playwright(全功能:截图、
ARIA 快照、点击/填表/滚动),否则退回 lynx(纯文本读页/搜索),两者都没有则工具不可用。
检测函数分别是 has_playwright(browser.py:106)、has_lynx(browser.py:111)。
搜索引擎抽象成 EngineType = Literal["google", "duckduckgo", "perplexity"](browser.py:369),
Perplexity 需要 API key 才进候选(browser.py:341-348);用 Anthropic 模型时还可开原生 web search。
Playwright 的具体动作实现在 _browser_playwright.py,lynx 在 _browser_lynx.py,Perplexity 在 _browser_perplexity.py。
7. 子代理:把工具能力打包成一个孩子
subagent(gptme/tools/subagent/)不是单个动作工具,而是派生一个完整的子 agent去干一件事。
它被从一个 1100 行的大模块拆成了包:types.py(数据类)、api.py(公共 API)、batch.py(批量)、
execution.py(执行后端)、hooks.py(完成通知)。
公共 API 在 api.py:subagent(启动,api.py:37)、subagent_status(api.py:868)、subagent_wait(api.py:877)、
subagent_cancel/subagent_reply/subagent_list/subagent_read_log。执行后端在 execution.py,支持线程内或
独立子进程运行,并可为子任务开 git worktree(create_worktree 定义在 gptme/util/git_worktree.py:44,
execution.py:675 调用)做隔离。工具本身注册在 subagent/__init__.py:379。批量并行由 subagent_batch(batch.py:72)负责。
8. 巧妙之处(带走的精华)
- 三级容错编辑,安全永远压过宽容。
patch先精确、后宽松,但两级都对"命中多处/无变化"零容忍 (patch.py:106、patch.py:158)。宁可让模型补上下文重试,也不赌一把改错地方。 - 失败即反馈,让模型自愈。 匹配失败时把整份文件回灌进错误信息(
GPTME_PATCH_RECOVERY,patch.py:122-127), 下一轮模型照真内容重写。 - 宽松只放宽该放宽的。
_lines_match_relaxed只原谅空白行的空白差异(patch.py:179),有实义的行仍逐字节比—— 精准地解决了"模型记错缩进空行"这个最高频的失败,又不至于误配。 - 公共底座复用。
Patch类同时被save的 diff 预览、patch_many的多文件应用复用(save.py:19、patch_many.py:18), 一处逻辑多处受益。 - 原子性与回滚。
patch_many先内存全部试算再落盘,落盘失败还回滚(patch_many.py:246-258), 跨文件批改不会改一半留残局。
9. 边界与局限
patch的宽松匹配只管空白行。 tab↔空格的行内差异、或有实义内容的行记错,仍会精确失败—— 这时靠GPTME_PATCH_RECOVERY回灌自愈,而不是更激进的模糊匹配。- 不唯一即拒绝,可能"卡住"。 上下文太少导致命中多处时,
patch直接拒绝而非猜一个,需要模型补更多上下文。 - 很多工具要外部依赖才可用。
morph要OPENROUTER_API_KEY、rag要gptme-rag、browser要 Playwright/lynx、tmux要 tmux、computer默认关闭且要 xdotool——available/disabled_by_default字段决定它们在不在提示词里。 - 本章不覆盖工具怎么被解析/注册/确认,那些在 02-tool-system 和 05-hooks-extensibility。
10. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| patch 三级容错入口 | gptme/tools/patch.py | Patch.apply |
| 宽松空白匹配 | gptme/tools/patch.py | _find_relaxed_match / _lines_match_relaxed |
| 冲突块解析 | gptme/tools/patch.py | Patch._from_codeblock / from_codeblock |
| 占位符/常量 | gptme/tools/patch.py | ORIGINAL / DIVIDER / UPDATED |
| 多 hunk 应用与失败报告 | gptme/tools/patch.py | apply |
| 回灌自愈开关 | gptme/tools/patch.py | GPTME_PATCH_RECOVERY |
| 整文件写 + patch 提醒 | gptme/tools/save.py | execute_save_impl / check_for_placeholders |
| 快速套改(专用模型) | gptme/tools/morph.py | execute_morph / is_openrouter_available |
| 多文件原子补丁 | gptme/tools/patch_many.py | execute_patch_many_impl |
| 哈希锚点算法(定义处) | gptme/tools/_anchored.py | apply_operations / snapshot_text |
| 哈希锚点工具(注册/渲染) | gptme/tools/patch_anchored.py | view_anchored / execute_patch_anchored |
| 持久 shell 会话 | gptme/tools/shell.py | ShellSession / get_shell |
| 命令允许清单/校验 | gptme/tools/shell_validation.py | is_allowlisted / check_with_shellcheck |
| 后台任务 | gptme/tools/shell_background.py | BackgroundJob / execute_bg_command |
| 持久 IPython + 函数注册 | gptme/tools/python.py | _get_ipython / register_function |
| tmux 会话 | gptme/tools/tmux.py | execute_tmux |
| 沙箱读文件 | gptme/tools/read.py | execute_read |
| 浏览器多后端选择 | gptme/tools/browser.py | has_playwright / has_lynx / EngineType |
| 控制整台电脑 | gptme/tools/computer.py | computer |
| 子代理派生 | gptme/tools/subagent/api.py | subagent / subagent_wait |
| git worktree 隔离(定义处) | gptme/util/git_worktree.py | create_worktree |