工具注册和作用域:谁能看到什么工具
ToolRuntime.register() 存入 ScopedLayers;view() 解析可见性:inherited tools 经 ToolRestriction 过滤,scope 自己注册的不被过滤;run_code 不在任何 layer 中由 view() 懒加载追加;isConcurrencySafe opt-in per tool,classifier 抛错返回 exclusive(fail-closed)。
你可能觉得工具注册就是往一个 Map 里 set(name, definition),get(name) 时拿出来。DeepSeek Harness 不是这么干的。如果只有一个全局 Map,你没法给不同 agent 配置不同的工具可见性,也没法实现”子 agent 继承父 agent 的工具但可以 deny 掉几个”这种层级作用域。
所以它用了 ScopedLayers——一层一层的作用域栈。更反直觉的是:ToolRestriction(allow/deny 过滤器)只过滤”继承来的工具”,当前 scope 自己注册的工具不受任何过滤影响。这不是 bug,这是故意设计的——子 agent 用来和你通信的内部工具,不能被你配置的 capability filter 剪掉。
ScopedLayers:不是一个 Map,是一层一层的栈
ToolRuntime 构造函数里初始化了 this.layers = new ScopedLayers(...),回调是 () => { this.ctx.emit('tools/change') }——任何 layer 变化都会发 tools/change 事件。这就是为什么上一章调度器能感知到 registry 变化即时创建 barrier。
ScopedLayers 维护一个 global layer 和每个 scope/agent 对应的 layer。每个 ToolLayer 包含:
tools: Map<string, ToolDefinition>:这一层注册的工具restrictions: CompiledToolRestriction[]:这一层加的过滤器(allow/deny)guards: ToolGuard[]:这一层注册的 guard 函数mode?:ToolPresentationMode:这一层的 presentation mode(native/code/both)
register(definition) 方法很简单:它调用 this.layers.effect(ctx, layer => layer.tools.insert(name, definition), { label: 'tools.register()' })。effect 是 Cordis 的作用域资源管理——在当前 ctx scope 内注册,ctx dispose 时自动取消注册(返回 disposer 函数)。
但 register 有两个硬限制:
- 输出必须声明
{ schema, render, presentationMeta? },否则抛 TypeError - name 不能是 `RUN_CODE_NAME(即 ‘run_code’),直接抛 Error:“tool name “run_code” is reserved…“
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "RUN_CODE_NAME" "$repo/packages/core/tools/src/index.ts" | head -10
sed -n '1036-1061p' "$repo/packages/core/tools/src/index.ts"
view():可见性解析的核心逻辑
view(scope?) 是整个工具可见性的核心。每次要解析一个 scope 能看到什么工具,都走这个方法。它的逻辑按顺序是:
第一步:拿 layer 链。 const layers = this.layers.chainLayers(scope)——从 global 开始,沿 scope 继承链一路到当前 scope,farthest ancestor first,最末端是当前 scope 自己的 layer(如果有的话)。
第二步:建 inherited map。 初始化 inherited 为 global layer 的 tools 副本,然后遍历 layers 中除了 own layer 之外的每一层,把 tools 合并进来——nearest ancestor wins(后遍历的覆盖前面的同名工具)。
第三步:过滤 inherited。 遍历 inherited 中的每个工具,检查 layers 中每一层是否 admits(name)。只有所有层都允许的工具才进入 visible map(restrictions 是 intersect 关系——任何一层 deny 了就不可见)。同时记录 knownNames(所有见过的名字,不管是否被过滤)和 restrictableNames(可以被 restrict 的名字,即 inherited 的名字)。
第四步:加 own tools。 如果当前 scope 有自己的 layer,把它的 tools 全部加进 visible 和 knownNames——这里不做任何 restriction 检查。own tools 直接覆盖 inherited 中的同名条目,且无视任何 deny/allow 规则。
第五步:懒加载加 run_code。 如果当前 scope 的 mode 不是 ‘native’(即 code 或 both),把 RUN_CODE_NAME 映射到 requireCodeTransport() 返回的定义,放进 visible。
那个关键注释就在这里:“That exemption is what a per-child capability filter has to keep intact: the delegation runtime registers a child’s reporting and structured-output tools into the child’s own layer, and a filter naming the capabilities the child may use must not strip the machinery it answers through.”
翻译一下就是:父 agent 给子 agent 设的 restrict filter,只能过滤子 agent 从父级继承来的工具,不能过滤子 agent 自己注册的工具。因为子 agent 运行时(比如 subagent 系统)会在子 agent 的 own layer 注册汇报工具、结构化输出工具——这些是子 agent 和父 agent 通信的管道,你要是把这些过滤掉了,子 agent 就没法回答你了。
flowchart TD
accTitle: 工具可见性解析流程
accDescr: view() 先构建 inherited map(global + ancestor layers,nearest wins),inherited 经所有层 restrictions 过滤,own tools 直接加入不经过滤,code-mode 追加 run_code。
START["view(scope)"] --> CHAIN["layers = chainLayers(scope)<br/>farthest ancestor first<br/>own layer 在最后"]
CHAIN --> INHERITED["inherited = new Map(global.tools)<br/>for layer in layers 且 layer !== own:<br/> 合并 layer.tools 到 inherited<br/> (nearest wins 覆盖同名)"]
INHERITED --> FILTER["visible = new Map()<br/>for [name, def] of inherited:<br/> knownNames.add(name)<br/> restrictableNames.add(name)<br/> if layers.every(l => l.admits(name)):<br/> visible.set(name, def)"]
FILTER --> OWN{"own layer 存在?"}
OWN -->|是| ADDOWN["for [name, def] of own.tools:<br/> knownNames.add(name)<br/> visible.set(name, def)<br/> (不过滤!直接覆盖)"]
OWN -->|否| MODE
ADDOWN --> MODE{"modeFor(scope) !== 'native'?"}
MODE -->|是| ADDRUN["visible.set(RUN_CODE_NAME,<br/> requireCodeTransport())"]
MODE -->|否| RETURN
ADDRUN --> RETURN["return { visible, knownNames, restrictableNames }"]
run_code:不在任何 layer 里的特殊工具
你注意到了吗?register() 方法明确禁止注册名为 ‘run_code’ 的工具。run_code 永远不会出现在任何 ToolLayer 的 tools map 里——它是 view() 在第五步根据 mode 条件追加的。
这意味着几件事:
第一,你不能用 register() 覆盖 run_code。想都别想,直接抛错。
第二,你不能用 restrict({ deny: ['run_code'] }) 移除它。因为 restrictableNames 只包含 inherited tools 的名字,run_code 不在 inherited 里,restrict() 方法开头会检查:如果 allow/deny 里包含 RUN_CODE_NAME,直接抛 Error:“tools.restrict() cannot name reserved Code Mode presentation transport “run_code”; restrict end-capability tools instead”。
第三,它的可见性完全由 modeFor(scope) 决定。mode 是 ‘native’ 时,view() 根本不追加它,native agent 的 visible map 里没有 run_code;mode 是 ‘code’ 或 ‘both’ 时才追加。mode 的继承规则是 nearest scope wins,沿 chainLayers 从近到远找第一个定义了 mode 的 layer,找不到用 defaultMode。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '1084-1086p' "$repo/packages/core/tools/src/index.ts"
你会看到 restrict() 里检查 RUN_CODE_NAME 的代码:它明确禁止你在 allow/deny 列表里写 ‘run_code’,提示你去 restrict 终端能力工具(比如 bash、fs)而不是 transport 本身。
isConcurrencySafe:opt-in,fail-closed
调度器里的 executionMode() 怎么判断一个 call 是 parallel 还是 exclusive?不是看工具名字,也不是默认并行——它调用工具定义的 isConcurrencySafe(args) 分类器。
注意这个分类器是每个工具 opt-in 的。defineTool() 时如果你不提供 isConcurrencySafe,这个工具就没有这个方法,executionMode() 怎么处理?看 defineTool() 里的包装逻辑:
if (userIsConcurrencySafe) {
tool.isConcurrencySafe = (args: unknown): boolean => {
if (validate(args).length > 0) return false
return userIsConcurrencySafe(args as InferArgs<S>)
}
}
如果没提供 userIsConcurrencySafe,tool 上就没有 isConcurrencySafe 方法。那 executionMode() 对没有这个方法的工具怎么处理?它默认是 exclusive——fail-closed。
还有:分类器函数如果抛错,不是让这个工具并行,而是当作 exclusive 处理。任何不确定的情况都保守地当成不能并行,这是安全默认。参数校验失败也返回 false(exclusive)——参数都不合法,当然不能让它进并行池添乱。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "executionMode" "$repo/packages/core/tools/src/index.ts" | head -10
你可以 grep 一下 executionMode 的实现,确认它的 fail-closed 逻辑——对没有 isConcurrencySafe 方法的工具、对分类器抛错的情况,都返回 exclusive。
restrict():它到底能限制什么
看完 view() 你就明白 restrict() 的边界了:
- 它只能在 scoped context(agent.ctx)里调用,全局 ctx 调用直接抛错。全局限制会影响所有 agent,这是不允许的——你想 deny 哪个 agent 就对哪个 agent 调。
- 空 filter()抛错:“tools.restrict() is a no-op”,几乎可以肯定是配置 bug。
- allow 和 deny 里的名字必须是已知的 global/inherited 工具名,unknown 名字抛错(列出来已知的给你看)。
- 不能包含 RUN_CODE_NAME,前面说过了。
- restrictions 是追加到当前 layer 的,多个 restrict() 调用的规则是 intersect(所有限制都要满足)。
- 它只影响 inherited tools 的可见性,不影响当前 scope 自己注册的工具。
presentAs(mode) 也是类似:只能在 scoped context 调用,设置当前 layer 的 mode,影响这个 scope 及子 scope 看到的工具 presentation 形态(native vs code SDK)。
容易踩的坑
坑一:以为 restrict({ deny: ['bash'] }) 能让子 agent 彻底用不了 bash。 如果 bash 是子 agent 自己在 own layer 注册的(比如某些 preset 会给子 agent 注册定制化 bash 工具),restrict 过滤不了它。你得确保 bash 是在 ancestor/global layer 注册的,restrict 才生效。
坑二:试图注册或禁用 run_code。 这是 reserved name,register() 会抛错,restrict() 也会抛错。想控制 code mode 开关用 presentAs(‘native’) 或者配置 mode,不要直接动 run_code。
坑三:以为工具默认并行。 不写 isConcurrencySafe 就默认 exclusive——你的自定义工具如果不声明可以并行,它就会作为屏障,等所有前面的跑完它才跑,它跑完前后面的也不能跑。想并行必须显式实现 isConcurrencySafe 并返回 true。
坑四:以为 isConcurrencySafe 是静态属性。 它接收 args 参数——同一个工具,不同参数可能有的可以并行有的不行。比如文件写入工具,写不同文件可以并行,写同一个文件不行——这时候你需要根据 args(文件路径)判断,而不是写死 return true。
坑五:own tools exemption 不是”子 agent 可以绕过父级的所有限制”。 它只是让子 agent 自己注册的工具不受父级 restrict 影响。子 agent 从父级继承来的工具(比如 bash、read_file 这些内置工具)还是受所有祖先层 restrictions 约束的。exemption 只保护子 agent 自己的”通信管道”工具,不是给子 agent 开后门。
工具注册和可见性解决了”谁能看到什么工具”的问题。但看到了不等于能调用——模型传过来的参数可能是错的、不存在的、或者在 code mode 下调用了非 run_code 工具。下一章讲参数校验,看”不行”这两个字到底在哪一层说出口。