Skip to content

Commit ff1fc8e

Browse files
roomoteedelauna
authored andcommitted
fix: guard recursive Gemini schema refs
1 parent 7697975 commit ff1fc8e

2 files changed

Lines changed: 75 additions & 7 deletions

File tree

src/api/providers/__tests__/gemini-handler.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,5 +629,57 @@ describe("GeminiHandler backend support", () => {
629629
})
630630
expect(schema.required).toEqual(expect.arrayContaining(["a", "b"]))
631631
})
632+
633+
it("should stop recursive $ref expansion before the sanitized schema becomes cyclic", async () => {
634+
const options = { apiProvider: "gemini" } as ApiHandlerOptions
635+
const handler = new GeminiHandler(options)
636+
const stub = vi.fn().mockReturnValue((async function* () {})())
637+
// @ts-ignore access private client
638+
handler["client"].models.generateContentStream = stub
639+
640+
await handler
641+
.createMessage("test", [] as any, {
642+
taskId: "test-task",
643+
tools: [
644+
{
645+
type: "function",
646+
function: {
647+
name: "recursive_ref_tool",
648+
description: "Tool with recursive $ref",
649+
parameters: {
650+
type: "object",
651+
$defs: {
652+
Node: {
653+
type: "object",
654+
properties: {
655+
value: { type: "string" },
656+
next: { $ref: "#/$defs/Node" },
657+
},
658+
required: ["value"],
659+
},
660+
},
661+
properties: {
662+
root: { $ref: "#/$defs/Node" },
663+
},
664+
required: ["root"],
665+
},
666+
},
667+
},
668+
],
669+
})
670+
.next()
671+
672+
const schema = stub.mock.calls[0][0].config.tools[0].functionDeclarations[0].parametersJsonSchema
673+
expect(() => JSON.stringify(schema)).not.toThrow()
674+
expect(JSON.stringify(schema)).not.toContain("$ref")
675+
expect(schema.properties.root).toEqual({
676+
type: "object",
677+
properties: {
678+
value: { type: "string" },
679+
next: {},
680+
},
681+
required: ["value"],
682+
})
683+
})
632684
})
633685
})

src/api/providers/gemini.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,17 @@ const GEMINI_SCHEMA_COMPATIBILITY_DROP_KEYS = new Set([
4646
"definitions",
4747
])
4848

49-
function sanitizeSchemaForGemini(schema: unknown, defs?: Record<string, unknown>): unknown {
49+
function sanitizeSchemaForGemini(
50+
schema: unknown,
51+
defs?: Record<string, unknown>,
52+
activeRefs: Set<string> = new Set(),
53+
): unknown {
5054
if (!schema || typeof schema !== "object") {
5155
return schema
5256
}
5357

5458
if (Array.isArray(schema)) {
55-
return schema.map((item) => sanitizeSchemaForGemini(item, defs))
59+
return schema.map((item) => sanitizeSchemaForGemini(item, defs, activeRefs))
5660
}
5761

5862
const source = schema as Record<string, unknown>
@@ -68,7 +72,19 @@ function sanitizeSchemaForGemini(schema: unknown, defs?: Record<string, unknown>
6872
if (match) {
6973
const resolved = resolvedDefs[match[1]]
7074
if (resolved !== undefined) {
71-
return sanitizeSchemaForGemini(resolved, resolvedDefs)
75+
// Recursive MCP schemas are valid JSON Schema but not something Gemini
76+
// can consume directly. Stop at the recursive edge so we still send a
77+
// finite, serializable schema instead of overflowing the stack.
78+
if (activeRefs.has(match[1])) {
79+
return {}
80+
}
81+
82+
activeRefs.add(match[1])
83+
try {
84+
return sanitizeSchemaForGemini(resolved, resolvedDefs, activeRefs)
85+
} finally {
86+
activeRefs.delete(match[1])
87+
}
7288
}
7389
}
7490
}
@@ -84,12 +100,12 @@ function sanitizeSchemaForGemini(schema: unknown, defs?: Record<string, unknown>
84100
: true
85101
})
86102
nullable = nullable || variants.length < composition.length
87-
Object.assign(result, sanitizeSchemaForGemini(variants[0] ?? {}, resolvedDefs))
103+
Object.assign(result, sanitizeSchemaForGemini(variants[0] ?? {}, resolvedDefs, activeRefs))
88104
}
89105

90106
if (Array.isArray(source.allOf)) {
91107
for (const variant of source.allOf) {
92-
const sanitized = sanitizeSchemaForGemini(variant, resolvedDefs)
108+
const sanitized = sanitizeSchemaForGemini(variant, resolvedDefs, activeRefs)
93109
if (sanitized && typeof sanitized === "object" && !Array.isArray(sanitized)) {
94110
const s = sanitized as Record<string, unknown>
95111
// Deep-merge properties so later allOf fragments don't overwrite
@@ -116,7 +132,7 @@ function sanitizeSchemaForGemini(schema: unknown, defs?: Record<string, unknown>
116132
}
117133

118134
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
119-
const sanitizedProperties = sanitizeSchemaForGemini(value, resolvedDefs)
135+
const sanitizedProperties = sanitizeSchemaForGemini(value, resolvedDefs, activeRefs)
120136
if (sanitizedProperties && typeof sanitizedProperties === "object" && !Array.isArray(sanitizedProperties)) {
121137
result.properties = {
122138
...(result.properties as Record<string, unknown> | undefined),
@@ -143,7 +159,7 @@ function sanitizeSchemaForGemini(schema: unknown, defs?: Record<string, unknown>
143159
continue
144160
}
145161

146-
result[key] = sanitizeSchemaForGemini(value, resolvedDefs)
162+
result[key] = sanitizeSchemaForGemini(value, resolvedDefs, activeRefs)
147163
}
148164

149165
if (nullable) {

0 commit comments

Comments
 (0)