64 个工具砍到 46:一个对象只配一个名字

一次「删比加多」的工具整合:185 个文件,+2358 / −2489,side chat 的常驻工具面从 64 个收到 46 个。
1. 常驻工具面是一笔每次请求都交的税⌗
MaxClaw 的工具分两类:常驻的,每次模型请求都在工具列表里;延迟的,属于某个 skill,用 activate_skill 激活后才出现。哪些常驻,由 src/runtime/tool-availability.ts 里一个集合 ALWAYS_AVAILABLE_TOOL_NAMES 决定。
这个集合不能随手放大,也不能随手缩小,两头都有成本:
- 放进去:每个常驻工具的名字、描述、参数 schema 每次请求都要发一遍。工具越多,模型每一步要在越多的候选里挑,名字相近的越容易挑错。
- 拿出来:对话中途再激活,工具数组就变了。源码注释写得很直白:这些工具「mid-conversation activation of any of them busts the prompt cache」。测试
turn-tool-surface-stability.test.ts专门守着「同一作用域里不同回合类型的工具数组字节稳定」。
所以常驻集合是个精打细算的东西。问题是,它这些年是一点点长出来的:每遇到一种需求,加一个专用小工具,名字起得很具体,描述写得很体贴。长到 64 个的时候,回头看列表,里面混着这样的组合:
shell_poll shell_wait shell_kill shell_list shell_resize shell_watch_remove ← 同一个后台会话
message_history_read message_context_read message_payload_read ← 同一批已存消息
ssh_read_file ssh_write_file ssh_file_edit ← 同一个文件,只是在别的机器上
llm_config_inspect list_models ← 框架自省的两个角落
get_current_time parse_timestamp ← 时间
list_dir glob_files ← shell 一行就能做
每个单拎出来都说得通,放在一起就是「同一件事有好几个入口」。模型在 shell_poll 和 shell_wait 之间犹豫、在 ssh_read_file 和 read_file 之间挑错,都是真实的选错成本。
2. 三条规则⌗
这次改动用三条规则过了一遍常驻集合:
| 规则 | 判据 | 结果 |
|---|---|---|
| 删 | 现有原语(shell、事件元数据、另一个工具)能等价替代 | get_current_time、parse_timestamp、get_facts、get_workdirs、list_dir、glob_files |
| 合 | 操作的是同一个对象,只是动作不同 | 6 个 shell 会话工具 → shell_session;3 个消息读取 → message_read;3 个远端文件工具 → 本地四个文件工具加 device;llm_config_inspect + list_models → introspect |
| 留 | 直觉觉得可删,但真实调用数据说它在被用 | set_workdirs |
账能对上:删 6,ssh 三件套并入既有工具 −3,shell 6 合 1 −5,消息 3 合 1 −2,自省两个并入 introspect −2,合计 −18。src/runtime/tool-roles.test.ts 里 side chat 的工具面清单,改动前 64 项,改动后 46 项。main、steward、maintainer 等其他角色的清单同步变短。
下面逐条说。
3. 删:能用 shell 和时间戳替代的⌗
3.1 时间工具⌗
get_current_time 和 parse_timestamp 是最早一批工具,那时候的想法是「模型不知道现在几点,给它一个工具」。但现在框架里每条事件、每个工具结果行都自带时间戳:
<framework from="tool-exec" at="2026-11-13T22:36:05+08:00" weekday="Fri"/>
模型每走一步都能看到当前时间,精确到秒,还带星期。真要算别的时间,docs/unified-shell.md 现在写的是:
没有时间工具:每条框架事件和每个工具结果行都带 at 时间戳;
要精确时间在 shell 里 date,转换时间戳用 date -d @<秒>
(Windows 用 Get-Date / [DateTimeOffset]::FromUnixTimeMilliseconds(…))。
两个工具删掉之后,连带的清理也很说明问题:预演模式的只读白名单 REHEARSAL_TOOL_ALLOWLIST 原本有三项,其中两项就是它们;orientation-cost.ts 的「中性调用」分类里也有它们。一个专用工具删掉,要改的不止它自己,还有所有按名字认它的地方。这一点第 6 节还会说到。
3.2 列目录与按名找文件⌗
list_dir(depth 1–2、默认 200 条)和 glob_files(ripgrep glob、1 万条截断)本质上是 ls、find、rg --files 的包装。它们的实现,包括一个手写的 walkFiles 栈式遍历、SKIP_DIR_NAMES 跳过表和分页排序,在 maxclaw-direct-tools.ts 里删掉了大约 160 行。
留下的只有 grep_files,因为按内容搜索有 shell 给不了的东西:统一的匹配结构(path / line / text)、二进制和超大文件跳过,以及 WSL 下自动改走 Windows 侧的 rg.exe,让 NTFS I/O 留在 Windows 那边。列目录这件事上,shell 已经做得一样好,包装不值得占一个常驻名额。
工具名别名表 TOOL_CALL_ALIASES 里的 ls → list_dir、glob → glob_files、find_files → glob_files 等映射一并删除。别名的作用是接住模型脱口而出的通用名,目标都没了,别名也就不该留。
3.3 被另一个工具覆盖的⌗
get_facts(category?)按类目列出最新 50 条事实。search_memory本来就检索 facts 层,而且返回带出处的答案。两个入口指向同一层数据,模型却要先想清楚用哪个。get_workdirs只做一件事:读当前对话声明的工作目录。它并进了introspect("workdirs"),set_workdirs的描述里补了一句「当前已声明的目录用 introspect(“workdirs”) 读取」,指路就在写入工具自己身上。
4. 合:同一个对象,一个工具⌗
4.1 shell 会话:六个工具变一个⌗
后台 shell 会话(采样窗口过了还在跑的命令,或者 monitor_start 起的监视)原来有六个管理工具。现在只剩一个 shell_session,用 action 区分:
shell_session(action, …)
poll 读新输出 / 往 PTY 写字符 session_id, chars?, keys?, peek?, offset? …
wait 回合内阻塞到退出或输出匹配 session_id, timeout_s?, until_output?
kill 终止进程组(幂等) session_id
list 列出运行中的 + 结束了还没人读的 (无参数)
resize 调整 PTY 尺寸 session_id, cols, rows
unwatch 去掉 watcher(含退出唤醒) session_id, watcher_id?
还有个顺带的好处:MaxClaw 的 shell 工具族按模型线格式二选一声明,Responses 系声明 exec_command + write_stdin(Codex unified exec 的原生名),其余声明 shell。以前会话管理也跟着分族,shell_poll 只在一族里有。现在 shell_session 两族都声明,会话管理这一层不再因为模型厂商不同而换名字。
4.2 消息读取:三个工具变一个⌗
message_history_read / message_context_read / message_payload_read 合成 message_read(mode):
| mode | 定位方式 | 读什么 |
|---|---|---|
history | sessionId 或 channel(二选一) | 一整段对话,view 可选 transcript / turns / audit |
context | messageId | 某条消息前后几条 |
payload | messageId | 单条消息的大字段分页(content / tool_result / reasoning / tool_calls) |
三个 mode 读的都是同一张消息表,区别只在「从哪进、读多宽」。合并之后,「读消息」这个意图只对应一个名字,参数决定粒度。
4.3 远端文件:不是新工具,是同一个工具的一个参数⌗
这是三处合并里最值得说的。ssh_read_file / ssh_write_file / ssh_file_edit 原本是 ops skill 里的一套独立实现。现在 read_file、write_file、file_edit、apply_patch 都多了一个可选的 device:
read_file(path) → 本 Tool Host 上的文件
read_file(path, device="<目标>") → 经 OpenSSH 读远端文件
device 可以是 OpenSSH 别名、主机名、user@host(不用注册),或者机群清单里的机器名(走证书连接)。实现迁到了 src/runtime/remote-files.ts(+782 行),ops 的 tools.ts 删掉了 728 行。
合并的关键不在省了三个名字,在于前置条件现在是同一套。remote-files.ts 头部注释:
The preconditions are the local tools' own: a unique match is the content-level
precondition for an edit, an explicit sha is strict, the remote write is a CAS
(src/shared/file-edit-utils.ts is shared with them).
测试 remote-files.test.ts 把这套契约逐条钉住:
edits without a prior read and reports old/new sha
ambiguous and missing matches fail with locations / nearest fragment
explicit expected_sha256 stays strict; a matching prefix is accepted
the write is a remote CAS: a concurrent change aborts it
an unread existing file is staged, not overwritten; overwrite=true writes
file permission failures stage the content without changing authentication or using sudo
read_file, write_file, file_edit and apply_patch with device act on the remote file,
not on a local path of the same name
两套实现时,本地和远端的规则会悄悄分叉,一边修了 bug 另一边没修。模型也得记两套:本地编辑要不要先读?远端呢?合成一个工具、共用 file-edit-utils.ts 之后,这个问题就不存在了。apply_patch 还白得了一个远端能力,以前根本没有 ssh_apply_patch。
只属于本地的东西也划清了边界:create_only 和 confirm_token 带上 device 直接拒绝,报错里写明远端的前置条件是 expected_sha256 或 overwrite。安全约束也写进了参数校验:device 必须匹配一个只允许别名、主机名、user@host、方括号 IPv6 的正则,不接受选项、空白或 shell 元字符,密钥只来自 OpenSSH 配置,工具不收 -i。
4.4 框架自省:入口本来就有⌗
introspect 本来就是「了解框架自己」的唯一入口(skills、hosts、browsers、tools……)。provider 配置真值和可选模型清单是同一类问题,却各有一个工具。现在它们变成 introspect 的三个 target:llm、llm:<provider>、models。实现原样搬了过去,skills/llm-config/tools.ts 变成了 src/llm/config-inspect.ts。
5. 合并不能变成一个松垮的大 schema⌗
把六个工具合成一个,最容易犯的错,是把所有参数堆成一个全可选的大对象,然后在 handler 里 if (action === 'kill' && params.timeout_s) … 一路补。这样模型传错参数不会报错,只会被悄悄忽略。
shell_session 的做法分两层:
// 第一层:每个 action 一个严格对象,多一个字段都不行
const killSessionAction = z.strictObject({ action: z.literal('kill'), host_id, session_id })
const listSessionAction = z.strictObject({ action: z.literal('list') })
// … poll / wait / resize / unwatch
const shellSessionActions = z.discriminatedUnion('action', [ … 六个 … ])
// 第二层:对模型发布的是一个扁平对象,每个字段的描述以所属 action 开头,
// 再用 superRefine 跑一遍严格联合,错误前面标上是哪个 action
const shellSessionParamsSchema = z.object({
action: z.enum(SHELL_SESSION_ACTIONS),
session_id: …, timeout_s: …describe('wait: …'), cols: …describe('resize: …'), …
}).superRefine((value, ctx) => {
const parsed = shellSessionActions.safeParse(value)
if (parsed.success) return
for (const issue of parsed.error.issues)
ctx.addIssue({ code: 'custom', message: `action=${value.action}: ${issue.message}`, path: issue.path })
})
为什么不直接把 discriminated union 发给模型?各家模型的函数调用 schema 对顶层 oneOf 支持参差,扁平对象到哪都能用。字段描述里的 wait:、resize: 前缀负责告诉模型「这个参数属于谁」,superRefine 负责在执行前把越界的拒掉。message_read 用的是同一个模式(historyRead / contextRead / payloadRead 三个 strictObject 加 superRefine,错误前缀 mode=…)。
测试里的用例:
accept({ action: 'kill', session_id: 4242 })
accept({ action: 'resize', session_id: 4242, cols: 80, rows: 24 })
refuse({ action: 'wait', session_id: 4242, chars: 'x' }) // chars 是 poll 的
refuse({ action: 'kill', session_id: 4242, timeout_s: 3 }) // timeout_s 是 wait 的
refuse({ action: 'list', session_id: 4242 }) // list 不收参数
refuse({ action: 'poll', session_id: 4242, until_output: 'x' })
refuse({ action: 'resize', session_id: 4242 }) // 缺 cols / rows
refuse({ session_id: 4242 }) // 没说要干什么
结果是:合并后的工具对参数的约束,和原来六个独立工具一样严,甚至更严(strictObject 拒绝未知字段)。模型传错时拿到的是「action=kill: 多了 timeout_s」,不是一次静默成功。
6. 代价:名字不再等于语义⌗
合并有一个不太显眼的代价。原来很多地方按工具名判断行为,现在同一个名字下的六个动作行为各不相同:
| 消费方 | 原来按名字判断 | 现在必须看 action |
|---|---|---|
| 并行安全 | shell_list 可并行 | isParallelSafe: args.action === 'list',其余五个都不行 |
| 路由到会话所属 Host | SHELL_FOLLOWUP_TOOLS 一个名字集合 | isShellFollowUp(call):poll / wait / kill / resize / unwatch,list 不算 |
| 空轮询提示(「仍在运行,可以结束回合」) | POLL_TOOLS 集合 | isShellPoll(call):只有 poll(和 write_stdin) |
| 插话 / 停止时交回真实结果 | 名单里列 shell_poll、shell_wait… | shell_session 整体在名单,瞬时动作交回无差别 |
| 定向成本分类 | shell_list = 自省,shell_poll = 跟随上一个 shell | list → self,poll / wait → 跟随,其余 → work |
| 丢失结果的诚实文案 | 工具名 | shell_session wait 这样「名字 + 动作」拼出来 |
这些判断要是散落在各处各写一遍 args.action === …,迟早有一处漏掉。这次的处理是把它们收到一个 30 行的小模块 src/runtime/shell-session-call.ts:
export const SHELL_SESSION_ACTIONS = ['poll', 'wait', 'kill', 'list', 'resize', 'unwatch'] as const
export function shellSessionAction(call): ShellSessionAction | null
export function isShellPoll(call): boolean // write_stdin 或 shell_session poll
export function isShellFollowUp(call): boolean // 针对已在运行的会话、要路由到它的 Host
路由器、轮询守卫、yield、成本分类都从这里取答案。「哪些调用算轮询」这类问题从此只有一个定义。
这也是为什么规则写的是同一对象才合,不是「相似的都合」。shell(起一条命令)和 shell_session(管一个已在运行的会话)没有合并:对象不同,路由规则也不同,前者去线程绑定的 Host,后者去会话所在的 Host。硬合在一起,上表每一行都得再多一个分支。
7. 留:数据说不能删的那一个⌗
按前三节的逻辑,set_workdirs 看起来也该删。它自己的描述第一句就是:
通常不需要调用,框架会根据文件写入路径自动推断并加入工作目录;
仅在需要主动声明、调整或清空时使用。
「通常不需要调用」的常驻工具,删掉很合理。但提交说明里记了它的真实使用量:378 个线程里调用了 384 次。几乎每个线程一次,而且就是在第一次写文件之前:模型在开工时先声明「我要在这个项目里干活」。框架的自动推断只能在写入之后补上,而 set_workdirs 做的事要在写入之前完成:检查 git / SVN / 同步盘结构,普通目录自动 init 并提交基线,存影子快照,绑定项目类别,注入根 AGENTS.md 全文。
384 / 378 这个比例本身也说明它没被滥用:不是一个线程里反复调,是每个线程恰好在该用的那一刻用一次。删掉它,这些线程就只能先写一个文件、触发推断、再回头读注入进来的项目规则。顺序整个反了。
它的读取伴侣 get_workdirs 删了(并进 introspect),写入保留。保留谁、删掉谁,看的是调用记录,不是描述里的自我评价。
8. 小结⌗
┌──────────── 能被现有原语等价替代? ── 是 ──→ 删
一个常驻工具 ─┤
├──────────── 和别的工具操作同一个对象? ── 是 ──→ 合进去,action/mode 区分,
│ 每个动作严格校验参数,
│ 按动作判断行为的逻辑收到一处
│
└──────────── 直觉想删? ── 先看调用数据 ── 在被稳定使用 ──→ 留
这次整合教会我的几件事:
- 专用小工具的成本不在它自己身上,在列表里所有和它名字相近的工具上。每加一个,模型在所有相关场景里多一个选错的机会。
- 合并要按对象合,不是按相似度合。 判据是「它们是不是在操作同一个东西」:同一个会话、同一张消息表、同一个文件(不管在哪台机器上)。
shell和shell_session名字最像,却不该合。 - 合并后的 schema 要比合并前更严,而不是更松。 扁平发布、严格校验、错误标出动作,三件事缺一件,合并就是在把选错的成本从「选错工具」挪到「静默传错参数」。
- 名字承载的语义要找个新家。 以前散落在各处的
name === 'shell_poll',要么集中到一个模块,要么等着某处漏改。 - 删之前看数据。 描述说「通常不需要」的工具,378 个线程里用了 384 次。
还没解决的:Responses 系的 apply_patch 是 Codex 的自由格式工具,没有参数位放 device,那一族模型改远端文件仍要走 read_file + write_file(带 device 和 expected_sha256)。工具面在这一处仍因厂商而异,只是少了一大截。