工具套件:模型的手脚如何精确落到真实目标
30 秒导读: 模型只会"说话"。工具套件给它装上手脚——让它说的那段话,精确、可靠地落到某个真实目标(一个文件、一个 shell 进程、一次网络请求、一个子 agent)上。本章解剖
packages/agent-core/src/tools/里内置工具的统一形态,再逐类走一遍最能体现工程量的几支,最后讲 skill 系统怎么把一份SKILL.md变成可注入的能力。
本章不讲什么(交给别章):
- 工具如何被回合循环调度(一次工具调用从请求到结果的一生)——见 01-loop.md。
- 工具如何过权限闸门(approval、permission mode)——见 02-agent.md。本章只讲工具自己声明了哪些用于闸门的字段(
approvalRule/matchesRule/accesses),不讲闸门怎么判。 - 抹平大模型、抹平执行环境的 kosong / kaos 两层——见 04-providers.md。
1. 先建立直觉:一支工具是什么
在 Kimi Code 里,一支内置工具就是一个实现了 ExecutableTool 接口的类。它对模型暴露三样东西——名字、说明书、参数格式;对运行时暴露一个方法——resolveExecution。
先看接口本身,只有一个方法比普通的 kosong Tool 多:
// packages/agent-core/src/loop/types.ts:157 —— 真实源码
export interface ExecutableTool<Input = unknown> extends Tool {
resolveExecution(input: Input): ToolExecution | Promise<ToolExecution>;
}
Tool(来自 kosong)提供 name / description / parameters 三个字段,是给模型看的"说明书";resolveExecution 是给运行时的"动作解析器"。内置工具的类型别名就是它(agent/tool/types.ts:5,BuiltinTool<Input> = ExecutableTool<Input>)。
一句话直觉: 说明书(name/description/parameters)是模型读的;resolveExecution 返回的那个对象是运行时执行 + 授权 + 渲染的。两者严格分家。
2. 一支工具的解剖:五件套
Kimi Code 每支内置工具都由同样五件套拼成。理解了这五件,就理解了整个套件——剩下的都是同一个模子里换动作。以文件编辑工具 EditTool(builtin/file/edit.ts)为范例。
2.1 五件套一览
| 件 | 是什么 | EditTool 里的样子 | 源码锚点 |
|---|---|---|---|
| ① 输入 schema | zod 对象,描述模型该传什么 | EditInputSchema(path / old_string / new_string / replace_all) | edit.ts:27 |
| ② 描述文 本 | 同名 .md 文件,?raw 原样注入 | import EDIT_DESCRIPTION from './edit.md?raw' | edit.ts:22 |
| ③ execute | 真正干活的异步函数 | private execution(args, path) | edit.ts:95 |
| ④ 声明式 accesses | 这次调用碰哪些资源(给并发/权限用) | ToolAccesses.readWriteFile(path) | edit.ts:75 |
| ⑤ 权限规则 | 生成/匹配审批规则的字符串与函数 | approvalRule + matchesRule | edit.ts:84-90 |
2.2 这五件怎么拼成一个类
工具类的三个只读字段就是给模型的说明书,构造时一次算好:
// edit.ts:58-61 —— 真实源码(节选)
export class EditTool implements BuiltinTool<EditInput> {
readonly name = 'Edit' as const;
readonly description = EDIT_DESCRIPTION; // ② 同名 .md
readonly parameters = toInputJsonSchema(EditInputSchema); // ① zod → JSON Schema
注意 parameters 不是直接把 zod 丢出去,而是过一道 toInputJsonSchema(第 4 节详解)。构造函数只收两个依赖——kaos(执行环境抽象)和 workspace(工作区配置);它不碰 node 的 fs,所有 I/O 走 kaos。
2.3 resolveExecution:把一次调用"解析"成一个动作
resolveExecution(args) 不执行任何 I/O,它把参数解析成一个 ToolExecution 描述对象,交给循环。这是整套设计最关键的一层:
// edit.ts:68-92 —— 真实源码(节选)
resolveExecution(args: EditInput): ToolExecution {
const path = resolvePathAccessPath(args.path, { // 先过路径安全策略
kaos: this.kaos, workspace: this.workspace, operation: 'write',
});
return {
accesses: ToolAccesses.readWriteFile(path), // ④ 声明:读写这一个文件
description: `Editing ${args.path}`, // 给 UI 的一句话
display: { kind: 'file_io', operation: 'edit', path, before: args.old_string, after: args.new_string },
approvalRule: literalRulePattern(this.name, path), // ⑤ 生成审批规则字符串
matchesRule: (ruleArgs) => matchesPathRuleSubject(/* ... */), // ⑤ 判断某条已有规则是否覆盖本次
execute: () => this.execution(args, path), // ③ 真正干活的闭包
};
}
ToolExecution 的字段契约在 loop/types.ts:140 的 RunnableToolExecution。为什么要分成"解析"和"执行"两步? 因为循环需要在真正执行前就拿到 accesses(排并发)、approvalRule(问权限)、display(先渲染出"正在编辑 X")。把这些从副作用里拆出来,循环就能在不触碰磁盘的前提下决定"这次调用能不能跑、要不要问人、和谁冲突"。
2.4 那道 .md?raw 是什么魔法
?raw 是 Vite/构建时的导入后缀:把同目录下的 edit.md 当纯字符串读进来,原样作为工具描述。好处是说明书和代码分离——edit.md 是给模型读的长篇散文(几十行的用法约束),不塞进 .ts 里污染代码。
有些工具的描述还要按运行时状态改写。例如 BashTool 的描述模板里含 ${...} 占位,构造时用 renderPrompt 填入真实超时值(bash.ts:132),还会根据"这个 agent 能不能用后台任务"删掉整段后台说明(withoutBackgroundDescription,bash.ts:136)。说明书是动态的,但一旦装进 tools[] 就要求会话内逐字节稳定(见 select-tools.ts:40 的注释,变动的东西只能进公告、不能进描述)。
3. 深挖 EditTool:"精确字符串替换"而非"模糊匹配"
编辑代码文件是编码 agent 工程含量最高的动作。业界有两条路:一条是模糊匹配(容忍缩进差异、相似块 fallback),一条是 Kimi Code 选的——精确字符串替换。理解它的四条硬约束,就理解了这支工具为什么"宁可报错也不猜"。
3.1 核心动作:首个 / 全部,非唯一即报错
execute 从 kaos 读出文件,转成"模型视图"后做替换。默认只换第一个匹配,但会先数一遍:
// edit.ts:109-137 —— 真实源码(节选,非 replace_all 分支)
if (!replaceAll) {
let count = 0, pos = 0;
while (pos < content.length) {
const idx = content.indexOf(args.old_string, pos);
if (idx === -1) break;
count++;
pos = idx + args.old_string.length;
}
if (count === 0) return { isError: true, output: `old_string not found in ${args.path} ...` };
if (count > 1) return { isError: true, output: `old_string is not unique ... found ${count} occurrences ...` };
// 唯一命中 → 替换、写回
}
为什么"非唯一即报错"是关键设计? 如果 old_string 在文件里出现两次,模型往往并不知道自己想改哪一个。此时猜是危险的——很可能改错地方。工具直接报错,并在错误信息里教模型两条出路:要么 replace_all=true 全换,要么"把周围上下文加进 old_string 让它唯一"(edit.ts:127-128)。错误信息本身就是给模型的提示词。
replace_all 分支用 content.split(old_string).join(new_string)(edit.ts:140),一次全换。
3.2 三条不显眼但要命的护栏
| 护栏 | 防的是什么 | 源码 |
|---|---|---|
old_string 必须非空(.min(1)) | 空串会让 indexOf("", pos) 永远命中 → while 死循环 | edit.ts:34,注释在 edit.ts:24-26 |
old_string === new_string 提前拦 | 什么都没改还写盘,纯浪费 | edit.ts:96-101 |
EISDIR 单独兜底 | 把目录当文件编辑,给人话而非栈 | edit.ts:155-156 |
第一条尤其精妙:min(1) 不是随手加的校验,注释里白纸黑字写明"非 replace_all 分支用 indexOf('', pos) 走位,空串会无限循环"。这是一条防死循环的护栏,伪装成一个 schema 约束。
3.3 CRLF / LF:模型看到的和磁盘上的不是同一份
Windows 文件是 \r\n,Unix 是 \n。如果让模型在 old_string 里精确匹配 \r\n,它几乎必然匹配失败。Kimi Code 的解法是造一个**"模型文本视图"**:读盘后把纯 CRLF 文件的 \r\n 全折成 \n 给模型看;写回时再还原成 CRLF。
// line-endings.ts:32-47 —— 真实源码(节选)
export function toModelTextView(raw: string): ModelTextView {
const lineEndingStyle = detectLineEndingStyle(raw); // 'lf' | 'crlf' | 'mixed'
if (lineEndingStyle !== 'crlf') return { text: raw, lineEndingStyle };
return { text: raw.replaceAll('\r\n', '\n'), lineEndingStyle }; // 纯 CRLF 折成 LF
}
export function materializeModelText(text: string, style: LineEndingStyle): string {
if (style !== 'crlf') return text;
return text.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'); // 写回还原
}
EditTool.execution 就是先 toModelTextView(raw) 拿视图、在视图上做替换、再 materializeModelText(newContent, style) 写回(edit.ts:105-135)。混合行尾(mixed,既有 CRLF 又有裸 CR)不折叠,而是让 Read 把 \r 显式渲染成 \r(makeCarriageReturnsVisible,line-endings.ts:49),edit.md 里也明确要求这种文件用真实 \r 转义(edit.md:12-13)。同一套 line-endings.ts 被 Read 和 Edit 共用,保证"模型读到的"和"模型能改的"是同一份视图。
4. 横切机制:所有工具共享的地基
五件套只是单支工具的形状。真正让这套东西成体系的,是每支工具都踩在同一批横切设施上。逐个看。
4.1 声明式 accesses:工具自己说"我碰什么",循环据此排并发
工具不直接管并发,它只声明这次调用会读/写/搜哪些路径。ToolAccesses 是一组构造器(loop/tool-access.ts:22):readFile / writeFile / readWriteFile / searchTree / readTree …… 还有两个极端:none()(不碰文件资源)和 all()(全局互斥)。
关键在 conflict:两个访问是否冲突,由"是否有写 + 路径是否重叠"决定。
// tool-access.ts:74-96 —— 真实源码(节选)
function resourceAccessesConflict(left, right): boolean {
if (left.kind === 'all' || right.kind === 'all') return true; // all 与一切冲突
if (!fileOperationsConflict(left.operation, right.operation)) return false; // 两个只读 → 不冲突
return fileAccessesOverlap(left, right); // 都写/一读一写 → 看路径是否重叠
}
于是:两个 Read 同一文件不冲突(可并发);Read 与 Edit 同一文件冲突(串行);两把 Edit 打不同文件不冲突。select_tools 和 AgentSwarm 不声明 accesses,默认降级成 all(),和同批一切串行——这是刻意的(select-tools.ts:11-17 注释:两个 select 并发结算会重复注入同一份 schema)。
4.2 输入 schema:zod → JSON Schema 的两处坑
toInputJsonSchema(support/input-schema.ts:26)把 zod schema 转成模型看的 draft-07 JSON Schema。它专门处理两个坑:
io: 'input'视图。 zod v4 默认出输出视图,会把带.default()的字段标成required——自相矛盾(既有默认值又必填),还让运行时 AJV 拒掉合法调用。强制 input 视图,defaulted 字段保持可选。- 补回
additionalProperties: false。 input 视图会丢掉这个闭合约束,于是closeObjectNodes递归给每个 object 节点补回(input-schema.ts:49)。因为运行时没有 zod 解析步,只有 AJV 校验;不闭合的话,一个拼错的参数会静默通过、被无声忽略。
真正的运行时参数校验在 args-validator.ts:按 $schema 或关键字选对应 draft 的 AJV 实例(ajvFor,args-validator.ts:30——draft-07 / 2019 / 2020 三个实例分开,因为 items 在不同 draft 语义不同),编译一次、每次调用 validateToolArgs,并把 AJV 报错翻成人话(must have required property 'x')。
4.3 路径访问策略:目录内可相对,目录外须绝对
所有文件工具(Read/Write/Edit/Grep/Glob/ReadMedia)在 resolveExecution 里第一步都调 resolvePathAccessPath(policies/path-access.ts:245)。这道策略做三件事,resolvePathAccess(path-access.ts:201)是核心:
用户传入 path
│
├─ 展开 ~ / 规范化 (win32 盘符等)
├─ 词法 canonical(解析 .. 和 . ,不跟随 symlink) ← canonicalizePath, path-access.ts:103
├─ 是否命中敏感文件? .env / id_rsa / credentials … ← isSensitiveFile, sensitive.ts:48
│ └─ 命中 → 抛 PATH_SENSITIVE(挡密钥外泄)
└─ 在工作区内吗? ← isWithinWorkspace, path-access.ts:154
├─ 在 → 放行(相对路径也行)
└─ 不在 → 看 guardMode:
'absolute-outside-allowed':原始路径是绝对的才放行,相对的报错
一句话规则:工作区内的路径可以相对写,工作区外的必须写绝对路径。 相对路径逃逸工作区(如 ../../etc)被拒,因为它多半是模型"以为自己在别处"的误判;而显式绝对路径是模型明确要访问外部,放行。敏感文件(sensitive.ts:12 的一小撮:.env、SSH 私钥、credentials)则无论如何都挡,还专门处理改名规避(id_rsa.key、.env.bak)和白名单(.env.example)。
注意注释诚实标明:这套是纯词法的,不跟随 symlink——一个指向工作区外的软链能绕过,因为 kaos 层还没有 realpath 支持(path-access.ts:262-265)。
4.4 审批规则:literal 与 glob 两种口径
rule-match.ts 给每支工具生成/匹配审批规则。literalRulePattern(toolName, subject) 生成形如 Edit(/abs/path) 的规则串,并转义 glob 元字符(rule-match.ts:9);匹配时路径类工具用 matchesPathRuleSubject(rule-match.ts:21,按路径 glob 语义,支持 ! 取反),命令/查询类用 matchesGlobRuleSubject。工具只负责给出规则和匹配函数,判不判由权限层(02 章)。
4.5 结果构建、渲染、注册表
ToolResultBuilder(support/result-builder.ts):统一的输出封装。默认 5 万字符上限、单行 2000 字符截断,越界写[...truncated]。Bash/Grep/Web 都用它,保证"塞给模型的结果"不会撑爆上下文。display:每个ToolExecution带一个display对象(kind: 'file_io' | 'command' | 'agent_call' | 'todo_list' | ...),schema 来自@moonshot-ai/protocol(display/schemas.ts)。这是给 TUI/Web 前端渲染"正在做什么"的结构化描述,和给模型的output分开。store(tools/store.ts):一个类型安全的 KV,给需要跨调用记状态的工具用。TodoListTool用declare module往里挂todo: TodoItem[](todo-list.ts:38),写走tools.update_store所以能在 wire replay 里重现。- 注册表
ToolManager(agent/tool/index.ts):持有builtinTools/userTools/mcpTools三张表。initializeBuiltinTools(agent/tool/index.ts:683)是总装配线——按能力/开关条件new出每支工具:
// agent/tool/index.ts:707-769 —— 真实源码(高度节选)
new b.ReadTool(kaos, workspace),
new b.EditTool(kaos, workspace),
new b.BashTool(kaos, cwd, background, { /* ... */ }),
goalToolsEnabled && new b.CreateGoalTool(this.agent), // 按开关挂
this.agent.rpc?.requestQuestion && new b.AskUserQuestionTool(this.agent), // 按能力挂
this.agent.cron && new b.CronCreateTool(this.agent.cron),
toolServices?.webSearcher && new b.WebSearchTool(toolServices.webSearcher), // 无 provider 就不挂
哪些工具存在,取决于 agent 的能力、profile 开关、有没有注入 provider——工具集是被"算"出来的,不是写死的。
5. 逐类工具巡览
五件套是模子,下面按类别过一遍具体的手脚。每类只点最能体现工程量的地方。
5.1 file —— 五支文件工具
| 工具 | 干什么 | 最值得看的点 | 源码 |
|---|---|---|---|
| Read | 按行读文本 | 行号视图、1000 行 / 100KB 双上限、tail 负偏移、二进制/图像识别后转介 ReadMediaFile | file/read.ts |
| Write | 整文件写 | 与 Edit 共用 line-endings 视图 | file/write.ts |
| Edit | 精确串替换 | 见第 3 节 | file/edit.ts |
| Grep | ripgrep 内容搜索 | 三种输出模式、分页、敏感文件二次过滤 | file/grep.ts |
| Glob | ripgrep 文件名匹配 | 按修改时间倒序、cwd 钉在搜索根解决相对 glob | file/glob.ts |
Read 的巧思: 结果分两路——文件内容进 output,而"读了 N 行 / 共 M 行 / 已截断"这类状态行走 note 侧信道(model-only,不进 UI),包在 <system> 里(read.ts:529-536)。这样状态元数据不会污染"模型看到的文件内容"本身。上限是常量:MAX_LINES=1000 / MAX_BYTES=100KB / MAX_LINE_LENGTH=2000(read.ts:16-18)。
Grep / Glob 共享 ripgrep 地基: 两支都 shell 出 rg,共用二进制定位(rg-locator)、子进程管道(run-rg)、.gitignore 处理。安全上有一处对称设计——敏感 glob 排除追加在用户 glob 之后,这样用户写个宽泛的 **/.env 也无法覆盖掉排除(grep.ts:490-495、glob.ts:322-327)。Glob 还有一处关键:把 rg 的 cwd 钉到搜索根、用 . 当搜索路径(glob.ts:202),否则 src/**/*.ts 这种带 / 的 pattern 会被拿去匹配绝对路径而永远匹配不上。
ReadMediaFile: 读图/视频成多模态内容,门控模型的 image_in / video_in 能力。图像支持 region(按原图像素抠矩形)和 full_resolution(跳过默认降采样),压缩上限由 owner 级的 ImageLimits(support/image-limits.ts:27,env > 配置 > 内置默认三级优先)解析。压缩绝不静默:note 里明说是原样发、降采样、还是裁切。
5.2 shell —— BashTool
一支工具,却是最重的一支。核心难点是进程生命周期:前台跑、超时自动转后台、手动 ctrl+b 分离、SIGTERM→宽限→SIGKILL 两段杀。执行不走 node:child_process,全经 kaos,并交给 BackgroundManager 托管(bash.ts:359)。几处硬化:
- 立即关 stdin(
closeProcessStdin,bash.ts:588):交互命令(cat、read、python -c 'input()')收到 EOF 而不是挂死。 - 非交互环境变量:注入
NO_COLOR=1/TERM=dumb/GIT_TERMINAL_PROMPT=0(bash.ts:281-289),让 git 不弹分页器、输出不带颜色码。 - 超时分层:前台默认 60s / 上限 5min,后台默认 10min / 上限 24h,schema 用
superRefine按前后台选对上限校验(bash.ts:82-93)。前台命中超时默认转后台而非杀死(可配),这套语义还会反向改写工具描述(withoutAutoBackgroundOnTimeout等一串函数),让模型看到的说明书和实际行为一致。
5.3 web —— 接口在核心,实现由 host 注入
FetchURLTool(web/fetch-url.ts:67)和 WebSearchTool(web/web-search.ts:43)在 agent-core 里只定义接口——UrlFetcher(fetch-url.ts:38)、WebSearchProvider(web-search.ts:29)。没注入 provider,工具就不注册、不暴露给模型(见 5.6 装配线的 toolServices?.webSearcher && ...)。
真实实现放在 tools/providers/:MoonshotWebSearchProvider(providers/moonshot-web-search.ts:39)打 Moonshot 搜索 API,用 bearer token(带 apiKey 兜底),把 search_results 映射成统一的 WebSearchResult;还有 moonshot-fetch-url.ts 和给本地/自托管用的 local-fetch-url.ts。这是依赖倒置:核心声明契约,宿主填实现,同一支工具能接不同后端。
两支工具都把"引用提醒"和数据放一起进 output(而非 message,因为 message 会在投给 provider 时被丢),提醒模型引用页面时写成 markdown 链接(web-search.ts:92、fetch-url.ts:112)。
5.4 planning —— 进/出计划模式
EnterPlanModeTool(planning/enter-plan-mode.ts:22)/ ExitPlanModeTool。进计划模式无需审批,execute 里直接调 agent.planMode.enter(),返回一段"你现在处于计划模式,只能用只读工具调研、然后写计划文件"的工作流指令(enter-plan-mode.ts:56)。工具本体极薄——真正的状态在 agent.planMode,工具只是模型触发它的把手。
5.5 collaboration —— 派活给别的 agent / 问人 / 用技能
这一类是"让模型协调其它执行体"的工具。
| 工具 | 干什么 | 关键设计 | 源码 |
|---|---|---|---|
| Agent | 派一个子 agent 跑一个任务 | 前台等结果/后台立返;resume 可续跑同一子 agent;子类型描述动态拼进 description | collaboration/agent.ts:107 |
| AgentSwarm | 一批子 agent 并行 | 用 prompt_template + items 展开;去重相同 prompt;上限 128;resume_agent_ids 续跑 | collaboration/agent-swarm.ts:86 |
| AskUserQuestion | 向用户提结构化选择题 | 反向 RPC 到宿主 UI;问题/选项文本必须唯一(AJV 表达不了,execute 里再查一遍) | collaboration/ask-user.ts:133 |
| Skill | 模型主动调用一个已注册技能 | 递归深度硬顶 MAX_SKILL_QUERY_DEPTH=3;把技能正文作为 user 消息注入 | collaboration/skill-tool.ts:77 |
Agent 工具的巧思: 输入 schema 用 z.preprocess 做兜底——不给 subagent_type 也不给 resume 时默认 'coder'(agent.ts:42-59)。子 agent 也经 BackgroundManager 托管,前台调用等 waitForForegroundRelease;结果里给足 resume_hint,并反复叮嘱"后台任务会自动通知、别 poll"(agent.ts:333)。
Skill 工具的防递归: 一个技能可能再调技能。MAX_SKILL_QUERY_DEPTH(skill-tool.ts:29)封死 Skill→Skill 无界递归,越界抛结构化 NestedSkillTooDeepError(而非软 tool-error),好让运行时区分"模型误派"和"安全网触发"(skill-tool.ts:104-113)。调用成功时,它把渲染好的技能正文用 agent.context.appendUserMessage 注入对话(skill-tool.ts:142),返回一句"技能已内联加载,按其指示做"。
5.6 goal / state / background / cron
- goal:
CreateGoal(goal/create-goal.ts:33)/GetGoal/UpdateGoal/SetGoalBudget。把"目标"变成 agent 持有的结构化持久状态(agent.goal),而非从 slash 命令解析的文本。按goalToolsEnabled开关挂载。 - state:
TodoListTool(state/todo-list.ts:92)。一支工具兼读写:传todos是替换、传空数组是清空、不传是查询。状态进ToolStore(见 4.5)。 - background:
TaskList/TaskOutput/TaskStop(tools/background/)。给 Bash/Agent 派出的后台任务做列/看/停。它们的存在与否决定了 Bash/Agent 能不能开后台(allowBackground)。 - cron:
CronCreate/CronList/CronDelete(tools/cron/)。定时触发,按agent.cron存在与否 挂载;cron/scheduler.ts是调度器,cron-expr.ts解析表达式。
5.7 动态工具集:select_tools 与渐进式披露
MCP 工具可能几十上百个,全塞进顶层 tools[] 会撑爆上下文。Kimi Code 的解法是渐进式披露:MCP 工具的完整 schema 不进 tools[],模型先在 <tools_added> 公告里看到名字,需要时调 select_tools(names) 把它们的定义追加进对话。
// select-tools.ts:103-113 —— 真实源码(节选)
this.agent.context.appendMessage({
role: 'system', content: [], toolCalls: [],
tools, // messages[].tools 线协议:携带工具定义
origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT },
});
manager.markDynamicToolsLoaded(toLoad); // 抢在 schema 消息落史前先记账
被选中的工具下一步就可调——循环每步重读可执行工具表(select-tools.ts:8)。混合输入按名逐个结算:命中的加载、已加载的报告、未知的单独报错,绝不"一个拼错全批重来"(select-tools.ts:79-90)。ToolManager 侧,loadableDynamicToolNames(agent/tool/index.ts:559)给出可选名单,getMcpToolSchema(agent/tool/index.ts:610)从活注册表(不从历史)读 schema。"已加载"账本以历史为唯一真相,能扛 resume、undo,并在压缩时清空——让模型重新按需选。
6. Skill 系统:把一份 SKILL.md 变成可注入能力
工具是编译进二进制的手脚;技能(skill)是用 Markdown 写的、可热加载的能力。5.5 的 Skill 工具只是模型触发技能的把手,技能本身怎么被发现、解析、渲染,在 packages/agent-core/src/skill/。
6.1 一条技能的一生
磁盘上的 SKILL.md / *.md 文件
│
├─ ① 发现:扫描若干根目录 scanner.ts:discoverSkills (scanner.ts:132)
│ 项目/用户/内置/插件多来源,首名胜出去重,深度上限 8 防软链成环
│
├─ ② 解析:切 frontmatter + 正文 parser.ts:parseSkillText (parser.ts:107)
│ YAML frontmatter → SkillMetadata;正文 trim;顺带抽 mermaid/d2 图
│
├─ ③ 入册:按名归一化建索引 registry.ts:SessionSkillRegistry (registry.ts:26)
│
└─ ④ 渲染:调用时展开占位 + 包 XML registry.renderSkillPrompt (registry.ts:91)
→ 作为 user 消息注入对话,模型照做
6.2 发现:多来源、首名胜出、防成环
resolveSkillRoots(scanner.ts:61)按固定优先级收集根目录:项目品牌目录(.kimi-code/skills)、项目通用目录(.agents/skills)、用户目录、插件根、内置。discoverSkills(scanner.ts:132)递归走每个根,遇到含 SKILL.md 的目录就当一个技能包登记;readdir 结果排序后再处理,让跨目录同名冲突的"首名胜出"是确定性的而非依赖文件系统顺序(scanner.ts:154-156)。递归深度封在 MAX_SKILL_SCAN_DEPTH=8(scanner.ts:17),防目录软链成环转圈。
6.3 解析:frontmatter + 参数占位展开
parseFrontmatter(parser.ts:82)手写切 --- 围栏,YAML 用 js-yaml。类型只认三种可内联的(prompt / inline,及 type 缺省)加 flow / reference,不认的抛 UnsupportedSkillTypeError 被跳过(types.ts:73、scanner.ts:402)。
技能正文可带参数占位,expandSkillParameters(parser.ts:176)负责展开:$NAME(具名)、$0/$1(位置)、$ARGUMENTS(整串)、${KIMI_SKILL_DIR}(技能目录,好让技能引用自带脚本);参数串按 shell 风格分词(引号成组)。若正文根本没占位但传了参数,就整串追加成一行 ARGUMENTS:(parser.ts:210-212)。所有插值都过 escapeXmlTags 防注入。
6.4 渲染 + 注入:包成 XML 块喂给模型
SessionSkillRegistry.renderSkillPrompt(registry.ts:91)展开占位、若来自插件再前置插件说明。注入时由 agent/skill/prompt.ts 包成 <kimi-skill-loaded name=... trigger=... source=... dir=... args=...>正文</kimi-skill-loaded> 块(prompt.ts:45),触发来源分 user-slash / model-tool / nested-skill。给模型的技能清单单独渲染(getModelSkillListing,registry.ts:133),只列可模型调用、非子技能的,并冠一句"忽略更早的清单,当前可用技能如下"来对抗上下文里的过期清单。
一句话直觉: 工具是"装好的手脚",技能是"随时能塞进脑子的一段专业操作手册"——发现即插即用,不用改一行代码、不用重编译。
7. 代码地图(导航索引)
按符号名 grep 比按行号更抗漂移。下表是读源码时该打开的关键文件与符号。
| 主题 | 文件路径 | 符号 |
|---|---|---|
| 工具接口(比 kosong Tool 多一个方法) | packages/agent-core/src/loop/types.ts | ExecutableTool / RunnableToolExecution / ToolExecution |
| 内置工具类型别名 | packages/agent-core/src/agent/tool/types.ts | BuiltinTool |
| 精确串替换范例 | packages/agent-core/src/tools/builtin/file/edit.ts | EditTool / replaceOnceLiteral / EditInputSchema |
| CRLF/LF 模型视图 | packages/agent-core/src/tools/builtin/file/line-endings.ts | toModelTextView / materializeModelText / detectLineEndingStyle |
| 声明式资源访问与冲突 | packages/agent-core/src/loop/tool-access.ts | ToolAccesses / resourceAccessesConflict |
| zod→JSON Schema(input 视图) | packages/agent-core/src/tools/support/input-schema.ts | toInputJsonSchema / closeObjectNodes |
| 运行时参数校验(AJV) | packages/agent-core/src/tools/args-validator.ts | ajvFor / validateToolArgs |
| 路径访问策略 | packages/agent-core/src/tools/policies/path-access.ts | resolvePathAccess / isWithinWorkspace / canonicalizePath |
| 敏感文件检测 | packages/agent-core/src/tools/policies/sensitive.ts | isSensitiveFile |
| 审批规则生成/匹配 | packages/agent-core/src/tools/support/rule-match.ts | literalRulePattern / matchesPathRuleSubject |
| 结果封装/截断 | packages/agent-core/src/tools/support/result-builder.ts | ToolResultBuilder |
| 按行读文本 | packages/agent-core/src/tools/builtin/file/read.ts | ReadTool / finishReadResult |
| ripgrep 内容搜索 | packages/agent-core/src/tools/builtin/file/grep.ts | GrepTool / buildRgArgs |
| ripgrep 文件名匹配 | packages/agent-core/src/tools/builtin/file/glob.ts | GlobTool / splitCompletePaths |
| 图像限额解析 | packages/agent-core/src/tools/support/image-limits.ts | ImageLimits |
| shell 执行 + 进程托管 | packages/agent-core/src/tools/builtin/shell/bash.ts | BashTool / closeProcessStdin |
| web 工具契约 | packages/agent-core/src/tools/builtin/web/fetch-url.ts · web-search.ts | UrlFetcher / WebSearchProvider |
| web provider 实现 | packages/agent-core/src/tools/providers/moonshot-web-search.ts | MoonshotWebSearchProvider |
| 派子 agent | packages/agent-core/src/tools/builtin/collaboration/agent.ts | AgentTool |
| 子 agent 群 | packages/agent-core/src/tools/builtin/collaboration/agent-swarm.ts | AgentSwarmTool / createAgentSwarmSpecs |
| 结构化问人 | packages/agent-core/src/tools/builtin/collaboration/ask-user.ts | AskUserQuestionTool / questionUniquenessError |
| 调用技能(防递归) | packages/agent-core/src/tools/builtin/collaboration/skill-tool.ts | SkillTool / MAX_SKILL_QUERY_DEPTH |
| 进计划模式 | packages/agent-core/src/tools/builtin/planning/enter-plan-mode.ts | EnterPlanModeTool |
| 目标状态 | packages/agent-core/src/tools/builtin/goal/create-goal.ts | CreateGoalTool |
| TODO 状态 | packages/agent-core/src/tools/builtin/state/todo-list.ts | TodoListTool |
| 动态工具集(渐进披露) | packages/agent-core/src/tools/builtin/select-tools.ts | SelectToolsTool |
| 工具装配线 + 注册表 | packages/agent-core/src/agent/tool/index.ts | ToolManager.initializeBuiltinTools / loadableDynamicToolNames / getMcpToolSchema |
| 技能发现 | packages/agent-core/src/skill/scanner.ts | discoverSkills / resolveSkillRoots |
| 技能解析 + 占位展开 | packages/agent-core/src/skill/parser.ts | parseSkillText / expandSkillParameters |
| 技能注册表 | packages/agent-core/src/skill/registry.ts | SessionSkillRegistry / renderSkillPrompt / getModelSkillListing |
| 技能注入 prompt | packages/agent-core/src/agent/skill/prompt.ts | renderSkillLoadedBlock |
相关章节:工具怎么被调度见 01-loop.md;权限闸门怎么判见 02-agent.md;kaos/kosong 两层抹平见 04-providers.md。