Skip to content

Commit 7fae76e

Browse files
authored
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
1 parent 6b141fb commit 7fae76e

File tree

3 files changed

+79
-15
lines changed

3 files changed

+79
-15
lines changed

src/api/providers/__tests__/bedrock-native-tools.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,13 @@ describe("AwsBedrockHandler Native Tool Calling", () => {
168168
expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)")
169169

170170
// Second tool: line_ranges should be transformed from type: ["array", "null"] to anyOf
171+
// with items moved inside the array variant (required by GPT-5-mini strict schema validation)
171172
const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any
172173
const lineRanges = readFileSchema.properties.files.items.properties.line_ranges
173-
expect(lineRanges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
174+
expect(lineRanges.anyOf).toEqual([{ type: "array", items: { type: "integer" } }, { type: "null" }])
174175
expect(lineRanges.type).toBeUndefined()
175-
// items also gets additionalProperties: false from normalization
176-
expect(lineRanges.items.type).toBe("integer")
176+
// items should now be inside the array variant, not at root
177+
expect(lineRanges.items).toBeUndefined()
177178
expect(lineRanges.description).toBe("Optional line ranges")
178179
})
179180

src/utils/__tests__/json-schema.spec.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ describe("normalizeToolSchema", () => {
2626

2727
const result = normalizeToolSchema(input)
2828

29-
// additionalProperties should NOT be added to array or primitive types
29+
// Array-specific properties (items) should be moved inside the array variant
30+
// This is required by strict schema validators like GPT-5-mini
3031
expect(result).toEqual({
31-
anyOf: [{ type: "array" }, { type: "null" }],
32-
items: { type: "string" },
32+
anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }],
3333
description: "Optional array",
3434
})
3535
})
@@ -97,15 +97,15 @@ describe("normalizeToolSchema", () => {
9797
const result = normalizeToolSchema(input)
9898

9999
// additionalProperties: false should ONLY be on object types
100+
// Array-specific properties (items) should be moved inside the array variant
100101
expect(result).toEqual({
101102
type: "array",
102103
items: {
103104
type: "object",
104105
properties: {
105106
path: { type: "string" },
106107
line_ranges: {
107-
anyOf: [{ type: "array" }, { type: "null" }],
108-
items: { type: "integer" },
108+
anyOf: [{ type: "array", items: { type: "integer" } }, { type: "null" }],
109109
},
110110
},
111111
additionalProperties: false,
@@ -143,7 +143,11 @@ describe("normalizeToolSchema", () => {
143143
const properties = result.properties as Record<string, Record<string, unknown>>
144144
const filesItems = properties.files.items as Record<string, unknown>
145145
const filesItemsProps = filesItems.properties as Record<string, Record<string, unknown>>
146-
expect(filesItemsProps.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
146+
// Array-specific properties (items) should be moved inside the array variant
147+
expect(filesItemsProps.line_ranges.anyOf).toEqual([
148+
{ type: "array", items: { type: "array", items: { type: "integer" } } },
149+
{ type: "null" },
150+
])
147151
})
148152

149153
it("should recursively transform anyOf arrays", () => {
@@ -255,13 +259,26 @@ describe("normalizeToolSchema", () => {
255259

256260
const result = normalizeToolSchema(input)
257261

258-
// Verify the line_ranges was transformed
262+
// Verify the line_ranges was transformed with items inside the array variant
259263
const files = (result.properties as Record<string, unknown>).files as Record<string, unknown>
260264
const items = files.items as Record<string, unknown>
261265
const props = items.properties as Record<string, Record<string, unknown>>
262-
expect(props.line_ranges.anyOf).toEqual([{ type: "array" }, { type: "null" }])
263-
// Verify other properties are preserved
264-
expect(props.line_ranges.items).toBeDefined()
266+
// Array-specific properties (items, minItems, maxItems) should be moved inside the array variant
267+
expect(props.line_ranges.anyOf).toEqual([
268+
{
269+
type: "array",
270+
items: {
271+
type: "array",
272+
items: { type: "integer" },
273+
minItems: 2,
274+
maxItems: 2,
275+
},
276+
},
277+
{ type: "null" },
278+
])
279+
// items should NOT be at root level anymore
280+
expect(props.line_ranges.items).toBeUndefined()
281+
// Other properties are preserved at root level
265282
expect(props.line_ranges.description).toBe("Optional line ranges")
266283
})
267284

src/utils/json-schema.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@ const OPENAI_SUPPORTED_FORMATS = new Set([
2323
"uuid",
2424
])
2525

26+
/**
27+
* Array-specific JSON Schema properties that must be nested inside array type variants
28+
* when converting to anyOf format (JSON Schema draft 2020-12).
29+
*/
30+
const ARRAY_SPECIFIC_PROPERTIES = ["items", "minItems", "maxItems", "uniqueItems"] as const
31+
32+
/**
33+
* Applies array-specific properties from source to target object.
34+
* Only copies properties that are defined in the source.
35+
*/
36+
function applyArrayProperties(
37+
target: Record<string, unknown>,
38+
source: Record<string, unknown>,
39+
): Record<string, unknown> {
40+
for (const prop of ARRAY_SPECIFIC_PROPERTIES) {
41+
if (source[prop] !== undefined) {
42+
target[prop] = source[prop]
43+
}
44+
}
45+
return target
46+
}
47+
2648
/**
2749
* Zod schema for JSON Schema primitive types
2850
*/
@@ -133,18 +155,42 @@ const NormalizedToolSchemaInternal: z.ZodType<Record<string, unknown>, z.ZodType
133155
})
134156
.passthrough()
135157
.transform((schema) => {
136-
const { type, required, properties, additionalProperties, format, ...rest } = schema
158+
const {
159+
type,
160+
required,
161+
properties,
162+
additionalProperties,
163+
format,
164+
items,
165+
minItems,
166+
maxItems,
167+
uniqueItems,
168+
...rest
169+
} = schema
137170
const result: Record<string, unknown> = { ...rest }
138171

139172
// Determine if this schema represents an object type
140173
const isObjectType =
141174
type === "object" || (Array.isArray(type) && type.includes("object")) || properties !== undefined
142175

176+
// Collect array-specific properties for potential use in type handling
177+
const arrayProps = { items, minItems, maxItems, uniqueItems }
178+
143179
// If type is an array, convert to anyOf format (JSON Schema 2020-12)
180+
// Array-specific properties must be moved inside the array variant
144181
if (Array.isArray(type)) {
145-
result.anyOf = type.map((t) => ({ type: t }))
182+
result.anyOf = type.map((t) => {
183+
if (t === "array") {
184+
return applyArrayProperties({ type: t }, arrayProps)
185+
}
186+
return { type: t }
187+
})
146188
} else if (type !== undefined) {
147189
result.type = type
190+
// For single "array" type, preserve array-specific properties at root
191+
if (type === "array") {
192+
applyArrayProperties(result, arrayProps)
193+
}
148194
}
149195

150196
// Strip unsupported format values for OpenAI compatibility

0 commit comments

Comments
 (0)