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
|
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 { buildSerpExtractionExpr } from "./extract";
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 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: [
"Returns ranked {title, url, snippet, rank} — links only, no page content. Follow up with browser_read_page.",
"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);
},
});
|