fix: flatten remaining anyOf/oneOf in Gemini schema cleaning

The Cloud Code Assist API rejects anyOf/oneOf in tool schemas, not just
unsupported keywords. The image tool (index 21) had:
  image: { anyOf: [{ type: "string" }, { type: "array" }] }
which caused "JSON schema is invalid" errors when forwarded to Anthropic
via google-antigravity.

simplifyUnionVariants only handles literal unions and single non-null
variants. This adds a fallback in cleanSchemaForGeminiWithDefs that
flattens any remaining anyOf/oneOf to a simple type schema.

Also reverts the previous provider-aware normalizeToolParameters and
sanitizeToolsForGoogle changes, which were incorrect — the cleaning IS
needed for Google's API regardless of which downstream model is used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sean McLellan
2026-02-16 16:36:31 -05:00
committed by Peter Steinberger
parent 1bbf6206d5
commit 06b961b037
4 changed files with 75 additions and 47 deletions

View File

@@ -245,11 +245,7 @@ export function sanitizeToolsForGoogle<
tools: AgentTool<TSchemaType, TResult>[];
provider: string;
}): AgentTool<TSchemaType, TResult>[] {
// google-antigravity serves Anthropic models (e.g. claude-opus-4-6-thinking),
// NOT Gemini. Applying Gemini schema cleaning strips JSON Schema keywords
// (minimum, maximum, format, etc.) that Anthropic's API requires for
// draft 2020-12 compliance. Only clean for actual Gemini providers.
if (params.provider !== "google-gemini-cli") {
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
return params.tools;
}
return params.tools.map((tool) => {

View File

@@ -62,10 +62,7 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
return existing;
}
export function normalizeToolParameters(
tool: AnyAgentTool,
options?: { modelProvider?: string },
): AnyAgentTool {
export function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
@@ -78,23 +75,15 @@ export function normalizeToolParameters(
// - Gemini rejects several JSON Schema keywords, so we scrub those.
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
// - Anthropic (google-antigravity) expects full JSON Schema draft 2020-12 compliance.
//
// Normalize once here so callers can always pass `tools` through unchanged.
const isGeminiProvider =
options?.modelProvider?.toLowerCase().includes("google") ||
options?.modelProvider?.toLowerCase().includes("gemini");
const isAnthropicProvider =
options?.modelProvider?.toLowerCase().includes("anthropic") ||
options?.modelProvider?.toLowerCase().includes("google-antigravity");
// If schema already has type + properties (no top-level anyOf to merge),
// clean it for Gemini compatibility (but only if using Gemini, not Anthropic)
// still clean it for Gemini compatibility
if ("type" in schema && "properties" in schema && !Array.isArray(schema.anyOf)) {
return {
...tool,
parameters: isGeminiProvider && !isAnthropicProvider ? cleanSchemaForGemini(schema) : schema,
parameters: cleanSchemaForGemini(schema),
};
}
@@ -106,13 +95,9 @@ export function normalizeToolParameters(
!Array.isArray(schema.anyOf) &&
!Array.isArray(schema.oneOf)
) {
const schemaWithType = { ...schema, type: "object" };
return {
...tool,
parameters:
isGeminiProvider && !isAnthropicProvider
? cleanSchemaForGemini(schemaWithType)
: schemaWithType,
parameters: cleanSchemaForGemini({ ...schema, type: "object" }),
};
}
@@ -169,34 +154,26 @@ export function normalizeToolParameters(
: undefined;
const nextSchema: Record<string, unknown> = { ...schema };
const flattenedSchema = {
type: "object",
...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}),
properties:
Object.keys(mergedProperties).length > 0 ? mergedProperties : (schema.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
additionalProperties: "additionalProperties" in schema ? schema.additionalProperties : true,
};
return {
...tool,
// Flatten union schemas into a single object schema:
// - Gemini doesn't allow top-level `type` together with `anyOf`.
// - OpenAI rejects schemas without top-level `type: "object"`.
// - Anthropic accepts proper JSON Schema with constraints.
// Merging properties preserves useful enums like `action` while keeping schemas portable.
parameters:
isGeminiProvider && !isAnthropicProvider
? cleanSchemaForGemini(flattenedSchema)
: flattenedSchema,
parameters: cleanSchemaForGemini({
type: "object",
...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
...(typeof nextSchema.description === "string"
? { description: nextSchema.description }
: {}),
properties:
Object.keys(mergedProperties).length > 0 ? mergedProperties : (schema.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
additionalProperties: "additionalProperties" in schema ? schema.additionalProperties : true,
}),
};
}
/**
* @deprecated Use normalizeToolParameters with modelProvider instead.
* This function should only be used for Gemini providers.
*/
export function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema);
}

View File

@@ -446,10 +446,7 @@ export function createOpenClawCodingTools(options?: {
});
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
// Provider-specific cleaning: Gemini needs constraint keywords stripped, but Anthropic expects them.
const normalized = subagentFiltered.map((tool) =>
normalizeToolParameters(tool, { modelProvider: options?.modelProvider }),
);
const normalized = subagentFiltered.map(normalizeToolParameters);
const withHooks = normalized.map((tool) =>
wrapToolWithBeforeToolCallHook(tool, {
agentId,

View File

@@ -339,6 +339,64 @@ function cleanSchemaForGeminiWithDefs(
}
}
// Cloud Code Assist API also rejects anyOf/oneOf in nested schemas.
// If simplifyUnionVariants couldn't reduce the union above, flatten it
// here as a fallback: pick the first variant's type or use a permissive
// schema so the tool declaration is accepted.
if (cleaned.anyOf && Array.isArray(cleaned.anyOf)) {
const variants = (cleaned.anyOf as Record<string, unknown>[]).filter(
(v) => v && typeof v === "object",
);
const types = new Set(variants.map((v) => v.type).filter(Boolean));
if (variants.length === 1) {
const merged: Record<string, unknown> = { ...variants[0] };
copySchemaMeta(cleaned, merged);
return merged;
}
if (types.size === 1) {
const merged: Record<string, unknown> = { type: Array.from(types)[0] };
copySchemaMeta(cleaned, merged);
return merged;
}
// Mixed types (e.g. string | array<string>): use first variant's type.
// The execute function already handles type coercion at runtime.
const first = variants[0];
if (first?.type) {
const merged: Record<string, unknown> = { type: first.type };
copySchemaMeta(cleaned, merged);
return merged;
}
const merged: Record<string, unknown> = {};
copySchemaMeta(cleaned, merged);
return merged;
}
if (cleaned.oneOf && Array.isArray(cleaned.oneOf)) {
const variants = (cleaned.oneOf as Record<string, unknown>[]).filter(
(v) => v && typeof v === "object",
);
const types = new Set(variants.map((v) => v.type).filter(Boolean));
if (variants.length === 1) {
const merged: Record<string, unknown> = { ...variants[0] };
copySchemaMeta(cleaned, merged);
return merged;
}
if (types.size === 1) {
const merged: Record<string, unknown> = { type: Array.from(types)[0] };
copySchemaMeta(cleaned, merged);
return merged;
}
const first = variants[0];
if (first?.type) {
const merged: Record<string, unknown> = { type: first.type };
copySchemaMeta(cleaned, merged);
return merged;
}
const merged: Record<string, unknown> = {};
copySchemaMeta(cleaned, merged);
return merged;
}
return cleaned;
}