pi-browser-harness 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,10 @@
1
1
  import { Type } from "typebox";
2
+ import { Markdown, Text } from "@mariozechner/pi-tui";
3
+ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
2
4
  import { type Result, err, ok } from "../util/result";
3
5
  import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
4
6
  import { applyTruncation } from "../util/truncate";
7
+ import type { NetworkRecord } from "../cdp/network-buffer";
5
8
 
6
9
  const HttpGetArgs = Type.Object({
7
10
  url: Type.String({ description: "URL to GET" }),
@@ -49,32 +52,200 @@ export const httpGetTool = defineBrowserTool({
49
52
  },
50
53
  });
51
54
 
52
- const NetLogArgs = Type.Object({
53
- eventTypes: Type.Optional(
54
- Type.Array(Type.String(), { description: "CDP event names to filter (e.g., ['Network.requestWillBeSent'])" }),
55
+ const NetworkRequestsArgs = Type.Object({
56
+ urlPattern: Type.Optional(
57
+ Type.String({ description: "Substring match by default. Wrap in slashes (e.g. /api\\\\.example/) for regex." }),
58
+ ),
59
+ methodFilter: Type.Optional(
60
+ Type.Array(Type.String(), { description: 'HTTP methods to include, e.g. ["GET","POST"].' }),
61
+ ),
62
+ statusFilter: Type.Optional(
63
+ Type.Object(
64
+ {
65
+ min: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })),
66
+ max: Type.Optional(Type.Integer({ minimum: 100, maximum: 599 })),
67
+ },
68
+ { description: "Inclusive HTTP status range." },
69
+ ),
70
+ ),
71
+ resourceTypes: Type.Optional(
72
+ Type.Array(Type.String(), { description: 'CDP resource types: "Document","Stylesheet","Image","Media","Font","Script","TextTrack","XHR","Fetch","EventSource","WebSocket","Manifest","SignedExchange","Ping","CSPViolationReport","Preflight","Other".' }),
73
+ ),
74
+ sinceMs: Type.Optional(
75
+ Type.Integer({ minimum: 0, description: "Only requests started in the last N ms." }),
76
+ ),
77
+ limit: Type.Optional(
78
+ Type.Integer({ default: 50, minimum: 1, maximum: 500, description: "Max records to return." }),
79
+ ),
80
+ includeResponseBodies: Type.Optional(
81
+ Type.Boolean({ default: false, description: "Fetch response bodies via Network.getResponseBody. Costs one CDP call per matched request." }),
55
82
  ),
56
- limit: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 500 })),
57
83
  });
58
84
 
59
- export const getNetworkLogTool = defineBrowserTool({
60
- name: "browser_get_network_log",
61
- label: "Browser Get Network Log",
85
+ const BODY_BUDGET_MS = 5_000;
86
+ const PER_BODY_CAP = 50_000;
87
+
88
+ const renderNetworkMarkdown = (
89
+ records: ReadonlyArray<NetworkRecord>,
90
+ opts: { includeBodies: boolean; total: number; bufferOverflowed: boolean; expanded: boolean },
91
+ ): string => {
92
+ if (records.length === 0) {
93
+ if (opts.bufferOverflowed) return "_No matching requests. (Buffer overflowed since last drain — some events were dropped.)_";
94
+ return "_No matching requests captured yet._";
95
+ }
96
+
97
+ const failed = records.filter((r) => r.failed || (r.status !== undefined && r.status >= 500)).length;
98
+ const totalBytes = records.reduce((s, r) => s + (r.responseBodySize ?? 0), 0);
99
+ const formatBytes = (n: number): string => {
100
+ if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
101
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
102
+ return `${n} B`;
103
+ };
104
+ const showWindow = (() => {
105
+ const finished = records.filter((r) => r.durationMs !== undefined);
106
+ if (finished.length === 0) return "";
107
+ const span = Math.max(...records.map((r) => r.requestStartedMs)) - Math.min(...records.map((r) => r.requestStartedMs));
108
+ return ` · ${(span / 1000).toFixed(1)}s window`;
109
+ })();
110
+
111
+ const headerLines: string[] = [
112
+ `**${records.length} requests** · ${failed} failed · ${formatBytes(totalBytes)}${showWindow}`,
113
+ ];
114
+ if (opts.bufferOverflowed) headerLines.push("⚠ buffer overflowed since last drain — older events dropped");
115
+ if (opts.total > records.length) headerLines.push(`(${records.length} of ${opts.total} matches shown — increase \`limit\` for more)`);
116
+
117
+ const compactRows = (records.length > 5 && !opts.expanded ? records.slice(0, 5) : records).map((r) => {
118
+ const status = r.failed ? "ERR" : r.status !== undefined ? String(r.status) : "—";
119
+ const warn = r.failed || (r.status !== undefined && r.status >= 500) ? " ⚠" : "";
120
+ const ms = r.durationMs !== undefined ? `${r.durationMs} ms` : "—";
121
+ const url = r.url.length > 60 ? r.url.slice(0, 57) + "…" : r.url;
122
+ return `\`${r.method.padEnd(4)} ${status.padStart(3)}\` ${url} _${ms}_${warn}`;
123
+ });
124
+
125
+ const rows = compactRows.join("\n");
126
+
127
+ if (!opts.expanded) {
128
+ const more = records.length > 5 ? `\n … ${records.length - 5} more` : "";
129
+ return `${headerLines.join("\n")}\n\n${rows}${more}`;
130
+ }
131
+
132
+ // Expanded: full table + optional body sections.
133
+ const tableHeader = `| # | Method | Status | URL | Type | ms |\n|---|---|---|---|---|---|`;
134
+ const tableRows = records
135
+ .map((r, i) => {
136
+ const status = r.failed ? "ERR" : r.status !== undefined ? String(r.status) : "—";
137
+ const ms = r.durationMs !== undefined ? String(r.durationMs) : "—";
138
+ const url = r.url.length > 80 ? r.url.slice(0, 77) + "…" : r.url;
139
+ return `| ${i + 1} | ${r.method} | ${status} | \`${url}\` | ${r.type} | ${ms} |`;
140
+ })
141
+ .join("\n");
142
+
143
+ let bodySections = "";
144
+ if (opts.includeBodies) {
145
+ const sections: string[] = [];
146
+ records.forEach((r, i) => {
147
+ if (r.body === undefined) return;
148
+ const sizes = `${r.requestBodySize} B → ${r.responseBodySize ?? 0} B`;
149
+ const status = r.failed ? "failed" : r.status ?? "?";
150
+ const header = `── #${i + 1} ${r.method} ${r.url} (${status}, ${r.durationMs ?? "?"} ms, ${r.type}, ${sizes}) ──`;
151
+ const bodyText = r.body === null ? "_(body unavailable — already GC'd by Chrome)_" : "```\n" + r.body + "\n```";
152
+ sections.push(`${header}\n\n${bodyText}`);
153
+ });
154
+ if (sections.length > 0) bodySections = "\n\n" + sections.join("\n\n");
155
+ }
156
+
157
+ return [headerLines.join("\n"), "", tableHeader, tableRows, bodySections].filter((s) => s !== "").join("\n");
158
+ };
159
+
160
+ type NetworkDetails = {
161
+ total: number;
162
+ returned: number;
163
+ bufferOverflowed: boolean;
164
+ truncated: boolean;
165
+ fullOutputPath?: string;
166
+ requests: ReadonlyArray<NetworkRecord>;
167
+ includeBodies: boolean;
168
+ };
169
+
170
+ export const networkRequestsTool = defineBrowserTool({
171
+ name: "browser_network_requests",
172
+ label: "Browser Network Requests",
62
173
  description:
63
- "Read buffered Network.* CDP events. NOTE: in v0.3 the event stream is consumed internally by the session manager; this tool is a placeholder. Use browser_execute_js with PerformanceObserver as a workaround.",
64
- promptSnippet: "Get buffered network events (DEPRECATED in v0.3 — use browser_execute_js with PerformanceObserver instead)",
174
+ "List recent network requests captured on the current tab since attach. Filter by URL pattern, method, status, resource type, or recency. Optionally include response bodies.",
175
+ promptSnippet: "Inspect recent network requests on the current page",
65
176
  promptGuidelines: [
66
- "v0.3 routes CDP events through an internal AsyncIterable; a synchronous drain API isn't exposed yet.",
67
- "For now, use browser_execute_js with PerformanceObserver, performance.getEntries(), or fetch interceptors.",
177
+ "Buffer is reset on tab switch — only requests on the current tab since attach are visible.",
178
+ 'urlPattern is a substring; wrap in slashes for regex (e.g. "/\\\\.json$/").',
179
+ "includeResponseBodies:true costs an extra CDP call per matched request — use only when you need payloads.",
180
+ "If bufferOverflowed:true in the result, older events were dropped (capacity 500 records/tab).",
68
181
  ],
69
- parameters: NetLogArgs,
70
- async handler(args): Promise<Result<ToolOk, ToolErr>> {
71
- return ok({
72
- text: "browser_get_network_log: in v0.3 the CDP event stream is consumed by the session; use browser_execute_js with PerformanceObserver or performance.getEntries() to inspect network activity.",
73
- details: {
74
- deprecated: true,
75
- eventTypes: args.eventTypes ?? null,
76
- limit: args.limit ?? null,
77
- },
182
+ parameters: NetworkRequestsArgs,
183
+
184
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
185
+ const session = client.session();
186
+ const drained = session.drainNetworkBuffer({
187
+ ...(args.urlPattern !== undefined ? { urlPattern: args.urlPattern } : {}),
188
+ ...(args.methodFilter !== undefined ? { methodFilter: args.methodFilter } : {}),
189
+ ...(args.statusFilter !== undefined ? { statusFilter: args.statusFilter } : {}),
190
+ ...(args.resourceTypes !== undefined ? { resourceTypes: args.resourceTypes } : {}),
191
+ ...(args.sinceMs !== undefined ? { sinceMs: args.sinceMs } : {}),
192
+ limit: args.limit ?? 50,
78
193
  });
194
+
195
+ if (args.includeResponseBodies) {
196
+ const ac = new AbortController();
197
+ const timer = setTimeout(() => ac.abort(), BODY_BUDGET_MS);
198
+ const fills = drained.records.map(async (r) => {
199
+ if (ac.signal.aborted) return;
200
+ const bodyRes = await session.call("Network.getResponseBody", { requestId: r.requestId });
201
+ if (!bodyRes.success) {
202
+ r.body = null;
203
+ return;
204
+ }
205
+ const b = bodyRes.data as { body: string; base64Encoded?: boolean };
206
+ let body = b.base64Encoded ? Buffer.from(b.body, "base64").toString("utf8") : b.body;
207
+ if (body.length > PER_BODY_CAP) body = body.slice(0, PER_BODY_CAP) + "…";
208
+ r.body = body;
209
+ });
210
+ await Promise.allSettled(fills);
211
+ clearTimeout(timer);
212
+ }
213
+
214
+ const text = renderNetworkMarkdown(drained.records, {
215
+ includeBodies: args.includeResponseBodies ?? false,
216
+ total: drained.total,
217
+ bufferOverflowed: drained.bufferOverflowed,
218
+ expanded: true, // full text always returned to the LLM; renderResult handles compact view for humans
219
+ });
220
+ const trunc = await applyTruncation(text, "network");
221
+
222
+ const details: NetworkDetails = {
223
+ total: drained.total,
224
+ returned: drained.records.length,
225
+ bufferOverflowed: drained.bufferOverflowed,
226
+ truncated: trunc.wasTruncated,
227
+ requests: drained.records,
228
+ includeBodies: args.includeResponseBodies ?? false,
229
+ };
230
+ if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
231
+
232
+ return ok({ text: trunc.text, details });
233
+ },
234
+
235
+ renderResult(result, expanded, theme) {
236
+ const details = result.details as NetworkDetails | undefined;
237
+ if (!details) return new Text(theme.fg("error", "network: no details"), 0, 0);
238
+
239
+ const md = renderNetworkMarkdown(details.requests, {
240
+ includeBodies: details.includeBodies && expanded,
241
+ total: details.total,
242
+ bufferOverflowed: details.bufferOverflowed,
243
+ expanded,
244
+ });
245
+ const tail = expanded
246
+ ? `\n\n${keyHint("app.tools.expand", "to collapse")}${details.fullOutputPath ? ` · full payload at \`${details.fullOutputPath}\`` : ""}`
247
+ : `\n\n${keyHint("app.tools.expand", "to expand")}`;
248
+
249
+ return new Markdown(md + tail, 0, 0, getMarkdownTheme());
79
250
  },
80
251
  });
@@ -63,12 +63,15 @@ const resizeIfNeeded = async (
63
63
  export const screenshotTool = defineBrowserTool({
64
64
  name: "browser_screenshot",
65
65
  label: "Browser Screenshot",
66
- description: "Capture the current page as PNG or JPEG. JPEG is 2-5x smaller — prefer it for photo-heavy pages.",
67
- promptSnippet: "Capture a screenshot of the current page",
66
+ description:
67
+ "Capture the page as a JPEG/PNG image. NOT a default exploration tool. Use ONLY when (a) you need to verify visual properties (layout, color, spacing, rendered text legibility), or (b) browser_snapshot and browser_execute_js cannot answer your question. For understanding what's on the page use browser_snapshot. For specific element values use browser_execute_js. For click coordinates use the @(x,y) hints in browser_snapshot's outline.",
68
+ promptSnippet: "Capture a screenshot — visual verification only",
68
69
  promptGuidelines: [
69
- "Use BEFORE clicks/scrolls to find coordinates; AFTER to verify the action.",
70
- "Pass format='jpeg' with a quality (60-90) for smaller files on photo-heavy pages.",
71
- "Set maxDim if the page is huge and you want to fit under LLM image-size limits.",
70
+ "DO NOT use as a default exploration tool. browser_snapshot is the default for understanding page structure — far cheaper and more reliable.",
71
+ "DO NOT use to find click coordinates. browser_snapshot already includes @(x,y) for every interactive element. If a target isn't there, use browser_execute_js with element.getBoundingClientRect().",
72
+ "DO use to verify visual rendering after an action: did the modal animate in correctly, did the chart render, are colors right, did the layout reflow as expected.",
73
+ "DO use as a debugging fallback when browser_execute_js or browser_snapshot return surprising results and you suspect the rendered page differs from the DOM.",
74
+ "format='jpeg' with quality 60-90 keeps screenshots small for photo-heavy pages. Use maxDim if the page is huge.",
72
75
  ],
73
76
  parameters: ScreenshotArgs,
74
77
  async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
@@ -0,0 +1,421 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { Type } from "typebox";
3
+ import { Container, Image, type ImageTheme, Markdown, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
+ import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
5
+ import { type Result, err, ok } from "../util/result";
6
+ import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool";
7
+ import { applyTruncation } from "../util/truncate";
8
+ import { screenshotPath } from "../util/paths";
9
+
10
+ const SnapshotArgs = Type.Object({
11
+ includeScreenshot: Type.Optional(
12
+ Type.Boolean({ default: false, description: "Also capture a JPEG screenshot of the current viewport." }),
13
+ ),
14
+ interestingOnly: Type.Optional(
15
+ Type.Boolean({ default: true, description: "Drop nodes the AX engine marked uninteresting (generic containers, inline text, etc.)." }),
16
+ ),
17
+ maxNodes: Type.Optional(
18
+ Type.Integer({ default: 1000, minimum: 1, maximum: 5000, description: "Cap on slim nodes returned." }),
19
+ ),
20
+ format: Type.Optional(
21
+ Type.Union(
22
+ [Type.Literal("outline"), Type.Literal("json")],
23
+ { default: "outline", description: "'outline' = indented markdown bullet tree; 'json' = raw slim structure." },
24
+ ),
25
+ ),
26
+ });
27
+
28
+ // Raw shapes from CDP Accessibility.getFullAXTree. Cast at this boundary only.
29
+ type AxValue = { value?: unknown };
30
+ type RawAxNode = {
31
+ nodeId: string;
32
+ parentId?: string;
33
+ childIds?: ReadonlyArray<string>;
34
+ ignored?: boolean;
35
+ role?: AxValue;
36
+ name?: AxValue;
37
+ value?: AxValue;
38
+ description?: AxValue;
39
+ properties?: ReadonlyArray<{ name?: string; value?: AxValue }>;
40
+ backendDOMNodeId?: number;
41
+ };
42
+
43
+ type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
44
+
45
+ type SlimNode = {
46
+ role: string;
47
+ name?: string;
48
+ value?: string;
49
+ description?: string;
50
+ state?: string;
51
+ children: SlimNode[];
52
+ /** Internal: kept for the post-build box fetch. Stripped before returning to the LLM. */
53
+ _backendId?: number;
54
+ /** Click target — center of the element's bounding box, in viewport CSS pixels. */
55
+ box?: Box;
56
+ };
57
+
58
+ const INTERACTIVE_ROLES = new Set([
59
+ "button",
60
+ "link",
61
+ "textbox",
62
+ "checkbox",
63
+ "radio",
64
+ "combobox",
65
+ "menuitem",
66
+ "tab",
67
+ "switch",
68
+ "slider",
69
+ "searchbox",
70
+ "spinbutton",
71
+ ]);
72
+
73
+ const stringOf = (v: AxValue | undefined): string | undefined => {
74
+ const x = v?.value;
75
+ if (typeof x === "string" && x !== "") return x;
76
+ if (typeof x === "number") return String(x);
77
+ return undefined;
78
+ };
79
+
80
+ const collectState = (props: RawAxNode["properties"]): string | undefined => {
81
+ if (!props || props.length === 0) return undefined;
82
+ const flags: string[] = [];
83
+ for (const p of props) {
84
+ const name = p.name;
85
+ if (!name) continue;
86
+ const raw = p.value?.value;
87
+ // Boolean flags worth surfacing in the outline.
88
+ if (raw === true && (name === "focused" || name === "required" || name === "disabled" || name === "checked" || name === "expanded" || name === "selected" || name === "pressed" || name === "modal")) {
89
+ flags.push(name);
90
+ }
91
+ if (typeof raw === "number" && name === "level") flags.push(`level ${raw}`);
92
+ }
93
+ return flags.length > 0 ? flags.join(", ") : undefined;
94
+ };
95
+
96
+ const buildTree = (
97
+ rawNodes: ReadonlyArray<RawAxNode>,
98
+ opts: { interestingOnly: boolean; maxNodes: number },
99
+ ): SlimNode[] => {
100
+ // Index for parent/child lookups.
101
+ const byId = new Map<string, RawAxNode>();
102
+ for (const n of rawNodes) byId.set(n.nodeId, n);
103
+
104
+ // CDP doesn't mark a single root explicitly; the root nodes are the ones
105
+ // whose parentId isn't in the index (typically just one — RootWebArea).
106
+ const rootIds: string[] = [];
107
+ for (const n of rawNodes) {
108
+ if (!n.parentId || !byId.has(n.parentId)) rootIds.push(n.nodeId);
109
+ }
110
+
111
+ let budget = opts.maxNodes;
112
+
113
+ const slim = (node: RawAxNode): SlimNode | undefined => {
114
+ if (budget <= 0) return undefined;
115
+ if (opts.interestingOnly && node.ignored) {
116
+ // Skip the ignored node but recurse through its children — Chrome often
117
+ // ignores wrapper divs but the meaningful descendants are still there.
118
+ const out: SlimNode[] = [];
119
+ for (const cid of node.childIds ?? []) {
120
+ const child = byId.get(cid);
121
+ if (!child) continue;
122
+ const slimChild = slim(child);
123
+ if (slimChild) out.push(slimChild);
124
+ }
125
+ // Hoist children into the parent. We return undefined; the parent's
126
+ // recursion will see them by re-walking — but that double-counts. Instead
127
+ // we handle hoisting at the parent level. So here just signal "skip me"
128
+ // by returning undefined; the parent will hoist by checking ignored flag.
129
+ return out.length > 0 ? { role: "_hoist", children: out } : undefined;
130
+ }
131
+ budget--;
132
+ const role = stringOf(node.role) ?? "unknown";
133
+ const name = stringOf(node.name);
134
+ const value = stringOf(node.value);
135
+ const description = stringOf(node.description);
136
+ const state = collectState(node.properties);
137
+ const children: SlimNode[] = [];
138
+ for (const cid of node.childIds ?? []) {
139
+ const child = byId.get(cid);
140
+ if (!child) continue;
141
+ const slimChild = slim(child);
142
+ if (!slimChild) continue;
143
+ // Hoist any synthetic _hoist nodes' children into this level.
144
+ if (slimChild.role === "_hoist") children.push(...slimChild.children);
145
+ else children.push(slimChild);
146
+ }
147
+ const out: SlimNode = { role, children };
148
+ if (name !== undefined) out.name = name;
149
+ if (value !== undefined) out.value = value;
150
+ if (description !== undefined) out.description = description;
151
+ if (state !== undefined) out.state = state;
152
+ if (node.backendDOMNodeId !== undefined) out._backendId = node.backendDOMNodeId;
153
+ return out;
154
+ };
155
+
156
+ const result: SlimNode[] = [];
157
+ for (const rid of rootIds) {
158
+ const root = byId.get(rid);
159
+ if (!root) continue;
160
+ const s = slim(root);
161
+ if (!s) continue;
162
+ if (s.role === "_hoist") result.push(...s.children);
163
+ else result.push(s);
164
+ }
165
+ return result;
166
+ };
167
+
168
+ const countNodes = (nodes: ReadonlyArray<SlimNode>): number => {
169
+ let n = 0;
170
+ for (const node of nodes) n += 1 + countNodes(node.children);
171
+ return n;
172
+ };
173
+
174
+ const summarize = (nodes: ReadonlyArray<SlimNode>): string => {
175
+ const counts = new Map<string, number>();
176
+ const walk = (ns: ReadonlyArray<SlimNode>): void => {
177
+ for (const n of ns) {
178
+ counts.set(n.role, (counts.get(n.role) ?? 0) + 1);
179
+ walk(n.children);
180
+ }
181
+ };
182
+ walk(nodes);
183
+ // Group landmarks (roles that are top-level structural).
184
+ const landmarkRoles = new Set(["banner", "navigation", "main", "complementary", "contentinfo", "search", "form", "region"]);
185
+ let landmarks = 0;
186
+ for (const [role, c] of counts) if (landmarkRoles.has(role)) landmarks += c;
187
+ const buttons = counts.get("button") ?? 0;
188
+ const inputs = (counts.get("textbox") ?? 0) + (counts.get("combobox") ?? 0) + (counts.get("checkbox") ?? 0);
189
+ const links = counts.get("link") ?? 0;
190
+ const parts: string[] = [];
191
+ if (landmarks > 0) parts.push(`${landmarks} landmark${landmarks === 1 ? "" : "s"}`);
192
+ if (buttons > 0) parts.push(`${buttons} button${buttons === 1 ? "" : "s"}`);
193
+ if (inputs > 0) parts.push(`${inputs} input${inputs === 1 ? "" : "s"}`);
194
+ if (links > 0) parts.push(`${links} link${links === 1 ? "" : "s"}`);
195
+ return parts.join(" · ");
196
+ };
197
+
198
+ const renderOutline = (nodes: ReadonlyArray<SlimNode>): string => {
199
+ const lines: string[] = [];
200
+ const walk = (ns: ReadonlyArray<SlimNode>, depth: number): void => {
201
+ for (const n of ns) {
202
+ const indent = " ".repeat(depth);
203
+ let line = `${indent}- ${n.role}`;
204
+ if (n.name) line += ` "${n.name}"`;
205
+ if (n.value && n.value !== n.name) line += ` = ${JSON.stringify(n.value)}`;
206
+ if (n.state) line += ` (${n.state})`;
207
+ // Surface click coordinates for interactive nodes — agents can pass these
208
+ // straight to browser_click without a screenshot round-trip.
209
+ if (n.box && INTERACTIVE_ROLES.has(n.role)) {
210
+ line += ` @(${n.box.cx},${n.box.cy})`;
211
+ }
212
+ lines.push(line);
213
+ walk(n.children, depth + 1);
214
+ }
215
+ };
216
+ walk(nodes, 0);
217
+ return lines.join("\n");
218
+ };
219
+
220
+ /** Strip internal _backendId from a slim tree before returning to callers. */
221
+ const stripInternals = (nodes: ReadonlyArray<SlimNode>): SlimNode[] =>
222
+ nodes.map((n) => {
223
+ const { _backendId: _bid, ...rest } = n;
224
+ return { ...rest, children: stripInternals(n.children) };
225
+ });
226
+
227
+ /**
228
+ * Walk all slim nodes and collect (slimNode, backendId) pairs for interactive
229
+ * roles. Caller fetches DOM.getBoxModel for each and writes box back into the
230
+ * slim node by reference.
231
+ */
232
+ const collectInteractiveTargets = (
233
+ nodes: ReadonlyArray<SlimNode>,
234
+ ): Array<{ node: SlimNode; backendId: number }> => {
235
+ const out: Array<{ node: SlimNode; backendId: number }> = [];
236
+ const walk = (ns: ReadonlyArray<SlimNode>): void => {
237
+ for (const n of ns) {
238
+ if (n._backendId !== undefined && INTERACTIVE_ROLES.has(n.role)) {
239
+ out.push({ node: n, backendId: n._backendId });
240
+ }
241
+ walk(n.children);
242
+ }
243
+ };
244
+ walk(nodes);
245
+ return out;
246
+ };
247
+
248
+ type SnapshotDetails = {
249
+ nodeCount: number;
250
+ truncated: boolean;
251
+ fullOutputPath?: string;
252
+ screenshotPath?: string;
253
+ url: string;
254
+ title: string;
255
+ };
256
+
257
+ export const snapshotTool = defineBrowserTool({
258
+ name: "browser_snapshot",
259
+ label: "Browser Snapshot",
260
+ description:
261
+ "DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy) plus click coordinates for every interactive element as @(x,y). Use this BEFORE deciding whether you need a screenshot — almost always sufficient on its own and far cheaper. Pair with browser_execute_js for surgical reads of specific element values.",
262
+ promptSnippet: "Get accessibility-tree snapshot of the current page (default for page inspection)",
263
+ promptGuidelines: [
264
+ "DEFAULT — use this whenever you need to know what's on a page, what's clickable, or how the page is structured.",
265
+ "Click coordinates come for free: every interactive element shows '@(x,y)' in the outline. Pass these straight to browser_click. NO screenshot round-trip needed.",
266
+ "DO NOT call browser_screenshot just to understand the page. This tool already gives you structure, labels, states, and click targets.",
267
+ "Pass includeScreenshot:true ONLY if you also need to verify visual rendering (rare).",
268
+ "format:'json' returns the raw slim structure (with `box` per node) for programmatic use; default 'outline' is human/LLM-readable.",
269
+ ],
270
+ parameters: SnapshotArgs,
271
+
272
+ async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> {
273
+ const session = client.session();
274
+
275
+ // 1. AX tree
276
+ const axRes = await session.call("Accessibility.getFullAXTree", {});
277
+ if (!axRes.success) return err({ kind: "cdp_error", message: axRes.error.message });
278
+ const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
279
+
280
+ // 2. Page url/title
281
+ const piRes = await client.pageInfo();
282
+ if (!piRes.success) return err({ kind: "cdp_error", message: piRes.error.message });
283
+ const pageUrl = "dialog" in piRes.data ? "" : piRes.data.url;
284
+ const pageTitle = "dialog" in piRes.data ? "" : piRes.data.title;
285
+
286
+ // 3. Slim + format
287
+ const slim = buildTree(rawNodes, {
288
+ interestingOnly: args.interestingOnly ?? true,
289
+ maxNodes: args.maxNodes ?? 1000,
290
+ });
291
+
292
+ // 4. Fetch bounding boxes for interactive nodes so the agent can click
293
+ // without a screenshot round-trip. Capped budget so a slow page can't
294
+ // wedge the call.
295
+ const targets = collectInteractiveTargets(slim);
296
+ if (targets.length > 0) {
297
+ const ac = new AbortController();
298
+ const timer = setTimeout(() => ac.abort(), 1_500);
299
+ await Promise.allSettled(
300
+ targets.map(async ({ node, backendId }) => {
301
+ if (ac.signal.aborted) return;
302
+ const r = await session.call("DOM.getBoxModel", { backendNodeId: backendId });
303
+ if (!r.success) return;
304
+ // CDP boundary cast: DOM.getBoxModel
305
+ const data = r.data as { model?: { content?: ReadonlyArray<number>; width?: number; height?: number } };
306
+ const content = data.model?.content;
307
+ if (!content || content.length < 8) return;
308
+ // content is [x1,y1, x2,y1, x2,y2, x1,y2] in viewport CSS pixels.
309
+ const x = content[0]!;
310
+ const y = content[1]!;
311
+ const width = data.model!.width ?? 0;
312
+ const height = data.model!.height ?? 0;
313
+ // Skip zero-size and off-viewport boxes — they can't be clicked.
314
+ if (width <= 0 || height <= 0 || x < 0 || y < 0) return;
315
+ node.box = {
316
+ x: Math.round(x),
317
+ y: Math.round(y),
318
+ width: Math.round(width),
319
+ height: Math.round(height),
320
+ cx: Math.round(x + width / 2),
321
+ cy: Math.round(y + height / 2),
322
+ };
323
+ }),
324
+ );
325
+ clearTimeout(timer);
326
+ }
327
+
328
+ const stripped = stripInternals(slim);
329
+ const text = (args.format ?? "outline") === "outline" ? renderOutline(slim) : JSON.stringify(stripped, null, 2);
330
+ const trunc = await applyTruncation(text, "snapshot");
331
+
332
+ // 4. Optional screenshot — JPEG q=80 to match screenshotTool's default.
333
+ let shotPath: string | undefined;
334
+ if (args.includeScreenshot) {
335
+ const shot = await session.call("Page.captureScreenshot", { format: "jpeg", quality: 80 });
336
+ if (shot.success) {
337
+ const data = (shot.data as { data: string }).data;
338
+ shotPath = screenshotPath(client.namespace, "jpeg");
339
+ writeFileSync(shotPath, Buffer.from(data, "base64"));
340
+ }
341
+ }
342
+
343
+ const details: SnapshotDetails = {
344
+ nodeCount: countNodes(slim),
345
+ truncated: trunc.wasTruncated,
346
+ url: pageUrl,
347
+ title: pageTitle,
348
+ };
349
+ if (trunc.fullOutputPath !== undefined) details.fullOutputPath = trunc.fullOutputPath;
350
+ if (shotPath !== undefined) details.screenshotPath = shotPath;
351
+
352
+ return ok({ text: trunc.text, details: { ...details, summary: summarize(slim) } });
353
+ },
354
+
355
+ renderResult(result, expanded, theme) {
356
+ const details = result.details as
357
+ | (SnapshotDetails & { summary?: string; ok?: boolean })
358
+ | undefined;
359
+ if (!details) return new Text(theme.fg("error", "snapshot: no details"), 0, 0);
360
+
361
+ const titleLine = details.title ? ` "${details.title}"` : "";
362
+ const summary = details.summary ?? "";
363
+ const screenshotNote = details.screenshotPath ? "screenshot attached" : "screenshot omitted";
364
+ const truncNote = details.truncated && details.fullOutputPath ? `\n\nFull tree at \`${details.fullOutputPath}\`` : "";
365
+
366
+ if (!expanded) {
367
+ const md = [
368
+ `**AX tree:** ${details.nodeCount} nodes · \`${details.url || "(no url)"}\``,
369
+ `${titleLine ? " " + titleLine : ""}`,
370
+ summary ? ` • ${summary}` : "",
371
+ ` ${keyHint("app.tools.expand", "to expand")} · ${screenshotNote}`,
372
+ ].filter((l) => l !== "").join("\n");
373
+ return new Markdown(md, 0, 0, getMarkdownTheme());
374
+ }
375
+
376
+ // Expanded view: read truncated text from result.content (the spilled file
377
+ // is too large to inline) — the same `text` we returned from handler is in
378
+ // result.content[0]. Fall back to a placeholder if shape is unexpected.
379
+ const content = result.content[0];
380
+ const treeText = content && content.type === "text" ? content.text : "";
381
+
382
+ const headerMd = [
383
+ `**AX tree:** ${details.nodeCount} nodes · \`${details.url || "(no url)"}\``,
384
+ titleLine ? ` ${titleLine}` : "",
385
+ "",
386
+ "```",
387
+ treeText,
388
+ "```",
389
+ truncNote,
390
+ "",
391
+ `${keyHint("app.tools.expand", "to collapse")}`,
392
+ ].filter((l) => l !== "").join("\n");
393
+
394
+ const treeBlock = new Markdown(headerMd, 0, 0, getMarkdownTheme());
395
+
396
+ if (!details.screenshotPath) return treeBlock;
397
+
398
+ // Mirror screenshotTool's width-clamp wrapper for the inline image.
399
+ try {
400
+ const buf = readFileSync(details.screenshotPath);
401
+ const b64 = buf.toString("base64");
402
+ const imageTheme: ImageTheme = { fallbackColor: (s: string) => theme.fg("dim", s) };
403
+ const image = new Image(b64, "image/jpeg", imageTheme, {
404
+ maxWidthCells: 80,
405
+ maxHeightCells: 24,
406
+ filename: details.screenshotPath,
407
+ });
408
+ const safeImage = {
409
+ invalidate: () => image.invalidate(),
410
+ render: (width: number) =>
411
+ image.render(width).map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width) : line)),
412
+ };
413
+ const container = new Container();
414
+ container.addChild(treeBlock);
415
+ container.addChild(safeImage);
416
+ return container;
417
+ } catch {
418
+ return treeBlock;
419
+ }
420
+ },
421
+ });