fix: scope gemini tool schemas

This commit is contained in:
Peter Steinberger 2026-01-03 13:52:10 +01:00
parent d42f31c2a5
commit 133889a2f6
4 changed files with 110 additions and 48 deletions

View File

@ -63,6 +63,10 @@ export async function loadModelCatalog(params?: {
// If we found nothing, don't cache this result so we can try again. // If we found nothing, don't cache this result so we can try again.
modelCatalogPromise = null; modelCatalogPromise = null;
} }
if (models.length === 0) {
// If we found nothing, don't cache this result so we can try again.
modelCatalogPromise = null;
}
} catch { } catch {
// Leave models empty on discovery errors and don't cache. // Leave models empty on discovery errors and don't cache.
modelCatalogPromise = null; modelCatalogPromise = null;

View File

@ -408,6 +408,8 @@ export async function runEmbeddedPiAgent(params: {
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdisCodingTools({ const tools = createClawdisCodingTools({
bash: params.config?.agent?.bash, bash: params.config?.agent?.bash,
provider,
surface: params.surface,
}); });
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();
const runtimeInfo = { const runtimeInfo = {

View File

@ -72,6 +72,50 @@ describe("createClawdisCodingTools", () => {
} }
}); });
it("keeps anyOf variants for non-Gemini providers", () => {
const tools = createClawdisCodingTools({ provider: "openai" });
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: Array<{ properties?: Record<string, unknown> }>;
};
expect(Array.isArray(parameters.anyOf)).toBe(true);
expect(parameters.anyOf?.length ?? 0).toBeGreaterThan(0);
const hasConst = parameters.anyOf?.some((variant) => {
const action = variant.properties?.action as
| { const?: unknown }
| undefined;
return typeof action?.const === "string";
});
expect(hasConst).toBe(true);
});
it("strips anyOf for google-gemini-cli tools", () => {
const tools = createClawdisCodingTools({ provider: "google-gemini-cli" });
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: unknown;
properties?: Record<string, unknown>;
required?: string[];
};
expect(parameters.anyOf).toBeUndefined();
expect(parameters.properties?.action).toBeDefined();
expect(parameters.required ?? []).toContain("action");
});
it("strips anyOf for google-antigravity tools", () => {
const tools = createClawdisCodingTools({ provider: "google-antigravity" });
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: unknown;
properties?: Record<string, unknown>;
};
expect(parameters.anyOf).toBeUndefined();
expect(parameters.properties?.action).toBeDefined();
});
it("includes bash and process tools", () => { it("includes bash and process tools", () => {
const tools = createClawdisCodingTools(); const tools = createClawdisCodingTools();
expect(tools.some((tool) => tool.name === "bash")).toBe(true); expect(tools.some((tool) => tool.name === "bash")).toBe(true);

View File

@ -141,80 +141,76 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
return existing; return existing;
} }
// Gemini tool schemas accept an OpenAPI-ish subset; strip unsupported bits.
function cleanSchemaForGemini(schema: unknown): unknown { function cleanSchemaForGemini(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema; if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini); if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
const obj = schema as Record<string, unknown>; const obj = schema as Record<string, unknown>;
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf); const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
const hasConst = "const" in obj;
const cleaned: Record<string, unknown> = {}; const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
// Skip unsupported schema features for Gemini:
// - patternProperties: not in OpenAPI 3.0 subset
// - const: convert to enum with single value instead
if (key === "patternProperties") { if (key === "patternProperties") {
// Gemini doesn't support patternProperties - skip it
continue; continue;
} }
// Convert const to enum (Gemini doesn't support const)
if (key === "const") { if (key === "const") {
cleaned.enum = [value]; cleaned.enum = [value];
continue; continue;
} }
// Skip 'type' if we have 'anyOf' — Gemini doesn't allow both
if (key === "type" && hasAnyOf) { if (key === "type" && hasAnyOf) {
continue; continue;
} }
if (key === "properties" && value && typeof value === "object") { if (key === "properties" && value && typeof value === "object") {
// Recursively clean nested properties
const props = value as Record<string, unknown>; const props = value as Record<string, unknown>;
cleaned[key] = Object.fromEntries( cleaned[key] = Object.fromEntries(
Object.entries(props).map(([k, v]) => [k, cleanSchemaForGemini(v)]) Object.entries(props).map(([k, v]) => [k, cleanSchemaForGemini(v)]),
); );
} else if (key === "items" && value && typeof value === "object") { } else if (key === "items" && value && typeof value === "object") {
// Recursively clean array items schema
cleaned[key] = cleanSchemaForGemini(value); cleaned[key] = cleanSchemaForGemini(value);
} else if (key === "anyOf" && Array.isArray(value)) { } else if (key === "anyOf" && Array.isArray(value)) {
// Clean each anyOf variant cleaned[key] = value.map((v) => cleanSchemaForGemini(v));
cleaned[key] = value.map(v => cleanSchemaForGemini(v));
} else if (key === "oneOf" && Array.isArray(value)) { } else if (key === "oneOf" && Array.isArray(value)) {
// Clean each oneOf variant cleaned[key] = value.map((v) => cleanSchemaForGemini(v));
cleaned[key] = value.map(v => cleanSchemaForGemini(v));
} else if (key === "allOf" && Array.isArray(value)) { } else if (key === "allOf" && Array.isArray(value)) {
// Clean each allOf variant cleaned[key] = value.map((v) => cleanSchemaForGemini(v));
cleaned[key] = value.map(v => cleanSchemaForGemini(v));
} else if (key === "additionalProperties" && value && typeof value === "object") { } else if (key === "additionalProperties" && value && typeof value === "object") {
// Recursively clean additionalProperties schema
cleaned[key] = cleanSchemaForGemini(value); cleaned[key] = cleanSchemaForGemini(value);
} else { } else {
cleaned[key] = value; cleaned[key] = value;
} }
} }
return cleaned; return cleaned;
} }
function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool { // Only Gemini providers need schema cleanup; other providers can keep richer JSON Schema.
function shouldCleanSchemaForGemini(provider?: string): boolean {
return provider === "google-gemini-cli" || provider === "google-antigravity";
}
function normalizeToolParameters(
tool: AnyAgentTool,
options?: { cleanForGemini?: boolean },
): AnyAgentTool {
const schema = const schema =
tool.parameters && typeof tool.parameters === "object" tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>) ? (tool.parameters as Record<string, unknown>)
: undefined; : undefined;
if (!schema) return tool; if (!schema) return tool;
const cleanForGemini = options?.cleanForGemini === true;
// If schema already has type + properties (no top-level anyOf to merge), if (
// still clean it for Gemini compatibility "type" in schema &&
if ("type" in schema && "properties" in schema && !Array.isArray(schema.anyOf)) { "properties" in schema &&
return { !Array.isArray(schema.anyOf)
...tool, ) {
parameters: cleanSchemaForGemini(schema), return cleanForGemini
}; ? { ...tool, parameters: cleanSchemaForGemini(schema) }
: tool;
} }
if (!Array.isArray(schema.anyOf)) return tool; if (!Array.isArray(schema.anyOf)) return tool;
const mergedProperties: Record<string, unknown> = {}; const mergedProperties: Record<string, unknown> = {};
const requiredCounts = new Map<string, number>(); const requiredCounts = new Map<string, number>();
@ -258,22 +254,29 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
.map(([key]) => key) .map(([key]) => key)
: undefined; : undefined;
const { anyOf: _unusedAnyOf, ...restSchema } = schema; const mergedSchema = {
...schema,
type: "object",
properties:
Object.keys(mergedProperties).length > 0
? mergedProperties
: (schema.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0
? { required: mergedRequired }
: {}),
additionalProperties:
"additionalProperties" in schema ? schema.additionalProperties : true,
};
// Preserve anyOf for non-Gemini providers; Gemini rejects top-level anyOf.
return { return {
...tool, ...tool,
parameters: cleanSchemaForGemini({ parameters: cleanForGemini
...restSchema, ? (() => {
type: "object", const { anyOf: _unusedAnyOf, ...rest } = mergedSchema;
properties: return cleanSchemaForGemini(rest);
Object.keys(mergedProperties).length > 0 })()
? mergedProperties : mergedSchema,
: (schema.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0
? { required: mergedRequired }
: {}),
additionalProperties:
"additionalProperties" in schema ? schema.additionalProperties : true,
}),
}; };
} }
@ -364,6 +367,8 @@ function createClawdisReadTool(base: AnyAgentTool): AnyAgentTool {
export function createClawdisCodingTools(options?: { export function createClawdisCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults; bash?: BashToolDefaults & ProcessToolDefaults;
surface?: string;
provider?: string;
}): AnyAgentTool[] { }): AnyAgentTool[] {
const bashToolName = "bash"; const bashToolName = "bash";
const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => {
@ -382,5 +387,12 @@ export function createClawdisCodingTools(options?: {
createWhatsAppLoginTool(), createWhatsAppLoginTool(),
...createClawdisTools(), ...createClawdisTools(),
]; ];
return tools.map(normalizeToolParameters); const allowDiscord = shouldIncludeDiscordTool(options?.surface);
const filtered = allowDiscord
? tools
: tools.filter((tool) => tool.name !== "discord");
const cleanForGemini = shouldCleanSchemaForGemini(options?.provider);
return filtered.map((tool) =>
normalizeToolParameters(tool, { cleanForGemini }),
);
} }