第十九章:元工具真正约束的是什么
拆 ToolSearch、StructuredOutput、TestingPermission 这些“工具之上的工具”。
1. 为什么这一组要单独拆
前面拆的大多数工具,目标都是替用户做事:
- 读文件
- 改代码
- 跑命令
- 搜网页
- 调 agent
但这一组不太一样。
它们真正作用的对象,不是“用户的业务任务”,而是 Claude Code 自己的运行时:
ToolSearchTool决定模型现在能不能真正拿到某个工具的 schemaSyntheticOutputTool决定一次非交互会话最后必须用什么结构化格式收口TestingPermissionTool决定权限审批链能不能被端到端验证
所以这组工具更像是:
工具系统的治理工具。
它们处理的不是业务世界,而是 Claude Code 自己的三个核心边界:
- 工具可见性边界
- 最终输出边界
- 权限验证边界
2. 先说结论
我对这一层的总体判断是:
Claude Code 不只把“干活能力”做成 tool,也把“控制工具系统本身的能力”做成了 tool。
更具体一点,它在做三件事:
- 不把所有工具 schema 一次性塞给模型,而是用
ToolSearchTool做按需加载 - 不把“按 JSON 输出”停留在 prompt 约定层,而是用
SyntheticOutputTool做运行时强约束 - 不把权限系统只放在单元测试里 mock,而是用
TestingPermissionTool走完整审批链做集成验证
这说明 Claude Code 的工具体系已经不是一个“功能列表”,而是一个:
可以管理自己暴露面、输出契约和安全管道的 runtime。
3. 源码锚点
这次主要看的源码是:
src/tools/ToolSearchTool/ToolSearchTool.tssrc/tools/ToolSearchTool/prompt.tssrc/tools/ToolSearchTool/constants.tssrc/tools/SyntheticOutputTool/SyntheticOutputTool.tssrc/tools/testing/TestingPermissionTool.tsx
配套还看了:
src/tools.tssrc/main.tsxsrc/QueryEngine.tssrc/utils/toolSearch.tssrc/utils/hooks/hookHelpers.tssrc/utils/api.tssrc/utils/collapseReadSearch.tssrc/constants/tools.tssrc/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.ts 里 isDeferredTool() 的规则很清楚。
优先级从高到低大概是:
alwaysLoad === true的工具永不 defer- MCP 工具默认全部 defer
ToolSearchTool自己永不 defer- 某些首轮必须可见的工具永不 defer
- 剩下才看
tool.shouldDefer === true
这里最值得注意的是“某些首轮必须可见的工具”这层例外:
AgentTool在 fork-subagent 打开时不能被 deferBriefTool在相关 feature 打开时不能被 deferSendUserFileTool在 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() 返回的只是一个轻量结构:
matchesquerytotal_deferred_toolspending_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_loadingschemaextractDiscoveredToolNames()会从历史消息里的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用外部传入的 schemacall(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 做的事情是:
- 注册一个
Stopfunction 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()永远返回askcall()永远成功
也就是说,它不承担任何业务动作。
它存在的唯一目的,就是:
稳定触发一次真实的权限审批流程。
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 对应两层
IDeferredToolCatalogIToolSchemaLoader
其中:
- catalog 负责判断哪些工具 defer、哪些 always load
- loader 负责关键词搜索、精确选择、schema 返回和缓存
另外要单独保留:
DiscoveredToolRegistry
用来记录会话里已经通过 tool_reference 发现过哪些工具。
9.2 StructuredOutput 不要只是一个 serializer
更合适的拆法是:
StructuredOutputContractToolOutputSchemaCompilerOutputContractEnforcer
其中:
- tool 负责暴露给模型
- compiler 负责把 JSON Schema 编译成运行时 validator
- enforcer 负责 stop hook、retry 限制和最终结果提取
这样你最后得到的不是“把对象序列化成 JSON”,而是:
一个能被 agent runtime 强制执行的结果契约。
9.3 TestingPermissionTool 要保留成真实工具,不要只写单测
这块最容易被偷懒。
我建议直接保留一个:
PermissionHarnessTool
只在测试宿主里注册,专门触发:
AskDenyAllow
三种不同审批路径。
这样后面你在 C# 版做桌面宿主、CLI 宿主或 Web 宿主时,都能复用同一套端到端验证思路。
10. 最后收一下
这一组工具真正说明的是:
Claude Code 的工具系统已经不是“做事接口集合”,而是“运行时规则本身也能被工具化”的系统。
ToolSearchTool 管的是“模型现在能知道什么工具”。
SyntheticOutputTool 管的是“这次会话最后必须怎么结束”。
TestingPermissionTool 管的是“权限链路能不能被真实验证”。
这三者放在一起,刚好把 Claude Code 的三个基础治理面补齐了:
- 暴露面治理
- 结果契约治理
- 安全流程治理
如果后面要做 C# 版,我会把这层排在很靠前的位置实现。
因为它们虽然不直接产出业务价值,却直接决定:
你的 agent runtime 到底是一个能长期演化的系统,还是一个工具越堆越乱的大 Prompt 壳子。