test: add exa web provider adapter
This commit is contained in:
110
.pi/agent/extensions/web-search/src/providers/exa.test.ts
Normal file
110
.pi/agent/extensions/web-search/src/providers/exa.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createExaProvider } from "./exa.ts";
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
name: "exa-main",
|
||||||
|
type: "exa" as const,
|
||||||
|
apiKey: "exa-test-key",
|
||||||
|
options: {
|
||||||
|
defaultSearchLimit: 7,
|
||||||
|
defaultFetchTextMaxCharacters: 9000,
|
||||||
|
defaultFetchHighlightsMaxCharacters: 1200,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
test("createExaProvider maps generic search requests to Exa search with contents disabled", async () => {
|
||||||
|
let captured: { query: string; options: Record<string, unknown> } | undefined;
|
||||||
|
|
||||||
|
const provider = createExaProvider(baseConfig, () => ({
|
||||||
|
async search(query, options) {
|
||||||
|
captured = { query, options };
|
||||||
|
return {
|
||||||
|
requestId: "req-search-1",
|
||||||
|
searchTime: 123,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: "doc-1",
|
||||||
|
title: "Exa Docs",
|
||||||
|
url: "https://exa.ai/docs",
|
||||||
|
publishedDate: "2026-04-09",
|
||||||
|
author: "Exa",
|
||||||
|
score: 0.98,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getContents() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await provider.search({
|
||||||
|
query: "exa docs",
|
||||||
|
includeDomains: ["exa.ai"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(captured, {
|
||||||
|
query: "exa docs",
|
||||||
|
options: {
|
||||||
|
contents: false,
|
||||||
|
numResults: 7,
|
||||||
|
includeDomains: ["exa.ai"],
|
||||||
|
excludeDomains: undefined,
|
||||||
|
startPublishedDate: undefined,
|
||||||
|
endPublishedDate: undefined,
|
||||||
|
category: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(result.providerName, "exa-main");
|
||||||
|
assert.equal(result.results[0]?.url, "https://exa.ai/docs");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createExaProvider fetch defaults to text and preserves per-url failures", async () => {
|
||||||
|
const calls: Array<{ urls: string[]; options: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
const provider = createExaProvider(baseConfig, () => ({
|
||||||
|
async search() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
async getContents(urls, options) {
|
||||||
|
const requestUrls = Array.isArray(urls) ? urls : [urls];
|
||||||
|
calls.push({ urls: requestUrls, options });
|
||||||
|
|
||||||
|
if (requestUrls[0] === "https://bad.example") {
|
||||||
|
throw new Error("429 rate limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestId: `req-${calls.length}`,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
url: requestUrls[0],
|
||||||
|
title: "Fetched page",
|
||||||
|
text: "Fetched body",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await provider.fetch({
|
||||||
|
urls: ["https://good.example", "https://bad.example"],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal((calls[0]?.options.text as { maxCharacters: number }).maxCharacters, 9000);
|
||||||
|
assert.deepEqual(result.results, [
|
||||||
|
{
|
||||||
|
url: "https://good.example",
|
||||||
|
title: "Fetched page",
|
||||||
|
text: "Fetched body",
|
||||||
|
highlights: undefined,
|
||||||
|
summary: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://bad.example",
|
||||||
|
title: null,
|
||||||
|
error: "429 rate limited",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
124
.pi/agent/extensions/web-search/src/providers/exa.ts
Normal file
124
.pi/agent/extensions/web-search/src/providers/exa.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import Exa from "exa-js";
|
||||||
|
import type { ExaProviderConfig } from "../schema.ts";
|
||||||
|
import type {
|
||||||
|
NormalizedFetchRequest,
|
||||||
|
NormalizedFetchResponse,
|
||||||
|
NormalizedSearchRequest,
|
||||||
|
NormalizedSearchResponse,
|
||||||
|
WebProvider,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
export interface ExaClientLike {
|
||||||
|
search(query: string, options?: Record<string, unknown>): Promise<any>;
|
||||||
|
getContents(urls: string[] | string, options?: Record<string, unknown>): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExaClientFactory = (apiKey: string) => ExaClientLike;
|
||||||
|
|
||||||
|
export function buildSearchOptions(config: ExaProviderConfig, request: NormalizedSearchRequest) {
|
||||||
|
return {
|
||||||
|
contents: false,
|
||||||
|
numResults: request.limit ?? config.options?.defaultSearchLimit ?? 5,
|
||||||
|
includeDomains: request.includeDomains,
|
||||||
|
excludeDomains: request.excludeDomains,
|
||||||
|
startPublishedDate: request.startPublishedDate,
|
||||||
|
endPublishedDate: request.endPublishedDate,
|
||||||
|
category: request.category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFetchOptions(config: ExaProviderConfig, request: NormalizedFetchRequest) {
|
||||||
|
const text = request.text ?? (!request.highlights && !request.summary);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(text
|
||||||
|
? {
|
||||||
|
text: {
|
||||||
|
maxCharacters: request.textMaxCharacters ?? config.options?.defaultFetchTextMaxCharacters ?? 12000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(request.highlights
|
||||||
|
? {
|
||||||
|
highlights: {
|
||||||
|
maxCharacters: config.options?.defaultFetchHighlightsMaxCharacters ?? 1000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(request.summary ? { summary: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExaProvider(
|
||||||
|
config: ExaProviderConfig,
|
||||||
|
createClient: ExaClientFactory = (apiKey) => new Exa(apiKey) as unknown as ExaClientLike,
|
||||||
|
): WebProvider {
|
||||||
|
const client = createClient(config.apiKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: config.name,
|
||||||
|
type: config.type,
|
||||||
|
|
||||||
|
async search(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse> {
|
||||||
|
const response = await client.search(request.query, buildSearchOptions(config, request));
|
||||||
|
return {
|
||||||
|
providerName: config.name,
|
||||||
|
requestId: response.requestId,
|
||||||
|
searchTime: response.searchTime,
|
||||||
|
results: (response.results ?? []).map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title ?? null,
|
||||||
|
url: item.url,
|
||||||
|
publishedDate: item.publishedDate,
|
||||||
|
author: item.author,
|
||||||
|
score: item.score,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse> {
|
||||||
|
const requestIds: string[] = [];
|
||||||
|
const options = buildFetchOptions(config, request);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
request.urls.map(async (url) => {
|
||||||
|
try {
|
||||||
|
const response = await client.getContents([url], options);
|
||||||
|
if (response.requestId) {
|
||||||
|
requestIds.push(response.requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = response.results?.[0];
|
||||||
|
if (!item) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: null,
|
||||||
|
error: "No content returned",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: item.url ?? url,
|
||||||
|
title: item.title ?? null,
|
||||||
|
text: typeof item.text === "string" ? item.text : undefined,
|
||||||
|
highlights: Array.isArray(item.highlights) ? item.highlights : undefined,
|
||||||
|
summary: typeof item.summary === "string" ? item.summary : undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
title: null,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerName: config.name,
|
||||||
|
requestIds,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
57
.pi/agent/extensions/web-search/src/providers/types.ts
Normal file
57
.pi/agent/extensions/web-search/src/providers/types.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface NormalizedSearchRequest {
|
||||||
|
query: string;
|
||||||
|
limit?: number;
|
||||||
|
includeDomains?: string[];
|
||||||
|
excludeDomains?: string[];
|
||||||
|
startPublishedDate?: string;
|
||||||
|
endPublishedDate?: string;
|
||||||
|
category?: string;
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedSearchResult {
|
||||||
|
id?: string;
|
||||||
|
title: string | null;
|
||||||
|
url: string;
|
||||||
|
publishedDate?: string;
|
||||||
|
author?: string;
|
||||||
|
score?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedSearchResponse {
|
||||||
|
providerName: string;
|
||||||
|
requestId?: string;
|
||||||
|
searchTime?: number;
|
||||||
|
results: NormalizedSearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedFetchRequest {
|
||||||
|
urls: string[];
|
||||||
|
text?: boolean;
|
||||||
|
highlights?: boolean;
|
||||||
|
summary?: boolean;
|
||||||
|
textMaxCharacters?: number;
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedFetchResult {
|
||||||
|
url: string;
|
||||||
|
title: string | null;
|
||||||
|
text?: string;
|
||||||
|
highlights?: string[];
|
||||||
|
summary?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedFetchResponse {
|
||||||
|
providerName: string;
|
||||||
|
requestIds?: string[];
|
||||||
|
results: NormalizedFetchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebProvider {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
search(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse>;
|
||||||
|
fetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse>;
|
||||||
|
}
|
||||||
@@ -384,7 +384,7 @@ git -C /home/alex/dotfiles commit -m "test: add web search config loader"
|
|||||||
- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.ts`
|
- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.ts`
|
||||||
- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts`
|
- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing Exa adapter tests**
|
- [x] **Step 1: Write the failing Exa adapter tests**
|
||||||
|
|
||||||
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts` with this content:
|
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts` with this content:
|
||||||
|
|
||||||
@@ -501,7 +501,7 @@ test("createExaProvider fetch defaults to text and preserves per-url failures",
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run the Exa adapter tests to verify they fail**
|
- [x] **Step 2: Run the Exa adapter tests to verify they fail**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -512,7 +512,7 @@ npx tsx --test src/providers/exa.test.ts
|
|||||||
|
|
||||||
Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./exa.ts`.
|
Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./exa.ts`.
|
||||||
|
|
||||||
- [ ] **Step 3: Write the provider interface and Exa adapter implementation**
|
- [x] **Step 3: Write the provider interface and Exa adapter implementation**
|
||||||
|
|
||||||
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/types.ts` with this content:
|
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/types.ts` with this content:
|
||||||
|
|
||||||
@@ -705,7 +705,7 @@ export function createExaProvider(
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Run the Exa adapter tests to verify they pass**
|
- [x] **Step 4: Run the Exa adapter tests to verify they pass**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -716,7 +716,7 @@ npx tsx --test src/providers/exa.test.ts
|
|||||||
|
|
||||||
Expected: `PASS` for both tests.
|
Expected: `PASS` for both tests.
|
||||||
|
|
||||||
- [ ] **Step 5: Commit the provider layer**
|
- [x] **Step 5: Commit the provider layer**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user