diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1628c86..9a3111236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc) - Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc) - Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj) +- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1) ## 2026.1.12-3 diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index e3797b51e..21e5fa2da 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -103,6 +103,31 @@ describe("createClawdbotCodingTools", () => { }); }); + it("drops null-only union variants without flattening other unions", () => { + const cleaned = __testing.cleanToolSchemaForGemini({ + type: "object", + properties: { + parentId: { anyOf: [{ type: "string" }, { type: "null" }] }, + count: { oneOf: [{ type: "string" }, { type: "number" }] }, + }, + }) as { + properties?: Record; + }; + + const parentId = cleaned.properties?.parentId as + | { type?: unknown; anyOf?: unknown; oneOf?: unknown } + | undefined; + expect(parentId?.anyOf).toBeUndefined(); + expect(parentId?.oneOf).toBeUndefined(); + expect(parentId?.type).toBe("string"); + + const count = cleaned.properties?.count as + | { type?: unknown; anyOf?: unknown; oneOf?: unknown } + | undefined; + expect(count?.anyOf).toBeUndefined(); + expect(Array.isArray(count?.oneOf)).toBe(true); + }); + it("preserves action enums in normalized schemas", () => { const tools = createClawdbotCodingTools(); const toolNames = [ diff --git a/src/agents/schema/clean-for-gemini.ts b/src/agents/schema/clean-for-gemini.ts index 219319fb8..2b12f4c23 100644 --- a/src/agents/schema/clean-for-gemini.ts +++ b/src/agents/schema/clean-for-gemini.ts @@ -67,6 +67,39 @@ function tryFlattenLiteralAnyOf( return null; } +function isNullSchema(variant: unknown): boolean { + if (!variant || typeof variant !== "object" || Array.isArray(variant)) { + return false; + } + const record = variant as Record; + if ("const" in record && record.const === null) return true; + if (Array.isArray(record.enum) && record.enum.length === 1) { + return record.enum[0] === null; + } + const typeValue = record.type; + if (typeValue === "null") return true; + if ( + Array.isArray(typeValue) && + typeValue.length === 1 && + typeValue[0] === "null" + ) { + return true; + } + return false; +} + +function stripNullVariants(variants: unknown[]): { + variants: unknown[]; + stripped: boolean; +} { + if (variants.length === 0) return { variants, stripped: false }; + const nonNull = variants.filter((variant) => !isNullSchema(variant)); + return { + variants: nonNull, + stripped: nonNull.length !== variants.length, + }; +} + type SchemaDefs = Map; function extendSchemaDefs( @@ -166,9 +199,24 @@ function cleanSchemaForGeminiWithDefs( const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf); const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf); + let cleanedAnyOf = hasAnyOf + ? (obj.anyOf as unknown[]).map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ) + : undefined; + let cleanedOneOf = hasOneOf + ? (obj.oneOf as unknown[]).map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ) + : undefined; if (hasAnyOf) { - const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]); + const { variants: nonNullVariants, stripped } = stripNullVariants( + cleanedAnyOf ?? [], + ); + if (stripped) cleanedAnyOf = nonNullVariants; + + const flattened = tryFlattenLiteralAnyOf(nonNullVariants); if (flattened) { const result: Record = { type: flattened.type, @@ -179,10 +227,28 @@ function cleanSchemaForGeminiWithDefs( } return result; } + if (stripped && nonNullVariants.length === 1) { + const lone = nonNullVariants[0]; + if (lone && typeof lone === "object" && !Array.isArray(lone)) { + const result: Record = { + ...(lone as Record), + }; + for (const key of ["description", "title", "default"]) { + if (key in obj && obj[key] !== undefined) result[key] = obj[key]; + } + return result; + } + return lone; + } } if (hasOneOf) { - const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]); + const { variants: nonNullVariants, stripped } = stripNullVariants( + cleanedOneOf ?? [], + ); + if (stripped) cleanedOneOf = nonNullVariants; + + const flattened = tryFlattenLiteralAnyOf(nonNullVariants); if (flattened) { const result: Record = { type: flattened.type, @@ -193,6 +259,19 @@ function cleanSchemaForGeminiWithDefs( } return result; } + if (stripped && nonNullVariants.length === 1) { + const lone = nonNullVariants[0]; + if (lone && typeof lone === "object" && !Array.isArray(lone)) { + const result: Record = { + ...(lone as Record), + }; + for (const key of ["description", "title", "default"]) { + if (key in obj && obj[key] !== undefined) result[key] = obj[key]; + } + return result; + } + return lone; + } } const cleaned: Record = {}; @@ -206,6 +285,15 @@ function cleanSchemaForGeminiWithDefs( } if (key === "type" && (hasAnyOf || hasOneOf)) continue; + if ( + key === "type" && + Array.isArray(value) && + value.every((entry) => typeof entry === "string") + ) { + const types = value.filter((entry) => entry !== "null"); + cleaned.type = types.length === 1 ? types[0] : types; + continue; + } if (key === "properties" && value && typeof value === "object") { const props = value as Record; @@ -218,13 +306,17 @@ function cleanSchemaForGeminiWithDefs( } else if (key === "items" && value && typeof value === "object") { cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack); } else if (key === "anyOf" && Array.isArray(value)) { - cleaned[key] = value.map((variant) => - cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), - ); + cleaned[key] = + cleanedAnyOf ?? + value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else if (key === "oneOf" && Array.isArray(value)) { - cleaned[key] = value.map((variant) => - cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), - ); + cleaned[key] = + cleanedOneOf ?? + value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else if (key === "allOf" && Array.isArray(value)) { cleaned[key] = value.map((variant) => cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),