数据截至 (上游 commit 44ba8283e9f7)
第 03 章 · 工具、MCP 与审批
本章讲 agent 的「手脚」:它能调的工具从哪来、注册成什么样、工具太多塞不下上下文怎么办、危险工具怎么让用户批准。读完你能讲清 Cherry Studio 的工具工程三件套——统一注册表、deferred tools、主进程审批。
1. 先建直觉:工具的三个来源,一个注册表
agent 能调的工具有三类来源,但都收进同一个进程级注册表 ToolRegistry:
| 来源 | 例子 | 谁注册 |
|---|---|---|
| 内建工具 | web_search / web_fetch / kb_search / kb_list | builtin/index.ts 的 registerBuiltinTools |
| MCP 工具 | mcp__gmail__sendMessage_<摘要> | mcp/mcpTools.ts 的 syncMcpToolsToRegistry |
| 元工具 | tool_search / tool_inspect / tool_invoke | 请求时按需注入(见 §4) |
ToolEntry 的形状(src/main/ai/tools/adapters/aiSdk/types.ts,见 docs/references/ai/tool-registry.md):
// 示意,非源码:工具目录里一条记录长什么样
interface ToolEntry {
name: string // 模型在 tool_calls 里写的「线名」,如 web_search
namespace: string // 分组:web / kb / mcp:<id> / meta
description: string // 一行简介,给 tool_search 看
defer: 'never' | 'always' | 'auto' // 是否折叠(见 §4)
tool: Tool // AI SDK 的 Tool:schema + execute + needsApproval
applies?(scope): boolean // 这次请求要不要启用它(纯函数)
}
registry(src/main/ai/tools/adapters/aiSdk/registry.ts 的 ToolRegistry,模块级单例)在工具文件 import 时登记,在请求时被 buildAgentParams 读取。selectActive(scope) 跑每条的 applies 谓词挑出本次启用的工具——applies 必须是纯函数(它每次 selectActive 都跑,副作用会破坏确定性)。
两套工具系统并存。 注意:这个 aiSdk
ToolRegistry服务普通聊天的Agent。Claude Code agent 会话有独立的一套——tools/adapters/claudeCode/agentTools.ts直接从 MCP 服务器和内建描述符构建 Claude 的工具策略,不消费这个ToolRegistry(见tool-registry.md开头)。别把两者混了。
2. 线名约定:__ 分段
用双下划线作段分隔符(让工具内部的单 _ 不歧义):
| 来源 | 线名模式 | 例子 |
|---|---|---|
| 内建 | 固定线名 <namespace>_<verb> | web_search, kb_search |
| MCP | mcp__<camelCase(server)>__<camelCase(tool)>_<身份摘要> | mcp__gmail__sendMessage_<摘要> |
| 元工具 | tool_<verb> | tool_search, tool_invoke |
MCP 线名末尾的 <身份摘要> 是 sha256(serverId + toolName) 的前 20 位十六进制(整体上限 63 字符,src/main/ai/mcp/mcpToolId.ts 的 buildMcpToolWireId):目录身份随 serverId 稳定、重名服务器不碰撞。无摘要的旧格式(buildFunctionCallToolName)仍用于 Claude Code adapter 与已持久化的策略规则。
3. MCP 工具:读目录永不阻塞启动
MCP(Model Context Protocol)是一个标准协议,把外部能力(Gmail、浏览器、文件系统…)包装成「服务器」,模型把它们当工具调。McpRuntimeService(src/main/ai/mcp/McpRuntimeService.ts)负责连这些服务器——支持 stdio(子进程)、Streamable HTTP / SSE(远程)、内存(进程内的内建服务器,如浏览器、文件系统)三种 transport。
最值得学的一点:读工具目录和刷新目录被拆成两条路。 McpCatalogService 把目录读取分成:
listTools(serverId) ← cache-only,只读共享缓存 mcp.tools.<serverId>,永不连服务器
▲ 所有构建工具面的热路径都走它(Claude SDK 桥、agent 工具策略、aiSdk 同步)
│ → 一个死的/慢的 MCP 服务器,绝不会卡住 agent/chat 启动(issue #16242)
│
refreshTools(serverId) ← 真正连接、列举、写缓存的「写路径」
只由后台 warmer 驱动(启动预热 / onToolListChanged / 手动刷新 / 启用开关)
listTools 第一次见到一个从没预热过的服务器(缓存 undefined,区别于「预热过但空 []」)时,会非阻塞地触发一次 refreshTools——这样无头/定时启动能自我预热,又不会反复探测死服务器。
代价:工具可用性是最终一致的。 一个启动时缓存还冷的服务器,这次会话贡献不了工具,下次才出现——而且 Claude Agent SDK 按 session 快照工具列表,所以这没法在会话中途变热(见 tool-registry.md 的 Tool catalog reads never block on MCP)。这是「永不阻塞启动」换来的取舍。
MCP 工具同步:syncMcpToolsToRegistry({ selectedToolIds })(mcp/mcpTools.ts)从缓存读每个选中服务器的工具,把每个注册成一条 ToolEntry,其 tool.execute 经 MCP transport 代理过去。同步幂等,陈旧项下次覆盖。
4. Deferred tools:工具太多就折叠进 tool_search
它要解决的小问题。 你挂了 50 个 MCP 工具,每个工具的 schema 都要塞进系统提示— —光工具定义就吃掉小上下文模型一半的窗口,还没开始聊。
思路。 把不常用的工具折叠起来:系统提示里只放三个元工具,让模型自己「先搜再调」。
不折叠(工具少): 折叠(工具多):
tool A schema tool_search ← 按 namespace 搜可用工具
tool B schema tool_inspect ← 看某个工具的完整签名
tool C schema ──────▶ tool_invoke ← 按名字带 JSON 参数调任意工具
...(50 个,撑爆) + 一段 <DEFERRED_TOOLS> 列出有哪些命名空间
什么时候折叠? shouldDefer(entries, contextWindow)(exposition/shouldDefer.ts)——除了「估算 token 超过上下文 10% 阈值」外,还有两道闸:
MIN_AUTO_DEFER_COUNT(=5):auto 池得够大,搜索-再-调才比直接内联划算。META_TOOLS_OVERHEAD_TOKENS(=500):省下的 token 得超过三个元工具本身的提示开销。
没有这两道闸,小工具集 + 小上下文模型会触发折叠却净亏 token。defer: 'always' 的工具总是折叠;defer: 'auto' 的按上面判定;defer: 'never' 永不折叠。
tool_search 长什么样。 它只暴露被折叠的工具(已经内联的搜出来是冗余),按命名空间分组返回简介;verbose 时返回完整 schema 并记进「已检视」账本,这样第一次 tool_invoke 不会被「没先 inspect」的门挡掉(createToolSearchTool,meta/toolSearch.ts)。注入由 applyDeferExposition(exposition/applyDeferExposition.ts)在「确实发生折叠时」才做——它把折叠的名字从 tools 里剥掉、注入三个元工具、返回 <DEFERRED_TOOLS> 段要列举的命名空间。
还有第四个元工具 tool_exec,故意不注入。 它是把整个注册表当全局 API 的沙箱 JS 执行器(meta/exec/),worker_threads + new Function 跑模型写的代码,有全 Node 权限——属于提权面,被刻意留在门外,等有明确需求时再用 Preference 开关放出(tool-registry.md 的 tool_exec is not injected)。
5. 工具审批:主进程独家裁决
它要解决的小问题。 agent 要跑 rm -rf 或发邮件,得让用户先点「允许」。但多窗口、可能掉线、可能 renderer 崩——谁是审批状态的权威?
铁律:Main 是审批状态的唯一写者。 renderer 只是渲染审批卡、收集用户决定、把决定 POST 给 Main;Main 把决定写进 DB 权威行、 落库、恢复流。renderer 从不自己写 approved/denied(见 docs/references/ai/tool-approval.md)。
① execute 时,wrapper 查 tool.needsApproval + 助手的自动批准策略
需要审批 → 写一个 approval-requested part,把工具 promise 挂起
▼
② AiStreamManager 把 topic 状态翻成 awaiting-approval(共享缓存,所有窗口原子看到)
▼
③ 用户点允许/拒绝 → useToolApprovalBridge → IPC Ai_ToolApproval_Respond
▼
④ Main 按 transport 分支:
• Claude-Agent 快路径:交给 AgentSessionRuntimeService.respondToolApproval
解决活的 canUseTool promise,流继续(命中活注册项时早返回,不读 DB)
• MCP 路径:读 anchor 行的 parts,应用决定,仅当 DB 行上确有该 part 才写
全部审批决定齐了 → 派发 continue-conversation 让流续上
▼
⑤ 续流一广播 pending,awaiting-approval 立刻翻回,审批卡同 tick 消失
审批门工具永不被折叠(两者咬合的设计)。 一个 force-prompt 的 MCP 工具注册时是 defer: 'never'——mcp/mcpTools.ts 读一次 isMcpToolForcePromptBySource 同时驱动 defer 和 needsApproval——所以它留在内联,SDK 的原生审批门才能在它身上触发。若把它折叠掉,它就从 SDK 工具集里消失,审批门永不触发,只能经 tool_invoke 调到、绕过审批卡。作为运行时兜底,tool_invoke / tool_exec 在执行时还会调 isApprovalGated 拒绝审批门工具,把模型引回内联调用(isApprovalGated.ts,见 tool-registry.md 的 Approval-gated tools are never deferred)。
Claude Code agent 会话的审批走 ToolApprovalRegistry(src/main/ai/runtime/claudeCode/ToolApprovalRegistry.ts)+ SDK 的 canUseTool;权限模式有 default / acceptEdits / bypassPermissions / plan / auto(AgentPermissionModeSchema,src/shared/data/api/schemas/agents.ts:40),改 agent 配置会经 reconcile() 对账到温热连接上(能热补丁的当场补,权限模式这类冻结项则等到下一轮 turn 边界生效;第 02 章 §2)。
6. 巧妙之处
- cache-only 读目录。 把「死 MCP 服务器不能拖垮 agent 启动」做成架构级保证(读/写两条路),而不是到处加超时。
- deferred tools 带成本闸。 不是「工具多就折叠」,而是「折叠的净收益真的为正才折叠」——两道闸防止小模型反而亏 token。
- defer 与审批咬合。 审批门工具
defer:'never'这条规则,把「安全」和「上下文优化」两个看似无关的子系统正确地连了起来——折叠不能把审批门折没了。 - 审批 Main 独写。 多窗口/掉线/崩溃下审批状态仍单点权威,renderer 永远只读。
7. 边界与局限
- 工具可用性最终一致:启动时缓存冷的 MCP 服务器,这次会话没它的工具。
- Claude 会话工具按 session 快照:中途加 MCP 服务器要等下一个会话。
tool_exec默认关闭:模型写代码跑全权限 Node 的能力存在但不开。- 普通聊天的
ToolRegistry与 Claude agent 的工具策略是两套,改一处不影响另一处——要小心别以为改了注册表就改了 agent 工具。