From 53d22a25089092cd9e02048ffbec1802fd0994e1 Mon Sep 17 00:00:00 2001 From: "Miroslav Drbal [ApoC]" Date: Thu, 26 Feb 2026 08:42:44 +0100 Subject: [PATCH] fix(ai): coerce string-to-number for Optional tool parameters (#179) * fix(ai): coerce string-to-number for Optional 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. 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 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 --- packages/ai/src/utils/validation.ts | 8 +++++ .../ai/test/tool-argument-coercion.test.ts | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index f6a65714b..6057c0896 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -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) 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 }; diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index e375fd18a..c87f1d856 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -325,4 +325,37 @@ describe("Tool argument coercion", () => { expect(() => validateToolArguments(tool, toolCall)).toThrow('Validation failed for tool "t6"'); }); + + it("coerces numeric string for Optional (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 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(); + }); });