数据截至 (上游 commit 7538cc96774b)
工具面:能力目录、执行与三道闸门
30 秒导读: 这一章回答两个问题——模型这一轮能看见哪些工具(能力目录怎么被装配、又被谁裁掉),以及它真的去调用时会被谁拦下(沙箱、审批、钩子三道闸门)。不讲 loop 什么时候调度工具(那是 02),也不讲请求怎么发给模型(那是 05)。
1. 这是什么(零基础也能懂)
一句话定义: Kun 的"工具面"是一个按轮次(turn)动态计算的工具清单 + 一条带三道闸门的执行通道。
它解决什么问题。 一个编码 agent 手里可能挂着几十上百个工具:读文件、跑 shell、查语言服务器、调 MCP 服务器、搜网页、生成图片、派子 agent……全部无差别塞给模型会出三种事故:
- 贵——工具 schema 是每次请求都要发的固定成本,而且它属于缓存前缀,一动就掉缓存(见 03)。
- 乱——Plan 模式下模型能看到
write,它就会去写。 - 险——
bash在只读沙箱里也被列出来,模型就会反复撞墙。
Kun 的答案是:目录本身就是策略的产物。同一个 runtime,换一个线程模式、换一个沙箱档位、换一个激活的技能,模型看见的工具清单就不一样。
它能做什么(这一层的职责):
| 职责 | 一句话 |
|---|---|
| 能力注册 | 十种 provider(内置 / MCP / web / 技能 / 记忆 / GUI / 委派 / 图 / 音 / 视频)各自贡献工具 |
| 目录组装 | 合成唯一命名的工具表,重名直接抛错 |
| 目录裁剪 | 按 provider 开关、allow/deny 名单、Plan 白名单、沙箱、shouldAdvertise 五道过滤 |
| 调用执行 | 沙箱 → 钩子 → 先读后写 → 审批 → 真执行 → 后置钩子 → 限流归一 |
| 结果规范化 | 截断、落临时文件、限流识别、错误变成模型可读的反馈而不是崩溃 |
用起来什么样。 从模型视角,工具就是一次请求里的一段 schema;从宿主视角,则是一次 listTools(context) 加一次 execute(call, context):
// 示意,非源码:宿主每一轮都重算一次目录
const context = { threadId, turnId, workspace, threadMode: 'plan', sandboxMode: 'workspace-write', ... }
const tools = await toolHost.listTools(context) // Plan 模式下只剩 read/grep/find/ls/create_plan/...
const result = await toolHost.execute(call, context) // 每次调用重新过闸门,不信任上一轮的结论
重点看两件事:目录随 context 变,以及**execute 不假设"能列出来就能执行"**——两处都要判,这是整章的骨架。
一句话直觉: 把它当成一家餐厅。CapabilityRegistry 是总菜单,listTools(context) 是今日可点的那一页(按食材、时段、客人身份现算),而 execute 前的三道闸门是厨房门口的三次核对——菜单印错了也不许上菜。
2. 顶层全景(它大概怎么转)
2.1 装配线:从 provider 到模型请求
怎么读这张图:从左到右是一次性装配(runtime 启动时),从上到下是每轮重算(每个 turn 都跑一遍)。
【启动一次】runtime-composition-services.ts
buildDefaultLocalTools() ─┐
mcpProviders ─┤
webProviders ─┤
memory / skill providers ─┼─→ baseToolProviders ─→ new CapabilityRegistry(...)
image / speech / music / ─┤ │ │
video providers ─┘ │ ├─→ LocalToolHost (主 agent)
computer-use / goal / ↓ └─→ childRegistry (子 agent,不含 computer_use)
todo / delegation ───────────→ 仅注入主 registry
【每一轮】loop/ 各模块(turn-context-resolver / model-step-preparation-service)
ToolHostContext(模式 / 沙箱 / 技能 / allow-deny / GUI 计划)
│
▼
listTools(context) ──五道过滤──▶ 工具目录 ──▶ buildToolCatalogFingerprint ──▶ 缓存前缀(第 03 章)
│
▼
模型选了一个工具 ──▶ execute(call, context) ──▶ 三道闸门
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ToolHost | 端口:listTools + execute,loop 只认这个接口 | kun/src/ports/tool-host.ts:181 |
ToolHostContext | 一轮的全部约束(模式、沙箱、审批、allow/deny、技能、GUI 计划) | kun/src/ports/tool-host.ts:55 |
CapabilityRegistry | 总菜单:provider 注册、去重、按 context 过 滤 | kun/src/adapters/tool/capability-registry.ts:38 |
LocalTool | 一个工具的完整声明:schema + toolKind + policy + shouldAdvertise | kun/src/adapters/tool/local-tool-host-types.ts:9 |
LocalToolHost | 默认宿主:把三道闸门串成一条执行通道 | kun/src/adapters/tool/local-tool-host-core.ts:21 |
| 沙箱闸门 | 按 toolKind 和路径拦写、拦命令 | kun/src/adapters/tool/sandbox-policy.ts |
| 审批闸门 | 把调用挂起,等 GUI 的 allow/deny | kun/src/adapters/in-memory-approval-gate.ts:15 |
| 钩子闸门 | 用户/内置钩子改参数、否决、改结果 | kun/src/hooks/hook-engine.ts |
| 装配根 | 把上面全部接起来,产出两个 registry | kun/src/server/runtime-composition-services.ts:356-410 |
2.3 主线走一遍(高层)
- loop 用线程状态拼出
ToolHostContext(kun/src/loop/tool-context-factory.ts)。 listTools(context)返回这一轮的工具目录(listModelTools,kun/src/loop/turn-context-resolver.ts:217-221)。- 目录被哈希成
toolCatalogFingerprint,喂给缓存前缀并检测漂移(kun/src/loop/model-step-preparation-service.ts:337)。 - 模型返回若干
tool_call;loop 决定串行还是小批量并发。 - 每个调用进
LocalToolHost.execute,穿三道闸门,产出一个TurnItem回到历史。
3. 能力目录怎么装配
这一节讲**"模型凭什么能看见某个工具"**。
3.1 十种 provider:能力按来源分类
ToolProviderKind 是一个闭集,十个值(kun/src/ports/tool-host.ts:11-21):
| kind | 谁提供 | 代表工具 |
|---|---|---|
built-in | 进程内实现 | read bash edit write grep find ls lsp verify_changes background_shell |
mcp | 外部 MCP 服务器 | mcp_<server>_<tool>,或搜索模式下的 mcp_search / mcp_describe / mcp_call |
web | 配置的搜索/抓取后端 | web_fetch web_search |
skill | 技能运行时 | load_skill |
memory | 长期记忆库 | memory_create memory_update memory_delete |
gui | 渲染进程的产品能力 | goal 三件套、todo 两件套、computer_use |
delegation | 子 agent 运行时 | delegate_task |
image | 图像生成后端 | generate_image |
audio | 语音/音乐后端 | generate_speech generate_music |
video | 视频生成后端 | generate_video |
每个 provider 除了 kind 还带三个开关(ToolProviderPolicy,ports/tool-host.ts:23):enabled(用户开没开)、available(后端连没连上)、reason(不可用的原因,会一路冒泡到 GUI 的能力面板)。"关掉"和"坏了"是两件事,诊断里分得清清楚楚。
3.2 注册:唯一命名 + 早失败
registerProvider(provider)
├─ provider.id 撞车 ──▶ throw `duplicate tool provider`
└─ 逐个工具
└─ tool.name 撞车 ──▶ throw `duplicate tool name`
见 capability-registry.ts:54-65。整个 registry 是全局扁平命名空间——所以 MCP 工具必须先改名再进场(§5.1)。装配期宁可炸,也不要运行期两个 search 抢一个名字。
3.3 五道过滤器:listTools 到底裁掉了什么
CapabilityRegistry.listTools(context)(capability-registry.ts:67-86)对每个工具连问五句,任何一句为否就不进目录:
| # | 问题 | 实现 |
|---|---|---|
| ① | provider 开着且可用?没被 blockedProviderIds 拉黑、在 allowedProviderIds 里? | canUseProvider,capability-registry.ts:112 |
| ② | 工具名过得了 allow/deny 名单? | canUseTool,capability-registry.ts:120 |
| ③ | 如果是 Plan 上下文,它在 Plan 白名单里? | PLAN_MODE_ALLOWED_TOOL_NAMES,capability-registry.ts:28(模块级常量,未导出;判定嵌在 ② 的 canUseTool 里,:126) |
| ④ | 当前沙箱允许"广告"它? | isToolAdvertisedInSandbox,sandbox-policy.ts:23 |
| ⑤ | 它自己的 shouldAdvertise(context) 点头了? | capability-registry.ts:124-125,字段定义在 local-tool-host-types.ts:48-52 |
Plan 白名单只 有七个名字:read grep find ls create_plan user_input request_user_input。触发条件是 threadMode === 'plan' 或 存在 guiPlan 上下文(isPlanModeContext,capability-registry.ts:130)——也就是说 GUI 递来一个计划上下文,本轮就自动降级成只读+写计划。
allow / deny 的语义不对称,这点很关键:
allowedToolNames/allowedProviderIds是白名单:一旦给了,名单外全没。blockedToolNames/blockedProviderIds/blockedSkillIds是黑名单:叠加在"继承父级"之上,只会减不会加。
后者正是子 agent 的"自定义能力范围"能安全实现的原因——黑名单永远无法提权(kun/src/delegation/child-agent-executor.ts:159-167 的注释把这条说得很直白)。
3.4 shouldAdvertise:工具自己决定要不要露面
这是目录层最灵活的一格。三个真实用例,外加一个"以前在这里、现在搬走了"的对照:
| 工具 | 何时才出现 | 实现 |
|---|---|---|
user_input / request_user_input | 这两个工具不带谓词、总是进目录;真正的闸门在上下文——IM 桥接和 headless 跑法(userInputDisabled)不 给上下文挂 awaitUserInput 字段(kun/src/loop/tool-discovery-context-factory.ts:82-90),真被调到就返回一句明确错误(kun/src/adapters/tool/local-tool-host.ts:113-118) | 见左 |
create_plan | Plan 模式或存在 GUI 计划上下文 | create-plan-tool.ts:223、isPlanToolContextActive:235 |
| MCP 工具 | 该服务器对当前 workspace 既可见又被信任 | kun/src/adapters/tool/mcp-tool-runtime.ts:152、canUseMcpServer 在 mcp-naming.ts:26 |
computer_use | mode==='always',或 auto 且当前模型支持图片输入 | computer-use-tool-provider.ts:173-177 |
最后一条尤其值得注意:它读的是 context.model.inputModalities——非视觉模型直接看不到"操作电脑"这个工具,省掉一整段 schema 也省掉一类必然失败的调用。
3.5 组装完了接给谁:目录指纹
目录一算完,loop 立刻把它哈希:
// kun/src/cache/tool-catalog-fingerprint.ts:11
buildToolCatalogFingerprint(tools)
// → { fingerprint, toolCount, toolNames, toolHashes }
normalizeToolSpecs 会按名字排序并递归排序 schema 的 key 再哈希(tool-catalog-fingerprint.ts:23-52),所以 provider 注册顺序变了、schema 字段顺序变了,指纹都不动——这正是缓存前缀需要的稳定性。指纹随后进入 turn 元数据与漂移检测(kun/src/loop/model-step-preparation-service.ts:337-373),漂移分级里 breaking 的处置是 turn 内冻结目录、变更延迟到下一 turn(0.3.0 之前是直接停这一轮,该刹车已移除)。目录与缓存的完整关系见 03 缓存优先。
顺带一个设计一致性的例子:技能 provider 在"技能功能没开或一个技能都没有"时返回空数组而不是空 provider,注释写明目的就是"让工具目录及其前缀指纹和以前一模一样"(skill-tool-provider.ts:11-14)。
4. 内置工具家族
这一节讲 built-in 这一支的实现套路——它是其他 provider 的模板。
4.1 十三个内置工具与三套预设
buildBuiltinLocalTools()(builtin-tools.ts:69)一次造齐十三个:
| 工具 | toolKind | policy | 定义处 |
|---|---|---|---|
read | tool_call | auto | builtin-read-tool.ts:37 |
grep | tool_call | auto | builtin-search-tools.ts:442 |
glob | tool_call | auto | builtin-search-tools.ts:216 |
find | tool_call | auto | builtin-search-tools.ts:221——glob 的 legacy 别名,modelAdvertised: false,模型看不见它 |
ls | tool_call | auto | builtin-search-tools.ts:169 |
lsp | tool_call | auto | builtin-lsp-tool.ts:68 |
repo_map | tool_call | auto | builtin-repo-map-tool.ts:114 |
git_inspect | tool_call | auto | builtin-git-inspect-tool.ts:46 |
edit | file_change | on-request | builtin-file-tools.ts:84 |
write | file_change | on-request | builtin-file-tools.ts:39 |
bash | command_execution | on-request | builtin-bash-tool.ts:78 |
verify_changes | command_execution | on-request | builtin-verify-tool.ts:56-57 |
send_im_attachment | tool_call | on-request | im-attachment-tool.ts:63 |
toolKind 不是装饰——它是沙箱唯一的判据(§6.1)。三个值恰好对应三类风险:纯读(tool_call)、改文件(file_change)、起进程(command_execution)。
同一批工厂还打包出三套预设:全量(builtin-tools.ts:69)、编码件(buildCodingBuiltinLocalTools,:93)、只读件(buildReadOnlyBuiltinLocalTools,:106——read/grep/glob/find/ls/repo_map/git_inspect 七件)。只读那套和子 agent 的 SUBAGENT_READ_ONLY_TOOL_NAMES(kun/src/contracts/capabilities-core.ts:274,read/grep/glob/ls/repo_map/web_fetch)高度重叠但不相同;loop 的并发白名单(§4.5)又是第三份。三处独立定义,语义各自服务一层。
顶层还有一组只做转发的门面文件:read.ts grep.ts find.ts ls.ts edit.ts write.ts bash.ts 每个都只有 6-9 行 export { … as … },把 createReadLocalTool 之类重命名成 createReadTool / createReadToolDefinition。它们存在的意义是保持对外 API 名字稳定,实现随便搬家。