From fd803a8683b80f0b339e7ff38f05249130f56c30 Mon Sep 17 00:00:00 2001 From: Teo Gonzalez Date: Wed, 15 Apr 2026 08:38:56 -0700 Subject: [PATCH 1/2] feat: add Exa AI-powered search provider Adds `createExaSearchProvider` to `@agentrail/capabilities` alongside the existing Tavily, Brave, and Jina adapters. Wires the new provider through the YAML config (`search.exaApiKey`), the `DeepResearchRuntimeConfig`, the `agentrail doctor` env-var check, and the playground/deep-research examples. - Maps Exa `/search` results into the standard `WebSearchResult` shape (title, url, snippet, publishedAt) with a highlights -> summary -> text snippet fallback. - Exposes optional search-type, category, domain, text, date, and user location filters on the provider factory. - Sets `x-exa-integration: agentrail` on every outbound request. - Tests: 4 new provider tests (success, snippet fallback, filter forwarding, non-2xx error) plus a deep-research wiring test and a config test. Signed-off-by: Teo Gonzalez --- .changeset/exa-search-provider.md | 26 ++++ CONTRIBUTING.md | 1 + config/agentrail.yaml.example | 2 + docs/guides/deployment.md | 1 + docs/tools/web-search.md | 24 +++- docs/zh/guides/deployment.md | 1 + examples/deep-research/src/routes/run.ts | 1 + .../src/chat/deep-research.ts | 1 + packages/app/src/config/index.ts | 13 +- packages/app/test/config.test.ts | 16 +++ packages/capabilities/src/index.ts | 2 + packages/capabilities/src/tools/index.ts | 2 + packages/capabilities/src/tools/web-search.ts | 97 +++++++++++++ packages/capabilities/test/web-search.test.ts | 128 ++++++++++++++++++ packages/cli/src/commands/doctor.ts | 12 ++ packages/deep-research/src/runtime.ts | 1 + packages/deep-research/src/tools.ts | 5 + packages/deep-research/test/tools.test.ts | 42 ++++++ 18 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 .changeset/exa-search-provider.md diff --git a/.changeset/exa-search-provider.md b/.changeset/exa-search-provider.md new file mode 100644 index 0000000..f0bce74 --- /dev/null +++ b/.changeset/exa-search-provider.md @@ -0,0 +1,26 @@ +--- +"@agentrail/capabilities": minor +"@agentrail/app": minor +"@agentrail/deep-research": minor +--- + +**Add Exa AI-powered search provider** + +New `createExaSearchProvider` adapter in `@agentrail/capabilities` exposes [Exa](https://exa.ai) as a `WebSearchProvider` alongside Tavily, Brave, and Jina. Exa's AI-powered search returns high-quality results with content snippets (highlights, summaries, or full text) that are normalized into the standard `WebSearchResult` shape. + +### New public exports (`@agentrail/capabilities`) + +- `createExaSearchProvider({ apiKey, ... })` — factory for the Exa adapter +- `ExaSearchProviderOptions` — includes `searchType`, `category`, `includeDomains`, `excludeDomains`, `includeText`, `excludeText`, `startPublishedDate`, `endPublishedDate`, `userLocation`, `timeoutMs` + +### Config (`@agentrail/app`) + +- `AgentrailConfig.search` gains an `exaApiKey` field (defaults to `""`) +- `SharedResolvedAppConfig` exposes the resolved `exaApiKey` alongside the existing provider keys + +### Deep research (`@agentrail/deep-research`) + +- `DeepResearchRuntimeConfig` gains an optional `exaApiKey` field +- Setting `search.provider: exa` + `search.exaApiKey` (or the `EXA_API_KEY` env var) now selects Exa as the search backend for the deep-research workflow + +All outbound requests set `x-exa-integration: agentrail` so usage can be attributed to this integration by the Exa team. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0c8d7d..de47f55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,7 @@ This file controls non-sensitive runtime settings such as: | `ANTHROPIC_API_KEY` | Anthropic LLM provider key | | `OPENAI_API_KEY` | OpenAI LLM provider key | | `TAVILY_API_KEY` | Tavily search integration key | +| `EXA_API_KEY` | Exa search integration key | Store secrets in environment variables only. Do not add real credentials to `config/agentrail.yaml`. diff --git a/config/agentrail.yaml.example b/config/agentrail.yaml.example index fe51409..b90913d 100644 --- a/config/agentrail.yaml.example +++ b/config/agentrail.yaml.example @@ -21,6 +21,8 @@ search: braveApiKey: "" # Optional Jina Search API key. Default: "". jinaApiKey: "" + # Optional Exa Search API key. Default: "". + exaApiKey: "" # Shared filesystem paths for local development data. paths: diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index b21b761..b295078 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -13,6 +13,7 @@ Never put secrets in config files. Use environment variables for all credentials | `TAVILY_API_KEY` | If using web search | Tavily search integration | | `BRAVE_SEARCH_API_KEY` | If using web search | Brave Search integration | | `JINA_API_KEY` | If using web search | Jina Search integration | +| `EXA_API_KEY` | If using web search | Exa search integration | | `AGENTRAIL_DATA_DIR` | Recommended | Root data directory for sessions, KB, skills | | `AGENTRAIL_CONFIG_PATH` | Optional | Override config file location | | `UI_SECRET_TOKEN` | If UI is public | Bearer token for `/api/*` routes | diff --git a/docs/tools/web-search.md b/docs/tools/web-search.md index 3f74929..86f9320 100644 --- a/docs/tools/web-search.md +++ b/docs/tools/web-search.md @@ -28,16 +28,28 @@ Returns a JSON array of search results as text. The `details` field: ## Providers -`createWebSearchTool(provider, options)` requires a `WebSearchProvider`. Three built-in adapters are available: +`createWebSearchTool(provider, options)` requires a `WebSearchProvider`. Four built-in adapters are available: | Factory | Provider | | ---------------------------------------- | --------------------------------------------- | | `createTavilySearchProvider({ apiKey })` | [Tavily](https://tavily.com) | | `createBraveSearchProvider({ apiKey })` | [Brave Search](https://brave.com/search/api/) | | `createJinaSearchProvider({ apiKey? })` | [Jina AI](https://jina.ai) | +| `createExaSearchProvider({ apiKey })` | [Exa](https://exa.ai) | All adapters accept an optional `timeoutMs` (defaults to `20000`). +The Exa adapter also accepts optional filters and search-type overrides: + +| Option | Description | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `searchType` | `"auto"` (default), `"neural"`, `"fast"`, `"deep-lite"`, `"deep"`, `"deep-reasoning"`, `"instant"` | +| `category` | Restrict to a category (`"news"`, `"research paper"`, `"company"`, etc.) | +| `includeDomains` / `excludeDomains` | Domain allow/deny lists | +| `includeText` / `excludeText` | Require or exclude literal phrases in the result body | +| `startPublishedDate` / `endPublishedDate` | ISO 8601 date range | +| `userLocation` | Two-letter ISO country code | + ## Factory options `createWebSearchTool(provider, options)` accepts: @@ -57,6 +69,16 @@ const webSearch = createWebSearchTool( ); ``` +Or with Exa: + +```ts +import { createWebSearchTool, createExaSearchProvider } from "@agentrail/capabilities"; + +const webSearch = createWebSearchTool( + createExaSearchProvider({ apiKey: process.env.EXA_API_KEY! }), +); +``` + Then pass `webSearch` into a profile's `agent.tools` array. ## Usage notes diff --git a/docs/zh/guides/deployment.md b/docs/zh/guides/deployment.md index 2db7cad..deac2fe 100644 --- a/docs/zh/guides/deployment.md +++ b/docs/zh/guides/deployment.md @@ -13,6 +13,7 @@ | `TAVILY_API_KEY` | 使用 Web Search 时 | Tavily Search | | `BRAVE_SEARCH_API_KEY` | 使用 Web Search 时 | Brave Search | | `JINA_API_KEY` | 使用 Web Search 时 | Jina Search | +| `EXA_API_KEY` | 使用 Web Search 时 | Exa Search | | `AGENTRAIL_DATA_DIR` | 推荐配置 | Session、知识库、Skills 的根目录 | | `AGENTRAIL_CONFIG_PATH` | 可选 | 覆盖配置文件位置 | | `UI_SECRET_TOKEN` | UI 对公网开放时 | `/api/*` 路由的 Bearer Token | diff --git a/examples/deep-research/src/routes/run.ts b/examples/deep-research/src/routes/run.ts index 3c52f7c..8829ad2 100644 --- a/examples/deep-research/src/routes/run.ts +++ b/examples/deep-research/src/routes/run.ts @@ -20,6 +20,7 @@ const run = createDeepResearchRunRoute({ tavilyApiKey: config.tavilyApiKey, braveApiKey: config.braveApiKey, jinaApiKey: config.jinaApiKey, + exaApiKey: config.exaApiKey, sandbox: config.sandbox, orchestration: config.orchestration, }, diff --git a/examples/playground-server/src/chat/deep-research.ts b/examples/playground-server/src/chat/deep-research.ts index 0dd9d56..30f37c9 100644 --- a/examples/playground-server/src/chat/deep-research.ts +++ b/examples/playground-server/src/chat/deep-research.ts @@ -25,6 +25,7 @@ function buildDeepResearchRuntime() { tavilyApiKey: config.tavilyApiKey, braveApiKey: config.braveApiKey, jinaApiKey: config.jinaApiKey, + exaApiKey: config.exaApiKey, sandbox: config.sandbox, orchestration: config.orchestration, }; diff --git a/packages/app/src/config/index.ts b/packages/app/src/config/index.ts index 496091e..7c6312e 100644 --- a/packages/app/src/config/index.ts +++ b/packages/app/src/config/index.ts @@ -79,6 +79,7 @@ export interface AgentrailConfig { tavilyApiKey: string; braveApiKey: string; jinaApiKey: string; + exaApiKey: string; }; paths: { dataDir: string; @@ -171,6 +172,7 @@ export interface SharedResolvedAppConfig { tavilyApiKey?: string; braveApiKey?: string; jinaApiKey?: string; + exaApiKey?: string; dataDir: string; uiSecretToken?: string; sandbox: SandboxRuntimeConfig; @@ -213,6 +215,7 @@ export const DEFAULT_AGENTRAIL_CONFIG: AgentrailConfig = { tavilyApiKey: "", braveApiKey: "", jinaApiKey: "", + exaApiKey: "", }, paths: { dataDir: DEFAULT_DATA_DIR, @@ -447,7 +450,7 @@ export function parseAgentrailConfig(raw: unknown): AgentrailConfig { const search = getObject(root, "search", [], DEFAULT_AGENTRAIL_CONFIG.search as UnknownRecord); assertNoUnknownKeys( search, - ["provider", "tavilyApiKey", "braveApiKey", "jinaApiKey"], + ["provider", "tavilyApiKey", "braveApiKey", "jinaApiKey", "exaApiKey"], ["search"], ); @@ -601,6 +604,12 @@ export function parseAgentrailConfig(raw: unknown): AgentrailConfig { ["search"], DEFAULT_AGENTRAIL_CONFIG.search.jinaApiKey, ), + exaApiKey: getString( + search, + "exaApiKey", + ["search"], + DEFAULT_AGENTRAIL_CONFIG.search.exaApiKey, + ), }, paths: { dataDir: path.resolve( @@ -877,6 +886,7 @@ function resolveSharedFields(config: AgentrailConfig): SharedResolvedAppConfig { const tavilyApiKey = normalizeOptionalString(config.search.tavilyApiKey); const braveApiKey = normalizeOptionalString(config.search.braveApiKey); const jinaApiKey = normalizeOptionalString(config.search.jinaApiKey); + const exaApiKey = normalizeOptionalString(config.search.exaApiKey); const uiSecretToken = normalizeOptionalString(config.auth.uiSecretToken); return { @@ -887,6 +897,7 @@ function resolveSharedFields(config: AgentrailConfig): SharedResolvedAppConfig { ...(tavilyApiKey ? { tavilyApiKey } : {}), ...(braveApiKey ? { braveApiKey } : {}), ...(jinaApiKey ? { jinaApiKey } : {}), + ...(exaApiKey ? { exaApiKey } : {}), dataDir: config.paths.dataDir, ...(uiSecretToken ? { uiSecretToken } : {}), sandbox: { diff --git a/packages/app/test/config.test.ts b/packages/app/test/config.test.ts index 9caa470..ea9cf31 100644 --- a/packages/app/test/config.test.ts +++ b/packages/app/test/config.test.ts @@ -45,6 +45,22 @@ describe("Agentrail config search settings", () => { jinaApiKey: "jina-key", }); }); + + it("parses and exposes the exa API key", () => { + const resolved = getDeepResearchConfig( + parseAgentrailConfig({ + search: { + provider: "exa", + exaApiKey: "exa-key", + }, + }), + ); + + expect(resolved).toMatchObject({ + searchProvider: "exa", + exaApiKey: "exa-key", + }); + }); }); describe("parseAgentrailConfig — permissions block", () => { diff --git a/packages/capabilities/src/index.ts b/packages/capabilities/src/index.ts index d701dcb..a32348f 100644 --- a/packages/capabilities/src/index.ts +++ b/packages/capabilities/src/index.ts @@ -160,6 +160,7 @@ export { createBashTool, createBraveSearchProvider, createEditTool, + createExaSearchProvider, createGlobTool, createJinaSearchProvider, createReadTool, @@ -177,6 +178,7 @@ export { export type { BraveSearchProviderOptions, EditToolOptions, + ExaSearchProviderOptions, JinaSearchProviderOptions, ReadToolOptions, SleepToolOptions, diff --git a/packages/capabilities/src/tools/index.ts b/packages/capabilities/src/tools/index.ts index 59cc8df..b7f4687 100644 --- a/packages/capabilities/src/tools/index.ts +++ b/packages/capabilities/src/tools/index.ts @@ -26,12 +26,14 @@ export type { } from "@/tools/web-fetch.js"; export { createBraveSearchProvider, + createExaSearchProvider, createJinaSearchProvider, createTavilySearchProvider, createWebSearchTool, } from "@/tools/web-search.js"; export type { BraveSearchProviderOptions, + ExaSearchProviderOptions, JinaSearchProviderOptions, TavilySearchProviderOptions, WebSearchOptions, diff --git a/packages/capabilities/src/tools/web-search.ts b/packages/capabilities/src/tools/web-search.ts index dbde730..ceb1bd0 100644 --- a/packages/capabilities/src/tools/web-search.ts +++ b/packages/capabilities/src/tools/web-search.ts @@ -46,6 +46,29 @@ export interface JinaSearchProviderOptions { timeoutMs?: number; } +export interface ExaSearchProviderOptions { + apiKey: string; + /** Override the search type (default: "auto"). */ + searchType?: "auto" | "neural" | "fast" | "deep-lite" | "deep" | "deep-reasoning" | "instant"; + /** Optional category filter, e.g. "news", "research paper", "company". */ + category?: + | "company" + | "research paper" + | "news" + | "personal site" + | "financial report" + | "people"; + includeDomains?: string[]; + excludeDomains?: string[]; + includeText?: string[]; + excludeText?: string[]; + startPublishedDate?: string; + endPublishedDate?: string; + /** Two-letter ISO country code applied to all queries. */ + userLocation?: string; + timeoutMs?: number; +} + interface TavilySearchResult { title?: string; url?: string; @@ -70,6 +93,19 @@ interface JinaSearchResult { published_at?: string; } +interface ExaSearchResult { + title?: string | null; + url?: string; + text?: string; + summary?: string; + highlights?: string[]; + publishedDate?: string | null; +} + +interface ExaSearchResponse { + results?: ExaSearchResult[]; +} + function createTimedSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { return signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) @@ -220,6 +256,67 @@ function coerceJinaResults(payload: unknown): JinaSearchResult[] { return []; } +/** Creates an Exa-backed search provider adapter. */ +export function createExaSearchProvider(options: ExaSearchProviderOptions): WebSearchProvider { + return { + async search(query, searchOptions = {}) { + const body: Record = { + query, + numResults: searchOptions.maxResults ?? DEFAULT_MAX_RESULTS, + type: options.searchType ?? "auto", + contents: { + text: { maxCharacters: 500 }, + highlights: { maxCharacters: 200 }, + }, + }; + + if (options.category) body.category = options.category; + if (options.includeDomains?.length) body.includeDomains = options.includeDomains; + if (options.excludeDomains?.length) body.excludeDomains = options.excludeDomains; + if (options.includeText?.length) body.includeText = options.includeText; + if (options.excludeText?.length) body.excludeText = options.excludeText; + if (options.startPublishedDate) body.startPublishedDate = options.startPublishedDate; + if (options.endPublishedDate) body.endPublishedDate = options.endPublishedDate; + if (options.userLocation) body.userLocation = options.userLocation; + + const response = await fetch("https://api.exa.ai/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": options.apiKey, + "x-exa-integration": "agentrail", + }, + body: JSON.stringify(body), + signal: createTimedSignal(searchOptions.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Exa search failed with status ${response.status}`); + } + + const data = (await response.json()) as ExaSearchResponse; + return (data.results ?? []) + .map((item) => { + const snippet = + (Array.isArray(item.highlights) && item.highlights.length > 0 + ? item.highlights.join(" ") + : undefined) ?? + item.summary ?? + item.text ?? + ""; + + return sanitizeSearchResult({ + title: item.title ?? item.url ?? "Untitled", + url: item.url ?? "", + snippet, + publishedAt: item.publishedDate ?? undefined, + }); + }) + .filter((item): item is WebSearchResult => Boolean(item)); + }, + }; +} + /** Creates a Jina-backed search provider adapter. */ export function createJinaSearchProvider( options: JinaSearchProviderOptions = {}, diff --git a/packages/capabilities/test/web-search.test.ts b/packages/capabilities/test/web-search.test.ts index f4c025a..117ce7b 100644 --- a/packages/capabilities/test/web-search.test.ts +++ b/packages/capabilities/test/web-search.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createBraveSearchProvider, + createExaSearchProvider, createJinaSearchProvider, createTavilySearchProvider, createWebSearchTool, @@ -109,6 +110,133 @@ describe("createWebSearchTool", () => { ]); }); + it("maps Exa responses into generic search results", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + results: [ + { + title: "OpenAI", + url: "https://openai.com", + highlights: ["AI research and deployment"], + summary: "AI research organization", + publishedDate: "2026-04-09T00:00:00.000Z", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = createExaSearchProvider({ apiKey: "exa-key" }); + const results = await provider.search("openai", { maxResults: 3 }); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.exa.ai/search"); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: "POST", + headers: expect.objectContaining({ + "x-api-key": "exa-key", + "x-exa-integration": "agentrail", + }), + }); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body).toMatchObject({ + query: "openai", + numResults: 3, + type: "auto", + contents: { + text: { maxCharacters: 500 }, + highlights: { maxCharacters: 200 }, + }, + }); + expect(results).toEqual([ + { + title: "OpenAI", + url: "https://openai.com", + snippet: "AI research and deployment", + publishedAt: "2026-04-09T00:00:00.000Z", + }, + ]); + }); + + it("falls back to summary, then text, when Exa highlights are missing", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + results: [ + { + title: "Summary only", + url: "https://example.com/a", + summary: "Summary snippet", + }, + { + title: "Text only", + url: "https://example.com/b", + text: "Full text snippet", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = createExaSearchProvider({ apiKey: "exa-key" }); + const results = await provider.search("fallback"); + + expect(results).toEqual([ + { title: "Summary only", url: "https://example.com/a", snippet: "Summary snippet" }, + { title: "Text only", url: "https://example.com/b", snippet: "Full text snippet" }, + ]); + }); + + it("forwards Exa filter options through the request body", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = createExaSearchProvider({ + apiKey: "exa-key", + searchType: "neural", + category: "research paper", + includeDomains: ["arxiv.org"], + excludeDomains: ["spam.example"], + startPublishedDate: "2025-01-01", + userLocation: "US", + }); + await provider.search("agents"); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(body).toMatchObject({ + query: "agents", + type: "neural", + category: "research paper", + includeDomains: ["arxiv.org"], + excludeDomains: ["spam.example"], + startPublishedDate: "2025-01-01", + userLocation: "US", + }); + }); + + it("throws when the Exa API returns a non-2xx status", async () => { + const fetchMock = vi.fn( + async () => new Response("boom", { status: 500, headers: { "content-type": "text/plain" } }), + ); + vi.stubGlobal("fetch", fetchMock); + + const provider = createExaSearchProvider({ apiKey: "exa-key" }); + await expect(provider.search("openai")).rejects.toThrow(/status 500/); + }); + it("maps Jina responses into generic search results", async () => { const fetchMock = vi.fn( async () => diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index ae372f4..d2cb5c7 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -42,6 +42,7 @@ async function checkEnvVars( tavilyApiKey?: string; braveApiKey?: string; jinaApiKey?: string; + exaApiKey?: string; } | undefined; @@ -99,6 +100,17 @@ async function checkEnvVars( }); } + const exaInConfig = Boolean(searchConfig?.exaApiKey); + if (!exaInConfig && searchConfig?.provider === "exa") { + const key = "EXA_API_KEY"; + const present = Boolean(process.env[key]); + results.push({ + name: `env.${key}`, + status: present ? "ok" : "fail", + message: present ? `${key} found` : `${key} missing — set it in env or config`, + }); + } + return results; } diff --git a/packages/deep-research/src/runtime.ts b/packages/deep-research/src/runtime.ts index 4f1127f..6ac986c 100644 --- a/packages/deep-research/src/runtime.ts +++ b/packages/deep-research/src/runtime.ts @@ -17,6 +17,7 @@ export interface DeepResearchRuntimeConfig { tavilyApiKey?: string; braveApiKey?: string; jinaApiKey?: string; + exaApiKey?: string; sandbox?: { image?: string; idleTimeoutMs?: number; diff --git a/packages/deep-research/src/tools.ts b/packages/deep-research/src/tools.ts index 359d5e3..57a72bb 100644 --- a/packages/deep-research/src/tools.ts +++ b/packages/deep-research/src/tools.ts @@ -7,6 +7,7 @@ import type { DeepResearchRuntimeConfig } from "@/runtime.js"; import { normalizeResearchUrl } from "@/utils.js"; import { createBraveSearchProvider, + createExaSearchProvider, createWebFetchTool as createGenericWebFetchTool, createWebSearchTool as createGenericWebSearchTool, createJinaSearchProvider, @@ -54,6 +55,10 @@ function resolveSearchProvider(runtime: DeepResearchRuntimeConfig): WebSearchPro return createJinaSearchProvider({ apiKey: runtime.jinaApiKey }); } + if (runtime.searchProvider === "exa" && runtime.exaApiKey) { + return createExaSearchProvider({ apiKey: runtime.exaApiKey }); + } + throw new Error( "Deep Research search is not configured. Set search.provider and its matching API key in config/agentrail.yaml.", ); diff --git a/packages/deep-research/test/tools.test.ts b/packages/deep-research/test/tools.test.ts index 0fb3b30..d670610 100644 --- a/packages/deep-research/test/tools.test.ts +++ b/packages/deep-research/test/tools.test.ts @@ -118,6 +118,48 @@ describe("deep-research tool adapters", () => { }); }); + it("selects Exa when configured", async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + results: [ + { + title: "OpenAI", + url: "https://openai.com", + highlights: ["AI research"], + publishedDate: "2026-04-09T00:00:00.000Z", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const tool = createWebSearchTool({ + dataDir: "/tmp/agentrail", + model: { provider: "mock", modelId: "mock-model" }, + searchProvider: "exa", + exaApiKey: "exa-key", + }); + + const result = await tool.execute("call-exa", { query: "openai" }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.exa.ai/search"); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + headers: expect.objectContaining({ + "x-api-key": "exa-key", + "x-exa-integration": "agentrail", + }), + }); + expect((result.details as { items: Array<{ fetchStatus: string }> }).items[0]).toMatchObject({ + title: "OpenAI", + fetchStatus: "skipped", + evidenceLevel: "snippet_only", + }); + }); + it("maps successful fetches into deep research fetch payloads", async () => { const fetchMock = vi.fn( async () => From 2f4ffd1cf5929b29f32a8779a36812e895430a5b Mon Sep 17 00:00:00 2001 From: Teo Gonzalez Date: Thu, 16 Apr 2026 10:34:17 -0700 Subject: [PATCH 2/2] fix(capabilities): request summary from Exa for snippet fallback The snippet normalization falls back to `item.summary` when highlights are missing, but the request body did not include `summary: true`, so the field was never populated. Add it so the highlights -> summary -> text fallback actually works, and assert it in the provider test. Signed-off-by: Teo Gonzalez --- packages/capabilities/src/tools/web-search.ts | 1 + packages/capabilities/test/web-search.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/capabilities/src/tools/web-search.ts b/packages/capabilities/src/tools/web-search.ts index ceb1bd0..b5d2f05 100644 --- a/packages/capabilities/src/tools/web-search.ts +++ b/packages/capabilities/src/tools/web-search.ts @@ -267,6 +267,7 @@ export function createExaSearchProvider(options: ExaSearchProviderOptions): WebS contents: { text: { maxCharacters: 500 }, highlights: { maxCharacters: 200 }, + summary: true, }, }; diff --git a/packages/capabilities/test/web-search.test.ts b/packages/capabilities/test/web-search.test.ts index 117ce7b..4e18299 100644 --- a/packages/capabilities/test/web-search.test.ts +++ b/packages/capabilities/test/web-search.test.ts @@ -150,6 +150,7 @@ describe("createWebSearchTool", () => { contents: { text: { maxCharacters: 500 }, highlights: { maxCharacters: 200 }, + summary: true, }, }); expect(results).toEqual([