数据截至 (上游 commit cdaa80b77807)
工具套件:模型的手脚如何精确落到真实目标
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:6,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),一次全换。