import { Type } from "typebox"; import { Compile } from "typebox/compile"; import { parseJson } from "../../schemas/parse"; import { type Result, err, ok } from "../../util/result"; import { applyTruncation } from "../../util/truncate"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool"; import { cdpCallBrowser, evalJs } from "../cdp-call"; import { closeIsolatedTab, evalInIsolatedTab, isJsonText, type IsolatedTab, navigateIsolatedTab, openIsolatedTab, waitForIsolatedLoad, } from "../isolated-tab"; import { renderExpandableText } from "../render"; import { extractReadable, type PageCapture, type ReadablePage } from "./readability"; const LOAD_TIMEOUT_MS = 15_000; const ReadPageArgs = Type.Object({ url: Type.Optional(Type.String({ description: "URL to open in an isolated tab, read, then close." })), targetId: Type.Optional( Type.String({ description: "Read an already-open tab this session owns instead of opening a URL." }), ), }); const ContentBlockSchema = Type.Object({ kind: Type.Union([ Type.Literal("paragraph"), Type.Literal("heading"), Type.Literal("listitem"), Type.Literal("blockquote"), Type.Literal("other"), ]), text: Type.String(), linkTextLength: Type.Number(), inBoilerplate: Type.Boolean(), }); const PageCaptureSchema = Type.Object({ url: Type.String(), title: Type.String(), blocks: Type.Array(ContentBlockSchema), bodyText: Type.String(), }); const pageCaptureValidator = Compile(PageCaptureSchema); const BODY_TEXT_LIMIT = 20_000; const buildPageCaptureExpr = (): string => ` (() => { const BOILERPLATE = new Set(['NAV', 'HEADER', 'FOOTER', 'ASIDE']); const inBoilerplate = (el) => { for (let n = el; n; n = n.parentElement) { if (BOILERPLATE.has(n.tagName)) return true; const role = n.getAttribute && n.getAttribute('role'); if (role === 'navigation' || role === 'banner' || role === 'contentinfo') return true; } return false; }; const kindOf = (tag) => { if (/^H[1-6]$/.test(tag)) return 'heading'; if (tag === 'LI') return 'listitem'; if (tag === 'BLOCKQUOTE') return 'blockquote'; if (tag === 'P') return 'paragraph'; return 'other'; }; const selector = 'p, h1, h2, h3, h4, h5, h6, li, blockquote'; const root = document.querySelector('article, main, [role=main]') || document.body; const blocks = []; for (const el of root.querySelectorAll(selector)) { const text = (el.innerText || '').replace(/\\s+/g, ' ').trim(); if (!text) continue; let linkTextLength = 0; for (const a of el.querySelectorAll('a')) linkTextLength += (a.innerText || '').length; blocks.push({ kind: kindOf(el.tagName), text, linkTextLength, inBoilerplate: inBoilerplate(el) }); } const bodyText = (document.body.innerText || '').replace(/\\n{3,}/g, '\\n\\n').slice(0, ${BODY_TEXT_LIMIT}); return JSON.stringify({ url: location.href, title: document.title, blocks, bodyText }); })() `; const toPageCapture = (raw: unknown): PageCapture | undefined => { if (typeof raw === "string") { const parsed = parseJson(raw, pageCaptureValidator); return parsed.success ? parsed.data : undefined; } return pageCaptureValidator.Check(raw) ? raw : undefined; }; const captureToResult = async (raw: unknown): Promise> => { const parsed = toPageCapture(raw); if (parsed === undefined) return err({ kind: "internal", message: "page capture returned an unexpected shape" }); const page: ReadablePage = extractReadable(parsed); const header = `# ${page.title}\n${page.url}\n(${page.wordCount} words)\n\n`; const truncated = await applyTruncation(header + page.text, "readpage"); const summary = `${page.title} ยท ${page.wordCount} words`; return ok({ text: truncated.text, details: { title: page.title, url: page.url, wordCount: page.wordCount, render: { summary, body: page.text, ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}), }, ...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}), }, }); }; const readOpenedUrl = async ( client: Parameters[0], url: string, signal: AbortSignal | undefined, ): Promise> => { const opened = await openIsolatedTab(client); if (!opened.success) return err(opened.error); const tab: IsolatedTab = opened.data; try { const navigated = await navigateIsolatedTab(client, tab, url); if (!navigated.success) return err(navigated.error); const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal); if (!loaded.success) return err(loaded.error); const captured = await evalInIsolatedTab(client, tab, buildPageCaptureExpr(), isJsonText); if (!captured.success) return err(captured.error); return captureToResult(captured.data); } finally { await closeIsolatedTab(client, tab); } }; const readOwnedTab = async ( client: Parameters[0], targetId: string, ): Promise> => { if (!client.owns(targetId)) { return err({ kind: "invalid_state", message: `Tab ${targetId} is not owned by this session.` }); } const attached = await cdpCallBrowser(client, "Target.attachToTarget", { targetId, flatten: true }); if (!attached.success) return attached; const sessionId = attached.data.sessionId; const captured = await evalJs(client, buildPageCaptureExpr(), sessionId); if (!captured.success) return captured; return captureToResult(captured.data); }; export const readPageTool = defineBrowserTool({ name: "browser_read_page", label: "Browser Read Page", description: "Read a page as clean article text โ€” main content with nav/ads/boilerplate stripped. Pass a url (opened in an isolated tab, read, then closed) or a targetId of an owned tab. Reader-mode counterpart to browser_web_search.", promptSnippet: "Read a page's main content as clean text", promptGuidelines: [ "Returns readable main-article text โ€” use this over browser_snapshot/browser_execute_js when you want an article's content for reading or research.", "If the result is short or reads like a nav shell (e.g. JS-rendered social pages like X, login-walled pages), extraction likely ran before the app rendered or missed the content โ€” do NOT treat the shell as the page. Re-read via og:title/og:description, or use browser_execute_js with an article/content selector.", ], parameters: ReadPageArgs, concurrency: "parallel", async handler(args, { client, signal }): Promise> { if (args.url !== undefined && args.url.length > 0) return readOpenedUrl(client, args.url, signal); if (args.targetId !== undefined && args.targetId.length > 0) return readOwnedTab(client, args.targetId); return err({ kind: "invalid_state", message: "provide either url or targetId" }); }, renderResult(result, expanded, theme) { return renderExpandableText("read_page", result, expanded, theme); }, });