diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index d751d0fcb..65803e138 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -764,11 +764,13 @@ function normalizeOptionalNullsForSchema( if (!(key in nextValue)) continue; const currentValue = nextValue[key]; const isNullish = currentValue === null || currentValue === "null"; - const isEmptyString = currentValue === ""; + const isInvalidEmptyString = + currentValue === "" && !required.has(key) && !branchMatchesSchema(propertySchema, currentValue); - // Strip null, string "null", and empty strings from optional fields. + // Strip null/string "null" from optional fields, and strip empty + // strings only when the property schema would reject the explicit value. // LLMs sometimes output these placeholders to mean "no value". - if ((isNullish || isEmptyString) && !required.has(key)) { + if ((isNullish || isInvalidEmptyString) && !required.has(key)) { if (!changed) { nextValue = { ...nextValue }; changed = true; @@ -1282,7 +1284,7 @@ function truncateArgsForError(value: unknown): unknown { /** * Validates tool call arguments against the tool's schema (Zod or plain JSON * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string - * containers, null/empty-string-for-optional, null-for-default) before + * containers, null/invalid-empty-string-for-optional, null-for-default) before * declaring failure. * * @throws Error with a formatted message when validation cannot be reconciled. @@ -1292,8 +1294,9 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[ const ctx = getValidationContext(tool); const { json } = ctx; - // Always normalize first — strip null, string "null", and empty strings - // from optional fields and substitute defaults. Handles LLM outputting + // Always normalize first — strip null/string "null" from optional fields, + // strip optional empty strings only when their property schema rejects the + // explicit value, and substitute defaults. Handles LLM outputting // placeholders for "no value" even when validation would otherwise pass. let normalizedArgs: unknown = originalArgs; let changed = false; diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index 7dead03d3..daf7de432 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -924,6 +924,27 @@ describe("Tool argument coercion", () => { expect(result).toEqual({ namespace: "kube-system" }); }); + it("preserves schema-valid empty strings on optional properties", () => { + const tool: Tool = { + name: "empty-string-tool", + description: "", + parameters: z.object({ + requiredText: z.string(), + optionalText: z.string().optional(), + optionalEnum: z.enum(["", "clear"]).optional(), + }), + }; + + const result = validateToolArguments(tool, { + type: "toolCall", + id: "call-valid-empty-optionals", + name: "empty-string-tool", + arguments: { requiredText: "ok", optionalText: "", optionalEnum: "" }, + }); + + expect(result).toEqual({ requiredText: "ok", optionalText: "", optionalEnum: "" }); + }); + it("drops null optional properties nested in array objects", () => { const tool: Tool = { name: "t12", @@ -950,7 +971,7 @@ describe("Tool argument coercion", () => { expect(result).toEqual({ edits: [{ target: "a", end: "e" }] }); }); - it("drops null and empty-string optional properties in anyOf object branches", () => { + it("drops null while preserving valid empty-string optional properties in anyOf object branches", () => { const opSchema = z.union([ z.object({ op: z.literal("add_task"), @@ -997,6 +1018,7 @@ describe("Tool argument coercion", () => { { op: "update", id: "task-1", + notes: "", status: "completed", }, ],