diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-04 22:44:55 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-04 22:44:55 +0530 |
| commit | 71da8e0719c50d9b11c918b032b9ed4d77aabf5b (patch) | |
| tree | 16794f5884f95abe9fff79d7eec7e08f24286aed /src | |
| parent | 2a98be01d28044beabe6a1363f01a5a3acde9fd5 (diff) | |
feat: parallel tool execution with automatic mutation serialization
- Add lightweight async mutex (src/util/mutex.ts) for FIFO serialization
- Add serialized?: boolean to BrowserToolDefinition
- Wire mutex acquisition into registerBrowserTool execute wrapper
- Expose mutationMutex() on BrowserClient
- Mark all mutation tools (click, type, scroll, navigate, switch_tab, etc.) as serialized
- Leave observation tools (screenshot, page_info, execute_js, http_get, etc.) unguarded
- Update prompt and skill docs to reflect parallel-safe behavior
- Fix scroll tool: add Page.bringToFront, explicit button:none/buttons:0/pointerType:mouse
- Update .gitignore to ignore scripts/ and data/ folders
Diffstat (limited to 'src')
| -rw-r--r-- | src/client.ts | 6 | ||||
| -rw-r--r-- | src/domains/scroll.ts | 25 | ||||
| -rw-r--r-- | src/prompt.ts | 14 | ||||
| -rw-r--r-- | src/registry.ts | 41 | ||||
| -rw-r--r-- | src/util/mutex.ts | 25 | ||||
| -rw-r--r-- | src/util/tool.ts | 58 |
6 files changed, 122 insertions, 47 deletions
diff --git a/src/client.ts b/src/client.ts index 1bf7ccc..94b6ac0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ import { type Result, err, ok } from "./util/result"; import { safeJs } from "./util/js-template"; +import { type Mutex, createMutex } from "./util/mutex"; import { discoverWsUrl } from "./cdp/discovery"; import { type CdpError, cdpError } from "./cdp/errors"; import type { CdpTransport } from "./cdp/transport"; @@ -27,6 +28,9 @@ export type BrowserClient = { current(): { readonly sessionId: string; readonly targetId: string } | null; session(): CdpSession; transport(): CdpTransport; + /** Returns the shared async mutex that serialized browser tools must acquire + * before performing mutations. Observation tools should not use this. */ + mutationMutex(): Mutex; }; const HEALTH_TTL_MS = 30_000; @@ -63,6 +67,7 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => { export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => { const transport = createCdpTransport(); const session = createCdpSession(transport); + const mutationMutex = createMutex(); let lastHealth = 0; let pageCache: { readonly info: PageInfo; readonly at: number } | null = null; let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null; @@ -228,5 +233,6 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = current: () => session.current(), session: () => session, transport: () => transport, + mutationMutex: () => mutationMutex, }; }; diff --git a/src/domains/scroll.ts b/src/domains/scroll.ts index db80af7..2bd2ed1 100644 --- a/src/domains/scroll.ts +++ b/src/domains/scroll.ts @@ -7,8 +7,8 @@ const ScrollArgs = Type.Object({ y: Type.Optional(Type.Number({ description: "Y coordinate where to scroll. Default: viewport center" })), deltaX: Type.Optional(Type.Number({ default: 0, description: "Horizontal scroll delta (CSS pixels). Positive = right." })), deltaY: Type.Optional(Type.Number({ - default: -300, - description: "Vertical scroll delta (CSS pixels). Positive = up (matches W3C wheel events). Default: -300 (scroll down).", + default: 300, + description: "Vertical scroll delta (CSS pixels). Positive = down, negative = up (W3C wheel-event convention). Default: 300 (scroll down).", })), }); @@ -16,10 +16,10 @@ export const scrollTool = defineBrowserTool({ name: "browser_scroll", label: "Browser Scroll", description: - "Scroll the page at given coordinates. deltaY follows W3C wheel-event convention: positive = scroll up, negative = scroll down. Default scrolls down (deltaY = -300).", - promptSnippet: "Scroll the page (deltaY positive=up, negative=down)", + "Scroll the page at given coordinates. deltaY follows W3C wheel-event convention: positive = scroll down, negative = scroll up. Default scrolls down (deltaY = 300).", + promptSnippet: "Scroll the page (deltaY positive=down, negative=up)", promptGuidelines: [ - "Default behavior scrolls down 300px (deltaY=-300). Pass a positive deltaY to scroll up.", + "Default behavior scrolls down 300px (deltaY=300). Pass a negative deltaY to scroll up.", "Pass x/y to target a specific scrollable region (e.g., a div with overflow); otherwise scrolls the page at viewport center.", ], parameters: ScrollArgs, @@ -35,14 +35,21 @@ export const scrollTool = defineBrowserTool({ const cx = args.x ?? Math.round(info.data.width / 2); const cy = args.y ?? Math.round(info.data.height / 2); const dx = args.deltaX ?? 0; - const dy = args.deltaY ?? -300; + const dy = args.deltaY ?? 300; + // The page target must be focused or Input events are dropped silently + // (mouseWheel hangs; synthesizeScrollGesture returns ok but no scroll). + // Page.bringToFront makes the target the active one in the browser. + const front = await client.session().call("Page.bringToFront", {}); + if (!front.success) return err({ kind: "cdp_error", message: front.error.message }); // Establish compositor mouse position before mouseWheel; without this, - // CDP sometimes drops the wheel event with a timeout. Fix preserved - // from v0.2.0. - const moved = await client.session().call("Input.dispatchMouseEvent", { type: "mouseMoved", x: cx, y: cy }); + // CDP sometimes drops the wheel event with a timeout. + const moved = await client.session().call("Input.dispatchMouseEvent", { + type: "mouseMoved", x: cx, y: cy, button: "none", buttons: 0, + }); if (!moved.success) return err({ kind: "cdp_error", message: moved.error.message }); const wheel = await client.session().call("Input.dispatchMouseEvent", { type: "mouseWheel", x: cx, y: cy, deltaX: dx, deltaY: dy, + button: "none", buttons: 0, pointerType: "mouse", }); if (!wheel.success) return err({ kind: "cdp_error", message: wheel.error.message }); return ok({ diff --git a/src/prompt.ts b/src/prompt.ts index 6d2b2ab..1a2a4c1 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -63,6 +63,16 @@ browser_run_script("/tmp/scrape-pages.js", { urls: [...] }) Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, Buffer, setTimeout, clearTimeout. +### Parallel Execution + +Observation tools (browser_screenshot, browser_page_info, browser_execute_js, browser_list_tabs, browser_http_get, etc.) can run in parallel with each other and with mutation tools. The harness automatically serializes mutation tools (click, type, scroll, navigate, switch_tab, etc.) so they never race on shared state. When operations are independent, emit them in the same turn for better performance. + +**Examples of safe parallel calls:** +\`\`\` +browser_screenshot() + browser_page_info() + browser_execute_js("document.title") +browser_http_get("https://api.example.com/data") + browser_click(x, y) +\`\`\` + ### Common Patterns **Navigation:** @@ -85,9 +95,9 @@ browser_http_get("https://api.example.com/data") **Scrolling:** \`\`\` -browser_screenshot() → browser_scroll({ deltaY: -500 }) → browser_screenshot() +browser_screenshot() → browser_scroll({ deltaY: 500 }) → browser_screenshot() \`\`\` -Note: deltaY follows W3C wheel-event convention: positive=up, negative=down. Default deltaY=-300 scrolls down. +Note: deltaY follows W3C wheel-event convention: positive=down, negative=up. Default deltaY=300 scrolls down. **Research Workflow (search + browser):** \`\`\` diff --git a/src/registry.ts b/src/registry.ts index 5f9d7da..d9b7cea 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -16,31 +16,34 @@ import { dragAndDropTool } from "./domains/drag"; import { httpGetTool, getNetworkLogTool } from "./domains/network"; import { executeJsTool, runScriptTool } from "./domains/js"; +// Mutation tools that modify page / browser state are marked `serialized: true` +// so they run through a shared async mutex. Observation / read-only tools are +// left unmarked and can execute in parallel. const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [ - clickTool, - typeTool, - pressKeyTool, - dispatchKeyTool, - scrollTool, + { ...clickTool, serialized: true }, + { ...typeTool, serialized: true }, + { ...pressKeyTool, serialized: true }, + { ...dispatchKeyTool, serialized: true }, + { ...scrollTool, serialized: true }, pageInfoTool, waitTool, - waitForLoadTool, - handleDialogTool, + { ...waitForLoadTool, serialized: true }, + { ...handleDialogTool, serialized: true }, screenshotTool, - navigateTool, - openUrlsTool, - goBackTool, - goForwardTool, - reloadTool, + { ...navigateTool, serialized: true }, + { ...openUrlsTool, serialized: true }, + { ...goBackTool, serialized: true }, + { ...goForwardTool, serialized: true }, + { ...reloadTool, serialized: true }, listTabsTool, currentTabTool, - switchTabTool, - newTabTool, - uploadFileTool, - downloadTool, - printToPdfTool, - viewportResizeTool, - dragAndDropTool, + { ...switchTabTool, serialized: true }, + { ...newTabTool, serialized: true }, + { ...uploadFileTool, serialized: true }, + { ...downloadTool, serialized: true }, + { ...printToPdfTool, serialized: true }, + { ...viewportResizeTool, serialized: true }, + { ...dragAndDropTool, serialized: true }, httpGetTool, getNetworkLogTool, executeJsTool, diff --git a/src/util/mutex.ts b/src/util/mutex.ts new file mode 100644 index 0000000..08efe76 --- /dev/null +++ b/src/util/mutex.ts @@ -0,0 +1,25 @@ +/** + * Lightweight async mutex (~10 lines). Acquire returns a release function. + * When the mutex is free, acquire() resolves immediately. When held, + * callers are queued and execute serially in FIFO order. + */ +export type Mutex = { + acquire(): Promise<() => void>; +}; + +export const createMutex = (): Mutex => { + let queue = Promise.resolve<unknown>(undefined); + + return { + async acquire(): Promise<() => void> { + let release = () => {}; + const next = new Promise<void>((resolve) => { + release = resolve; + }); + const prev = queue; + queue = prev.then(() => next); + await prev; + return release; + }, + }; +}; diff --git a/src/util/tool.ts b/src/util/tool.ts index d269ba2..c2bb3f8 100644 --- a/src/util/tool.ts +++ b/src/util/tool.ts @@ -53,6 +53,10 @@ export type BrowserToolDefinition<S extends TSchema> = { readonly handler: ToolHandler<S>; readonly renderCall?: (args: Static<S>, theme: Theme) => Component; readonly renderResult?: (result: AgentToolResult<unknown>, expanded: boolean, theme: Theme) => Component; + /** If true, the tool acquires the client's mutation mutex before executing. + * This ensures that two serialized tools never run concurrently, + * preventing race conditions on shared CDP session/page state. */ + readonly serialized?: boolean; readonly ensureAlive?: boolean; }; @@ -146,23 +150,43 @@ export const registerBrowserTool = ( ); } } - const result = await def.handler(args, { - client, - signal, - onUpdate: (u) => { - if (onUpdate) { - // AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails. - const update: AgentToolResult<OkDetails> = { - content: [{ type: "text", text: u.text }], - details: { ok: true, ...(u.details ?? {}) }, - }; - // Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails). - (onUpdate as AgentToolUpdateCallback<OkDetails>)(update); - } - }, - extensionCtx, - }); - return toToolResult(result, def.name); + // Serialized tools acquire the client's mutation mutex so that only one + // mutation tool runs at a time. Observation tools skip this and run freely + // in parallel. AbortSignal is checked after acquiring the lock so that a + // cancelled task doesn't hold the mutex forever. + let release: (() => void) | undefined; + if (def.serialized) { + const acquire = await client.mutationMutex().acquire(); + release = acquire; + if (signal?.aborted) { + release(); + return toToolResult( + { success: false, error: { kind: "invalid_state", message: "Tool execution aborted before entering serialized lane" } }, + def.name, + ); + } + } + try { + const result = await def.handler(args, { + client, + signal, + onUpdate: (u) => { + if (onUpdate) { + // AgentToolUpdateCallback expects AgentToolResult<TDetails>; we use unknown for TDetails. + const update: AgentToolResult<OkDetails> = { + content: [{ type: "text", text: u.text }], + details: { ok: true, ...(u.details ?? {}) }, + }; + // Cast needed: onUpdate is AgentToolUpdateCallback<unknown> (generic TDetails). + (onUpdate as AgentToolUpdateCallback<OkDetails>)(update); + } + }, + extensionCtx, + }); + return toToolResult(result, def.name); + } finally { + release?.(); + } }, }; pi.registerTool(td); |
