Skip to content

Commit 7811888

Browse files
Merge pull request #333 from realorange1994/feature/exa-search
feat: 添加 Exa AI 搜索适配器
2 parents c3d63c8 + b966eef commit 7811888

6 files changed

Lines changed: 547 additions & 7 deletions

File tree

packages/builtin-tools/src/tools/WebSearchTool/WebSearchTool.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ const inputSchema = lazySchema(() =>
2323
.array(z.string())
2424
.optional()
2525
.describe('Never include search results from these domains'),
26+
num_results: z
27+
.number()
28+
.optional()
29+
.describe('Number of search results to return (default: 8)'),
30+
livecrawl: z
31+
.enum(['fallback', 'preferred'])
32+
.optional()
33+
.describe(
34+
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
35+
),
36+
search_type: z
37+
.enum(['auto', 'fast', 'deep'])
38+
.optional()
39+
.describe(
40+
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
41+
),
42+
context_max_characters: z
43+
.number()
44+
.optional()
45+
.describe('Maximum characters for context string optimized for LLMs (default: 10000)'),
2646
}),
2747
)
2848
type InputSchema = ReturnType<typeof inputSchema>
@@ -148,6 +168,10 @@ export const WebSearchTool = buildTool({
148168
const adapterResults = await adapter.search(query, {
149169
allowedDomains: input.allowed_domains,
150170
blockedDomains: input.blocked_domains,
171+
numResults: input.num_results,
172+
livecrawl: input.livecrawl,
173+
searchType: input.search_type,
174+
contextMaxCharacters: input.context_max_characters,
151175
signal: context.abortController.signal,
152176
onProgress(progress) {
153177
if (onProgress) {

packages/builtin-tools/src/tools/WebSearchTool/__tests__/adapterFactory.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ describe('createAdapter', () => {
5252
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
5353
})
5454

55-
test('selects the Bing adapter for third-party Anthropic base URLs', () => {
55+
test('selects the Exa adapter for third-party Anthropic base URLs', () => {
5656
delete process.env.WEB_SEARCH_ADAPTER
5757
isFirstPartyBaseUrl = false
5858

59-
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
59+
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
6060
})
6161
})
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import { afterEach, describe, expect, mock, test } from 'bun:test'
2+
3+
const _abortMock = () => ({
4+
AbortError: class AbortError extends Error {
5+
constructor(message?: string) { super(message); this.name = 'AbortError' }
6+
},
7+
isAbortError: (e: unknown) => e instanceof Error && (e as Error).name === 'AbortError',
8+
})
9+
mock.module('src/utils/errors.js', _abortMock)
10+
mock.module('src/utils/errors', _abortMock)
11+
12+
describe('ExaSearchAdapter.search', () => {
13+
const createAdapter = async () => {
14+
const { ExaSearchAdapter } = await import('../adapters/exaAdapter')
15+
return new ExaSearchAdapter()
16+
}
17+
18+
// Exa MCP returns SSE lines like: data: {"result":{"content":[{"type":"text","text":"..."}]}}
19+
const buildSseResponse = (text: string) => `data: ${JSON.stringify({ result: { content: [{ type: 'text', text }] } })}\n`
20+
21+
const STRUCTURED_TEXT = [
22+
'Title: Example Result 1',
23+
'URL: https://example.com/page1',
24+
'Content: This is the content snippet for page 1.',
25+
'',
26+
'---',
27+
'',
28+
'Title: Example Result 2',
29+
'URL: https://example.com/page2',
30+
'Content: This is the content snippet for page 2.',
31+
].join('\n')
32+
33+
afterEach(() => {
34+
mock.restore()
35+
})
36+
37+
test('parses structured Title/URL/Content blocks from SSE response', async () => {
38+
mock.module('axios', () => ({
39+
default: {
40+
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
41+
isCancel: () => false,
42+
},
43+
}))
44+
45+
const adapter = await createAdapter()
46+
const results = await adapter.search('test query', {})
47+
48+
expect(results).toHaveLength(2)
49+
expect(results[0]).toEqual({
50+
title: 'Example Result 1',
51+
url: 'https://example.com/page1',
52+
snippet: 'This is the content snippet for page 1.',
53+
})
54+
expect(results[1]).toEqual({
55+
title: 'Example Result 2',
56+
url: 'https://example.com/page2',
57+
snippet: 'This is the content snippet for page 2.',
58+
})
59+
})
60+
61+
test('parses markdown link fallback when no structured blocks', async () => {
62+
const markdownText = '- [React Docs](https://react.dev/docs)\n- [React Hooks](https://react.dev/hooks)'
63+
mock.module('axios', () => ({
64+
default: {
65+
post: mock(() => Promise.resolve({ data: buildSseResponse(markdownText) })),
66+
isCancel: () => false,
67+
},
68+
}))
69+
70+
const adapter = await createAdapter()
71+
const results = await adapter.search('react', {})
72+
73+
expect(results).toHaveLength(2)
74+
expect(results[0]).toEqual({
75+
title: 'React Docs',
76+
url: 'https://react.dev/docs',
77+
snippet: undefined,
78+
})
79+
expect(results[1].url).toBe('https://react.dev/hooks')
80+
})
81+
82+
test('parses plain URL fallback', async () => {
83+
const plainUrlText = 'https://example.com/page1\nhttps://example.com/page2'
84+
mock.module('axios', () => ({
85+
default: {
86+
post: mock(() => Promise.resolve({ data: buildSseResponse(plainUrlText) })),
87+
isCancel: () => false,
88+
},
89+
}))
90+
91+
const adapter = await createAdapter()
92+
const results = await adapter.search('test', {})
93+
94+
expect(results).toHaveLength(2)
95+
expect(results[0].url).toBe('https://example.com/page1')
96+
})
97+
98+
test('returns empty array for empty response', async () => {
99+
mock.module('axios', () => ({
100+
default: {
101+
post: mock(() => Promise.resolve({ data: '' })),
102+
isCancel: () => false,
103+
},
104+
}))
105+
106+
const adapter = await createAdapter()
107+
const results = await adapter.search('test', {})
108+
109+
expect(results).toHaveLength(0)
110+
})
111+
112+
test('parses direct JSON response (non-SSE fallback)', async () => {
113+
const jsonResponse = JSON.stringify({
114+
result: { content: [{ type: 'text', text: STRUCTURED_TEXT }] },
115+
})
116+
mock.module('axios', () => ({
117+
default: {
118+
post: mock(() => Promise.resolve({ data: jsonResponse })),
119+
isCancel: () => false,
120+
},
121+
}))
122+
123+
const adapter = await createAdapter()
124+
const results = await adapter.search('test', {})
125+
126+
expect(results).toHaveLength(2)
127+
expect(results[0].url).toBe('https://example.com/page1')
128+
})
129+
130+
test('calls onProgress with query_update and search_results_received', async () => {
131+
mock.module('axios', () => ({
132+
default: {
133+
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
134+
isCancel: () => false,
135+
},
136+
}))
137+
138+
const progressCalls: any[] = []
139+
const onProgress = (p: any) => progressCalls.push(p)
140+
141+
const adapter = await createAdapter()
142+
await adapter.search('test', { onProgress })
143+
144+
expect(progressCalls).toHaveLength(2)
145+
expect(progressCalls[0]).toEqual({ type: 'query_update', query: 'test' })
146+
expect(progressCalls[1]).toEqual({
147+
type: 'search_results_received',
148+
resultCount: 2,
149+
query: 'test',
150+
})
151+
})
152+
153+
test('filters results by allowedDomains', async () => {
154+
const mixedText = [
155+
'Title: Allowed',
156+
'URL: https://allowed.com/a',
157+
'---',
158+
'Title: Blocked',
159+
'URL: https://blocked.com/b',
160+
].join('\n')
161+
162+
mock.module('axios', () => ({
163+
default: {
164+
post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })),
165+
isCancel: () => false,
166+
},
167+
}))
168+
169+
const adapter = await createAdapter()
170+
const results = await adapter.search('test', { allowedDomains: ['allowed.com'] })
171+
172+
expect(results).toHaveLength(1)
173+
expect(results[0].url).toBe('https://allowed.com/a')
174+
})
175+
176+
test('filters results by blockedDomains', async () => {
177+
const mixedText = [
178+
'Title: Good',
179+
'URL: https://good.com/a',
180+
'---',
181+
'Title: Spam',
182+
'URL: https://spam.com/b',
183+
].join('\n')
184+
185+
mock.module('axios', () => ({
186+
default: {
187+
post: mock(() => Promise.resolve({ data: buildSseResponse(mixedText) })),
188+
isCancel: () => false,
189+
},
190+
}))
191+
192+
const adapter = await createAdapter()
193+
const results = await adapter.search('test', { blockedDomains: ['spam.com'] })
194+
195+
expect(results).toHaveLength(1)
196+
expect(results[0].url).toBe('https://good.com/a')
197+
})
198+
199+
test('filters subdomains with allowedDomains', async () => {
200+
const text = [
201+
'Title: Subdomain',
202+
'URL: https://docs.example.com/page',
203+
'---',
204+
'Title: Other',
205+
'URL: https://other.com/page',
206+
].join('\n')
207+
208+
mock.module('axios', () => ({
209+
default: {
210+
post: mock(() => Promise.resolve({ data: buildSseResponse(text) })),
211+
isCancel: () => false,
212+
},
213+
}))
214+
215+
const adapter = await createAdapter()
216+
const results = await adapter.search('test', { allowedDomains: ['example.com'] })
217+
218+
expect(results).toHaveLength(1)
219+
expect(results[0].url).toBe('https://docs.example.com/page')
220+
})
221+
222+
test('throws AbortError when signal is already aborted', async () => {
223+
mock.module('axios', () => ({
224+
default: {
225+
post: mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) })),
226+
isCancel: () => false,
227+
},
228+
}))
229+
230+
const adapter = await createAdapter()
231+
const controller = new AbortController()
232+
controller.abort()
233+
234+
const { AbortError } = await import('src/utils/errors')
235+
await expect(
236+
adapter.search('test', { signal: controller.signal }),
237+
).rejects.toThrow(AbortError)
238+
})
239+
240+
test('re-throws non-abort axios errors', async () => {
241+
const networkError = new Error('Network error')
242+
mock.module('axios', () => ({
243+
default: {
244+
post: mock(() => Promise.reject(networkError)),
245+
isCancel: () => false,
246+
},
247+
}))
248+
249+
const adapter = await createAdapter()
250+
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
251+
})
252+
253+
test('sends correct MCP request payload to Exa endpoint', async () => {
254+
const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) }))
255+
mock.module('axios', () => ({
256+
default: {
257+
post: axiosPost,
258+
isCancel: () => false,
259+
},
260+
}))
261+
262+
const adapter = await createAdapter()
263+
await adapter.search('hello world', {})
264+
265+
expect(axiosPost.mock.calls).toHaveLength(1)
266+
const [url, body, config] = (axiosPost.mock.calls as any[][])[0]
267+
expect(url).toBe('https://mcp.exa.ai/mcp')
268+
expect(body.jsonrpc).toBe('2.0')
269+
expect(body.method).toBe('tools/call')
270+
expect(body.params.name).toBe('web_search_exa')
271+
expect(body.params.arguments.query).toBe('hello world')
272+
expect(body.params.arguments.type).toBe('auto')
273+
expect(body.params.arguments.numResults).toBe(8)
274+
expect(body.params.arguments.livecrawl).toBe('fallback')
275+
expect(body.params.arguments.contextMaxCharacters).toBe(10000)
276+
expect(config.headers.Accept).toBe('application/json, text/event-stream')
277+
})
278+
279+
test('passes custom search options to MCP request', async () => {
280+
const axiosPost = mock(() => Promise.resolve({ data: buildSseResponse(STRUCTURED_TEXT) }))
281+
mock.module('axios', () => ({
282+
default: {
283+
post: axiosPost,
284+
isCancel: () => false,
285+
},
286+
}))
287+
288+
const adapter = await createAdapter()
289+
await adapter.search('test', {
290+
numResults: 15,
291+
livecrawl: 'preferred',
292+
searchType: 'deep',
293+
contextMaxCharacters: 20000,
294+
})
295+
296+
const [, body] = (axiosPost.mock.calls as any[][])[0]
297+
expect(body.params.arguments.numResults).toBe(15)
298+
expect(body.params.arguments.livecrawl).toBe('preferred')
299+
expect(body.params.arguments.type).toBe('deep')
300+
expect(body.params.arguments.contextMaxCharacters).toBe(20000)
301+
})
302+
})

0 commit comments

Comments
 (0)