
本文介绍的项目是 dskcode------一个基于 DeepSeek 的 AI 编程助手终端 CLI 工具,采用 TypeScript 实现,只面向国内用户。本文所有设计细节与代码示例均来自
dskcode的实际实现。安装试用:
npm install -g dskcode或npx dskcode

后续说明:本文基于
agent-cli的src/tool/模块实现。代码是 TypeScript,但设计思想与语言无关------任何 LLM agent 在接工具时都绕不开"工具系统"这一层。受众假设:你已经能让 LLM 调上
bash、read_file之类的工具,但接 5 个工具以后就崩了------参数校验总是漏、并行执行串了一团、错误码五花八门、写工具偶尔越界写到~/.ssh。如果你在找"怎么把 agent 的工具系统做强壮",这篇文章就是答案。
- Agent 工具系统的本质 = 声明式工具注册表 + 强类型契约 + 统一结果协议 + 沙箱化的执行上下文。这四件事都做到位,接 80 个工具也跟接 8 个一样稳。
- 三条核心原则:运行时不知道类型,但工具自己知道 (类型擦除 + 工厂模式推导)/ LLM 看得懂的 schema 跟开发者写的类型用同一个源头 (Zod ↔ JSONSchema 自动双向)/ 任何工具失败都必须是无歧义的 ToolResult(成功和失败同结构,带 error code)。
- 关键 trade-off:用 Zod 换来极简的"写一次、出三个东西"(TS 类型 + JSONSchema + 校验器),代价就是引入一个 ~50KB 的依赖------值。
1. 为什么 agent 工具系统值得单独写一篇
LLM agent 工具系统的麻烦,不是"能不能调起来",而是"调起来以后能不能不崩"。
一个 agent 跑久了以后,工具集会长这样:
bash
read_file # 读文件
write_file # 写文件
edit_file # 精确替换
bash # 跑命令
grep # 搜代码
glob # 找文件
fetch # 抓网页
... # 30 个 + 各种 User-defined
接 5 个工具的时候,每个工具里自己 parse 自己 validate 自己 try/catch,代码量是 5×80=400 行,能撑。
接 30 个工具的时候,你突然发现:
- 同样的"读文件但太大"错误,有的工具返回
FILE_TOO_LARGE,有的返回TOOL_ERROR,有的甚至 throw - 有的工具的 args 是
{ path: string },有的偷偷用{ filePath }或{ filepath } - LLM 给出错误参数(
path写成Path),有的工具悄悄 fallback,有的直接 crash - bash 跑超时了杀进程不彻底,grep 跑 100M 日志把内存炸了
- 写工具的安全边界靠每个工具自己记,有几天就有一个忘了
这就是"工具系统"这个抽象层要解决的问题:把跨工具的样板代码、所有工具都要关心的那些事情,集中到一个地方。
2. 设计目标:工具系统要解决哪几类问题
工具系统的"职责范围"划清楚,实现就不会膨胀成大泥球。我们的目标只有四类:
| 类别 | 解决的问题 | 在 src/tool/ 里的对应 |
|---|---|---|
| 注册 | 工具有名、有描述、有调用方式,能被框架 find | registry.ts |
| 类型 | 工具自己的 args/返回值有强类型,但注册表能存"任意工具" | types.ts 的 AgentTool + AnyAgentTool |
| 校验 | LLM 给的参数对不对,不对就告诉它怎么改 | schema-validator.ts + zod-schema-validator.ts |
| 沙箱 | 路径安全、超时、输出截断、二进制检测等横切关注 | sandbox.ts + eol.ts + diff.ts |
不在范围内的------比如"工具依赖注入"、"工具远程调用"、"工具 trace 持久化"、"工具 dashboard"------我们坚决不做。这些一旦做了,工具系统就退化成"再做个微服务框架"。
下面按这四类一一展开。
3. 注册:ToolRegistry 的三件事
注册表本质上是个有过滤能力的 Map。三个动作:
typescript
// src/tool/registry.ts(简化版)
export class ToolRegistry {
readonly #tools = new Map<string, AnyAgentTool>();
// ① 注册
register<I, O>(tool: AgentTool<I, O>): this {
return this.registerErased(eraseTool(tool)); // 自动擦类型
}
// ② 按名查
get(name: string): AnyAgentTool | undefined {
if (!this.#isToolEnabled(name)) return undefined;
return this.#tools.get(name);
}
// ③ 列出可用的
list(): AnyAgentTool[] {
const result: AnyAgentTool[] = [];
for (const [name, tool] of this.#tools) {
if (this.#isToolEnabled(name)) result.push(tool);
}
return result;
}
}
真正麻烦的是"过滤"------三层 enable 检查
list() 不是简单 Map.values()。在 agent 这种"动态拼装"场景里,工具的可用性是组合出来的:
typescript
#isToolEnabled(name: string): boolean {
const tool = this.#tools.get(name);
if (!tool) return false;
// 1. 用户在配置里禁用?------比如项目中禁用了 bash
if (this.#disabledNames.has(name)) return false;
// 2. Feature Flag 关闭?------比如某个工具还在实验阶段
if (!this.#featureFlagChecker(name)) return false;
// 3. Provider 兼容?------比如这个工具只支持 Anthropic
if (this.#provider && tool.supportedProviders.length > 0) {
if (!tool.supportedProviders.includes(this.#provider)) return false;
}
return true;
}
这一段代码里藏着一个非显然的设计决定:为什么是 AND 而不是 OR?
考虑这种场景:用户在配置里写了 disabledTools: ["bash"](项目策略禁 bash),但工具自己又标记了 supportedProviders: ["anthropic"](只支持 Claude)。这两条都得满足才启用。任何一条不满足都不可用。
如果用 OR,用户禁用 + Provider 不支持的"双重不可用"就被奇偶校验炸没了。
注册表还做了"按 kind 分桶"的便利 API
typescript
listByKind(kind: ToolKind): AnyAgentTool[] { ... }
listReadTools(): AnyAgentTool[] { return this.listByKind(ToolKind.Read); }
listWriteTools(): AnyAgentTool[] { return this.list().filter((t) => !isReadOnly(t.kind)); }
这个不是装饰性便利------它是 agent 主循环的关键依赖:读工具可并行 / 写工具要串行。具体见后文 § 8。
4. 类型:AgentTool<I, O> 与类型擦除
这是整个工具系统的"最灵魂"的设计决策之一。
工具自己声明自己的类型
typescript
// src/tool/types.ts(节选)
export interface AgentTool<I, O extends ToolResult = ToolResult> {
readonly name: string;
readonly kind: ToolKind;
readonly parameters: JSONSchema;
readonly description: string;
// 关键:工具自己的 execute 签名,带 I 的强类型
execute(args: I, ctx: ToolContext): Promise<O>;
initialTitle?(args: I): string;
// ...
}
每个工具的 execute(args, ctx) 在自己的文件里都有完整的 TS 类型 ------read_file 看到 { path: string, startLine?: number },edit_file 看到 { path: string, old_text: string, new_text: string },写错了 IDE 直接报错。
但注册表只能存"任意工具"------这里必须把类型擦掉
typescript
// src/tool/types.ts(节选)
export interface AnyAgentTool {
readonly name: string;
readonly description: string;
readonly kind: ToolKind;
readonly parameters: JSONSchema;
readonly supportsInputStreaming: boolean;
readonly supportedProviders: string[];
// 关键:args 变成 unknown,execute 内部断言
execute(args: unknown, ctx: ToolContext): Promise<ToolResult>;
initialTitle?(args: unknown): string;
}
如果注册表保留泛型,那就得 Map<string, AgentTool<unknown, ToolResult>>------接着所有用注册表的地方都得自己知道"哦,这一项是个 read_fileTool"。这套擦除的本质是:让"找工具"和"用工具"在类型层面解耦。
擦除本身只一步:
typescript
export function eraseTool<I, O extends ToolResult = ToolResult>(
tool: AgentTool<I, O>,
): AnyAgentTool {
return {
get name() { return tool.name; },
get description() { return tool.description; },
// ...
async execute(args: unknown, ctx: ToolContext): Promise<ToolResult> {
// 内部断言:反正 Registry 调用方知道名字
return tool.execute(args as I, ctx);
},
initialTitle(args: unknown): string {
return tool.initialTitle?.(args as I) ?? tool.name;
},
};
}
调用方拿到 AnyAgentTool 后,需要做的事只有一个:根据名字断言回具体的工具类型:
typescript
const readTool = registry.get("read_file")!;
// readTool 是 AnyAgentTool,但我们知道它是 read_file
const result = await readTool.execute({ path: "src/main.ts" }, ctx);
// ^ 强类型仅在我们自己知道时成立
这套"上游强类型 + 注册表擦除 + 执行前断言"是经过权衡的------比"全部 any"安全,比"全部带泛型"灵活。
类型擦除的代价是什么?
唯一代价 :在调用 execute(args) 时,如果 args 是外部输入(从 LLM JSON 解析来的),你没法靠 TS 类型系统保护。这正是下一节要解决的------运行时 schema 校验。
5. 校验:Zod 与 JSONSchema 的双轨设计
LLM 给的参数是结构化的 JSON,但它会出错 ------给 read_file 一个 { path: 42 }(数字而不是字符串)、少一个 old_text 字段、timeout 给了 "abc" 等。怎么让 LLM 自我修正?
校验结果必须结构化、可喂回给 LLM
typescript
// src/tool/schema-validator.ts(节选)
export interface ValidationIssue {
path: string; // JSON Pointer:`$.path`、`$.items[2].name`
expected: string; // "string" / "present" / "enum[A,B,C]" / "length >= 3"
received: string; // 截断到 60 字符的值描述,避免爆日志
message: string; // 人类可读的中文,直接喂给 LLM
}
注意 message 是中文。这个细节救命------LLM 拿到中文错误消息比英文更容易自我修正,因为训练语料里中文 prompt 多。path 用 JSON Pointer 又能精确告诉它"哪个字段错了"。
双轨:JSONSchema 和 Zod 都行,产物同构
工具的参数 schema 有两种声明方式------
typescript
// 方式 A:JSONSchema(老 8 个内置工具用)
export const readFileTool: AgentTool<ReadFileArgs> = {
name: "read_file",
kind: ToolKind.Read,
parameters: {
type: "object",
properties: {
path: { type: "string", description: "..." },
startLine: { type: "number", description: "..." },
},
required: ["path"],
additionalProperties: false,
},
// ...
};
// 方式 B:Zod-first(新工具推荐)
import { z } from "zod";
import { defineTool } from "../zod-schema-validator.js";
const ReadFileSchema = z.object({
path: z.string().min(1, "path 不能为空"),
startLine: z.number().int().min(1).optional(),
endLine: z.number().int().min(1).optional(),
});
export const readFileZodTool = defineTool<z.infer<typeof ReadFileSchema>>({
name: "read_file_zod",
kind: ToolKind.Read,
schema: ReadFileSchema, // 一处写,出三个东西
async execute(args, ctx) { ... }
});
defineTool 的精髓:写一次 Zod schema,同时拿到 TS 类型 + JSONSchema + 校验器。源头唯一,后面少一半维护工作。
同构这件事比想象重要
校验失败的下游消费者有三个:
- LLM ------ 喂进下一轮对话,让它修正
- Reflector ------ 决定要不要 reflection / 重试
- UI ------ 给开发者展示"哪步出错了"
这三个消费者都不该关心"这个工具是用 Zod 还是 JSONSchema" ------它们只面对 ValidationIssue[]。
实现上用一个鸭子类型检测分支:
typescript
// src/tool/schema-validator.ts(节选)
export function validateArgs(args: unknown, schema: unknown): ValidationResult {
// 鸭子类型检测 Zod schema------避免静态 import 拖重
if (isZodSchema(schema)) {
return zodSafeValidate(args, schema as never);
}
// 否则走 JSONSchema 轻量校验
if (!isPlainObject(schema)) return { ok: true, issues: [] };
// ...
}
function isZodSchema(schema: unknown): schema is ZodType {
const obj = schema as Record<string, unknown>;
return (
"_def" in obj &&
typeof obj.parse === "function" &&
typeof obj.safeParse === "function"
);
}
为什么鸭子类型而不是 instanceof z.ZodType?因为静态 import zod 会让 schema-validator 加载时拉入 zod,任何不写 Zod 工具的项目也要背负这个依赖。鸭子检测让 zod 只在确实用到的工具里"按需"被加载。
integer ↔ number 互通这个细节
JS 里没有真正的 int 。1 是 number,JSON.parse 也给 number。如果 schema 写 integer、LLM 给 1、validate 说"应 integer 实际 number"------这是 false positive。
所以:
typescript
function typeMatches(actual: string, expected: string): boolean {
if (actual === expected) return true;
if (expected === "integer" && actual === "number") return true;
if (expected === "number" && actual === "integer") return true;
return false;
}
jsonTypeOf 里区分 integer/number,但校验时互通------这才对得上 LLM 实际行为。
6. 沙箱:横切关注集中在 sandbox.ts
读工具、写工具、bash 工具都关心"路径安全"和"超时"------这些必须集中。我见过有人每个工具写一份 if (path.startsWith("~")) expand...,这是噩梦。
6.1 路径解析与 @ 引用
typescript
// src/tool/sandbox.ts(节选)
/** 剩下一个开头的 `@` 引用标记 */
export function stripMentionPrefix(inputPath: string): string {
if (inputPath.startsWith("@")) return inputPath.slice(1);
return inputPath;
}
export function resolvePath(inputPath: string, cwd: string): string {
const stripped = stripMentionPrefix(inputPath);
const resolved = isAbsolute(stripped) ? stripped : resolve(cwd, stripped);
return resolve(resolved); // 二次 normalize
}
为什么二次 resolve?因为 resolve 不会消除 ..(只解析 ~ 和相对路径),所以 resolve("/a/b/../c") 仍是 /a/b/../c。这步把语义"压实"成绝对路径,后面的 realpath 才能可靠比。
@ 这个细节救过我 ------system prompt 里 @<path> 是"文件路径引用"的语法糖,但 LLM 老老实实把 @test.ts 原样传给工具。
6.2 写工具必须confine在白名单里
typescript
export async function confine(
allowedRoots: string[],
target: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
if (allowedRoots.length === 0) return { ok: true }; // 没限制就放行
const realTarget = await realPath(target);
for (const root of allowedRoots) {
const realRoot = await realPath(root);
const rel = relative(realRoot, realTarget);
// 三种威胁都判到:..跳出、空(==root)、绝对路径
if (!rel.startsWith("..") && rel !== "" && !rel.startsWith("/") && !rel.startsWith("\\")) {
return { ok: true };
}
if (realTarget === realRoot) return { ok: true };
}
return { ok: false, error: `路径 "${target}" 不在允许的写入范围内 ${allowedRoots.join(", ")}` };
}
工具写代码:
typescript
if (ctx.writeRoots && ctx.writeRoots.length > 0) {
const conf = await confine(ctx.writeRoots, filePath);
if (!conf.ok) {
return { success: false, data: conf.error, error: "OUTSIDE_WRITE_ROOTS" };
}
}
这意味着即使 LLM 写出 target = ~/.ssh/authorized_keys,也会被 confine 拒绝。
realPath 是救命细节 ------路径里如果有符号链接,/var/www -> /home/user/www 这种,通过 realpath 解析后才不会被绕过。
6.3 超时中止:外部信号 + 内置计时器
typescript
export function createTimeoutSignal(signal?: AbortSignal, timeoutMs = 30_000): AbortController {
const controller = new AbortController();
if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true });
const timer = setTimeout(() => controller.abort(), timeoutMs);
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
return controller;
}
为什么外部 signal + 内部计时联动 ?用户可能在 UI 里点 "Stop"(externalAbort),bash 自己跑超 30s(timeoutAbort),任何一个触发都要中止子进程。
6.4 bash 子进程的两段杀
bash 的 execCommand 还做了一件粗暴的事:
typescript
const timeout = setTimeout(() => {
child.kill("SIGTERM");
setTimeout(() => {
child.kill("SIGKILL"); // 5 秒还不退,强杀
}, 5000);
}, timeoutMs);
SIGTERM 给个 5s 优雅退出窗口,5s 还没走就用 SIGKILL。这避免了"假超时"(进程没真死,资源没释放) ------尤其是 npm install、cargo build 这种会 spawn 子子进程的命令。
7. 结果协议:ToolResult 的成功和失败同结构
LLM agent 的工具结果必须遵守一个铁律:成功和失败都返回同一形状的对象。否则 LLM 看到错误时的训练模式就跟正常回应混合,越用越乱。
ToolResult 的核心字段
typescript
// src/tool/types.ts(节选)
export interface ToolResult {
success: boolean; // 唯一判断成败的标志
data: string; // 给 LLM 看的内容(成功是输出,失败是错误)
error?: string; // 错误分类标记,LLM 不用关心,但程序要关心
diff?: FileDiff; // 仅文件修改类工具携带
summary?: string; // 给 UI 看的一行摘要(LLM 看不到)
issues?: ValidationIssue[]; // schema 校验失败时携带
denial?: ToolDenial; // v0.6 新增:权限拒绝详情
}
每个工具错误都必须有一个机器可读的 error code:
| 错误场景 | success |
error |
data |
|---|---|---|---|
| 文件不存在 | false |
READ_ERROR |
"读取文件失败:ENOENT..." |
| 文件太大 | false |
FILE_TOO_LARGE |
"文件过大(15.3MB)..." |
| 二进制文件 | false |
BINARY_FILE |
"看起来是二进制文件..." |
| old_text 没找到 | false |
TEXT_NOT_FOUND |
"未找到要替换的文本..." |
| old_text 出现多次 | false |
TEXT_MULTIPLE_MATCHES |
"出现多次,请提供更多上下文..." |
| 超出 writeRoots | false |
OUTSIDE_WRITE_ROOTS |
"路径不在允许范围内..." |
| 命令退出非 0 | false |
EXIT_CODE_1 |
"...\n[退出码: 1]" |
| 成功 | true |
(无) | 工具输出 |
错误码不要 Message 这种"描述性"的------error 字段是给程序判断分支用的,必须是稳定的标识符 。LLM 想读错误细节,从 data 里读。
data 永远可被 LLM 直接读
不抛 Error,不扔 undefined,不调用 JSON.stringify(obj)(LLM 看到大堆对象描述会吐)。
失败时 data 是可读的错误描述,LLM 下一轮对话里看到就能自己修正------比如:
typescript
return {
success: false,
data: "未找到要替换的文本。请确认 old_text 与文件内容完全一致(包括缩进和空格)。",
error: "TEXT_NOT_FOUND",
};
LLM 看到这段大概率第二轮回放老文本或者先用 read_file 看一眼,而不是卡死。
summary 给 UI,data 给 LLM------两条线
typescript
return {
success: true,
data: `文件已编辑:${filePath}\n替换位置:第 120 行...`, // ← 喂 LLM
summary: `📝 修改: foo.ts (+3 -5)`, // ← 喂 UI
diff, // ← 喂 UI 比对
};
为什么两条线? 写一个 10MB 文件 diff 出来,UI 直接渲染行不通;但 LLM 需要看完整内容来验证修改对不对。data 是"LLM 友好版",summary 是"人友好版",diff 是"工具友好版"。
8. 工具的并行与串行:从 ToolKind 出发
工具系统的实际能力,不只是"调得通",还包括"调得聪明 "。一个工程性 agent 同时跑 3 个 read_file 是常见需求;同时跑 3 个 edit_file 编辑同一文件就是灾难。
ToolKind 是语义分类
typescript
export enum ToolKind {
Read = "read", // 纯读,无副作用,可并行
Edit = "edit", // 文件/目录内容编辑
Delete = "delete", // 删除
Move = "move", // 重命名/移动
Other = "other", // bash、fetch 兜底
}
export function isReadOnly(kind: ToolKind): boolean {
return kind === ToolKind.Read;
}
这个字段的本职工作就是 agent 主循环用来决定并行度的开关:
typescript
const reads = registry.listReadTools();
const writes = registry.listWriteTools();
// 主循环:
// 1. 把所有 read 类调用 fanout 并行(独立 IO)
// 2. writes 必须等前面批次完成才执行(单线程串行)
为什么这么分?读操作无外部副作用 ------同一秒 5 个 read_file 跑两个不同文件,系统状态不变。写操作有副作用(一个写覆盖另一个,顺序敏感),必须串行。
还有一个隐藏的好处:UI 分类展示
终端里如果把工具调用按 ToolKind 分组渲染,用户一眼能看出"现在在读 / 现在在写":
scss
✓ read_file src/main.ts (1ms)
✓ read_file src/utils.ts (2ms)
✓ grep "TODO" (8ms, 3 matches)
── 现在开始写 ──
✓ edit_file src/main.ts (+3 -1)
✓ bash npm test (exit 0)
这种"读 / 写"分组不是炫技------它让用户对 agent 当前在哪一阶段心里有数。
9. 端到端组装
把上面所有零件粘起来是这样的:
一次完整调用的代码长这样:
typescript
// ① 找工具
const tool = registry.get(toolName);
if (!tool) return { success: false, data: `工具 "${toolName}" 不存在`, error: "TOOL_NOT_FOUND" };
// ② 校验参数(用工具自己的 schema,JSONSchema 或 Zod 都行)
const validation = validateArgs(rawArgs, (tool as any).schema ?? tool.parameters);
if (!validation.ok) {
return {
success: false,
data: "参数校验失败:\n" + validation.issues.map((i) => i.message).join("\n"),
error: "INVALID_ARGS",
issues: validation.issues,
};
}
// ③ 可选:权限闸检查(若有统一策略接入点;不传则这一段跳过)
const gate = dispatchOptions.gate; // 调用上下文持有的 Gate,本节不依赖具体实现
if (gate && !(await gate.check(toolName, rawArgs))) {
return {
success: false,
data: `权限拒绝:${gate.lastDenial?.reason ?? "未知原因"}`,
error: "GATE_DENIED",
denial: gate.lastDenial,
};
}
// ④ 准备上下文
const ctx: ToolContext = {
cwd: process.cwd(),
signal: externalSignal,
timeout: 30_000,
writeRoots: config.writeRoots, // 可选
};
// ⑤ 执行
try {
const result = await tool.execute(rawArgs, ctx);
// ⑥ 任何抛异常都会被 registry.execute 包成 EXECUTION_ERROR(见 registry.ts)
return result;
} catch (err) {
return {
success: false,
data: `异常:${(err as Error).message}`,
error: "EXECUTION_ERROR",
};
}
这就是核心调度逻辑的全部------剩下的都是工具自己的事。
10. 真实工具的例子:edit_file 是怎么"按规矩"写的
挑 edit_file 来讲,因为它涉及沙箱、diff、eol 三个横切关注,最能说明问题:
typescript
// src/tool/builtins/edit-file.ts(节选)
export const editFileTool: AgentTool<EditFileArgs> = {
name: "edit_file",
kind: ToolKind.Edit,
description: "对文件进行精确字符串替换...",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "..." },
old_text: { type: "string", description: "..." },
new_text: { type: "string", description: "..." },
},
required: ["path", "old_text", "new_text"],
additionalProperties: false, // ← 防止 LLM 给多余字段
},
async execute(args, ctx) {
// ① 参数快检(防御性,正式校验在调度期)
if (!args.path) return { success: false, data: "缺少 path", error: "INVALID_ARGS" };
if (typeof args.old_text !== "string") return { success: false, data: "...", error: "INVALID_ARGS" };
if (typeof args.new_text !== "string") return { success: false, data: "...", error: "INVALID_ARGS" };
// ② 路径解析
const filePath = resolvePath(args.path, ctx.cwd);
// ③ 沙箱:写操作必须在白名单根下
if (ctx.writeRoots && ctx.writeRoots.length > 0) {
const conf = await confine(ctx.writeRoots, filePath);
if (!conf.ok) return { success: false, data: conf.error, error: "OUTSIDE_WRITE_ROOTS" };
}
// ④ 读 + LF 归一(原文件可能是 CRLF,LLM 习惯用 LF)
const content = await readFile(filePath, "utf-8");
const contentN = toLf(content);
const oldTextN = toLf(args.old_text);
// ⑤ 唯一性匹配 ------ 出现 0 次或 2+ 次都报错
const first = contentN.indexOf(oldTextN);
if (first === -1) return { success: false, data: "未找到...", error: "TEXT_NOT_FOUND" };
if (contentN.indexOf(oldTextN, first + 1) !== -1) {
return { success: false, data: "出现多次...", error: "TEXT_MULTIPLE_MATCHES" };
}
// ⑥ 替换并恢复原 EOL
const newContentN =
contentN.slice(0, first) +
toLf(args.new_text) +
contentN.slice(first + oldTextN.length);
const writtenContent = normalizeEol(content, newContentN);
await writeFile(filePath, writtenContent, "utf-8");
// ⑦ 算 diff 给 UI
const diff = computeFileDiff(content, writtenContent, filePath);
return {
success: true,
data: `文件已编辑:${filePath}\n替换位置:第 ${startLine} 行\n变更:${diff.additions} +/ ${diff.deletions} -`,
summary: `📝 修改: ${basename(filePath)} (+${diff.additions} -${diff.deletions})`,
diff,
};
},
};
值得点名的几个细节:
additionalProperties: false------ 防止 LLM 给出多余字段,这些字段多半是幻觉(LLM 不该给的)。- 双重错误码 ------
TEXT_NOT_FOUND和TEXT_MULTIPLE_MATCHES是两个完全不同的情况,LLM 的应对策略不一样(前者读文件确认 old_text,后者加上下文),所以必须细分。 - CRLF/LF 归一化 ------ 这是 Windows 用户最痛的点。
toLf抹平 EOL 后做匹配,normalizeEol把替换回写到原文件时还原 EOL,LLM 不会因为平台差异被反复绊倒。 - diff 是结果的一部分,不是副作用 ------ 算 diff 只多几十毫秒,但给 UI 的体验巨大提升(改动一目了然)。
11. Trade-offs 和踩过的坑
坑 1:Zod 引入的代价到底有多大?
zod 大约 50KB(gzip 前)。如果你的项目坚决不用 Zod,完全可以用纯 JSONSchema 撑住------8 个内置工具都是 JSONSchema,跑了半年没问题。
但是 ,每加一个新工具,都得写三遍(JSONSchema + TS interface + 校验),同步改造特别累。Zod-first 一次写对,光节省的人力时间就把那 50KB 赚回来了。
正确选择:新工具用 Zod-first,旧工具不动,interface 全留 。我们就是这么干的(_examples/read-file-zod.ts 就是给后人看的样板)。
坑 2:工具抛异常应该被吞掉
如果工具自己 throw new Error,Registry 会拦下来包成 EXECUTION_ERROR。这看起来"温柔",但实际上是保护工具作者------任何工具忘了 try/catch 都不会让整个 agent 崩。
反面教材:有的 agent 框架让异常往上冒,工具作者必须严格 try/catch。结果就是大家互相甩锅"你那边 throw 了"。
坑 3:并行读真的安全吗?
read_file 标 Read,理论上可并行 。但如果 LLM 在同一轮里读了 foo.ts 又读了它产生的日志------逻辑上有依赖。模型自己会 sort 这个依赖,我们不需要工具系统管。
反过来 ,写工具被模型标 Read(想偷懒)怎么办?------别担心,LLM 不会犯这种错 ,因为写工具的 kind: ToolKind.Edit 是声明的,如果模型调用它,我们会按 Edit 走串行逻辑。这是 schema 校验而不是 kindness 校验。
坑 4:ToolResult.data 不要塞对象
typescript
// ❌ 错误:LLM 看到一堆 JSON 不认识
return { success: true, data: JSON.stringify(someComplexObject) };
// ✓ 正确:人类语言描述 + 关键事实
return {
success: true,
data: `找到 3 个匹配:\n1. src/a.ts:12 → const foo = bar\n2. src/b.ts:5 → ...`,
};
LLM 对"自然语言 + 编号列表"特别敏感,对纯 JSON 描述经常误读。data 字段默认是给 LLM 看的,按 LLM 友好的方式写。
坑 5:工具 description 是给 LLM 看的 system prompt
我开始以为 description 是给开发者看的文档,后来发现------它是 LLM 决定"调不调你"的依据。写得糟糕的 description 会让 LLM 永远不调你(或者乱调)。
好实践:
- 明确说适用场景 :
"适用于查看源代码、配置文件等文本文件"比~读取文件~有用。 - 明确说禁用场景 :
"不要用 cat/type 读文件,请用 read_file"------ 抢同类工具的生态位。 - 指明错误修正路径 :
"如果 old_text 出现多次,请提供更多上下文"------ 直接喂给 LLM 当 hint。
坑 6:@ 引用标记这玩意儿得尽早统一
我看见有人让每个工具自己 if (path.startsWith("@")) path = path.slice(1),结果有两个工具忘了 strip,系统 prompt 提的 @xxx.ts 引用语法在它们那里就废了。
typescript
// 解决方案:把 stripMentionPrefix 注入 resolvePath,谁都绕不开
export function resolvePath(inputPath: string, cwd: string): string {
const stripped = stripMentionPrefix(inputPath); // ← 必经一步
// ...
}
横切关注,集中做,做一次。所有写文件路径解析的地方都该走 resolvePath ,而不是自己 path.resolve(cwd, args.path)。
12. 结语:工具系统的"宪法"
回头看整套设计,会发现它是几条通用原则的具象化:
- 统一契约 ------
AgentTool.execute(args, ctx)这一种调用方式撑住所有 8+ 个工具 - 类型擦除 ------
AnyAgentTool+eraseTool,让"找工具"与"用工具"在类型层面解耦 - 集中策略 ------ 沙箱
confine永远是最后一道闸,谁也不许自己再写一份 - 同构结果 ------
ToolResult.success/data/error同一形状,LLM 看到的成功和失败都是字符串描述 - 默认策略 ------
ToolKind.Other兜底所有非文件类工具
这套工具系统自己内部也"宪法"级的几条规矩:
- 关注点分离 ------ 沙箱归沙箱、校验归校验、调用归调用,不许塞在一起
- 同构协议 ------ 成功/失败、Zod/JSONSchema、内置/UDF 必须统一接口
- 可降级而非崩溃 ------ schema 错了给 issue 让 LLM 重试、文件越界给 error code 让 UI 提示、异常被
Registry.execute包成EXECUTION_ERROR而不是炸进程 - 横切关注必须集中 ------ 路径、超时、输出截断、EOL、diff 谁写一次就行,不允许每个工具重抄一遍
- 类型安全可降级 ------ 每个工具自己保留强类型,统一到 Registry 时擦成
AnyAgentTool,调用方按名字回断言;代价是 args 走 schema 校验二次防护
落到代码上,核心模块只有 ~1200 行:types.ts 200 行 / registry.ts 130 行 / sandbox.ts 200 行 / schema-validator.ts 250 行 / zod-schema-validator.ts 400 行。再加 8 个内置工具共 ~1500 行。
这套设计可以直接拷过去用------任何"让 LLM 调工具"的场景都能套。哪天你看到自己 agent 在第 30 个工具上线后代码量没翻倍、可读性没变差、错误处理不混乱------就该知道这不到 2000 行代码值了。
进一步阅读
- 类型擦除参考:TypeScript: Type Erasure in Generic Containers
- Schema 双轨参考:Zod vs JSON Schema ------ Zod 自身有
z.toJSONSchema()转换 - 类似设计:
- LangChain 的 Tool / ToolExecutor ------ 异曲同工的
name + description + func(args)三件套 - Vercel AI SDK 的 Tool Definition ------ 用 Zod-first 思路,把工具定义和 provider 解耦
- Anthropic Computer Use 的 tool_use block ------ 服务端校验的兜底
- LangChain 的 Tool / ToolExecutor ------ 异曲同工的
有问题随时留言。觉得有用的话,顺手点个 收藏 ⭐ ~