mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
9202dbb1b6
* 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.
194 lines
5.4 KiB
TypeScript
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"/);
|
|
});
|
|
});
|