Book 第十九章:元工具真正约束的是什么
第四部分:扩展与协作

第十九章:元工具真正约束的是什么

拆 ToolSearch、StructuredOutput、TestingPermission 这些“工具之上的工具”。

1. 为什么这一组要单独拆

前面拆的大多数工具,目标都是替用户做事:

  • 读文件
  • 改代码
  • 跑命令
  • 搜网页
  • 调 agent

但这一组不太一样。

它们真正作用的对象,不是“用户的业务任务”,而是 Claude Code 自己的运行时:

  • ToolSearchTool 决定模型现在能不能真正拿到某个工具的 schema
  • SyntheticOutputTool 决定一次非交互会话最后必须用什么结构化格式收口
  • TestingPermissionTool 决定权限审批链能不能被端到端验证

所以这组工具更像是:

工具系统的治理工具。

它们处理的不是业务世界,而是 Claude Code 自己的三个核心边界:

  • 工具可见性边界
  • 最终输出边界
  • 权限验证边界

2. 先说结论

我对这一层的总体判断是:

Claude Code 不只把“干活能力”做成 tool,也把“控制工具系统本身的能力”做成了 tool。

更具体一点,它在做三件事:

  1. 不把所有工具 schema 一次性塞给模型,而是用 ToolSearchTool 做按需加载
  2. 不把“按 JSON 输出”停留在 prompt 约定层,而是用 SyntheticOutputTool 做运行时强约束
  3. 不把权限系统只放在单元测试里 mock,而是用 TestingPermissionTool 走完整审批链做集成验证

这说明 Claude Code 的工具体系已经不是一个“功能列表”,而是一个:

可以管理自己暴露面、输出契约和安全管道的 runtime。

3. 源码锚点

这次主要看的源码是:

  • src/tools/ToolSearchTool/ToolSearchTool.ts
  • src/tools/ToolSearchTool/prompt.ts
  • src/tools/ToolSearchTool/constants.ts
  • src/tools/SyntheticOutputTool/SyntheticOutputTool.ts
  • src/tools/testing/TestingPermissionTool.tsx

配套还看了:

  • src/tools.ts
  • src/main.tsx
  • src/QueryEngine.ts
  • src/utils/toolSearch.ts
  • src/utils/hooks/hookHelpers.ts
  • src/utils/api.ts
  • src/utils/collapseReadSearch.ts
  • src/constants/tools.ts
  • src/tasks/LocalAgentTask/LocalAgentTask.tsx

4. 总体结构图

flowchart TD
    A["Claude Code 元工具层"] --> B["ToolSearchTool"]
    A --> C["SyntheticOutputTool<br/>StructuredOutput"]
    A --> D["TestingPermissionTool"]

    B --> B1["deferred tool 判定"]
    B1 --> B2["关键词 / select: 搜索"]
    B2 --> B3["返回 tool_reference"]
    B3 --> B4["后续请求只带已发现工具"]

    C --> C1["非交互会话 + JSON Schema"]
    C1 --> C2["运行时创建 StructuredOutput"]
    C2 --> C3["Stop hook 强制必须调用"]
    C3 --> C4["QueryEngine 捕获 structured_output"]

    D --> D1["仅 test 环境注入"]
    D1 --> D2["checkPermissions 永远 ask"]
    D2 --> D3["端到端验证权限弹窗链路"]

这张图里最关键的一点是:

这三个工具都不是“多一种业务能力”,而是在改 Claude Code 自己的运行规则。

5. ToolSearchTool:它不是搜索框,而是延迟 schema 加载器

很多人第一次看 ToolSearchTool,会把它理解成“给模型找工具名的检索工具”。

这只对了一半。

它真正重要的不是“搜”,而是:

把 deferred tools 的完整 schema 按需加载进当前会话。

5.1 它解决的是 prompt 太大,不是搜索体验不好

prompt.ts 的描述写得很直白:

  • deferred tools 初始只暴露名字
  • 在没 fetch 之前,没有参数 schema
  • 没 schema 就不能调用
  • ToolSearchTool 返回的是 <functions> 等价物

也就是说,它的目标不是帮模型“想起来有哪些工具”,而是避免:

  • MCP 工具太多
  • 特定实验工具太多
  • 一上来把全部 schema 塞进 system prompt,直接把 token 预算打爆

所以它本质上是:

prompt-cache / token-budget 治理机制。

5.2 哪些工具会被 defer,不是随便定的

src/tools/ToolSearchTool/prompt.tsisDeferredTool() 的规则很清楚。

优先级从高到低大概是:

  • alwaysLoad === true 的工具永不 defer
  • MCP 工具默认全部 defer
  • ToolSearchTool 自己永不 defer
  • 某些首轮必须可见的工具永不 defer
  • 剩下才看 tool.shouldDefer === true

这里最值得注意的是“某些首轮必须可见的工具”这层例外:

  • AgentTool 在 fork-subagent 打开时不能被 defer
  • BriefTool 在相关 feature 打开时不能被 defer
  • SendUserFileTool 在 bridge 激活时不能被 defer

这说明 Claude Code 的 defer 逻辑不是纯技术优化,而是带产品语义的:

凡是会影响第一轮核心交互协议的工具,不能藏到 ToolSearch 后面。

5.3 它不是模糊搜一下就完了,而是有一整套检索策略

ToolSearchTool.ts 里做了几层事情。

第一层是精确选择:

  • 支持 select:Read,Edit,Grep
  • 即便目标工具不在 deferred 集合里,只要已经在总工具池里,也会返回
  • 这被作者明确当成 harmless no-op,用来减少模型重试抖动

第二层是 MCP 前缀匹配:

  • 如果 query 以 mcp__server 开头
  • 就优先按 server 前缀匹配一批工具

第三层才是关键词搜索:

  • 常规工具名会拆 CamelCase
  • MCP 工具名会拆 mcp__server__action
  • 支持 +required optional 这种必含词 + 排序词查询
  • searchHint 比 prompt 描述权重更高
  • prompt 描述会 memoize,避免每轮反复展开整段工具 prompt

这里的 getToolDescriptionMemoized() 很关键。

它不是缓存“搜索结果”,而是缓存:

每个工具完整 prompt 描述文本。

原因很现实:

  • 关键词搜索要扫工具描述
  • 工具描述本身可能很长
  • 如果 deferred tools 每轮都重新 tool.prompt(...),开销很大

所以 Claude Code 又加了一层:

  • 当前 deferred tools 集合如果变了
  • 就通过 maybeInvalidateCache() 清掉描述缓存

这说明 ToolSearchTool 已经不只是功能工具,还承担了运行时缓存治理。

5.4 它返回的不是普通摘要,而是 tool_reference

这是 ToolSearchTool 最关键的一点。

call() 返回的只是一个轻量结构:

  • matches
  • query
  • total_deferred_tools
  • pending_mcp_servers

但真正重要的是 mapToolResultToToolResultBlockParam()

匹配成功时,它不会回一段“建议你用哪个工具”的说明,而是直接返回:

  • tool_result
  • 里面的 content 是一组 tool_reference

这意味着 ToolSearchTool 的结果不是给人看的,而是给模型运行时扩 schema 用的。

所以更准确的说法应该是:

ToolSearchTool 是一个 schema loader,不是搜索 UI。

5.5 无结果时,它还会告诉模型“别急,MCP 还在连”

call() 里专门会看:

  • appState.mcp.clients
  • 有没有 type === 'pending' 的 server

如果一个都没搜到,它会把 pending_mcp_servers 带回去。

最后在 mapToolResultToToolResultBlockParam() 里,无结果提示会变成:

  • 没找到匹配工具
  • 但有些 MCP server 还在连接,稍后再试

这个处理很细。

它解决的是一个典型错觉:

  • 模型以为“没有这个工具”
  • 实际上只是 server 还没连好

Claude Code 在这里主动把“连接未完成”和“真的不存在”区分开了。

5.6 Claude Code 还围着它做了很多额外处理

ToolSearchTool 真正发挥作用,不只靠它自己的文件。

外围至少还有这几层配套:

  • src/tools.ts 里只在 isToolSearchEnabledOptimistic() 为真时才把它放进基础工具池
  • src/utils/toolSearch.ts 里还会按模型能力、beta 形状、threshold 再决定这一轮到底启不用
  • src/utils/api.ts 会把 defer 工具输出成 defer_loading schema
  • extractDiscoveredToolNames() 会从历史消息里的 tool_reference 回扫出“已经发现过哪些工具”
  • src/utils/collapseReadSearch.ts 会把 ToolSearch 当成 silent meta-operation,尽量不打断 transcript 折叠

这几层合起来以后,它就不只是“找工具”,而是整套动态工具暴露机制的入口。

6. SyntheticOutputTool:它不是“提醒模型输出 JSON”,而是结构化收口协议

SyntheticOutputTool 的公开名是 StructuredOutput

这个名字已经说明它的真实作用:

它不是帮模型格式化文本,而是把最终输出升级成一次正式工具调用。

6.1 它只在特定场景出现,而且是运行时动态创建

isSyntheticOutputToolEnabled() 的规则很简单:

  • 只有非交互会话才启用

main.tsx 做得更彻底:

  • 先跑正常 getTools() 过滤
  • 只有当用户传了 jsonSchema
  • 且当前是 non-interactive session
  • 才会 createSyntheticOutputTool(jsonSchema)
  • 然后把返回的 tool 动态 append 到工具池末尾

代码里还专门写了注释:

这是实现细节,不应该当成用户可控的普通工具。

所以它不是常驻基础工具,而是:

一次会话级输出契约的临时执行器。

6.2 它把“输出必须符合 schema”做成了 runtime 校验

createSyntheticOutputTool() 这里很值得抄。

它不是简单把传进来的 JSON Schema 原样挂上去,而是先:

  • new Ajv({ allErrors: true })
  • validateSchema(jsonSchema)
  • compile(jsonSchema)

如果 schema 本身不合法,直接返回 { error }

如果 schema 合法,再生成一个定制过的 StructuredOutput 实例:

  • inputJSONSchema 用外部传入的 schema
  • call(input) 时用 Ajv 对真正输入再校验一次
  • 不通过就抛 TelemetrySafeError

这意味着 Claude Code 并不满足于:

  • prompt 里告诉模型“请按 JSON 输出”

而是直接把这个要求提升成:

  • runtime 可验证
  • 调用失败可重试
  • schema 错误和模型输出错误能分开

6.3 它还专门做了性能缓存

这里有个小而典型的工程细节。

作者专门用了 WeakMap<object, CreateResult>() 做 schema 对象级缓存。

原因在注释里写得很清楚:

  • 某些 workflow 会重复多次用同一个 schema 对象
  • 如果每次都重新 Ajv()validateSchema()compile(),会有明显额外开销

所以它不是缓存“schema 字符串”,而是缓存:

同一个 schema 对象引用对应的已经编译好的结果。

这很像一种 runtime 内部工具工厂缓存。

6.4 Claude Code 不只注入它,还强制它必须被调用

这点是 StructuredOutput 最关键的运行时升级。

QueryEngine.ts 里会在检测到:

  • 当前 query 带了 jsonSchema
  • 工具池里也确实存在 StructuredOutput

之后调用 registerStructuredOutputEnforcement(...)

src/utils/hooks/hookHelpers.ts 里这个 enforcement 做的事情是:

  • 注册一个 Stop function hook
  • 判断本轮消息里有没有成功调用 StructuredOutput
  • 如果没有,就向模型追加强提醒: You MUST call the StructuredOutput tool to complete this request.

也就是说,Claude Code 在这里不是“建议你收尾时调一下”,而是:

StructuredOutput 变成了 stop 条件的一部分。

6.5 QueryEngine 还会继续追踪它的调用次数和最终结果

QueryEngine.ts 里围着这个工具又做了三层处理:

  • 记录本轮开始前 StructuredOutput 已经被调用了多少次
  • 在 query 中持续统计新增调用次数
  • 超过 MAX_STRUCTURED_OUTPUT_RETRIES 时直接报错退出

同时它还会在消息流里识别:

  • attachment.type === 'structured_output'

然后把真正的结构化结果取出来。

这说明 StructuredOutput 在 Claude Code 里已经不是普通工具输出,而是:

headless query 的正式结果通道。

6.6 它在宿主 UI 里还被刻意“降可见”

这又是一个很能说明定位的细节。

外围代码对它做了很多“别让用户把它当业务工具”的处理:

  • tools.ts 里把它列进 specialTools,避免走普通基础工具过滤路径
  • LocalAgentTask.tsx 里在 recent activity preview 中显式忽略它
  • renderToolUseMessage() 也只提供极简摘要

这说明作者很清楚:

  • 它对 runtime 很关键
  • 但对用户来说,最好像内部协议一样存在

7. TestingPermissionTool:它不是功能工具,而是权限链路测试夹具

TestingPermissionTool 的实现很短,但设计意义一点都不小。

7.1 它的目标非常单纯

这个工具只有三件核心行为:

  • 空输入 schema
  • checkPermissions() 永远返回 ask
  • call() 永远成功

也就是说,它不承担任何业务动作。

它存在的唯一目的,就是:

稳定触发一次真实的权限审批流程。

7.2 它不是 mock,而是走正式工具协议

最值得注意的是,它没有绕过运行时。

它仍然是标准 buildTool(...) 生成的正式工具,仍然会经过:

  • 工具注册
  • schema 暴露
  • 权限判断
  • tool use / tool result
  • transcript / UI 映射

所以它测的不是“某个 if 分支”,而是:

整个 permission pipeline 端到端到底通不通。

这比单独测 checkPermissions() 返回值更接近真实问题。

7.3 它只在 test 环境注入

tools.ts 里的注册条件很明确:

  • process.env.NODE_ENV === 'test'

这意味着它不会污染正常产品工具池。

它是典型的:

  • 对生产运行时零业务价值
  • 但对运行时可验证性很重要

的测试专用工具。

8. 把这三个放在一起看,Claude Code 暴露出了什么设计思路

这一组工具放在一起,能看出 Claude Code 几个很稳定的倾向。

8.1 它喜欢把“隐性运行时规则”显式工具化

很多系统会把这些逻辑藏在内部代码里:

  • 哪些工具先不暴露
  • 最后必须返回什么格式
  • 权限弹窗怎么测

Claude Code 则更倾向于把它们做成正式工具协议。

好处是:

  • 模型侧语义统一
  • transcript 可观察
  • hook / 权限 / telemetry 都能统一复用

8.2 它把“可见性”当成一级治理问题

ToolSearchTool 说明 Claude Code 不认为“工具越多越好”。

它真正关心的是:

  • 当前轮次该暴露多少工具
  • 哪些工具应该先隐藏
  • 哪些工具一旦被发现,就要在后续上下文持续可见

这是一种非常 runtime-oriented 的设计。

8.3 它把“结构化输出”当成协议,不当成提示词技巧

SyntheticOutputTool 说明 Claude Code 不愿意把结果格式完全托付给 prompt obedience。

它更相信:

  • 单独的执行工具
  • 可校验 schema
  • hook 强制收口
  • retry 上限

这比“请返回 JSON,不要多说别的”稳得多。

8.4 它把测试也放进正式运行时边界里

TestingPermissionTool 说明作者不是在外围做一层测试替身,而是直接让测试去跑正式 runtime。

这对复杂 agent 系统很重要,因为真正容易坏的地方往往不是纯函数,而是:

  • 注册链
  • 过滤链
  • 权限链
  • transcript/UI 链

9. 如果翻成 C#,我建议怎么拆

这块我不建议直接把 TypeScript 文件一比一翻译成类。

更适合 C# 的拆法大概是:

9.1 ToolSearchTool 对应两层

  • IDeferredToolCatalog
  • IToolSchemaLoader

其中:

  • catalog 负责判断哪些工具 defer、哪些 always load
  • loader 负责关键词搜索、精确选择、schema 返回和缓存

另外要单独保留:

  • DiscoveredToolRegistry

用来记录会话里已经通过 tool_reference 发现过哪些工具。

9.2 StructuredOutput 不要只是一个 serializer

更合适的拆法是:

  • StructuredOutputContractTool
  • OutputSchemaCompiler
  • OutputContractEnforcer

其中:

  • tool 负责暴露给模型
  • compiler 负责把 JSON Schema 编译成运行时 validator
  • enforcer 负责 stop hook、retry 限制和最终结果提取

这样你最后得到的不是“把对象序列化成 JSON”,而是:

一个能被 agent runtime 强制执行的结果契约。

9.3 TestingPermissionTool 要保留成真实工具,不要只写单测

这块最容易被偷懒。

我建议直接保留一个:

  • PermissionHarnessTool

只在测试宿主里注册,专门触发:

  • Ask
  • Deny
  • Allow

三种不同审批路径。

这样后面你在 C# 版做桌面宿主、CLI 宿主或 Web 宿主时,都能复用同一套端到端验证思路。

10. 最后收一下

这一组工具真正说明的是:

Claude Code 的工具系统已经不是“做事接口集合”,而是“运行时规则本身也能被工具化”的系统。

ToolSearchTool 管的是“模型现在能知道什么工具”。

SyntheticOutputTool 管的是“这次会话最后必须怎么结束”。

TestingPermissionTool 管的是“权限链路能不能被真实验证”。

这三者放在一起,刚好把 Claude Code 的三个基础治理面补齐了:

  • 暴露面治理
  • 结果契约治理
  • 安全流程治理

如果后面要做 C# 版,我会把这层排在很靠前的位置实现。

因为它们虽然不直接产出业务价值,却直接决定:

你的 agent runtime 到底是一个能长期演化的系统,还是一个工具越堆越乱的大 Prompt 壳子。