diff --git a/src/common/utils/tools/toolDefinitions.test.ts b/src/common/utils/tools/toolDefinitions.test.ts index b74ab9c6fd..38fc753435 100644 --- a/src/common/utils/tools/toolDefinitions.test.ts +++ b/src/common/utils/tools/toolDefinitions.test.ts @@ -2,6 +2,7 @@ import { RUNTIME_MODE } from "@/common/types/runtime"; import { buildTaskToolDescription, getAvailableTools, + supportsGoogleNativeToolsWithFunctionTools, TaskToolArgsSchema, TOOL_DEFINITIONS, } from "./toolDefinitions"; @@ -424,6 +425,31 @@ describe("TOOL_DEFINITIONS", () => { expect(enabledTools).toContain("workflow_run"); }); + it("gates native Google tools to Gemini 3 models", () => { + expect(getAvailableTools("google:gemini-2.5-pro")).not.toContain("google_search"); + expect(getAvailableTools("google:gemini-2.5-pro")).not.toContain("url_context"); + expect(getAvailableTools("google:gemini-4-pro")).not.toContain("google_search"); + expect(getAvailableTools("google:gemini-4-pro")).not.toContain("url_context"); + + for (const modelString of [ + "google:gemini-3.1-pro-preview", + "google:gemini-3.5-flash", + "google:models/gemini-3.5-flash", + ]) { + const tools = getAvailableTools(modelString); + expect(tools).toContain("google_search"); + expect(tools).toContain("url_context"); + } + }); + + it("classifies Gemini 3 as supporting mixed native Google and function tools", () => { + expect(supportsGoogleNativeToolsWithFunctionTools("gemini-2.5-pro")).toBe(false); + expect(supportsGoogleNativeToolsWithFunctionTools("gemini-3.1-pro-preview")).toBe(true); + expect(supportsGoogleNativeToolsWithFunctionTools("gemini-3.5-flash")).toBe(true); + expect(supportsGoogleNativeToolsWithFunctionTools("models/gemini-3.5-flash")).toBe(true); + expect(supportsGoogleNativeToolsWithFunctionTools("gemini-4-pro")).toBe(false); + }); + it("agent_skill_write schema rejects an advertise tool argument (advertise is authored in content)", () => { const parsed = TOOL_DEFINITIONS.agent_skill_write.schema.safeParse({ name: "demo-skill", diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index bcbcc9d046..70f204f5ed 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -2332,6 +2332,19 @@ export function getToolSchemas(): Record { ); } +/** + * Google's mixed built-in + function tool path is currently supported for Gemini 3. + * Keep native Google tools gated here so the prompt allowlist matches the actual toolset. + */ +export function supportsGoogleNativeToolsWithFunctionTools(modelId: string): boolean { + const bareModelId = modelId.split("/").pop() ?? modelId; + const match = /^gemini-(\d+)(?:[.-]|$)/.exec(bareModelId); + if (!match) return false; + const major = Number.parseInt(match[1], 10); + // The installed @ai-sdk/google mixed native/function serialization path is Gemini 3-only. + return major === 3; +} + /** * Get which tools are available for a given model * @param modelString The model string (e.g., "anthropic:claude-opus-4-1") @@ -2348,7 +2361,7 @@ export function getAvailableTools( enableMuxGlobalAgentsTools?: boolean; } ): string[] { - const [provider] = modelString.split(":"); + const [provider, modelId = ""] = modelString.split(":"); const enableAgentReport = options?.enableAgentReport ?? true; const enableAnalyticsQuery = options?.enableAnalyticsQuery ?? true; const enableAdvisor = options?.enableAdvisor ?? false; @@ -2416,7 +2429,10 @@ export function getAvailableTools( } return baseTools; case "google": - return [...baseTools, "google_search"]; + if (supportsGoogleNativeToolsWithFunctionTools(modelId)) { + return [...baseTools, "google_search", "url_context"]; + } + return baseTools; default: return baseTools; } diff --git a/src/common/utils/tools/tools.test.ts b/src/common/utils/tools/tools.test.ts index 69f6140c58..f46f44665d 100644 --- a/src/common/utils/tools/tools.test.ts +++ b/src/common/utils/tools/tools.test.ts @@ -223,6 +223,67 @@ describe("getToolsForModel", () => { expect(Object.keys(tools).filter((toolName) => toolName.startsWith("desktop_"))).toEqual([]); }); + test("adds native Google Search and URL Context only for Gemini 3 models", async () => { + const runtime = new LocalRuntime(process.cwd()); + const initStateManager = createInitStateManager(); + + const gemini25Tools = await getToolsForModel( + "google:gemini-2.5-pro", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + workspaceId: "ws-1", + }, + "ws-1", + initStateManager + ); + expect(gemini25Tools.google_search).toBeUndefined(); + expect(gemini25Tools.url_context).toBeUndefined(); + + const gemini4Tools = await getToolsForModel( + "google:gemini-4-pro", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + workspaceId: "ws-1", + }, + "ws-1", + initStateManager + ); + expect(gemini4Tools.google_search).toBeUndefined(); + expect(gemini4Tools.url_context).toBeUndefined(); + + const gemini35Tools = await getToolsForModel( + "google:gemini-3.5-flash", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + workspaceId: "ws-1", + }, + "ws-1", + initStateManager + ); + const namespacedGemini35Tools = await getToolsForModel( + "google:models/gemini-3.5-flash", + { + cwd: process.cwd(), + runtime, + runtimeTempDir: "/tmp", + workspaceId: "ws-1", + }, + "ws-1", + initStateManager + ); + expect(namespacedGemini35Tools.google_search).toBeDefined(); + expect(namespacedGemini35Tools.url_context).toBeDefined(); + + expect(gemini35Tools.google_search).toBeDefined(); + expect(gemini35Tools.url_context).toBeDefined(); + }); + test("returns tool keys in sorted order", async () => { const runtime = new LocalRuntime(process.cwd()); const initStateManager = createInitStateManager(); diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 9277fe3a1f..88801c86e3 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -53,7 +53,10 @@ import { log } from "@/node/services/log"; import { attachModelOnlyToolNotifications } from "@/common/utils/tools/internalToolResultFields"; import { NotificationEngine } from "@/node/services/agentNotifications/NotificationEngine"; import { TodoListReminderSource } from "@/node/services/agentNotifications/sources/TodoListReminderSource"; -import { getAvailableTools } from "@/common/utils/tools/toolDefinitions"; +import { + getAvailableTools, + supportsGoogleNativeToolsWithFunctionTools, +} from "@/common/utils/tools/toolDefinitions"; import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer"; import type { Runtime } from "@/node/runtime/Runtime"; @@ -594,10 +597,20 @@ export async function getToolsForModel( break; } - // Note: Gemini 3 tool support: - // Combining native tools with function calling is currently only - // supported in the Live API. Thus no `google_search` or `url_context` added here. - // - https://ai.google.dev/gemini-api/docs/function-calling?example=meeting#native-tools + case "google": { + if (supportsGoogleNativeToolsWithFunctionTools(modelId)) { + const { google } = await import("@ai-sdk/google"); + allTools = { + ...baseTools, + ...(mcpTools ?? {}), + // Google exposes native Search and URL Context as provider-executed tools for + // Gemini 3+. These coexist with Mux function tools in the standard streaming API. + google_search: google.tools.googleSearch({}) as Tool, + url_context: google.tools.urlContext({}) as Tool, + }; + } + break; + } } } catch (error) { // If tools aren't available, just use base tools diff --git a/src/node/utils/main/tokenizer.test.ts b/src/node/utils/main/tokenizer.test.ts index a23c8db4c1..39eb10974c 100644 --- a/src/node/utils/main/tokenizer.test.ts +++ b/src/node/utils/main/tokenizer.test.ts @@ -74,4 +74,16 @@ describe("tokenizer", () => { ); expect(tokens).toBeGreaterThan(0); }); + + test("uses native Google tool token fallbacks for Gemini 3", async () => { + await expect( + getToolDefinitionTokens("url_context", "google:gemini-2.5-pro", undefined, undefined) + ).resolves.toBe(0); + await expect( + getToolDefinitionTokens("url_context", "google:gemini-3.5-flash", undefined, undefined) + ).resolves.toBe(50); + await expect( + getToolDefinitionTokens("google_search", "google:gemini-3.5-flash", undefined, undefined) + ).resolves.toBe(50); + }); }); diff --git a/src/node/utils/main/tokenizer.ts b/src/node/utils/main/tokenizer.ts index 395b6037a8..994e2b2b37 100644 --- a/src/node/utils/main/tokenizer.ts +++ b/src/node/utils/main/tokenizer.ts @@ -243,6 +243,17 @@ export function countTokensForData(data: unknown, tokenizer: Tokenizer): Promise return tokenizer.countTokens(serialized); } +const TOOL_DEFINITION_FALLBACK_TOKENS: Record = { + bash: 65, + file_read: 45, + file_edit_replace_string: 70, + file_edit_replace_lines: 80, + file_edit_insert: 50, + web_search: 50, + google_search: 50, + url_context: 50, +}; + export async function getToolDefinitionTokens( toolName: string, modelString: string, @@ -260,22 +271,13 @@ export async function getToolDefinitionTokens( const toolSchemas = getToolSchemas(); const toolSchema = toolSchemas[toolName]; if (!toolSchema) { - return 40; + return TOOL_DEFINITION_FALLBACK_TOKENS[toolName] ?? 40; } const tokenizerModel = metadataModelOverride ?? modelString; return countTokens(tokenizerModel, JSON.stringify(toolSchema)); } catch { - const fallbackSizes: Record = { - bash: 65, - file_read: 45, - file_edit_replace_string: 70, - file_edit_replace_lines: 80, - file_edit_insert: 50, - web_search: 50, - google_search: 50, - }; - return fallbackSizes[toolName] ?? 40; + return TOOL_DEFINITION_FALLBACK_TOKENS[toolName] ?? 40; } }