mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(llm-core): guard tool argument schema validation
This commit is contained in:
@@ -38,4 +38,52 @@ describe("validateToolArguments", () => {
|
||||
}),
|
||||
).toThrow(/Validation failed for tool "decimal-tool"/);
|
||||
});
|
||||
|
||||
it("reports unreadable tool parameter schemas as unsupported", () => {
|
||||
const hostileTool = {
|
||||
name: "hostile-tool",
|
||||
description: "test tool",
|
||||
get parameters() {
|
||||
throw new Error("parameters getter exploded");
|
||||
},
|
||||
} as Tool;
|
||||
|
||||
expect(() =>
|
||||
validateToolArguments(hostileTool, {
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "hostile-tool",
|
||||
arguments: {},
|
||||
}),
|
||||
).toThrow(/Unsupported tool schema for "hostile-tool": unreadable schema at parameters/);
|
||||
});
|
||||
|
||||
it("reports unreadable nested JSON schemas as unsupported", () => {
|
||||
const properties: Record<string, unknown> = {};
|
||||
Object.defineProperty(properties, "amount", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error("schema properties exploded");
|
||||
},
|
||||
});
|
||||
const hostileTool = {
|
||||
name: "hostile-tool",
|
||||
description: "test tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties,
|
||||
},
|
||||
} as Tool;
|
||||
|
||||
expect(() =>
|
||||
validateToolArguments(hostileTool, {
|
||||
type: "toolCall",
|
||||
id: "call-1",
|
||||
name: "hostile-tool",
|
||||
arguments: { amount: "12" },
|
||||
}),
|
||||
).toThrow(
|
||||
/Unsupported tool schema for "hostile-tool": unreadable schema at parameters\.properties\.amount/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,73 @@ function isValidatorSchema(value: unknown): value is Tool["parameters"] {
|
||||
return isRecord(value);
|
||||
}
|
||||
|
||||
function unsupportedSchemaError(toolName: string, path: string): Error {
|
||||
return new Error(`Unsupported tool schema for "${toolName}": unreadable schema at ${path}`);
|
||||
}
|
||||
|
||||
function isUnsupportedSchemaError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.startsWith("Unsupported tool schema for ");
|
||||
}
|
||||
|
||||
function schemaPath(parent: string, key: string): string {
|
||||
if (/^[A-Za-z_$][\w$]*$/u.test(key)) {
|
||||
return `${parent}.${key}`;
|
||||
}
|
||||
return `${parent}[${JSON.stringify(key)}]`;
|
||||
}
|
||||
|
||||
function assertReadableSchema(
|
||||
schema: unknown,
|
||||
toolName: string,
|
||||
path: string,
|
||||
seen = new WeakSet<object>(),
|
||||
): void {
|
||||
if (!isRecord(schema)) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(schema)) {
|
||||
return;
|
||||
}
|
||||
seen.add(schema);
|
||||
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.keys(schema);
|
||||
} catch {
|
||||
throw unsupportedSchemaError(toolName, path);
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const childPath = Array.isArray(schema) ? `${path}[${key}]` : schemaPath(path, key);
|
||||
let child: unknown;
|
||||
try {
|
||||
child = Reflect.get(schema, key);
|
||||
} catch {
|
||||
throw unsupportedSchemaError(toolName, childPath);
|
||||
}
|
||||
assertReadableSchema(child, toolName, childPath, seen);
|
||||
}
|
||||
}
|
||||
|
||||
function readToolParameters(tool: Tool): Tool["parameters"] {
|
||||
try {
|
||||
return Reflect.get(tool, "parameters") as Tool["parameters"];
|
||||
} catch {
|
||||
throw unsupportedSchemaError(tool.name, "parameters");
|
||||
}
|
||||
}
|
||||
|
||||
function guardSchemaOperation<T>(toolName: string, path: string, operation: () => T): T {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
if (isUnsupportedSchemaError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw unsupportedSchemaError(toolName, path);
|
||||
}
|
||||
}
|
||||
|
||||
const JSON_NUMBER_TOKEN_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/iu;
|
||||
|
||||
function parseJsonNumberString(value: string): number | undefined {
|
||||
@@ -293,13 +360,20 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown {
|
||||
/** Validates tool arguments against TypeBox or plain JSON-schema parameters. */
|
||||
export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown {
|
||||
const args = structuredClone(toolCall.arguments);
|
||||
Value.Convert(tool.parameters, args);
|
||||
const parameters = readToolParameters(tool);
|
||||
assertReadableSchema(parameters, tool.name, "parameters");
|
||||
guardSchemaOperation(tool.name, "parameters", () => Value.Convert(parameters, args));
|
||||
|
||||
const validator = getValidator(tool.parameters);
|
||||
if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) {
|
||||
const validator = guardSchemaOperation(tool.name, "parameters", () => getValidator(parameters));
|
||||
if (
|
||||
guardSchemaOperation(tool.name, "parameters", () => !hasTypeBoxMetadata(parameters)) &&
|
||||
isJsonSchemaObject(parameters)
|
||||
) {
|
||||
// TypeBox Value.Convert is intentionally conservative for plain JSON schemas;
|
||||
// mirror the provider-facing coercions so model-emitted string numbers validate.
|
||||
const coerced = coerceWithJsonSchema(args, tool.parameters);
|
||||
const coerced = guardSchemaOperation(tool.name, "parameters", () =>
|
||||
coerceWithJsonSchema(args, parameters),
|
||||
);
|
||||
if (coerced !== args) {
|
||||
if (isRecord(args) && isRecord(coerced)) {
|
||||
for (const key of Object.keys(args)) {
|
||||
@@ -307,20 +381,26 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown {
|
||||
}
|
||||
Object.assign(args, coerced);
|
||||
} else {
|
||||
return validator.Check(coerced) ? coerced : args;
|
||||
return guardSchemaOperation(tool.name, "parameters", () => validator.Check(coerced))
|
||||
? coerced
|
||||
: args;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (validator.Check(args)) {
|
||||
if (guardSchemaOperation(tool.name, "parameters", () => validator.Check(args))) {
|
||||
return args;
|
||||
}
|
||||
|
||||
const errors =
|
||||
validator
|
||||
.Errors(args)
|
||||
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
|
||||
.join("\n") || "Unknown validation error";
|
||||
const errors = guardSchemaOperation(
|
||||
tool.name,
|
||||
"parameters",
|
||||
() =>
|
||||
validator
|
||||
.Errors(args)
|
||||
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
|
||||
.join("\n") || "Unknown validation error",
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`,
|
||||
|
||||
Reference in New Issue
Block a user