数据截至 (上游 commit 565d53515b54)
工具宇宙:FS 形状接口与 :// 内 部 URL
30 秒导读: oh-my-pi 的 coding-agent 挂着三十余个工具,但读者不必逐个记。抓住一句话就够:它们共用一个「文件系统形状」的接口——绝大多数工具都只吃一个
path字段,后面能接一套统一的:N-M/:raw选择器;而path不止是磁盘文件,还能是pr://123、agent://reviewer_0、skill://humanizer这样的内部 URL。于是「读一个 PR」「读子 agent 的输出」「读一份 skill」和「读src/foo.ts」是同一个动作、同一个工具、同一个入口。本章讲清这套命名空间怎么装配、怎么分派、怎么落盘。
本章是 oh-my-pi 系列的第 3 章。它只讲工具面的形状与命名空间;edit 的编辑语言(hashline)留给 第 4 章,主循环怎么调度工具见 第 1 章,模型侧的 in-band 工具调用见 第 2 章。
1. 这是什么(零基础也能懂)
一句话定义: 工具面(tool surface)是 agent 能对世界做的所有动作的集合;oh-my-pi 把这个集合设计成一个共享的文件系统命名空间,而不是一堆各说各话的 API。
它解决什么问题。 一个 coding-agent 想干的事很杂:读文件、跑命令、搜代码、看 PR、看 issue、翻子 agent 的报告、查一份 skill 文档、读项目记忆……最偷懒的做法是给每件事配一个专用工具(read_file、get_pull_request、get_subagent_output、read_skill……),模型要学几十套参数。oh-my-pi 反着来:能表达成「读一个地址」的,就都归到 read;地址长什么样由 URL scheme 决定。
用起来什么样。 下面几行都是同一个 read 工具,只是 path 的形状不同:
read src/foo.ts:50-100 # 磁盘文件的 50–100 行
read pr://123 # 123 号 PR(实时走 gh,带缓存)
read agent://reviewer_0 # 名为 reviewer_0 的子 agent 的输出
read skill://humanizer # humanizer 这份 skill 的 SKILL.md
read issue://can1357/oh-my-pi/1608 # 指定仓库的 1608 号 issue
read memory://root # 本项目的记忆摘要
一句话直觉。 把它想成 Unix 的哲学:「一切皆文件」。Unix 让设备、进程、网络都以文件路径示人,你用同一个 cat 就能读;oh-my-pi 让 PR、 子 agent、skill、记忆都以 scheme://… 路径示人,你用同一个 read 就能读。scheme 是挂载点,handler 是那个挂载点的驱动。
本节不碰底层。记住三件事:① 工具吃 path;② path 可以是内部 URL;③ 于是异构资源被统一成「读/写一个地址」。
2. 顶层全景(它大概怎么转)
这套工具面有三个关切,分别由三组文件负责:
| 关切 | 干什么 | 核心文件 |
|---|---|---|
| 注册与装配 | 决定「这个 session 挂哪些工具、哪些先亮哪些藏起来」 | tools/index.ts、tools/builtin-names.ts |
| FS 形状接口 | 让工具吃统一的 path+选择器,并按形状分派到不同后端 | tools/read.ts、write.ts、grep.ts、glob.ts |
:// 内部 URL | 把 PR / 子 agent / skill / 记忆等异构资源统一成可 read/write 的地址 | internal-urls/router.ts、parse.ts + 各 scheme handler |
主线走一遍(高层,不进代码): 一次 read pr://123 的旅程——
模型发出 read{path:"pr://123"}
│
▼
ReadTool.execute ── 按 path 的「形状」逐级判断该交给谁 ──┐
│ │
│ 是 http(s):// ? ─ 否 │ 分派链(命中即停):
│ 是 conflict:// ? ─ 否 │ file:// → conflict:// → http URL
│ 能被内部路由器认领? ── 是(pr://) │ → 内部 URL 路由器 → 归档 → sqlite
▼ │ → PDF → 普通磁盘文件
InternalUrlRouter.instance().resolve("pr://123") │
│ 查 scheme="pr" 的 handler ┘
▼
PrProtocolHandler.resolve ── 走 gh 缓存,拉 123 号 PR,渲成 markdown
│
▼
返回 InternalResource{ content, contentType, immutable:true }
│
▼
ReadTool 把它当「一份只读文本」格式化 → 交回模型
怎么读这张图: 关键在 ReadTool.execute 那一段「逐级判断形状」。它不是先解析出「这是 PR」再调 PR 专用逻辑;它是把所有输入都当路径,顺着一条分派链往下问「你是不是这种形状」,内部 URL 只是其中一环。真源码见 tools/read.ts:1070-1213(execute 的分派头),分派链的顺序在下文 §5 展开。
3. 工具注册与装配(哪些工具、怎么亮出来)
这节讲什么: session 启动时,工具不是「全挂上」这么简单——有别名、有开关、有「先藏起来按需激活」。装配逻辑集中在 createTools。
3.1 一张注册表,两类工具
所有内建工具是一张名字 → 工厂函数的表。可见的一类在 BUILTIN_TOOLS,藏起来的一类在 HIDDEN_TOOLS:
// 示意,非源码:注册表就是「名字 → 造一个工具实例」
export const BUILTIN_TOOLS = {
read: s => new ReadTool(s),
bash: s => new BashTool(s),
edit: s => new EditTool(s),
grep: s => new GrepTool(s),
glob: s => new GlobTool(s),
// …共 30 个
};
export const HIDDEN_TOOLS = {
think: () => new ThinkTool(),
yield: s => new YieldTool(s),
goal: s => new GoalTool(s),
};
真源码:BUILTIN_TOOLS 在 tools/index.ts:417,HIDDEN_TOOLS 在 tools/index.ts:449-454。可见工具的规范名单(29 个)在 tools/builtin-names.ts:1-31 的 BUILTIN_TOOL_NAMES;隐藏的 3 个是 think、yield、goal。(旧版隐藏的 resolve、report_finding、report_tool_issue 已改造成 xd:// 设备或移除,见 §8。)实际挂载数还受下面的开关与 MCP/扩展工具影响。
3.2 老名字还能用:别名归一
历史上 grep 叫过 search、glob 叫过 find。为了不砸掉旧调用,注册前先过一遍归一化:
| 模型说 | 实际路由到 |
|---|---|
search | grep |
find | glob |
映射表 LEGACY_BUILTIN_TOOL_NAME_ALIASES 与 normalizeToolName 在 tools/builtin-names.ts:39-52。所以本章标题里说的 find,在当前代码里是 glob 的别名。
3.3 谁亮、谁不亮:开关 + 递归深度
createTools(tools/index.ts:487-680)不是无脑挂满 30 个。它用一个 isToolAllowed 谓词逐个筛(tools/index.ts:597-631),依据包括:
- 设置开关:
bash.enabled/glob.enabled/browser.enabled等,任一关掉对应工具就不挂。 - 递归深度:
task(spawn 子 agent)受task.maxRecursionDepth限制,子 agent 到底就不再给task;irc、manage_skill、learn只在顶层(taskDepth === 0)开。 - 后端可达性:
eval只要 JS/Python/Ruby/Julia 任一后端可用就挂,并在首次调用时才细分派到具体后端(tools/index.ts:544-549)。
一个旧特例的消亡值得记:旧版 resolve(两段式落盘的隐藏工具)曾在这里「无条件补挂」(不在请求列表里也会加);现在两段式改为写 xd:// URL(§8),xd:// 前缀由 write 工具直接认领,不再需要任何无条件补挂的工具。
3.4 essential 与 discoverable:先亮一小撮
工具太多会撑爆上下文。oh-my-pi 给每个工具打一个 loadMode 标签:
essential(必备):默认永远亮着。默认必备集是read / bash / edit / write / glob / eval(DEFAULT_ESSENTIAL_TOOL_NAMES,tools/index.ts:382-389)。discoverable(可发现):平时藏起来,模型通过搜索按需激活(见 §7)。
当发现模式为 all 时,filterInitialToolsForDiscoveryAll(tools/index.ts:415-435)会把非必备的可发现工具从初始工具集里剔掉——除非它被显式请求、被上次会话恢复、或被某个强制 tool_choice 特性点名(forceActive,少了它 provider 会 400)。
4. FS 形状接口:一个 path,一套选择器
这节讲什么: 为什么说这些工具「共用一个文件系统形状」。核心是两点:参数形状统一 + 访问权限按 path 分级。
4.1 参数就是一个 path(+ 内嵌选择器)
read 的入参 schema 简单到只有一个字段:
// tools/read.ts:712-716
const readSchema = type({
path: type("string").describe(
'Local path, internal URI (e.g. "omp://", "issue://123", "pr://123"), or URL; ' +
'append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")',
),
});
write、grep、glob 同样以 path 为主轴。行范围、原始模式等修饰不另开参数,而是内嵌进 path 的 :<sel> 后缀,由 parseSel 统一解析(现已抽到 tools/read-selector.ts:34):
| 选择器 | 含义 |
|---|---|
:50-100 | 第 50–100 行 |
:50+20 | 从第 50 行起 20 行 |
:50- | 从第 50 行到末尾 |
:raw | 原始输出,不加行号/hashline |
:conflicts | 列出该文件的 git 冲突块 |
:raw:50-100 | 组合:原始模式 + 行范围 |
一套选择器语法对所有 path 形状生效——磁盘文件能用,内部 URL 也能用(read pr://123:1-40 取 PR 渲染文本的前 40 行)。read 会先把选择器从 URL 上「剥」下来再交给 handler(splitInternalUrlSel,见 §5)。
4.2 权限按 path 的形状分级(read / write / exec)
同一个工具,对不同 path 可能是不同危险级别。oh-my-pi 用三档 tier 表达:
| tier | 含义 | 谁默认吃这档 |
|---|---|---|
read | 只读,最安全 | read(本地/内部 URL)、glob |
write | 落盘,改本地或用户数据 | write(本地文件)、edit |
exec | 执行/外联,最危险 | bash、ssh、任何 SSH 目标 |
妙处在于 tier 是按参数动态算的,不是钉死在工具上:
read平时是readtier,但 path 指向ssh://远程主机、或是带选择器的 PDF 拆图读时升到exec(tools/read.ts:618-624)——前者要开外联连接跑远程 shell,后者要跑外部拆图进程。write平时是writetier,但如果目标 URL 的 handler 没有write钩子(纯只读 scheme),它其实落不了盘,tier 降到read;若 handler 有write钩子(如vault://笔记,会改用户数据),才保持write(tools/write.ts:507-544)。xd://设 备写另有一套:裁决设备(xd://resolve/xd://reject/xd://propose)是「终结一个已预览过的动作」,固定readtier;其余设备写按其挂载工具自身的 tier 算,解析失败一律按exec失败关闭。bash命中危险命令模式时,除了exec还带上override:true,强制弹审批(tools/bash.ts:553-580)。
tier 怎么对上「审批模式」由 resolveApproval 裁决(tools/approval.ts:120-219),这是 §8 的两道门之一。
5. :// 内部 URL:把异构资源统一成地址
这节讲什么: 内部 URL 是这套设计的心脏。它让「一个 PR」「一份 skill」「一个子 agent 的输出」都变成 read/write 能吃的 path。机制是一个进程级路由器 + 每个 scheme 一个 handler。
5.1 路由器:一个 scheme 一个驱动
InternalUrlRouter 是进程级单例,构造时把每个 scheme 的 handler 注册进一张 Map(internal-urls/router.ts:32-60)。共 13 个 scheme,各认领一个前缀:
InternalUrlRouter (进程唯一,13 个 scheme)
├─ "omp" → OmpProtocolHandler (内嵌文档)
├─ "agent" → AgentProtocolHandler (子 agent 输出)
├─ "memory" → MemoryProtocolHandler (项目记忆)
├─ "local" → LocalProtocolHandler (本会话产物,可写)
├─ "skill" → SkillProtocolHandler (skill 文档)
├─ "rule" → RuleProtocolHandler (激活的规则)
├─ "issue" → IssueProtocolHandler ┐ (GitHub,走 gh 缓存)
├─ "pr" → PrProtocolHandler ┘
├─ "history" → HistoryProtocolHandler (agent 转录)
└─ "ssh" · "mcp" · "vault" · "artifact" (余下 4 个)
判定与解析各一个入口:
canHandle(input)——正则抓^scheme://,查 Map 里有没有这个 scheme(internal-urls/router.ts:79-83)。工具就靠它判断「这个 path 是不是我该交给路由器的」。resolve(input, ctx)——解析 URL、找 handler、调handler.resolve,并由路由器统一盖上immutable标记(internal-urls/router.ts:138),handler 自己不用操心这个字段。
5.2 为什么要自己写 URL 解析器
标准 new URL() 会把冒号当端口分隔符,于是 skill://plugin:name 这种主机段里带冒号的命名空间 URL 会被解坏。parseInternalUrl(internal-urls/parse.ts:53-103)先用正则抽出 scheme/host/path,new URL() 失败时再退回一个手搓的 URL-like 对象,并保留原始大小写的 rawHost。所有解析内部 URL 的地方都必须走这个函数,不许直接 new URL()(文件头注释明说)。
5.3 handler 的合同:resolve / write? / complete?
每个 scheme handler 实现同一个接口 ProtocolHandler(internal-urls/types.ts:153-196):
| 成员 |
|---|