跳到主要内容

数据截至 (上游 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 + shouldAdvertisekun/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/denykun/src/adapters/in-memory-approval-gate.ts:15
钩子闸门用户/内置钩子改参数、否决、改结果kun/src/hooks/hook-engine.ts
装配根把上面全部接起来,产出两个 registrykun/src/server/runtime-composition-services.ts:356-410

2.3 主线走一遍(高层)

  1. loop 用线程状态拼出 ToolHostContext(kun/src/loop/tool-context-factory.ts)。
  2. listTools(context) 返回这一轮的工具目录(listModelTools,kun/src/loop/turn-context-resolver.ts:217-221)。
  3. 目录被哈希成 toolCatalogFingerprint,喂给缓存前缀并检测漂移(kun/src/loop/model-step-preparation-service.ts:337)。
  4. 模型返回若干 tool_call;loop 决定串行还是小批量并发。
  5. 每个调用进 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_planPlan 模式或存在 GUI 计划上下文create-plan-tool.ts:223isPlanToolContextActive:235
MCP 工具该服务器对当前 workspace 既可见又被信任kun/src/adapters/tool/mcp-tool-runtime.ts:152canUseMcpServermcp-naming.ts:26
computer_usemode==='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)一次造齐十三个:

工具toolKindpolicy定义处
readtool_callautobuiltin-read-tool.ts:37
greptool_callautobuiltin-search-tools.ts:442
globtool_callautobuiltin-search-tools.ts:216
findtool_callautobuiltin-search-tools.ts:221——glob 的 legacy 别名,modelAdvertised: false,模型看不见它
lstool_callautobuiltin-search-tools.ts:169
lsptool_callautobuiltin-lsp-tool.ts:68
repo_maptool_callautobuiltin-repo-map-tool.ts:114
git_inspecttool_callautobuiltin-git-inspect-tool.ts:46
editfile_changeon-requestbuiltin-file-tools.ts:84
writefile_changeon-requestbuiltin-file-tools.ts:39
bashcommand_executionon-requestbuiltin-bash-tool.ts:78
verify_changescommand_executionon-requestbuiltin-verify-tool.ts:56-57
send_im_attachmenttool_callon-requestim-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 名字稳定,实现随便搬家。

4.2 共用件:所有内置工具都靠这几个小工具活着

共用件干什么位置
withToolBoundary把任何抛出的异常转成 {output:{error}, isError:true}——工具炸了是给模型的反馈,不是让 turn 死builtin-tool-utils.ts:27
resolveWorkspacePath把模型给的路径解析成绝对路径,并做符号链接逃逸检查builtin-tool-utils.ts:42
truncateHead / truncateTail默认 2000 行 / 50 KB 的双上限截断truncate.ts:1-2, 46, 116
OutputAccumulator流式收集子进程输出,超量滚到临时文件,只把尾部喂给模型output-accumulator.ts:126
normalizeRateLimitedToolOutput从任意结构里嗅出"429 / rate limit / retry after",统一成 code: 'rate_limited'tool-rate-limit.ts:23
ReadTracker先读后写约束read-tracker.ts:17
withFileMutationQueue同一文件的改动串行化file-mutation-queue.ts:145

resolveWorkspacePath 是安全上最要紧的一个,它的逃逸检查不是简单的字符串前缀比较:

  • danger-full-access 直接放行(用户明示要越界),builtin-tool-utils.ts:78-89
  • 否则把目标路径经 resolvePathThroughSymlinks 解到物理位置,再与各工作区根的物理根逐一比 isPathInsideOrEqual(:96-130)。
  • 悬挂符号链接单独处理:realpath 对"路径不存在"和"链接目标不存在"都报 ENOENT,于是 resolveSymlinkSafe(kun/src/adapters/tool/workspace-path.ts:104-123)逐段回退,遇到本身是符号链接的段就显式 readlink 跟过去——注释写明词法位置不能当作最终写入目标(:28-32),否则 <ws>/evil -> /etc/passwd 这类悬空链接会把写入逃逸出工作区。
  • 返回的却是词法路径而非 realpath 结果(builtin-tool-utils.ts:122-127 的注释),因为下游要拿它当子进程 cwd 和展示路径;只有外部授权写才返回校验过的物理路径,防止验证后符号链接被调包。

4.3 写路径上的两把小锁

锁一:先读后写(ReadTracker)。 模型要 edit 一个文件,必须在本线程read 过它,否则拿到的是错误结果而不是一次损坏的写入(read-tracker.ts:46-59)。两个细节:

  • 跨轮次的读算数。注释直接引了 issue #640:硬性要求"同一轮内读过"会逼模型退回 sed/bash 改文件,结果更糟(read-tracker.ts:61-67)。
  • 新鲜度另有兜底:requireOldTextInRead 会检查每个 oldText 片段是否真出现在最近一次读到的内容里(:68-79),再加上 edit 自己对磁盘现状的匹配,陈旧的 SEARCH 串会失败在报错上而不是损坏在文件上。
  • 压缩/丢弃上下文会让"读过"这件事失效,所以 ToolHost 特意留了 clearReadTracker 这个可选钩子(ports/tool-host.ts:211)。

锁二:同文件串行(withFileMutationQueue)。 两层:

进程内:fileMutationQueues: Map<realpath, Promise> ← 同一文件的调用排队
进程间:mkdir(tmpdir/kun-file-mutation-locks/<sha256>.lock) ← 目录创建的原子性当锁
└─ owner.json 记 pid;持有者进程死了就抢锁;无主锁 10 分钟算过期;等待上限 60 秒

file-mutation-queue.ts:99-131, 145-184。队列的 key 是 realpath(:133),所以经由不同符号链接指向同一文件的两次写也会被串起来。

4.4 edit 的容错匹配:先精确,再温和归一

applyEditsToNormalizedContent(edit-diff.ts:149)的流程:

oldText 空? ──▶ 报错(不允许空匹配)


fuzzyFindText: 先 indexOf 精确找
├─ 命中 ──────────────────────▶ usedFuzzyMatch=false
└─ 没中 ──▶ 两边都 normalizeForFuzzyMatch 再找
├─ 命中 ──▶ 整个文件切到归一化版本再改
└─ 没中 ──▶ 报"找不到",并带上第几条 edit


出现 >1 次? ──▶ 报"有歧义,请给更长的上下文"

normalizeForFuzzyMatch(edit-diff.ts:61)只做五类无争议的归一:NFKC、去行尾空白、弯引号→直引号、各种破折号→ASCII -、各种宽度空格→普通空格。它刻意不做缩进容忍或行内空白折叠——模糊得越少,误改的风险越低。

写回前后还各有一手:stripBom + detectLineEnding + restoreLineEndings(builtin-file-tools.ts:126-130)保证 CRLF 文件和带 BOM 的文件不会被"顺手改格式"。

另有一个很实用的错误分支:当工具参数 JSON 解析失败、以 { __raw: "..." } 形式落到 write/edit 时,返回的不是"缺字段",而是一句诊断——"你的输出限额把 payload 截断了,收到 N 个字符,请先写骨架再分多次小编辑"(builtin-file-tools.ts:26-37)。把最可能的根因直接讲给模型听,比通用报错省一整轮试错。

4.5 只读工具的小批量并发

loop 侧有一条限定很死的并发通道(kun/src/loop/tool-dispatch-policy.ts:50isParallelSafeToolCall,车道划分在 :29classifyToolDispatchLane),四个条件全中才允许并发:

条件
审批策略不是 always / untrusted / never否则会弹审批或被拦,扇出没意义
工具名在 PARALLEL_READ_ONLY_TOOL_NAMESread grep glob find ls(tool-dispatch-policy.ts:4)
call.toolKindtool_call 或没给模型自称的 kind 也要过审
provider kind 是 built-in同名的 MCP 工具不享受这条通道

批次上限 DEFAULT_MAX_PARALLEL_READ_ONLY_TOOL_CALLS = 3(tool-dispatch-policy.ts:9)。唯一的例外是 delegate_task:它整条消息一起扇出,真实并发由委派运行时的信号量兜底(kun/src/loop/tool-call-dispatcher.ts:65-76)。而且批次保持同质——委派批和只读批不混(collectParallelToolDispatchCandidates,tool-dispatch-policy.ts:70-92)。

5. 外部 provider:把别人的能力接成本地工具

5.1 MCP:改名、信任、注解、搜索模式

改名。 normalizeMcpToolName(serverId, toolName)`mcp_${slug(serverId)}_${slug(toolName)}`(kun/src/adapters/tool/mcp-naming.ts:12)。扁平命名空间下,这是避免撞名的前提。

信任是双闸的(mcp-naming.ts:16-28):

  • isMcpServerVisible:workspaceRoots 为空视为全局可见,否则当前 workspace 必须落在某个根下。
  • isMcpServerTrusted:trustScope === 'user' 直接信任,否则要落在 trustedWorkspaceRoots 下。
  • canUseMcpServer = 两者都为真,既做 shouldAdvertise(不广告,kun/src/adapters/tool/mcp-tool-runtime.ts:152),又在 execute 开头再判一次并给出"不可见/不受信任"的错误文案(:162 起)。

MCP 注解直接映射审批策略(policyFromAnnotations,kun/src/adapters/tool/mcp-tool-runtime.ts:466):

注解组合得到的 policy
readOnlyHint 且非 openWorlddestructiveauto(不打扰)
destructiveHinton-request
openWorldHintuntrusted(最严,白名单外一律问)
其它 / 没注解on-request

工具太多时切成搜索模式。 shouldUseMcpSearch(kun/src/adapters/tool/mcp-tool-runtime.ts:459,判定在 mcp-tool-provider.ts:318):direct 恒否、search 恒是、autotoolCount >= autoThresholdToolCount;再要求至少一个服务器连上。命中后不再逐个广告 MCP 工具,只上三个:mcp_search / mcp_describe / mcp_call(名字在 mcp-tool-search.ts:19-21,provider 在 :100)。检索用的是自建 BM25 式索引,tokenizeMcpSearchText(kun/src/adapters/tool/mcp-tool-search-ranking.ts:76)对拉丁词做下划线/连字符拆分,对汉字段做 2-4 字滑窗 n-gram——中文查询也能命中英文工具描述之外的中文说明

这条岔路的代价换算很直白:几十个工具的 schema 常驻前缀 vs 三个工具 + 一次检索往返。

掉线重连要同时补两个 registry(主的和子 agent 的,回调里各 registerProvider 一次、撞名就忽略,kun/src/server/runtime-composition-config.ts:656-666),并且搜索模式下不重复注册直连 provider,否则会双份(if (!searchActive) providers.push(...directProviders),kun/src/adapters/tool/mcp-tool-provider.ts:345-347)。

5.2 其余 provider 的共同套路

provider工具值得记的一点
webweb_fetch web_search两个都是 policy: 'untrusted'(web-tool-provider.ts:108, 166)——外网内容天然不可信
image / audio / videogenerate_image generate_speech generate_music generate_video后端不可用时返回reason 的不可用 provider,GUI 能显示"为什么没有"
computer-usecomputer_usecommand_execution 类;每轮动作预算 maxActionsPerTurn,超了返回可读错误而不是继续点(computer-use-tool-provider.ts:190-208)
memorymemory_create memory_update memory_delete全部 on-request——长期记忆的写入必须过审批
skillload_skill让模型自己按 id 拉全文,弥补触发词没命中的情况
delegationdelegate_taskpolicy: 'auto',schema 里内联了可用 profile 枚举
guigoal / todo 三件套两件套直接操作线程服务,属于产品层能力(见 06)

computer_use 还有一条结构性隔离:它只注册进主 registry,故意不进 baseToolProviders,注释写明 "host control must not be delegable to subagents"(kun/src/server/runtime-composition-services.ts:399-401,browser_use 同样只进主 registry)。这不是靠名单拦,是靠两个 registry 物理分开——子 agent 永远不知道有这个工具。

5.3 create_plan:把工具锁死在一条保留路径上

GUI 的计划工具是"闸门写进工具本体"的最好例子。除了 shouldAdvertise 只在计划上下文露面,执行时 resolveReservedTarget(create-plan-tool.ts:342-381)逐条比对:

检查不过就拒绝
guiPlan 上下文create_plan requires an active GUI plan context
operation 与上下文一致操作不匹配
workspace 匹配(guiPlanWorkspaceMatches,kun/src/shared/gui-plan.ts:47)工作区不匹配
路径是计划目录直属的 .md 文件(isGuiPlanRelativePath,kun/src/shared/gui-plan.ts:22)路径非法
draft 不允许写旧目录 .deepseekgui/plan(isGuiPlanCurrentRelativePath,kun/src/shared/gui-plan.ts:34)只能 refine
plan_relative_path / plan_id 与预留值一致与预留不符

路径校验函数本身也做了反规避:统一 \/、折叠重复斜杠、去 ./、转小写,必须以 .md 结尾、必须是该目录的直接子文件、任何一段是 .. 都拒(kun/src/shared/gui-plan.ts:22-41,两个判定函数用的是同一套归一化)。

路径别串味: gui-plan.ts 在克隆里有两份——runtime 侧 kun/src/shared/gui-plan.ts 和 GUI 侧 src/shared/gui-plan.ts,内容是互为镜像但行号完全不同(runtime 侧文件头注释写明 GUI 侧才是真源,见 kun/src/shared/gui-plan.ts:1-11)。本章讲的是 runtime 侧,引用一律写全路径;GUI 侧那份的行号见 06 GUI 产品层

6. 三道闸门

前面讲的是"能看见什么"。这一节讲"点了之后被谁拦"。核心事实:LocalToolHost.execute 不信任目录——resolveTool 会把 §3.3 的过滤条件再判一遍,不通过就抛错(capability-registry.ts:88-106)。

先把名词钉死。 全篇的"三道闸门"只指执行通道上这三道,顺序也固定(步号对应 §6.4 那张十八步表):

闸门是什么在执行通道的第几步
闸门一沙箱(sandboxBlockForTool)第 5 步、第 10 步各判一次
闸门二审批(ApprovalGate)第 12 步
闸门三钩子(PreToolUse / PostToolUse)第 6 步、第 16 步

两个容易被误算进来的东西,这里一次说清:

  • 工具风暴熔断(ToolStormBreaker)不是闸门。 它在 loop 层按 turn 拦重复调用,压根不进 toolHost.execute,归 02 Agent Loop §7.5。
  • shouldAdvertise 也不是闸门。 它是 §3.3 目录过滤的第 ⑤ 道,决定"露不露面",不决定"跑不跑得动"。

6.1 闸门一:沙箱(按 toolKind 和路径)

四档模式(kun/src/contracts/policy.ts:16-25),判据只有 toolKind:

沙箱模式file_changecommand_execution工作区外写
read-only拦(sandbox_read_only)
workspace-write拦(sandbox_write_blocked)
danger-full-access
external-sandbox拦(进程内文件工具不执行外部沙箱)

实现在 sandbox-policy.ts:31-61;路径级判断另有 canWritePath(:63-98),write/edit 在动手前先 assertCanWritePath(builtin-file-tools.ts:64, 122)。

三个必须记住的细节:

  1. DEFAULT_SANDBOX_MODE = 'danger-full-access'(contracts/policy.ts:22)。没显式设沙箱就是全放行——这是产品取舍,不是笔误,但读代码时容易看漏。
  2. user_input / request_user_input 豁免所有沙箱与审批(sandbox-policy.ts:340;执行通道侧的放行在 kun/src/adapters/tool/local-tool-host-core.ts:505:511:527-529isInteractiveGuiGateTool)。理由是它们只是向用户提问,不碰任何资源;反过来说,在 approvalPolicy === 'never' 下它们也是唯二还能跑的工具
  3. 沙箱判两次:第一次用工具声明的 tool.toolKind(local-tool-host-core.ts:93-99),第二次在 runtimePolicyBlock 里用 call.toolKind ?? tool.toolKind(:496-499)——模型自己填的 kind 也要过一遍闸

6.2 闸门二:审批(按策略 × 工具 policy 的二维表)

六种运行时策略(contracts/policy.ts:3-11,默认 auto)× 五种工具 policy(kun/src/adapters/tool/local-tool-host-types.ts:19-23)交出这张表(源自 requiresApproval,kun/src/adapters/tool/local-tool-host-core.ts:513-529):

运行时 approvalPolicy是否弹审批
always全部弹
auto全部不弹(默认档)
on-request / suggest工具 policy !== 'auto' 才弹
untrustedauto 工具且在 allowList 里才免;其余全弹
never不弹——因为在更早一步就整体拦掉了(runtimePolicyBlock,local-tool-host-core.ts:505-510)

工具侧的 policy 语义(local-tool-host-types.ts:19-23):auto 直接跑、on-request/suggest 总是问、never 直接禁、untrusted 除非在白名单否则问。

审批本身是异步挂起:execute 造一个 ApprovalRequest(domain/approval.ts:22)交给 context.awaitApproval,GUI 那边通过 InMemoryApprovalGate.decide 把 promise 兑现(adapters/in-memory-approval-gate.ts:26-36)。被拒不是异常,而是返回一条 code: 'approval_denied' 的错误型工具结果(local-tool-host-core.ts:269-291)——模型看得见自己被拒了;闸门侧另有 approval_requested / approval_resolved 事件落进历史,SSE 的迟到订阅者可以重放(domain/approval.ts:3-8)。

6.3 闸门三:钩子(唯一能改写调用的一道)

六个相位(hooks/hook-engine.ts:12-19),其中两个跑在工具宿主里、四个跑在 loop 里:

PreToolUse ──┐ ┌── UserPromptSubmit(可否决 turn / 注入上下文)
├─ 工具宿主内 ├── TurnStart (只观察)
PostToolUse ─┘ ├── TurnEnd (只观察)
└── PreCompact (只观察)

PreToolUse 的能力最大(hook-engine.ts:47-62, 122-130):

  • arguments改写参数,且改写会链式传递给后续钩子和真正的执行。
  • decision: 'deny' → 直接否决,链条停止。
  • decision: 'allow' → 置 autoApproved,跳过审批;但后面的钩子仍可翻盘否决。

PostToolUse 能改 outputisError。钩子自己崩了或超时(默认 60 秒)会被上层兜成一个 hook_failed 的工具错误,而不是让 turn 死(local-tool-host-core.ts:105-109:417-424)。命令型钩子的约定极简:调用信息 JSON 写进 stdin,结果从 stdout 读,退出码 2 表示阻断(hook-engine.ts:90-93)。

配置有两种形态(hooks/hook-config.ts:9, 29):command(跑一条 shell)和 workflow(POST 给本地 WorkflowRuntime,模式分 observe/block/rewrite)。内置钩子永远排在配置钩子前面(kun/src/server/runtime-composition-services.ts:403-409),目前只有一个:设计质量检查器——PostToolUse 上挂 write/edit 两个工具,扫出前端设计问题后把 design_quality_review 块折进工具结果,模型下一轮自己改;它只提示不报错、不阻断(hooks/builtins/design-quality-hook.ts:1-10, 79-80)。

6.4 完整执行顺序(这是本章的落点)

LocalToolHost.execute 的十八步,顺序本身就是设计(kun/src/adapters/tool/local-tool-host-core.ts:74-440):

做什么行号失败形态
1取本 turn 钉住的组件(registry/hooks)+ prepare:79-80
2abort 检查:81-84抛异常(中断要往上传)
3resolveTool:目录条件重判:85-89抛异常
4policy === 'never':90-92抛异常
5沙箱(按 tool.toolKind):93-99错误结果
6PreToolUse 钩子:101-117hook_failed / hook_denied
7参数信封归一(__raw 分支):118-121
8Plan 模式工具拦截(planModeToolBlock):122-133错误结果
9先读后写校验:134-149read_before_edit_required
10运行时策略(沙箱按 call.toolKind + never 总拦):151-163错误结果
11工作区外写目标清点 + 显式审批分类:165-199sandbox_write_blocked / approval_classification_failed
12审批(需要时挂起等 GUI):216-288approval_denied 错误结果
13abort 复检:317-319抛异常
14操作台账去重:结局未知 / 同调用在跑 / 已完成直接重放:320-359tool_outcome_unknown / tool_invocation_in_progress
15真执行(带流式 onUpdate):375-392tool_execution_failed
16PostToolUse 钩子:413-424hook_failed
17限流归一:425-427转成 rate_limited 错误
18记账 ReadTracker + 大输出外置 + 台账完结 + 产出 item:428-436

贯穿全表的一条纪律:除了 abort,任何失败都变成一个带 code 的错误型工具结果,而不是异常。源码注释说得最清楚——"一个工具炸了(MCP 服务器返回协议错误、provider 有 bug)是给模型的反馈,不是杀掉整个 turn 的理由"(local-tool-host-core.ts:398-401)。

7. 工具形态的扩展能力

有三类能力不是"新工具",而是改变工具面本身的机制。

7.1 技能(Skills):注入指令 + 收窄工具

SkillRuntime.resolveTurn(kun/src/skills/skill-runtime-engine.ts:127)每轮做四件事:渲染技能目录 → 匹配触发 → 按预算注入正文 → 算出 allowedToolNames

发现路径是三家约定 + 两个补充目录(kun/src/skills/skill-runtime-contracts.ts:22-27):.agents/skills.claude/skills.codex/skills.kun/skillsskills

触发打分(skill-runtime-engine.ts:325-350,手动 load_skill 激活另算 :155)分四档,分数决定谁先被注入:

触发方式基础分
用户显式点名1000
命令前缀(prompt 以某个命令开头)900
prompt 正则模式500
文件类型(从路径/prompt 里提取扩展名)300

同分再加 skill.priority,排序后取前 activeLimit = 3 个(skill-runtime-contracts.ts:15)。

预算是硬的:正文注入上限 24 KB、目录上限 8 KB(skill-runtime-contracts.ts:16-17)。buildInjection(kun/src/skills/skill-runtime-support.ts:369-397)逐个累加,放不下就跳过这个技能(:386,不是截断它)——保证注入进去的每份指令都是完整的。注意这只管自动注入;手动 load_skill 拉全文的那条路相反——超预算会裁正文(skill-runtime-engine.ts:225-233)。

工具收窄是并集再取交:所有被激活技能的 allowedTools 求并集,交给本轮的 allowedToolNames(skill-runtime-support.ts:392),于是 §3.3 的第②道过滤生效。blockedToolsFor(:400-406)反过来算出"因为收窄而被挡掉的工具名",纯做诊断展示。

load_skill 工具则是补漏:触发词没命中时,模型可以拿目录里的 id 主动把全文拉过来(skill-tool-provider.ts:26-53)。

7.2 委派(Delegation):子 agent 是工具,不是提权

delegate_task 表面是一个工具,背后是一整个子 runtime。四层约束:

① profile 决定工具策略。 SubagentToolPolicy 只有两值:readOnly | inherit(kun/src/contracts/capabilities-core.ts:251)。内置 profile 里 design-reviewerover-engineering-reviewerexplore 都是 readOnly,generalinherit(delegation/builtin-profiles.ts:20, 40, 67, 82)。profile 还能被工作区覆盖:<workspace>/.kun/agents/*.md 的 frontmatter,叠加顺序是 内置 < GUI < 工作区(delegation/workspace-agents.ts:5-27, 38)。

② 收窄,最具体的赢。 child-agent-executor.ts:154-167:

显式 allowedTools ──▶ 用它
否则 readOnly ──▶ SUBAGENT_READ_ONLY_TOOL_NAMES(read/grep/glob/ls/repo_map/web_fetch)
否则 inherit ──▶ 不设 allow-list(子 agent 看到父 agent 的完整工具集)
再叠加三条 deny:blockedTools(按工具名) / blockedMcpServers(按 provider id `mcp:<id>`) / blockedSkills(按技能 id)

MCP 按 provider id 而不是工具名拉黑,注释点明理由是"防漂移"——该服务器以后新增的工具也自动隐藏。

③ 不可提权。 子 agent 跑在父线程的 approvalPolicy/sandboxMode 下,注释直说"子不是一次提权:只读的父只会产出只读的子"(child-agent-executor.ts:147-153)。加上 §5.2 的 computer_use 物理隔离,两条独立防线。

④ 并发与预算。 maxParallel 是信号量(parallelLimit,kun/src/delegation/delegation-runtime-base.ts:171-176),满了 FIFO 排队且支持中途取消(acquireSlot,:174-186)。maxChildRuns 每线程子任务总额已移除——契约里它现在是 legacy 字段,解析时直接被丢弃(kun/src/contracts/capabilities-core.ts:394-398)。用量通过 recordExternalUsage 回记到父线程(kun/src/server/runtime-composition-registry.ts:242),子 agent 花的 token 算在父账上

子 agent 的自定义 system prompt 采用"追加而非替换":基础前缀保留,附加内容拼在后面并换一个指纹——同一 agent 的多次调用仍能命中前缀缓存,跨 agent 的复用主动放弃(child-agent-executor.ts:169-174)。

7.3 记忆(Memory):写要审批,读是自动

写入侧三个工具全是 on-request(memory-tool-provider.ts:26, 58, 80),描述里直接写着"after explicit user approval"。读取侧则完全自动:loop 每轮用 prompt 当查询取回至多 8 条(retrieveMemories,kun/src/loop/turn-context-resolver.ts:321-334,调用点在 :141),memoryInstructions 渲染成一段 - [id] (scope) content 的列表塞进本轮上下文(kun/src/loop/memory-instructions.ts:1,注入点在 kun/src/loop/model-step-preparation-service.ts:593)。scope 分 user/workspace/project 三档(memory-tool-provider.ts:20)。

这条不对称是刻意的:读记忆不改变世界,写记忆会长期影响后续所有会话。

8. 巧妙之处(可以直接借走的)

  1. toolKind 是唯一的沙箱判据,而且判两次。 一个三值枚举就把"读/写/起进程"的风险分完;第二次判用模型自称的 kind(kun/src/adapters/tool/local-tool-host-core.ts:499-502),堵住"声明成 tool_call 混过去"。
  2. 能力不可用要带 reason ToolProviderPolicyenabled(用户关的)和 available(后端坏的)拆开,外加原因文本(ports/tool-host.ts:23-29),GUI 能力面板不用猜。
  3. 目录指纹先规范化再哈希。 排序工具名 + 递归排序 schema key(tool-catalog-fingerprint.ts:23-52),让"注册顺序变化"不再误伤缓存。
  4. 技能预算是"放不下就整份跳过",不是截断(kun/src/skills/skill-runtime-support.ts:386)——半份指令比没有更危险。
  5. 黑名单只减不加,因此可以安全下放。 子 agent 的自定义范围全部用 deny 表达(child-agent-executor.ts:159-167),结构上就无法提权。
  6. 参数截断给出可执行的补救话术。 write/edit__raw 分支直接告诉模型"先写骨架再多次小编辑"(builtin-file-tools.ts:26-37)。
  7. 文件锁用 mkdir 的原子性 + owner pid + 双重过期。 死进程留下的锁被抢占,无主锁 10 分钟过期,等待 60 秒超时(file-mutation-queue.ts:84-131)。
  8. MCP 工具太多就换协议形状。 从"全部广告"切到"三个元工具 + 本地检索"(kun/src/adapters/tool/mcp-tool-provider.ts:318mcp-tool-search.ts:100),把常驻 token 换成一次检索往返。
  9. user_input 的豁免设计。 它绕过沙箱与审批,于是 approvalPolicy: 'never' 下 agent 仍有一条"向人求助"的出路,不至于彻底哑掉。
  10. computer_use 靠两个 registry 物理隔离,而不是名单。 结构性保证比配置性保证难被绕过(kun/src/server/runtime-composition-services.ts:399-401)。

9. 边界与局限(诚实说)

  • 默认沙箱是 danger-full-access(contracts/policy.ts:22)。没显式配置就是全放行;workspace-write 是可选而非默认。
  • external-sandbox 是占位。 进程内文件工具不执行它,只会以 sandbox_write_blocked 拒绝并明说"external-sandbox 未被进程内文件工具执行"(sandbox-policy.ts:78-86)。真正的外部沙箱执行路径不在本章代码里。
  • ToolHost 只有一个真实实现。 端口注释提到"远程宿主可以扇出到沙箱环境"(ports/tool-host.ts:176-180),但仓库里只有 LocalToolHost
  • 模糊匹配刻意保守。 只归一化引号/破折号/空格/行尾/NFKC,不容忍缩进差异(edit-diff.ts:61-70);缩进不一致时 edit 会直接报"找不到"。
  • 审批网关是内存态。 InMemoryApprovalGate 不持久化,进程重启后待决审批全丢。
  • 命令型钩子拿到完整调用 JSON。 参数里可能含敏感内容;钩子是用户自配的可信执行体,不做隔离。
  • Plan 白名单是硬编码的模块常量,未导出、不可配置(capability-registry.ts:28)。要放开某个工具进 Plan 模式必须改代码。

10. 代码地图(导航索引)

主题文件路径符号名
工具宿主端口kun/src/ports/tool-host.tsToolHost ToolHostContext ToolProviderKind GuiPlanContext
工具声明kun/src/adapters/tool/local-tool-host-types.tsLocalTool LocalToolHost.defineTool
执行通道kun/src/adapters/tool/local-tool-host-core.tsLocalToolHost.execute requiresApproval runtimePolicyBlock
默认工具集kun/src/adapters/tool/local-tool-host.tsdefaultLocalTools buildDefaultLocalTools userInputTool
能力注册表kun/src/adapters/tool/capability-registry.tsCapabilityRegistry PLAN_MODE_ALLOWED_TOOL_NAMES isPlanModeContext
沙箱策略kun/src/adapters/tool/sandbox-policy.tseffectiveSandboxMode sandboxBlockForTool isToolAdvertisedInSandbox canWritePath
策略枚举kun/src/contracts/policy.tsAPPROVAL_POLICIES SANDBOX_MODES DEFAULT_SANDBOX_MODE
审批kun/src/adapters/in-memory-approval-gate.ts · kun/src/domain/approval.tsInMemoryApprovalGate createApprovalRequest
钩子引擎kun/src/hooks/hook-engine.tsHOOK_PHASES runPreToolUseHooks runPostToolUseHooks HOOK_BLOCKING_EXIT_CODE
钩子配置 / 内置钩子kun/src/hooks/hook-config.ts · kun/src/hooks/builtins/resolveConfiguredHooks buildBuiltinHooks buildDesignQualityHook
内置工具工厂kun/src/adapters/tool/builtin-tools.tsbuildBuiltinLocalTools buildReadOnlyBuiltinLocalTools
文件工具kun/src/adapters/tool/builtin-file-tools.tscreateWriteLocalTool createEditLocalTool
编辑匹配kun/src/adapters/tool/edit-diff.tsfuzzyFindText normalizeForFuzzyMatch applyEditsToNormalizedContent
路径与边界kun/src/adapters/tool/builtin-tool-utils.tsresolveWorkspacePath withToolBoundary workspaceRoot
先读后写kun/src/adapters/tool/read-tracker.tsReadTracker.validateBeforeTool
文件串行kun/src/adapters/tool/file-mutation-queue.tswithFileMutationQueue
输出治理kun/src/adapters/tool/output-accumulator.ts · truncate.ts · tool-rate-limit.tsOutputAccumulator truncateTail normalizeRateLimitedToolOutput
shell / 后台 shellkun/src/adapters/tool/builtin-bash-tool.ts · background-shell-tool.tscreateBashLocalTool createBackgroundShellTool
验证 / LSPkun/src/adapters/tool/builtin-verify-tool.ts · builtin-lsp-tool.tsVERIFY_CHANGES_TOOL_NAME createLspLocalTool
MCPkun/src/adapters/tool/mcp-tool-provider.ts · mcp-naming.ts · mcp-tool-runtime.tsnormalizeMcpToolName canUseMcpServer policyFromAnnotations
MCP 搜索模式kun/src/adapters/tool/mcp-tool-search.tscreateMcpSearchProvider tokenizeMcpSearchText
其它 providerkun/src/adapters/tool/{web,image-gen,media-gen,computer-use,memory,skill,delegation}-tool-provider.tsbuildWebToolProviders buildDelegationToolProviders
GUI 计划工具kun/src/adapters/tool/create-plan-tool.ts · kun/src/shared/gui-plan.tsCREATE_PLAN_TOOL_NAME isPlanToolContextActive resolveReservedTarget isGuiPlanRelativePath isGuiPlanCurrentRelativePath guiPlanWorkspaceMatches
技能运行时kun/src/skills/skill-runtime-engine.ts · skill-runtime-support.ts · skill-runtime-contracts.tsSkillManifest SkillRuntime.resolveTurn buildInjection
委派运行时kun/src/delegation/DelegationRuntime createChildAgentExecutor BUILTIN_SUBAGENT_PROFILES loadWorkspaceAgentProfiles
记忆kun/src/memory/memory-store.ts · kun/src/loop/turn-context-resolver.ts · kun/src/loop/memory-instructions.tsMemoryStore retrieveMemories memoryInstructions
目录指纹kun/src/cache/tool-catalog-fingerprint.tsbuildToolCatalogFingerprint
只读并发kun/src/loop/tool-dispatch-policy.ts · kun/src/loop/tool-call-dispatcher.tsisParallelSafeToolCall PARALLEL_READ_ONLY_TOOL_NAMES DEFAULT_MAX_PARALLEL_READ_ONLY_TOOL_CALLS
装配根kun/src/server/runtime-composition-services.tsbaseToolProviders childRegistry registry

接着读: 工具什么时候被调度、结果怎么回到历史 → 02 Agent Loop;目录指纹和缓存前缀的关系 → 03 缓存优先;工具 schema 怎么变成模型请求 → 05 模型接入层;GUI 计划与自动化编排 → 06 GUI 产品层