瀏覽代碼

fix: move array-specific properties into anyOf variant in normalizeToolSchema (#10276)

* 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

* refactor: extract array-specific properties constant and helper function
Daniel 1 周之前
父節點
當前提交
7fae76ec86

+ 4 - 3
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")
 		})
 

+ 27 - 10
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<string, Record<string, unknown>>
 		const filesItems = properties.files.items as Record<string, unknown>
 		const filesItemsProps = filesItems.properties as Record<string, Record<string, unknown>>
-		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<string, unknown>).files as Record<string, unknown>
 		const items = files.items as Record<string, unknown>
 		const props = items.properties as Record<string, Record<string, unknown>>
-		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")
 	})
 

+ 48 - 2
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<string, unknown>,
+	source: Record<string, unknown>,
+): Record<string, unknown> {
+	for (const prop of ARRAY_SPECIFIC_PROPERTIES) {
+		if (source[prop] !== undefined) {
+			target[prop] = source[prop]
+		}
+	}
+	return target
+}
+
 /**
  * Zod schema for JSON Schema primitive types
  */
@@ -133,18 +155,42 @@ const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, 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<string, unknown> = { ...rest }
 
 				// Determine if this schema represents an object type
 				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 must be moved inside the array variant
 				if (Array.isArray(type)) {
-					result.anyOf = type.map((t) => ({ type: t }))
+					result.anyOf = type.map((t) => {
+						if (t === "array") {
+							return applyArrayProperties({ type: t }, arrayProps)
+						}
+						return { type: t }
+					})
 				} else if (type !== undefined) {
 					result.type = type
+					// For single "array" type, preserve array-specific properties at root
+					if (type === "array") {
+						applyArrayProperties(result, arrayProps)
+					}
 				}
 
 				// Strip unsupported format values for OpenAI compatibility