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
This commit is contained in:
Daniel 2025-12-22 14:01:43 -05:00 committed by GitHub
parent 6b141fbb25
commit 7fae76ec86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 79 additions and 15 deletions

View file

@ -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")
})

View file

@ -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")
})

View file

@ -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