Ctrl K
ComunidadGuías…

server/browser-tool-shape.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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<string> = 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<string, unknown>; required?: unknown } & Record<string, unknown> } & Record<string, unknown>;

function isRecord(value: unknown): value is Record<string, unknown> {
  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<Tool["inputSchema"]>;
    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<string, number>();
  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<string, unknown>;
  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)]));
}