diff --git a/packages/core/src/agents/browser/browserAgentDefinition.ts b/packages/core/src/agents/browser/browserAgentDefinition.ts new file mode 100644 index 0000000000..a4a1603f1e --- /dev/null +++ b/packages/core/src/agents/browser/browserAgentDefinition.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Browser Agent definition following the LocalAgentDefinition pattern. + * + * This agent uses LocalAgentExecutor for its reAct loop, like CodebaseInvestigatorAgent. + * It is available ONLY via delegate_to_agent, NOT as a direct tool. + * + * Tools are configured dynamically at invocation time via browserAgentFactory. + */ + +import type { LocalAgentDefinition } from '../types.js'; +import type { Config } from '../../config/config.js'; +import { z } from 'zod'; +import { + isPreviewModel, + PREVIEW_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_FLASH_MODEL, +} from '../../config/models.js'; + +/** Canonical agent name — used for routing and configuration lookup. */ +export const BROWSER_AGENT_NAME = 'browser_agent'; + +/** + * Output schema for browser agent results. + */ +export const BrowserTaskResultSchema = z.object({ + success: z.boolean().describe('Whether the task was completed successfully'), + summary: z + .string() + .describe('A summary of what was accomplished or what went wrong'), + data: z + .unknown() + .optional() + .describe('Optional extracted data from the task'), +}); + +/** + * System prompt for the semantic browser agent. + * Extracted from prototype (computer_use_subagent_cdt branch). + */ +export const BROWSER_SYSTEM_PROMPT = `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request. + +IMPORTANT: You will receive an accessibility tree snapshot showing elements with uid values (e.g., uid=87_4 button "Login"). +Use these uid values directly with your tools: +- click(uid="87_4") to click the Login button +- fill(uid="87_2", value="john") to fill a text field +- fill_form(elements=[{uid: "87_2", value: "john"}, {uid: "87_3", value: "pass"}]) to fill multiple fields at once + +PARALLEL TOOL CALLS - CRITICAL: +- Do NOT make parallel calls for actions that change page state (click, fill, press_key, etc.) +- Each action changes the DOM and invalidates UIDs from the current snapshot +- Make state-changing actions ONE AT A TIME, then observe the results +- For typing text, prefer type_text tool instead of multiple press_key calls + +OVERLAY/POPUP HANDLING: +Before interacting with page content, scan the accessibility tree for blocking overlays: +- Tooltips, popups, modals, cookie banners, newsletter prompts, promo dialogs +- These often have: close buttons (×, X, Close, Dismiss), "Got it", "Accept", "No thanks" buttons +- Common patterns: elements with role="dialog", role="tooltip", role="alertdialog", or aria-modal="true" +- If you see such elements, DISMISS THEM FIRST by clicking close/dismiss buttons before proceeding +- If a click seems to have no effect, check if an overlay appeared or is blocking the target + +For complex visual interactions (coordinate-based clicks, dragging) OR when you need to identify elements by visual attributes not present in the AX tree (e.g., "click the yellow button", "find the red error message"), use delegate_to_visual_agent with a clear instruction. + +CRITICAL: When you have fully completed the user's task, you MUST call the complete_task tool with a summary of what you accomplished. Do NOT just return text - you must explicitly call complete_task to exit the loop.`; + +/** + * Browser Agent Definition Factory. + * + * Following the CodebaseInvestigatorAgent pattern: + * - Returns a factory function that takes Config for dynamic model selection + * - kind: 'local' for LocalAgentExecutor + * - toolConfig is set dynamically by browserAgentFactory + */ +export const BrowserAgentDefinition = ( + config: Config, +): LocalAgentDefinition => { + // Use Preview Flash model if the main model is any of the preview models. + // If the main model is not a preview model, use the default flash model. + const model = isPreviewModel(config.getModel()) + ? PREVIEW_GEMINI_FLASH_MODEL + : DEFAULT_GEMINI_FLASH_MODEL; + + return { + name: BROWSER_AGENT_NAME, + kind: 'local', + experimental: true, + displayName: 'Browser Agent', + description: `Specialized agent for web browser automation using the Accessibility Tree. + Use this agent for: navigating websites, filling forms, clicking buttons, + extracting information from web pages. It can see and interact with the page + structure semantically through the accessibility tree.`, + + inputConfig: { + inputSchema: { + type: 'object', + properties: { + task: { + type: 'string', + description: 'The task to perform in the browser.', + }, + }, + required: ['task'], + }, + }, + + outputConfig: { + outputName: 'result', + description: 'The result of the browser task.', + schema: BrowserTaskResultSchema, + }, + + processOutput: (output) => JSON.stringify(output, null, 2), + + modelConfig: { + // Dynamic model based on whether user is using preview models + model, + generateContentConfig: { + temperature: 0.1, + topP: 0.95, + }, + }, + + runConfig: { + maxTimeMinutes: 10, + maxTurns: 50, + }, + + // Tools are set dynamically by browserAgentFactory after MCP connection + // This is undefined here and will be set at invocation time + toolConfig: undefined, + + promptConfig: { + query: `Your task is: + +\${task} + + +First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`, + systemPrompt: BROWSER_SYSTEM_PROMPT, + }, + }; +}; diff --git a/packages/core/src/agents/browser/browserAgentFactory.test.ts b/packages/core/src/agents/browser/browserAgentFactory.test.ts new file mode 100644 index 0000000000..11ccde56b9 --- /dev/null +++ b/packages/core/src/agents/browser/browserAgentFactory.test.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createBrowserAgentDefinition, + cleanupBrowserAgent, +} from './browserAgentFactory.js'; +import { makeFakeConfig } from '../../test-utils/config.js'; +import type { Config } from '../../config/config.js'; +import type { MessageBus } from '../../confirmation-bus/message-bus.js'; +import { BrowserAgentDefinition } from './browserAgentDefinition.js'; +import type { BrowserManager } from './browserManager.js'; + +// Create mock browser manager +const mockBrowserManager = { + ensureConnection: vi.fn().mockResolvedValue(undefined), + getDiscoveredTools: vi.fn().mockResolvedValue([ + { name: 'take_snapshot', description: 'Take snapshot' }, + { name: 'click', description: 'Click element' }, + ]), + callTool: vi.fn().mockResolvedValue({ content: [] }), + close: vi.fn().mockResolvedValue(undefined), +}; + +// Mock dependencies +vi.mock('./browserManager.js', () => ({ + BrowserManager: vi.fn(() => mockBrowserManager), +})); + +vi.mock('../../utils/debugLogger.js', () => ({ + debugLogger: { + log: vi.fn(), + error: vi.fn(), + }, +})); + +describe('browserAgentFactory', () => { + let mockConfig: Config; + let mockMessageBus: MessageBus; + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset mock implementations + mockBrowserManager.ensureConnection.mockResolvedValue(undefined); + mockBrowserManager.getDiscoveredTools.mockResolvedValue([ + { name: 'take_snapshot', description: 'Take snapshot' }, + { name: 'click', description: 'Click element' }, + ]); + mockBrowserManager.close.mockResolvedValue(undefined); + + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + customConfig: { + headless: false, + }, + }, + }, + }, + }); + + mockMessageBus = { + publish: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as MessageBus; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('createBrowserAgentDefinition', () => { + it('should ensure browser connection', async () => { + await createBrowserAgentDefinition(mockConfig, mockMessageBus); + + expect(mockBrowserManager.ensureConnection).toHaveBeenCalled(); + }); + + it('should return agent definition with discovered tools', async () => { + const { definition } = await createBrowserAgentDefinition( + mockConfig, + mockMessageBus, + ); + + expect(definition.name).toBe(BrowserAgentDefinition.name); + expect(definition.toolConfig?.tools).toHaveLength(2); + }); + + it('should return browser manager for cleanup', async () => { + const { browserManager } = await createBrowserAgentDefinition( + mockConfig, + mockMessageBus, + ); + + expect(browserManager).toBeDefined(); + }); + + it('should call printOutput when provided', async () => { + const printOutput = vi.fn(); + + await createBrowserAgentDefinition( + mockConfig, + mockMessageBus, + printOutput, + ); + + expect(printOutput).toHaveBeenCalled(); + }); + + it('should create definition with correct structure', async () => { + const { definition } = await createBrowserAgentDefinition( + mockConfig, + mockMessageBus, + ); + + expect(definition.kind).toBe('local'); + expect(definition.inputConfig).toBeDefined(); + expect(definition.outputConfig).toBeDefined(); + expect(definition.promptConfig).toBeDefined(); + }); + }); + + describe('cleanupBrowserAgent', () => { + it('should call close on browser manager', async () => { + await cleanupBrowserAgent( + mockBrowserManager as unknown as BrowserManager, + ); + + expect(mockBrowserManager.close).toHaveBeenCalled(); + }); + + it('should handle errors during cleanup gracefully', async () => { + const errorManager = { + close: vi.fn().mockRejectedValue(new Error('Close failed')), + } as unknown as BrowserManager; + + // Should not throw + await expect(cleanupBrowserAgent(errorManager)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agents/browser/browserAgentFactory.ts b/packages/core/src/agents/browser/browserAgentFactory.ts new file mode 100644 index 0000000000..ae8138de9f --- /dev/null +++ b/packages/core/src/agents/browser/browserAgentFactory.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Factory for creating browser agent definitions with configured tools. + * + * This factory is called when the browser agent is invoked via delegate_to_agent. + * It creates a BrowserManager, connects the isolated MCP client, wraps tools, + * and returns a fully configured LocalAgentDefinition. + * + * IMPORTANT: The MCP tools are ONLY available to the browser agent's isolated + * registry. They are NOT registered in the main agent's ToolRegistry. + */ + +import type { Config } from '../../config/config.js'; +import type { LocalAgentDefinition } from '../types.js'; +import type { MessageBus } from '../../confirmation-bus/message-bus.js'; +import { BrowserManager } from './browserManager.js'; +import { + BrowserAgentDefinition, + type BrowserTaskResultSchema, +} from './browserAgentDefinition.js'; +import { createMcpDeclarativeTools } from './mcpToolWrapper.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +/** + * Creates a browser agent definition with MCP tools configured. + * + * This is called when the browser agent is invoked via delegate_to_agent. + * The MCP client is created fresh and tools are wrapped for the agent's + * isolated registry - NOT registered with the main agent. + * + * @param config Runtime configuration + * @param messageBus Message bus for tool invocations + * @param printOutput Optional callback for progress messages + * @returns Fully configured LocalAgentDefinition with MCP tools + */ +export async function createBrowserAgentDefinition( + config: Config, + messageBus: MessageBus, + printOutput?: (msg: string) => void, +): Promise<{ + definition: LocalAgentDefinition; + browserManager: BrowserManager; +}> { + debugLogger.log( + 'Creating browser agent definition with isolated MCP tools...', + ); + + // Create and initialize browser manager with isolated MCP client + const browserManager = new BrowserManager(config); + await browserManager.ensureConnection(); + + if (printOutput) { + printOutput('Browser connected with isolated MCP client.'); + } + + // Create declarative tools from dynamically discovered MCP tools + // These tools dispatch to browserManager's isolated client + const mcpTools = await createMcpDeclarativeTools(browserManager, messageBus); + + debugLogger.log( + `Created ${mcpTools.length} isolated MCP tools for browser agent: ` + + mcpTools.map((t) => t.name).join(', '), + ); + + // Create configured definition with tools + const definition: LocalAgentDefinition = { + ...BrowserAgentDefinition, + toolConfig: { + tools: mcpTools, + }, + }; + + return { definition, browserManager }; +} + +/** + * Cleans up browser resources after agent execution. + * + * @param browserManager The browser manager to clean up + */ +export async function cleanupBrowserAgent( + browserManager: BrowserManager, +): Promise { + try { + await browserManager.close(); + debugLogger.log('Browser agent cleanup complete'); + } catch (error) { + debugLogger.error( + `Error during browser cleanup: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts new file mode 100644 index 0000000000..fd9b8edaa8 --- /dev/null +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BrowserAgentInvocation } from './browserAgentInvocation.js'; +import { makeFakeConfig } from '../../test-utils/config.js'; +import type { Config } from '../../config/config.js'; +import type { MessageBus } from '../../confirmation-bus/message-bus.js'; +import type { AgentInputs } from '../types.js'; + +// Mock dependencies before imports +vi.mock('../../utils/debugLogger.js', () => ({ + debugLogger: { + log: vi.fn(), + error: vi.fn(), + }, +})); + +describe('BrowserAgentInvocation', () => { + let mockConfig: Config; + let mockMessageBus: MessageBus; + let mockParams: AgentInputs; + + beforeEach(() => { + vi.clearAllMocks(); + + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + customConfig: { + headless: false, + }, + }, + }, + }, + }); + + mockMessageBus = { + publish: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as MessageBus; + + mockParams = { + task: 'Navigate to example.com and click the button', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create invocation with params', () => { + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + expect(invocation.params).toEqual(mockParams); + }); + + it('should use browser_agent as default tool name', () => { + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + expect(invocation['_toolName']).toBe('browser_agent'); + }); + + it('should use custom tool name if provided', () => { + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + 'custom_name', + 'Custom Display Name', + ); + + expect(invocation['_toolName']).toBe('custom_name'); + expect(invocation['_toolDisplayName']).toBe('Custom Display Name'); + }); + }); + + describe('getDescription', () => { + it('should return description with input summary', () => { + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + const description = invocation.getDescription(); + + expect(description).toContain('browser agent'); + expect(description).toContain('task'); + }); + + it('should truncate long input values', () => { + const longParams = { + task: 'A'.repeat(100), + }; + + const invocation = new BrowserAgentInvocation( + mockConfig, + longParams, + mockMessageBus, + ); + + const description = invocation.getDescription(); + + // Should be truncated to max length + expect(description.length).toBeLessThanOrEqual(200); + }); + }); + + describe('toolLocations', () => { + it('should return empty array by default', () => { + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + const locations = invocation.toolLocations(); + + expect(locations).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts new file mode 100644 index 0000000000..75475b5211 --- /dev/null +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Browser agent invocation that handles async tool setup. + * + * Unlike regular LocalSubagentInvocation, this invocation: + * 1. Uses browserAgentFactory to create definition with MCP tools + * 2. Cleans up browser resources after execution + * + * The MCP tools are only available in the browser agent's isolated registry. + */ + +import type { Config } from '../../config/config.js'; +import { LocalAgentExecutor } from '../local-executor.js'; +import type { AnsiOutput } from '../../utils/terminalSerializer.js'; +import { BaseToolInvocation, type ToolResult } from '../../tools/tools.js'; +import { ToolErrorType } from '../../tools/tool-error.js'; +import type { AgentInputs, SubagentActivityEvent } from '../types.js'; +import type { MessageBus } from '../../confirmation-bus/message-bus.js'; +import { + createBrowserAgentDefinition, + cleanupBrowserAgent, +} from './browserAgentFactory.js'; +import { BrowserAgentDefinition } from './browserAgentDefinition.js'; + +const INPUT_PREVIEW_MAX_LENGTH = 50; +const DESCRIPTION_MAX_LENGTH = 200; + +/** + * Browser agent invocation with async tool setup. + * + * This invocation handles the browser agent's special requirements: + * - MCP connection and tool wrapping at invocation time + * - Browser cleanup after execution + */ +export class BrowserAgentInvocation extends BaseToolInvocation< + AgentInputs, + ToolResult +> { + constructor( + private readonly config: Config, + params: AgentInputs, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ) { + super( + params, + messageBus, + _toolName ?? BrowserAgentDefinition.name, + _toolDisplayName ?? BrowserAgentDefinition.displayName, + ); + } + + /** + * Returns a concise, human-readable description of the invocation. + */ + getDescription(): string { + const inputSummary = Object.entries(this.params) + .map( + ([key, value]) => + `${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`, + ) + .join(', '); + + const description = `Running browser agent with inputs: { ${inputSummary} }`; + return description.slice(0, DESCRIPTION_MAX_LENGTH); + } + + /** + * Executes the browser agent. + * + * This method: + * 1. Creates browser manager and MCP connection + * 2. Wraps MCP tools for the isolated registry + * 3. Runs the agent via LocalAgentExecutor + * 4. Cleans up browser resources + */ + async execute( + signal: AbortSignal, + updateOutput?: (output: string | AnsiOutput) => void, + ): Promise { + let browserManager; + + try { + if (updateOutput) { + updateOutput('🌐 Starting browser agent...\n'); + } + + // Create definition with MCP tools + const printOutput = updateOutput + ? (msg: string) => updateOutput(`🌐 ${msg}\n`) + : undefined; + + const result = await createBrowserAgentDefinition( + this.config, + this.messageBus, + printOutput, + ); + const { definition } = result; + browserManager = result.browserManager; + + if (updateOutput) { + updateOutput( + `🌐 Browser connected. Tools: ${definition.toolConfig?.tools.length ?? 0}\n`, + ); + } + + // Create activity callback for streaming output + const onActivity = (activity: SubagentActivityEvent): void => { + if (!updateOutput) return; + + if ( + activity.type === 'THOUGHT_CHUNK' && + typeof activity.data['text'] === 'string' + ) { + updateOutput(`🌐💭 ${activity.data['text']}`); + } + }; + + // Create and run executor with the configured definition + const executor = await LocalAgentExecutor.create( + definition, + this.config, + onActivity, + ); + + const output = await executor.run(this.params, signal); + + const resultContent = `Browser agent finished. +Termination Reason: ${output.terminate_reason} +Result: +${output.result}`; + + const displayContent = ` +Browser Agent Finished + +Termination Reason: ${output.terminate_reason} + +Result: +${output.result} +`; + + return { + llmContent: [{ text: resultContent }], + returnDisplay: displayContent, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + return { + llmContent: `Browser agent failed. Error: ${errorMessage}`, + returnDisplay: `Browser Agent Failed\nError: ${errorMessage}`, + error: { + message: errorMessage, + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } finally { + // Always cleanup browser resources + if (browserManager) { + await cleanupBrowserAgent(browserManager); + } + } + } +} diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts new file mode 100644 index 0000000000..d24e2c0a7b --- /dev/null +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BrowserManager } from './browserManager.js'; +import { makeFakeConfig } from '../../test-utils/config.js'; +import type { Config } from '../../config/config.js'; + +// Mock the MCP SDK +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: vi.fn().mockImplementation(() => ({ + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn().mockResolvedValue({ + tools: [ + { name: 'take_snapshot', description: 'Take a snapshot' }, + { name: 'click', description: 'Click an element' }, + { name: 'click_at', description: 'Click at coordinates' }, + { name: 'take_screenshot', description: 'Take a screenshot' }, + ], + }), + callTool: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Tool result' }], + }), + })), +})); + +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: vi.fn().mockImplementation(() => ({ + close: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock('../../utils/debugLogger.js', () => ({ + debugLogger: { + log: vi.fn(), + error: vi.fn(), + }, +})); + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +describe('BrowserManager', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.resetAllMocks(); + + // Setup mock config + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + customConfig: { + headless: false, + }, + }, + }, + }, + }); + + // Re-setup Client mock after reset + vi.mocked(Client).mockImplementation( + () => + ({ + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn().mockResolvedValue({ + tools: [ + { name: 'take_snapshot', description: 'Take a snapshot' }, + { name: 'click', description: 'Click an element' }, + { name: 'click_at', description: 'Click at coordinates' }, + { name: 'take_screenshot', description: 'Take a screenshot' }, + ], + }), + callTool: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Tool result' }], + }), + }) as unknown as InstanceType, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getRawMcpClient', () => { + it('should ensure connection and return raw MCP client', async () => { + const manager = new BrowserManager(mockConfig); + const client = await manager.getRawMcpClient(); + + expect(client).toBeDefined(); + expect(Client).toHaveBeenCalled(); + }); + + it('should return cached client if already connected', async () => { + const manager = new BrowserManager(mockConfig); + + // First call + const client1 = await manager.getRawMcpClient(); + + // Second call should use cache + const client2 = await manager.getRawMcpClient(); + + expect(client1).toBe(client2); + // Client constructor should only be called once + expect(Client).toHaveBeenCalledTimes(1); + }); + }); + + describe('getDiscoveredTools', () => { + it('should return tools discovered from MCP server including visual tools', async () => { + const manager = new BrowserManager(mockConfig); + const tools = await manager.getDiscoveredTools(); + + expect(tools).toHaveLength(4); + expect(tools.map((t) => t.name)).toContain('take_snapshot'); + expect(tools.map((t) => t.name)).toContain('click'); + expect(tools.map((t) => t.name)).toContain('click_at'); + expect(tools.map((t) => t.name)).toContain('take_screenshot'); + }); + }); + + describe('callTool', () => { + it('should call tool on MCP client and return result', async () => { + const manager = new BrowserManager(mockConfig); + const result = await manager.callTool('take_snapshot', { verbose: true }); + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Tool result' }], + }); + }); + }); + + describe('MCP connection', () => { + it('should spawn npx chrome-devtools-mcp with --isolated and --experimental-vision', async () => { + const manager = new BrowserManager(mockConfig); + await manager.ensureConnection(); + + // Verify StdioClientTransport was created with correct args + expect(StdioClientTransport).toHaveBeenCalledWith({ + command: 'npx', + args: expect.arrayContaining([ + '-y', + expect.stringMatching(/chrome-devtools-mcp@/), + '--isolated', + '--experimental-vision', + ]), + }); + }); + + it('should pass headless flag when configured', async () => { + const headlessConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + customConfig: { + headless: true, + }, + }, + }, + }, + }); + + const manager = new BrowserManager(headlessConfig); + await manager.ensureConnection(); + + expect(StdioClientTransport).toHaveBeenCalledWith({ + command: 'npx', + args: expect.arrayContaining(['--headless']), + }); + }); + + it('should pass chromeProfilePath when configured', async () => { + const profileConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + customConfig: { + chromeProfilePath: '/path/to/profile', + }, + }, + }, + }, + }); + + const manager = new BrowserManager(profileConfig); + await manager.ensureConnection(); + + expect(StdioClientTransport).toHaveBeenCalledWith({ + command: 'npx', + args: expect.arrayContaining(['--profile-path', '/path/to/profile']), + }); + }); + }); + + describe('MCP isolation', () => { + it('should use raw MCP SDK Client, not McpClient wrapper', async () => { + const manager = new BrowserManager(mockConfig); + await manager.ensureConnection(); + + // Verify we're using the raw Client from MCP SDK + expect(Client).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'gemini-cli-browser-agent', + }), + expect.any(Object), + ); + }); + + it('should not use McpClientManager from config', async () => { + // Spy on config method to verify isolation + const getMcpClientManagerSpy = vi.spyOn( + mockConfig, + 'getMcpClientManager', + ); + + const manager = new BrowserManager(mockConfig); + await manager.ensureConnection(); + + // Config's getMcpClientManager should NOT be called + // This ensures isolation from main registry + expect(getMcpClientManagerSpy).not.toHaveBeenCalled(); + }); + }); + + describe('close', () => { + it('should close MCP connections', async () => { + const manager = new BrowserManager(mockConfig); + const client = await manager.getRawMcpClient(); + + await manager.close(); + + expect(client.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts new file mode 100644 index 0000000000..36d624c2c6 --- /dev/null +++ b/packages/core/src/agents/browser/browserManager.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Manages browser lifecycle for the Browser Agent. + * + * Handles: + * - Browser management via chrome-devtools-mcp with --isolated mode + * - CDP connection via raw MCP SDK Client (NOT registered in main registry) + * - Visual tools via --experimental-vision flag + * + * IMPORTANT: The MCP client here is ISOLATED from the main agent's tool registry. + * Tools discovered from chrome-devtools-mcp are NOT registered in the main registry. + * They are wrapped as DeclarativeTools and passed directly to the browser agent. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js'; +import { debugLogger } from '../../utils/debugLogger.js'; +import type { Config } from '../../config/config.js'; + +// Pin chrome-devtools-mcp version for reproducibility +// v0.13.0+ required for --experimental-vision support +const CHROME_DEVTOOLS_MCP_VERSION = '0.13.0'; + +// Default timeout for MCP operations +const MCP_TIMEOUT_MS = 60_000; + +/** + * Content item from an MCP tool call response. + * Can be text or image (for take_screenshot). + */ +export interface McpContentItem { + type: 'text' | 'image'; + text?: string; + /** Base64-encoded image data (for type='image') */ + data?: string; + /** MIME type of the image (e.g., 'image/png') */ + mimeType?: string; +} + +/** + * Result from an MCP tool call. + */ +export interface McpToolCallResult { + content?: McpContentItem[]; + isError?: boolean; +} + +/** + * Manages browser lifecycle and ISOLATED MCP client for the Browser Agent. + * + * The browser is launched and managed by chrome-devtools-mcp in --isolated mode. + * Visual tools (click_at, etc.) are enabled via --experimental-vision flag. + * + * Key isolation property: The MCP client here does NOT register tools + * in the main ToolRegistry. Tools are kept local to the browser agent. + */ +export class BrowserManager { + // Raw MCP SDK Client - NOT the wrapper McpClient + private rawMcpClient: Client | undefined; + private mcpTransport: StdioClientTransport | undefined; + private discoveredTools: McpTool[] = []; + + constructor(private config: Config) {} + + /** + * Gets the raw MCP SDK Client for direct tool calls. + * This client is ISOLATED from the main tool registry. + */ + async getRawMcpClient(): Promise { + if (this.rawMcpClient) { + return this.rawMcpClient; + } + await this.ensureConnection(); + if (!this.rawMcpClient) { + throw new Error('Failed to initialize chrome-devtools MCP client'); + } + return this.rawMcpClient; + } + + /** + * Gets the tool definitions discovered from the MCP server. + * These are dynamically fetched from chrome-devtools-mcp. + */ + async getDiscoveredTools(): Promise { + await this.ensureConnection(); + return this.discoveredTools; + } + + /** + * Calls a tool on the MCP server. + * + * @param toolName The name of the tool to call + * @param args Arguments to pass to the tool + * @param signal Optional AbortSignal to cancel the call + * @returns The result from the MCP server + */ + async callTool( + toolName: string, + args: Record, + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) { + throw signal.reason ?? new Error('Operation cancelled'); + } + + const client = await this.getRawMcpClient(); + const callPromise = client.callTool( + { name: toolName, arguments: args }, + undefined, + { timeout: MCP_TIMEOUT_MS }, + ); + + // If no signal, just await directly + if (!signal) { + return this.toResult(await callPromise); + } + + // Race the call against the abort signal + let onAbort: (() => void) | undefined; + try { + const result = await Promise.race([ + callPromise, + new Promise((_resolve, reject) => { + onAbort = () => + reject(signal.reason ?? new Error('Operation cancelled')); + signal.addEventListener('abort', onAbort, { once: true }); + }), + ]); + return this.toResult(result); + } finally { + if (onAbort) { + signal.removeEventListener('abort', onAbort); + } + } + } + + /** + * Safely maps a raw MCP SDK callTool response to our typed McpToolCallResult + * without using unsafe type assertions. + */ + private toResult( + raw: Awaited>, + ): McpToolCallResult { + return { + content: Array.isArray(raw.content) + ? raw.content.map( + (item: { + type?: string; + text?: string; + data?: string; + mimeType?: string; + }) => ({ + type: (item.type === 'image' ? 'image' : 'text'), + text: item.text, + data: item.data, + mimeType: item.mimeType, + }), + ) + : undefined, + isError: raw.isError === true, + }; + } + + /** + * Ensures browser and MCP client are connected. + */ + async ensureConnection(): Promise { + if (this.rawMcpClient) { + return; + } + await this.connectMcp(); + } + + /** + * Closes browser and cleans up connections. + * The browser process is managed by chrome-devtools-mcp, so closing + * the transport will terminate the browser. + */ + async close(): Promise { + // Close MCP client first + if (this.rawMcpClient) { + try { + await this.rawMcpClient.close(); + } catch (error) { + debugLogger.error( + `Error closing MCP client: ${error instanceof Error ? error.message : String(error)}`, + ); + } + this.rawMcpClient = undefined; + } + + // Close transport (this terminates the npx process and browser) + if (this.mcpTransport) { + try { + await this.mcpTransport.close(); + } catch (error) { + debugLogger.error( + `Error closing MCP transport: ${error instanceof Error ? error.message : String(error)}`, + ); + } + this.mcpTransport = undefined; + } + + this.discoveredTools = []; + } + + /** + * Connects to chrome-devtools-mcp which manages the browser process. + * + * Spawns npx chrome-devtools-mcp with: + * - --isolated: Manages its own browser instance + * - --experimental-vision: Enables visual tools (click_at, etc.) + * + * IMPORTANT: This does NOT use McpClientManager and does NOT register + * tools in the main ToolRegistry. The connection is isolated to this + * BrowserManager instance. + */ + private async connectMcp(): Promise { + debugLogger.log('Connecting isolated MCP client to chrome-devtools-mcp...'); + + // Create raw MCP SDK Client (not the wrapper McpClient) + this.rawMcpClient = new Client( + { + name: 'gemini-cli-browser-agent', + version: '1.0.0', + }, + { + capabilities: {}, + }, + ); + + // Build args for chrome-devtools-mcp + const mcpArgs = [ + '-y', + `chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION}`, + '--isolated', + '--experimental-vision', + ]; + + // Add optional settings from config + const browserConfig = this.config.getBrowserAgentConfig(); + if (browserConfig.customConfig.headless) { + mcpArgs.push('--headless'); + } + if (browserConfig.customConfig.chromeProfilePath) { + mcpArgs.push( + '--profile-path', + browserConfig.customConfig.chromeProfilePath, + ); + } + + debugLogger.log( + `Launching chrome-devtools-mcp with args: ${mcpArgs.join(' ')}`, + ); + + // Create stdio transport to npx chrome-devtools-mcp + this.mcpTransport = new StdioClientTransport({ + command: 'npx', + args: mcpArgs, + }); + + // Connect to MCP server + await this.rawMcpClient.connect(this.mcpTransport); + debugLogger.log('MCP client connected to chrome-devtools-mcp'); + + // Discover tools from the MCP server + await this.discoverTools(); + } + + /** + * Discovers tools from the connected MCP server. + */ + private async discoverTools(): Promise { + if (!this.rawMcpClient) { + throw new Error('MCP client not connected'); + } + + const response = await this.rawMcpClient.listTools(); + this.discoveredTools = response.tools; + + debugLogger.log( + `Discovered ${this.discoveredTools.length} tools from chrome-devtools-mcp: ` + + this.discoveredTools.map((t) => t.name).join(', '), + ); + } +} diff --git a/packages/core/src/agents/browser/mcpToolWrapper.test.ts b/packages/core/src/agents/browser/mcpToolWrapper.test.ts new file mode 100644 index 0000000000..dee5b0eb45 --- /dev/null +++ b/packages/core/src/agents/browser/mcpToolWrapper.test.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createMcpDeclarativeTools } from './mcpToolWrapper.js'; +import type { BrowserManager, McpToolCallResult } from './browserManager.js'; +import type { MessageBus } from '../../confirmation-bus/message-bus.js'; +import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js'; + +describe('mcpToolWrapper', () => { + let mockBrowserManager: BrowserManager; + let mockMessageBus: MessageBus; + let mockMcpTools: McpTool[]; + + beforeEach(() => { + vi.resetAllMocks(); + + // Setup mock MCP tools discovered from server + mockMcpTools = [ + { + name: 'take_snapshot', + description: 'Take a snapshot of the page accessibility tree', + inputSchema: { + type: 'object', + properties: { + verbose: { type: 'boolean', description: 'Include details' }, + }, + }, + }, + { + name: 'click', + description: 'Click on an element by uid', + inputSchema: { + type: 'object', + properties: { + uid: { type: 'string', description: 'Element uid' }, + }, + required: ['uid'], + }, + }, + ]; + + // Setup mock browser manager + mockBrowserManager = { + getDiscoveredTools: vi.fn().mockResolvedValue(mockMcpTools), + callTool: vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Tool result' }], + } as McpToolCallResult), + } as unknown as BrowserManager; + + // Setup mock message bus + mockMessageBus = { + publish: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as MessageBus; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('createMcpDeclarativeTools', () => { + it('should create declarative tools from discovered MCP tools', async () => { + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + expect(tools).toHaveLength(2); + expect(tools[0].name).toBe('take_snapshot'); + expect(tools[1].name).toBe('click'); + }); + + it('should return tools with correct description', async () => { + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + expect(tools[0].description).toBe( + 'Take a snapshot of the page accessibility tree', + ); + expect(tools[1].description).toBe('Click on an element by uid'); + }); + + it('should return tools with proper FunctionDeclaration schema', async () => { + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + const schema = tools[0].schema; + expect(schema.name).toBe('take_snapshot'); + expect(schema.parametersJsonSchema).toBeDefined(); + }); + }); + + describe('McpDeclarativeTool.build', () => { + it('should create invocation that can be executed', async () => { + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + const invocation = tools[0].build({ verbose: true }); + + expect(invocation).toBeDefined(); + expect(invocation.params).toEqual({ verbose: true }); + }); + + it('should return invocation with correct description', async () => { + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + const invocation = tools[0].build({}); + + expect(invocation.getDescription()).toContain('take_snapshot'); + }); + }); + + describe('McpToolInvocation.execute', () => { + it('should call browserManager.callTool with correct params', async () => { + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + const invocation = tools[1].build({ uid: 'elem-123' }); + await invocation.execute(new AbortController().signal); + + expect(mockBrowserManager.callTool).toHaveBeenCalledWith( + 'click', + { + uid: 'elem-123', + }, + expect.any(AbortSignal), + ); + }); + + it('should return success result from MCP tool', async () => { + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + const invocation = tools[0].build({ verbose: true }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toBe('Tool result'); + expect(result.error).toBeUndefined(); + }); + + it('should handle MCP tool errors', async () => { + vi.mocked(mockBrowserManager.callTool).mockResolvedValue({ + content: [{ type: 'text', text: 'Element not found' }], + isError: true, + } as McpToolCallResult); + + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + const invocation = tools[1].build({ uid: 'invalid' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Element not found'); + }); + + it('should handle exceptions during tool call', async () => { + vi.mocked(mockBrowserManager.callTool).mockRejectedValue( + new Error('Connection lost'), + ); + + const tools = await createMcpDeclarativeTools( + mockBrowserManager, + mockMessageBus, + ); + + const invocation = tools[0].build({}); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Connection lost'); + }); + }); +}); diff --git a/packages/core/src/agents/browser/mcpToolWrapper.ts b/packages/core/src/agents/browser/mcpToolWrapper.ts new file mode 100644 index 0000000000..ee13d93a58 --- /dev/null +++ b/packages/core/src/agents/browser/mcpToolWrapper.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Creates DeclarativeTool classes for MCP tools. + * + * These tools are ONLY registered in the browser agent's isolated ToolRegistry, + * NOT in the main agent's registry. They dispatch to the BrowserManager's + * isolated MCP client directly. + * + * Tool definitions are dynamically discovered from chrome-devtools-mcp + * at runtime, not hardcoded. + */ + +import type { FunctionDeclaration } from '@google/genai'; +import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js'; +import { + DeclarativeTool, + BaseToolInvocation, + Kind, + type ToolResult, + type ToolInvocation, + type ToolCallConfirmationDetails, +} from '../../tools/tools.js'; +import type { MessageBus } from '../../confirmation-bus/message-bus.js'; +import type { BrowserManager, McpToolCallResult } from './browserManager.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +/** + * Tool invocation that dispatches to BrowserManager's isolated MCP client. + */ +class McpToolInvocation extends BaseToolInvocation< + Record, + ToolResult +> { + constructor( + private readonly browserManager: BrowserManager, + private readonly toolName: string, + params: Record, + messageBus: MessageBus, + ) { + super(params, messageBus, toolName, toolName); + } + + getDescription(): string { + return `Calling MCP tool: ${this.toolName}`; + } + + /** + * TODO: Remove this override once subagent tool confirmation is implemented + * in the framework. Currently, subagent tools auto-approve by bypassing + * the MessageBus confirmation flow. This matches how codebase_investigator + * and other subagents work. + */ + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + return false; + } + + async execute(signal: AbortSignal): Promise { + try { + // Call the MCP tool via BrowserManager's isolated client + const result: McpToolCallResult = await this.browserManager.callTool( + this.toolName, + this.params, + signal, + ); + + // Extract text content from MCP response + let textContent = ''; + if (result.content && Array.isArray(result.content)) { + textContent = result.content + .filter((c) => c.type === 'text' && c.text) + .map((c) => c.text) + .join('\n'); + } + + if (result.isError) { + return { + llmContent: `Error: ${textContent}`, + returnDisplay: `Error: ${textContent}`, + error: { message: textContent }, + }; + } + + return { + llmContent: textContent || 'Tool executed successfully.', + returnDisplay: textContent || 'Tool executed successfully.', + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + debugLogger.error(`MCP tool ${this.toolName} failed: ${errorMsg}`); + return { + llmContent: `Error: ${errorMsg}`, + returnDisplay: `Error: ${errorMsg}`, + error: { message: errorMsg }, + }; + } + } +} + +/** + * DeclarativeTool wrapper for an MCP tool. + */ +class McpDeclarativeTool extends DeclarativeTool< + Record, + ToolResult +> { + constructor( + private readonly browserManager: BrowserManager, + name: string, + description: string, + parameterSchema: unknown, + messageBus: MessageBus, + ) { + super( + name, + name, + description, + Kind.Other, + parameterSchema, + messageBus, + /* isOutputMarkdown */ true, + /* canUpdateOutput */ false, + ); + } + + build( + params: Record, + ): ToolInvocation, ToolResult> { + return new McpToolInvocation( + this.browserManager, + this.name, + params, + this.messageBus, + ); + } +} + +/** + * Creates DeclarativeTool instances from dynamically discovered MCP tools. + * + * These tools are registered in the browser agent's isolated ToolRegistry, + * NOT in the main agent's registry. + * + * Tool definitions are fetched dynamically from the MCP server at runtime. + * + * @param browserManager The browser manager with isolated MCP client + * @param messageBus Message bus for tool invocations + * @returns Array of DeclarativeTools that dispatch to the isolated MCP client + */ +export async function createMcpDeclarativeTools( + browserManager: BrowserManager, + messageBus: MessageBus, +): Promise { + // Get dynamically discovered tools from the MCP server + const mcpTools = await browserManager.getDiscoveredTools(); + + debugLogger.log( + `Creating ${mcpTools.length} declarative tools for browser agent`, + ); + + return mcpTools.map((mcpTool) => { + const schema = convertMcpToolToFunctionDeclaration(mcpTool); + return new McpDeclarativeTool( + browserManager, + mcpTool.name, + mcpTool.description ?? '', + schema.parametersJsonSchema, + messageBus, + ); + }); +} + +/** + * Converts MCP tool definition to Gemini FunctionDeclaration. + */ +function convertMcpToolToFunctionDeclaration( + mcpTool: McpTool, +): FunctionDeclaration { + // MCP tool inputSchema is a JSON Schema object + // We pass it directly as parametersJsonSchema + return { + name: mcpTool.name, + description: mcpTool.description ?? '', + parametersJsonSchema: mcpTool.inputSchema ?? { + type: 'object', + properties: {}, + }, + }; +} diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index bcd4d65878..ee447d47db 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -12,6 +12,8 @@ import { loadAgentsFromDirectory } from './agentLoader.js'; import { CodebaseInvestigatorAgent } from './codebase-investigator.js'; import { CliHelpAgent } from './cli-help-agent.js'; import { GeneralistAgent } from './generalist-agent.js'; +import { IntrospectionAgent } from './introspection-agent.js'; +import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js'; import { A2AClientManager } from './a2a-client-manager.js'; import { ADCHandler } from './remote-invocation.js'; import { type z } from 'zod'; @@ -201,6 +203,19 @@ export class AgentRegistry { this.registerLocalAgent(CodebaseInvestigatorAgent(this.config)); this.registerLocalAgent(CliHelpAgent(this.config)); this.registerLocalAgent(GeneralistAgent(this.config)); + + // Register the introspection agent if it's explicitly enabled. + const introspectionSettings = this.config.getIntrospectionAgentSettings(); + if (introspectionSettings.enabled) { + this.registerLocalAgent(IntrospectionAgent); + } + + // Register the browser agent if enabled in settings. + // Tools are configured dynamically at invocation time via browserAgentFactory. + const browserConfig = this.config.getBrowserAgentConfig(); + if (browserConfig.enabled) { + this.registerLocalAgent(BrowserAgentDefinition(this.config)); + } } private async refreshAgents(): Promise { diff --git a/packages/core/src/agents/subagent-tool-wrapper.ts b/packages/core/src/agents/subagent-tool-wrapper.ts index d068973a67..57ee929205 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.ts @@ -14,6 +14,8 @@ import type { Config } from '../config/config.js'; import type { AgentDefinition, AgentInputs } from './types.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { RemoteAgentInvocation } from './remote-invocation.js'; +import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js'; +import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; /** @@ -79,6 +81,17 @@ export class SubagentToolWrapper extends BaseDeclarativeTool< ); } + // Special handling for browser agent - needs async MCP setup + if (definition.name === BROWSER_AGENT_NAME) { + return new BrowserAgentInvocation( + this.config, + params, + effectiveMessageBus, + _toolName, + _toolDisplayName, + ); + } + return new LocalSubagentInvocation( definition, this.config,