1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
|
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 {
closeIsolatedTab,
evalInIsolatedTab,
isJsonText,
navigateIsolatedTab,
openIsolatedTab,
waitForIsolatedLoad,
} from "../isolated-tab";
import { renderExpandableText } from "../render";
import { classifySerp, parseGoogleSerp, type SearchResult, type SerpExtraction } from "./google-serp";
const LOAD_TIMEOUT_MS = 15_000;
const WebSearchArgs = Type.Object({
query: Type.String({ description: "The search query." }),
limit: Type.Optional(
Type.Integer({ default: 10, minimum: 1, maximum: 30, description: "Max results to return. Default: 10." }),
),
});
const PAGE_TEXT_LIMIT = 4000;
const buildSerpExtractionExpr = (): string => `
(() => {
const main = document.querySelector('#search, #rso, #main') || document.body;
const headings = Array.from(main.querySelectorAll('a h3'));
const seen = new Set();
const anchors = [];
for (const h3 of headings) {
const a = h3.closest('a[href]');
if (!a) continue;
const href = a.href;
if (!href || seen.has(href)) continue;
seen.add(href);
const container =
a.closest('div.g') ||
a.closest('div[data-hveid]') ||
a.parentElement?.parentElement ||
a.parentElement;
const containerText = container ? container.innerText || '' : '';
const heading = h3.innerText || h3.textContent || '';
const snippet = containerText.replace(heading, '').replace(/\\s+/g, ' ').trim().slice(0, 500);
anchors.push({ href, heading, snippet });
}
const pageText = (document.body.innerText || '').slice(0, ${PAGE_TEXT_LIMIT});
return JSON.stringify({ anchors, pageText });
})()
`;
const buildSerpUrl = (query: string, limit: number): string =>
`https://www.google.com/search?q=${encodeURIComponent(query)}&num=${limit}&hl=en`;
const SerpExtractionSchema = Type.Object({
anchors: Type.Array(
Type.Object({ href: Type.String(), heading: Type.String(), snippet: Type.String() }),
),
pageText: Type.String(),
});
const serpExtractionValidator = Compile(SerpExtractionSchema);
const formatResults = (query: string, results: ReadonlyArray<SearchResult>): string => {
const lines = results.map((r) => `${r.rank}. ${r.title}\n ${r.url}\n ${r.snippet}`);
return `${results.length} result(s) for "${query}":\n\n${lines.join("\n\n")}`;
};
export const webSearchTool = defineBrowserTool({
name: "browser_web_search",
label: "Browser Web Search",
description:
"Search the web and return ranked results (title, url, snippet, rank). Scrapes a Google results page in the real Chrome — no API key. Returns links only; use browser_read_page to read a result's content.",
promptSnippet: "Search the web for a query",
promptGuidelines: [
"On a CAPTCHA / bot wall the call fails with kind:'invalid_state' and details.reason:'captcha' (plus serpUrl) — surface it to the user; do not retry in a tight loop.",
"On zero results it fails with details.reason:'no_results' — rephrase the query and try again.",
"Runs in its own isolated tab and never disturbs the user's current tab.",
],
parameters: WebSearchArgs,
concurrency: "serialized",
async handler(args, { client, signal }): Promise<Result<ToolOk, ToolErr>> {
const limit = args.limit ?? 10;
const serpUrl = buildSerpUrl(args.query, limit);
const opened = await openIsolatedTab(client);
if (!opened.success) return err(opened.error);
const tab = opened.data;
try {
const navigated = await navigateIsolatedTab(client, tab, serpUrl);
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 extracted = await evalInIsolatedTab(client, tab, buildSerpExtractionExpr(), isJsonText);
if (!extracted.success) return err(extracted.error);
const parsedJson = parseJson(extracted.data, serpExtractionValidator);
if (!parsedJson.success) {
return err({ kind: "internal", message: `SERP extraction returned an unexpected shape: ${parsedJson.error}` });
}
const extraction: SerpExtraction = parsedJson.data;
const results = parseGoogleSerp(extraction.anchors, limit);
const verdict = classifySerp(extraction.pageText, results.length);
if (verdict !== "ok") {
return err({
kind: "invalid_state",
message: verdict === "captcha" ? "Google served a CAPTCHA / bot wall" : "no results found",
details: { reason: verdict, serpUrl },
});
}
const body = formatResults(args.query, results);
const truncated = await applyTruncation(body, "search");
return ok({
text: truncated.text,
details: {
results,
engine: "google",
query: args.query,
render: {
summary: `${results.length} result(s) · google · "${args.query}"`,
body,
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
},
...(truncated.fullOutputPath !== undefined ? { fullOutputPath: truncated.fullOutputPath } : {}),
},
});
} finally {
await closeIsolatedTab(client, tab);
}
},
renderResult(result, expanded, theme) {
return renderExpandableText("web_search", result, expanded, theme);
},
});
|