Skip to content

Commit 49e33c5

Browse files
committed
feat: add isolatedContext routing for parallel agent page resolution
Add optional `isolatedContext` parameter to all page-dependent tools so parallel agents can resolve pages by context name instead of relying on the global selected-page pointer. When an agent creates a page with `new_page(isolatedContext: "my-agent")`, all subsequent tool calls can pass `isolatedContext: "my-agent"` to operate on the correct page without race conditions from other agents calling `select_page` concurrently. McpContext tracks per-context selected pages and resolvePageByContext() looks up the right page by context name. When the parameter is omitted, tools fall back to getSelectedPage() (fully backward compatible). Updated tools: take_screenshot, take_snapshot, wait_for, navigate_page, resize_page, emulate, click_at, fill, fill_form, upload_file, press_key, evaluate_script, performance_start_trace, performance_stop_trace, screencast_start.
1 parent ec0c4d1 commit 49e33c5

14 files changed

Lines changed: 283 additions & 49 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
9+
import type {TestScenario} from '../eval_gemini.ts';
10+
11+
export const scenario: TestScenario = {
12+
prompt:
13+
'Create a new page <TEST_URL> in an isolated context called "emulateCtx". Emulate a mobile viewport (390x844, isMobile true, hasTouch true) on that page using the same isolated context.',
14+
maxTurns: 3,
15+
htmlRoute: {
16+
path: '/test.html',
17+
htmlContent: `
18+
<h1>Emulation Test</h1>
19+
`,
20+
},
21+
expectations: calls => {
22+
assert.strictEqual(calls.length, 2);
23+
assert.ok(calls[0].name === 'new_page', 'First call should be new_page');
24+
assert.deepStrictEqual(calls[0].args.isolatedContext, 'emulateCtx');
25+
assert.ok(calls[1].name === 'emulate', 'Second call should be emulate');
26+
assert.deepStrictEqual(calls[1].args.isolatedContext, 'emulateCtx');
27+
assert.ok(calls[1].args.viewport, 'Viewport should be set');
28+
},
29+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
9+
import type {TestScenario} from '../eval_gemini.ts';
10+
11+
export const scenario: TestScenario = {
12+
prompt:
13+
'Create a new page <TEST_URL> in an isolated context called "navContext". Then navigate that page to https://example.com using the same isolated context.',
14+
maxTurns: 3,
15+
htmlRoute: {
16+
path: '/test.html',
17+
htmlContent: `
18+
<h1>Initial Page</h1>
19+
`,
20+
},
21+
expectations: calls => {
22+
assert.strictEqual(calls.length, 2);
23+
assert.ok(calls[0].name === 'new_page', 'First call should be new_page');
24+
assert.deepStrictEqual(calls[0].args.isolatedContext, 'navContext');
25+
assert.ok(
26+
calls[1].name === 'navigate_page',
27+
'Second call should be navigate_page',
28+
);
29+
assert.deepStrictEqual(calls[1].args.isolatedContext, 'navContext');
30+
assert.deepStrictEqual(calls[1].args.url, 'https://example.com');
31+
},
32+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
9+
import type {TestScenario} from '../eval_gemini.ts';
10+
11+
export const scenario: TestScenario = {
12+
prompt:
13+
'Create a new page <TEST_URL> in an isolated context called "myContext". Take a snapshot of that page using the same isolated context.',
14+
maxTurns: 3,
15+
htmlRoute: {
16+
path: '/test.html',
17+
htmlContent: `
18+
<h1>Snapshot Test</h1>
19+
<p>Content for snapshot testing.</p>
20+
`,
21+
},
22+
expectations: calls => {
23+
assert.strictEqual(calls.length, 2);
24+
assert.ok(calls[0].name === 'new_page', 'First call should be new_page');
25+
assert.deepStrictEqual(calls[0].args.isolatedContext, 'myContext');
26+
assert.ok(
27+
calls[1].name === 'take_snapshot',
28+
'Second call should be take_snapshot',
29+
);
30+
assert.deepStrictEqual(calls[1].args.isolatedContext, 'myContext');
31+
},
32+
};

src/McpContext.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export class McpContext implements Context {
131131
#pages: Page[] = [];
132132
#pageToDevToolsPage = new Map<Page, Page>();
133133
#selectedPage?: Page;
134+
// Per-context selected page tracking for parallel agent support.
135+
#contextSelectedPage = new Map<string, Page>();
134136
#textSnapshot: TextSnapshot | null = null;
135137
#networkCollector: NetworkCollector;
136138
#consoleCollector: ConsoleCollector;
@@ -315,15 +317,18 @@ export class McpContext implements Context {
315317
return this.#networkCollector.getById(this.getSelectedPage(), reqid);
316318
}
317319

318-
async emulate(options: {
319-
networkConditions?: string | null;
320-
cpuThrottlingRate?: number | null;
321-
geolocation?: GeolocationOptions | null;
322-
userAgent?: string | null;
323-
colorScheme?: 'dark' | 'light' | 'auto' | null;
324-
viewport?: Viewport | null;
325-
}): Promise<void> {
326-
const page = this.getSelectedPage();
320+
async emulate(
321+
options: {
322+
networkConditions?: string | null;
323+
cpuThrottlingRate?: number | null;
324+
geolocation?: GeolocationOptions | null;
325+
userAgent?: string | null;
326+
colorScheme?: 'dark' | 'light' | 'auto' | null;
327+
viewport?: Viewport | null;
328+
},
329+
targetPage?: Page,
330+
): Promise<void> {
331+
const page = targetPage ?? this.getSelectedPage();
327332
const currentSettings = this.#emulationSettingsMap.get(page) ?? {};
328333
const newSettings: EmulationSettings = {...currentSettings};
329334
let timeoutsNeedUpdate = false;
@@ -500,6 +505,41 @@ export class McpContext implements Context {
500505
return page;
501506
}
502507

508+
resolvePageByContext(isolatedContext?: string): Page {
509+
if (isolatedContext === undefined) {
510+
return this.getSelectedPage();
511+
}
512+
513+
// Try the per-context selected page first.
514+
const tracked = this.#contextSelectedPage.get(isolatedContext);
515+
if (tracked && !tracked.isClosed()) {
516+
return tracked;
517+
}
518+
519+
// Fall back: find any non-closed page in the context.
520+
const ctx = this.#isolatedContexts.get(isolatedContext);
521+
if (!ctx) {
522+
throw new Error(
523+
`No isolated context named "${isolatedContext}" exists. ` +
524+
`Create one first with new_page(isolatedContext: "${isolatedContext}").`,
525+
);
526+
}
527+
528+
for (const page of this.#pages) {
529+
if (
530+
!page.isClosed() &&
531+
this.#pageToIsolatedContextName.get(page) === isolatedContext
532+
) {
533+
this.#contextSelectedPage.set(isolatedContext, page);
534+
return page;
535+
}
536+
}
537+
538+
throw new Error(
539+
`No open page found in isolated context "${isolatedContext}".`,
540+
);
541+
}
542+
503543
getPageById(pageId: number): Page {
504544
const page = this.#pages.find(p => this.#pageIdMap.get(p) === pageId);
505545
if (!page) {
@@ -534,6 +574,12 @@ export class McpContext implements Context {
534574
void newPage.emulateFocusedPage(true).catch(error => {
535575
this.logger('Error turning on focused page emulation', error);
536576
});
577+
578+
// Track per-context selected page for parallel agent routing.
579+
const contextName = this.#pageToIsolatedContextName.get(newPage);
580+
if (contextName) {
581+
this.#contextSelectedPage.set(contextName, newPage);
582+
}
537583
}
538584

539585
#updateSelectedPageTimeouts() {
@@ -729,8 +775,9 @@ export class McpContext implements Context {
729775
async createTextSnapshot(
730776
verbose = false,
731777
devtoolsData: DevToolsData | undefined = undefined,
778+
targetPage?: Page,
732779
): Promise<void> {
733-
const page = this.getSelectedPage();
780+
const page = targetPage ?? this.getSelectedPage();
734781
const rootNode = await page.accessibility.snapshot({
735782
includeIframes: true,
736783
interestingOnly: !verbose,
@@ -881,8 +928,12 @@ export class McpContext implements Context {
881928
return this.#networkCollector.getIdForResource(request);
882929
}
883930

884-
waitForTextOnPage(text: string, timeout?: number): Promise<Element> {
885-
const page = this.getSelectedPage();
931+
waitForTextOnPage(
932+
text: string,
933+
timeout?: number,
934+
targetPage?: Page,
935+
): Promise<Element> {
936+
const page = targetPage ?? this.getSelectedPage();
886937
const frames = page.frames();
887938

888939
let locator = this.#locatorClass.race(

src/McpResponse.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export class McpResponse implements Response {
238238
await context.createTextSnapshot(
239239
this.#snapshotParams.verbose,
240240
this.#devToolsData,
241+
this.#snapshotParams.page,
241242
);
242243
const textSnapshot = context.getTextSnapshot();
243244
if (textSnapshot) {

src/tools/ToolDefinition.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export interface ImageContentData {
5353
export interface SnapshotParams {
5454
verbose?: boolean;
5555
filePath?: string;
56+
page?: Page;
5657
}
5758

5859
export interface DevToolsData {
@@ -107,6 +108,7 @@ export type Context = Readonly<{
107108
recordedTraces(): TraceResult[];
108109
storeTraceRecording(result: TraceResult): void;
109110
getSelectedPage(): Page;
111+
resolvePageByContext(isolatedContext?: string): Page;
110112
getDialog(): Dialog | undefined;
111113
clearDialog(): void;
112114
getPageById(pageId: number): Page;
@@ -118,14 +120,17 @@ export type Context = Readonly<{
118120
getIsolatedContextName(page: Page): string | undefined;
119121
getElementByUid(uid: string): Promise<ElementHandle<Element>>;
120122
getAXNodeByUid(uid: string): TextSnapshotNode | undefined;
121-
emulate(options: {
122-
networkConditions?: string | null;
123-
cpuThrottlingRate?: number | null;
124-
geolocation?: GeolocationOptions | null;
125-
userAgent?: string | null;
126-
colorScheme?: 'dark' | 'light' | 'auto' | null;
127-
viewport?: Viewport | null;
128-
}): Promise<void>;
123+
emulate(
124+
options: {
125+
networkConditions?: string | null;
126+
cpuThrottlingRate?: number | null;
127+
geolocation?: GeolocationOptions | null;
128+
userAgent?: string | null;
129+
colorScheme?: 'dark' | 'light' | 'auto' | null;
130+
viewport?: Viewport | null;
131+
},
132+
targetPage?: Page,
133+
): Promise<void>;
129134
getNetworkConditions(): string | null;
130135
getCpuThrottlingRate(): number;
131136
getGeolocation(): GeolocationOptions | null;
@@ -144,7 +149,11 @@ export type Context = Readonly<{
144149
action: () => Promise<unknown>,
145150
options?: {timeout?: number},
146151
): Promise<void>;
147-
waitForTextOnPage(text: string, timeout?: number): Promise<Element>;
152+
waitForTextOnPage(
153+
text: string,
154+
timeout?: number,
155+
page?: Page,
156+
): Promise<Element>;
148157
getDevToolsData(): Promise<DevToolsData>;
149158
/**
150159
* Returns a reqid for a cdpRequestId.
@@ -173,6 +182,18 @@ export function defineTool<Schema extends zod.ZodRawShape>(
173182
export const CLOSE_PAGE_ERROR =
174183
'The last open page cannot be closed. It is fine to keep it open.';
175184

185+
export const isolatedContextSchema = {
186+
isolatedContext: zod
187+
.string()
188+
.optional()
189+
.describe(
190+
'The name of the isolated browser context to resolve the page from. ' +
191+
'When provided, the tool operates on the page belonging to this context ' +
192+
'instead of the globally selected page. ' +
193+
'Use this to avoid race conditions when multiple agents work in parallel.',
194+
),
195+
};
196+
176197
export const timeoutSchema = {
177198
timeout: zod
178199
.number()

src/tools/emulation.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import {zod, PredefinedNetworkConditions} from '../third_party/index.js';
99

1010
import {ToolCategory} from './categories.js';
11-
import {defineTool} from './ToolDefinition.js';
11+
import {defineTool, isolatedContextSchema} from './ToolDefinition.js';
1212

1313
const throttlingOptions: [string, ...string[]] = [
1414
'No emulation',
@@ -24,6 +24,7 @@ export const emulate = defineTool({
2424
readOnlyHint: false,
2525
},
2626
schema: {
27+
...isolatedContextSchema,
2728
networkConditions: zod
2829
.enum(throttlingOptions)
2930
.optional()
@@ -104,6 +105,9 @@ export const emulate = defineTool({
104105
),
105106
},
106107
handler: async (request, _response, context) => {
107-
await context.emulate(request.params);
108+
const page = context.resolvePageByContext(
109+
request.params.isolatedContext,
110+
);
111+
await context.emulate(request.params, page);
108112
},
109113
});

0 commit comments

Comments
 (0)