diff --git a/integration-tests/browser-agent.concurrent.responses b/integration-tests/browser-agent.concurrent.responses new file mode 100644 index 0000000000..f64397e02d --- /dev/null +++ b/integration-tests/browser-agent.concurrent.responses @@ -0,0 +1,8 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll launch two browser agents concurrently to check both repositories."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and get the page title"}}},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is Example Domain."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is Example Domain."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Both browser agents completed successfully. Agent 1 and Agent 2 both navigated to their respective pages and confirmed the page titles."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":40,"totalTokenCount":340}}]} diff --git a/integration-tests/browser-agent.test.ts b/integration-tests/browser-agent.test.ts index 09e20bcb26..325fdc1db5 100644 --- a/integration-tests/browser-agent.test.ts +++ b/integration-tests/browser-agent.test.ts @@ -307,4 +307,48 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => { await run.expectText('successfully written', 15000); }); + + it('should handle concurrent browser agents with isolated session mode', async () => { + rig.setup('browser-concurrent', { + fakeResponsesPath: join(__dirname, 'browser-agent.concurrent.responses'), + settings: { + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: true, + // Isolated mode supports concurrent browser agents. + // Persistent/existing modes reject concurrent calls to prevent + // Chrome profile lock conflicts. + sessionMode: 'isolated', + }, + }, + }, + }); + + const result = await rig.run({ + args: 'Launch two browser agents concurrently to check example.com', + }); + + assertModelHasOutput(result); + + const toolLogs = rig.readToolLogs(); + const browserCalls = toolLogs.filter( + (t) => t.toolRequest.name === 'browser_agent', + ); + + // Both browser_agent invocations should have been called + expect(browserCalls.length).toBe(2); + + // Both should complete successfully (no errors) + for (const call of browserCalls) { + expect( + call.toolRequest.success, + `browser_agent call failed: ${JSON.stringify(call.toolRequest)}`, + ).toBe(true); + } + }); }); diff --git a/packages/core/src/agents/browser/browserAgentFactory.test.ts b/packages/core/src/agents/browser/browserAgentFactory.test.ts index 1be28e60c4..b071a420ab 100644 --- a/packages/core/src/agents/browser/browserAgentFactory.test.ts +++ b/packages/core/src/agents/browser/browserAgentFactory.test.ts @@ -38,6 +38,8 @@ const mockBrowserManager = { ]), callTool: vi.fn().mockResolvedValue({ content: [] }), close: vi.fn().mockResolvedValue(undefined), + acquire: vi.fn(), + release: vi.fn(), }; // Mock dependencies diff --git a/packages/core/src/agents/browser/browserAgentFactory.ts b/packages/core/src/agents/browser/browserAgentFactory.ts index e07f403ba7..f26dc79c69 100644 --- a/packages/core/src/agents/browser/browserAgentFactory.ts +++ b/packages/core/src/agents/browser/browserAgentFactory.ts @@ -81,207 +81,218 @@ export async function createBrowserAgentDefinition( // Get or create browser manager singleton for this session mode/profile const browserManager = BrowserManager.getInstance(config); - await browserManager.ensureConnection(); + browserManager.acquire(); - debugLogger.log('Browser connected with isolated MCP client.'); + try { + await browserManager.ensureConnection(); - // Determine if input blocker should be active (non-headless + enabled) - const shouldDisableInput = config.shouldDisableBrowserUserInput(); - // Inject automation overlay and input blocker if not in headless mode - const browserConfig = config.getBrowserAgentConfig(); - if (!browserConfig?.customConfig?.headless) { - debugLogger.log('Injecting automation overlay...'); - await injectAutomationOverlay(browserManager); - if (shouldDisableInput) { - debugLogger.log('Injecting input blocker...'); - await injectInputBlocker(browserManager); - } - } + debugLogger.log('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, - shouldDisableInput, - browserConfig.customConfig.blockFileUploads, - ); - const availableToolNames = mcpTools.map((t) => t.name); - - // Register high-priority policy rules for sensitive actions which is not - // able to be overwrite by YOLO mode. - const policyEngine = config.getPolicyEngine(); - - if (policyEngine) { - const existingRules = policyEngine.getRules(); - - const restrictedTools = ['fill', 'fill_form']; - - // ASK_USER for upload_file and evaluate_script when sensitive action - // need confirmation. - if (browserConfig.customConfig.confirmSensitiveActions) { - restrictedTools.push('upload_file', 'evaluate_script'); - } - - for (const toolName of restrictedTools) { - const rule = generateAskUserRules(toolName); - if (!existingRules.some((r) => isRuleEqual(r, rule))) { - policyEngine.addRule(rule); + // Determine if input blocker should be active (non-headless + enabled) + const shouldDisableInput = config.shouldDisableBrowserUserInput(); + // Inject automation overlay and input blocker if not in headless mode + const browserConfig = config.getBrowserAgentConfig(); + if (!browserConfig?.customConfig?.headless) { + debugLogger.log('Injecting automation overlay...'); + await injectAutomationOverlay(browserManager); + if (shouldDisableInput) { + debugLogger.log('Injecting input blocker...'); + await injectInputBlocker(browserManager); } } - // Reduce noise for read-only tools in default mode - const readOnlyTools = (await browserManager.getDiscoveredTools()) - .filter((t) => !!t.annotations?.readOnlyHint) - .map((t) => t.name); - const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot']; + // Create declarative tools from dynamically discovered MCP tools + // These tools dispatch to browserManager's isolated client + const mcpTools = await createMcpDeclarativeTools( + browserManager, + messageBus, + shouldDisableInput, + browserConfig.customConfig.blockFileUploads, + ); + const availableToolNames = mcpTools.map((t) => t.name); - for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) { - if (availableToolNames.includes(toolName)) { - const rule = generateAllowRules(toolName); + // Register high-priority policy rules for sensitive actions which is not + // able to be overwrite by YOLO mode. + const policyEngine = config.getPolicyEngine(); + + if (policyEngine) { + const existingRules = policyEngine.getRules(); + + const restrictedTools = ['fill', 'fill_form']; + + // ASK_USER for upload_file and evaluate_script when sensitive action + // need confirmation. + if (browserConfig.customConfig.confirmSensitiveActions) { + restrictedTools.push('upload_file', 'evaluate_script'); + } + + for (const toolName of restrictedTools) { + const rule = generateAskUserRules(toolName); if (!existingRules.some((r) => isRuleEqual(r, rule))) { policyEngine.addRule(rule); } } - } - } - function generateAskUserRules(toolName: string): PolicyRule { - return { - toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`, - decision: PolicyDecision.ASK_USER, - priority: 999, - source: 'BrowserAgent (Sensitive Actions)', - mcpName: BROWSER_AGENT_NAME, + // Reduce noise for read-only tools in default mode + const readOnlyTools = (await browserManager.getDiscoveredTools()) + .filter((t) => !!t.annotations?.readOnlyHint) + .map((t) => t.name); + const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot']; + + for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) { + if (availableToolNames.includes(toolName)) { + const rule = generateAllowRules(toolName); + if (!existingRules.some((r) => isRuleEqual(r, rule))) { + policyEngine.addRule(rule); + } + } + } + } + + function generateAskUserRules(toolName: string): PolicyRule { + return { + toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`, + decision: PolicyDecision.ASK_USER, + priority: 999, + source: 'BrowserAgent (Sensitive Actions)', + mcpName: BROWSER_AGENT_NAME, + }; + } + + function generateAllowRules(toolName: string): PolicyRule { + return { + toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`, + decision: PolicyDecision.ALLOW, + priority: PRIORITY_SUBAGENT_TOOL, + source: 'BrowserAgent (Read-Only)', + mcpName: BROWSER_AGENT_NAME, + }; + } + + // Check if policy rule the same in all the attributes that we care about + function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) { + return ( + rule1.toolName === rule2.toolName && + rule1.decision === rule2.decision && + rule1.priority === rule2.priority && + rule1.mcpName === rule2.mcpName + ); + } + + // Validate required semantic tools are available + const requiredSemanticTools = [ + 'click', + 'fill', + 'navigate_page', + 'take_snapshot', + ]; + const missingSemanticTools = requiredSemanticTools.filter( + (t) => !availableToolNames.includes(t), + ); + + const rawSessionMode = browserConfig?.customConfig?.sessionMode; + const sessionMode = + rawSessionMode === 'isolated' || rawSessionMode === 'existing' + ? rawSessionMode + : 'persistent'; + + recordBrowserAgentToolDiscovery( + config, + mcpTools.length, + missingSemanticTools, + sessionMode, + ); + + if (missingSemanticTools.length > 0) { + debugLogger.warn( + `Semantic tools missing (${missingSemanticTools.join(', ')}). ` + + 'Some browser interactions may not work correctly.', + ); + } + + // Only click_at is strictly required — text input can use press_key or fill. + const requiredVisualTools = ['click_at']; + const missingVisualTools = requiredVisualTools.filter( + (t) => !availableToolNames.includes(t), + ); + + // Check whether vision can be enabled; returns structured type with code and message. + function getVisionDisabledReason(): VisionDisabledReason { + const browserConfig = config.getBrowserAgentConfig(); + if (!browserConfig.customConfig.visualModel) { + return { + code: 'no_visual_model', + message: 'No visualModel configured.', + }; + } + if (missingVisualTools.length > 0) { + return { + code: 'missing_visual_tools', + message: + `Visual tools missing (${missingVisualTools.join(', ')}). ` + + `The installed chrome-devtools-mcp version may be too old.`, + }; + } + const authType = config.getContentGeneratorConfig()?.authType; + const blockedAuthTypes = new Set([ + AuthType.LOGIN_WITH_GOOGLE, + AuthType.LEGACY_CLOUD_SHELL, + AuthType.COMPUTE_ADC, + ]); + if (authType && blockedAuthTypes.has(authType)) { + return { + code: 'blocked_auth_type', + message: 'Visual agent model not available for current auth type.', + }; + } + return undefined; + } + + const allTools: AnyDeclarativeTool[] = [...mcpTools]; + const visionDisabledReason = getVisionDisabledReason(); + + logBrowserAgentVisionStatus(config, { + enabled: !visionDisabledReason, + disabled_reason: visionDisabledReason?.code, + }); + + if (visionDisabledReason) { + debugLogger.log(`Vision disabled: ${visionDisabledReason.message}`); + } else { + allTools.push( + createAnalyzeScreenshotTool(browserManager, config, messageBus), + ); + } + + debugLogger.log( + `Created ${allTools.length} tools for browser agent: ` + + allTools.map((t) => t.name).join(', '), + ); + + // Create configured definition with tools + // BrowserAgentDefinition is a factory function - call it with config + const baseDefinition = BrowserAgentDefinition( + config, + !visionDisabledReason, + ); + const definition: LocalAgentDefinition = { + ...baseDefinition, + toolConfig: { + tools: allTools, + }, }; - } - function generateAllowRules(toolName: string): PolicyRule { return { - toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`, - decision: PolicyDecision.ALLOW, - priority: PRIORITY_SUBAGENT_TOOL, - source: 'BrowserAgent (Read-Only)', - mcpName: BROWSER_AGENT_NAME, + definition, + browserManager, + visionEnabled: !visionDisabledReason, + sessionMode, }; + } catch (error) { + // Release the browser manager if setup fails, so concurrent tasks can try again. + browserManager.release(); + throw error; } - - // Check if policy rule the same in all the attributes that we care about - function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) { - return ( - rule1.toolName === rule2.toolName && - rule1.decision === rule2.decision && - rule1.priority === rule2.priority && - rule1.mcpName === rule2.mcpName - ); - } - - // Validate required semantic tools are available - const requiredSemanticTools = [ - 'click', - 'fill', - 'navigate_page', - 'take_snapshot', - ]; - const missingSemanticTools = requiredSemanticTools.filter( - (t) => !availableToolNames.includes(t), - ); - - const rawSessionMode = browserConfig?.customConfig?.sessionMode; - const sessionMode = - rawSessionMode === 'isolated' || rawSessionMode === 'existing' - ? rawSessionMode - : 'persistent'; - - recordBrowserAgentToolDiscovery( - config, - mcpTools.length, - missingSemanticTools, - sessionMode, - ); - - if (missingSemanticTools.length > 0) { - debugLogger.warn( - `Semantic tools missing (${missingSemanticTools.join(', ')}). ` + - 'Some browser interactions may not work correctly.', - ); - } - - // Only click_at is strictly required — text input can use press_key or fill. - const requiredVisualTools = ['click_at']; - const missingVisualTools = requiredVisualTools.filter( - (t) => !availableToolNames.includes(t), - ); - - // Check whether vision can be enabled; returns structured type with code and message. - function getVisionDisabledReason(): VisionDisabledReason { - const browserConfig = config.getBrowserAgentConfig(); - if (!browserConfig.customConfig.visualModel) { - return { - code: 'no_visual_model', - message: 'No visualModel configured.', - }; - } - if (missingVisualTools.length > 0) { - return { - code: 'missing_visual_tools', - message: - `Visual tools missing (${missingVisualTools.join(', ')}). ` + - `The installed chrome-devtools-mcp version may be too old.`, - }; - } - const authType = config.getContentGeneratorConfig()?.authType; - const blockedAuthTypes = new Set([ - AuthType.LOGIN_WITH_GOOGLE, - AuthType.LEGACY_CLOUD_SHELL, - AuthType.COMPUTE_ADC, - ]); - if (authType && blockedAuthTypes.has(authType)) { - return { - code: 'blocked_auth_type', - message: 'Visual agent model not available for current auth type.', - }; - } - return undefined; - } - - const allTools: AnyDeclarativeTool[] = [...mcpTools]; - const visionDisabledReason = getVisionDisabledReason(); - - logBrowserAgentVisionStatus(config, { - enabled: !visionDisabledReason, - disabled_reason: visionDisabledReason?.code, - }); - - if (visionDisabledReason) { - debugLogger.log(`Vision disabled: ${visionDisabledReason.message}`); - } else { - allTools.push( - createAnalyzeScreenshotTool(browserManager, config, messageBus), - ); - } - - debugLogger.log( - `Created ${allTools.length} tools for browser agent: ` + - allTools.map((t) => t.name).join(', '), - ); - - // Create configured definition with tools - // BrowserAgentDefinition is a factory function - call it with config - const baseDefinition = BrowserAgentDefinition(config, !visionDisabledReason); - const definition: LocalAgentDefinition = { - ...baseDefinition, - toolConfig: { - tools: allTools, - }, - }; - - return { - definition, - browserManager, - visionEnabled: !visionDisabledReason, - sessionMode, - }; } /** diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index a87b88cb1b..ac90564f06 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -192,7 +192,10 @@ describe('BrowserAgentInvocation', () => { promptConfig: { query: '', systemPrompt: '' }, toolConfig: { tools: ['analyze_screenshot', 'click'] }, }, - browserManager: {} as never, + browserManager: { + release: vi.fn(), + callTool: vi.fn().mockResolvedValue({ content: [] }), + } as never, visionEnabled: true, sessionMode: 'persistent', }); @@ -766,6 +769,7 @@ describe('BrowserAgentInvocation', () => { } return { isError: false }; }), + release: vi.fn(), }; vi.mocked(createBrowserAgentDefinition).mockResolvedValue({ diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 6fb05753ee..e71d82cf55 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -440,6 +440,8 @@ ${output.result}`; } } catch { // Ignore errors for removing the overlays. + } finally { + browserManager.release(); } } } diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index baabc80bcb..65c17bfb09 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -873,6 +873,122 @@ describe('BrowserManager', () => { expect(instance1).not.toBe(instance2); }); + + it('should throw when acquired instance is requested in persistent mode', () => { + // mockConfig defaults to persistent mode + const instance1 = BrowserManager.getInstance(mockConfig); + instance1.acquire(); + + expect(() => BrowserManager.getInstance(mockConfig)).toThrow( + /Cannot launch a concurrent browser agent in "persistent" session mode/, + ); + }); + + it('should throw when acquired instance is requested in existing mode', () => { + const existingConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'existing' }, + }, + }); + + const instance1 = BrowserManager.getInstance(existingConfig); + instance1.acquire(); + + expect(() => BrowserManager.getInstance(existingConfig)).toThrow( + /Cannot launch a concurrent browser agent in "existing" session mode/, + ); + }); + + it('should return a different instance when the primary is acquired in isolated mode', () => { + const isolatedConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'isolated' }, + }, + }); + + const instance1 = BrowserManager.getInstance(isolatedConfig); + instance1.acquire(); + + const instance2 = BrowserManager.getInstance(isolatedConfig); + + expect(instance2).not.toBe(instance1); + expect(instance1.isAcquired()).toBe(true); + expect(instance2.isAcquired()).toBe(false); + }); + + it('should reuse the primary when it has been released', () => { + const instance1 = BrowserManager.getInstance(mockConfig); + instance1.acquire(); + instance1.release(); + + const instance2 = BrowserManager.getInstance(mockConfig); + + expect(instance2).toBe(instance1); + expect(instance1.isAcquired()).toBe(false); + }); + + it('should reuse a released parallel instance in isolated mode', () => { + const isolatedConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'isolated' }, + }, + }); + + const instance1 = BrowserManager.getInstance(isolatedConfig); + instance1.acquire(); + + const instance2 = BrowserManager.getInstance(isolatedConfig); + instance2.acquire(); + instance2.release(); + + // Primary is still acquired, parallel is released — should reuse parallel + const instance3 = BrowserManager.getInstance(isolatedConfig); + expect(instance3).toBe(instance2); + }); + + it('should create multiple parallel instances in isolated mode', () => { + const isolatedConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'isolated' }, + }, + }); + + const instance1 = BrowserManager.getInstance(isolatedConfig); + instance1.acquire(); + + const instance2 = BrowserManager.getInstance(isolatedConfig); + instance2.acquire(); + + const instance3 = BrowserManager.getInstance(isolatedConfig); + + expect(instance1).not.toBe(instance2); + expect(instance2).not.toBe(instance3); + expect(instance1).not.toBe(instance3); + }); + + it('should throw when MAX_PARALLEL_INSTANCES is reached in isolated mode', () => { + const isolatedConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'isolated' }, + }, + }); + + // Acquire MAX_PARALLEL_INSTANCES instances + for (let i = 0; i < BrowserManager.MAX_PARALLEL_INSTANCES; i++) { + const instance = BrowserManager.getInstance(isolatedConfig); + instance.acquire(); + } + + // Next call should throw + expect(() => BrowserManager.getInstance(isolatedConfig)).toThrow( + /Maximum number of parallel browser instances/, + ); + }); }); describe('resetAll', () => { diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index 89d54e9c72..ebc43bc374 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -114,6 +114,12 @@ export class BrowserManager { // --- Static singleton management --- private static instances = new Map(); + /** + * Maximum number of parallel browser instances allowed in isolated mode. + * Prevents unbounded resource consumption from concurrent browser_agent calls. + */ + static readonly MAX_PARALLEL_INSTANCES = 5; + /** * Returns the cache key for a given config. * Uses `sessionMode:profilePath` so different profiles get separate instances. @@ -128,14 +134,64 @@ export class BrowserManager { /** * Returns an existing BrowserManager for the current config's session mode * and profile, or creates a new one. + * + * Concurrency rules: + * - **persistent / existing mode**: Only one instance is allowed at a time. + * If the instance is already in-use, an error is thrown instructing the + * caller to run browser tasks sequentially. + * - **isolated mode**: Parallel instances are allowed up to + * MAX_PARALLEL_INSTANCES. Each isolated instance gets its own temp profile. */ static getInstance(config: Config): BrowserManager { const key = BrowserManager.getInstanceKey(config); + const sessionMode = + config.getBrowserAgentConfig().customConfig.sessionMode ?? 'persistent'; let instance = BrowserManager.instances.get(key); if (!instance) { instance = new BrowserManager(config); BrowserManager.instances.set(key, instance); debugLogger.log(`Created new BrowserManager singleton (key: ${key})`); + } else if (instance.inUse) { + // Persistent and existing modes share a browser profile directory. + // Chrome prevents multiple instances from using the same profile, so + // concurrent usage would cause "profile locked" errors. + if (sessionMode === 'persistent' || sessionMode === 'existing') { + throw new Error( + `Cannot launch a concurrent browser agent in "${sessionMode}" session mode. ` + + `The browser instance is already in use by another task. ` + + `Please run browser tasks sequentially, or switch to "isolated" session mode for concurrent browser usage.`, + ); + } + + // Isolated mode: allow parallel instances up to the limit. + let inUseCount = 1; // primary is already in-use + let suffix = 1; + let parallelKey = `${key}:${suffix}`; + let parallel = BrowserManager.instances.get(parallelKey); + while (parallel?.inUse) { + inUseCount++; + if (inUseCount >= BrowserManager.MAX_PARALLEL_INSTANCES) { + throw new Error( + `Maximum number of parallel browser instances (${BrowserManager.MAX_PARALLEL_INSTANCES}) reached. ` + + `Please wait for an existing browser task to complete before starting a new one.`, + ); + } + suffix++; + parallelKey = `${key}:${suffix}`; + parallel = BrowserManager.instances.get(parallelKey); + } + if (!parallel) { + parallel = new BrowserManager(config); + BrowserManager.instances.set(parallelKey, parallel); + debugLogger.log( + `Created parallel BrowserManager (key: ${parallelKey})`, + ); + } else { + debugLogger.log( + `Reusing released parallel BrowserManager (key: ${parallelKey})`, + ); + } + instance = parallel; } else { debugLogger.log( `Reusing existing BrowserManager singleton (key: ${key})`, @@ -180,6 +236,36 @@ export class BrowserManager { private isClosing = false; private connectionPromise: Promise | undefined; + /** + * Whether this instance is currently acquired by an active invocation. + * Used by getInstance() to avoid handing the same browser to concurrent + * browser_agent calls. + */ + private inUse = false; + + /** + * Marks this instance as in-use. Call this when starting a browser agent + * invocation so concurrent calls get a separate instance. + */ + acquire(): void { + this.inUse = true; + } + + /** + * Marks this instance as available for reuse. Call this in the finally + * block of a browser agent invocation. + */ + release(): void { + this.inUse = false; + } + + /** + * Returns whether this instance is currently acquired by an active invocation. + */ + isAcquired(): boolean { + return this.inUse; + } + /** State for action rate limiting */ private actionCounter = 0; private readonly maxActionsPerTask: number;