跳到主要内容

内置工具:agent 的手脚(shell/python/编辑/浏览)

30 秒导读: 上一章讲了"代码块怎么被识别成工具调用"(02-tool-system), 这一章讲这些工具具体做什么。它们是 agent 伸向真实世界的手脚——跑命令、写文件、点网页。 全章的重头戏是 gptme/tools/patch.py:一个把"模型给的旧代码"精确落到真实文件上的三级容错引擎。

本章不重复"工具是怎么被解析/注册的"(那是 02-tool-system 的事), 只讲每个工具落到现实的那一下:它解决什么、代码在哪、有什么坑。


1. 先建立直觉:内置工具是"手脚",不是"大脑"

模型本身只会说话——输出文本。它没法自己读文件、跑命令、翻网页。 gptme 的内置工具就是补上这一截:把模型说的那段话,翻译成对真实世界的一个动作

按"动作落到哪里"分,内置工具大致是四族:

工具族落到哪里代表工具
执行操作系统进程shellipythontmux
编辑落地磁盘上的文件patchsave/appendmorphpatch_manypatch_anchored
感知屏幕 / 图像 / 检索readvisionscreenshotrag
外部世界浏览器 / 整台电脑browsercomputer

四族里,编辑落地这一支工程含量最高。原因在下一节。


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_patchexecute_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.pyPatchapply

4.4 patch_anchored:哈希锚点,抗行号漂移

patch_anchored(patch_anchored.py)针对的是另一个痛点:行号会漂。它走两步编辑:

  1. view_anchored <file>:把文件每一行前面加一个内容哈希锚点 (snapshot_text,定义在 gptme/tools/_anchored.py:73,由 patch_anchored.py:46 调用), 渲染成 <锚点>│ <行内容>
  2. 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:134288)。

注意:锚点算法(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 REPLPython 计算、调用注册函数
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_typesipython/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
computerX11/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:106patch.py:158)。宁可让模型补上下文重试,也不赌一把改错地方。
  • 失败即反馈,让模型自愈。 匹配失败时把整份文件回灌进错误信息(GPTME_PATCH_RECOVERY,patch.py:122-127), 下一轮模型照真内容重写。
  • 宽松只放宽该放宽的。 _lines_match_relaxed 只原谅空白行的空白差异(patch.py:179),有实义的行仍逐字节比—— 精准地解决了"模型记错缩进空行"这个最高频的失败,又不至于误配。
  • 公共底座复用。 Patch 类同时被 save 的 diff 预览、patch_many 的多文件应用复用(save.py:19patch_many.py:18), 一处逻辑多处受益。
  • 原子性与回滚。 patch_many 先内存全部试算再落盘,落盘失败还回滚(patch_many.py:246-258), 跨文件批改不会改一半留残局。

9. 边界与局限

  • patch 的宽松匹配只管空白行。 tab↔空格的行内差异、或有实义内容的行记错,仍会精确失败—— 这时靠 GPTME_PATCH_RECOVERY 回灌自愈,而不是更激进的模糊匹配。
  • 不唯一即拒绝,可能"卡住"。 上下文太少导致命中多处时,patch 直接拒绝而非猜一个,需要模型补更多上下文。
  • 很多工具要外部依赖才可用。 morphOPENROUTER_API_KEYraggptme-ragbrowser 要 Playwright/lynx、 tmux 要 tmux、computer 默认关闭且要 xdotool——available / disabled_by_default 字段决定它们在不在提示词里。
  • 本章不覆盖工具怎么被解析/注册/确认,那些在 02-tool-system05-hooks-extensibility

10. 代码地图(导航索引)

主题文件路径符号名
patch 三级容错入口gptme/tools/patch.pyPatch.apply
宽松空白匹配gptme/tools/patch.py_find_relaxed_match / _lines_match_relaxed
冲突块解析gptme/tools/patch.pyPatch._from_codeblock / from_codeblock
占位符/常量gptme/tools/patch.pyORIGINAL / DIVIDER / UPDATED
多 hunk 应用与失败报告gptme/tools/patch.pyapply
回灌自愈开关gptme/tools/patch.pyGPTME_PATCH_RECOVERY
整文件写 + patch 提醒gptme/tools/save.pyexecute_save_impl / check_for_placeholders
快速套改(专用模型)gptme/tools/morph.pyexecute_morph / is_openrouter_available
多文件原子补丁gptme/tools/patch_many.pyexecute_patch_many_impl
哈希锚点算法(定义处)gptme/tools/_anchored.pyapply_operations / snapshot_text
哈希锚点工具(注册/渲染)gptme/tools/patch_anchored.pyview_anchored / execute_patch_anchored
持久 shell 会话gptme/tools/shell.pyShellSession / get_shell
命令允许清单/校验gptme/tools/shell_validation.pyis_allowlisted / check_with_shellcheck
后台任务gptme/tools/shell_background.pyBackgroundJob / execute_bg_command
持久 IPython + 函数注册gptme/tools/python.py_get_ipython / register_function
tmux 会话gptme/tools/tmux.pyexecute_tmux
沙箱读文件gptme/tools/read.pyexecute_read
浏览器多后端选择gptme/tools/browser.pyhas_playwright / has_lynx / EngineType
控制整台电脑gptme/tools/computer.pycomputer
子代理派生gptme/tools/subagent/api.pysubagent / subagent_wait
git worktree 隔离(定义处)gptme/util/git_worktree.pycreate_worktree