pi-browser-harness 0.14.1 → 0.16.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.
- package/CHANGELOG.md +25 -0
- package/README.md +15 -8
- package/package.json +1 -1
- package/skills/pi-browser-harness/SKILL.md +1 -2
- package/src/cdp/commands.ts +28 -0
- package/src/client.ts +10 -3
- package/src/daemon/bridge.ts +4 -3
- package/src/daemon/protocol.ts +11 -0
- package/src/daemon/transport.ts +1 -0
- package/src/domains/cdp-call.ts +2 -1
- package/src/domains/click.ts +5 -2
- package/src/domains/form.ts +40 -31
- package/src/domains/js.ts +20 -2
- package/src/domains/keyboard.ts +171 -35
- package/src/domains/page/click.page.ts +22 -0
- package/src/domains/page/fill.page.ts +70 -1
- package/src/domains/page/serp.page.ts +29 -16
- package/src/domains/page.ts +1 -1
- package/src/domains/screenshot.ts +21 -1
- package/src/domains/search/engines/bing.ts +47 -0
- package/src/domains/search/engines/brave.ts +23 -0
- package/src/domains/search/engines/duckduckgo.ts +42 -0
- package/src/domains/search/engines/google.ts +95 -0
- package/src/domains/search/engines/index.ts +13 -0
- package/src/domains/search/engines/registry.ts +29 -0
- package/src/domains/search/redirect-resolver.ts +1 -1
- package/src/domains/search/serp.ts +157 -0
- package/src/domains/search/web-search.ts +235 -105
- package/src/domains/snapshot.ts +1 -1
- package/src/domains/target.ts +83 -7
- package/src/recipes/recordable.ts +4 -3
- package/src/registry.ts +1 -2
- package/src/util/image-size.ts +16 -0
- package/src/domains/search/google-serp.ts +0 -155
|
@@ -4,46 +4,58 @@ import { parseJson } from "../../schemas/parse";
|
|
|
4
4
|
import { type Result, err, ok } from "../../util/result";
|
|
5
5
|
import { applyTruncation } from "../../util/truncate";
|
|
6
6
|
import { type DetailsOf, defineBrowserTool, type ToolErr, type ToolOk } from "../../util/tool";
|
|
7
|
+
import type { BrowserClient } from "../../client";
|
|
7
8
|
import {
|
|
8
9
|
closeIsolatedTab,
|
|
9
10
|
evalInIsolatedTab,
|
|
11
|
+
type IsolatedTab,
|
|
10
12
|
isJsonText,
|
|
11
13
|
navigateIsolatedTab,
|
|
12
14
|
openIsolatedTab,
|
|
13
15
|
waitForIsolatedLoad,
|
|
14
16
|
} from "../isolated-tab";
|
|
15
17
|
import { pageCallExpression } from "../page/inject";
|
|
18
|
+
import { pollUntil } from "../poll";
|
|
19
|
+
import { safeJs } from "../../util/js-template";
|
|
16
20
|
import { extractSerp } from "../page/serp.page";
|
|
17
21
|
import type * as extracted from "../page/serp.page";
|
|
18
22
|
import { ExpandableTextSchema, renderExpandableText } from "../render";
|
|
19
23
|
import { deadlineSignal, runLimited } from "../concurrency";
|
|
24
|
+
import { defaultEngines, type EngineRegistry } from "./engines";
|
|
20
25
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
parseGoogleSerp,
|
|
26
|
+
classifySerpWith,
|
|
27
|
+
parseSerp,
|
|
24
28
|
type ResolvedTargets,
|
|
29
|
+
type SearchEngine,
|
|
25
30
|
type SearchResult,
|
|
26
31
|
type SerpExtraction,
|
|
27
|
-
|
|
32
|
+
type SerpSelectors,
|
|
33
|
+
} from "./serp";
|
|
28
34
|
import { fetchRedirectTarget, type RedirectResolver } from "./redirect-resolver";
|
|
29
35
|
|
|
30
36
|
const LOAD_TIMEOUT_MS = 15_000;
|
|
31
37
|
const RESOLVE_BUDGET_MS = 5_000;
|
|
32
38
|
const RESOLVE_CONCURRENCY = 5;
|
|
39
|
+
const RENDER_WAIT_MS = 4_000;
|
|
33
40
|
|
|
34
41
|
export type WebSearchDeps = {
|
|
42
|
+
// The mounted engines; mount order is the fallback ladder.
|
|
43
|
+
readonly engines: EngineRegistry;
|
|
35
44
|
readonly resolveRedirect: RedirectResolver;
|
|
36
45
|
readonly resolveBudgetMs?: number;
|
|
37
46
|
};
|
|
38
47
|
|
|
39
|
-
// Resolves every /goto
|
|
40
|
-
// absent from the map, and the
|
|
41
|
-
const
|
|
48
|
+
// Resolves every server-side redirect anchor (Google /goto) in parallel under one time budget; a slot that fails or
|
|
49
|
+
// runs out of time is simply absent from the map, and the engine falls back to that anchor's cite.
|
|
50
|
+
const resolveRedirectTargets = async (
|
|
51
|
+
engine: SearchEngine,
|
|
42
52
|
anchors: ReadonlyArray<SerpExtraction["anchors"][number]>,
|
|
43
53
|
deps: WebSearchDeps,
|
|
44
54
|
signal: AbortSignal | undefined,
|
|
45
55
|
): Promise<ResolvedTargets> => {
|
|
46
|
-
const
|
|
56
|
+
const needsResolve = engine.needsRedirectResolve;
|
|
57
|
+
if (needsResolve === undefined) return new Map();
|
|
58
|
+
const hrefs = anchors.map((a) => a.href).filter(needsResolve);
|
|
47
59
|
if (hrefs.length === 0) return new Map();
|
|
48
60
|
const deadline = deadlineSignal(deps.resolveBudgetMs ?? RESOLVE_BUDGET_MS, signal);
|
|
49
61
|
const targets = await runLimited(hrefs, { limit: RESOLVE_CONCURRENCY, signal: deadline.signal }, (href, s) =>
|
|
@@ -58,25 +70,17 @@ const resolveGotoTargets = async (
|
|
|
58
70
|
return resolved;
|
|
59
71
|
};
|
|
60
72
|
|
|
61
|
-
const WebSearchArgs = Type.Object({
|
|
62
|
-
query: Type.String({ description: "The search query." }),
|
|
63
|
-
limit: Type.Optional(
|
|
64
|
-
Type.Integer({ default: 10, minimum: 1, maximum: 30, description: "Max results to return. Default: 10." }),
|
|
65
|
-
),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
73
|
const PAGE_TEXT_LIMIT = 4000;
|
|
69
74
|
|
|
70
75
|
// The page function declares the extraction shape it produces; this pins it to the shape the SERP parser consumes,
|
|
71
76
|
// so the two cannot drift apart silently (the runtime validator below still guards the actual wire value).
|
|
72
77
|
type Pinned<T extends SerpExtraction> = T;
|
|
73
78
|
export type ExtractionFromPage = Pinned<extracted.SerpExtraction>;
|
|
79
|
+
type PinnedSelectors<T extends extracted.SerpSelectors> = T;
|
|
80
|
+
export type SelectorsForPage = PinnedSelectors<SerpSelectors>;
|
|
74
81
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const buildSerpUrl = (query: string, limit: number): string =>
|
|
79
|
-
`https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`;
|
|
82
|
+
const extractionExpression = (engine: SearchEngine): string =>
|
|
83
|
+
pageCallExpression(extractSerp, engine.selectors, PAGE_TEXT_LIMIT);
|
|
80
84
|
|
|
81
85
|
const SerpExtractionSchema = Type.Object({
|
|
82
86
|
anchors: Type.Array(
|
|
@@ -102,105 +106,231 @@ const SearchResultSchema = Type.Object({
|
|
|
102
106
|
rank: Type.Number(),
|
|
103
107
|
});
|
|
104
108
|
|
|
109
|
+
const AttemptSchema = Type.Object({
|
|
110
|
+
engine: Type.String(),
|
|
111
|
+
kind: Type.String(),
|
|
112
|
+
reason: Type.Optional(Type.String()),
|
|
113
|
+
message: Type.String(),
|
|
114
|
+
});
|
|
115
|
+
|
|
105
116
|
const WebSearchDetailsSchema = Type.Object({
|
|
106
117
|
results: Type.Array(SearchResultSchema),
|
|
107
|
-
engine: Type.
|
|
118
|
+
engine: Type.String(),
|
|
108
119
|
query: Type.String(),
|
|
120
|
+
// Engines tried and failed before `engine` answered; absent when the first engine answered.
|
|
121
|
+
fallbacks: Type.Optional(Type.Array(AttemptSchema)),
|
|
109
122
|
render: ExpandableTextSchema,
|
|
110
123
|
fullOutputPath: Type.Optional(Type.String()),
|
|
111
124
|
});
|
|
112
125
|
|
|
113
126
|
export type WebSearchDetails = DetailsOf<typeof WebSearchDetailsSchema>;
|
|
127
|
+
type Attempt = DetailsOf<typeof AttemptSchema>;
|
|
114
128
|
|
|
115
129
|
const formatResults = (query: string, results: ReadonlyArray<SearchResult>): string => {
|
|
116
130
|
const lines = results.map((r) => `${r.rank}. ${r.title}\n ${r.url}\n ${r.snippet}`);
|
|
117
131
|
return `${results.length} result(s) for "${query}":\n\n${lines.join("\n\n")}`;
|
|
118
132
|
};
|
|
119
133
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
"Runs in its own isolated tab and never disturbs the user's current tab.",
|
|
131
|
-
],
|
|
132
|
-
parameters: WebSearchArgs,
|
|
133
|
-
details: WebSearchDetailsSchema,
|
|
134
|
-
concurrency: "serialized",
|
|
135
|
-
async handler(args, { client, signal }): Promise<Result<ToolOk<WebSearchDetails>, ToolErr>> {
|
|
136
|
-
const limit = args.limit ?? 10;
|
|
137
|
-
const serpUrl = buildSerpUrl(args.query, limit);
|
|
138
|
-
|
|
139
|
-
const opened = await openIsolatedTab(client);
|
|
140
|
-
if (!opened.success) return err(opened.error);
|
|
141
|
-
const tab = opened.data;
|
|
142
|
-
|
|
143
|
-
try {
|
|
144
|
-
const navigated = await navigateIsolatedTab(client, tab, serpUrl);
|
|
145
|
-
if (!navigated.success) return err(navigated.error);
|
|
146
|
-
|
|
147
|
-
const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
|
|
148
|
-
if (!loaded.success) return err(loaded.error);
|
|
149
|
-
|
|
150
|
-
const extracted = await evalInIsolatedTab(client, tab, SERP_EXTRACTION_EXPR, isJsonText);
|
|
151
|
-
if (!extracted.success) return err(extracted.error);
|
|
152
|
-
|
|
153
|
-
const parsedJson = parseSerpExtraction(extracted.data);
|
|
154
|
-
if (!parsedJson.success) {
|
|
155
|
-
return err({ kind: "internal", message: `SERP extraction returned an unexpected shape: ${parsedJson.error}` });
|
|
156
|
-
}
|
|
157
|
-
const extraction: SerpExtraction = parsedJson.data;
|
|
158
|
-
|
|
159
|
-
const resolved = await resolveGotoTargets(extraction.anchors, deps, signal);
|
|
160
|
-
const results = parseGoogleSerp(extraction.anchors, limit, resolved);
|
|
161
|
-
const anchorCount = extraction.anchors.length;
|
|
162
|
-
const verdict = classifySerp(extraction.pageText, results.length, anchorCount);
|
|
163
|
-
if (verdict === "captcha") {
|
|
164
|
-
return err({
|
|
165
|
-
kind: "invalid_state",
|
|
166
|
-
message: "Google served a CAPTCHA / bot wall",
|
|
167
|
-
details: { reason: verdict, serpUrl },
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
if (verdict === "extraction_mismatch") {
|
|
171
|
-
return err({
|
|
172
|
-
kind: "invalid_state",
|
|
173
|
-
message: `found ${anchorCount} result anchor(s) but none parsed to an external url (Google markup may have changed)`,
|
|
174
|
-
details: { reason: verdict, anchorCount, serpUrl },
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
if (verdict === "no_results") {
|
|
178
|
-
return err({ kind: "invalid_state", message: "no results found", details: { reason: verdict, serpUrl } });
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const body = formatResults(args.query, results);
|
|
182
|
-
const truncated = await applyTruncation(body, "search");
|
|
183
|
-
return ok({
|
|
184
|
-
text: truncated.text,
|
|
185
|
-
details: {
|
|
186
|
-
results,
|
|
187
|
-
engine: "google",
|
|
188
|
-
query: args.query,
|
|
189
|
-
render: {
|
|
190
|
-
summary: `${results.length} result(s) · google · "${args.query}"`,
|
|
191
|
-
body,
|
|
192
|
-
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
193
|
-
},
|
|
194
|
-
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
195
|
-
},
|
|
196
|
-
});
|
|
197
|
-
} finally {
|
|
198
|
-
await closeIsolatedTab(client, tab);
|
|
199
|
-
}
|
|
200
|
-
},
|
|
201
|
-
renderResult(result, expanded) {
|
|
202
|
-
return renderExpandableText(result.details.render, expanded);
|
|
134
|
+
// One engine, one SERP, in the already-open isolated tab.
|
|
135
|
+
const searchEngine = async (
|
|
136
|
+
engine: SearchEngine,
|
|
137
|
+
query: string,
|
|
138
|
+
limit: number,
|
|
139
|
+
ctx: {
|
|
140
|
+
readonly client: BrowserClient;
|
|
141
|
+
readonly tab: IsolatedTab;
|
|
142
|
+
readonly signal: AbortSignal | undefined;
|
|
143
|
+
readonly expression: string;
|
|
203
144
|
},
|
|
145
|
+
deps: WebSearchDeps,
|
|
146
|
+
): Promise<Result<ReadonlyArray<SearchResult>, ToolErr>> => {
|
|
147
|
+
const { client, tab, signal, expression } = ctx;
|
|
148
|
+
const serpUrl = engine.buildUrl(query, limit);
|
|
149
|
+
|
|
150
|
+
const navigated = await navigateIsolatedTab(client, tab, serpUrl);
|
|
151
|
+
if (!navigated.success) return err(navigated.error);
|
|
152
|
+
|
|
153
|
+
const loaded = await waitForIsolatedLoad(client, tab, LOAD_TIMEOUT_MS, signal);
|
|
154
|
+
if (!loaded.success) return err(loaded.error);
|
|
155
|
+
|
|
156
|
+
if (engine.readySelector !== undefined) {
|
|
157
|
+
// A miss is not a failure: a bot wall or an empty SERP never renders results, and extraction classifies it.
|
|
158
|
+
await pollUntil(client, safeJs`document.querySelector(${engine.readySelector}) !== null`, {
|
|
159
|
+
timeoutMs: RENDER_WAIT_MS,
|
|
160
|
+
signal,
|
|
161
|
+
sessionId: tab.sessionId,
|
|
162
|
+
what: `${engine.name} results`,
|
|
163
|
+
});
|
|
164
|
+
if (signal?.aborted === true) return err({ kind: "timeout", message: `Aborted while waiting for ${engine.name} results` });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const evaluated = await evalInIsolatedTab(client, tab, expression, isJsonText);
|
|
168
|
+
if (!evaluated.success) return err(evaluated.error);
|
|
169
|
+
|
|
170
|
+
const parsedJson = parseSerpExtraction(evaluated.data);
|
|
171
|
+
if (!parsedJson.success) {
|
|
172
|
+
return err({ kind: "internal", message: `SERP extraction returned an unexpected shape: ${parsedJson.error}` });
|
|
173
|
+
}
|
|
174
|
+
const extraction: SerpExtraction = parsedJson.data;
|
|
175
|
+
|
|
176
|
+
const resolved = await resolveRedirectTargets(engine, extraction.anchors, deps, signal);
|
|
177
|
+
const results = parseSerp(engine, extraction.anchors, limit, resolved);
|
|
178
|
+
const anchorCount = extraction.anchors.length;
|
|
179
|
+
const verdict = classifySerpWith(engine.captchaMarkers, extraction.pageText, results.length, anchorCount);
|
|
180
|
+
if (verdict === "captcha") {
|
|
181
|
+
return err({
|
|
182
|
+
kind: "invalid_state",
|
|
183
|
+
message: `${engine.name} served a CAPTCHA / bot wall`,
|
|
184
|
+
details: { reason: verdict, serpUrl },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (verdict === "extraction_mismatch") {
|
|
188
|
+
return err({
|
|
189
|
+
kind: "invalid_state",
|
|
190
|
+
message: `found ${anchorCount} result anchor(s) but none parsed to an external url (${engine.name} markup may have changed)`,
|
|
191
|
+
details: { reason: verdict, anchorCount, serpUrl },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (verdict === "no_results") {
|
|
195
|
+
return err({ kind: "invalid_state", message: "no results found", details: { reason: verdict, serpUrl } });
|
|
196
|
+
}
|
|
197
|
+
return ok(results);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const withEngine = (engine: string, error: ToolErr): ToolErr => ({
|
|
201
|
+
...error,
|
|
202
|
+
details: { ...error.details, engine },
|
|
204
203
|
});
|
|
205
204
|
|
|
206
|
-
|
|
205
|
+
const toAttempt = (engine: string, error: ToolErr): Attempt => {
|
|
206
|
+
const reason = error.details?.["reason"];
|
|
207
|
+
return {
|
|
208
|
+
engine,
|
|
209
|
+
kind: error.kind,
|
|
210
|
+
...(typeof reason === "string" ? { reason } : {}),
|
|
211
|
+
message: error.message,
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// A dead browser or a cancelled call fails every engine the same way, so the ladder stops instead of trying the next.
|
|
216
|
+
const stopsLadder = (error: ToolErr, signal: AbortSignal | undefined): boolean =>
|
|
217
|
+
signal?.aborted === true || error.kind === "not_connected";
|
|
218
|
+
|
|
219
|
+
const exhausted = (attempts: ReadonlyArray<Attempt>): ToolErr => {
|
|
220
|
+
const allEmpty = attempts.every((a) => a.reason === "no_results");
|
|
221
|
+
const summary = attempts.map((a) => `${a.engine}: ${a.message}`).join("; ");
|
|
222
|
+
return {
|
|
223
|
+
kind: "invalid_state",
|
|
224
|
+
message: allEmpty ? `no results found on any engine (${summary})` : `every search engine failed (${summary})`,
|
|
225
|
+
details: { reason: allEmpty ? "no_results" : "all_engines_failed", attempts },
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// A named engine is tried alone; no name means the whole mounted ladder.
|
|
230
|
+
const pickLadder = (
|
|
231
|
+
engines: EngineRegistry,
|
|
232
|
+
name: string | undefined,
|
|
233
|
+
): Result<ReadonlyArray<SearchEngine>, ToolErr> => {
|
|
234
|
+
if (name === undefined) return ok(engines.ladder());
|
|
235
|
+
const named = engines.get(name);
|
|
236
|
+
if (named !== undefined) return ok([named]);
|
|
237
|
+
return err({
|
|
238
|
+
kind: "invalid_args",
|
|
239
|
+
message: `unknown search engine "${name}"; mounted engines: ${engines.ids().join(", ")}`,
|
|
240
|
+
details: { engine: name, engines: engines.ids() },
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const webSearchArgs = (engines: EngineRegistry) =>
|
|
245
|
+
Type.Object({
|
|
246
|
+
query: Type.String({ description: "The search query." }),
|
|
247
|
+
limit: Type.Optional(
|
|
248
|
+
Type.Integer({ default: 10, minimum: 1, maximum: 30, description: "Max results to return. Default: 10." }),
|
|
249
|
+
),
|
|
250
|
+
engine: Type.Optional(
|
|
251
|
+
Type.String({
|
|
252
|
+
enum: [...engines.ids()],
|
|
253
|
+
description: `Search only this engine (${engines.ids().join(", ")}), with no fallback. Omit to walk the fallback ladder ${engines.ids().join(" → ")} until one returns results.`,
|
|
254
|
+
}),
|
|
255
|
+
),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
export const createWebSearchTool = (deps: WebSearchDeps) => {
|
|
259
|
+
const { engines } = deps;
|
|
260
|
+
const ids = engines.ids();
|
|
261
|
+
if (ids.length === 0) throw new Error("browser_web_search needs at least one mounted search engine");
|
|
262
|
+
const ladderText = ids.join(" → ");
|
|
263
|
+
// Built once per engine: stringifying the page function per call was the only cost of the thunk.
|
|
264
|
+
const expressions = new Map(engines.ladder().map((e) => [e.id, extractionExpression(e)] as const));
|
|
265
|
+
|
|
266
|
+
return defineBrowserTool({
|
|
267
|
+
name: "browser_web_search",
|
|
268
|
+
label: "Browser Web Search",
|
|
269
|
+
description:
|
|
270
|
+
`Search the web and return ranked results (title, url, snippet, rank). Scrapes a search engine results page in the real Chrome — no API key. Engines: ${ids.join(", ")}. With no engine given it walks the fallback ladder ${ladderText} until one returns results. Returns links only; use browser_read_page to read a result's content.`,
|
|
271
|
+
promptSnippet: "Search the web for a query",
|
|
272
|
+
promptGuidelines: [
|
|
273
|
+
`Omit engine to get the fallback ladder (${ladderText}); details.engine says which engine answered and details.fallbacks lists the engines that failed first.`,
|
|
274
|
+
"Pass engine only when one engine is specifically needed; a named engine is tried alone, with no fallback.",
|
|
275
|
+
"On a CAPTCHA / bot wall the call fails with kind:'invalid_state' and details.reason:'captcha' (plus serpUrl and engine) — surface it to the user; do not retry in a tight loop.",
|
|
276
|
+
"On zero results it fails with details.reason:'no_results' — rephrase the query and try again.",
|
|
277
|
+
"If results were on the page but none could be parsed it fails with details.reason:'extraction_mismatch' (plus anchorCount) — the engine's markup changed; report it rather than retrying.",
|
|
278
|
+
"When every engine on the ladder fails it is details.reason:'all_engines_failed' with details.attempts naming each engine's failure.",
|
|
279
|
+
"Runs in its own isolated tab and never disturbs the user's current tab.",
|
|
280
|
+
],
|
|
281
|
+
parameters: webSearchArgs(engines),
|
|
282
|
+
details: WebSearchDetailsSchema,
|
|
283
|
+
concurrency: "serialized",
|
|
284
|
+
async handler(args, { client, signal }): Promise<Result<ToolOk<WebSearchDetails>, ToolErr>> {
|
|
285
|
+
const limit = args.limit ?? 10;
|
|
286
|
+
const ladder = pickLadder(engines, args.engine);
|
|
287
|
+
if (!ladder.success) return err(ladder.error);
|
|
288
|
+
|
|
289
|
+
const opened = await openIsolatedTab(client);
|
|
290
|
+
if (!opened.success) return err(opened.error);
|
|
291
|
+
const tab = opened.data;
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const attempts: Attempt[] = [];
|
|
295
|
+
for (const engine of ladder.data) {
|
|
296
|
+
const id = engine.id;
|
|
297
|
+
const expression = expressions.get(id) ?? extractionExpression(engine);
|
|
298
|
+
const searched = await searchEngine(engine, args.query, limit, { client, tab, signal, expression }, deps);
|
|
299
|
+
if (!searched.success) {
|
|
300
|
+
// A single named engine reports its own failure exactly, as if there were no ladder.
|
|
301
|
+
if (ladder.data.length === 1 || stopsLadder(searched.error, signal)) return err(withEngine(id, searched.error));
|
|
302
|
+
attempts.push(toAttempt(id, searched.error));
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const results = searched.data;
|
|
306
|
+
const body = formatResults(args.query, results);
|
|
307
|
+
const truncated = await applyTruncation(body, "search");
|
|
308
|
+
const via = attempts.length > 0 ? ` (after ${attempts.map((a) => a.engine).join(", ")} failed)` : "";
|
|
309
|
+
return ok({
|
|
310
|
+
text: truncated.text,
|
|
311
|
+
details: {
|
|
312
|
+
results,
|
|
313
|
+
engine: id,
|
|
314
|
+
query: args.query,
|
|
315
|
+
...(attempts.length > 0 ? { fallbacks: attempts } : {}),
|
|
316
|
+
render: {
|
|
317
|
+
summary: `${results.length} result(s) · ${id}${via} · "${args.query}"`,
|
|
318
|
+
body,
|
|
319
|
+
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
320
|
+
},
|
|
321
|
+
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return err(exhausted(attempts));
|
|
326
|
+
} finally {
|
|
327
|
+
await closeIsolatedTab(client, tab);
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
renderResult(result, expanded) {
|
|
331
|
+
return renderExpandableText(result.details.render, expanded);
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
export const webSearchTool = createWebSearchTool({ engines: defaultEngines(), resolveRedirect: fetchRedirectTarget });
|
package/src/domains/snapshot.ts
CHANGED
|
@@ -115,7 +115,7 @@ export const snapshotTool = defineBrowserTool({
|
|
|
115
115
|
name: "browser_snapshot",
|
|
116
116
|
label: "Browser Snapshot",
|
|
117
117
|
description:
|
|
118
|
-
"DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy). Every interactive element gets a stable ref shown as [eN] plus click coordinates @(x,y). Pass the ref to browser_click/
|
|
118
|
+
"DEFAULT tool for understanding what is on the page. Returns the structured accessibility tree (roles, names, states, hierarchy). Every interactive element gets a stable ref shown as [eN] plus click coordinates @(x,y). Pass the ref to browser_click/browser_type/etc. — refs survive re-renders, coordinates don't. Use this BEFORE deciding whether you need a screenshot. Pair with browser_execute_js for surgical reads of specific element values.",
|
|
119
119
|
promptSnippet: "Get accessibility-tree snapshot with stable element refs (default for page inspection)",
|
|
120
120
|
promptGuidelines: [
|
|
121
121
|
"Re-run browser_snapshot after a navigation or a major re-render to get fresh refs.",
|
package/src/domains/target.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { BrowserClient } from "../client";
|
|
2
|
+
import { isRecord } from "../util/guards";
|
|
2
3
|
import { type Result, err, ok } from "../util/result";
|
|
3
4
|
import type { ToolErr } from "../util/tool";
|
|
4
|
-
import {
|
|
5
|
+
import { boxOf } from "./box";
|
|
6
|
+
import { cdpCall } from "./cdp-call";
|
|
7
|
+
import { hitInfo } from "./page/click.page";
|
|
8
|
+
import { elementFunctionSource } from "./page/inject";
|
|
9
|
+
import { resolveRefToBackendId } from "./ref-resolve";
|
|
5
10
|
|
|
6
11
|
export type PointArgs = {
|
|
7
12
|
readonly ref?: string | undefined;
|
|
@@ -13,23 +18,94 @@ export type ResolvedTarget = {
|
|
|
13
18
|
readonly x: number;
|
|
14
19
|
readonly y: number;
|
|
15
20
|
readonly label: string;
|
|
21
|
+
// Set when hit-testing found something other than the ref's element under the point (an overlay, a sticky header).
|
|
22
|
+
readonly covered?: string;
|
|
23
|
+
// For an x/y click: what is under the point, so the agent can tell whether it hit what it meant to.
|
|
24
|
+
readonly hit?: string;
|
|
16
25
|
};
|
|
17
26
|
|
|
27
|
+
type Viewport = { readonly width: number; readonly height: number };
|
|
28
|
+
|
|
29
|
+
// The layout viewport in CSS pixels, or undefined when Chrome would not say (then no bounds check is made).
|
|
30
|
+
export const viewportOf = async (client: BrowserClient): Promise<Viewport | undefined> => {
|
|
31
|
+
const r = await cdpCall(client, "Page.getLayoutMetrics", {});
|
|
32
|
+
if (!r.success) return undefined;
|
|
33
|
+
const { clientWidth, clientHeight } = r.data.cssLayoutViewport;
|
|
34
|
+
return clientWidth > 0 && clientHeight > 0 ? { width: clientWidth, height: clientHeight } : undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const inside = (vp: Viewport, x: number, y: number): boolean => x >= 0 && y >= 0 && x < vp.width && y < vp.height;
|
|
38
|
+
|
|
39
|
+
const outsideErr = (x: number, y: number, vp: Viewport, ref?: string): ToolErr => ({
|
|
40
|
+
kind: "invalid_state",
|
|
41
|
+
message: ref !== undefined
|
|
42
|
+
? `[${ref}] is at (${x}, ${y}), outside the ${vp.width}x${vp.height} viewport even after scrolling it into view — it may be clipped by a scroll container or positioned off-screen. Scroll its container (browser_scroll) and re-snapshot.`
|
|
43
|
+
: `Point (${x}, ${y}) is outside the ${vp.width}x${vp.height} viewport, so the click would hit nothing. Use the element's ref from browser_snapshot instead — browser_click scrolls it into view.`,
|
|
44
|
+
details: { x, y, viewportWidth: vp.width, viewportHeight: vp.height, ...(ref !== undefined ? { ref } : {}) },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const HIT_FN = elementFunctionSource(hitInfo);
|
|
48
|
+
|
|
49
|
+
const isHitInfo = (v: unknown): v is { readonly onTarget: boolean; readonly hit: string } =>
|
|
50
|
+
isRecord(v) && typeof v["onTarget"] === "boolean" && typeof v["hit"] === "string";
|
|
51
|
+
|
|
52
|
+
// What hit-testing finds at the point, relative to the target element (or just what is there, with no target);
|
|
53
|
+
// undefined when it cannot tell.
|
|
54
|
+
const hitTest = async (client: BrowserClient, backendId: number | undefined, x: number, y: number): Promise<{ readonly onTarget: boolean; readonly hit: string } | undefined> => {
|
|
55
|
+
const loc = await cdpCall(client, "DOM.getNodeForLocation", { x: Math.round(x), y: Math.round(y), ignorePointerEventsNone: true });
|
|
56
|
+
if (!loc.success) return undefined;
|
|
57
|
+
const target = await cdpCall(client, "DOM.resolveNode", { backendNodeId: backendId ?? loc.data.backendNodeId });
|
|
58
|
+
const hit = await cdpCall(client, "DOM.resolveNode", { backendNodeId: loc.data.backendNodeId });
|
|
59
|
+
const targetId = target.success ? target.data.object.objectId : undefined;
|
|
60
|
+
const hitId = hit.success ? hit.data.object.objectId : undefined;
|
|
61
|
+
try {
|
|
62
|
+
if (targetId === undefined || hitId === undefined) return undefined;
|
|
63
|
+
const called = await cdpCall(client, "Runtime.callFunctionOn", {
|
|
64
|
+
objectId: targetId,
|
|
65
|
+
functionDeclaration: HIT_FN,
|
|
66
|
+
arguments: [{ objectId: hitId }],
|
|
67
|
+
returnByValue: true,
|
|
68
|
+
});
|
|
69
|
+
if (!called.success || called.data.exceptionDetails !== undefined) return undefined;
|
|
70
|
+
const v: unknown = called.data.result.value;
|
|
71
|
+
return isHitInfo(v) ? v : undefined;
|
|
72
|
+
} finally {
|
|
73
|
+
for (const id of [targetId, hitId]) if (id !== undefined) await cdpCall(client, "Runtime.releaseObject", { objectId: id });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Turns a ref (or x/y) into the point a trusted mouse event should land on. For a ref: scroll the element into view
|
|
78
|
+
// (nested scroll containers included), measure it now, refuse a point still outside the viewport, and hit-test it so
|
|
79
|
+
// a covered element is reported instead of silently clicking whatever is on top.
|
|
18
80
|
export const resolveTarget = async (
|
|
19
81
|
client: BrowserClient,
|
|
20
82
|
args: PointArgs,
|
|
21
83
|
): Promise<Result<ResolvedTarget, ToolErr>> => {
|
|
22
84
|
if (args.ref !== undefined) {
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
85
|
+
const backendId = resolveRefToBackendId(client, args.ref);
|
|
86
|
+
if (!backendId.success) return backendId;
|
|
87
|
+
// A failed scroll is not fatal: the bounds check below catches an element that is still out of view.
|
|
88
|
+
await cdpCall(client, "DOM.scrollIntoViewIfNeeded", { backendNodeId: backendId.data });
|
|
89
|
+
const box = await boxOf(client, backendId.data);
|
|
90
|
+
if (!box.success) {
|
|
91
|
+
return err({ kind: "invalid_state", message: `Ref ${args.ref} is unknown or stale — re-run browser_snapshot to get fresh refs.`, details: { ref: args.ref } });
|
|
92
|
+
}
|
|
93
|
+
const { cx, cy } = box.data;
|
|
94
|
+
const vp = await viewportOf(client);
|
|
95
|
+
if (vp !== undefined && !inside(vp, cx, cy)) return err(outsideErr(cx, cy, vp, args.ref));
|
|
96
|
+
const hit = await hitTest(client, backendId.data, cx, cy);
|
|
25
97
|
return ok({
|
|
26
|
-
x:
|
|
27
|
-
y:
|
|
28
|
-
label: `[${args.ref}] (${
|
|
98
|
+
x: cx,
|
|
99
|
+
y: cy,
|
|
100
|
+
label: `[${args.ref}] (${cx}, ${cy})`,
|
|
101
|
+
...(hit !== undefined && !hit.onTarget ? { covered: hit.hit } : {}),
|
|
29
102
|
});
|
|
30
103
|
}
|
|
31
104
|
if (args.x !== undefined && args.y !== undefined) {
|
|
32
|
-
|
|
105
|
+
const vp = await viewportOf(client);
|
|
106
|
+
if (vp !== undefined && !inside(vp, args.x, args.y)) return err(outsideErr(args.x, args.y, vp));
|
|
107
|
+
const hit = await hitTest(client, undefined, args.x, args.y);
|
|
108
|
+
return ok({ x: args.x, y: args.y, label: `(${args.x}, ${args.y})`, ...(hit !== undefined ? { hit: hit.hit } : {}) });
|
|
33
109
|
}
|
|
34
110
|
return err({ kind: "invalid_args", message: "Provide either `ref` or both `x` and `y`." });
|
|
35
111
|
};
|
|
@@ -20,7 +20,7 @@ export const REPLAYABLE: ReadonlyMap<string, "ref" | "fields" | "none"> = new Ma
|
|
|
20
20
|
["browser_dispatch_key", "ref"],
|
|
21
21
|
["browser_upload_file", "ref"],
|
|
22
22
|
["browser_fill_form", "fields"],
|
|
23
|
-
["browser_type", "
|
|
23
|
+
["browser_type", "ref"],
|
|
24
24
|
["browser_press_key", "none"],
|
|
25
25
|
["browser_navigate", "none"],
|
|
26
26
|
["browser_go_back", "none"],
|
|
@@ -303,8 +303,9 @@ export const observeCall: CallObserver = async ({ name, concurrency, args, clien
|
|
|
303
303
|
}
|
|
304
304
|
const fields = mode === "fields" ? await fieldsOf(client, args) : undefined;
|
|
305
305
|
let password = fields?.some((f) => f.password) ?? false;
|
|
306
|
-
if (name === "browser_fill"
|
|
307
|
-
|
|
306
|
+
if (name === "browser_fill" || (name === "browser_type" && (ref !== undefined || selector !== undefined))) {
|
|
307
|
+
password = await isPasswordTarget(client, ref, selector);
|
|
308
|
+
} else if (name === "browser_type") {
|
|
308
309
|
const r = await evalJs(client, FOCUSED_IS_PASSWORD_EXPR);
|
|
309
310
|
password = r.success && r.data === true;
|
|
310
311
|
}
|
package/src/registry.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { BrowserTool } from "./util/tool";
|
|
|
4
4
|
import { type Result, err, ok } from "./util/result";
|
|
5
5
|
import { clickTool } from "./domains/click";
|
|
6
6
|
import { typeTool, pressKeyTool, dispatchKeyTool } from "./domains/keyboard";
|
|
7
|
-
import {
|
|
7
|
+
import { selectOptionTool, focusTool, setCheckedTool } from "./domains/form";
|
|
8
8
|
import { fillFormTool } from "./domains/form-batch";
|
|
9
9
|
import { pageInfoTool, waitTool, waitForLoadTool, waitForTool } from "./domains/page";
|
|
10
10
|
import { scrollTool } from "./domains/scroll";
|
|
@@ -31,7 +31,6 @@ export const ALL_TOOLS: ReadonlyArray<BrowserTool> = [
|
|
|
31
31
|
setupTool,
|
|
32
32
|
clickTool,
|
|
33
33
|
typeTool,
|
|
34
|
-
fillTool,
|
|
35
34
|
fillFormTool,
|
|
36
35
|
selectOptionTool,
|
|
37
36
|
setCheckedTool,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Reads an image's pixel width from its header, without decoding it (sharp is optional). PNG keeps it in IHDR; JPEG
|
|
2
|
+
// in the first start-of-frame marker. Returns undefined for anything else or a truncated file.
|
|
3
|
+
export const imageWidth = (buf: Buffer): number | undefined => {
|
|
4
|
+
if (buf.length >= 24 && buf.readUInt32BE(0) === 0x89504e47) return buf.readUInt32BE(16);
|
|
5
|
+
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return undefined;
|
|
6
|
+
let i = 2;
|
|
7
|
+
while (i + 9 < buf.length) {
|
|
8
|
+
if (buf[i] !== 0xff) return undefined;
|
|
9
|
+
const marker = buf[i + 1] ?? 0;
|
|
10
|
+
const len = buf.readUInt16BE(i + 2);
|
|
11
|
+
// SOF0..SOF15, minus DHT (C4), JPG (C8) and DAC (CC), carry the frame size: height then width.
|
|
12
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return buf.readUInt16BE(i + 7);
|
|
13
|
+
i += 2 + len;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
};
|