e4a16451ec
- Added `ToolTier`, `ToolApproval`, and `ToolApprovalDecision` types and exported approval APIs. - Updated approval-mode options from `auto|prompt|custom` to `always-ask|write|yolo` and defaulted mode to `yolo`. - Changed approval resolution to apply per-tool decisions first, then mode-tier limits, with legacy-mode migration. - Assigned read/write/exec `approval` and approval-detail prompts across built-in, custom, extension, and MCP tools.
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
import * as z from "zod/v4";
|
|
import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
|
|
import type { ToolSession } from "../sdk";
|
|
import { truncateForPrompt } from "./approval";
|
|
import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
|
|
import type { Observation, ScreenshotResult } from "./browser/tab-protocol";
|
|
import { acquireTab, dropHeadlessTabs, getTab, releaseAllTabs, releaseTab, runInTab } from "./browser/tab-supervisor";
|
|
import type { OutputMeta } from "./output-meta";
|
|
import { resolveToCwd } from "./path-utils";
|
|
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
|
import { toolResult } from "./tool-result";
|
|
import { clampTimeout } from "./tool-timeouts";
|
|
|
|
export { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./browser/readable";
|
|
export type { Observation, ObservationEntry } from "./browser/tab-protocol";
|
|
|
|
const DEFAULT_TAB_NAME = "main";
|
|
|
|
const appSchema = z.object({
|
|
path: z.string().describe("binary path to spawn").optional(),
|
|
cdp_url: z.string().describe("existing cdp endpoint").optional(),
|
|
args: z.array(z.string()).describe("extra cli args").optional(),
|
|
target: z.string().describe("substring to pick a window").optional(),
|
|
});
|
|
|
|
const browserSchema = z.object({
|
|
action: z.enum(["open", "close", "run"] as const).describe("operation"),
|
|
name: z.string().describe("tab id (default 'main')").optional(),
|
|
url: z.string().describe("url to open").optional(),
|
|
app: appSchema.optional(),
|
|
viewport: z
|
|
.object({
|
|
width: z.number(),
|
|
height: z.number(),
|
|
scale: z.number().optional(),
|
|
})
|
|
.optional(),
|
|
wait_until: z
|
|
.enum(["load", "domcontentloaded", "networkidle0", "networkidle2"] as const)
|
|
.describe("navigation wait condition")
|
|
.optional(),
|
|
dialogs: z
|
|
.enum(["accept", "dismiss"] as const)
|
|
.describe("auto-handle dialogs")
|
|
.optional(),
|
|
code: z.string().describe("js body to run in tab").optional(),
|
|
timeout: z.number().default(30).describe("timeout in seconds (default 30, max 300)").optional(),
|
|
all: z.boolean().describe("close every tab").optional(),
|
|
kill: z.boolean().describe("also kill spawned-app browsers").optional(),
|
|
});
|
|
|
|
/** Input schema for the browser tool. */
|
|
export type BrowserParams = z.infer<typeof browserSchema>;
|
|
|
|
/** Details describing a browser tool execution result (for renderers + transcript). */
|
|
export interface BrowserToolDetails {
|
|
action: BrowserParams["action"];
|
|
name?: string;
|
|
url?: string;
|
|
browser?: BrowserKindTag;
|
|
viewport?: { width: number; height: number; deviceScaleFactor?: number };
|
|
observation?: Observation;
|
|
screenshots?: ScreenshotResult[];
|
|
result?: string;
|
|
meta?: OutputMeta;
|
|
}
|
|
|
|
function resolveBrowserKind(params: BrowserParams, session: ToolSession): BrowserKind {
|
|
const app = params.app;
|
|
if (app?.cdp_url) {
|
|
return { kind: "connected", cdpUrl: app.cdp_url.replace(/\/+$/, "") };
|
|
}
|
|
if (app?.path) {
|
|
const exe = resolveToCwd(app.path, session.cwd);
|
|
return { kind: "spawned", path: exe };
|
|
}
|
|
const headless = session.settings.get("browser.headless") as boolean;
|
|
return { kind: "headless", headless };
|
|
}
|
|
|
|
/**
|
|
* Browser tool: stateful, multi-tab. Three actions:
|
|
* - `open` → acquire/create a named tab on a browser kind (headless | spawned | connected) and optionally goto a url.
|
|
* - `close` → release a named tab (or all tabs); dispose browser when refcount hits 0.
|
|
* - `run` → execute JS code against an existing tab with `page`/`browser`/`tab` helpers in scope.
|
|
*/
|
|
export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolDetails> {
|
|
readonly name = "browser";
|
|
readonly approval = "exec" as const;
|
|
readonly formatApprovalDetails = (args: unknown): string[] => {
|
|
const params = args as Partial<BrowserParams>;
|
|
const lines = [`Action: ${typeof params.action === "string" ? params.action : "(missing)"}`];
|
|
const tabName = typeof params.name === "string" ? params.name : DEFAULT_TAB_NAME;
|
|
lines.push(`Tab: ${truncateForPrompt(tabName)}`);
|
|
if (typeof params.url === "string" && params.url.length > 0) {
|
|
lines.push(`URL: ${truncateForPrompt(params.url)}`);
|
|
}
|
|
if (typeof params.code === "string" && params.code.length > 0) {
|
|
lines.push(`Code:\n${truncateForPrompt(params.code)}`);
|
|
}
|
|
return lines;
|
|
};
|
|
readonly label = "Browser";
|
|
readonly loadMode = "discoverable";
|
|
readonly summary = "Control a headless browser to navigate and interact with web pages";
|
|
readonly parameters = browserSchema;
|
|
readonly strict = true;
|
|
|
|
constructor(private readonly session: ToolSession) {}
|
|
#description?: string;
|
|
get description(): string {
|
|
this.#description ??= prompt.render(browserDescription, {});
|
|
return this.#description;
|
|
}
|
|
|
|
/** Restart browser to apply mode changes (e.g. headless toggle). Drops only headless browsers. */
|
|
async restartForModeChange(): Promise<void> {
|
|
await dropHeadlessTabs();
|
|
}
|
|
|
|
async execute(
|
|
_toolCallId: string,
|
|
params: BrowserParams,
|
|
signal?: AbortSignal,
|
|
_onUpdate?: AgentToolUpdateCallback<BrowserToolDetails>,
|
|
_ctx?: AgentToolContext,
|
|
): Promise<AgentToolResult<BrowserToolDetails>> {
|
|
try {
|
|
throwIfAborted(signal);
|
|
const timeoutSeconds = clampTimeout("browser", params.timeout);
|
|
const timeoutMs = timeoutSeconds * 1000;
|
|
const name = params.name ?? DEFAULT_TAB_NAME;
|
|
const details: BrowserToolDetails = { action: params.action, name };
|
|
|
|
switch (params.action) {
|
|
case "open":
|
|
return await this.#open(name, params, details, timeoutMs, signal);
|
|
case "close":
|
|
return await this.#close(name, params, details, signal);
|
|
case "run":
|
|
return await this.#run(name, params, details, timeoutMs, signal);
|
|
default:
|
|
throw new ToolError(`Unsupported action: ${(params as BrowserParams).action}`);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ToolAbortError) throw error;
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
throw new ToolAbortError();
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async #open(
|
|
name: string,
|
|
params: BrowserParams,
|
|
details: BrowserToolDetails,
|
|
timeoutMs: number,
|
|
signal?: AbortSignal,
|
|
): Promise<AgentToolResult<BrowserToolDetails>> {
|
|
const kind = resolveBrowserKind(params, this.session);
|
|
details.browser = kind.kind;
|
|
|
|
// If a tab with this name already exists on a different browser kind, fail fast — caller must close first.
|
|
const existing = getTab(name);
|
|
if (existing && !sameBrowserKind(existing.browser.kind, kind)) {
|
|
throw new ToolError(
|
|
`Tab ${JSON.stringify(name)} is bound to a different browser (${describeKind(existing.browser.kind)}). Close it first.`,
|
|
);
|
|
}
|
|
|
|
const browser = await untilAborted(signal, () =>
|
|
acquireBrowser(kind, {
|
|
cwd: this.session.cwd,
|
|
viewport: params.viewport
|
|
? {
|
|
width: params.viewport.width,
|
|
height: params.viewport.height,
|
|
deviceScaleFactor: params.viewport.scale,
|
|
}
|
|
: undefined,
|
|
appArgs: params.app?.args,
|
|
signal,
|
|
}),
|
|
);
|
|
|
|
const result = await untilAborted(signal, () =>
|
|
acquireTab(name, browser, {
|
|
url: params.url,
|
|
waitUntil: params.wait_until,
|
|
viewport: params.viewport
|
|
? {
|
|
width: params.viewport.width,
|
|
height: params.viewport.height,
|
|
deviceScaleFactor: params.viewport.scale,
|
|
}
|
|
: undefined,
|
|
target: params.app?.target,
|
|
timeoutMs,
|
|
dialogs: params.dialogs,
|
|
signal,
|
|
}),
|
|
);
|
|
const tab = result.tab;
|
|
const url = tab.info.url;
|
|
const title = tab.info.title ?? "";
|
|
details.url = url;
|
|
details.viewport = tab.info.viewport;
|
|
const verb = result.created ? "Opened" : "Reused";
|
|
const lines = [
|
|
`${verb} tab ${JSON.stringify(name)} on ${describeBrowser(browser)}`,
|
|
`URL: ${url}`,
|
|
title ? `Title: ${title}` : null,
|
|
].filter((l): l is string => typeof l === "string");
|
|
details.result = lines.join("\n");
|
|
return toolResult(details).text(lines.join("\n")).done();
|
|
}
|
|
|
|
async #close(
|
|
name: string,
|
|
params: BrowserParams,
|
|
details: BrowserToolDetails,
|
|
signal?: AbortSignal,
|
|
): Promise<AgentToolResult<BrowserToolDetails>> {
|
|
const kill = !!params.kill;
|
|
if (params.all) {
|
|
const count = await untilAborted(signal, () => releaseAllTabs({ kill }));
|
|
details.result = `Closed ${count} tab(s)`;
|
|
return toolResult(details).text(details.result).done();
|
|
}
|
|
const closed = await untilAborted(signal, () => releaseTab(name, { kill }));
|
|
details.result = closed ? `Closed tab ${JSON.stringify(name)}` : `No tab named ${JSON.stringify(name)}`;
|
|
return toolResult(details).text(details.result).done();
|
|
}
|
|
|
|
async #run(
|
|
name: string,
|
|
params: BrowserParams,
|
|
details: BrowserToolDetails,
|
|
timeoutMs: number,
|
|
signal?: AbortSignal,
|
|
): Promise<AgentToolResult<BrowserToolDetails>> {
|
|
if (!params.code?.trim()) {
|
|
throw new ToolError("Missing required parameter 'code' for action 'run'.");
|
|
}
|
|
const tab = getTab(name);
|
|
if (tab) {
|
|
details.browser = tab.browser.kind.kind;
|
|
details.url = tab.info.url;
|
|
}
|
|
|
|
const { displays, returnValue, screenshots } = await runInTab(name, {
|
|
code: params.code,
|
|
timeoutMs,
|
|
signal,
|
|
session: this.session,
|
|
});
|
|
|
|
if (screenshots.length) details.screenshots = screenshots;
|
|
|
|
const content = [...displays];
|
|
if (returnValue !== undefined) {
|
|
content.push({ type: "text", text: stringifyReturnValue(returnValue) });
|
|
}
|
|
if (!content.length) {
|
|
content.push({ type: "text", text: `Ran code on tab ${JSON.stringify(name)}` });
|
|
}
|
|
const textOnly = content
|
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
.map(c => c.text)
|
|
.join("\n");
|
|
details.result = textOnly;
|
|
return toolResult(details).content(content).done();
|
|
}
|
|
}
|
|
|
|
function describeBrowser(handle: BrowserHandle): string {
|
|
switch (handle.kind.kind) {
|
|
case "headless":
|
|
return `headless browser (${handle.kind.headless ? "hidden" : "visible"})`;
|
|
case "spawned":
|
|
return `spawned ${handle.kind.path} (pid ${handle.pid ?? "?"})`;
|
|
case "connected":
|
|
return `connected ${handle.cdpUrl ?? handle.kind.cdpUrl}`;
|
|
}
|
|
}
|
|
|
|
function describeKind(kind: BrowserKind): string {
|
|
switch (kind.kind) {
|
|
case "headless":
|
|
return `headless ${kind.headless ? "hidden" : "visible"}`;
|
|
case "spawned":
|
|
return `spawned:${kind.path}`;
|
|
case "connected":
|
|
return `connected:${kind.cdpUrl}`;
|
|
}
|
|
}
|
|
|
|
function sameBrowserKind(a: BrowserKind, b: BrowserKind): boolean {
|
|
if (a.kind !== b.kind) return false;
|
|
if (a.kind === "headless" && b.kind === "headless") return a.headless === b.headless;
|
|
if (a.kind === "spawned" && b.kind === "spawned") return a.path === b.path;
|
|
if (a.kind === "connected" && b.kind === "connected") return a.cdpUrl === b.cdpUrl;
|
|
return false;
|
|
}
|
|
|
|
function stringifyReturnValue(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
try {
|
|
return JSON.stringify(value, null, 2) ?? String(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|