Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/cli/src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ async function executeRunCommand(task: string, options: any): Promise<void> {
}

// Create WebAgent
const searchProvider = options.searchProvider ?? cfg.search_provider;
const webAgent = new WebAgent(browser, {
debug: debugMode,
vision: options.vision ?? cfg.vision,
Expand All @@ -341,8 +342,15 @@ async function executeRunCommand(task: string, options: any): Promise<void> {
initialNavigationRetries: options.initialNavigationRetries ?? cfg.initial_navigation_retries,
maxConsecutiveErrors: options.maxConsecutiveErrors ?? cfg.max_consecutive_errors,
maxTotalErrors: options.maxTotalErrors ?? cfg.max_total_errors,
searchProvider: options.searchProvider ?? cfg.search_provider,
searchApiKey: cfg.parallel_api_key,
searchProvider,
// Only pass a key for providers that use one; browser providers and
// "none" don't, so we avoid threading an unrelated key through config.
searchApiKey:
searchProvider === "exa-api"
? cfg.exa_api_key
: searchProvider === "parallel-api"
? cfg.parallel_api_key
: undefined,
tabstackApiKey: options.tabstackApiKey ?? cfg.tabstack_api_key,
tabstackApiUrl: options.tabstackApiUrl ?? cfg.tabstack_api_url,
trustedHostnames: options.trustedHostnames ?? cfg.trusted_hostnames,
Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,14 @@ export type ReasoningLevel = (typeof REASONING_LEVELS)[number];
export const LOGGERS = ["console", "json"] as const;
export type LoggerType = (typeof LOGGERS)[number];

export const SEARCH_PROVIDERS = ["none", "duckduckgo", "google", "bing", "parallel-api"] as const;
export const SEARCH_PROVIDERS = [
"none",
"duckduckgo",
"google",
"bing",
"parallel-api",
"exa-api",
] as const;
export type SearchProviderName = (typeof SEARCH_PROVIDERS)[number];

export type ConfigFieldType = "string" | "string[]" | "number" | "boolean" | "enum";
Expand Down Expand Up @@ -136,6 +143,7 @@ export interface PiloConfig {
// Search Configuration
search_provider?: SearchProviderName;
parallel_api_key?: string;
exa_api_key?: string;

// Tabstack Configuration
tabstack_api_key?: string;
Expand Down Expand Up @@ -215,6 +223,7 @@ export interface PiloConfigResolved {
// Search Configuration
search_provider: SearchProviderName;
parallel_api_key?: string;
exa_api_key?: string;

// Tabstack Configuration
tabstack_api_key?: string;
Expand Down Expand Up @@ -733,6 +742,14 @@ export const FIELDS: Record<ConfigKey, FieldDef> = {
description: "Parallel API key for search",
category: "search",
},
exa_api_key: {
type: "string",
cli: "--exa-api-key",
placeholder: "key",
env: ["EXA_API_KEY"],
description: "Exa API key for search",
category: "search",
},

// Tabstack Configuration
tabstack_api_key: {
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/search/debugPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Debug helpers for search providers.
*/

const MAX_STRING_LEN = 120;

/**
* Deep-clone a value for debug logging, truncating any long string so the
* "flavor" of a response (text, summaries, snippets, etc.) is visible without
* dumping the full payload. Non-string values pass through unchanged.
*/
export function abbreviateForDebug(value: unknown): unknown {
const json = JSON.stringify(value, (_key, v) =>
typeof v === "string" && v.length > MAX_STRING_LEN ? `${v.slice(0, MAX_STRING_LEN)}…` : v,
);
return json === undefined ? value : JSON.parse(json);
}
108 changes: 108 additions & 0 deletions packages/core/src/search/providers/exaSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Exa API Search Provider
*
* API-based search provider that uses the Exa API for search.
* Returns results formatted as markdown for consistency with browser providers.
*/

import type { AriaBrowser } from "../../browser/ariaBrowser.js";
import type { SearchProvider } from "../searchProvider.js";
import {
wrapExternalContentWithWarning,
ExternalContentLabel,
} from "../../utils/promptSecurity.js";
import { abbreviateForDebug } from "../debugPreview.js";

interface ExaSearchResult {
url: string;
title?: string;
highlights?: string[];
}

interface ExaApiResponse {
results?: ExaSearchResult[];
}

export class ExaSearchProvider implements SearchProvider {
readonly name = "exa-api";
readonly requiresBrowser = false;

constructor(
private apiKey: string,
private debug = false,
) {}

async search(query: string, _browser?: AriaBrowser): Promise<string> {
const url = "https://api.exa.ai/search";
const body = JSON.stringify({
query,
// Opt into highlights, or Exa returns metadata only (no snippets).
contents: { highlights: { maxCharacters: 1500 } },
});

if (this.debug) {
// Log the exact outbound request body (sans API key) so the query and
// contents options are observable. Matches the [X:debug] console.warn convention.
console.warn(`[ExaSearch:debug] POST ${url}`, body);
}

const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.apiKey,
},
body,
});

if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error");
throw new Error(`Exa API error (${response.status}): ${errorText}`);
}

const data = (await response.json()) as ExaApiResponse;

if (this.debug) {
// Log the count plus an abbreviated sample of the first result so all
// returned fields (including ones we don't map, like summary/score/
// publishedDate) are visible, with long strings truncated.
const results = data.results ?? [];
console.warn(
`[ExaSearch:debug] response: ${results.length} result(s), sample:`,
abbreviateForDebug(results[0]),
);
}

return this.formatAsMarkdown(query, data);
}

private formatAsMarkdown(query: string, data: ExaApiResponse): string {
const header = `# Search Results for "${query}" (via ${this.name})`;

let wrapped: string;
if (!data.results || data.results.length === 0) {
wrapped = wrapExternalContentWithWarning(
`${header}\n\nNo results found.`,
ExternalContentLabel.SearchResults,
);
} else {
const lines: string[] = [];

data.results.forEach((result, index) => {
const title = result.title || result.url;
lines.push(`${index + 1}. [${title}](${result.url})`);
if (result.highlights?.length) {
lines.push(result.highlights.join("\n"));
}
lines.push("");
});

wrapped = wrapExternalContentWithWarning(
`${header}\n\n${lines.join("\n").trim()}`,
ExternalContentLabel.SearchResults,
);
}

return wrapped;
}
}
37 changes: 30 additions & 7 deletions packages/core/src/search/providers/parallelSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
wrapExternalContentWithWarning,
ExternalContentLabel,
} from "../../utils/promptSecurity.js";
import { abbreviateForDebug } from "../debugPreview.js";

interface ParallelSearchResult {
url: string;
Expand All @@ -27,21 +28,33 @@ export class ParallelSearchProvider implements SearchProvider {
readonly name = "parallel-api";
readonly requiresBrowser = false;

constructor(private apiKey: string) {}
constructor(
private apiKey: string,
private debug = false,
) {}

async search(query: string, _browser?: AriaBrowser): Promise<string> {
const response = await fetch("https://api.parallel.ai/v1beta/search", {
const url = "https://api.parallel.ai/v1beta/search";
const body = JSON.stringify({
objective: query,
search_queries: [query],
excerpts: { max_chars_per_result: 1500 },
});

if (this.debug) {
// Log the exact outbound request body (sans API key) so the query and
// options are observable. Matches the [X:debug] console.warn convention.
console.warn(`[ParallelSearch:debug] POST ${url}`, body);
}

const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.apiKey,
"parallel-beta": "search-extract-2025-10-10",
},
body: JSON.stringify({
objective: query,
search_queries: [query],
excerpts: { max_chars_per_result: 1500 },
}),
body,
});

if (!response.ok) {
Expand All @@ -55,6 +68,16 @@ export class ParallelSearchProvider implements SearchProvider {
throw new Error(`Parallel API error: ${data.error}`);
}

if (this.debug) {
// Log the count plus an abbreviated sample of the first result so all
// returned fields are visible, with long strings truncated.
const results = data.results ?? [];
console.warn(
`[ParallelSearch:debug] response: ${results.length} result(s), sample:`,
abbreviateForDebug(results[0]),
);
}

return this.formatAsMarkdown(query, data);
}

Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/search/searchProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface SearchProvider {
export interface CreateSearchProviderOptions {
/** API key for providers that require authentication (e.g., Parallel) */
apiKey?: string;
/** When true, API providers log their outbound request at debug level */
debug?: boolean;
}

/**
Expand Down Expand Up @@ -49,7 +51,14 @@ export async function createSearchProvider(
throw new Error("Parallel API key is required for parallel-api search provider");
}
const { ParallelSearchProvider } = await import("./providers/parallelSearch.js");
return new ParallelSearchProvider(options.apiKey);
return new ParallelSearchProvider(options.apiKey, options.debug);
}
case "exa-api": {
if (!options.apiKey) {
throw new Error("Exa API key is required for exa-api search provider");
}
const { ExaSearchProvider } = await import("./providers/exaSearch.js");
return new ExaSearchProvider(options.apiKey, options.debug);
}
default:
throw new Error(`Unknown search provider: ${providerName}`);
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/webAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,10 @@ export class WebAgent {
throw new Error("parallel_api_key is required when search_provider is 'parallel-api'");
}

if (this.searchProvider === "exa-api" && !this.searchApiKey) {
throw new Error("exa_api_key is required when search_provider is 'exa-api'");
}

// Initialize services
this.compressor = new SnapshotCompressor();
this.eventEmitter = options.eventEmitter ?? new WebAgentEventEmitter();
Expand Down Expand Up @@ -420,6 +424,7 @@ export class WebAgent {
if (this.searchProvider !== "none") {
this.searchService = await SearchService.create(this.searchProvider, this.browser, {
apiKey: this.searchApiKey,
debug: this.debug,
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ describe("ConfigManager", () => {
"unsafe_mode",
"search_provider",
"parallel_api_key",
"exa_api_key",
"tabstack_api_key",
"tabstack_api_url",
"upload_allowed_paths",
Expand Down
Loading
Loading