From 49459b4019013daa695d4548907de4a3d474d645 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 22 Dec 2025 12:50:55 -0500 Subject: [PATCH 1/2] fix: move array-specific properties into anyOf variant in normalizeToolSchema Fixes read_file tool schema rejection with GPT-5-mini which requires items property to be inside the { type: 'array' } variant when using anyOf for nullable arrays. Resolves ROO-262 --- .../__tests__/bedrock-native-tools.spec.ts | 7 ++-- src/utils/__tests__/json-schema.spec.ts | 37 ++++++++++++++----- src/utils/json-schema.ts | 35 +++++++++++++++++- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/api/providers/__tests__/bedrock-native-tools.spec.ts b/src/api/providers/__tests__/bedrock-native-tools.spec.ts index 8325f94bfb..0396a81744 100644 --- a/src/api/providers/__tests__/bedrock-native-tools.spec.ts +++ b/src/api/providers/__tests__/bedrock-native-tools.spec.ts @@ -168,12 +168,13 @@ describe("AwsBedrockHandler Native Tool Calling", () => { expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)") // Second tool: line_ranges should be transformed from type: ["array", "null"] to anyOf + // with items moved inside the array variant (required by GPT-5-mini strict schema validation) const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any const lineRanges = readFileSchema.properties.files.items.properties.line_ranges - expect(lineRanges.anyOf).toEqual([{ type: "array" }, { type: "null" }]) + expect(lineRanges.anyOf).toEqual([{ type: "array", items: { type: "integer" } }, { type: "null" }]) expect(lineRanges.type).toBeUndefined() - // items also gets additionalProperties: false from normalization - expect(lineRanges.items.type).toBe("integer") + // items should now be inside the array variant, not at root + expect(lineRanges.items).toBeUndefined() expect(lineRanges.description).toBe("Optional line ranges") }) diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index c53e0d7b86..5a1510be43 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -26,10 +26,10 @@ describe("normalizeToolSchema", () => { const result = normalizeToolSchema(input) - // additionalProperties should NOT be added to array or primitive types + // Array-specific properties (items) should be moved inside the array variant + // This is required by strict schema validators like GPT-5-mini expect(result).toEqual({ - anyOf: [{ type: "array" }, { type: "null" }], - items: { type: "string" }, + anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], description: "Optional array", }) }) @@ -97,6 +97,7 @@ describe("normalizeToolSchema", () => { const result = normalizeToolSchema(input) // additionalProperties: false should ONLY be on object types + // Array-specific properties (items) should be moved inside the array variant expect(result).toEqual({ type: "array", items: { @@ -104,8 +105,7 @@ describe("normalizeToolSchema", () => { properties: { path: { type: "string" }, line_ranges: { - anyOf: [{ type: "array" }, { type: "null" }], - items: { type: "integer" }, + anyOf: [{ type: "array", items: { type: "integer" } }, { type: "null" }], }, }, additionalProperties: false, @@ -143,7 +143,11 @@ describe("normalizeToolSchema", () => { const properties = result.properties as Record> const filesItems = properties.files.items as Record const filesItemsProps = filesItems.properties as Record> - expect(filesItemsProps.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }]) + // Array-specific properties (items) should be moved inside the array variant + expect(filesItemsProps.line_ranges.anyOf).toEqual([ + { type: "array", items: { type: "array", items: { type: "integer" } } }, + { type: "null" }, + ]) }) it("should recursively transform anyOf arrays", () => { @@ -255,13 +259,26 @@ describe("normalizeToolSchema", () => { const result = normalizeToolSchema(input) - // Verify the line_ranges was transformed + // Verify the line_ranges was transformed with items inside the array variant const files = (result.properties as Record).files as Record const items = files.items as Record const props = items.properties as Record> - expect(props.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }]) - // Verify other properties are preserved - expect(props.line_ranges.items).toBeDefined() + // Array-specific properties (items, minItems, maxItems) should be moved inside the array variant + expect(props.line_ranges.anyOf).toEqual([ + { + type: "array", + items: { + type: "array", + items: { type: "integer" }, + minItems: 2, + maxItems: 2, + }, + }, + { type: "null" }, + ]) + // items should NOT be at root level anymore + expect(props.line_ranges.items).toBeUndefined() + // Other properties are preserved at root level expect(props.line_ranges.description).toBe("Optional line ranges") }) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 180a51848b..12a8bf96d3 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -133,7 +133,18 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType }) .passthrough() .transform((schema) => { - const { type, required, properties, additionalProperties, format, ...rest } = schema + const { + type, + required, + properties, + additionalProperties, + format, + items, + minItems, + maxItems, + uniqueItems, + ...rest + } = schema const result: Record = { ...rest } // Determine if this schema represents an object type @@ -141,10 +152,30 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType type === "object" || (Array.isArray(type) && type.includes("object")) || properties !== undefined // If type is an array, convert to anyOf format (JSON Schema 2020-12) + // Array-specific properties (items, minItems, maxItems, uniqueItems) must be moved + // inside the array variant, not left at root level where they're invalid if (Array.isArray(type)) { - result.anyOf = type.map((t) => ({ type: t })) + result.anyOf = type.map((t) => { + if (t === "array") { + // Move array-specific properties into the array variant + const arrayVariant: Record = { type: t } + if (items !== undefined) arrayVariant.items = items + if (minItems !== undefined) arrayVariant.minItems = minItems + if (maxItems !== undefined) arrayVariant.maxItems = maxItems + if (uniqueItems !== undefined) arrayVariant.uniqueItems = uniqueItems + return arrayVariant + } + return { type: t } + }) } else if (type !== undefined) { result.type = type + // For single "array" type, preserve array-specific properties at root + if (type === "array") { + if (items !== undefined) result.items = items + if (minItems !== undefined) result.minItems = minItems + if (maxItems !== undefined) result.maxItems = maxItems + if (uniqueItems !== undefined) result.uniqueItems = uniqueItems + } } // Strip unsupported format values for OpenAI compatibility From 9ba1cc36c41cd09d38f727394a2959add53441ab Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 22 Dec 2025 13:09:36 -0500 Subject: [PATCH 2/2] refactor: extract array-specific properties constant and helper function --- src/utils/json-schema.ts | 41 +++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 12a8bf96d3..8059c2ee0d 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -23,6 +23,28 @@ const OPENAI_SUPPORTED_FORMATS = new Set([ "uuid", ]) +/** + * Array-specific JSON Schema properties that must be nested inside array type variants + * when converting to anyOf format (JSON Schema draft 2020-12). + */ +const ARRAY_SPECIFIC_PROPERTIES = ["items", "minItems", "maxItems", "uniqueItems"] as const + +/** + * Applies array-specific properties from source to target object. + * Only copies properties that are defined in the source. + */ +function applyArrayProperties( + target: Record, + source: Record, +): Record { + for (const prop of ARRAY_SPECIFIC_PROPERTIES) { + if (source[prop] !== undefined) { + target[prop] = source[prop] + } + } + return target +} + /** * Zod schema for JSON Schema primitive types */ @@ -151,19 +173,15 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType const isObjectType = type === "object" || (Array.isArray(type) && type.includes("object")) || properties !== undefined + // Collect array-specific properties for potential use in type handling + const arrayProps = { items, minItems, maxItems, uniqueItems } + // If type is an array, convert to anyOf format (JSON Schema 2020-12) - // Array-specific properties (items, minItems, maxItems, uniqueItems) must be moved - // inside the array variant, not left at root level where they're invalid + // Array-specific properties must be moved inside the array variant if (Array.isArray(type)) { result.anyOf = type.map((t) => { if (t === "array") { - // Move array-specific properties into the array variant - const arrayVariant: Record = { type: t } - if (items !== undefined) arrayVariant.items = items - if (minItems !== undefined) arrayVariant.minItems = minItems - if (maxItems !== undefined) arrayVariant.maxItems = maxItems - if (uniqueItems !== undefined) arrayVariant.uniqueItems = uniqueItems - return arrayVariant + return applyArrayProperties({ type: t }, arrayProps) } return { type: t } }) @@ -171,10 +189,7 @@ const NormalizedToolSchemaInternal: z.ZodType, z.ZodType result.type = type // For single "array" type, preserve array-specific properties at root if (type === "array") { - if (items !== undefined) result.items = items - if (minItems !== undefined) result.minItems = minItems - if (maxItems !== undefined) result.maxItems = maxItems - if (uniqueItems !== undefined) result.uniqueItems = uniqueItems + applyArrayProperties(result, arrayProps) } }