@framers/agentos-ext-web-search 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -188
- package/dist/index.d.ts +14 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +52 -50
- package/dist/index.js.map +1 -1
- package/dist/services/searchProvider.d.ts +54 -2
- package/dist/services/searchProvider.d.ts.map +1 -1
- package/dist/services/searchProvider.js +168 -17
- package/dist/services/searchProvider.js.map +1 -1
- package/dist/tools/factCheck.d.ts +46 -88
- package/dist/tools/factCheck.d.ts.map +1 -1
- package/dist/tools/factCheck.js +134 -170
- package/dist/tools/factCheck.js.map +1 -1
- package/dist/tools/researchAggregator.d.ts +37 -91
- package/dist/tools/researchAggregator.d.ts.map +1 -1
- package/dist/tools/researchAggregator.js +134 -175
- package/dist/tools/researchAggregator.js.map +1 -1
- package/dist/tools/webSearch.d.ts +31 -59
- package/dist/tools/webSearch.d.ts.map +1 -1
- package/dist/tools/webSearch.js +69 -87
- package/dist/tools/webSearch.js.map +1 -1
- package/dist/types.js +1 -2
- package/dist/types.js.map +1 -1
- package/manifest.json +11 -6
- package/package.json +2 -2
- package/src/index.ts +64 -46
- package/src/services/searchProvider.ts +231 -25
- package/src/tools/factCheck.ts +206 -224
- package/src/tools/researchAggregator.ts +209 -217
- package/src/tools/webSearch.ts +103 -102
- package/test/integration.spec.ts +240 -39
- package/test/searchProvider.spec.ts +263 -18
- package/test/webSearch.spec.ts +129 -27
- package/tsconfig.json +7 -2
|
@@ -11,7 +11,8 @@ describe('SearchProviderService', () => {
|
|
|
11
11
|
service = new SearchProviderService({
|
|
12
12
|
serperApiKey: 'test-serper-key',
|
|
13
13
|
serpApiKey: 'test-serpapi-key',
|
|
14
|
-
braveApiKey: 'test-brave-key'
|
|
14
|
+
braveApiKey: 'test-brave-key',
|
|
15
|
+
searxngUrl: 'http://localhost:8888',
|
|
15
16
|
});
|
|
16
17
|
vi.clearAllMocks();
|
|
17
18
|
});
|
|
@@ -53,23 +54,15 @@ describe('SearchProviderService', () => {
|
|
|
53
54
|
|
|
54
55
|
it('should use DuckDuckGo as final fallback', async () => {
|
|
55
56
|
const mockFetch = global.fetch as any;
|
|
56
|
-
|
|
57
|
-
// All API providers fail
|
|
58
|
-
mockFetch.mockRejectedValue(new Error('API error'));
|
|
59
|
-
|
|
60
|
-
// Mock DuckDuckGo HTML response
|
|
61
|
-
const duckDuckGoHtml = `
|
|
62
|
-
<div class="result">
|
|
63
|
-
<h2 class="result__title">
|
|
64
|
-
<a href="https://example.com">Test Result</a>
|
|
65
|
-
</h2>
|
|
66
|
-
<a class="result__snippet">Test snippet from DuckDuckGo</a>
|
|
67
|
-
</div>
|
|
68
|
-
`;
|
|
69
|
-
|
|
57
|
+
|
|
70
58
|
mockFetch.mockResolvedValueOnce({
|
|
71
59
|
ok: true,
|
|
72
|
-
|
|
60
|
+
json: async () => ({
|
|
61
|
+
Heading: 'Test Result',
|
|
62
|
+
AbstractText: 'Test snippet from DuckDuckGo',
|
|
63
|
+
AbstractURL: 'https://example.com',
|
|
64
|
+
RelatedTopics: [],
|
|
65
|
+
}),
|
|
73
66
|
});
|
|
74
67
|
|
|
75
68
|
const serviceNoKeys = new SearchProviderService({});
|
|
@@ -142,12 +135,264 @@ describe('SearchProviderService', () => {
|
|
|
142
135
|
});
|
|
143
136
|
});
|
|
144
137
|
|
|
138
|
+
describe('getAvailableProviders', () => {
|
|
139
|
+
it('should return providers with configured API keys', () => {
|
|
140
|
+
const providers = service.getAvailableProviders();
|
|
141
|
+
expect(providers).toEqual(['serper', 'serpapi', 'brave', 'searxng']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should include searxng when URL is configured', () => {
|
|
145
|
+
const searxngService = new SearchProviderService({ searxngUrl: 'http://searxng:8080' });
|
|
146
|
+
expect(searxngService.getAvailableProviders()).toEqual(['searxng']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should return empty array with no API keys', () => {
|
|
150
|
+
const emptyService = new SearchProviderService({});
|
|
151
|
+
expect(emptyService.getAvailableProviders()).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('searchSearXNG', () => {
|
|
156
|
+
it('should format request with correct URL params', async () => {
|
|
157
|
+
const mockFetch = global.fetch as any;
|
|
158
|
+
mockFetch.mockResolvedValue({
|
|
159
|
+
ok: true,
|
|
160
|
+
json: async () => ({
|
|
161
|
+
results: [
|
|
162
|
+
{ title: 'SearXNG Result', url: 'https://example.com/searxng', content: 'SearXNG snippet' },
|
|
163
|
+
],
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const searxngService = new SearchProviderService({ searxngUrl: 'http://searxng:8080' });
|
|
168
|
+
const result = await searxngService.search('test query', { provider: 'searxng' });
|
|
169
|
+
|
|
170
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
171
|
+
expect.stringContaining('http://searxng:8080/search?'),
|
|
172
|
+
);
|
|
173
|
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
|
174
|
+
expect(calledUrl).toContain('format=json');
|
|
175
|
+
expect(calledUrl).toContain('categories=general');
|
|
176
|
+
expect(result.provider).toBe('searxng');
|
|
177
|
+
expect(result.results).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should map SearXNG "content" field to "snippet"', async () => {
|
|
181
|
+
const mockFetch = global.fetch as any;
|
|
182
|
+
mockFetch.mockResolvedValue({
|
|
183
|
+
ok: true,
|
|
184
|
+
json: async () => ({
|
|
185
|
+
results: [
|
|
186
|
+
{ title: 'Title', url: 'https://example.com', content: 'SearXNG content field' },
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const searxngService = new SearchProviderService({ searxngUrl: 'http://searxng:8080' });
|
|
192
|
+
const result = await searxngService.search('test', { provider: 'searxng' });
|
|
193
|
+
|
|
194
|
+
expect(result.results[0].snippet).toBe('SearXNG content field');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should pass category parameter to SearXNG', async () => {
|
|
198
|
+
const mockFetch = global.fetch as any;
|
|
199
|
+
mockFetch.mockResolvedValue({
|
|
200
|
+
ok: true,
|
|
201
|
+
json: async () => ({ results: [] }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const searxngService = new SearchProviderService({ searxngUrl: 'http://searxng:8080' });
|
|
205
|
+
await searxngService.search('AI news', { provider: 'searxng', category: 'news' });
|
|
206
|
+
|
|
207
|
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
|
208
|
+
expect(calledUrl).toContain('categories=news');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should handle SearXNG API errors', async () => {
|
|
212
|
+
const mockFetch = global.fetch as any;
|
|
213
|
+
mockFetch.mockResolvedValueOnce({ ok: false, statusText: 'Service Unavailable' });
|
|
214
|
+
// DuckDuckGo fallback
|
|
215
|
+
mockFetch.mockResolvedValueOnce({
|
|
216
|
+
ok: true,
|
|
217
|
+
json: async () => ({ Heading: 'DDG', AbstractText: 'Fallback', AbstractURL: 'https://example.com', RelatedTopics: [] }),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const searxngService = new SearchProviderService({ searxngUrl: 'http://searxng:8080' });
|
|
221
|
+
const result = await searxngService.search('test');
|
|
222
|
+
|
|
223
|
+
// Should fall through to DuckDuckGo
|
|
224
|
+
expect(result.provider).toBe('duckduckgo');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should strip trailing slashes from SearXNG URL', async () => {
|
|
228
|
+
const mockFetch = global.fetch as any;
|
|
229
|
+
mockFetch.mockResolvedValue({
|
|
230
|
+
ok: true,
|
|
231
|
+
json: async () => ({ results: [] }),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const searxngService = new SearchProviderService({ searxngUrl: 'http://searxng:8080///' });
|
|
235
|
+
await searxngService.search('test', { provider: 'searxng' });
|
|
236
|
+
|
|
237
|
+
const calledUrl = mockFetch.mock.calls[0][0] as string;
|
|
238
|
+
expect(calledUrl).toMatch(/^http:\/\/searxng:8080\/search\?/);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('normalizeUrl', () => {
|
|
243
|
+
it('should strip www prefix', () => {
|
|
244
|
+
expect(SearchProviderService.normalizeUrl('https://www.example.com/page'))
|
|
245
|
+
.toBe('https://example.com/page');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should strip tracking params (utm_*, fbclid, gclid)', () => {
|
|
249
|
+
const url = 'https://example.com/page?utm_source=google&utm_medium=cpc&fbclid=abc&real=keep';
|
|
250
|
+
const normalized = SearchProviderService.normalizeUrl(url);
|
|
251
|
+
expect(normalized).toContain('real=keep');
|
|
252
|
+
expect(normalized).not.toContain('utm_source');
|
|
253
|
+
expect(normalized).not.toContain('fbclid');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should strip trailing slashes', () => {
|
|
257
|
+
expect(SearchProviderService.normalizeUrl('https://example.com/page/'))
|
|
258
|
+
.toBe('https://example.com/page');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should sort remaining query params', () => {
|
|
262
|
+
const url = 'https://example.com/page?z=1&a=2';
|
|
263
|
+
const normalized = SearchProviderService.normalizeUrl(url);
|
|
264
|
+
expect(normalized).toBe('https://example.com/page?a=2&z=1');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should handle invalid URLs gracefully', () => {
|
|
268
|
+
expect(SearchProviderService.normalizeUrl('not a url')).toBe('not a url');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('multiSearch', () => {
|
|
273
|
+
it('should fan out to all available providers + duckduckgo', async () => {
|
|
274
|
+
const mockFetch = global.fetch as any;
|
|
275
|
+
mockFetch.mockImplementation(async (url: string) => {
|
|
276
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
277
|
+
if (urlStr.includes('serper.dev')) {
|
|
278
|
+
return { ok: true, json: async () => ({ organic: [
|
|
279
|
+
{ title: 'Result A', link: 'https://example.com/a', snippet: 'Snippet A', position: 1 },
|
|
280
|
+
{ title: 'Result B', link: 'https://example.com/b', snippet: 'Snippet B', position: 2 },
|
|
281
|
+
] }) };
|
|
282
|
+
}
|
|
283
|
+
if (urlStr.includes('serpapi.com')) {
|
|
284
|
+
return { ok: true, json: async () => ({ organic_results: [
|
|
285
|
+
{ title: 'Result A', link: 'https://example.com/a', snippet: 'Snippet A longer', position: 1 },
|
|
286
|
+
{ title: 'Result C', link: 'https://example.com/c', snippet: 'Snippet C', position: 2 },
|
|
287
|
+
] }) };
|
|
288
|
+
}
|
|
289
|
+
if (urlStr.includes('brave.com')) {
|
|
290
|
+
return { ok: true, json: async () => ({ web: { results: [
|
|
291
|
+
{ title: 'Result A', url: 'https://example.com/a', description: 'Snippet A brave' },
|
|
292
|
+
] } }) };
|
|
293
|
+
}
|
|
294
|
+
if (urlStr.includes('localhost:8888')) {
|
|
295
|
+
return { ok: true, json: async () => ({ results: [
|
|
296
|
+
{ title: 'Result A', url: 'https://example.com/a', content: 'Snippet A searxng' },
|
|
297
|
+
{ title: 'Result E', url: 'https://example.com/e', content: 'Snippet E' },
|
|
298
|
+
] }) };
|
|
299
|
+
}
|
|
300
|
+
// duckduckgo
|
|
301
|
+
return { ok: true, json: async () => ({ Heading: 'Result D', AbstractText: 'DDG snippet', AbstractURL: 'https://example.com/d', RelatedTopics: [] }) };
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const result = await service.multiSearch('test query');
|
|
305
|
+
|
|
306
|
+
expect(result.metadata.providersQueried).toContain('serper');
|
|
307
|
+
expect(result.metadata.providersQueried).toContain('searxng');
|
|
308
|
+
expect(result.metadata.providersQueried).toContain('duckduckgo');
|
|
309
|
+
expect(result.metadata.deduplicatedCount).toBeLessThanOrEqual(result.metadata.totalRawResults);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should deduplicate results across providers', async () => {
|
|
313
|
+
const mockFetch = global.fetch as any;
|
|
314
|
+
// Route mocks by URL since Promise.allSettled doesn't guarantee call order
|
|
315
|
+
mockFetch.mockImplementation(async (url: string) => {
|
|
316
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
317
|
+
if (urlStr.includes('serper.dev')) {
|
|
318
|
+
return { ok: true, json: async () => ({ organic: [{ title: 'Same', link: 'https://example.com/same', snippet: 'Same snippet', position: 1 }] }) };
|
|
319
|
+
}
|
|
320
|
+
if (urlStr.includes('serpapi.com')) {
|
|
321
|
+
return { ok: true, json: async () => ({ organic_results: [{ title: 'Same', link: 'https://example.com/same', snippet: 'Same snippet', position: 1 }] }) };
|
|
322
|
+
}
|
|
323
|
+
if (urlStr.includes('brave.com')) {
|
|
324
|
+
return { ok: true, json: async () => ({ web: { results: [{ title: 'Same', url: 'https://example.com/same', description: 'Same snippet' }] } }) };
|
|
325
|
+
}
|
|
326
|
+
if (urlStr.includes('localhost:8888')) {
|
|
327
|
+
return { ok: true, json: async () => ({ results: [{ title: 'Same', url: 'https://example.com/same', content: 'Same snippet from SearXNG' }] }) };
|
|
328
|
+
}
|
|
329
|
+
// duckduckgo
|
|
330
|
+
return { ok: true, json: async () => ({ Heading: 'Same', AbstractText: 'Same', AbstractURL: 'https://example.com/same', RelatedTopics: [] }) };
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const result = await service.multiSearch('test');
|
|
334
|
+
|
|
335
|
+
// Should be deduplicated to 1 result
|
|
336
|
+
expect(result.results).toHaveLength(1);
|
|
337
|
+
expect(result.results[0].agreementCount).toBeGreaterThanOrEqual(4);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should rank results with more provider agreement higher', async () => {
|
|
341
|
+
const mockFetch = global.fetch as any;
|
|
342
|
+
mockFetch.mockImplementation(async (url: string) => {
|
|
343
|
+
const urlStr = typeof url === 'string' ? url : url.toString();
|
|
344
|
+
if (urlStr.includes('serper.dev')) {
|
|
345
|
+
return { ok: true, json: async () => ({ organic: [
|
|
346
|
+
{ title: 'A', link: 'https://example.com/a', snippet: 'A', position: 1 },
|
|
347
|
+
{ title: 'B', link: 'https://example.com/b', snippet: 'B', position: 2 },
|
|
348
|
+
] }) };
|
|
349
|
+
}
|
|
350
|
+
if (urlStr.includes('serpapi.com')) {
|
|
351
|
+
return { ok: true, json: async () => ({ organic_results: [
|
|
352
|
+
{ title: 'A', link: 'https://example.com/a', snippet: 'A', position: 1 },
|
|
353
|
+
] }) };
|
|
354
|
+
}
|
|
355
|
+
if (urlStr.includes('brave.com')) {
|
|
356
|
+
return { ok: true, json: async () => ({ web: { results: [
|
|
357
|
+
{ title: 'A', url: 'https://example.com/a', description: 'A' },
|
|
358
|
+
] } }) };
|
|
359
|
+
}
|
|
360
|
+
if (urlStr.includes('localhost:8888')) {
|
|
361
|
+
return { ok: true, json: async () => ({ results: [
|
|
362
|
+
{ title: 'A', url: 'https://example.com/a', content: 'A' },
|
|
363
|
+
] }) };
|
|
364
|
+
}
|
|
365
|
+
// duckduckgo returns B only
|
|
366
|
+
return { ok: true, json: async () => ({ Heading: 'B', AbstractText: 'B', AbstractURL: 'https://example.com/b', RelatedTopics: [] }) };
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const result = await service.multiSearch('test');
|
|
370
|
+
|
|
371
|
+
// A appears in 4 providers, B in 2 — A should rank first
|
|
372
|
+
expect(result.results[0].url).toBe('https://example.com/a');
|
|
373
|
+
expect(result.results[0].confidenceScore).toBeGreaterThan(result.results[1].confidenceScore);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should handle all providers failing gracefully', async () => {
|
|
377
|
+
const mockFetch = global.fetch as any;
|
|
378
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
379
|
+
|
|
380
|
+
const result = await service.multiSearch('test');
|
|
381
|
+
|
|
382
|
+
expect(result.results).toHaveLength(0);
|
|
383
|
+
expect(result.metadata.providersFailed.length).toBeGreaterThan(0);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
145
387
|
describe('getRecommendedProviders', () => {
|
|
146
|
-
it('should return provider recommendations', () => {
|
|
388
|
+
it('should return provider recommendations including SearXNG', () => {
|
|
147
389
|
const providers = SearchProviderService.getRecommendedProviders();
|
|
148
|
-
expect(providers).toHaveLength(
|
|
390
|
+
expect(providers).toHaveLength(5);
|
|
149
391
|
expect(providers[0].name).toBe('Serper');
|
|
150
392
|
expect(providers[0].signupUrl).toContain('serper.dev');
|
|
393
|
+
const searxng = providers.find((p) => p.name === 'SearXNG');
|
|
394
|
+
expect(searxng).toBeDefined();
|
|
395
|
+
expect(searxng!.freeQuota).toContain('Unlimited');
|
|
151
396
|
});
|
|
152
397
|
});
|
|
153
398
|
});
|
package/test/webSearch.spec.ts
CHANGED
|
@@ -8,7 +8,13 @@ vi.mock('../src/services/searchProvider');
|
|
|
8
8
|
describe('WebSearchTool', () => {
|
|
9
9
|
let tool: WebSearchTool;
|
|
10
10
|
let mockSearchService: vi.Mocked<SearchProviderService>;
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
const ctx: any = {
|
|
13
|
+
gmiId: 'test-gmi',
|
|
14
|
+
personaId: 'test-persona',
|
|
15
|
+
userContext: { userId: 'test-user' },
|
|
16
|
+
};
|
|
17
|
+
|
|
12
18
|
beforeEach(() => {
|
|
13
19
|
mockSearchService = new SearchProviderService({}) as any;
|
|
14
20
|
tool = new WebSearchTool(mockSearchService);
|
|
@@ -17,8 +23,9 @@ describe('WebSearchTool', () => {
|
|
|
17
23
|
|
|
18
24
|
describe('constructor', () => {
|
|
19
25
|
it('should initialize with correct metadata', () => {
|
|
20
|
-
expect(tool.id).toBe('
|
|
21
|
-
expect(tool.name).toBe('
|
|
26
|
+
expect(tool.id).toBe('web-search-v1');
|
|
27
|
+
expect(tool.name).toBe('web_search');
|
|
28
|
+
expect(tool.displayName).toBe('Web Search');
|
|
22
29
|
expect(tool.description).toContain('Search the web');
|
|
23
30
|
});
|
|
24
31
|
});
|
|
@@ -41,11 +48,12 @@ describe('WebSearchTool', () => {
|
|
|
41
48
|
provider: 'serper' as const
|
|
42
49
|
};
|
|
43
50
|
|
|
44
|
-
const result = await tool.execute(input);
|
|
51
|
+
const result = await tool.execute(input, ctx);
|
|
45
52
|
|
|
46
53
|
expect(mockSearchService.search).toHaveBeenCalledWith('test query', {
|
|
47
54
|
maxResults: 5,
|
|
48
|
-
provider: 'serper'
|
|
55
|
+
provider: 'serper',
|
|
56
|
+
category: undefined,
|
|
49
57
|
});
|
|
50
58
|
expect(result.success).toBe(true);
|
|
51
59
|
expect(result.output).toEqual(mockResults);
|
|
@@ -56,7 +64,7 @@ describe('WebSearchTool', () => {
|
|
|
56
64
|
mockSearchService.search = vi.fn().mockRejectedValue(error);
|
|
57
65
|
|
|
58
66
|
const input = { query: 'test query' };
|
|
59
|
-
const result = await tool.execute(input);
|
|
67
|
+
const result = await tool.execute(input as any, ctx);
|
|
60
68
|
|
|
61
69
|
expect(result.success).toBe(false);
|
|
62
70
|
expect(result.error).toBe('API error');
|
|
@@ -68,61 +76,155 @@ describe('WebSearchTool', () => {
|
|
|
68
76
|
results: [],
|
|
69
77
|
metadata: {}
|
|
70
78
|
});
|
|
71
|
-
|
|
79
|
+
|
|
72
80
|
const input = { query: 'test query' };
|
|
73
|
-
await tool.execute(input);
|
|
74
|
-
|
|
81
|
+
await tool.execute(input as any, ctx);
|
|
82
|
+
|
|
75
83
|
expect(mockSearchService.search).toHaveBeenCalledWith('test query', {
|
|
76
84
|
maxResults: 10,
|
|
77
|
-
provider: undefined
|
|
85
|
+
provider: undefined,
|
|
86
|
+
category: undefined,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should pass category to search service', async () => {
|
|
91
|
+
mockSearchService.search = vi.fn().mockResolvedValue({
|
|
92
|
+
provider: 'searxng',
|
|
93
|
+
results: [],
|
|
94
|
+
metadata: {}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await tool.execute({ query: 'AI news', provider: 'searxng', category: 'news' }, ctx);
|
|
98
|
+
|
|
99
|
+
expect(mockSearchService.search).toHaveBeenCalledWith('AI news', {
|
|
100
|
+
maxResults: 10,
|
|
101
|
+
provider: 'searxng',
|
|
102
|
+
category: 'news',
|
|
78
103
|
});
|
|
79
104
|
});
|
|
80
105
|
});
|
|
81
106
|
|
|
82
|
-
describe('
|
|
107
|
+
describe('validateArgs', () => {
|
|
83
108
|
it('should validate required query parameter', () => {
|
|
84
|
-
const result = tool.
|
|
85
|
-
expect(result.
|
|
109
|
+
const result = tool.validateArgs({});
|
|
110
|
+
expect(result.isValid).toBe(false);
|
|
86
111
|
expect(result.errors).toContain('Query is required');
|
|
87
112
|
});
|
|
88
113
|
|
|
89
114
|
it('should validate query is a string', () => {
|
|
90
|
-
const result = tool.
|
|
91
|
-
expect(result.
|
|
115
|
+
const result = tool.validateArgs({ query: 123 });
|
|
116
|
+
expect(result.isValid).toBe(false);
|
|
92
117
|
expect(result.errors).toContain('Query must be a string');
|
|
93
118
|
});
|
|
94
119
|
|
|
95
120
|
it('should validate maxResults is a positive number', () => {
|
|
96
|
-
const result = tool.
|
|
97
|
-
expect(result.
|
|
121
|
+
const result = tool.validateArgs({ query: 'test', maxResults: -1 });
|
|
122
|
+
expect(result.isValid).toBe(false);
|
|
98
123
|
expect(result.errors).toContain('maxResults must be a positive number');
|
|
99
124
|
});
|
|
100
125
|
|
|
101
126
|
it('should validate provider is from allowed list', () => {
|
|
102
|
-
const result = tool.
|
|
103
|
-
expect(result.
|
|
127
|
+
const result = tool.validateArgs({ query: 'test', provider: 'invalid' });
|
|
128
|
+
expect(result.isValid).toBe(false);
|
|
104
129
|
expect(result.errors).toContain('Invalid provider');
|
|
105
130
|
});
|
|
106
|
-
|
|
131
|
+
|
|
107
132
|
it('should pass validation with correct inputs', () => {
|
|
108
|
-
const result = tool.
|
|
133
|
+
const result = tool.validateArgs({
|
|
109
134
|
query: 'test query',
|
|
110
135
|
maxResults: 5,
|
|
111
136
|
provider: 'serper'
|
|
112
137
|
});
|
|
113
|
-
expect(result.
|
|
114
|
-
|
|
138
|
+
expect(result.isValid).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should accept searxng as a valid provider', () => {
|
|
142
|
+
const result = tool.validateArgs({ query: 'test', provider: 'searxng' });
|
|
143
|
+
expect(result.isValid).toBe(true);
|
|
115
144
|
});
|
|
116
145
|
});
|
|
117
146
|
|
|
118
|
-
describe('
|
|
147
|
+
describe('multiSearch', () => {
|
|
148
|
+
it('should call multiSearch when multiSearch input is true', async () => {
|
|
149
|
+
const mockMultiResults = {
|
|
150
|
+
results: [{ title: 'Multi', url: 'https://example.com', snippet: 'Multi result', providers: ['serper', 'brave'], agreementCount: 2, confidenceScore: 80, providerPositions: { serper: 1, brave: 1 } }],
|
|
151
|
+
metadata: { query: 'test', timestamp: new Date().toISOString(), totalResponseTime: 100, providersQueried: ['serper', 'brave'], providersSucceeded: ['serper', 'brave'], providersFailed: [], totalRawResults: 2, deduplicatedCount: 1 },
|
|
152
|
+
};
|
|
153
|
+
mockSearchService.multiSearch = vi.fn().mockResolvedValue(mockMultiResults);
|
|
154
|
+
|
|
155
|
+
const result = await tool.execute({ query: 'test', multiSearch: true }, ctx);
|
|
156
|
+
|
|
157
|
+
expect(mockSearchService.multiSearch).toHaveBeenCalledWith('test', { maxResults: 10 });
|
|
158
|
+
expect(result.success).toBe(true);
|
|
159
|
+
expect(result.output).toEqual(mockMultiResults);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should use defaultMultiSearch from constructor when input omits multiSearch', async () => {
|
|
163
|
+
const toolWithDefault = new WebSearchTool(mockSearchService, true);
|
|
164
|
+
const mockMultiResults = {
|
|
165
|
+
results: [],
|
|
166
|
+
metadata: { query: 'test', timestamp: new Date().toISOString(), totalResponseTime: 50, providersQueried: [], providersSucceeded: [], providersFailed: [], totalRawResults: 0, deduplicatedCount: 0 },
|
|
167
|
+
};
|
|
168
|
+
mockSearchService.multiSearch = vi.fn().mockResolvedValue(mockMultiResults);
|
|
169
|
+
|
|
170
|
+
await toolWithDefault.execute({ query: 'test' }, ctx);
|
|
171
|
+
|
|
172
|
+
expect(mockSearchService.multiSearch).toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should fall back to single search when provider is specified even with multiSearch', async () => {
|
|
176
|
+
mockSearchService.search = vi.fn().mockResolvedValue({ provider: 'serper', results: [], metadata: {} });
|
|
177
|
+
|
|
178
|
+
await tool.execute({ query: 'test', multiSearch: true, provider: 'serper' }, ctx);
|
|
179
|
+
|
|
180
|
+
// When provider is specified, multiSearch is ignored — single provider search is used
|
|
181
|
+
expect(mockSearchService.search).toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('validateArgs - multiSearch', () => {
|
|
186
|
+
it('should reject multiSearch + provider combination', () => {
|
|
187
|
+
const result = tool.validateArgs({ query: 'test', multiSearch: true, provider: 'serper' });
|
|
188
|
+
expect(result.isValid).toBe(false);
|
|
189
|
+
expect(result.errors).toContain('Cannot specify both multiSearch and a specific provider');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should reject non-boolean multiSearch', () => {
|
|
193
|
+
const result = tool.validateArgs({ query: 'test', multiSearch: 'yes' });
|
|
194
|
+
expect(result.isValid).toBe(false);
|
|
195
|
+
expect(result.errors).toContain('multiSearch must be a boolean');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should accept multiSearch: true without provider', () => {
|
|
199
|
+
const result = tool.validateArgs({ query: 'test', multiSearch: true });
|
|
200
|
+
expect(result.isValid).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('inputSchema', () => {
|
|
119
205
|
it('should return proper JSON schema', () => {
|
|
120
|
-
const schema = tool.
|
|
206
|
+
const schema = tool.inputSchema;
|
|
121
207
|
expect(schema.type).toBe('object');
|
|
122
208
|
expect(schema.required).toEqual(['query']);
|
|
123
209
|
expect(schema.properties.query.type).toBe('string');
|
|
124
|
-
expect(schema.properties.maxResults.type).toBe('
|
|
210
|
+
expect(schema.properties.maxResults.type).toBe('integer');
|
|
125
211
|
expect(schema.properties.provider.enum).toContain('serper');
|
|
212
|
+
expect(schema.properties.provider.enum).toContain('searxng');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should include multiSearch in schema', () => {
|
|
216
|
+
const schema = tool.inputSchema;
|
|
217
|
+
expect(schema.properties.multiSearch).toBeDefined();
|
|
218
|
+
expect(schema.properties.multiSearch.type).toBe('boolean');
|
|
219
|
+
expect(schema.properties.multiSearch.default).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should include category field for SearXNG', () => {
|
|
223
|
+
const schema = tool.inputSchema;
|
|
224
|
+
expect(schema.properties.category).toBeDefined();
|
|
225
|
+
expect(schema.properties.category.type).toBe('string');
|
|
226
|
+
expect(schema.properties.category.enum).toContain('news');
|
|
227
|
+
expect(schema.properties.category.enum).toContain('science');
|
|
126
228
|
});
|
|
127
229
|
});
|
|
128
|
-
});
|
|
230
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"target": "ES2022",
|
|
4
|
-
"module": "
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
5
6
|
"lib": ["ES2022"],
|
|
7
|
+
"baseUrl": ".",
|
|
8
|
+
"paths": {
|
|
9
|
+
"@framers/agentos": ["../../../../../agentos/dist/index.d.ts"],
|
|
10
|
+
"@framers/agentos/*": ["../../../../../agentos/dist/*"]
|
|
11
|
+
},
|
|
6
12
|
"outDir": "./dist",
|
|
7
13
|
"rootDir": "./src",
|
|
8
14
|
"strict": true,
|
|
@@ -13,7 +19,6 @@
|
|
|
13
19
|
"declarationMap": true,
|
|
14
20
|
"sourceMap": true,
|
|
15
21
|
"resolveJsonModule": true,
|
|
16
|
-
"moduleResolution": "node",
|
|
17
22
|
"types": ["node"]
|
|
18
23
|
},
|
|
19
24
|
"include": ["src/**/*"],
|