跪拜 Guibai
← Back to the summary

TypeBox Catches LLM Tool-Call Arguments That Are Valid JSON but Wrong Types

This article is the fifth in the "Developing a Coding Agent from Scratch" series. In the previous article, we used a state machine to validate the large model's event stream, ensuring the correct order of tool_call_start, tool_call_delta, and tool_call_end, and that the final parameters are consistent with the streamed JSON.

But "it is a valid JSON object" does not mean "it meets the tool's requirements." For example, the read tool requires limit to be a number, but the model might return:

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

This JSON is syntactically correct, and the root node is an object, but the string "20" cannot be used directly as a number. If it is not intercepted before tool execution, the error will enter code with real side effects like file reading and command execution.

This article will use TypeBox to define the parameter structure for each tool and implement two validation entry points:

Model-returned JSON string -> parseToolArguments()
Already parsed unknown object -> validateToolArguments()
                              |
                              v
                    ToolDefinition.parameters
                              |
                     Pass -> Accurately typed parameters
                     Fail -> ToolArgumentsValidationError

First, understand the relevant types in the ai module

Tool parameter validation is located in packages/ai, because this defines the base contract that the Provider, Agent, and specific tools all adhere to. First, open:

packages/ai/src/types.ts

The first type directly relevant to this article is ToolCallContent:

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

It represents that the model has generated a complete tool call:

Here, arguments is written as Record<string, unknown> instead of a specific tool's parameter type because ToolCallContent needs to represent different tools like read, write, bash, etc. At this point, we can only determine it is a key-value object, not whether each field is correct.

The specific tool's parameter requirements are described by ToolDefinition:

import type { TSchema } from "typebox";

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

TParameters is a generic parameter representing the current tool's own TypeBox schema. Generics allow different tools to retain their own parameter structures instead of all degrading to a vague TSchema.

Tool definitions are passed to the Provider via Context.tools:

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

The Provider sends these tool descriptions to the large model. After the model selects a tool, it gradually returns call information through the following three stream events:

| { 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 }

Therefore, this data chain can be summarized as:

ToolDefinition.parameters
    -> Provider tells the model what parameters the tool needs
    -> Model returns ToolCallContent.arguments
    -> Parameter validator checks against the same parameters schema
    -> Only after passing is entry into the subsequent tool execution flow allowed

What is TypeBox

TypeScript types only exist during compilation. The following type assertion does not check runtime data:

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

If externalValue.limit is actually a string, TypeScript will not help us convert or reject it.

TypeBox is a TypeScript library for building JSON Schema. Its characteristic is that the same schema can serve two phases simultaneously:

  1. Compilation phase: Derive TypeScript types.
  2. Runtime phase: Check the actual JavaScript values received.

For example, defining a simplified read tool:

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 is a JSON Schema object at runtime. Static<typeof readParameters> will yield the following type at compile time:

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

Common constructors include:

TypeBox Syntax Represented Parameter
Type.String() String
Type.Number() Number
Type.Boolean() Boolean
Type.Array(Type.String()) Array of strings
Type.Optional(...) Optional field
Type.Object({ ... }) Object and its fields

Constraints can also be expressed through options. For example, minLength: 1 means the string cannot be empty, and minimum: 1 means the number cannot be less than 1.

This project installs the fixed version [email protected]. The runtime validator is imported from 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

This project only uses strict checking, without implicit type conversion. That is, when the schema requires a number, the string "20" will be rejected, not silently converted to the number 20. This allows early exposure of protocol errors from the model or Provider.

Define tool parameter errors

Continue modifying the file created in the previous article:

packages/ai/src/utils/validation.ts

The first half of this file already contains the event stream state machine. The code in this article is appended after createStreamEventValidator(), without needing to modify the original state machine.

First, define a dedicated error type:

/** Indicates that a set of tool parameters cannot safely satisfy the tool's 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];
	}
}

This error retains two types of structured information:

For example, when read is missing path, the final error looks like:

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

The error message should not include the entire original parameters. Tool parameters may contain long text, user input, or even sensitive information in the future; keeping only the tool name, field path, and failure reason is more suitable for transmission and logging.

Format TypeBox error paths

Add the imports needed for this article at the top of the file:

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";

Where:

TypeBox uses JSON Pointer to represent field locations, for example:

/path
/options/labels/0

JSON Pointer specifies that ~ in field names should be written as ~0, and / should be written as ~1. First, add the escape function:

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

Then uniformly format TypeBox errors:

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}`;
}

When a required field is missing, the field itself does not exist, so TypeBox's instancePath usually only points to its parent object, with the missing field name located in requiredProperties. Here, the two parts are concatenated to ensure that when the root object is missing path, it reports /path instead of the ambiguous root path /.

First, constrain the parameter root node

The TypeBox schema is responsible for checking specific fields. Before that, we establish a more fundamental boundary: the root node of tool parameters must be a plain object.

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;
}

This function will reject:

Model tool parameters should be a plain JSON-style object, not a class instance with methods or hidden behaviors.

Next, create a deep copy:

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",
		]);
	}
}

Why call structuredClone() before validation? Look at the following example:

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

input.limit = -1;

If validated and input point to the same object, the caller can still change it to an illegal value after validation. Cloning first and then checking separates the return value from external mutable references.

If the object contains values that cannot be cloned, such as functions, structuredClone() will throw a native exception. Here, it is uniformly converted to ToolArgumentsValidationError to avoid the upper layer needing to identify native errors from different platforms.

It should be noted that cloning is not permission control. It does not judge whether a file path is out of bounds, nor does it restrict commands, timeouts, or side effects; these rules belong to the responsibilities of specific tools and the Agent Loop later on.

Implement object parameter validation

Now implement the first public entry point 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"],
	);
}

Its processing order is:

  1. Accept unknown, not trusting external data prematurely.
  2. Confirm the root node is a plain object and create a deep copy.
  3. Use Compile(tool.parameters) to create a validator.
  4. Use Check(candidate) to perform strict validation.
  5. On failure, collect and format all issues.
  6. On success, return Static<TParameters>.

Check() not only returns a boolean value; it is also a type guard. In the success branch of the if, TypeScript can confirm that candidate conforms to the current schema, so there is no need to write the following dangerous assertion:

return candidate as Static<TParameters>;

This is precisely the value of TypeBox: runtime checking and compile-time types are connected by the same schema.

Errors from Compile(tool.parameters) itself are also not caught here. An invalid schema is a developer configuration error, not a model parameter error; the two should maintain different error semantics.

Implement the JSON string entry point

Tool parameters arrive as JSON string fragments during streaming. The previous article would parse the complete string into an object at tool_call_end, but other Provider adapters might also directly receive a complete JSON string, so a unified entry point is provided:

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);
}

This function only adds a layer of JSON syntax processing; after successful parsing, it immediately reuses validateToolArguments(). This way, both entry points share the exact same root object restrictions, schema rules, cloning behavior, and error format.

JSON.parse() returns any in TypeScript's standard declaration. Using as unknown here is not claiming the data is already valid, but tightening the unsafe any back to unknown, forcing it to continue through the subsequent runtime validation.

The complete data flow is now:

flowchart LR
	Json["Model Parameter JSON"] --> Parse["JSON.parse"]
	Object["Existing Parameter Object"] --> Clone["Plain Object Check and Deep Copy"]
	Parse --> Clone
	Clone --> Compile["Compile(parameters)"]
	Compile --> Check{"Check(candidate)"}
	Check -->|Pass| Typed["Static"]
	Check -->|Fail| Issues["Errors(candidate)"]
	Issues --> Error["ToolArgumentsValidationError"]

Export from the ai package entry point

Finally, modify:

packages/ai/src/index.ts

Keep the original exports and add the three new symbols to the public exports of validation.ts:

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

formatValidationIssue(), isPlainObject(), and cloneToolArguments() are all internal implementation details and do not need to be exposed from the package entry point.

The project entry point has already re-exported commonly used symbols from TypeBox:

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

This way, when defining specific tools later, schema constructors and public types can be uniformly obtained from @di-code/ai, without depending on the internal file paths of the ai package.

Define and validate a tool

Below, a simplified read tool is used to connect the previous types and functions:

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 checks that the object conforms to ToolDefinition while preserving the specific schema type of parameters. Thus, the return value after successful validation also retains accurate fields:

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

If limit is a string, path is empty, a required field is missing, or the JSON syntax is incorrect, the entry points will throw ToolArgumentsValidationError instead of passing problematic parameters to the subsequent execution layer.

Summary

This article adds a second validation boundary for tool calls:

Currently, we have only established a parameter safety boundary and have not yet actually looked up and executed tools. When implementing the Agent tool call loop later, the Agent will first find the ToolDefinition by name, then call the validation functions from this article, and finally pass the parameters to the specific tool.

This chapter's git branch address: eventstream-validation

If you are also interested in Agent development, welcome to like, bookmark, and follow. Column: Developing a Coding Agent from Scratch - Dongfang Xiaoyue's Column - Juejin