Files
openclaw/packages/llm-core/src/validation.test.ts
liuhao1024 9202dbb1b6 fix(llm): coerce stringified JSON arrays/objects in tool argument validation (fixes #96916) (AI-assisted) (#96922)
* fix(llm): coerce stringified JSON arrays/objects in tool argument validation

When LLMs serialize array or object tool parameters as JSON strings
(e.g. tags: '["test","debug"]' instead of tags: ["test","debug"]),
validateToolArguments now attempts JSON.parse coercion before
rejecting the value. This mirrors the existing numeric string
coercion path and fixes MCP tool calls from providers like MiMo,
Ollama, and others that stringify complex parameters.

Fixes #96916

* fix(llm): bound JSON.parse size for schema-gated array/object coercion

Add MAX_JSON_COERCE_LENGTH (64KB) guard before JSON.parse in the array
and object coercion branches. Oversized stringified arguments are left
for normal validation to reject rather than synchronously parsed.

Addresses Codex review finding: unbounded JSON.parse on model-controlled
tool arguments could block the event loop or spike memory.
2026-06-28 18:32:59 -07:00

194 lines
5.4 KiB
TypeScript

// LLM Core tests cover validation behavior.
import { describe, expect, it } from "vitest";
import type { Tool } from "./types.js";
import { validateToolArguments } from "./validation.js";
const decimalTool = {
name: "decimal-tool",
description: "test tool",
parameters: {
type: "object",
properties: {
amount: { type: "number" },
count: { type: "integer" },
},
required: ["amount", "count"],
additionalProperties: false,
},
} as Tool;
describe("validateToolArguments", () => {
it("coerces strict decimal numeric strings for plain JSON schemas", () => {
expect(
validateToolArguments(decimalTool, {
type: "toolCall",
id: "call-1",
name: "decimal-tool",
arguments: { amount: "1e3", count: "+3" },
}),
).toEqual({ amount: 1000, count: 3 });
});
it("rejects non-decimal numeric strings for plain JSON schemas", () => {
expect(() =>
validateToolArguments(decimalTool, {
type: "toolCall",
id: "call-1",
name: "decimal-tool",
arguments: { amount: "0x10", count: "0b10" },
}),
).toThrow(/Validation failed for tool "decimal-tool"/);
});
it("preserves null in anyOf [{type: string}, {type: null}] without coercing to empty string (#96716)", () => {
const tool = {
name: "nullable-tool",
description: "test tool",
parameters: {
type: "object",
properties: {
insight_id: { anyOf: [{ type: "string" }, { type: "null" }] },
cluster_name: { type: "string" },
},
required: ["cluster_name"],
additionalProperties: false,
},
} as Tool;
expect(
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "nullable-tool",
arguments: { insight_id: null, cluster_name: "testenv" },
}),
).toEqual({ insight_id: null, cluster_name: "testenv" });
});
});
const arrayTool = {
name: "array-tool",
description: "test tool with array param",
parameters: {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
},
required: ["tags"],
additionalProperties: false,
},
} as Tool;
const objectTool = {
name: "object-tool",
description: "test tool with object param",
parameters: {
type: "object",
properties: {
config: {
type: "object",
properties: {
enabled: { type: "boolean" },
retries: { type: "number" },
},
},
},
required: ["config"],
additionalProperties: false,
},
} as Tool;
describe("validateToolArguments — stringified JSON coercion", () => {
it("coerces stringified JSON array to array for plain JSON schemas", () => {
expect(
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-2",
name: "array-tool",
arguments: { tags: '["test","debug"]' },
}),
).toEqual({ tags: ["test", "debug"] });
});
it("coerces stringified JSON object to object for plain JSON schemas", () => {
expect(
validateToolArguments(objectTool, {
type: "toolCall",
id: "call-3",
name: "object-tool",
arguments: { config: '{"enabled":true,"retries":3}' },
}),
).toEqual({ config: { enabled: true, retries: 3 } });
});
it("passes through valid arrays unchanged", () => {
expect(
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-4",
name: "array-tool",
arguments: { tags: ["already", "array"] },
}),
).toEqual({ tags: ["already", "array"] });
});
it("passes through valid objects unchanged", () => {
expect(
validateToolArguments(objectTool, {
type: "toolCall",
id: "call-5",
name: "object-tool",
arguments: { config: { enabled: false, retries: 1 } },
}),
).toEqual({ config: { enabled: false, retries: 1 } });
});
it("rejects invalid JSON string for array param", () => {
expect(() =>
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-6",
name: "array-tool",
arguments: { tags: "not-json" },
}),
).toThrow(/Validation failed for tool "array-tool"/);
});
it("rejects JSON string that is wrong type for array param", () => {
expect(() =>
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-7",
name: "array-tool",
arguments: { tags: '{"not":"array"}' },
}),
).toThrow(/Validation failed for tool "array-tool"/);
});
it("skips JSON coercion for oversized array string", () => {
const hugeArray = JSON.stringify(Array.from({ length: 100_000 }, (_, i) => i));
expect(hugeArray.length).toBeGreaterThan(64 * 1024);
expect(() =>
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-8",
name: "array-tool",
arguments: { tags: hugeArray },
}),
).toThrow(/Validation failed for tool "array-tool"/);
});
it("skips JSON coercion for oversized object string", () => {
const hugeObj = JSON.stringify({ data: "x".repeat(70_000) });
expect(hugeObj.length).toBeGreaterThan(64 * 1024);
expect(() =>
validateToolArguments(objectTool, {
type: "toolCall",
id: "call-9",
name: "object-tool",
arguments: { config: hugeObj },
}),
).toThrow(/Validation failed for tool "object-tool"/);
});
});