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:
Miroslav Drbal [ApoC]
2026-02-26 08:42:44 +01:00
committed by GitHub
parent bb9f899638
commit 53d22a2508
2 changed files with 41 additions and 0 deletions
+8
View File
@@ -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();
});
});