// agent-browser 0.37.0's core profile advertises 29 tools at ~12.5k tokens, mostly fifteen launch/session parameters // repeated on every tool, and every result arrives twice (text plus a 3-5x larger structuredContent that Codex keeps in history). import { BrowserHostError, loaderMissingLibrary } from "./browser/chrome/host-setup.ts"; import { trimResultText } from "./mcp-trim.ts"; /** A model must never set these: `session` reaches another bot's browser, `extraArgs`/`caCert`/`headed`/`webgpu`/`webmcp` * change the launch (`headed` overrides AGENT_BROWSER_HEADED), `all` on close closes every bot's browser, and a * screenshot's `path`/`screenshotDir` writes anywhere the server can. */ export const HARNESS_OWNED_BROWSER_PARAMS: ReadonlySet = new Set([ "all", "allowedDomains", "caCert", "clearCaCert", "extraArgs", "headed", "idleTimeout", "namespace", "path", "restore", "restoreCheckFn", "restoreCheckText", "restoreCheckUrl", "restoreSave", "screenshotDir", "session", "timeoutMs", "webgpu", "webmcp", ]); /** ~8k tokens: a heavy encyclopedia article's interactive snapshot (21k chars) fits; a whole product page as markdown (79k) does not. */ export const DEFAULT_BROWSER_RESULT_BUDGET = 32_000; const BROWSER_NARROWING_HINT = " For a snapshot, pass selector or depth, or set compact; for one value such as a price, use agent_browser_get_text with a selector instead of reading the whole page."; type Tool = { inputSchema?: { properties?: Record; required?: unknown } & Record } & Record; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } export function verifiedBrowserNavigation(args: unknown, observation: unknown): boolean { if (!isRecord(observation) || observation.isError === true || !Array.isArray(observation.content)) return false; const structured = observation.structuredContent; const response = isRecord(structured) ? structured.response : undefined; const data = isRecord(response) ? response.data : undefined; const url = isRecord(args) ? args.url : undefined; if (typeof url === "string" && /^https?:\/\//i.test(url.trim()) && isRecord(data) && data.origin === "about:blank") return false; return observation.content.some((item) => isRecord(item) && item.type === "text" && typeof item.text === "string" && item.text.trim().length > 0); } /** agent-browser 0.37.0 fetches a `url` given to agent_browser_read with its own HTTP client, around the browser and its * egress gate, and ignores every proxy setting; without one it reads the open tab. */ const READ_TOOL = "agent_browser_read"; export const READ_OPEN_PAGE_ONLY = "agent_browser_read reads the page open in this browser. Open the address with agent_browser_open first, then call agent_browser_read without url."; export function readsAroundBrowser(params: unknown): boolean { return isRecord(params) && params.name === READ_TOOL && isRecord(params.arguments) && params.arguments.url !== undefined; } export function slimBrowserToolList(result: unknown): unknown { if (!isRecord(result) || !Array.isArray(result.tools)) return result; const tools = (result.tools as unknown[]).map((tool) => { if (!isRecord(tool) || !isRecord(tool.inputSchema)) return tool; const schema = tool.inputSchema as NonNullable; const hidden = (name: string) => HARNESS_OWNED_BROWSER_PARAMS.has(name) || (tool.name === READ_TOOL && name === "url"); const properties = isRecord(schema.properties) ? Object.fromEntries(Object.entries(schema.properties).filter(([name]) => !hidden(name))) : schema.properties; const required = Array.isArray(schema.required) ? schema.required.filter((name) => typeof name !== "string" || !hidden(name)) : schema.required; return { ...tool, inputSchema: { ...schema, ...(properties === undefined ? {} : { properties }), ...(required === undefined ? {} : { required }) } }; }); return { ...result, tools }; } export function stripHarnessOwnedArguments(params: unknown): unknown { if (!isRecord(params) || !isRecord(params.arguments)) return params; const kept = Object.entries(params.arguments).filter(([name]) => !HARNESS_OWNED_BROWSER_PARAMS.has(name)); return kept.length === Object.keys(params.arguments).length ? params : { ...params, arguments: Object.fromEntries(kept) }; } /** agent-browser's `lifecycle` block is ~330 of a click's 420 characters; only a relaunch (pages and refs gone) is kept. */ export function withoutLifecycle(text: string): string { let parsed: unknown; try { parsed = JSON.parse(text); } catch { return text; } if (!isRecord(parsed) || !isRecord(parsed.data) || !isRecord(parsed.data.lifecycle)) return text; const { lifecycle, ...data } = parsed.data; if (lifecycle.relaunchedBrowser === true) data.browserRelaunched = true; return JSON.stringify({ ...parsed, data }); } export interface PageState { url: string; title: string; text: string } /** agent-browser renumbers every @eN ref on each snapshot, so the page is read with eval and a model's refs stay valid. */ export const OBSERVE_PAGE_SCRIPT = "JSON.stringify({ url: location.href, title: document.title, text: (document.body ? document.body.innerText : '').slice(0, 200000) })"; export function parsePageState(result: unknown): PageState | null { if (!isRecord(result) || result.isError === true || !Array.isArray(result.content)) return null; const text = (result.content as unknown[]).find((part) => isRecord(part) && part.type === "text" && typeof part.text === "string") as { text: string } | undefined; try { let value: unknown = JSON.parse(text?.text ?? ""); if (typeof value === "string") value = JSON.parse(value); if (isRecord(value) && typeof value.url === "string" && typeof value.title === "string" && typeof value.text === "string") { return { url: value.url, title: value.title, text: value.text }; } } catch {} return null; } const CHANGE_LINES = 20; const CHANGE_CHARS = 1_500; function visibleLines(text: string): string[] { return text.split("\n").map((line) => line.trim()).filter(Boolean); } function linesOnlyIn(next: string[], previous: string[]): string[] { const left = new Map(); for (const line of previous) left.set(line, (left.get(line) ?? 0) + 1); return next.filter((line) => { const count = left.get(line) ?? 0; if (count) left.set(line, count - 1); return !count; }); } function listLines(lines: string[]): string { const shown: string[] = []; let chars = 0; for (const line of lines) { if (shown.length === CHANGE_LINES || chars + line.length > CHANGE_CHARS) break; shown.push(JSON.stringify(line)); chars += line.length; } return shown.join(", ") + (shown.length < lines.length ? ` and ${lines.length - shown.length} more lines` : ""); } const SENSITIVE_PARAM = /token|code|key|secret|pass|pwd|auth|session|sig|otp|state|nonce|credential|ticket|email/iu; /** A hash route names a page; any other fragment may carry a token, as OAuth's `#access_token=…` does. */ const HASH_ROUTE = /^#!?\/[\w\-./~%]*$/u; /** Origin, path and query with sensitive values (and any email) replaced by `…`; no credentials, no fragment beyond a hash route. */ export function sanitizeUrl(href: string): string { let url: URL; try { url = new URL(href); } catch { try { url = new URL(`https://${href}`); } catch { return ""; } if (!url.hostname.includes(".")) return ""; } if (url.protocol !== "http:" && url.protocol !== "https:") return url.protocol === "about:" ? "about:blank" : `${url.protocol}…`; const query = [...url.searchParams].map(([key, value]) => `${encodeURIComponent(key)}=${SENSITIVE_PARAM.test(key) || value.includes("@") ? "…" : encodeURIComponent(value)}`).join("&"); const text = `${url.origin}${url.pathname}${query ? `?${query}` : ""}${HASH_ROUTE.test(url.hash) ? url.hash : ""}`; return text.length > 500 ? `${text.slice(0, 499)}…` : text; } export function describePageChange(before: PageState, after: PageState): string { if (after.url !== before.url) { return `The page is now ${sanitizeUrl(after.url) || "a new address"}${after.title ? ` (${JSON.stringify(after.title)})` : ""}. Refs from earlier snapshots do not apply to it; take a snapshot before acting on refs.`; } const previous = visibleLines(before.text); const next = visibleLines(after.text); const added = linesOnlyIn(next, previous); const removed = linesOnlyIn(previous, next); const title = after.title !== before.title ? ` The title is now ${JSON.stringify(after.title)}.` : ""; if (!added.length && !removed.length) return `The visible text of the page did not change.${title}`; return [ added.length ? `Text that appeared on the page: ${listLines(added)}.` : "", removed.length ? `Text that disappeared: ${listLines(removed)}.` : "", ].filter(Boolean).join(" ") + title; } /** agent-browser's own error when the Chrome it launched dies before DevTools is up. */ const LAUNCH_FAILURE = "Chrome exited early (exit code:"; /** agent-browser prints that launch's stderr after this line ("Chrome stderr:" or "Chrome stderr (last N lines):"). */ const CHROME_STDERR = /^Chrome stderr(?: \(last \d+ lines\))?:$/m; const MAX_LIBRARY_NAME = 120; /** The library a failed Chrome launch's own stderr names, not one a page's text merely quotes. */ function launchMissingLibrary(text: string): string | undefined { if (!text.trimStart().startsWith(LAUNCH_FAILURE)) return undefined; const stderr = CHROME_STDERR.exec(text); const library = stderr ? loaderMissingLibrary(text.slice(stderr.index)) : undefined; return library && library.length > MAX_LIBRARY_NAME ? `${library.slice(0, MAX_LIBRARY_NAME)}…` : library; } export function shapeBrowserToolResult(result: unknown, options: { toolName?: string; budget?: number } = {}): unknown { if (!isRecord(result) || !Array.isArray(result.content)) return result; const budget = options.budget ?? DEFAULT_BROWSER_RESULT_BUDGET; let textParts = 0; let library: string | undefined; const content = (result.content as unknown[]).map((part) => { if (!isRecord(part) || part.type !== "text" || typeof part.text !== "string") return part; textParts++; if (result.isError === true && process.platform === "linux") library ??= launchMissingLibrary(part.text); const outcome = trimResultText({ text: withoutLifecycle(part.text), budget, toolName: options.toolName }); return { ...part, text: outcome.trimmed ? outcome.text + BROWSER_NARROWING_HINT : outcome.text }; }); if (!textParts) return { ...result, content }; // agent-browser launches Chrome itself, so a host missing system libraries // only shows the loader's words; say how to install them, as our own launch does. // The advice leads the first text part: some MCP clients show only that one, // and a long stderr can be cut off at the end. if (library) { const first = content.find((part): part is { text: string } => isRecord(part) && part.type === "text" && typeof part.text === "string"); if (first) first.text = `${new BrowserHostError("libraries", [library]).message}\n\n${first.text}`; } return { ...Object.fromEntries(Object.entries(result).filter(([key]) => key !== "structuredContent")), content }; } /** Images and binary data pass through: a screenshot cannot be scrubbed. */ export function scrubBrowserResult(result: unknown, scrub: (text: string) => string, depth = 0): unknown { if (typeof result === "string") return scrub(result); if (depth > 32 || result === null || typeof result !== "object") return result; if (Array.isArray(result)) return result.map((item) => scrubBrowserResult(item, scrub, depth + 1)); const record = result as Record; const binary = record.type === "image" || record.type === "audio"; return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, (binary && key === "data") || key === "blob" ? value : scrubBrowserResult(value, scrub, depth + 1)])); }