fix(ai): coerce string-to-number for Optional<number> tool parameters (#179)
* fix(ai): coerce string-to-number for Optional<number> parameters
When a tool schema uses Type.Optional(Type.Number()), TypeBox generates
an anyOf:[{type:"number"},{type:"null"}] schema. AJV reports validation
failures against this as keyword:"anyOf" errors rather than
keyword:"type" errors.
coerceArgsFromErrors only processes keyword:"type" errors, so Optional
numeric fields were invisible to the coercion loop. normalizeOptionalNullsForSchema
traverses anyOf branches recursively but returned early at primitive-type
branches (type !== "object") without attempting string coercion, so the
anyOf traversal produced no change and the string was left uncoerced.
Fix: add a string-to-number coercion guard in normalizeOptionalNullsForSchema
before the early return, covering schema branches that declare
type:"number" or type:"integer". This allows the existing anyOf
traversal to coerce "1.0" -> 1.0 when the schema is Optional<number>.
Required numeric fields (Type.Number()) were unaffected as they produce
keyword:"type" errors handled by the existing path.
Reproducer: any MCP tool with an optional float parameter (e.g. tick_size:
Option<f64> in Rust/schemars) receives the value as a string from the
LLM and fails deserialization despite the coercion infrastructure being
designed to handle exactly this case.
* style: biome format validation.ts and coercion test
---------
Co-authored-by: Miroslav Drbal <miroslav.drbal@gendigital.com>
This commit is contained in:
committed by
GitHub
parent
bb9f899638
commit
53d22a2508
@@ -299,6 +299,14 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val
|
||||
return { value: changed ? nextValue : value, changed };
|
||||
}
|
||||
|
||||
// Coerce string → number/integer when the schema branch declares those types.
|
||||
// This fixes anyOf:[{type:"number"},{type:"null"}] (i.e. Optional<number>) where
|
||||
// AJV reports an "anyOf" error rather than a "type" error, bypassing
|
||||
// coerceArgsFromErrors which only handles keyword:"type" errors.
|
||||
if ((schemaObject.type === "number" || schemaObject.type === "integer") && typeof value === "string") {
|
||||
return tryParseNumberString(value, [schemaObject.type as string]);
|
||||
}
|
||||
|
||||
if (schemaObject.type !== "object") return { value, changed: false };
|
||||
if (typeof value !== "object" || value === null) return { value, changed: false };
|
||||
if (Array.isArray(value)) return { value, changed: false };
|
||||
|
||||
@@ -325,4 +325,37 @@ describe("Tool argument coercion", () => {
|
||||
|
||||
expect(() => validateToolArguments(tool, toolCall)).toThrow('Validation failed for tool "t6"');
|
||||
});
|
||||
|
||||
it("coerces numeric string for Optional<number> (anyOf:[number,null])", () => {
|
||||
const tool: Tool = {
|
||||
name: "t14",
|
||||
description: "",
|
||||
parameters: Type.Object({ tick_size: Type.Optional(Type.Number()) }),
|
||||
};
|
||||
const toolCall: ToolCall = {
|
||||
type: "toolCall",
|
||||
id: "call-14",
|
||||
name: "t14",
|
||||
arguments: { tick_size: "1.0" },
|
||||
};
|
||||
const result = validateToolArguments(tool, toolCall);
|
||||
expect(result.tick_size).toBe(1);
|
||||
expect(typeof result.tick_size).toBe("number");
|
||||
});
|
||||
|
||||
it("leaves Optional<number> as undefined when absent", () => {
|
||||
const tool: Tool = {
|
||||
name: "t15",
|
||||
description: "",
|
||||
parameters: Type.Object({ tick_size: Type.Optional(Type.Number()) }),
|
||||
};
|
||||
const toolCall: ToolCall = {
|
||||
type: "toolCall",
|
||||
id: "call-15",
|
||||
name: "t15",
|
||||
arguments: {},
|
||||
};
|
||||
const result = validateToolArguments(tool, toolCall);
|
||||
expect(result.tick_size).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user