Zero 架构 · 第十七课
第二课讲了 executeToolCall 那道闸门 —— 什么时候准跑。这一课讲被它调度的东西本身长什么样:
一个工具怎么注册、怎么被分发、怎么在不改基接口的前提下长出新能力。答案是 Zero 工具系统的中枢:
Registry + 一个极小的 Tool 接口
types.go:119。
对齐 mission:一个 coding agent 的能力边界,就是它工具集的边界。你要能随时加一个新工具(甚至外部 MCP 工具), 又不能让每加一个都去动核心循环、也不能让某个工具作者忘了检查权限就闯了祸。这一课的功夫,是看 Zero 怎么用 「一个必须实现的小接口 + 一排可选接口 + 一条唯一分发咽喉」同时拿下可扩展与安全这两个通常打架的目标。
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 工具后,把每个工具的 schema 都塞进 prompt 会把上下文撑爆(第五课的 prompt 预算)。所以部分工具延迟广告:
平时不进 prompt,靠 tool_search 按需捞出。IsDeferred
registry.go:67 判它藏不藏,而
DeferralEligible registry.go:88 有个微妙作用:
延迟机制只在工具总数超过阈值时启用。DeferralEligible 让「可被延迟的工具数」保持稳定——
否则临时 un-defer 一个工具,可能把计数压到阈值之下,反手把所有工具都强行曝光回 prompt 里,前功尽弃。它把计数与「当前谁被藏」解耦,让阈值判定稳定。
最后一层:哪些工具进注册表,本身也是分层组合的。CoreReadOnlyToolsScoped
registry.go:237、CoreWriteToolsScoped :258、
CoreShellToolsScoped :268、CoreNetworkTools :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 脱敏、权限门、沙箱评估怎么全挤在这一个函数里——
这就是「一条唯一咽喉同时拿下可扩展与安全」的全部现场。
Tool 接口的对象、schema 怎么来)?」、
「PrePermissionRejecter(types.go:141)为什么必须是本地确定性的、它和沙箱评估的分工是什么?」,
或者换个大块:「权限授权的持久化与 scope 匹配——一次 /allow 之后,下次同类调用怎么被认成『已授权』(grants.go / grant_scope.go)?」