aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmail.com>2026-07-28 00:01:32 +0530
committerAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-08-02 19:12:46 +0530
commit5cfa7ccc8f4082db111bc0186c5fa3d44b767ad3 (patch)
tree05720833665398725d960aad67496ff6159540eb
parent753c2ad615fa9827e168be256e1a7c7384605855 (diff)
refactor(tools): declare concurrency in the tool definition
serialized: true was applied in registry.ts, away from the tools it describes. Now a required concurrency field on each definition, with a test pinning the serialized set so the classes cannot drift. The lock check defaults to taking the mutex for any class that is not explicitly "parallel", so a definition that bypasses defineBrowserTool at runtime fails safe rather than racing on shared CDP state. The test script gains --conditions=import: pi-coding-agent publishes no "require" condition, so importing any tool definition from a CJS-loaded test could not resolve it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-rw-r--r--package.json2
-rw-r--r--src/domains/click.ts1
-rw-r--r--src/domains/console.ts1
-rw-r--r--src/domains/dialog.ts1
-rw-r--r--src/domains/drag.ts1
-rw-r--r--src/domains/files.ts3
-rw-r--r--src/domains/form.ts3
-rw-r--r--src/domains/forms.ts4
-rw-r--r--src/domains/history.ts3
-rw-r--r--src/domains/js.ts2
-rw-r--r--src/domains/keyboard.ts3
-rw-r--r--src/domains/navigate.ts2
-rw-r--r--src/domains/network.ts2
-rw-r--r--src/domains/page.ts4
-rw-r--r--src/domains/readpage/read-page.ts1
-rw-r--r--src/domains/screenshot.ts1
-rw-r--r--src/domains/scroll.ts1
-rw-r--r--src/domains/search/web-search.ts1
-rw-r--r--src/domains/setup.ts1
-rw-r--r--src/domains/snapshot.ts1
-rw-r--r--src/domains/tabs.ts5
-rw-r--r--src/domains/viewport.ts1
-rw-r--r--src/registry.ts58
-rw-r--r--src/util/tool.ts11
-rw-r--r--test/domains/registry-test.ts127
25 files changed, 198 insertions, 42 deletions
diff --git a/package.json b/package.json
index 8c306d0..d21dcd6 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,7 @@
},
"scripts": {
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
- "test": "node --import tsx --test \"test/{profile,deep-research,domains,cdp,util}/**/*-test.ts\"",
+ "test": "node --conditions=import --import tsx --test \"test/{profile,deep-research,domains,cdp,util}/**/*-test.ts\"",
"prepublishOnly": "tsc --noEmit"
},
"publishConfig": {
diff --git a/src/domains/click.ts b/src/domains/click.ts
index db340a8..81bd7ca 100644
--- a/src/domains/click.ts
+++ b/src/domains/click.ts
@@ -67,6 +67,7 @@ export const clickTool = defineBrowserTool({
"If a click doesn't register, set BH_DEBUG_CLICKS=1 to get annotated screenshots (debug only). For React/Vue components ignoring clicks, try browser_dispatch_key.",
],
parameters: ClickArgs,
+ concurrency: "serialized",
renderCall: (a) => new Text(`πŸ–±οΈ Click ${a.ref ? `[${a.ref}]` : `(${a.x}, ${a.y})`}`, 0, 0),
async handler(args, { client }) {
const button = args.button ?? "left";
diff --git a/src/domains/console.ts b/src/domains/console.ts
index f9814d6..9696cd9 100644
--- a/src/domains/console.ts
+++ b/src/domains/console.ts
@@ -119,6 +119,7 @@ export const consoleTool = defineBrowserTool({
"Don't use as a default observation tool β€” prefer browser_snapshot for page structure.",
],
parameters: ConsoleArgs,
+ concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const session = client.session();
diff --git a/src/domains/dialog.ts b/src/domains/dialog.ts
index c30e70c..7038c64 100644
--- a/src/domains/dialog.ts
+++ b/src/domains/dialog.ts
@@ -18,6 +18,7 @@ export const handleDialogTool = defineBrowserTool({
"For prompt() dialogs, supply promptText with the value to submit.",
],
parameters: HandleDialogArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.session().call("Page.handleJavaScriptDialog", {
accept: args.accept,
diff --git a/src/domains/drag.ts b/src/domains/drag.ts
index 9075fd0..9ad2ada 100644
--- a/src/domains/drag.ts
+++ b/src/domains/drag.ts
@@ -24,6 +24,7 @@ export const dragAndDropTool = defineBrowserTool({
"dataTransfer is an optional MIME→data map (e.g., { 'text/plain': 'hello' }).",
],
parameters: DragArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const data = args.dataTransfer
? {
diff --git a/src/domains/files.ts b/src/domains/files.ts
index cf9393e..4e0195d 100644
--- a/src/domains/files.ts
+++ b/src/domains/files.ts
@@ -109,6 +109,7 @@ export const uploadFileTool = defineBrowserTool({
"File path must be absolute and readable.",
],
parameters: UploadArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const readable = await verifyReadable(args.filePath);
if (!readable.success) return readable;
@@ -160,6 +161,7 @@ export const downloadTool = defineBrowserTool({
"Affects all subsequent downloads on this browser.",
],
parameters: DownloadArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
try {
await mkdir(args.downloadPath, { recursive: true });
@@ -205,6 +207,7 @@ export const printToPdfTool = defineBrowserTool({
promptSnippet: "Print the current page to PDF",
promptGuidelines: ["Default output path is in tmpdir; pass outputPath to control."],
parameters: PrintPdfArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.session().call("Page.printToPDF", {
printBackground: true,
diff --git a/src/domains/form.ts b/src/domains/form.ts
index 95516c5..6e3285a 100644
--- a/src/domains/form.ts
+++ b/src/domains/form.ts
@@ -136,6 +136,7 @@ export const fillTool = defineBrowserTool({
"The result reports the field's value after writing and an appended page-changes diff β€” confirm both match what you intended before moving on.",
],
parameters: FillArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FILL_FN, arg: args.value });
if (!r.success) return r;
@@ -198,6 +199,7 @@ export const focusTool = defineBrowserTool({
"For simply setting a value, prefer browser_fill (it doesn't need a separate focus step).",
],
parameters: FocusArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await runOnElement(client, { ref: args.ref, selector: args.selector, fnBody: FOCUS_FN, arg: "" });
if (!r.success) return r;
@@ -277,6 +279,7 @@ export const selectOptionTool = defineBrowserTool({
"If no option matches, the error details list the available options so you can retry.",
],
parameters: SelectOptionArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
if (args.value === undefined && args.label === undefined && args.index === undefined) {
return err({ kind: "invalid_state", message: "Provide one of value, label, or index." });
diff --git a/src/domains/forms.ts b/src/domains/forms.ts
index e38af9a..ed64a74 100644
--- a/src/domains/forms.ts
+++ b/src/domains/forms.ts
@@ -231,6 +231,7 @@ export const fillTool = defineBrowserTool({
"If a ref no longer resolves, re-run browser_snapshot to get fresh refs.",
],
parameters: FillArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await resolveAndCall(client, args.ref, FILL_FN, [args.value]);
if (!r.success) return r;
@@ -278,6 +279,7 @@ export const fillFormTool = defineBrowserTool({
"Re-run browser_snapshot afterwards to verify the form state if needed.",
],
parameters: FillFormArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const results: Array<Readonly<Record<string, unknown>>> = [];
let okCount = 0;
@@ -323,6 +325,7 @@ export const selectOptionTool = defineBrowserTool({
"Use this for native <select> elements β€” clicking coordinates does not work for OS dropdowns.",
],
parameters: SelectOptionArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
if (args.value === undefined && args.label === undefined) {
return err({ kind: "invalid_state", message: "Provide value or label to select an option." });
@@ -356,6 +359,7 @@ export const setCheckedTool = defineBrowserTool({
"For radios, set checked:true on the option you want; the group deselects the others.",
],
parameters: SetCheckedArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await resolveAndCall(client, args.ref, CHECK_FN, [args.checked]);
if (!r.success) return r;
diff --git a/src/domains/history.ts b/src/domains/history.ts
index 96036ba..fbab376 100644
--- a/src/domains/history.ts
+++ b/src/domains/history.ts
@@ -19,6 +19,7 @@ export const goBackTool = defineBrowserTool({
promptSnippet: "Go back one page",
promptGuidelines: ["Use to return to the previous page after navigating."],
parameters: Type.Object({}),
+ concurrency: "serialized",
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
const h = await fetchHistory(client);
if (!h.success) return h;
@@ -38,6 +39,7 @@ export const goForwardTool = defineBrowserTool({
promptSnippet: "Go forward one page",
promptGuidelines: ["Use to undo a browser_go_back."],
parameters: Type.Object({}),
+ concurrency: "serialized",
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
const h = await fetchHistory(client);
if (!h.success) return h;
@@ -57,6 +59,7 @@ export const reloadTool = defineBrowserTool({
promptSnippet: "Reload the page",
promptGuidelines: ["Use to refresh the page, e.g. after server-side changes."],
parameters: Type.Object({}),
+ concurrency: "serialized",
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.session().call("Page.reload");
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
diff --git a/src/domains/js.ts b/src/domains/js.ts
index 14862e8..b4a5b4c 100644
--- a/src/domains/js.ts
+++ b/src/domains/js.ts
@@ -51,6 +51,7 @@ export const executeJsTool = defineBrowserTool({
"Result must be JSON-serializable (Runtime.evaluate returnByValue=true).",
],
parameters: ExecuteJsArgs,
+ concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.evaluateJs(args.expression, args.targetId);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -158,6 +159,7 @@ export const runScriptTool = defineBrowserTool({
"Script MUST return { content: [{ type: 'text', text: '...' }], details?: {...} }. Throw on errors.",
],
parameters: RunScriptArgs,
+ concurrency: "parallel",
async handler(args, { client, signal, onUpdate, extensionCtx }): Promise<Result<ToolOk, ToolErr>> {
if (!isAbsolute(args.path)) {
return err({ kind: "invalid_state", message: "Script path must be absolute" });
diff --git a/src/domains/keyboard.ts b/src/domains/keyboard.ts
index 6722ffb..473f633 100644
--- a/src/domains/keyboard.ts
+++ b/src/domains/keyboard.ts
@@ -26,6 +26,7 @@ export const typeTool = defineBrowserTool({
"For special keys (Enter, Tab, Escape, arrows), use browser_press_key instead.",
],
parameters: TypeArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const focused = await client.evaluateJs(safeJs`
(() => {
@@ -79,6 +80,7 @@ export const pressKeyTool = defineBrowserTool({
"Modifiers: 1=Alt, 2=Ctrl, 4=Meta(Cmd), 8=Shift. Combine with bitwise OR: Ctrl+Shift = 2|8 = 10.",
],
parameters: PressKeyArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const k = args.key;
const code = virtualKeyCode(k);
@@ -136,6 +138,7 @@ export const dispatchKeyTool = defineBrowserTool({
"eventType defaults to 'keydown'.",
],
parameters: DispatchKeyArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const eventType = args.eventType ?? "keydown";
const target = args.ref ?? args.selector ?? "";
diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts
index 98a2c13..ba86751 100644
--- a/src/domains/navigate.ts
+++ b/src/domains/navigate.ts
@@ -28,6 +28,7 @@ export const navigateTool = defineBrowserTool({
"Note: Google and some sites with strict anti-bot detection may reject CDP navigation. Use browser_http_get for search results.",
],
parameters: NavigateArgs,
+ concurrency: "serialized",
renderCall: (a) => new Text(`🌐 Navigate to ${a.url}`, 0, 0),
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const tabs = await client.listTabs(false);
@@ -82,6 +83,7 @@ export const openUrlsTool = defineBrowserTool({
"Use browser_wait_for_load on a tab before extracting data from SPAs.",
],
parameters: OpenUrlsArgs,
+ concurrency: "serialized",
renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0),
async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> {
const total = args.urls.length;
diff --git a/src/domains/network.ts b/src/domains/network.ts
index ca46664..dc23c6d 100644
--- a/src/domains/network.ts
+++ b/src/domains/network.ts
@@ -25,6 +25,7 @@ export const httpGetTool = defineBrowserTool({
"No JS rendering β€” for SPAs use browser_navigate.",
],
parameters: HttpGetArgs,
+ concurrency: "parallel",
ensureAlive: false,
async handler(args): Promise<Result<ToolOk, ToolErr>> {
const ac = new AbortController();
@@ -180,6 +181,7 @@ export const networkRequestsTool = defineBrowserTool({
"If bufferOverflowed:true in the result, older events were dropped (capacity 500 records/tab).",
],
parameters: NetworkRequestsArgs,
+ concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const session = client.session();
diff --git a/src/domains/page.ts b/src/domains/page.ts
index 8e674cb..0d7aa8b 100644
--- a/src/domains/page.ts
+++ b/src/domains/page.ts
@@ -17,6 +17,7 @@ export const pageInfoTool = defineBrowserTool({
"browser_page_info auto-detects alert, confirm, prompt, and beforeunload dialogs.",
],
parameters: Type.Object({}),
+ concurrency: "parallel",
async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.pageInfo();
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -46,6 +47,7 @@ export const waitTool = defineBrowserTool({
promptSnippet: "Wait N seconds",
promptGuidelines: ["Use sparingly β€” prefer browser_wait_for_load when waiting for a page load."],
parameters: WaitArgs,
+ concurrency: "parallel",
async handler(args, { signal }): Promise<Result<ToolOk, ToolErr>> {
try {
await sleep(Math.round(args.seconds * 1000), signal);
@@ -71,6 +73,7 @@ export const waitForLoadTool = defineBrowserTool({
"Returns when readyState becomes 'complete' OR the timeout elapses.",
],
parameters: WaitForLoadArgs,
+ concurrency: "serialized",
async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
const timeoutMs = (args.timeout ?? 15) * 1000;
const start = Date.now();
@@ -106,6 +109,7 @@ export const waitForTool = defineBrowserTool({
"Returns a typed timeout error if the condition isn't met within the timeout.",
],
parameters: WaitForArgs,
+ concurrency: "parallel",
async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
if (args.selector === undefined && args.text === undefined) {
return err({ kind: "invalid_state", message: "Provide a selector or text to wait for." });
diff --git a/src/domains/readpage/read-page.ts b/src/domains/readpage/read-page.ts
index c21b264..453c347 100644
--- a/src/domains/readpage/read-page.ts
+++ b/src/domains/readpage/read-page.ts
@@ -109,6 +109,7 @@ export const readPageTool = defineBrowserTool({
"Boilerplate-heavy or structure-less pages fall back to bounded body text rather than erroring.",
],
parameters: ReadPageArgs,
+ concurrency: "parallel",
async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
if (args.url !== undefined && args.url.length > 0) return readOpenedUrl(client, args.url, signal);
if (args.targetId !== undefined && args.targetId.length > 0) return readOwnedTab(client, args.targetId);
diff --git a/src/domains/screenshot.ts b/src/domains/screenshot.ts
index a30d336..e3b3615 100644
--- a/src/domains/screenshot.ts
+++ b/src/domains/screenshot.ts
@@ -74,6 +74,7 @@ export const screenshotTool = defineBrowserTool({
"format='jpeg' with quality 60-90 keeps screenshots small for photo-heavy pages. Use maxDim if the page is huge.",
],
parameters: ScreenshotArgs,
+ concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const cap = await captureBase(client, args);
if (!cap.success) return cap;
diff --git a/src/domains/scroll.ts b/src/domains/scroll.ts
index 2bd2ed1..f29e931 100644
--- a/src/domains/scroll.ts
+++ b/src/domains/scroll.ts
@@ -23,6 +23,7 @@ export const scrollTool = defineBrowserTool({
"Pass x/y to target a specific scrollable region (e.g., a div with overflow); otherwise scrolls the page at viewport center.",
],
parameters: ScrollArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const info = await client.pageInfo();
if (!info.success) return err({ kind: "cdp_error", message: info.error.message });
diff --git a/src/domains/search/web-search.ts b/src/domains/search/web-search.ts
index 8908e29..69677b4 100644
--- a/src/domains/search/web-search.ts
+++ b/src/domains/search/web-search.ts
@@ -50,6 +50,7 @@ export const webSearchTool = defineBrowserTool({
"Runs in its own isolated tab and never disturbs the user's current tab.",
],
parameters: WebSearchArgs,
+ concurrency: "serialized",
async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
const limit = args.limit ?? 10;
const serpUrl = buildSerpUrl(args.query, limit);
diff --git a/src/domains/setup.ts b/src/domains/setup.ts
index 2ddd739..c2d8a4f 100644
--- a/src/domains/setup.ts
+++ b/src/domains/setup.ts
@@ -28,6 +28,7 @@ export const setupTool = defineBrowserTool({
"After browser_setup succeeds, retry the browser tool that failed.",
],
parameters: SetupArgs,
+ concurrency: "parallel",
// ensureAlive:false β€” this tool IS the setup; it must bypass the socket guard
ensureAlive: false,
renderCall: () => new Text("πŸ”§ Initializing browser...", 0, 0),
diff --git a/src/domains/snapshot.ts b/src/domains/snapshot.ts
index e1c66d3..bfc475c 100644
--- a/src/domains/snapshot.ts
+++ b/src/domains/snapshot.ts
@@ -313,6 +313,7 @@ export const snapshotTool = defineBrowserTool({
"format:'json' returns the raw slim structure (with `ref` and `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
],
parameters: SnapshotArgs,
+ concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const session = client.session();
diff --git a/src/domains/tabs.ts b/src/domains/tabs.ts
index e3e2623..2971a6b 100644
--- a/src/domains/tabs.ts
+++ b/src/domains/tabs.ts
@@ -24,6 +24,7 @@ export const listTabsTool = defineBrowserTool({
"Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.",
],
parameters: ListTabsArgs,
+ concurrency: "parallel",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.listTabs(args.includeInternal ?? true);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -50,6 +51,7 @@ export const currentTabTool = defineBrowserTool({
promptSnippet: "Get current tab info",
promptGuidelines: ["Returns targetId, url, title, and whether the tab is owned by this harness session."],
parameters: Type.Object({}),
+ concurrency: "parallel",
async handler(_a, { client }): Promise<Result<ToolOk, ToolErr>> {
const cur = client.current();
if (!cur) return err({ kind: "invalid_state", message: "No tab attached." });
@@ -83,6 +85,7 @@ export const switchTabTool = defineBrowserTool({
"Refuses tabs not owned by this session β€” use browser_new_tab to open a controllable tab instead.",
],
parameters: SwitchTabArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
// Resolve to a concrete targetId (exact, or unique hex prefix). We resolve
// first so the ownership check sees the canonical id, not the prefix.
@@ -141,6 +144,7 @@ export const newTabTool = defineBrowserTool({
"Pass url to navigate immediately.",
],
parameters: NewTabArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.newTab(args.url);
if (!r.success) return err({ kind: "cdp_error", message: r.error.message });
@@ -167,6 +171,7 @@ export const closeTabTool = defineBrowserTool({
"Accepts exact targetId or a unique hex prefix (>=8 chars).",
],
parameters: CloseTabArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
let resolved = args.targetId;
if (!client.owns(resolved)) {
diff --git a/src/domains/viewport.ts b/src/domains/viewport.ts
index 015ac68..9e9ac07 100644
--- a/src/domains/viewport.ts
+++ b/src/domains/viewport.ts
@@ -20,6 +20,7 @@ export const viewportResizeTool = defineBrowserTool({
"Pass deviceScaleFactor=2 to simulate retina displays.",
],
parameters: ViewportArgs,
+ concurrency: "serialized",
async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
const r = await client.session().call("Emulation.setDeviceMetricsOverride", {
width: args.width,
diff --git a/src/registry.ts b/src/registry.ts
index a063c9f..8301d29 100644
--- a/src/registry.ts
+++ b/src/registry.ts
@@ -22,53 +22,47 @@ import { setupTool } from "./domains/setup";
import { webSearchTool } from "./domains/search/web-search";
import { readPageTool } from "./domains/readpage/read-page";
-// 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> = [
- // setup comes first β€” the agent calls this to self-recover from not-initialized
+export const ALL_TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [
setupTool,
- { ...clickTool, serialized: true },
- { ...typeTool, serialized: true },
- { ...fillTool, serialized: true },
- { ...selectOptionTool, serialized: true },
- { ...focusTool, serialized: true },
- { ...pressKeyTool, serialized: true },
- { ...dispatchKeyTool, serialized: true },
- { ...scrollTool, serialized: true },
+ clickTool,
+ typeTool,
+ fillTool,
+ selectOptionTool,
+ focusTool,
+ pressKeyTool,
+ dispatchKeyTool,
+ scrollTool,
pageInfoTool,
waitTool,
waitForTool,
- { ...waitForLoadTool, serialized: true },
- { ...handleDialogTool, serialized: true },
+ waitForLoadTool,
+ handleDialogTool,
screenshotTool,
- { ...navigateTool, serialized: true },
- { ...openUrlsTool, serialized: true },
- { ...goBackTool, serialized: true },
- { ...goForwardTool, serialized: true },
- { ...reloadTool, serialized: true },
+ navigateTool,
+ openUrlsTool,
+ goBackTool,
+ goForwardTool,
+ reloadTool,
listTabsTool,
currentTabTool,
- { ...switchTabTool, serialized: true },
- { ...newTabTool, serialized: true },
- { ...closeTabTool, serialized: true },
- { ...uploadFileTool, serialized: true },
- { ...downloadTool, serialized: true },
- { ...printToPdfTool, serialized: true },
- { ...viewportResizeTool, serialized: true },
- { ...dragAndDropTool, serialized: true },
+ switchTabTool,
+ newTabTool,
+ closeTabTool,
+ uploadFileTool,
+ downloadTool,
+ printToPdfTool,
+ viewportResizeTool,
+ dragAndDropTool,
httpGetTool,
networkRequestsTool,
consoleTool,
snapshotTool,
executeJsTool,
runScriptTool,
- // Opens and owns an isolated tab to scrape a SERP β€” same class as openUrlsTool.
- { ...webSearchTool, serialized: true },
- // Pure isolated read (own sessionId, own tab) β€” safe concurrent, like executeJsTool.
+ webSearchTool,
readPageTool,
];
export const registerAllTools = (pi: ExtensionAPI, client: BrowserClient): void => {
- for (const t of TOOLS) registerBrowserTool(pi, client, t);
+ for (const t of ALL_TOOLS) registerBrowserTool(pi, client, t);
};
diff --git a/src/util/tool.ts b/src/util/tool.ts
index 1e33c43..90a2e15 100644
--- a/src/util/tool.ts
+++ b/src/util/tool.ts
@@ -55,10 +55,7 @@ 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 concurrency: "serialized" | "parallel";
readonly ensureAlive?: boolean;
};
@@ -169,12 +166,8 @@ export const registerBrowserTool = (
);
}
}
- // 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) {
+ if (def.concurrency !== "parallel") {
const acquire = await client.mutationMutex().acquire();
release = acquire;
if (signal?.aborted) {
diff --git a/test/domains/registry-test.ts b/test/domains/registry-test.ts
new file mode 100644
index 0000000..a35f485
--- /dev/null
+++ b/test/domains/registry-test.ts
@@ -0,0 +1,127 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { Type } from "typebox";
+import type { ExtensionAPI, ToolDefinition } from "@mariozechner/pi-coding-agent";
+import type { TSchema } from "typebox";
+import { ALL_TOOLS } from "../../src/registry";
+import { type AnyBrowserToolDefinition, registerBrowserTool } from "../../src/util/tool";
+import type { BrowserClient } from "../../src/client";
+import { ok } from "../../src/util/result";
+
+const SERIALIZED = new Set([
+ "browser_click",
+ "browser_type",
+ "browser_fill",
+ "browser_select_option",
+ "browser_focus",
+ "browser_press_key",
+ "browser_dispatch_key",
+ "browser_scroll",
+ "browser_wait_for_load",
+ "browser_handle_dialog",
+ "browser_navigate",
+ "browser_open_urls",
+ "browser_go_back",
+ "browser_go_forward",
+ "browser_reload",
+ "browser_switch_tab",
+ "browser_new_tab",
+ "browser_close_tab",
+ "browser_upload_file",
+ "browser_download",
+ "browser_print_to_pdf",
+ "browser_viewport_resize",
+ "browser_drag_and_drop",
+ "browser_web_search",
+]);
+
+describe("tool registry", () => {
+ test("every tool declares a concurrency class", () => {
+ for (const t of ALL_TOOLS) {
+ assert.ok(
+ t.concurrency === "serialized" || t.concurrency === "parallel",
+ `${t.name} has no concurrency class`,
+ );
+ }
+ });
+
+ test("the serialized set is exactly the mutating tools", () => {
+ const actual = new Set(
+ ALL_TOOLS.filter((t) => t.concurrency === "serialized").map((t) => t.name),
+ );
+ assert.deepEqual([...actual].sort(), [...SERIALIZED].sort());
+ });
+
+ test("tool names are unique", () => {
+ const names = ALL_TOOLS.map((t) => t.name);
+ assert.equal(new Set(names).size, names.length);
+ });
+
+ test("every tool name uses the browser_ prefix", () => {
+ for (const t of ALL_TOOLS) assert.match(t.name, /^browser_/);
+ });
+});
+
+type LockProbe = {
+ readonly acquires: ReadonlyArray<string>;
+ run: (def: AnyBrowserToolDefinition) => Promise<void>;
+};
+
+const lockProbe = (): LockProbe => {
+ const acquires: string[] = [];
+ const client = {
+ mutationMutex: () => ({
+ acquire: async () => {
+ acquires.push("acquired");
+ return () => {};
+ },
+ }),
+ } as unknown as BrowserClient;
+ return {
+ acquires,
+ run: async (def) => {
+ let registered: ToolDefinition<TSchema> | undefined;
+ const pi = {
+ registerTool: (td: ToolDefinition<TSchema>) => {
+ registered = td;
+ },
+ } as unknown as ExtensionAPI;
+ registerBrowserTool(pi, client, def);
+ assert.ok(registered, "tool was not registered");
+ await registered.execute?.("id", {}, undefined, undefined, undefined as never);
+ },
+ };
+};
+
+const probeTool = (concurrency: unknown): AnyBrowserToolDefinition =>
+ ({
+ name: "browser_probe",
+ label: "Probe",
+ description: "probe",
+ promptSnippet: "probe",
+ promptGuidelines: [],
+ parameters: Type.Object({}),
+ ensureAlive: false,
+ concurrency,
+ handler: async () => ok({ text: "done" }),
+ }) as AnyBrowserToolDefinition;
+
+describe("concurrency lane selection", () => {
+ test("a parallel tool skips the mutation mutex", async () => {
+ const probe = lockProbe();
+ await probe.run(probeTool("parallel"));
+ assert.deepEqual([...probe.acquires], []);
+ });
+
+ test("a serialized tool takes the mutation mutex", async () => {
+ const probe = lockProbe();
+ await probe.run(probeTool("serialized"));
+ assert.deepEqual([...probe.acquires], ["acquired"]);
+ });
+
+ test("a definition with no concurrency class takes the mutation mutex", async () => {
+ const probe = lockProbe();
+ await probe.run(probeTool(undefined));
+ assert.deepEqual([...probe.acquires], ["acquired"]);
+ });
+});