从零开发一个 Coding Agent(五):使用 TypeBox 校验工具参数

0 阅读9分钟

本篇文章是《从零开发一个 Coding Agent》系列第五篇。在上一篇中,我们使用状态机校验了大模型事件流,确保 tool_call_starttool_call_deltatool_call_end 的顺序正确,并且最终参数与流式传输的 JSON 一致。

但“它是一个合法 JSON 对象”不等于“它符合工具要求”。例如 read 工具要求 limit 是数字,模型却可能返回:

{
	"path": "README.md",
	"limit": "20"
}

这段 JSON 语法正确,根节点也是对象,但是字符串 "20" 不能直接当作数字使用。如果不在工具执行前拦截,错误就会进入文件读取、命令执行等真正有副作用的代码。

这一篇将使用 TypeBox 为每个工具定义参数结构,并实现两个校验入口:

模型返回的 JSON 字符串 -> parseToolArguments()
已经解析出的未知对象   -> validateToolArguments()
                              |
                              v
                    ToolDefinition.parameters
                              |
                    通过 -> 有准确类型的参数
                    失败 -> ToolArgumentsValidationError

先了解 ai 模块中的相关类型

工具参数校验位于 packages/ai,因为这里定义的是 Provider、Agent 和具体工具共同遵守的基础契约。先打开:

packages/ai/src/types.ts

本篇直接相关的第一个类型是 ToolCallContent

export interface ToolCallContent {
	type: "tool_call";
	id: string;
	name: string;
	arguments: Record<string, unknown>;
}

它表示模型已经生成了一次完整工具调用:

  • id 是本次调用的唯一标识,后续工具结果要通过它与调用对应。
  • name 是模型希望调用的工具名称,例如 read
  • arguments 是模型提供的参数。

这里把 arguments 写成 Record<string, unknown>,而不是某个具体工具的参数类型,是因为 ToolCallContent 需要同时表示 readwritebash 等不同工具。此时只能确定它是一个键值对象,还不能确定每个字段是否正确。

具体工具对参数的要求由 ToolDefinition 描述:

import type { TSchema } from "typebox";

export interface ToolDefinition<TParameters extends TSchema = TSchema> {
	name: string;
	description: string;
	parameters: TParameters;
}

TParameters 是一个泛型参数,表示当前工具自己的 TypeBox schema。泛型让不同工具保留各自的参数结构,而不是全部退化成模糊的 TSchema

工具定义会通过 Context.tools 交给 Provider:

export interface Context {
	systemPrompt?: string;
	messages: Message[];
	tools?: ToolDefinition[];
}

Provider 把这些工具描述发送给大模型。模型选择工具后,通过下面三种流事件逐步返回调用信息:

| { type: "tool_call_start"; contentIndex: number; id: string; name: string }
| { type: "tool_call_delta"; contentIndex: number; argumentsDelta: string }
| { type: "tool_call_end"; contentIndex: number; toolCall: ToolCallContent }

因此,这条数据链可以概括为:

ToolDefinition.parameters
    -> Provider 告诉模型工具需要哪些参数
    -> 模型返回 ToolCallContent.arguments
    -> 参数校验器按同一份 parameters schema 检查
    -> 通过后才允许进入后续工具执行流程

TypeBox 是什么

TypeScript 类型只在编译期间存在。下面这句类型断言不会检查运行时数据:

const argumentsValue = externalValue as { path: string; limit: number };

如果 externalValue.limit 实际是字符串,TypeScript 也不会帮我们转换或拒绝它。

TypeBox 是一个用于构建 JSON Schema 的 TypeScript 库。它的特点是同一份 schema 可以同时服务于两个阶段:

  1. 编译阶段:推导出 TypeScript 类型。
  2. 运行阶段:检查真正收到的 JavaScript 值。

例如定义一个简化的 read 工具:

import { Type } from "typebox";
import type { Static } from "typebox";

const readParameters = Type.Object({
	path: Type.String({ minLength: 1 }),
	limit: Type.Number({ minimum: 1 }),
});

type ReadArguments = Static<typeof readParameters>;

readParameters 在运行时是一个 JSON Schema 对象。Static<typeof readParameters> 则会在编译期得到下面的类型:

type ReadArguments = {
	path: string;
	limit: number;
};

常用构造器包括:

TypeBox 写法表示的参数
Type.String()字符串
Type.Number()数字
Type.Boolean()布尔值
Type.Array(Type.String())字符串数组
Type.Optional(...)可选字段
Type.Object({ ... })对象及其字段

还可以通过选项表达约束。例如 minLength: 1 表示字符串不能为空,minimum: 1 表示数字不能小于 1。

本项目安装的是固定版本 typebox@1.1.38。运行时校验器从 typebox/compile 导入:

import { Compile } from "typebox/compile";

const validator = Compile(readParameters);

validator.Check({ path: "README.md", limit: 20 }); // true
validator.Check({ path: "README.md", limit: "20" }); // false

本项目只使用严格检查,不使用隐式类型转换。也就是说,schema 要求数字时,字符串 "20" 会被拒绝,而不会悄悄变成数字 20。这样能够尽早暴露模型或 Provider 的协议错误。

定义工具参数错误

继续修改上一篇创建的文件:

packages/ai/src/utils/validation.ts

这个文件前半部分已经包含事件流状态机。本篇代码追加在 createStreamEventValidator() 后面,不需要修改原来的状态机。

先定义一个专用错误类型:

/** 表示一组工具参数无法安全地满足该工具的 TypeBox schema。 */
export class ToolArgumentsValidationError extends Error {
	readonly toolName: string;
	readonly issues: readonly string[];

	constructor(toolName: string, issues: readonly string[]) {
		const details = issues.map((issue) => `  - ${issue}`).join("\n");
		super(`Invalid arguments for tool "${toolName}":\n${details}`);
		this.name = "ToolArgumentsValidationError";
		this.toolName = toolName;
		this.issues = [...issues];
	}
}

这个错误保留两类结构化信息:

  • toolName:哪个工具的参数不合法。
  • issues:具体有哪些字段或约束失败。

例如 read 缺少 path 时,最终错误类似:

Invalid arguments for tool "read":
  - /path: must have required properties path

错误消息不应该附带整个原始参数。工具参数未来可能包含长文本、用户输入甚至敏感信息;只保留工具名、字段路径和失败原因,更适合传递和记录。

格式化 TypeBox 错误路径

在文件顶部增加本篇需要的 import:

import type { Static, TSchema } from "typebox";
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import type { AssistantContent, StreamEvent, ToolDefinition } from "../types.ts";

其中:

  • TSchema 表示任意 TypeBox schema。
  • Static<TSchema> 用于得到 schema 对应的 TypeScript 类型。
  • Compile() 把 schema 编译成运行时校验器。
  • TLocalizedValidationError 是 TypeBox 返回的错误对象类型。

TypeBox 使用 JSON Pointer(JSON 指针)表示字段位置,例如:

/path
/options/labels/0

JSON Pointer 规定,字段名中的 ~ 要写成 ~0/ 要写成 ~1。先加入转义函数:

function escapeJsonPointerSegment(segment: string): string {
	return segment.replace(/~/g, "~0").replace(/\//g, "~1");
}

然后统一格式化 TypeBox 的错误:

function formatValidationIssue(error: TLocalizedValidationError): string {
	if (error.keyword === "required") {
		const requiredProperty = error.params.requiredProperties[0];
		if (requiredProperty) {
			const segment = escapeJsonPointerSegment(requiredProperty);
			return `${error.instancePath}/${segment}: ${error.message}`;
		}
	}

	const path = error.instancePath || "/";
	return `${path}: ${error.message}`;
}

缺少必填字段时,字段本身还不存在,所以 TypeBox 的 instancePath 通常只指向它的父对象,缺失的字段名位于 requiredProperties 中。这里把两部分拼起来,确保根对象缺少 path 时报告 /path,而不是含糊的根路径 /

先限制参数根节点

TypeBox schema 负责检查具体字段。在此之前,我们先建立一个更基础的边界:工具参数的根节点必须是普通对象。

function isPlainObject(value: unknown): value is Record<string, unknown> {
	if (typeof value !== "object" || value === null || Array.isArray(value)) {
		return false;
	}

	const prototype = Object.getPrototypeOf(value);
	return prototype === Object.prototype || prototype === null;
}

这个函数会拒绝:

  • null
  • 数组
  • Date
  • Map
  • 其他拥有特殊原型的实例

模型工具参数应当是一个普通的 JSON 风格对象,而不是带有方法或隐藏行为的类实例。

接着创建一份深拷贝:

function cloneToolArguments(toolName: string, value: unknown): Record<string, unknown> {
	if (!isPlainObject(value)) {
		throw new ToolArgumentsValidationError(toolName, ["/: arguments must be a plain object"]);
	}

	try {
		return structuredClone(value);
	} catch {
		throw new ToolArgumentsValidationError(toolName, [
			"/: arguments must contain only structured-clone-compatible values",
		]);
	}
}

为什么要在校验前调用 structuredClone()?看下面的例子:

const input = { path: "README.md", limit: 20 };
const validated = validateToolArguments(readTool, input);

input.limit = -1;

如果 validatedinput 指向同一个对象,调用方在校验后仍能把它改成非法值。先克隆再检查,可以让返回值与外部可变引用分离。

如果对象中含有函数等无法克隆的值,structuredClone() 会抛出原生异常。这里把它统一转换为 ToolArgumentsValidationError,避免上层需要识别不同平台的原生错误。

需要注意,克隆不是权限控制。它不会判断文件路径是否越界,也不会限制命令、超时或副作用;这些规则属于以后具体工具和 Agent Loop 的职责。

实现对象参数校验

现在实现第一个公共入口 validateToolArguments()

export function validateToolArguments<TParameters extends TSchema>(
	tool: ToolDefinition<TParameters>,
	value: unknown,
): Static<TParameters> {
	const candidate = cloneToolArguments(tool.name, value);
	const validator = Compile(tool.parameters);

	if (validator.Check(candidate)) {
		return candidate;
	}

	const issues = validator.Errors(candidate).map(formatValidationIssue);
	throw new ToolArgumentsValidationError(
		tool.name,
		issues.length > 0 ? issues : ["/: arguments do not satisfy the tool schema"],
	);
}

它的处理顺序是:

  1. 接受 unknown,不提前相信外部数据。
  2. 确认根节点是普通对象,并创建深拷贝。
  3. 使用 Compile(tool.parameters) 创建校验器。
  4. 使用 Check(candidate) 执行严格校验。
  5. 失败时收集并格式化全部问题。
  6. 成功时返回 Static<TParameters>

Check() 不只是返回布尔值,它还是一个类型守卫。在 if 的成功分支中,TypeScript 能确认 candidate 符合当前 schema,因此不需要写下面这种危险断言:

return candidate as Static<TParameters>;

这正是 TypeBox 的价值:运行时检查与编译期类型由同一份 schema 连接起来。

这里也没有捕获 Compile(tool.parameters) 自己的错误。无效 schema 是开发者配置错误,不是模型参数错误;两者应保持不同的错误语义。

实现 JSON 字符串入口

工具参数在流式传输过程中以 JSON 字符串片段到达。上一篇会在 tool_call_end 时把完整字符串解析成对象,不过其他 Provider 适配器也可能直接拿到完整 JSON 字符串,因此再提供一个统一入口:

export function parseToolArguments<TParameters extends TSchema>(
	tool: ToolDefinition<TParameters>,
	json: string,
): Static<TParameters> {
	let value: unknown;
	try {
		value = JSON.parse(json) as unknown;
	} catch {
		throw new ToolArgumentsValidationError(tool.name, ["/: arguments must be valid JSON"]);
	}

	return validateToolArguments(tool, value);
}

这个函数只增加一层 JSON 语法处理,解析成功后立即复用 validateToolArguments()。这样两个入口共享完全相同的根对象限制、schema 规则、克隆行为和错误格式。

JSON.parse() 在 TypeScript 的标准声明中返回 any。这里使用 as unknown 不是声称数据已经合法,而是把不安全的 any 收紧回 unknown,强制它继续经过后面的运行时校验。

完整数据流现在是:

flowchart LR
	Json[&#34;模型参数 JSON&#34;] --> Parse[&#34;JSON.parse&#34;]
	Object[&#34;已有参数对象&#34;] --> Clone[&#34;普通对象检查与深拷贝&#34;]
	Parse --> Clone
	Clone --> Compile[&#34;Compile(parameters)&#34;]
	Compile --> Check{&#34;Check(candidate)&#34;}
	Check -->|通过| Typed[&#34;Static&#34;]
	Check -->|失败| Issues[&#34;Errors(candidate)&#34;]
	Issues --> Error[&#34;ToolArgumentsValidationError&#34;]

从 ai 包入口导出

最后修改:

packages/ai/src/index.ts

保留原有导出,并把三个新符号加入 validation.ts 的公共导出:

export {
	createStreamEventValidator,
	parseToolArguments,
	StreamSequenceError,
	ToolArgumentsValidationError,
	validateToolArguments,
} from "./utils/validation.ts";

formatValidationIssue()isPlainObject()cloneToolArguments() 都是内部实现细节,不需要从包入口暴露。

项目入口已经重新导出了 TypeBox 的常用符号:

export type { Static, TSchema } from "typebox";
export { Type } from "typebox";

这样以后定义具体工具时,可以统一从 @di-code/ai 获取 schema 构造器和公共类型,不必依赖 ai 包内部文件路径。

定义并校验一个工具

下面用一个简化的 read 工具串起前面的类型和函数:

import {
	parseToolArguments,
	Type,
	type ToolDefinition,
	validateToolArguments,
} from "@di-code/ai";

const parameters = Type.Object({
	path: Type.String({ minLength: 1 }),
	limit: Type.Number({ minimum: 1 }),
	options: Type.Object({
		labels: Type.Array(Type.String()),
	}),
});

const readTool = {
	name: "read",
	description: "Read a text file",
	parameters,
} satisfies ToolDefinition<typeof parameters>;

satisfies 会检查对象符合 ToolDefinition,同时保留 parameters 的具体 schema 类型。于是校验成功后的返回值也会保留准确字段:

const fromObject = validateToolArguments(readTool, {
	path: "README.md",
	limit: 20,
	options: { labels: ["docs"] },
});

const fromJson = parseToolArguments(
	readTool,
	'{"path":"README.md","limit":20,"options":{"labels":["docs"]}}',
);

console.log(fromObject.path); // string
console.log(fromJson.limit); // number

如果 limit 是字符串、path 为空、必填字段缺失,或者 JSON 语法错误,入口都会抛出 ToolArgumentsValidationError,而不会把问题参数交给后续执行层。

总结

这一篇为工具调用增加了第二层校验边界:

  • 上一篇的状态机保证工具调用事件顺序正确,流式 JSON 前后一致。
  • 本篇的 TypeBox 校验保证完整参数满足具体工具的字段和约束。
  • ToolDefinition<TParameters> 保存 schema 的具体类型。
  • Static<TParameters> 把校验成功的结果变成准确的 TypeScript 类型。
  • validateToolArguments() 处理已经解析的未知对象。
  • parseToolArguments() 处理完整 JSON 字符串,并复用同一套对象校验。
  • ToolArgumentsValidationError 提供稳定、可识别且不泄漏原始参数的错误信息。

当前我们只是建立了参数安全边界,还没有真正查找和执行工具。后续实现 Agent 工具调用闭环时,Agent 会先根据名称找到 ToolDefinition,再调用本篇的校验函数,最后才把参数交给具体工具。

本章节git分支地址:eventstream-validation

如果你对Agent开发也感兴趣,欢迎点赞收藏+关注。专栏:从零开发一个Coding Agent - 东方小月的专栏 - 掘金