Zero 架构 · 第十七课

工具注册表:5 个方法的核心接口,和它身后一排「可选能力」

第二课讲了 executeToolCall 那道闸门 —— 什么时候准跑。这一课讲被它调度的东西本身长什么样: 一个工具怎么注册、怎么被分发、怎么在不改基接口的前提下长出新能力。答案是 Zero 工具系统的中枢: Registry + 一个极小的 Tool 接口 types.go:119

对齐 mission:一个 coding agent 的能力边界,就是它工具集的边界。你要能随时加一个新工具(甚至外部 MCP 工具), 又不能让每加一个都去动核心循环、也不能让某个工具作者忘了检查权限就闯了祸。这一课的功夫,是看 Zero 怎么用 「一个必须实现的小接口 + 一排可选接口 + 一条唯一分发咽喉」同时拿下可扩展安全这两个通常打架的目标。

核心接口小到只有 5 个方法

type Tool interface {
    Name() string
    Description() string
    Parameters() Schema          // 喂给模型的 JSON schema
    Safety() Safety              // 声明:副作用 + 权限 + 理由
    Run(ctx, args) Result        // 真正干活
}                                                // types.go:119

就这么点。一个工具只要答得出「我叫什么、干什么、参数长啥样、我有多危险、怎么跑」,就能被注册 (Register registry.go:96,一个 map[string]Tool)。 注意 Safety types.go:60:它是声明式元数据 (SideEffect/Permission/Reason)—— 工具声明自己的副作用和危险度,但绝不自己检查权限。 判定权归第十三课的沙箱引擎。工具说「我是什么」,引擎决定「你能不能跑」—— 策略与机制分离。

可选能力:不改基接口,靠「结构类型 + 分发时断言」长出来

5 个方法是所有工具的最小契约。但有的工具需要更多上下文:要拿到沙箱引擎、要拿到进度回调(第十六课的 Progress)。若把这些塞进基接口,那所有工具都得被迫实现一堆用不到的方法。Zero 的选择是一排可选接口 ——工具愿意就实现,注册表在分发时用类型断言探一下:

type sandboxAwareTool interface { RunWithSandbox(ctx, args, *sandbox.Engine) Result }  // registry.go:45
type optionsAwareTool interface { RunWithOptions(ctx, args, RunOptions) Result }        // registry.go:49
type deferredTool        interface { Deferred() bool }                                  // registry.go:56
type deferralEligibleTool interface { DeferralEligible() bool }                         // registry.go:79

分发时「谁能耐大用谁」:RunWithOptions > RunWithSandbox > 裸 Run (registry.go:177)。一个工具只要结构上多长出一个方法,就自动升级到更富的分发路径—— 不用改 Tool 接口,不用动其他工具,更不用在核心循环里加 if type==...。这与第九课「一个 Provider 接口喂饱各家」是同一手 Go 结构类型的活用。

唯一咽喉:RunWithOptions 一条路,横切逻辑全收于此

所有工具调用只有一条真正的分发路径——RunWithOptions registry.go:117。裸 Run 只是它的薄封装。既然唯一,那些「每个工具都必须做、但工具作者最容易忘」的横切关注点,就都钉死在这一处:

func (r *Registry) RunWithOptions(ctx, name, args, opts) Result {
    defer scrubResultSecrets(&result)         // ① 出口一律脱敏      registry.go:121
    tool := r.Get(name)                        // 未知工具直接拒       :123
    if rej, ok := tool.(PrePermissionRejecter); ok { … }   // ② 权限前本地拦截 :127
    perm := r.effectiveToolPermission(tool, args)          // ③ 算有效权限  :133
    decision := opts.Sandbox.Evaluate(…)                   // ④ 交沙箱裁决  :137
    switch decision.Action { Deny→拒; Prompt→按授权; }      //             :147
    // ⑤ 按能力择优分发
    if t, ok := tool.(optionsAwareTool); ok { return t.RunWithOptions(…) }  // :177
    if t, ok := tool.(sandboxAwareTool); ok { return t.RunWithSandbox(…) }  // :185
    return tool.Run(ctx, args)                                              // :192
}
为什么「唯一咽喉」是安全的关键 权限门、沙箱评估(第十三课)、密钥擦洗(scrubResultSecrets registry.go:206,呼应第十课 Redact)——全部收在这一个函数里。 于是一个工具作者无法忘记检查权限或擦洗密钥:他根本没机会,这些不由工具做,而由咽喉替所有工具统一做。 defer 更保证了哪怕中途 return,出口脱敏也必然执行。可扩展(随便加工具)与安全(没人能绕过检查)这对通常打架的目标,就靠「小接口 + 唯一咽喉」同时拿下。

权限只能「放松」,不能「收紧」:ArgsPermissioner

effectiveToolPermission registry.go:197 默认取工具静态声明的 Safety.Permission。但有的工具能就这一次具体调用证明自己无害——比如 Task 派出去的是个只读子 agent(第十六课的 IsReadOnlySpecialist)。这时它实现 ArgsPermissioner types.go:134,把 Prompt 降成 Allow

方向是单向的:只准降,不准升 PermissionForArgs 的契约是只能放松:能证明这次调用安全就放行,拿不准就必须退回更严的静态权限。 为什么不许它反向收紧?因为收紧的判定权属于沙箱引擎(第十三课),工具自评「我更危险」会让判定散落回各工具、破坏策略/机制分离。 放松是「工具用自己独有的 args 知识补充一条豁免」,收紧则是越权做引擎的活。

延迟加载:MCP 工具一多,得先「藏起来」再按需现身

接入一堆 MCP 工具后,把每个工具的 schema 都塞进 prompt 会把上下文撑爆(第五课的 prompt 预算)。所以部分工具延迟广告: 平时不进 prompt,靠 tool_search 按需捞出。IsDeferred registry.go:67 判它藏不藏,而 DeferralEligible registry.go:88 有个微妙作用:

延迟机制只在工具总数超过阈值时启用。DeferralEligible 让「可被延迟的工具数」保持稳定—— 否则临时 un-defer 一个工具,可能把计数压到阈值之下,反手把所有工具都强行曝光回 prompt 里,前功尽弃。它把计数与「当前谁被藏」解耦,让阈值判定稳定。

组合:核心工具集也是「按 scope 拼出来」的

最后一层:哪些工具进注册表,本身也是分层组合的。CoreReadOnlyToolsScoped registry.go:237CoreWriteToolsScoped :258CoreShellToolsScoped :268CoreNetworkTools :277 各产一批,CoreToolsScoped :290 按 scope 拼总。 工具集边界=agent 能力边界,而这个边界是可按场景裁剪的:只读会话就不装 write/shell,一如第五课的 system prompt 按预算裁段。

动手回忆

一个工具怎样在不改动基接口的前提下获得额外能力?

工具的 Safety 字段在整个权限体系里扮演什么角色?

为什么所有工具调用都必须穿过 RunWithOptions 这一条路?

接下来该读的一手源码

less +119 ../zero/internal/tools/types.go       # Tool 接口 5 方法 + Safety 声明式元数据
less +134 ../zero/internal/tools/types.go       # ArgsPermissioner:只准放松,不准收紧
less +117 ../zero/internal/tools/registry.go    # RunWithOptions:唯一咽喉 + defer 脱敏 + 择优分发
less +197 ../zero/internal/tools/registry.go    # effectiveToolPermission:静态权限 vs args 放松
less +206 ../zero/internal/tools/registry.go    # scrubResultSecrets:出口边界脱敏
less +290 ../zero/internal/tools/registry.go    # CoreToolsScoped:按 scope 拼出核心工具集

读的顺序:先看 types.go:119 的 5 方法接口,体会它刻意的小。再看 registry.go:45 那排可选接口和 :177 的择优分发—— 理解「能力靠结构类型长出来」。最后精读 RunWithOptions 全身:注意 defer 脱敏、权限门、沙箱评估怎么全挤在这一个函数里—— 这就是「一条唯一咽喉同时拿下可扩展与安全」的全部现场。

我是你的老师 —— 随时问我。 适合现在追问: 「MCP 工具是怎么被接进这张注册表的(外部进程的工具如何变成一个满足 Tool 接口的对象、schema 怎么来)?」、 「PrePermissionRejecter(types.go:141)为什么必须是本地确定性的、它和沙箱评估的分工是什么?」, 或者换个大块:「权限授权的持久化与 scope 匹配——一次 /allow 之后,下次同类调用怎么被认成『已授权』(grants.go / grant_scope.go)?」