pi-agent-browser-native 0.2.40 → 0.2.42

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,19 +1,34 @@
1
1
  /**
2
- * Purpose: Provide the optional Brave-backed `agent_browser_web_search` companion tool.
3
- * Responsibilities: Define strict search input schema, resolve the configured Brave credential lazily, call Brave Search with cancellation/timeout, normalize compact results, and keep secrets out of content/details.
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
4
  * Scope: Live web search only; browser automation remains in the `agent_browser` tool.
5
5
  */
6
6
 
7
7
  import { StringEnum } from "@earendil-works/pi-ai";
8
8
  import { defineTool } from "@earendil-works/pi-coding-agent";
9
9
  import { Type } from "typebox";
10
- import { resolveBraveApiKey, type AgentBrowserConfigState } from "./config.js";
10
+ import {
11
+ DEFAULT_WEB_SEARCH_PROVIDER,
12
+ WEB_SEARCH_PROVIDERS,
13
+ resolvePreferredWebSearchCredential,
14
+ type AgentBrowserConfigState,
15
+ type WebSearchProvider,
16
+ } from "./config.js";
11
17
 
12
18
  export const AGENT_BROWSER_WEB_SEARCH_TOOL_NAME = "agent_browser_web_search";
13
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";
14
21
  export const DEFAULT_SEARCH_RESULT_COUNT = 5;
15
22
  export const MAX_SEARCH_RESULT_COUNT = 10;
16
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";
17
32
 
18
33
  export type BraveWebSearchResult = {
19
34
  title?: unknown;
@@ -40,21 +55,86 @@ export type BraveWebSearchResponse = {
40
55
  };
41
56
  };
42
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
+
43
76
  export type NormalizedSearchResult = {
44
77
  title: string;
45
78
  url: string;
46
79
  description?: string;
80
+ highlights?: string[];
47
81
  source?: string;
48
82
  age?: string;
49
83
  language?: string;
50
84
  };
51
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
+
52
122
  export const AgentBrowserWebSearchParams = Type.Object(
53
123
  {
54
124
  query: Type.String({
55
125
  minLength: 1,
56
- description: "Search query to run with Brave Search.",
126
+ description: "Search query to run with the configured Exa or Brave web search provider.",
57
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
+ ),
58
138
  count: Type.Optional(
59
139
  Type.Integer({
60
140
  minimum: 1,
@@ -79,12 +159,12 @@ export const AgentBrowserWebSearchParams = Type.Object(
79
159
  Type.String({
80
160
  minLength: 2,
81
161
  maxLength: 8,
82
- description: "Optional search language code, such as en or en-US.",
162
+ description: "Optional Brave search language code, such as en or en-US.",
83
163
  }),
84
164
  ),
85
165
  safesearch: Type.Optional(
86
166
  StringEnum(["off", "moderate", "strict"] as const, {
87
- description: "Optional Brave safe-search setting. Defaults to Brave's API default.",
167
+ description: "Optional search safety setting. Brave forwards this as safesearch; Exa maps moderate/strict to moderation=true.",
88
168
  }),
89
169
  ),
90
170
  freshness: Type.Optional(
@@ -217,7 +297,24 @@ export function normalizeSearchUrl(value: unknown): string | undefined {
217
297
  }
218
298
  }
219
299
 
220
- export function normalizeSearchResult(result: BraveWebSearchResult): NormalizedSearchResult | undefined {
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 {
221
318
  const title = cleanSearchText(result.title, 180);
222
319
  const url = normalizeSearchUrl(result.url);
223
320
  if (!title || !url) return undefined;
@@ -231,17 +328,41 @@ export function normalizeSearchResult(result: BraveWebSearchResult): NormalizedS
231
328
  };
232
329
  }
233
330
 
234
- export function formatSearchResults(query: string, results: NormalizedSearchResult[]): string {
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);
235
352
  if (results.length === 0) {
236
- return `No Brave web results found for: ${query}`;
353
+ return `No ${providerLabel} web results found for: ${query}`;
237
354
  }
238
- const lines = [`Brave web search results for: ${query}`, ""];
355
+ const lines = [`${providerLabel} web search results for: ${query}`, ""];
239
356
  results.forEach((result, index) => {
240
357
  lines.push(`${index + 1}. ${result.title}`);
241
358
  lines.push(` URL: ${result.url}`);
242
359
  if (result.source) lines.push(` Source: ${result.source}`);
243
360
  if (result.age) lines.push(` Age: ${result.age}`);
244
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
+ }
245
366
  lines.push("");
246
367
  });
247
368
  return lines.join("\n").trimEnd();
@@ -254,7 +375,7 @@ export function buildBraveSearchUrl(params: {
254
375
  country?: string;
255
376
  searchLang?: string;
256
377
  safesearch?: "off" | "moderate" | "strict";
257
- freshness?: "pd" | "pw" | "pm" | "py";
378
+ freshness?: SearchFreshness;
258
379
  }): URL {
259
380
  const url = new URL(BRAVE_SEARCH_ENDPOINT);
260
381
  url.searchParams.set("q", params.query);
@@ -267,85 +388,315 @@ export function buildBraveSearchUrl(params: {
267
388
  return url;
268
389
  }
269
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
+
270
426
  function redactSearchSecret(text: string, apiKey: string): string {
271
427
  return apiKey ? text.split(apiKey).join("[REDACTED]") : text;
272
428
  }
273
429
 
274
- export async function fetchBraveSearchJson(url: URL, apiKey: string, signal?: AbortSignal): Promise<BraveWebSearchResponse> {
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
+ }
275
496
  const controller = new AbortController();
276
- const timeout = setTimeout(() => controller.abort(new Error("Brave search timed out")), SEARCH_REQUEST_TIMEOUT_MS);
277
- const abort = () => controller.abort(signal?.reason ?? new Error("Brave search cancelled"));
278
- signal?.addEventListener("abort", abort, { once: true });
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 });
279
500
  try {
280
- const response = await fetch(url, {
281
- headers: {
282
- Accept: "application/json",
283
- "X-Subscription-Token": apiKey,
284
- },
501
+ const response = await fetch(options.request, {
502
+ ...(options.init ?? {}),
285
503
  signal: controller.signal,
286
504
  });
287
505
  const text = await response.text();
288
506
  if (!response.ok) {
289
- const errorPreview = cleanSearchText(redactSearchSecret(text, apiKey), 300);
290
- throw new Error(`Brave search failed with HTTP ${response.status}: ${errorPreview ? redactSearchSecret(errorPreview, apiKey) : response.statusText}`);
507
+ throw new Error(formatSearchHttpError(options.provider, response.status, response.statusText, text, options.apiKey));
291
508
  }
292
509
  try {
293
- return JSON.parse(text) as BraveWebSearchResponse;
510
+ return JSON.parse(text) as T;
294
511
  } catch (error) {
295
- throw new Error(`Brave search returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
512
+ throw new Error(`${options.invalidJsonMessage}: ${error instanceof Error ? error.message : String(error)}`);
296
513
  }
297
514
  } finally {
298
515
  clearTimeout(timeout);
299
- signal?.removeEventListener("abort", abort);
516
+ options.signal?.removeEventListener("abort", abort);
300
517
  }
301
518
  }
302
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
+
303
647
  export function createAgentBrowserWebSearchTool(configState: AgentBrowserConfigState) {
648
+ const requestGate = new WebSearchRequestGate();
304
649
  return defineTool({
305
650
  name: AGENT_BROWSER_WEB_SEARCH_TOOL_NAME,
306
651
  label: "Agent Browser Web Search",
307
- description: `Search the web with Brave Search when configured. Returns up to ${MAX_SEARCH_RESULT_COUNT} concise web results.`,
308
- promptSnippet: "Search the live web with Brave Search for current or external web information.",
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.",
309
654
  promptGuidelines: [
310
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.",
311
- "Prefer agent_browser_web_search over opening a search engine results page with agent_browser when a quick result list is enough.",
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.",
312
659
  "After using agent_browser_web_search, cite result URLs in the final answer when web evidence informed the answer.",
313
660
  ],
314
661
  parameters: AgentBrowserWebSearchParams,
315
662
  async execute(_toolCallId, params, signal) {
316
- const resolvedCredential = await resolveBraveApiKey(configState, { signal });
317
- if (!resolvedCredential) {
318
- throw new Error("Brave Search credential source is configured but did not resolve. Run pi-agent-browser-config web-search status for setup details.");
663
+ if (!configState.webSearchEnabled) {
664
+ throw new Error("agent_browser_web_search is disabled by pi-agent-browser-native config.");
319
665
  }
666
+ const requestedProvider = params.provider ?? "auto";
667
+ const resolved = await resolvePreferredWebSearchCredential(configState, { provider: requestedProvider, signal });
668
+ if (!resolved) throw new Error(buildMissingCredentialError(requestedProvider));
320
669
  const query = params.query.trim();
321
670
  if (!query) throw new Error("query must not be blank");
322
671
  const count = Math.min(Math.max(params.count ?? DEFAULT_SEARCH_RESULT_COUNT, 1), MAX_SEARCH_RESULT_COUNT);
323
672
  const offset = Math.max(params.offset ?? 0, 0);
324
- const url = buildBraveSearchUrl({
325
- query,
673
+ const adapter = getWebSearchProviderAdapter(resolved.provider);
674
+ const executionParams: WebSearchExecutionParams = {
675
+ country: params.country,
326
676
  count,
677
+ freshness: params.freshness,
327
678
  offset,
328
- country: params.country,
329
- searchLang: params.searchLang,
679
+ query,
330
680
  safesearch: params.safesearch,
331
- freshness: params.freshness,
332
- });
333
- const data = await fetchBraveSearchJson(url, resolvedCredential.value, signal);
334
- const results = (data.web?.results ?? [])
335
- .map(normalizeSearchResult)
336
- .filter((result): result is NormalizedSearchResult => Boolean(result));
337
- const returnedQuery = cleanSearchText(data.query?.altered, 300) ?? cleanSearchText(data.query?.original, 300) ?? query;
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
+ };
338
697
  return {
339
- content: [{ type: "text", text: formatSearchResults(returnedQuery, results) }],
340
- details: {
341
- provider: "brave",
342
- query,
343
- returnedQuery,
344
- count,
345
- offset,
346
- fetchedAt: new Date().toISOString(),
347
- results,
348
- },
698
+ content: [{ type: "text", text: formatSearchResults(adapter.provider, normalized.returnedQuery, normalized.results) }],
699
+ details,
349
700
  };
350
701
  },
351
702
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-browser-native",
3
- "version": "0.2.40",
3
+ "version": "0.2.42",
4
4
  "description": "pi extension that exposes agent-browser as a native tool for browser automation",
5
5
  "type": "module",
6
6
  "author": "Mitch Fultz (https://github.com/fitchmultz)",