Skip to content
This repository was archived by the owner on Apr 17, 2026. It is now read-only.
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
26 changes: 26 additions & 0 deletions .changeset/exa-search-provider.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 2 additions & 0 deletions config/agentrail.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/guides/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
24 changes: 23 additions & 1 deletion docs/tools/web-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/zh/guides/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions examples/deep-research/src/routes/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions examples/playground-server/src/chat/deep-research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function buildDeepResearchRuntime() {
tavilyApiKey: config.tavilyApiKey,
braveApiKey: config.braveApiKey,
jinaApiKey: config.jinaApiKey,
exaApiKey: config.exaApiKey,
sandbox: config.sandbox,
orchestration: config.orchestration,
};
Expand Down
13 changes: 12 additions & 1 deletion packages/app/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export interface AgentrailConfig {
tavilyApiKey: string;
braveApiKey: string;
jinaApiKey: string;
exaApiKey: string;
};
paths: {
dataDir: string;
Expand Down Expand Up @@ -171,6 +172,7 @@ export interface SharedResolvedAppConfig {
tavilyApiKey?: string;
braveApiKey?: string;
jinaApiKey?: string;
exaApiKey?: string;
dataDir: string;
uiSecretToken?: string;
sandbox: SandboxRuntimeConfig;
Expand Down Expand Up @@ -213,6 +215,7 @@ export const DEFAULT_AGENTRAIL_CONFIG: AgentrailConfig = {
tavilyApiKey: "",
braveApiKey: "",
jinaApiKey: "",
exaApiKey: "",
},
paths: {
dataDir: DEFAULT_DATA_DIR,
Expand Down Expand Up @@ -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"],
);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -887,6 +897,7 @@ function resolveSharedFields(config: AgentrailConfig): SharedResolvedAppConfig {
...(tavilyApiKey ? { tavilyApiKey } : {}),
...(braveApiKey ? { braveApiKey } : {}),
...(jinaApiKey ? { jinaApiKey } : {}),
...(exaApiKey ? { exaApiKey } : {}),
dataDir: config.paths.dataDir,
...(uiSecretToken ? { uiSecretToken } : {}),
sandbox: {
Expand Down
16 changes: 16 additions & 0 deletions packages/app/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/capabilities/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export {
createBashTool,
createBraveSearchProvider,
createEditTool,
createExaSearchProvider,
createGlobTool,
createJinaSearchProvider,
createReadTool,
Expand All @@ -177,6 +178,7 @@ export {
export type {
BraveSearchProviderOptions,
EditToolOptions,
ExaSearchProviderOptions,
JinaSearchProviderOptions,
ReadToolOptions,
SleepToolOptions,
Expand Down
2 changes: 2 additions & 0 deletions packages/capabilities/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
98 changes: 98 additions & 0 deletions packages/capabilities/src/tools/web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)])
Expand Down Expand Up @@ -220,6 +256,68 @@ 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<string, unknown> = {
query,
numResults: searchOptions.maxResults ?? DEFAULT_MAX_RESULTS,
type: options.searchType ?? "auto",
contents: {
text: { maxCharacters: 500 },
highlights: { maxCharacters: 200 },
summary: true,
},
Comment on lines +267 to +271

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The contents object in the request body is missing the summary field. However, the normalization logic at line 304 attempts to use item.summary as a fallback. Without requesting it in the POST body, item.summary will always be undefined in the response from the Exa API. To support the fallback strategy described in the PR summary (highlights -> summary -> text), you should include summary: true in the request.

        contents: {
          text: { maxCharacters: 500 },
          highlights: { maxCharacters: 200 },
          summary: true,
        },

};

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 = {},
Expand Down
Loading