fix(google): skip unreadable tool schemas

This commit is contained in:
Vincent Koc
2026-06-04 18:07:06 +02:00
parent 0913b6989c
commit 1efebe257a
7 changed files with 680 additions and 36 deletions

View File

@@ -301,6 +301,88 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
expect(declarations[1]?.behavior).toBe("NON_BLOCKING");
});
it("skips unreadable realtime tool declarations while preserving healthy tools", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
const cyclicSchema: Record<string, unknown> = { type: "object" };
cyclicSchema.properties = { self: cyclicSchema };
const sharedProperty = { type: "string" };
const bridge = provider.createBridge({
providerConfig: {
apiKey: "gemini-key",
model: "gemini-live-2.5-flash-preview",
},
tools: [
{
type: "function",
get name(): string {
throw new Error("tool name unavailable");
},
description: "Broken",
parameters: {},
},
{
type: "function",
name: "revoked",
description: "Broken",
parameters: proxy,
},
{
type: "function",
name: "cyclic",
description: "Broken",
parameters: cyclicSchema,
},
{
type: "function",
name: "lookup",
description: "Lookup",
parameters: {
type: "object",
properties: {
first: sharedProperty,
second: sharedProperty,
},
required: ["first", "second"],
},
},
] as never,
onAudio: vi.fn(),
onClearAudio: vi.fn(),
});
await bridge.connect();
const declarations =
(
lastConnectParams().config as {
tools?: Array<{
functionDeclarations?: Array<{
description?: string;
name?: string;
parametersJsonSchema?: unknown;
}>;
}>;
}
).tools?.[0]?.functionDeclarations ?? [];
expect(declarations).toEqual([
{
name: "lookup",
description: "Lookup",
parametersJsonSchema: {
type: "object",
properties: {
first: { type: "string" },
second: { type: "string" },
},
required: ["first", "second"],
},
},
]);
});
it("omits zero temperature for native audio responses", async () => {
const provider = buildGoogleRealtimeVoiceProvider();
const bridge = provider.createBridge({

View File

@@ -47,6 +47,7 @@ import {
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { createGoogleGenAI } from "./google-genai-runtime.js";
import { materializeGoogleToolSchema } from "./tool-schema.js";
const GOOGLE_REALTIME_DEFAULT_MODEL = "gemini-2.5-flash-native-audio-preview-12-2025";
const GOOGLE_REALTIME_DEFAULT_VOICE = "Kore";
@@ -338,17 +339,27 @@ function buildRealtimeInputConfig(
}
function buildFunctionDeclarations(tools: RealtimeVoiceTool[] | undefined): FunctionDeclaration[] {
return (tools ?? []).map((tool) => {
return (tools ?? []).flatMap((tool) => {
const declaration = buildFunctionDeclaration(tool);
return declaration ? [declaration] : [];
});
}
function buildFunctionDeclaration(tool: RealtimeVoiceTool): FunctionDeclaration | undefined {
try {
const name = tool.name;
const declaration: FunctionDeclaration = {
name: tool.name,
name,
description: tool.description,
parametersJsonSchema: tool.parameters,
parametersJsonSchema: materializeGoogleToolSchema(tool.parameters),
};
if (tool.name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
declaration.behavior = "NON_BLOCKING" as Behavior;
}
return declaration;
});
} catch {
return undefined;
}
}
function buildGoogleLiveConnectConfig(config: GoogleRealtimeLiveConfig): LiveConnectConfig {

View File

@@ -0,0 +1,45 @@
const MAX_GOOGLE_TOOL_SCHEMA_DEPTH = 64;
const MAX_GOOGLE_TOOL_SCHEMA_NODES = 10_000;
export function materializeGoogleToolSchema(schema: unknown): unknown {
const state = { nodes: 0 };
return materializeGoogleToolSchemaValue(schema, state, 0, new Set<object>());
}
function materializeGoogleToolSchemaValue(
schema: unknown,
state: { nodes: number },
depth: number,
stack: Set<object>,
): unknown {
if (depth > MAX_GOOGLE_TOOL_SCHEMA_DEPTH) {
throw new Error("Google tool schema exceeds maximum supported depth");
}
if (++state.nodes > MAX_GOOGLE_TOOL_SCHEMA_NODES) {
throw new Error("Google tool schema exceeds maximum supported size");
}
if (typeof schema !== "object" || schema === null) {
return schema;
}
if (stack.has(schema)) {
throw new Error("Google tool schema contains a cycle");
}
stack.add(schema);
try {
if (Array.isArray(schema)) {
return schema.map((item) => materializeGoogleToolSchemaValue(item, state, depth + 1, stack));
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
Object.defineProperty(result, key, {
value: materializeGoogleToolSchemaValue(value, state, depth + 1, stack),
enumerable: true,
configurable: true,
writable: true,
});
}
return result;
} finally {
stack.delete(schema);
}
}

View File

@@ -1867,6 +1867,204 @@ describe("google transport stream", () => {
expect(params.toolConfig).toBeUndefined();
});
it("skips unreadable Google tools while preserving healthy declarations", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
const cyclicSchema: Record<string, unknown> = { type: "object" };
cyclicSchema.properties = { self: cyclicSchema };
const sharedProperty = { type: "string" };
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
get name(): string {
throw new Error("tool name unavailable");
},
description: "Broken",
parameters: {},
},
{
name: "revoked",
description: "Broken",
parameters: proxy,
},
{
name: "cyclic",
description: "Broken",
parameters: cyclicSchema,
},
{
name: "lookup",
description: "Lookup",
parameters: {
type: "object",
properties: {
first: sharedProperty,
second: sharedProperty,
},
required: ["first", "second"],
},
},
],
} as never);
expect(params.tools).toEqual([
{
functionDeclarations: [
{
name: "lookup",
description: "Lookup",
parametersJsonSchema: {
type: "object",
properties: {
first: { type: "string" },
second: { type: "string" },
},
required: ["first", "second"],
},
},
],
},
]);
});
it("preserves reserved Google tool schema property names", () => {
const parameters = JSON.parse(
'{"type":"object","properties":{"__proto__":{"type":"string"}},"required":["__proto__"]}',
) as Record<string, unknown>;
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "lookup",
description: "Lookup",
parameters,
},
],
} as never);
const schema = params.tools?.[0]?.functionDeclarations?.[0]?.parametersJsonSchema as
| { properties?: Record<string, unknown>; required?: unknown }
| undefined;
const properties = schema?.properties;
expect(properties).toBeDefined();
expect(Object.hasOwn(properties as Record<string, unknown>, "__proto__")).toBe(true);
expect(Reflect.get(properties as Record<string, unknown>, "__proto__")).toEqual({
type: "string",
});
expect(schema?.required).toEqual(["__proto__"]);
});
it("treats null Google tool choice as no explicit choice", () => {
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "lookup",
description: "Lookup",
parameters: {
type: "object",
properties: {
query: { type: "string" },
},
},
},
],
} as never,
{ toolChoice: null } as never,
);
expect(params.tools).toBeDefined();
expect(params.toolConfig).toBeUndefined();
});
it("omits optional tools when every Google tool declaration is skipped", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
const params = buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "revoked",
description: "Broken",
parameters: proxy,
},
],
} as never,
{ toolChoice: "auto" },
);
expect(params.tools).toBeUndefined();
expect(params.toolConfig).toBeUndefined();
});
it("throws when forced Google tool choice has no valid declarations", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
expect(() =>
buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "revoked",
description: "Broken",
parameters: proxy,
},
],
} as never,
{ toolChoice: "any" },
),
).toThrow("Google tool choice requires at least one valid tool declaration");
});
it("throws when pinned function tool choice is skipped", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
expect(() =>
buildGoogleGenerativeAiParams(
buildGeminiModel(),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "revoked",
description: "Broken",
parameters: proxy,
},
{
name: "lookup",
description: "Lookup",
parameters: {
type: "object",
properties: {
query: { type: "string" },
},
required: ["query"],
},
},
],
} as never,
{
toolChoice: {
type: "function",
function: { name: "revoked" },
},
},
),
).toThrow("Google tool choice references an unavailable tool: revoked");
});
it("uses a non-empty text placeholder for empty user text", () => {
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
messages: [

View File

@@ -37,6 +37,7 @@ import {
type GoogleThinkingInputLevel,
type GoogleThinkingLevel,
} from "./thinking-api.js";
import { materializeGoogleToolSchema } from "./tool-schema.js";
import {
isGoogleVertexCredentialsMarker,
resolveGoogleVertexAuthorizedUserHeaders,
@@ -290,7 +291,8 @@ function mapToolChoice(
return undefined;
}
if (typeof choice === "object" && choice.type === "function") {
return { mode: "ANY", allowedFunctionNames: [choice.function.name] };
const name = choice.function.name;
return { mode: "ANY", allowedFunctionNames: [name] };
}
switch (choice) {
case "none":
@@ -303,6 +305,41 @@ function mapToolChoice(
}
}
function getPinnedFunctionToolName(
choice: GoogleTransportOptions["toolChoice"],
): string | undefined {
return choice !== null && typeof choice === "object" && choice.type === "function"
? choice.function.name
: undefined;
}
function requiresGoogleToolChoice(choice: GoogleTransportOptions["toolChoice"]): boolean {
return (
choice === "any" || choice === "required" || getPinnedFunctionToolName(choice) !== undefined
);
}
function collectFunctionDeclarationNames(
tools: GoogleGenerateContentRequest["tools"],
): Set<string> {
const names = new Set<string>();
for (const tool of tools ?? []) {
const declarations = tool.functionDeclarations;
if (!Array.isArray(declarations)) {
continue;
}
for (const declaration of declarations) {
if (declaration && typeof declaration === "object" && "name" in declaration) {
const name = (declaration as { name?: unknown }).name;
if (typeof name === "string") {
names.add(name);
}
}
}
}
return names;
}
function mapStopReasonString(reason: string): "stop" | "length" | "error" {
switch (reason) {
case "STOP":
@@ -680,17 +717,34 @@ function convertGoogleTools(tools: NonNullable<Context["tools"]>) {
if (tools.length === 0) {
return undefined;
}
const functionDeclarations = tools.flatMap((tool) => {
const declaration = buildGoogleFunctionDeclaration(tool);
return declaration ? [declaration] : [];
});
if (functionDeclarations.length === 0) {
return undefined;
}
return [
{
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
parametersJsonSchema: tool.parameters,
})),
functionDeclarations,
},
];
}
function buildGoogleFunctionDeclaration(
tool: NonNullable<Context["tools"]>[number],
): Record<string, unknown> | undefined {
try {
return {
name: tool.name,
description: tool.description,
parametersJsonSchema: materializeGoogleToolSchema(tool.parameters),
};
} catch {
return undefined;
}
}
export function buildGoogleGenerativeAiParams(
model: GoogleTransportModel,
context: Context,
@@ -732,12 +786,23 @@ export function buildGoogleGenerativeAiParams(
};
}
if (!cachedContent && context.tools?.length) {
params.tools = convertGoogleTools(context.tools);
const toolChoice = mapToolChoice(options?.toolChoice);
if (toolChoice) {
params.toolConfig = {
functionCallingConfig: toolChoice,
};
const tools = convertGoogleTools(context.tools);
if (!tools && requiresGoogleToolChoice(options?.toolChoice)) {
throw new Error("Google tool choice requires at least one valid tool declaration");
}
if (tools) {
const declarationNames = collectFunctionDeclarationNames(tools);
const pinnedFunctionName = getPinnedFunctionToolName(options?.toolChoice);
if (pinnedFunctionName && !declarationNames.has(pinnedFunctionName)) {
throw new Error(`Google tool choice references an unavailable tool: ${pinnedFunctionName}`);
}
params.tools = tools;
const toolChoice = mapToolChoice(options?.toolChoice);
if (toolChoice) {
params.toolConfig = {
functionCallingConfig: toolChoice,
};
}
}
}
return params;

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { Context, Tool } from "../types.js";
import { convertMessages, convertTools } from "./google-shared.js";
import {
buildGoogleGenerateContentParams,
convertMessages,
convertTools,
} from "./google-shared.js";
import {
asRecord,
expectConvertedRoles,
@@ -148,6 +152,171 @@ describe("google-shared convertTools", () => {
expect(config.required).toEqual(["retries"]);
expect(params.required).toEqual(["config"]);
});
it("preserves reserved JSON Schema property names", () => {
const parameters = JSON.parse(
'{"type":"object","properties":{"__proto__":{"type":"string"}},"required":["__proto__"]}',
) as Record<string, unknown>;
const converted = convertTools([
{
name: "lookup",
description: "Lookup",
parameters,
},
] as unknown as Tool[]);
const params = getFirstToolParameters(
converted as Parameters<typeof getFirstToolParameters>[0],
);
const properties = asRecord(params.properties);
expect(Object.hasOwn(properties, "__proto__")).toBe(true);
expect(Reflect.get(properties, "__proto__")).toEqual({ type: "string" });
expect(params.required).toEqual(["__proto__"]);
});
it("skips unreadable tool schemas while preserving healthy declarations", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
const cyclicSchema: Record<string, unknown> = { type: "object" };
cyclicSchema.properties = { self: cyclicSchema };
const sharedProperty = { type: "string" };
const converted = convertTools([
{
get name(): string {
throw new Error("tool name unavailable");
},
description: "Broken",
parameters: {},
} as Tool,
{
name: "revoked",
description: "Broken",
parameters: proxy,
} as unknown as Tool,
{
name: "cyclic",
description: "Broken",
parameters: cyclicSchema,
} as unknown as Tool,
{
name: "lookup",
description: "Lookup",
parameters: {
type: "object",
properties: {
first: sharedProperty,
second: sharedProperty,
},
required: ["first", "second"],
},
},
]);
const declarations = converted?.[0]?.functionDeclarations ?? [];
expect(declarations).toEqual([
{
name: "lookup",
description: "Lookup",
parametersJsonSchema: {
type: "object",
properties: {
first: { type: "string" },
second: { type: "string" },
},
required: ["first", "second"],
},
},
]);
});
it("keeps OpenAPI parameter cleanup while skipping unreadable schemas", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
const converted = convertTools(
[
{
name: "revoked",
description: "Broken",
parameters: proxy,
} as unknown as Tool,
{
name: "lookup",
description: "Lookup",
parameters: {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {
query: { type: "string" },
},
},
} as unknown as Tool,
],
true,
);
expect(converted?.[0]?.functionDeclarations).toEqual([
{
name: "lookup",
description: "Lookup",
parameters: {
type: "object",
properties: {
query: { type: "string" },
},
},
},
]);
});
});
describe("google-shared buildGoogleGenerateContentParams", () => {
it("omits optional tools when every Google tool declaration is skipped", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
const params = buildGoogleGenerateContentParams(
makeModel("gemini-2.5-pro"),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "revoked",
description: "Broken",
parameters: proxy,
} as unknown as Tool,
],
},
{ toolChoice: "auto" },
);
expect(params.config?.tools).toBeUndefined();
expect(params.config?.toolConfig).toBeUndefined();
});
it("throws when forced Google tool choice has no valid declarations", () => {
const { proxy, revoke } = Proxy.revocable({}, {});
revoke();
expect(() =>
buildGoogleGenerateContentParams(
makeModel("gemini-2.5-pro"),
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
tools: [
{
name: "revoked",
description: "Broken",
parameters: proxy,
} as unknown as Tool,
],
},
{ toolChoice: "any" },
),
).toThrow("Google tool choice requires at least one valid tool declaration");
});
});
describe("google-shared convertMessages", () => {

View File

@@ -337,23 +337,88 @@ const JSON_SCHEMA_META_DECLARATIONS = new Set([
"$defs",
"definitions", // pre-draft-2019-09 equivalent of $defs
]);
const MAX_GOOGLE_TOOL_SCHEMA_DEPTH = 64;
const MAX_GOOGLE_TOOL_SCHEMA_NODES = 10_000;
/**
* Strip meta-declarations from a schema obj
*/
function sanitizeForOpenApi(schema: unknown): unknown {
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
return materializeGoogleToolSchema(schema, { stripMetaDeclarations: true });
}
function materializeGoogleToolSchema(
schema: unknown,
options: { stripMetaDeclarations: boolean },
): unknown {
const state = { nodes: 0 };
return materializeGoogleToolSchemaValue(schema, options, state, 0, new Set<object>());
}
function materializeGoogleToolSchemaValue(
schema: unknown,
options: { stripMetaDeclarations: boolean },
state: { nodes: number },
depth: number,
stack: Set<object>,
): unknown {
if (depth > MAX_GOOGLE_TOOL_SCHEMA_DEPTH) {
throw new Error("Google tool schema exceeds maximum supported depth");
}
if (++state.nodes > MAX_GOOGLE_TOOL_SCHEMA_NODES) {
throw new Error("Google tool schema exceeds maximum supported size");
}
if (typeof schema !== "object" || schema === null) {
return schema;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
if (JSON_SCHEMA_META_DECLARATIONS.has(key)) {
continue;
}
result[key] = sanitizeForOpenApi(value);
if (stack.has(schema)) {
throw new Error("Google tool schema contains a cycle");
}
stack.add(schema);
try {
if (Array.isArray(schema)) {
return schema.map((item) =>
materializeGoogleToolSchemaValue(item, options, state, depth + 1, stack),
);
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
if (options.stripMetaDeclarations && JSON_SCHEMA_META_DECLARATIONS.has(key)) {
continue;
}
Object.defineProperty(result, key, {
value: materializeGoogleToolSchemaValue(value, options, state, depth + 1, stack),
enumerable: true,
configurable: true,
writable: true,
});
}
return result;
} finally {
stack.delete(schema);
}
}
function buildGoogleFunctionDeclaration(
tool: Tool,
useParameters: boolean,
): Record<string, unknown> | undefined {
try {
const name = tool.name;
const description = tool.description;
const parameters = useParameters
? sanitizeForOpenApi(tool.parameters)
: materializeGoogleToolSchema(tool.parameters, { stripMetaDeclarations: false });
return {
name,
description,
...(useParameters ? { parameters } : { parametersJsonSchema: parameters }),
};
} catch {
return undefined;
}
return result;
}
/**
@@ -371,15 +436,16 @@ export function convertTools(
if (tools.length === 0) {
return undefined;
}
const functionDeclarations = tools.flatMap((tool) => {
const declaration = buildGoogleFunctionDeclaration(tool, useParameters);
return declaration ? [declaration] : [];
});
if (functionDeclarations.length === 0) {
return undefined;
}
return [
{
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
...(useParameters
? { parameters: sanitizeForOpenApi(tool.parameters as unknown) }
: { parametersJsonSchema: tool.parameters }),
})),
functionDeclarations,
},
];
}
@@ -400,6 +466,10 @@ export function mapToolChoice(choice: string): FunctionCallingConfigMode {
}
}
function requiresGoogleToolChoice(choice: GoogleProviderOptions["toolChoice"]): boolean {
return choice === "any";
}
export function createGoogleAssistantOutput<T extends GoogleApiType>(
model: Model<T>,
api: Api = model.api,
@@ -485,13 +555,17 @@ export function buildGoogleGenerateContentParams<T extends GoogleApiType>(
generationConfig.stopSequences = options.stop;
}
const tools = context.tools && context.tools.length > 0 ? convertTools(context.tools) : undefined;
if (!tools && context.tools?.length && requiresGoogleToolChoice(options.toolChoice)) {
throw new Error("Google tool choice requires at least one valid tool declaration");
}
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
...(tools && { tools }),
};
if (context.tools && context.tools.length > 0 && options.toolChoice) {
if (tools && options.toolChoice) {
config.toolConfig = {
functionCallingConfig: {
mode: mapToolChoice(options.toolChoice),