Skip to content

Commit 98d9213

Browse files
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 and zod schema validation - Auto-detection via EXA_API_KEY env var - Configurable contents and maxChars options Co-Authored-By: unknown <>
1 parent ec8d4f8 commit 98d9213

3 files changed

Lines changed: 185 additions & 6 deletions

File tree

src/agents/tools/web-search.ts

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
writeCache,
1818
} from "./web-shared.js";
1919

20-
const SEARCH_PROVIDERS = ["brave", "perplexity", "grok"] as const;
20+
const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "exa"] as const;
2121
const DEFAULT_SEARCH_COUNT = 5;
2222
const MAX_SEARCH_COUNT = 10;
2323

@@ -36,6 +36,9 @@ const ANTHROPIC_MESSAGES_ENDPOINT =
3636
"/v1/messages";
3737
const DEFAULT_ANTHROPIC_SEARCH_MODEL = "claude-sonnet-4-5-20250929";
3838

39+
const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
40+
const DEFAULT_EXA_MAX_CHARS = 1500;
41+
3942
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
4043
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
4144
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
@@ -106,6 +109,13 @@ type GrokConfig = {
106109
inlineCitations?: boolean;
107110
};
108111

112+
type ExaConfig = {
113+
apiKey?: string;
114+
contents?: boolean;
115+
maxChars?: number;
116+
};
117+
118+
109119
type GrokSearchResponse = {
110120
output_text?: string;
111121
citations?: string[];
@@ -179,6 +189,14 @@ function resolveSearchApiKey(search?: WebSearchConfig): string | undefined {
179189
}
180190

181191
function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
192+
if (provider === "exa") {
193+
return {
194+
error: "missing_exa_api_key",
195+
message:
196+
"web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.",
197+
docs: "https://docs.openclaw.ai/tools/web",
198+
};
199+
}
182200
if (provider === "perplexity") {
183201
return {
184202
error: "missing_perplexity_api_key",
@@ -338,6 +356,41 @@ function resolveGrokInlineCitations(grok?: GrokConfig): boolean {
338356
return grok?.inlineCitations === true;
339357
}
340358

359+
function resolveExaConfig(search?: WebSearchConfig): ExaConfig {
360+
if (!search || typeof search !== "object") {
361+
return {};
362+
}
363+
const exa = "exa" in search ? search.exa : undefined;
364+
if (!exa || typeof exa !== "object") {
365+
return {};
366+
}
367+
return exa as ExaConfig;
368+
}
369+
370+
function resolveExaApiKey(exa?: ExaConfig): string | undefined {
371+
const fromConfig = normalizeApiKey(exa?.apiKey);
372+
if (fromConfig) {
373+
return fromConfig;
374+
}
375+
const fromEnv = normalizeApiKey(process.env.EXA_API_KEY);
376+
return fromEnv || undefined;
377+
}
378+
379+
function resolveExaContents(exa?: ExaConfig): boolean {
380+
if (exa && typeof exa.contents === "boolean") {
381+
return exa.contents;
382+
}
383+
return true;
384+
}
385+
386+
function resolveExaMaxChars(exa?: ExaConfig): number {
387+
if (exa && typeof exa.maxChars === "number" && exa.maxChars > 0) {
388+
return exa.maxChars;
389+
}
390+
return DEFAULT_EXA_MAX_CHARS;
391+
}
392+
393+
341394
function resolveAnthropicApiKey(): string | undefined {
342395
const fromEnv = normalizeApiKey(process.env.ANTHROPIC_API_KEY);
343396
return fromEnv || undefined;
@@ -567,6 +620,69 @@ async function runGrokSearch(params: {
567620
return { content, citations, inlineCitations };
568621
}
569622

623+
624+
async function runExaSearch(params: {
625+
query: string;
626+
count: number;
627+
apiKey: string;
628+
timeoutSeconds: number;
629+
contents: boolean;
630+
maxChars: number;
631+
}): Promise<{
632+
results: Array<{
633+
title: string;
634+
url: string;
635+
description: string;
636+
published?: string;
637+
}>;
638+
}> {
639+
const body: Record<string, unknown> = {
640+
query: params.query,
641+
numResults: params.count,
642+
type: "auto",
643+
};
644+
if (params.contents) {
645+
body.contents = {
646+
text: { maxCharacters: params.maxChars },
647+
};
648+
}
649+
650+
const res = await fetch(EXA_SEARCH_ENDPOINT, {
651+
method: "POST",
652+
headers: {
653+
"Content-Type": "application/json",
654+
"x-api-key": params.apiKey,
655+
"x-exa-integration": "openclaw",
656+
},
657+
body: JSON.stringify(body),
658+
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
659+
});
660+
661+
if (!res.ok) {
662+
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
663+
const detail = detailResult.text;
664+
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
665+
}
666+
667+
const data = (await res.json()) as {
668+
results?: Array<{
669+
title?: string;
670+
url?: string;
671+
text?: string;
672+
publishedDate?: string;
673+
}>;
674+
};
675+
676+
return {
677+
results: (data.results ?? []).map((r) => ({
678+
title: r.title ?? "",
679+
url: r.url ?? "",
680+
description: r.text ?? "",
681+
published: r.publishedDate ?? undefined,
682+
})),
683+
};
684+
}
685+
570686
async function runWebSearch(params: {
571687
query: string;
572688
count: number;
@@ -582,9 +698,13 @@ async function runWebSearch(params: {
582698
perplexityModel?: string;
583699
grokModel?: string;
584700
grokInlineCitations?: boolean;
701+
exaContents?: boolean;
702+
exaMaxChars?: number;
585703
}): Promise<Record<string, unknown>> {
586704
const cacheKey = normalizeCacheKey(
587-
params.provider === "brave"
705+
params.provider === "exa"
706+
? `${params.provider}:${params.query}:${params.count}:${String(params.exaContents ?? true)}:${params.exaMaxChars ?? DEFAULT_EXA_MAX_CHARS}`
707+
: params.provider === "brave"
588708
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
589709
: params.provider === "perplexity"
590710
? `${params.provider}:${params.query}:${params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}`
@@ -639,6 +759,41 @@ async function runWebSearch(params: {
639759
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
640760
return payload;
641761
}
762+
if (params.provider === "exa") {
763+
const exaResult = await runExaSearch({
764+
query: params.query,
765+
count: params.count,
766+
apiKey: params.apiKey,
767+
timeoutSeconds: params.timeoutSeconds,
768+
contents: params.exaContents ?? true,
769+
maxChars: params.exaMaxChars ?? DEFAULT_EXA_MAX_CHARS,
770+
});
771+
772+
const mapped = exaResult.results.map((entry) => ({
773+
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
774+
url: entry.url,
775+
description: entry.description ? wrapWebContent(entry.description, "web_search") : "",
776+
published: entry.published || undefined,
777+
siteName: resolveSiteName(entry.url) || undefined,
778+
}));
779+
780+
const payload = {
781+
query: params.query,
782+
provider: params.provider,
783+
count: mapped.length,
784+
tookMs: Date.now() - start,
785+
externalContent: {
786+
untrusted: true,
787+
source: "web_search",
788+
provider: params.provider,
789+
wrapped: true,
790+
},
791+
results: mapped,
792+
};
793+
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
794+
return payload;
795+
}
796+
642797

643798
if (params.provider !== "brave") {
644799
throw new Error("Unsupported web search provider.");
@@ -711,10 +866,14 @@ export function createWebSearchTool(options?: {
711866
}
712867

713868
const provider = resolveSearchProvider(search);
869+
const exaConfig = resolveExaConfig(search);
714870
const perplexityConfig = resolvePerplexityConfig(search);
715871
const grokConfig = resolveGrokConfig(search);
716872

717873
const description =
874+
provider === "exa"
875+
? "Search the web using Exa. Returns structured results with optional page text."
876+
: const description =
718877
provider === "perplexity"
719878
? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
720879
: provider === "grok"
@@ -734,7 +893,9 @@ export function createWebSearchTool(options?: {
734893
? perplexityAuth?.apiKey
735894
: provider === "grok"
736895
? resolveGrokApiKey(grokConfig)
737-
: resolveSearchApiKey(search);
896+
: provider === "exa"
897+
? resolveExaApiKey(exaConfig)
898+
: resolveSearchApiKey(search);
738899

739900
if (!apiKey) {
740901
const anthropicKey = resolveAnthropicApiKey();
@@ -827,6 +988,8 @@ export function createWebSearchTool(options?: {
827988
perplexityModel: resolvePerplexityModel(perplexityConfig),
828989
grokModel: resolveGrokModel(grokConfig),
829990
grokInlineCitations: resolveGrokInlineCitations(grokConfig),
991+
exaContents: resolveExaContents(exaConfig),
992+
exaMaxChars: resolveExaMaxChars(exaConfig),
830993
});
831994
return jsonResult(result);
832995
},
@@ -840,4 +1003,7 @@ export const __testing = {
8401003
resolveGrokApiKey,
8411004
resolveGrokModel,
8421005
resolveGrokInlineCitations,
1006+
resolveExaApiKey,
1007+
resolveExaContents,
1008+
resolveExaMaxChars,
8431009
} as const;

src/config/types.tools.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,8 @@ export type ToolsConfig = {
336336
search?: {
337337
/** Enable web search tool (default: true when API key is present). */
338338
enabled?: boolean;
339-
/** Search provider ("brave", "perplexity", or "grok"). */
340-
provider?: "brave" | "perplexity" | "grok";
339+
/** Search provider ("brave", "perplexity", "grok", or "exa"). */
340+
provider?: "brave" | "perplexity" | "grok" | "exa";
341341
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
342342
apiKey?: string;
343343
/** Default search results count (1-10). */
@@ -364,6 +364,11 @@ export type ToolsConfig = {
364364
/** Include inline citations in response text as markdown links (default: false). */
365365
inlineCitations?: boolean;
366366
};
367+
exa?: {
368+
apiKey?: string;
369+
contents?: boolean;
370+
maxChars?: number;
371+
};
367372
};
368373
fetch?: {
369374
/** Enable web fetch tool (default: true). */

src/config/zod-schema.agent-runtime.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
171171
export const ToolsWebSearchSchema = z
172172
.object({
173173
enabled: z.boolean().optional(),
174-
provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok")]).optional(),
174+
provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok"), z.literal("exa")]).optional(),
175175
apiKey: z.string().optional(),
176176
maxResults: z.number().int().positive().optional(),
177177
timeoutSeconds: z.number().int().positive().optional(),
@@ -192,6 +192,14 @@ export const ToolsWebSearchSchema = z
192192
})
193193
.strict()
194194
.optional(),
195+
exa: z
196+
.object({
197+
apiKey: z.string().optional().register(sensitive),
198+
contents: z.boolean().optional(),
199+
maxChars: z.number().int().positive().optional(),
200+
})
201+
.strict()
202+
.optional(),
195203
})
196204
.strict()
197205
.optional();

0 commit comments

Comments
 (0)