From f6e4874fc1ce50e83d5063332afab0f0c56b7d04 Mon Sep 17 00:00:00 2001 From: Tanishq <30299564+10ishq@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:03:00 +0000 Subject: [PATCH] feat(web-search): add Exa as a web search provider Adds Exa (https://exa.ai) as a web search provider alongside Brave, Perplexity, and Grok. - runExaSearch with x-exa-integration header for usage tracking - Config types, zod schema, and field help text - Auto-detection via EXA_API_KEY env var - Configurable contents and maxChars options Co-Authored-By: unknown <> --- src/agents/tools/web-search.ts | 172 ++++++++++++++++++++++++- src/config/schema.help.ts | 5 + src/config/types.tools.ts | 9 +- src/config/zod-schema.agent-runtime.ts | 10 +- 4 files changed, 190 insertions(+), 6 deletions(-) diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 3f1c585ea6c4f..06884e6a607cf 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -18,7 +18,7 @@ import { writeCache, } from "./web-shared.js"; -const SEARCH_PROVIDERS = ["brave", "perplexity", "grok"] as const; +const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "exa"] as const; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; @@ -32,6 +32,9 @@ const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; const DEFAULT_GROK_MODEL = "grok-4-1-fast"; +const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; +const DEFAULT_EXA_MAX_CHARS = 1500; + const SEARCH_CACHE = new Map>>(); const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; @@ -102,6 +105,13 @@ type GrokConfig = { inlineCitations?: boolean; }; +type ExaConfig = { + apiKey?: string; + contents?: boolean; + maxChars?: number; +}; + + type GrokSearchResponse = { output?: Array<{ type?: string; @@ -211,6 +221,14 @@ function resolveSearchApiKey(search?: WebSearchConfig): string | undefined { } function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { + if (provider === "exa") { + return { + error: "missing_exa_api_key", + message: + "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } if (provider === "perplexity") { return { error: "missing_perplexity_api_key", @@ -389,6 +407,41 @@ function resolveGrokInlineCitations(grok?: GrokConfig): boolean { return grok?.inlineCitations === true; } +function resolveExaConfig(search?: WebSearchConfig): ExaConfig { + if (!search || typeof search !== "object") { + return {}; + } + const exa = "exa" in search ? search.exa : undefined; + if (!exa || typeof exa !== "object") { + return {}; + } + return exa as ExaConfig; +} + +function resolveExaApiKey(exa?: ExaConfig): string | undefined { + const fromConfig = normalizeApiKey(exa?.apiKey); + if (fromConfig) { + return fromConfig; + } + const fromEnv = normalizeApiKey(process.env.EXA_API_KEY); + return fromEnv || undefined; +} + +function resolveExaContents(exa?: ExaConfig): boolean { + if (exa && typeof exa.contents === "boolean") { + return exa.contents; + } + return true; +} + +function resolveExaMaxChars(exa?: ExaConfig): number { + if (exa && typeof exa.maxChars === "number" && exa.maxChars > 0) { + return exa.maxChars; + } + return DEFAULT_EXA_MAX_CHARS; +} + + function resolveSearchCount(value: unknown, fallback: number): number { const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback; const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed))); @@ -573,6 +626,69 @@ async function runGrokSearch(params: { return { content, citations, inlineCitations }; } + +async function runExaSearch(params: { + query: string; + count: number; + apiKey: string; + timeoutSeconds: number; + contents: boolean; + maxChars: number; +}): Promise<{ + results: Array<{ + title: string; + url: string; + description: string; + published?: string; + }>; +}> { + const body: Record = { + query: params.query, + numResults: params.count, + type: "auto", + }; + if (params.contents) { + body.contents = { + text: { maxCharacters: params.maxChars }, + }; + } + + const res = await fetch(EXA_SEARCH_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": params.apiKey, + "x-exa-integration": "openclaw", + }, + body: JSON.stringify(body), + signal: withTimeout(undefined, params.timeoutSeconds * 1000), + }); + + if (!res.ok) { + const detailResult = await readResponseText(res, { maxBytes: 64_000 }); + const detail = detailResult.text; + throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as { + results?: Array<{ + title?: string; + url?: string; + text?: string; + publishedDate?: string; + }>; + }; + + return { + results: (data.results ?? []).map((r) => ({ + title: r.title ?? "", + url: r.url ?? "", + description: r.text ?? "", + published: r.publishedDate ?? undefined, + })), + }; +} + async function runWebSearch(params: { query: string; count: number; @@ -588,9 +704,13 @@ async function runWebSearch(params: { perplexityModel?: string; grokModel?: string; grokInlineCitations?: boolean; + exaContents?: boolean; + exaMaxChars?: number; }): Promise> { const cacheKey = normalizeCacheKey( - params.provider === "brave" + params.provider === "exa" + ? `${params.provider}:${params.query}:${params.count}:${String(params.exaContents ?? true)}:${params.exaMaxChars ?? DEFAULT_EXA_MAX_CHARS}` + : params.provider === "brave" ? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}` : params.provider === "perplexity" ? `${params.provider}:${params.query}:${params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}:${params.freshness || "default"}` @@ -658,6 +778,41 @@ async function runWebSearch(params: { writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); return payload; } + if (params.provider === "exa") { + const exaResult = await runExaSearch({ + query: params.query, + count: params.count, + apiKey: params.apiKey, + timeoutSeconds: params.timeoutSeconds, + contents: params.exaContents ?? true, + maxChars: params.exaMaxChars ?? DEFAULT_EXA_MAX_CHARS, + }); + + const mapped = exaResult.results.map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url, + description: entry.description ? wrapWebContent(entry.description, "web_search") : "", + published: entry.published || undefined, + siteName: resolveSiteName(entry.url) || undefined, + })); + + const payload = { + query: params.query, + provider: params.provider, + count: mapped.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: params.provider, + wrapped: true, + }, + results: mapped, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + if (params.provider !== "brave") { throw new Error("Unsupported web search provider."); @@ -737,10 +892,14 @@ export function createWebSearchTool(options?: { } const provider = resolveSearchProvider(search); + const exaConfig = resolveExaConfig(search); const perplexityConfig = resolvePerplexityConfig(search); const grokConfig = resolveGrokConfig(search); const description = + provider === "exa" + ? "Search the web using Exa. Returns structured results with optional page text." + : const description = provider === "perplexity" ? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search." : provider === "grok" @@ -760,7 +919,9 @@ export function createWebSearchTool(options?: { ? perplexityAuth?.apiKey : provider === "grok" ? resolveGrokApiKey(grokConfig) - : resolveSearchApiKey(search); + : provider === "exa" + ? resolveExaApiKey(exaConfig) + : resolveSearchApiKey(search); if (!apiKey) { return jsonResult(missingSearchKeyPayload(provider)); @@ -808,6 +969,8 @@ export function createWebSearchTool(options?: { perplexityModel: resolvePerplexityModel(perplexityConfig), grokModel: resolveGrokModel(grokConfig), grokInlineCitations: resolveGrokInlineCitations(grokConfig), + exaContents: resolveExaContents(exaConfig), + exaMaxChars: resolveExaMaxChars(exaConfig), }); return jsonResult(result); }, @@ -825,4 +988,7 @@ export const __testing = { resolveGrokModel, resolveGrokInlineCitations, extractGrokContent, + resolveExaApiKey, + resolveExaContents, + resolveExaMaxChars, } as const; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 75f6bb82062e2..4b3cd28c52297 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -117,6 +117,11 @@ export const FIELD_HELP: Record = { "tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.exa.apiKey": "Exa API key (fallback: EXA_API_KEY env var).", + "tools.web.search.exa.contents": + "Include page text in results; when false, only URLs and titles are returned (default: true).", + "tools.web.search.exa.maxChars": + "Max characters of page text per result; higher values provide more context but use more tokens (default: 1500).", "tools.web.search.perplexity.apiKey": "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", "tools.web.search.perplexity.baseUrl": diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index f8ad8dc1d4461..4cee89f99273c 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -389,8 +389,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave", "perplexity", or "grok"). */ - provider?: "brave" | "perplexity" | "grok"; + /** Search provider ("brave", "perplexity", "grok", or "exa"). */ + provider?: "brave" | "perplexity" | "grok" | "exa"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: string; /** Default search results count (1-10). */ @@ -417,6 +417,11 @@ export type ToolsConfig = { /** Include inline citations in response text as markdown links (default: false). */ inlineCitations?: boolean; }; + exa?: { + apiKey?: string; + contents?: boolean; + maxChars?: number; + }; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 6e0a92cfd6801..23f46d2935740 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -239,7 +239,7 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), - provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok")]).optional(), + provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok"), z.literal("exa")]).optional(), apiKey: z.string().optional().register(sensitive), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), @@ -260,6 +260,14 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + exa: z + .object({ + apiKey: z.string().optional().register(sensitive), + contents: z.boolean().optional(), + maxChars: z.number().int().positive().optional(), + }) + .strict() + .optional(), }) .strict() .optional();