UNPKG

agents

Version:

A home for your AI agents

348 lines (347 loc) 15.2 kB
import { t as __DO_NOT_USE_WILL_BREAK__agentContext } from "../current-agent-DhoDkSnH.js"; import "../internal_context.js"; import { c as browserLinks, d as browserScrape, i as DurableBrowserSessionStore, l as browserMarkdown, o as browserContent, s as browserExtract, t as BrowserConnector } from "../connector-CptFKzRh.js"; import { tool } from "ai"; import { z } from "zod"; import { DynamicWorkerExecutor, createCodemodeRuntime, truncateResult } from "@cloudflare/codemode"; //#region src/browser/ai.ts let didWarnExperimental = false; let didDebugQuickActionSkip = false; function browserScreenshotOutput(value) { const outer = typeof value === "object" && value !== null ? value : null; const result = outer && typeof outer.result === "object" && outer.result !== null ? outer.result : outer; if (result?.type !== "browser_screenshot" || typeof result.mediaType !== "string" || typeof result.data !== "string") return null; return result; } const BASE64_REDACTION_THRESHOLD = 4096; const MAX_REDACTION_DEPTH = 20; const MAX_REDACTION_NODES = 1e4; function base64Details(value, minimumLength = BASE64_REDACTION_THRESHOLD) { if (value.length < minimumLength) return null; const dataUrl = /^data:([^;,]+)(?:;[^;,]*)*;base64,([A-Za-z0-9+/]*={0,2})$/i.exec(value); const encoded = dataUrl?.[2] ?? value; if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) return null; const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; return { mediaType: dataUrl?.[1], chars: encoded.length, bytes: encoded.length / 4 * 3 - padding }; } function base64Redaction(value, mediaType, minimumLength) { const details = base64Details(value, minimumLength); if (!details) return value; const type = mediaType ?? details.mediaType; return `[base64${type ? ` ${type}` : ""} data omitted: ${details.chars.toLocaleString()} chars, approximately ${details.bytes.toLocaleString()} bytes]`; } function redactBase64Payloads(value) { const ancestors = /* @__PURE__ */ new WeakSet(); let nodes = 0; function visit(current, depth) { if (depth > MAX_REDACTION_DEPTH) return "[nested value omitted]"; if (++nodes > MAX_REDACTION_NODES) return "[remaining values omitted]"; if (typeof current === "string") return base64Redaction(current); if (typeof current !== "object" || current === null) return current; if (current instanceof ArrayBuffer || ArrayBuffer.isView(current) || current instanceof Date || current instanceof Map || current instanceof Set) return current; if (ancestors.has(current)) return "[circular reference omitted]"; ancestors.add(current); try { if (Array.isArray(current)) return current.map((entry) => visit(entry, depth + 1)); const record = current; const screenshot = record.type === "browser_screenshot" && typeof record.mediaType === "string"; return Object.fromEntries(Object.entries(record).map(([key, entry]) => [key, screenshot && key === "data" && typeof entry === "string" ? base64Redaction(entry, record.mediaType, 0) : visit(entry, depth + 1)])); } finally { ancestors.delete(current); } } return visit(value, 0); } function transformBrowserResult(value) { return browserScreenshotOutput(value) ? value : truncateResult(redactBase64Payloads(value)); } function browserExecuteModelOutput(output) { const screenshot = browserScreenshotOutput(output); if (screenshot) { const approximateBytes = Math.floor(screenshot.data.length * 3 / 4); return { type: "text", value: `Screenshot captured successfully (${screenshot.mediaType}, approximately ${approximateBytes.toLocaleString()} bytes); the image is kept for the UI and omitted here.` }; } const redacted = redactBase64Payloads(typeof output === "object" && output !== null && !Array.isArray(output) ? (({ calls: _calls, ...rest }) => ({ ...rest, ...Array.isArray(rest.logs) ? { logs: truncateResult(rest.logs) } : {} }))(output) : output); try { const serialized = JSON.stringify(redacted); return { type: "json", value: serialized === void 0 ? null : JSON.parse(serialized) }; } catch { return { type: "text", value: "Browser execution completed, but its result could not be serialized for model context." }; } } /** * The Durable Object state to build the runtime in: the explicit `ctx` if * given, otherwise the current Agent's `ctx` (via `getCurrentAgent()`), so * `createBrowserRuntime` can be called from an Agent method without threading * `this.ctx` through. */ function resolveCtx(options) { if (options.ctx) return options.ctx; return (__DO_NOT_USE_WILL_BREAK__agentContext.getStore()?.agent)?.ctx; } function connectorOptions(options, ctx) { if (options.cdpUrl) return { cdpUrl: options.cdpUrl, cdpHeaders: options.cdpHeaders, timeout: options.timeout }; if (!options.browser) throw new Error("Either 'browser' (Fetcher binding) or 'cdpUrl' must be provided"); return { browser: options.browser, store: options.store ?? new DurableBrowserSessionStore(ctx.storage), session: options.session, timeout: options.timeout }; } /** * Create the browser codemode runtime: the `browser_execute` tool plus the * runtime handle and connector for host-side wiring (approvals, session info, * sweeps). * * @example * ```ts * export class MyAgent extends Agent<Env> { * get browser() { * return createBrowserRuntime({ * ctx: this.ctx, * browser: this.env.BROWSER, * loader: this.env.LOADER, * session: { mode: "dynamic" } * }); * } * * @callable() * async closeBrowserSession() { * await this.browser.connector.closeSession(); * } * } * ``` */ function createBrowserRuntime(options) { if (!didWarnExperimental) { didWarnExperimental = true; console.warn("[agents/browser] Browser tools are experimental and may change in a future release."); } const ctx = resolveCtx(options); if (!ctx) throw new Error("createBrowserRuntime requires a Durable Object 'ctx' — pass it explicitly, or call from within an Agent so it can be resolved via getCurrentAgent()"); const connector = new BrowserConnector(ctx, connectorOptions(options, ctx)); const runtime = createCodemodeRuntime({ ctx, executor: new DynamicWorkerExecutor({ loader: options.loader, timeout: options.timeout }), connectors: [connector], name: options.name ?? "browser", transformResult: transformBrowserResult }); const isKitesurf = !options.cdpUrl && options.session?.browser === "kitesurf"; const tools = { browser_execute: { ...runtime.tool({ connectorHints: { cdp: isKitesurf ? "Kitesurf one-shot Browser CDP. Call codemode.describe(\"cdp\") for connector types and Kitesurf execution rules. codemode.search indexes connector methods and snippets, not underlying CDP commands. Complete the task in one execution and do not pause. Return screenshots as { type: \"browser_screenshot\", mediaType: \"image/png\", data: screenshot.data }." : "Browser CDP. Return screenshots as { type: 'browser_screenshot', mediaType: 'image/png', data: screenshot.data }; the UI keeps the image while the model receives a compact summary." } }), toModelOutput: ({ output }) => browserExecuteModelOutput(output) } }; if (options.quickActions !== false && (!isKitesurf || options.quickActions !== void 0)) { const qa = typeof options.quickActions === "object" ? options.quickActions : {}; const quickActionBrowser = qa.browser ?? options.browser; if (quickActionBrowser) Object.assign(tools, createQuickActionTools({ browser: quickActionBrowser, actions: qa.actions, maxChars: qa.maxChars, options: qa.options })); else if (options.quickActions) throw new Error("quickActions requires a Browser Run binding — set 'browser' (env.BROWSER) or 'quickActions.browser'"); else if (!didDebugQuickActionSkip) { didDebugQuickActionSkip = true; console.debug("[agents/browser] Quick Action tools skipped — no Browser Run binding (only 'cdpUrl' is set). Pass 'browser' or 'quickActions.browser' to enable them."); } } return { runtime, connector, tools }; } /** * Create AI SDK tools for browser automation via CDP code mode. * * Returns a `ToolSet` with a single durable `browser_execute` tool backed by * a codemode runtime: the model writes TypeScript against the `cdp` connector * (`cdp.send`, `cdp.attachToTarget`, `cdp.spec`, …), executions are recorded * for abort-and-replay, and browser sessions survive pauses. * * @example * ```ts * import { createBrowserTools } from "agents/browser/ai"; * import { generateText } from "ai"; * * // inside a Durable Object / Agent: * const browserTools = createBrowserTools({ * ctx: this.ctx, * browser: this.env.BROWSER, * loader: this.env.LOADER, * }); * * const result = await generateText({ * model, * tools: { ...browserTools, ...otherTools }, * messages, * }); * ``` */ function createBrowserTools(options) { return createBrowserRuntime(options).tools; } const DEFAULT_QUICK_ACTION_TOOLS = [ "markdown", "extract", "links", "scrape" ]; const DEFAULT_QUICK_ACTION_MAX_CHARS = 5e4; const pageInputSchema = z.object({ url: z.string().url().optional().describe("URL of the page to load"), html: z.string().optional().describe("Raw HTML to render instead of loading a URL") }).refine((value) => Boolean(value.url) || Boolean(value.html), { message: "Provide either 'url' or 'html'" }); function toPage(input, options) { const page = input.url ? { url: input.url } : { html: input.html }; return { ...options, ...page }; } function truncate(text, maxChars) { if (maxChars <= 0 || text.length <= maxChars) return text; return `${text.slice(0, maxChars)}\n\n[truncated ${text.length - maxChars} characters]`; } /** * Keep a tool result within a rough character budget so a single browse cannot * blow the model's context window — while preserving the result's shape so the * model sees a consistent type across calls: * * - strings (markdown/content) are truncated to a string; * - arrays (links/scrape) are trimmed from the end but stay arrays; * - only an opaque oversized object (e.g. a sprawling `extract`) degrades to a * `{ truncated, note, preview }` summary, since it cannot be trimmed safely. */ function boundResult(value, maxChars) { if (maxChars <= 0) return value; if (typeof value === "string") return truncate(value, maxChars); let json; try { json = JSON.stringify(value); } catch { return value; } if (json.length <= maxChars) return value; if (Array.isArray(value)) { const trimmed = boundArray(value, maxChars); if (trimmed.length > 0) return trimmed; } return { truncated: true, note: `Result is too large (${json.length} characters); narrow the request.`, preview: `${json.slice(0, maxChars)}…` }; } /** * Take as many leading items as fit within `maxChars` (measured against their * JSON length), returning a trimmed array of the same element type. Silent by * design: the model gets fewer, valid items rather than a reshaped result. */ function boundArray(value, maxChars) { const out = []; let size = 2; for (const item of value) { const itemSize = JSON.stringify(item).length + 1; if (size + itemSize > maxChars) break; out.push(item); size += itemSize; } return out; } /** * Create AI SDK tools for Browser Run [Quick Actions](https://developers.cloudflare.com/browser-run/quick-actions/): * stateless one-shot browsing (read a page as Markdown, extract structured * data with AI, list links, scrape elements). Unlike `createBrowserTools`, * these need only the `browser` binding — no Durable Object, loader, or * sandbox — so they work from any Worker. * * @example * ```ts * import { createQuickActionTools } from "agents/browser/ai"; * * const tools = createQuickActionTools({ browser: this.env.BROWSER }); * const result = await generateText({ model, tools, messages }); * ``` */ function createQuickActionTools(options) { const { browser } = options; const requestOptions = options.options; const enabled = new Set(options.actions ?? DEFAULT_QUICK_ACTION_TOOLS); const maxChars = options.maxChars ?? DEFAULT_QUICK_ACTION_MAX_CHARS; const tools = {}; if (enabled.has("markdown")) tools.browser_markdown = tool({ description: "Load a web page (or render raw HTML) and return its content as Markdown. Best for reading articles, docs, or any page as text.", inputSchema: pageInputSchema, execute: async (input) => boundResult(await browserMarkdown(browser, toPage(input, requestOptions)), maxChars) }); if (enabled.has("extract")) tools.browser_extract = tool({ description: "Extract structured data from a web page using AI. Describe what you want in 'prompt'. Passing a JSON Schema in 'schema' is strongly recommended — without one the extractor often fails to produce JSON.", inputSchema: z.object({ url: z.string().url().optional().describe("URL of the page to load"), html: z.string().optional().describe("Raw HTML to render instead of loading a URL"), prompt: z.string().optional().describe("What to extract, in natural language"), schema: z.record(z.string(), z.unknown(), { error: "Schema must be a JSON object" }).optional().describe("Optional JSON Schema describing the desired output") }).refine((value) => Boolean(value.url) || Boolean(value.html), { message: "Provide either 'url' or 'html'" }).refine((value) => Boolean(value.prompt) || Boolean(value.schema), { message: "Provide either 'prompt' or 'schema'" }), execute: async (input) => boundResult(await browserExtract(browser, { ...toPage(input, requestOptions), prompt: input.prompt, response_format: input.schema ? { type: "json_schema", json_schema: input.schema } : void 0 }), maxChars) }); if (enabled.has("links")) tools.browser_links = tool({ description: "Return every link found on a web page (including ones not visible). Useful for discovering pages to follow.", inputSchema: pageInputSchema, execute: async (input) => boundResult(await browserLinks(browser, toPage(input, requestOptions)), maxChars) }); if (enabled.has("scrape")) tools.browser_scrape = tool({ description: "Scrape specific elements from a web page by CSS selector. Returns the matched elements' text, HTML, and attributes.", inputSchema: z.object({ url: z.string().url().optional().describe("URL of the page to load"), html: z.string().optional().describe("Raw HTML to render instead of loading a URL"), selectors: z.array(z.string()).min(1).describe("CSS selectors to extract") }).refine((value) => Boolean(value.url) || Boolean(value.html), { message: "Provide either 'url' or 'html'" }), execute: async (input) => boundResult(await browserScrape(browser, { ...toPage(input, requestOptions), elements: input.selectors.map((selector) => ({ selector })) }), maxChars) }); if (enabled.has("content")) tools.browser_content = tool({ description: "Load a web page and return its fully rendered HTML (after JavaScript runs). Prefer 'browser_markdown' unless you need the raw HTML.", inputSchema: pageInputSchema, execute: async (input) => boundResult(await browserContent(browser, toPage(input, requestOptions)), maxChars) }); return tools; } //#endregion export { createBrowserRuntime, createBrowserTools, createQuickActionTools }; //# sourceMappingURL=ai.js.map