pi-agent-browser-native 0.2.39 → 0.2.41

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.
@@ -0,0 +1,703 @@
1
+ /**
2
+ * Purpose: Provide the optional provider-backed `agent_browser_web_search` companion tool.
3
+ * Responsibilities: Define strict search input schema, resolve configured Brave/Exa credentials lazily, call the selected search API with cancellation/timeout, normalize compact results, and keep secrets out of content/details.
4
+ * Scope: Live web search only; browser automation remains in the `agent_browser` tool.
5
+ */
6
+
7
+ import { StringEnum } from "@earendil-works/pi-ai";
8
+ import { defineTool } from "@earendil-works/pi-coding-agent";
9
+ import { Type } from "typebox";
10
+ import {
11
+ DEFAULT_WEB_SEARCH_PROVIDER,
12
+ WEB_SEARCH_PROVIDERS,
13
+ resolvePreferredWebSearchCredential,
14
+ type AgentBrowserConfigState,
15
+ type WebSearchProvider,
16
+ } from "./config.js";
17
+
18
+ export const AGENT_BROWSER_WEB_SEARCH_TOOL_NAME = "agent_browser_web_search";
19
+ export const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
20
+ export const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
21
+ export const DEFAULT_SEARCH_RESULT_COUNT = 5;
22
+ export const MAX_SEARCH_RESULT_COUNT = 10;
23
+ export const SEARCH_REQUEST_TIMEOUT_MS = 15_000;
24
+ export const EXA_DEEP_SEARCH_REQUEST_TIMEOUT_MS = 45_000;
25
+ export const WEB_SEARCH_MIN_REQUEST_INTERVAL_MS = 1_100;
26
+ export const EXA_SEARCH_TYPES = ["auto", "fast", "instant", "deep-lite", "deep", "deep-reasoning"] as const;
27
+ export type ExaSearchType = typeof EXA_SEARCH_TYPES[number];
28
+ export const WEB_SEARCH_PROVIDER_PARAM_VALUES = ["auto", ...WEB_SEARCH_PROVIDERS] as const;
29
+ export type WebSearchProviderParam = typeof WEB_SEARCH_PROVIDER_PARAM_VALUES[number];
30
+
31
+ type SearchFreshness = "pd" | "pw" | "pm" | "py";
32
+
33
+ export type BraveWebSearchResult = {
34
+ title?: unknown;
35
+ url?: unknown;
36
+ description?: unknown;
37
+ age?: unknown;
38
+ language?: unknown;
39
+ profile?: {
40
+ name?: unknown;
41
+ url?: unknown;
42
+ };
43
+ meta_url?: {
44
+ hostname?: unknown;
45
+ };
46
+ };
47
+
48
+ export type BraveWebSearchResponse = {
49
+ query?: {
50
+ original?: unknown;
51
+ altered?: unknown;
52
+ };
53
+ web?: {
54
+ results?: BraveWebSearchResult[];
55
+ };
56
+ };
57
+
58
+ export type ExaWebSearchResult = {
59
+ title?: unknown;
60
+ url?: unknown;
61
+ publishedDate?: unknown;
62
+ author?: unknown;
63
+ text?: unknown;
64
+ highlights?: unknown;
65
+ summary?: unknown;
66
+ };
67
+
68
+ export type ExaWebSearchResponse = {
69
+ requestId?: unknown;
70
+ searchType?: unknown;
71
+ results?: ExaWebSearchResult[];
72
+ output?: unknown;
73
+ costDollars?: unknown;
74
+ };
75
+
76
+ export type NormalizedSearchResult = {
77
+ title: string;
78
+ url: string;
79
+ description?: string;
80
+ highlights?: string[];
81
+ source?: string;
82
+ age?: string;
83
+ language?: string;
84
+ };
85
+
86
+ type WebSearchToolDetails = {
87
+ provider: WebSearchProvider;
88
+ query: string;
89
+ returnedQuery: string;
90
+ count: number;
91
+ offset: number;
92
+ fetchedAt: string;
93
+ results: NormalizedSearchResult[];
94
+ searchType?: string;
95
+ requestId?: string;
96
+ };
97
+
98
+ type WebSearchExecutionParams = {
99
+ country?: string;
100
+ count: number;
101
+ freshness?: SearchFreshness;
102
+ offset: number;
103
+ query: string;
104
+ safesearch?: "off" | "moderate" | "strict";
105
+ searchLang?: string;
106
+ searchType?: ExaSearchType;
107
+ };
108
+
109
+ type NormalizedProviderResponse = {
110
+ extraDetails?: Pick<WebSearchToolDetails, "requestId" | "searchType">;
111
+ results: NormalizedSearchResult[];
112
+ returnedQuery: string;
113
+ };
114
+
115
+ export interface WebSearchProviderAdapter<Request = unknown, Response = unknown> {
116
+ buildRequest(params: WebSearchExecutionParams): Request;
117
+ fetchJson(request: Request, apiKey: string, signal?: AbortSignal): Promise<Response>;
118
+ normalizeResponse(response: Response, params: WebSearchExecutionParams): NormalizedProviderResponse;
119
+ provider: WebSearchProvider;
120
+ }
121
+
122
+ export const AgentBrowserWebSearchParams = Type.Object(
123
+ {
124
+ query: Type.String({
125
+ minLength: 1,
126
+ description: "Search query to run with the configured Exa or Brave web search provider.",
127
+ }),
128
+ provider: Type.Optional(
129
+ StringEnum(WEB_SEARCH_PROVIDER_PARAM_VALUES, {
130
+ description: `Optional provider override. auto uses configured keys and preferredProvider; when both Exa and Brave are available, the default preferred provider is ${DEFAULT_WEB_SEARCH_PROVIDER}.`,
131
+ }),
132
+ ),
133
+ searchType: Type.Optional(
134
+ StringEnum(EXA_SEARCH_TYPES, {
135
+ description: "Optional Exa search type. Defaults to auto; ignored by Brave. Use deep/deep-reasoning only for harder research because they are slower.",
136
+ }),
137
+ ),
138
+ count: Type.Optional(
139
+ Type.Integer({
140
+ minimum: 1,
141
+ maximum: MAX_SEARCH_RESULT_COUNT,
142
+ description: `Number of web results to return. Defaults to ${DEFAULT_SEARCH_RESULT_COUNT}; max ${MAX_SEARCH_RESULT_COUNT}.`,
143
+ }),
144
+ ),
145
+ offset: Type.Optional(
146
+ Type.Integer({
147
+ minimum: 0,
148
+ maximum: 9,
149
+ description: "Zero-based result offset for pagination. Defaults to 0.",
150
+ }),
151
+ ),
152
+ country: Type.Optional(
153
+ Type.String({
154
+ pattern: "^[A-Za-z]{2}$",
155
+ description: "Optional 2-letter country code, such as US or GB.",
156
+ }),
157
+ ),
158
+ searchLang: Type.Optional(
159
+ Type.String({
160
+ minLength: 2,
161
+ maxLength: 8,
162
+ description: "Optional Brave search language code, such as en or en-US.",
163
+ }),
164
+ ),
165
+ safesearch: Type.Optional(
166
+ StringEnum(["off", "moderate", "strict"] as const, {
167
+ description: "Optional search safety setting. Brave forwards this as safesearch; Exa maps moderate/strict to moderation=true.",
168
+ }),
169
+ ),
170
+ freshness: Type.Optional(
171
+ StringEnum(["pd", "pw", "pm", "py"] as const, {
172
+ description: "Optional freshness window: pd=past day, pw=past week, pm=past month, py=past year.",
173
+ }),
174
+ ),
175
+ },
176
+ { additionalProperties: false },
177
+ );
178
+
179
+ const HTML_ENTITY_REPLACEMENTS: Readonly<Record<string, string>> = {
180
+ amp: "&",
181
+ apos: "'",
182
+ gt: ">",
183
+ lt: "<",
184
+ nbsp: " ",
185
+ quot: '"',
186
+ };
187
+
188
+ const HTML_TAG_NAMES_TO_STRIP = new Set([
189
+ "a",
190
+ "abbr",
191
+ "address",
192
+ "article",
193
+ "aside",
194
+ "audio",
195
+ "b",
196
+ "base",
197
+ "blockquote",
198
+ "body",
199
+ "br",
200
+ "button",
201
+ "canvas",
202
+ "code",
203
+ "div",
204
+ "em",
205
+ "embed",
206
+ "footer",
207
+ "form",
208
+ "h1",
209
+ "h2",
210
+ "h3",
211
+ "h4",
212
+ "h5",
213
+ "h6",
214
+ "head",
215
+ "header",
216
+ "html",
217
+ "i",
218
+ "iframe",
219
+ "img",
220
+ "input",
221
+ "li",
222
+ "link",
223
+ "main",
224
+ "mark",
225
+ "math",
226
+ "meta",
227
+ "nav",
228
+ "object",
229
+ "ol",
230
+ "option",
231
+ "p",
232
+ "pre",
233
+ "script",
234
+ "section",
235
+ "select",
236
+ "source",
237
+ "span",
238
+ "strong",
239
+ "style",
240
+ "svg",
241
+ "table",
242
+ "tbody",
243
+ "td",
244
+ "textarea",
245
+ "tfoot",
246
+ "th",
247
+ "thead",
248
+ "tr",
249
+ "u",
250
+ "ul",
251
+ "video",
252
+ ]);
253
+
254
+ function decodeHtmlEntity(entity: string): string {
255
+ const named = HTML_ENTITY_REPLACEMENTS[entity.toLowerCase()];
256
+ if (named !== undefined) return named;
257
+ const decimalMatch = /^#(\d+)$/.exec(entity);
258
+ const hexMatch = /^#x([0-9a-f]+)$/i.exec(entity);
259
+ const codePoint = decimalMatch ? Number.parseInt(decimalMatch[1] ?? "", 10) : hexMatch ? Number.parseInt(hexMatch[1] ?? "", 16) : undefined;
260
+ if (codePoint === undefined || !Number.isFinite(codePoint)) return `&${entity};`;
261
+ try {
262
+ return String.fromCodePoint(codePoint);
263
+ } catch {
264
+ return `&${entity};`;
265
+ }
266
+ }
267
+
268
+ export function decodeHtmlEntities(value: string): string {
269
+ return value.replace(/&([a-z][a-z0-9]+|#\d+|#x[0-9a-f]+);/gi, (_match, entity: string) => decodeHtmlEntity(entity));
270
+ }
271
+
272
+ function stripDecodedHtmlTags(value: string): string {
273
+ return value.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ").replace(/<\/?([a-z][a-z0-9-]*)(\s[^>]*)?>/gi, (match, tagName: string, attributes: string | undefined) => {
274
+ if (attributes || match.startsWith("</") || HTML_TAG_NAMES_TO_STRIP.has(tagName.toLowerCase())) return " ";
275
+ return match;
276
+ });
277
+ }
278
+
279
+ export function cleanSearchText(value: unknown, maxLength = 500): string | undefined {
280
+ if (typeof value !== "string") return undefined;
281
+ const cleaned = stripDecodedHtmlTags(decodeHtmlEntities(value.replace(/<[^>]*>/g, " ")))
282
+ .replace(/\s+/g, " ")
283
+ .trim();
284
+ if (!cleaned) return undefined;
285
+ if (cleaned.length <= maxLength) return cleaned;
286
+ return `${cleaned.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
287
+ }
288
+
289
+ export function normalizeSearchUrl(value: unknown): string | undefined {
290
+ if (typeof value !== "string") return undefined;
291
+ try {
292
+ const url = new URL(value);
293
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
294
+ return url.toString();
295
+ } catch {
296
+ return undefined;
297
+ }
298
+ }
299
+
300
+ function getHostname(url: string): string | undefined {
301
+ try {
302
+ return new URL(url).hostname;
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
307
+
308
+ function normalizeHighlightList(value: unknown): string[] | undefined {
309
+ if (!Array.isArray(value)) return undefined;
310
+ const highlights = value
311
+ .map((entry) => cleanSearchText(entry, 320))
312
+ .filter((entry): entry is string => Boolean(entry))
313
+ .slice(0, 3);
314
+ return highlights.length > 0 ? highlights : undefined;
315
+ }
316
+
317
+ export function normalizeBraveSearchResult(result: BraveWebSearchResult): NormalizedSearchResult | undefined {
318
+ const title = cleanSearchText(result.title, 180);
319
+ const url = normalizeSearchUrl(result.url);
320
+ if (!title || !url) return undefined;
321
+ return {
322
+ title,
323
+ url,
324
+ description: cleanSearchText(result.description, 320),
325
+ source: cleanSearchText(result.profile?.name, 120) ?? cleanSearchText(result.meta_url?.hostname, 120),
326
+ age: cleanSearchText(result.age, 80),
327
+ language: cleanSearchText(result.language, 40),
328
+ };
329
+ }
330
+
331
+ export function normalizeExaSearchResult(result: ExaWebSearchResult): NormalizedSearchResult | undefined {
332
+ const title = cleanSearchText(result.title, 180);
333
+ const url = normalizeSearchUrl(result.url);
334
+ if (!title || !url) return undefined;
335
+ const highlights = normalizeHighlightList(result.highlights);
336
+ return {
337
+ title,
338
+ url,
339
+ description: cleanSearchText(result.summary, 320) ?? highlights?.[0] ?? cleanSearchText(result.text, 320),
340
+ highlights,
341
+ source: cleanSearchText(result.author, 120) ?? cleanSearchText(getHostname(url), 120),
342
+ age: cleanSearchText(result.publishedDate, 80),
343
+ };
344
+ }
345
+
346
+ function getProviderLabel(provider: WebSearchProvider): string {
347
+ return provider === "exa" ? "Exa" : "Brave";
348
+ }
349
+
350
+ export function formatSearchResults(provider: WebSearchProvider, query: string, results: NormalizedSearchResult[]): string {
351
+ const providerLabel = getProviderLabel(provider);
352
+ if (results.length === 0) {
353
+ return `No ${providerLabel} web results found for: ${query}`;
354
+ }
355
+ const lines = [`${providerLabel} web search results for: ${query}`, ""];
356
+ results.forEach((result, index) => {
357
+ lines.push(`${index + 1}. ${result.title}`);
358
+ lines.push(` URL: ${result.url}`);
359
+ if (result.source) lines.push(` Source: ${result.source}`);
360
+ if (result.age) lines.push(` Age: ${result.age}`);
361
+ if (result.description) lines.push(` Summary: ${result.description}`);
362
+ if (result.highlights && result.highlights.length > 1) {
363
+ lines.push(" Highlights:");
364
+ for (const highlight of result.highlights) lines.push(` - ${highlight}`);
365
+ }
366
+ lines.push("");
367
+ });
368
+ return lines.join("\n").trimEnd();
369
+ }
370
+
371
+ export function buildBraveSearchUrl(params: {
372
+ query: string;
373
+ count: number;
374
+ offset: number;
375
+ country?: string;
376
+ searchLang?: string;
377
+ safesearch?: "off" | "moderate" | "strict";
378
+ freshness?: SearchFreshness;
379
+ }): URL {
380
+ const url = new URL(BRAVE_SEARCH_ENDPOINT);
381
+ url.searchParams.set("q", params.query);
382
+ url.searchParams.set("count", String(params.count));
383
+ url.searchParams.set("offset", String(params.offset));
384
+ if (params.country) url.searchParams.set("country", params.country.toUpperCase());
385
+ if (params.searchLang) url.searchParams.set("search_lang", params.searchLang);
386
+ if (params.safesearch) url.searchParams.set("safesearch", params.safesearch);
387
+ if (params.freshness) url.searchParams.set("freshness", params.freshness);
388
+ return url;
389
+ }
390
+
391
+ const FRESHNESS_DAYS: Record<SearchFreshness, number> = {
392
+ pd: 1,
393
+ pw: 7,
394
+ pm: 31,
395
+ py: 365,
396
+ };
397
+
398
+ function getStartPublishedDate(freshness: SearchFreshness | undefined, now: () => Date): string | undefined {
399
+ if (!freshness) return undefined;
400
+ const days = FRESHNESS_DAYS[freshness];
401
+ return new Date(now().getTime() - days * 24 * 60 * 60 * 1000).toISOString();
402
+ }
403
+
404
+ export function buildExaSearchRequestBody(params: {
405
+ query: string;
406
+ count: number;
407
+ offset: number;
408
+ country?: string;
409
+ safesearch?: "off" | "moderate" | "strict";
410
+ freshness?: SearchFreshness;
411
+ searchType?: ExaSearchType;
412
+ }, now: () => Date = () => new Date()): Record<string, unknown> {
413
+ const body: Record<string, unknown> = {
414
+ query: params.query,
415
+ type: params.searchType ?? "auto",
416
+ numResults: Math.min(params.count + params.offset, 100),
417
+ contents: { highlights: true },
418
+ };
419
+ if (params.country) body.userLocation = params.country.toUpperCase();
420
+ if (params.safesearch && params.safesearch !== "off") body.moderation = true;
421
+ const startPublishedDate = getStartPublishedDate(params.freshness, now);
422
+ if (startPublishedDate) body.startPublishedDate = startPublishedDate;
423
+ return body;
424
+ }
425
+
426
+ function redactSearchSecret(text: string, apiKey: string): string {
427
+ return apiKey ? text.split(apiKey).join("[REDACTED]") : text;
428
+ }
429
+
430
+ function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
431
+ if (ms <= 0) return Promise.resolve();
432
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("Web search cancelled"));
433
+ return new Promise((resolve, reject) => {
434
+ const cleanup = () => signal?.removeEventListener("abort", abort);
435
+ const timeout = setTimeout(() => {
436
+ cleanup();
437
+ resolve();
438
+ }, ms);
439
+ const abort = () => {
440
+ clearTimeout(timeout);
441
+ cleanup();
442
+ reject(signal?.reason ?? new Error("Web search cancelled"));
443
+ };
444
+ signal?.addEventListener("abort", abort, { once: true });
445
+ });
446
+ }
447
+
448
+ export class WebSearchRequestGate {
449
+ private lastRequestStartedAt = 0;
450
+ private tail: Promise<unknown> = Promise.resolve();
451
+
452
+ constructor(
453
+ private readonly now: () => number = Date.now,
454
+ private readonly sleep: (ms: number, signal?: AbortSignal) => Promise<void> = sleepWithAbort,
455
+ ) {}
456
+
457
+ run<T>(signal: AbortSignal | undefined, task: () => Promise<T>): Promise<T> {
458
+ const runTask = async () => {
459
+ const elapsedMs = this.lastRequestStartedAt === 0 ? WEB_SEARCH_MIN_REQUEST_INTERVAL_MS : this.now() - this.lastRequestStartedAt;
460
+ const waitMs = Math.max(0, WEB_SEARCH_MIN_REQUEST_INTERVAL_MS - elapsedMs);
461
+ if (waitMs > 0) await this.sleep(waitMs, signal);
462
+ if (signal?.aborted) throw signal.reason ?? new Error("Web search cancelled");
463
+ this.lastRequestStartedAt = this.now();
464
+ return task();
465
+ };
466
+ const result = this.tail.then(runTask, runTask);
467
+ this.tail = result.catch(() => undefined);
468
+ return result;
469
+ }
470
+ }
471
+
472
+ function formatSearchHttpError(provider: WebSearchProvider, status: number, statusText: string, body: string, apiKey: string): string {
473
+ const providerLabel = getProviderLabel(provider);
474
+ const errorPreview = cleanSearchText(redactSearchSecret(body, apiKey), 300);
475
+ if (status === 429) {
476
+ const preview = errorPreview ? ` Upstream details: ${redactSearchSecret(errorPreview, apiKey)}` : "";
477
+ return `${providerLabel} search rate limit exceeded (HTTP 429). Do not issue parallel or repeated agent_browser_web_search calls; use one high-signal query, inspect those results, then wait before retrying or ask the user to adjust their ${providerLabel} API plan/limits.${preview}`;
478
+ }
479
+ return `${providerLabel} search failed with HTTP ${status}: ${errorPreview ? redactSearchSecret(errorPreview, apiKey) : statusText}`;
480
+ }
481
+
482
+ async function fetchSearchJson<T>(options: {
483
+ apiKey: string;
484
+ cancelMessage: string;
485
+ init?: RequestInit;
486
+ invalidJsonMessage: string;
487
+ provider: WebSearchProvider;
488
+ request: string | URL;
489
+ signal?: AbortSignal;
490
+ timeoutMessage: string;
491
+ timeoutMs: number;
492
+ }): Promise<T> {
493
+ if (options.signal?.aborted) {
494
+ throw options.signal.reason ?? new Error(options.cancelMessage);
495
+ }
496
+ const controller = new AbortController();
497
+ const timeout = setTimeout(() => controller.abort(new Error(options.timeoutMessage)), options.timeoutMs);
498
+ const abort = () => controller.abort(options.signal?.reason ?? new Error(options.cancelMessage));
499
+ options.signal?.addEventListener("abort", abort, { once: true });
500
+ try {
501
+ const response = await fetch(options.request, {
502
+ ...(options.init ?? {}),
503
+ signal: controller.signal,
504
+ });
505
+ const text = await response.text();
506
+ if (!response.ok) {
507
+ throw new Error(formatSearchHttpError(options.provider, response.status, response.statusText, text, options.apiKey));
508
+ }
509
+ try {
510
+ return JSON.parse(text) as T;
511
+ } catch (error) {
512
+ throw new Error(`${options.invalidJsonMessage}: ${error instanceof Error ? error.message : String(error)}`);
513
+ }
514
+ } finally {
515
+ clearTimeout(timeout);
516
+ options.signal?.removeEventListener("abort", abort);
517
+ }
518
+ }
519
+
520
+ export async function fetchBraveSearchJson(url: URL, apiKey: string, signal?: AbortSignal): Promise<BraveWebSearchResponse> {
521
+ return fetchSearchJson<BraveWebSearchResponse>({
522
+ apiKey,
523
+ cancelMessage: "Brave search cancelled",
524
+ init: {
525
+ headers: {
526
+ Accept: "application/json",
527
+ "X-Subscription-Token": apiKey,
528
+ },
529
+ },
530
+ invalidJsonMessage: "Brave search returned invalid JSON",
531
+ provider: "brave",
532
+ request: url,
533
+ signal,
534
+ timeoutMessage: "Brave search timed out",
535
+ timeoutMs: SEARCH_REQUEST_TIMEOUT_MS,
536
+ });
537
+ }
538
+
539
+ function getExaRequestTimeoutMs(searchType: ExaSearchType | undefined): number {
540
+ return searchType?.startsWith("deep") ? EXA_DEEP_SEARCH_REQUEST_TIMEOUT_MS : SEARCH_REQUEST_TIMEOUT_MS;
541
+ }
542
+
543
+ export async function fetchExaSearchJson(body: Record<string, unknown>, apiKey: string, signal?: AbortSignal, timeoutMs = SEARCH_REQUEST_TIMEOUT_MS): Promise<ExaWebSearchResponse> {
544
+ return fetchSearchJson<ExaWebSearchResponse>({
545
+ apiKey,
546
+ cancelMessage: "Exa search cancelled",
547
+ init: {
548
+ body: JSON.stringify(body),
549
+ headers: {
550
+ Accept: "application/json",
551
+ "Content-Type": "application/json",
552
+ "x-api-key": apiKey,
553
+ },
554
+ method: "POST",
555
+ },
556
+ invalidJsonMessage: "Exa search returned invalid JSON",
557
+ provider: "exa",
558
+ request: EXA_SEARCH_ENDPOINT,
559
+ signal,
560
+ timeoutMessage: "Exa search timed out",
561
+ timeoutMs,
562
+ });
563
+ }
564
+
565
+ const BRAVE_WEB_SEARCH_ADAPTER: WebSearchProviderAdapter<URL, BraveWebSearchResponse> = {
566
+ provider: "brave",
567
+ buildRequest(params) {
568
+ return buildBraveSearchUrl({
569
+ query: params.query,
570
+ count: params.count,
571
+ offset: params.offset,
572
+ country: params.country,
573
+ searchLang: params.searchLang,
574
+ safesearch: params.safesearch,
575
+ freshness: params.freshness,
576
+ });
577
+ },
578
+ fetchJson(request, apiKey, signal) {
579
+ return fetchBraveSearchJson(request, apiKey, signal);
580
+ },
581
+ normalizeResponse(response, params) {
582
+ return {
583
+ results: (response.web?.results ?? [])
584
+ .map(normalizeBraveSearchResult)
585
+ .filter((result): result is NormalizedSearchResult => Boolean(result)),
586
+ returnedQuery: cleanSearchText(response.query?.altered, 300) ?? cleanSearchText(response.query?.original, 300) ?? params.query,
587
+ };
588
+ },
589
+ };
590
+
591
+ type ExaSearchRequest = {
592
+ body: Record<string, unknown>;
593
+ timeoutMs: number;
594
+ };
595
+
596
+ const EXA_WEB_SEARCH_ADAPTER: WebSearchProviderAdapter<ExaSearchRequest, ExaWebSearchResponse> = {
597
+ provider: "exa",
598
+ buildRequest(params) {
599
+ const searchType = params.searchType ?? "auto";
600
+ return {
601
+ body: buildExaSearchRequestBody({
602
+ query: params.query,
603
+ count: params.count,
604
+ offset: params.offset,
605
+ country: params.country,
606
+ safesearch: params.safesearch,
607
+ freshness: params.freshness,
608
+ searchType,
609
+ }),
610
+ timeoutMs: getExaRequestTimeoutMs(searchType),
611
+ };
612
+ },
613
+ fetchJson(request, apiKey, signal) {
614
+ return fetchExaSearchJson(request.body, apiKey, signal, request.timeoutMs);
615
+ },
616
+ normalizeResponse(response, params) {
617
+ const searchType = params.searchType ?? "auto";
618
+ return {
619
+ extraDetails: {
620
+ requestId: cleanSearchText(response.requestId, 120),
621
+ searchType: cleanSearchText(response.searchType, 80) ?? searchType,
622
+ },
623
+ results: (response.results ?? [])
624
+ .map(normalizeExaSearchResult)
625
+ .filter((result): result is NormalizedSearchResult => Boolean(result))
626
+ .slice(params.offset, params.offset + params.count),
627
+ returnedQuery: params.query,
628
+ };
629
+ },
630
+ };
631
+
632
+ export const WEB_SEARCH_PROVIDER_ADAPTERS: Readonly<Record<WebSearchProvider, WebSearchProviderAdapter>> = {
633
+ exa: EXA_WEB_SEARCH_ADAPTER,
634
+ brave: BRAVE_WEB_SEARCH_ADAPTER,
635
+ };
636
+
637
+ export function getWebSearchProviderAdapter(provider: WebSearchProvider): WebSearchProviderAdapter {
638
+ return WEB_SEARCH_PROVIDER_ADAPTERS[provider];
639
+ }
640
+
641
+ function buildMissingCredentialError(provider: WebSearchProviderParam): string {
642
+ if (provider === "brave") return "agent_browser_web_search provider brave was requested but no BRAVE_API_KEY/config credential resolved.";
643
+ if (provider === "exa") return "agent_browser_web_search provider exa was requested but no EXA_API_KEY/config credential resolved.";
644
+ return "No Exa or Brave web search credential resolved. Configure webSearch.exaApiKey or webSearch.braveApiKey, or load EXA_API_KEY/BRAVE_API_KEY in the runtime environment.";
645
+ }
646
+
647
+ export function createAgentBrowserWebSearchTool(configState: AgentBrowserConfigState) {
648
+ const requestGate = new WebSearchRequestGate();
649
+ return defineTool({
650
+ name: AGENT_BROWSER_WEB_SEARCH_TOOL_NAME,
651
+ label: "Agent Browser Web Search",
652
+ description: `Search the web with Exa or Brave when configured. Returns up to ${MAX_SEARCH_RESULT_COUNT} concise web results.`,
653
+ promptSnippet: "Search the live web with Exa or Brave for current or external information.",
654
+ promptGuidelines: [
655
+ "Use agent_browser_web_search when live web search would help answer the task, find current external information, or discover candidate URLs for agent_browser.",
656
+ "The tool chooses Exa or Brave from configured keys; when both are available, Exa is preferred by default unless webSearch.preferredProvider says otherwise. Use provider only when the user/config calls for a specific provider.",
657
+ "Prefer agent_browser_web_search over opening a search engine results page with agent_browser when a quick result list is enough; use agent_browser for interaction, DOM, screenshots, or auth.",
658
+ "Do not issue parallel or repeated agent_browser_web_search calls; use one high-signal query, inspect the results, then only run a focused follow-up if needed. If the provider returns HTTP 429, stop searching and tell the user the API plan/rate limit needs time or a plan change.",
659
+ "After using agent_browser_web_search, cite result URLs in the final answer when web evidence informed the answer.",
660
+ ],
661
+ parameters: AgentBrowserWebSearchParams,
662
+ async execute(_toolCallId, params, signal) {
663
+ if (!configState.webSearchEnabled) {
664
+ throw new Error("agent_browser_web_search is disabled by pi-agent-browser-native config.");
665
+ }
666
+ const requestedProvider = params.provider ?? "auto";
667
+ const resolved = await resolvePreferredWebSearchCredential(configState, { provider: requestedProvider, signal });
668
+ if (!resolved) throw new Error(buildMissingCredentialError(requestedProvider));
669
+ const query = params.query.trim();
670
+ if (!query) throw new Error("query must not be blank");
671
+ const count = Math.min(Math.max(params.count ?? DEFAULT_SEARCH_RESULT_COUNT, 1), MAX_SEARCH_RESULT_COUNT);
672
+ const offset = Math.max(params.offset ?? 0, 0);
673
+ const adapter = getWebSearchProviderAdapter(resolved.provider);
674
+ const executionParams: WebSearchExecutionParams = {
675
+ country: params.country,
676
+ count,
677
+ freshness: params.freshness,
678
+ offset,
679
+ query,
680
+ safesearch: params.safesearch,
681
+ searchLang: params.searchLang,
682
+ searchType: params.searchType ?? "auto",
683
+ };
684
+ const request = adapter.buildRequest(executionParams);
685
+ const data = await requestGate.run(signal, () => adapter.fetchJson(request, resolved.credential.value, signal));
686
+ const normalized = adapter.normalizeResponse(data, executionParams);
687
+ const details: WebSearchToolDetails = {
688
+ provider: adapter.provider,
689
+ query,
690
+ returnedQuery: normalized.returnedQuery,
691
+ count,
692
+ offset,
693
+ ...normalized.extraDetails,
694
+ fetchedAt: new Date().toISOString(),
695
+ results: normalized.results,
696
+ };
697
+ return {
698
+ content: [{ type: "text", text: formatSearchResults(adapter.provider, normalized.returnedQuery, normalized.results) }],
699
+ details,
700
+ };
701
+ },
702
+ });
703
+ }