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.
- package/CHANGELOG.md +30 -1
- package/README.md +71 -18
- package/docs/ARCHITECTURE.md +8 -8
- package/docs/COMMAND_REFERENCE.md +54 -18
- package/docs/RELEASE.md +1 -1
- package/docs/SUPPORT_MATRIX.md +13 -11
- package/docs/TOOL_CONTRACT.md +39 -13
- package/extensions/agent-browser/index.ts +3 -2
- package/extensions/agent-browser/lib/config-policy.js +679 -0
- package/extensions/agent-browser/lib/config.ts +103 -351
- package/extensions/agent-browser/lib/input-modes/params.ts +1 -1
- package/extensions/agent-browser/lib/launch-scoped-flags.ts +67 -0
- package/extensions/agent-browser/lib/playbook.ts +28 -17
- package/extensions/agent-browser/lib/results/presentation/browser-profile-recovery.ts +67 -0
- package/extensions/agent-browser/lib/results/presentation/errors.ts +4 -0
- package/extensions/agent-browser/lib/runtime.ts +6 -73
- package/extensions/agent-browser/lib/web-search.ts +403 -52
- package/package.json +1 -1
- package/scripts/config.mjs +190 -106
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Purpose: Provide the optional
|
|
3
|
-
* Responsibilities: Define strict search input schema, resolve
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
353
|
+
return `No ${providerLabel} web results found for: ${query}`;
|
|
237
354
|
}
|
|
238
|
-
const lines = [
|
|
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?:
|
|
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
|
-
|
|
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(
|
|
277
|
-
const abort = () => controller.abort(signal?.reason ?? new Error(
|
|
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(
|
|
281
|
-
|
|
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
|
-
|
|
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
|
|
510
|
+
return JSON.parse(text) as T;
|
|
294
511
|
} catch (error) {
|
|
295
|
-
throw new 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
|
|
308
|
-
promptSnippet: "Search the live web with Brave
|
|
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
|
-
"
|
|
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
|
-
|
|
317
|
-
|
|
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
|
|
325
|
-
|
|
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
|
-
|
|
329
|
-
searchLang: params.searchLang,
|
|
679
|
+
query,
|
|
330
680
|
safesearch: params.safesearch,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const
|
|
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