diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-04-30 17:52:26 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-04-30 17:52:26 +0530 |
| commit | 28578d1d6237cdec3f613a3ed9fa6e888dd71a6b (patch) | |
| tree | c7f69ac2fb286a4551c5f7ecc0e67225b674f945 | |
| parent | b6d752222947229112132ac0fbd87f355fc78974 (diff) | |
Replace dynamic tool registration with browser_run_script
Remove register_tool/remove_tool/list_dynamic_tools and the entire
dynamic-tools.ts module. Replace with a single browser_run_script tool
that executes temporary scripts from disk with daemon access.
Scripts are now written to disk before execution, making them auditable
and re-runnable. Same bindings (daemon, require, params, etc.) as before
via the same AsyncFunction mechanism.
| -rw-r--r-- | README.md | 43 | ||||
| -rw-r--r-- | skills/pi-browser-harness/SKILL.md | 57 | ||||
| -rw-r--r-- | src/dynamic-tools.ts | 335 | ||||
| -rw-r--r-- | src/index.ts | 2 | ||||
| -rw-r--r-- | src/prompt.ts | 28 | ||||
| -rw-r--r-- | src/tools.ts | 132 |
6 files changed, 207 insertions, 390 deletions
@@ -73,9 +73,7 @@ That's it. Try: | Tool | What it does | |------|--------------| -| `list_dynamic_tools` | List tools registered this session | -| `register_tool` | Add a new tool from JS — callable next turn | -| `remove_tool` | Retire a registered tool | +| `browser_run_script` | Execute a temporary script file with daemon access | --- @@ -152,29 +150,30 @@ browser_press_key("v", { modifiers: 4 }) // Cmd+V browser_press_key("T", { modifiers: 10 }) // Ctrl+Shift+T ``` -### Writing your own tool mid-task +### Writing a temporary script -When the built-ins aren't enough, `register_tool` injects a new one — available on the next turn. +When the built-ins aren't enough, write a script to disk and run it with `browser_run_script`. ```js -register_tool({ - name: "scrape_results", - implementation: ` - const data = await daemon.evaluateJS(\` - JSON.stringify( - Array.from(document.querySelectorAll('${params.selector}')) - .map(el => el.textContent.trim()) - ) - \`); - return { - content: [{ type: "text", text: data }], - details: { items: JSON.parse(data) }, - }; - `, -}); +// 1. Write the script to disk +write("/tmp/scrape-results.js", ` + const data = await daemon.evaluateJS(\` + JSON.stringify( + Array.from(document.querySelectorAll('${params.selector}')) + .map(el => el.textContent.trim()) + ) + \`); + return { + content: [{ type: "text", text: data }], + details: { items: JSON.parse(data) }, + }; +`) + +// 2. Execute it +browser_run_script("/tmp/scrape-results.js", { selector: ".result" }) ``` -Tool implementations receive `params`, `daemon`, `require`, `signal`, `onUpdate`, `ctx`, plus the standard JS globals. +Scripts receive `params`, `daemon`, `require`, `signal`, `onUpdate`, `ctx`, plus the standard JS globals. The script is on disk — auditable and re-runnable. --- @@ -201,7 +200,7 @@ Tool implementations receive `params`, `daemon`, `require`, `signal`, `onUpdate` ## Security -This extension drives your real Chrome. The agent can see open tabs, read page content, submit forms, and act inside authenticated sessions. `register_tool` evaluates JavaScript in the pi process with full `require` access — review any dynamic tool you didn't write yourself. +This extension drives your real Chrome. The agent can see open tabs, read page content, submit forms, and act inside authenticated sessions. `browser_run_script` evaluates JavaScript in the pi process with full `require` access — review any temporary scripts the agent writes to `/tmp/` before executing them. --- diff --git a/skills/pi-browser-harness/SKILL.md b/skills/pi-browser-harness/SKILL.md index cc19e7a..59c236f 100644 --- a/skills/pi-browser-harness/SKILL.md +++ b/skills/pi-browser-harness/SKILL.md @@ -60,6 +60,9 @@ and cross-origin content. No selectors needed. - `browser_wait_for_load` — wait for document.readyState === 'complete' - `browser_handle_dialog` — accept or dismiss JS dialogs (alert/confirm/prompt) +### Extending +- `browser_run_script` — execute a temporary script file with daemon access + ## Pattern Reference ### Navigation @@ -97,49 +100,46 @@ browser_screenshot() → visually inspect the page browser_execute_js("document.querySelector('.content').innerText") → extract text ``` -## Self-Extending Harness +## Temporary Scripts -The harness can write its own tools when you need a capability that doesn't exist yet. -This is the same philosophy as browser-harness: "The agent writes what's missing, mid-task." +When the built-in tools aren't enough, write a temporary script to disk and execute it +with `browser_run_script`. Scripts run in the harness process with direct access to +the browser daemon and full Node.js APIs — no dynamic tool registration needed. ``` ● agent: needs to scrape 47 paginated search result pages │ - ● calls list_dynamic_tools() → empty + ● write("/tmp/scrape-pages.js", "...script using daemon.cdp() and daemon.evaluateJS()...") │ - ● calls register_tool({ name: "scrape_paginated", implementation: "..." }) + ● browser_run_script("/tmp/scrape-pages.js", { urls: [...] }) │ - ✓ next turn: scrape_paginated() is available and callable + ✓ script executes and returns results ``` -**Tool lifecycle:** -- `list_dynamic_tools` — see what's already been registered -- `register_tool` — inject a new tool (available next turn, no reload) -- `remove_tool` — retire a tool you no longer need +The script is written to disk, so it's auditable and re-runnable. -**When to extend:** +**When to use scripts:** - You're about to do the same 3+ step sequence more than once - You need a capability the built-in tools don't provide (e.g., bulk CSV export, structured extraction, pagination) - You discover a domain-specific pattern that should be reusable - The alternative is a fragile multi-turn loop of screenshots + clicks -**What NOT to do with dynamic tools:** -- Don't register tools for one-off actions. Use the built-in browser_* tools directly. -- Don't duplicate existing tools. Call list_dynamic_tools first. -- Don't try to call browser_* tools from within a dynamic tool implementation. Sequence them as separate tool calls. +**What NOT to do:** +- Don't write scripts for one-off actions — use the built-in browser_* tools directly. +- Don't try to call browser_* tools from within a script — sequence them as separate tool calls. -**Implementation bindings available inside dynamic tools:** -- `params` — the tool's arguments object +**Script bindings:** +- `params` — the arguments passed to browser_run_script - `daemon` — browser daemon (daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.) -- `require` — Node.js require() (use 'node:fs/promises', 'node:path', 'node:crypto', etc.) +- `require` — Node.js require() (use 'fs', 'path', 'crypto', etc.) - `signal` — AbortSignal for cancellation - `onUpdate` — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] }) - `ctx` — ExtensionContext (ctx.cwd, ctx.signal, etc.) - `console`, `fetch`, `JSON`, `Buffer`, `setTimeout`, `clearTimeout` -**Example — a dynamic tool that extracts structured data:** +**Example — a script that extracts structured data:** ```javascript -// register_tool implementation: +// /tmp/extract-products.js const info = await daemon.getPageInfo(); if ("dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message); const data = await daemon.evaluateJS(` @@ -201,16 +201,13 @@ browser_execute_js("!!document.querySelector('.loaded-content')") ``` pi agent → pi-browser-harness (TypeScript) - │ Unix socket - ▼ - /tmp/bu-<namespace>.sock - │ + │ CDP WebSocket ▼ - daemon.py → CDP WebSocket → Chrome + Chrome -Dynamic tools (registered at runtime): - list_dynamic_tools / register_tool / remove_tool - │ - ▼ - pi.registerTool() → available next turn, no reload +Temporary scripts: + write("/tmp/script.js") → browser_run_script("/tmp/script.js") + │ │ + ▼ ▼ + script on disk (auditable) executed in harness process with daemon access ``` diff --git a/src/dynamic-tools.ts b/src/dynamic-tools.ts deleted file mode 100644 index baaf70f..0000000 --- a/src/dynamic-tools.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * dynamic-tools.ts — self-writing tools for pi-browser-harness. - * - * Mirrors the browser-harness philosophy: the agent writes what's missing, - * mid-task. No framework, no recipes, no rails. - * - * Provides three management tools: - * list_dynamic_tools — see what's already registered - * register_tool — inject a new tool into the harness - * remove_tool — retire a tool that's no longer needed - * - * Tools registered via register_tool (which internally calls pi.registerTool()) - * are available immediately in the same session — the LLM can call them on - * the very next sub-turn within the same agent processing cycle, without - * needing /reload. The agent decides to extend the harness the same way - * it decides to write any code: it sees a gap and fills it. - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { Type } from "typebox"; -import type { BrowserDaemon } from "./daemon"; - -// ── Types ──────────────────────────────────────────────────────────────────── - -interface DynamicToolDef { - name: string; - label: string; - description: string; - execute: (params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, ctx: unknown) => Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> | undefined }>; -} - -// ── AsyncFunction constructor ──────────────────────────────────────────────── - -const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (...args: string[]) => (...args: unknown[]) => Promise<unknown>; - -// ── Registration ───────────────────────────────────────────────────────────── - -export function registerDynamicTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { - const registry = new Map<string, DynamicToolDef>(); - - // ═══════════════════════════════════════════════════════════════════════════ - // list_dynamic_tools - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "list_dynamic_tools", - label: "List Dynamic Tools", - description: - "List all dynamically registered tools with their names, labels, and descriptions. " + - "Call this FIRST when you're unsure whether a needed capability already exists — " + - "before writing a new tool.", - promptSnippet: "List all dynamically registered tools (names + descriptions)", - promptGuidelines: [ - "Call list_dynamic_tools before register_tool to avoid duplicating existing capabilities.", - "If a needed tool exists but has the wrong shape, use remove_tool to replace it with a corrected version.", - ], - parameters: Type.Object({}), - async execute() { - if (registry.size === 0) { - return { - content: [{ - type: "text" as const, - text: "No dynamic tools registered yet. The harness is clean — use register_tool to teach it something new.", - }], - details: { tools: [], count: 0 }, - }; - } - - const tools = Array.from(registry.values()).map((t) => ({ - name: t.name, - label: t.label, - description: t.description, - })); - - return { - content: [{ - type: "text" as const, - text: `${tools.length} dynamic tool(s) registered:\n\n` + - tools.map((t) => `• ${t.name} — ${t.description}`).join("\n"), - }], - details: { tools, count: tools.length }, - }; - }, - }); - - // ═══════════════════════════════════════════════════════════════════════════ - // register_tool - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "register_tool", - label: "Register Dynamic Tool", - description: - "Register a new tool by providing its JavaScript implementation. " + - "The tool becomes available immediately in the same session — " + - "the LLM can call it on the very next sub-turn within the same " + - "agent processing cycle, without reloading. " + - "Use this whenever you discover you need a capability the harness doesn't " + - "already have. Don't work around limitations, extend the harness.\n\n" + - "The implementation runs as an async function body. It receives these bindings:\n" + - " params — the tool's arguments (as passed by the caller)\n" + - " daemon — the browser daemon (for composing browser actions: daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.)\n" + - " require — Node.js require() for builtins and installed packages\n" + - " signal — AbortSignal for cancellation\n" + - " onUpdate — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })\n" + - " ctx — ExtensionContext with cwd, sessionManager, ui, signal, etc.\n" + - " console, fetch, JSON, Buffer, setTimeout, clearTimeout\n\n" + - "The implementation MUST return { content: [{ type: 'text', text: '...' }], details?: {...} }.\n" + - "For errors, throw: throw new Error('something went wrong').\n\n" + - "Example implementation:\n" + - ' const info = await daemon.getPageInfo();\n' + - ' if ("dialog" in info) throw new Error("Dialog is blocking the page");\n' + - ' return { content: [{ type: "text", text: `Current URL: ${info.url}` }], details: { url: info.url } };', - promptSnippet: "Register a new tool with custom JavaScript logic (available immediately, next sub-turn)", - promptGuidelines: [ - "Use register_tool when you need a capability not provided by the built-in browser_* tools. The new tool is available immediately — the LLM can call it on the very next sub-turn within the same agent processing cycle, without reloading.", - "Before registering, always call list_dynamic_tools to check if a suitable tool already exists.", - "The implementation has access to the browser daemon via `daemon` — use daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc. to compose browser actions inside the tool.", - "For pure data processing or file operations, use require() to access Node.js modules like fs, path, crypto.", - "If the implementation needs existing browser tools (browser_navigate, browser_screenshot, etc.), break those into separate tool calls before or after the dynamic tool — do not try to invoke them from within the implementation.", - ], - parameters: Type.Object({ - name: Type.String({ - description: "Unique tool name in snake_case (e.g. 'scrape_paginated_results', 'extract_structured_data')", - }), - label: Type.String({ - description: "Human-readable label for the tool (e.g. 'Scrape Paginated Results')", - }), - description: Type.String({ - description: "What the tool does, including expected parameter names and their types. This is shown to the LLM when it considers which tool to call.", - }), - implementation: Type.String({ - description: - "JavaScript code (async function body). Bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, Buffer, setTimeout, clearTimeout. Must return { content: [{ type: 'text', text: '...' }], details?: {...} }.", - }), - }), - async execute(_id, p, _signal, _onUpdate) { - const { name, label, description, implementation } = p as { - name: string; - label: string; - description: string; - implementation: string; - }; - - // ── Validation ────────────────────────────────────────────────── - if (!/^[a-z][a-z0-9_]*$/.test(name)) { - return { - content: [{ - type: "text" as const, - text: `Invalid tool name '${name}'. Must be snake_case: lowercase letters, digits, and underscores, starting with a letter.`, - }], - details: { error: "invalid_name" }, - }; - } - - if (registry.has(name)) { - const existing = registry.get(name)!; - return { - content: [{ - type: "text" as const, - text: `Tool '${name}' already exists: ${existing.description}\n\nUse remove_tool("${name}") first if you want to replace it, then call register_tool again.`, - }], - details: { exists: true, existingLabel: existing.label }, - }; - } - - // ── Compile ───────────────────────────────────────────────────── - let executeFn: (...args: unknown[]) => Promise<unknown>; - try { - // The LLM writes a function body. We wrap it in an async function. - // Prepend "use strict" so accidental global leaks become errors. - const source = `"use strict";\n${implementation}`; - executeFn = new AsyncFunction( - "params", "daemon", "require", "signal", "onUpdate", "ctx", - "console", "fetch", "JSON", "Buffer", "setTimeout", "clearTimeout", - source, - ); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - return { - content: [{ - type: "text" as const, - text: `Syntax error in implementation: ${msg}\n\nFix the JavaScript syntax and try again.`, - }], - details: { error: "syntax_error", message: msg }, - }; - } - - // ── Smoke test ────────────────────────────────────────────────── - // Run the function once with empty params to catch obvious errors early. - try { - const testResult = await executeFn( - {}, - daemon, - require, - undefined, - (_update: unknown) => {}, - { cwd: process.cwd() }, - console, - fetch, - JSON, - Buffer, - setTimeout, - clearTimeout, - ); - if (!testResult || typeof testResult !== "object" || !Array.isArray((testResult as any).content)) { - return { - content: [{ - type: "text" as const, - text: `Implementation must return an object with a 'content' array. Got: ${JSON.stringify(testResult)}`, - }], - details: { error: "invalid_return" }, - }; - } - } catch (err: unknown) { - // Smoke test failure is ok — the params were empty, so runtime errors - // from missing params are expected. Only reject if the error looks - // like a code bug (e.g. ReferenceError for undefined variables). - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("is not defined") || msg.includes("Cannot find module")) { - return { - content: [{ - type: "text" as const, - text: `Implementation failed smoke test: ${msg}\n\nCheck for undefined variables, missing imports, or broken require() calls.`, - }], - details: { error: "smoke_test_failed", message: msg }, - }; - } - // Otherwise it's likely just missing params — proceed. - } - - // ── Register ──────────────────────────────────────────────────── - const def: DynamicToolDef = { - name, - label, - description, - execute: async (params, signal, onUpdate, ctx) => { - const result = await executeFn( - params, - daemon, - require, - signal, - onUpdate, - ctx, - console, - fetch, - JSON, - Buffer, - setTimeout, - clearTimeout, - ); - return result as { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> | undefined }; - }, - }; - - registry.set(name, def); - - // Register with pi — available immediately on the next sub-turn - pi.registerTool({ - name, - label, - description, - parameters: Type.Object({}, { additionalProperties: true }), - async execute(_toolCallId, execParams, execSignal, execOnUpdate, execCtx) { - const tool = registry.get(name); - if (!tool) { - throw new Error(`Tool '${name}' was removed. Use list_dynamic_tools to see available tools.`); - } - return tool.execute( - (execParams ?? {}) as Record<string, unknown>, - execSignal, - execOnUpdate as ((update: unknown) => void) | undefined, - execCtx, - ); - }, - }); - - return { - content: [{ - type: "text" as const, - text: `✅ Tool '${name}' registered and available immediately.\n\nLabel: ${label}\nDescription: ${description}\n\nYou can now call it as: ${name}(...)`, - }], - details: { name, label }, - }; - }, - }); - - // ═══════════════════════════════════════════════════════════════════════════ - // remove_tool - // ═══════════════════════════════════════════════════════════════════════════ - pi.registerTool({ - name: "remove_tool", - label: "Remove Dynamic Tool", - description: - "Remove a previously registered dynamic tool. The tool will not be available on subsequent turns. " + - "Use this to replace a broken tool or clean up tools you no longer need.", - promptSnippet: "Remove a dynamically registered tool", - promptGuidelines: [ - "Use remove_tool to retire a dynamic tool you no longer need or to replace it with a corrected version.", - "After removing a tool, you can immediately register a replacement with the same name via register_tool.", - ], - parameters: Type.Object({ - name: Type.String({ description: "Name of the tool to remove (as shown by list_dynamic_tools)" }), - }), - async execute(_id, p) { - const { name } = p as { name: string }; - const existed = registry.has(name); - - if (!existed) { - return { - content: [{ - type: "text" as const, - text: `Tool '${name}' is not in the dynamic registry. Use list_dynamic_tools to see registered tools.`, - }], - details: { removed: false }, - }; - } - - // Remove from registry - registry.delete(name); - - // Deactivate from pi's active tool set so it doesn't appear in the system prompt - const activeNames = pi.getActiveTools() - .map((t: unknown) => (t as { name: string }).name) - .filter((n: string) => n !== name); - pi.setActiveTools(activeNames); - - return { - content: [{ - type: "text" as const, - text: `🗑️ Tool '${name}' removed. It will no longer appear in the system prompt or be callable.`, - }], - details: { removed: true }, - }; - }, - }); -} diff --git a/src/index.ts b/src/index.ts index 31a7c28..7a3464c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,6 @@ import { registerRenderers } from "./renderers"; import { registerSetupCommand } from "./setup"; import { type BrowserState, defaultState, persistState, restoreState } from "./state"; import { cleanupTempDirs, registerTools } from "./tools"; -import { registerDynamicTools } from "./dynamic-tools"; export default function browserHarnessExtension(pi: ExtensionAPI) { // ── Resolve namespace ────────────────────────────────────────────────────── @@ -137,7 +136,6 @@ export default function browserHarnessExtension(pi: ExtensionAPI) { // Register tools (once) if (!toolsRegistered && daemon) { registerTools(pi, daemon); - registerDynamicTools(pi, daemon); toolsRegistered = true; } diff --git a/src/prompt.ts b/src/prompt.ts index 26c41b5..8e3b727 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -39,6 +39,29 @@ to interact with pages visually, and other tools alongside them. | browser_wait | Wait N seconds | | browser_wait_for_load | Wait for document.readyState === 'complete' | | browser_handle_dialog | Accept or dismiss a JS dialog | +| browser_run_script | Execute a temporary script file with daemon access (write script to disk, then run) | + +### Temporary Scripts + +When the built-in tools aren't enough for a multi-step workflow, write a temporary +script to disk and execute it with browser_run_script. The script runs in the +harness process with direct access to the browser daemon and Node.js APIs. + +\`\`\` +write("/tmp/scrape-pages.js", \` + const results = []; + for (const url of params.urls) { + await daemon.cdp("Page.navigate", { url }); + await new Promise(r => setTimeout(r, 2000)); + const data = await daemon.evaluateJS("document.title"); + results.push({ url, title: data }); + } + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; +\`) +browser_run_script("/tmp/scrape-pages.js", { urls: [...] }) +\`\`\` + +Script bindings: params, daemon, require, signal, onUpdate, ctx, console, fetch, JSON, Buffer, setTimeout, clearTimeout. ### Common Patterns @@ -74,5 +97,10 @@ browser_switch_tab(targetId: "...") → switch to a tab browser_screenshot() → visually inspect the page browser_execute_js("document.querySelector('.main').innerText") → extract content \`\`\` + +**Temporary Scripts (extending the harness):** +\`\`\` +write("/tmp/extract.js", "...script with daemon access...") → browser_run_script("/tmp/extract.js") +\`\`\` `; } diff --git a/src/tools.ts b/src/tools.ts index 6b3bb8d..822854c 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -15,12 +15,16 @@ import { import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "typebox"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { BrowserDaemon } from "./daemon"; import type { TabInfo } from "./protocol"; +// ── AsyncFunction constructor ──────────────────────────────────────────────── + +const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (...args: string[]) => (...args: unknown[]) => Promise<unknown>; + // ── Key code map ───────────────────────────────────────────────────────────── const SPECIAL_KEYS = [ @@ -1225,4 +1229,130 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { ); }, }); + + // ═══════════════════════════════════════════════════════════════════════════ + // browser_run_script + // ═══════════════════════════════════════════════════════════════════════════ + pi.registerTool({ + name: "browser_run_script", + label: "Browser Run Script", + description: + "Execute a temporary JavaScript script file with access to the browser daemon " + + "and Node.js APIs. Use this when the built-in browser_* tools are insufficient " + + "for a multi-step workflow — write a script to disk, then run it with this tool. " + + "The script receives these bindings in scope:\n" + + " params — the arguments passed to this tool\n" + + " daemon — the browser daemon (daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.)\n" + + " require — Node.js require() for builtins and installed packages\n" + + " signal — AbortSignal for cancellation\n" + + " onUpdate — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })\n" + + " ctx — ExtensionContext with cwd, sessionManager, ui, signal, etc.\n" + + " console, fetch, JSON, Buffer, setTimeout, clearTimeout\n\n" + + "The script MUST return { content: [{ type: 'text', text: '...' }], details?: {...} }.\n" + + "For errors, throw: throw new Error('something went wrong').", + promptSnippet: "Run a temporary script with browser daemon access (write script to disk first)", + promptGuidelines: [ + "Use write to create a temporary script file, then browser_run_script to execute it — no dynamic tool registration needed.", + "Scripts are written to disk, making them auditable and re-runnable. The user can inspect them.", + "The script has access to the browser daemon via `daemon` — use daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.", + "For pure data processing or file operations, use require() to access Node.js modules like fs, path, crypto.", + "If the script only needs built-in browser_* tools, break those into separate tool calls before or after — do not try to invoke them from within the script.", + ], + parameters: Type.Object({ + path: Type.String({ description: "Path to the temporary script file (.js or .mjs) to execute" }), + params: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Optional parameters to pass to the script as `params`" })), + }), + async execute(_id, p, signal, onUpdate, ctx) { + const { path: scriptPath, params: scriptParams } = p as { + path: string; + params?: Record<string, unknown>; + }; + + // ── Read the script from disk ──────────────────────────────────── + let source: string; + try { + source = await readFile(scriptPath, "utf8"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ + type: "text" as const, + text: `Failed to read script: ${msg}\n\nPath: ${scriptPath}\nCheck that the file exists and is readable.`, + }], + details: { error: "read_failed", message: msg }, + }; + } + + if (source.trim().length === 0) { + return { + isError: true, + content: [{ type: "text" as const, text: `Script is empty: ${scriptPath}` }], + details: { error: "empty_script" }, + }; + } + + // ── Compile ───────────────────────────────────────────────────── + let executeFn: (...args: unknown[]) => Promise<unknown>; + try { + const wrapped = `"use strict";\n${source}`; + executeFn = new AsyncFunction( + "params", "daemon", "require", "signal", "onUpdate", "ctx", + "console", "fetch", "JSON", "Buffer", "setTimeout", "clearTimeout", + wrapped, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ + type: "text" as const, + text: `Syntax error in script: ${msg}\n\nFile: ${scriptPath}\nFix the JavaScript syntax and try again.`, + }], + details: { error: "syntax_error", message: msg }, + }; + } + + // ── Execute ───────────────────────────────────────────────────── + try { + const result = await executeFn( + scriptParams ?? {}, + daemon, + require, + signal, + onUpdate ?? ((_update: unknown) => {}), + ctx ?? { cwd: process.cwd() }, + console, + fetch, + JSON, + Buffer, + setTimeout, + clearTimeout, + ); + + if (!result || typeof result !== "object" || !Array.isArray((result as any).content)) { + return { + isError: true, + content: [{ + type: "text" as const, + text: `Script must return { content: [{ type: 'text', text: '...' }], details?: {...} }.\nGot: ${JSON.stringify(result)}\n\nFile: ${scriptPath}`, + }], + details: { error: "invalid_return" }, + }; + } + + return result as { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> | undefined }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [{ + type: "text" as const, + text: `Script execution failed: ${msg}\n\nFile: ${scriptPath}\nCheck for runtime errors, undefined variables, or broken require() calls.`, + }], + details: { error: "execution_failed", message: msg }, + }; + } + }, + }); } |
