@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.
@@ -1,128 +1,129 @@
1
- import { ITool } from '@framers/agentos';
2
- import { SearchProviderService } from '../services/searchProvider';
3
-
4
1
  /**
5
- * Web search tool that queries multiple search providers
6
- *
7
- * @class WebSearchTool
8
- * @implements {ITool}
9
- *
10
- * @example
11
- * ```typescript
12
- * const tool = new WebSearchTool(searchService);
13
- * const result = await tool.execute({
14
- * query: 'AgentOS extensions',
15
- * maxResults: 10,
16
- * provider: 'serper'
17
- * });
18
- * ```
2
+ * Web Search Tool — multi-provider search via SearchProviderService.
3
+ *
4
+ * Updated to conform to AgentOS `ITool` (inputSchema + ToolExecutionContext).
5
+ *
6
+ * @module @framers/agentos-ext-web-search
19
7
  */
20
- export class WebSearchTool implements ITool {
21
- public readonly id = 'webSearch';
22
- public readonly name = 'Web Search';
23
- public readonly description = 'Search the web using multiple providers';
24
-
25
- /**
26
- * Creates an instance of WebSearchTool
27
- * @param {SearchProviderService} searchService - The search provider service
28
- */
29
- constructor(private searchService: SearchProviderService) {}
30
-
31
- /**
32
- * Executes a web search query
33
- *
34
- * @param {Object} input - The search parameters
35
- * @param {string} input.query - The search query string
36
- * @param {number} [input.maxResults=10] - Maximum number of results to return
37
- * @param {string} [input.provider] - Specific provider to use (serper, serpapi, brave, duckduckgo)
38
- * @returns {Promise<{success: boolean, output?: any, error?: string}>} The search results
39
- *
40
- * @throws {Error} Throws if all providers fail
41
- */
42
- async execute(input: {
43
- query: string;
44
- maxResults?: number;
45
- provider?: 'serper' | 'serpapi' | 'brave' | 'duckduckgo';
46
- }): Promise<{ success: boolean; output?: any; error?: string }> {
8
+
9
+ import type { ITool, JSONSchemaObject, ToolExecutionContext, ToolExecutionResult } from '@framers/agentos';
10
+ import type { ProviderResponse, MultiSearchResponse } from '../services/searchProvider.js';
11
+ import { SearchProviderService } from '../services/searchProvider.js';
12
+
13
+ export interface WebSearchInput {
14
+ query: string;
15
+ maxResults?: number;
16
+ provider?: 'serper' | 'serpapi' | 'brave' | 'searxng' | 'duckduckgo';
17
+ multiSearch?: boolean;
18
+ category?: string;
19
+ }
20
+
21
+ export type WebSearchOutput = ProviderResponse | MultiSearchResponse;
22
+
23
+ export class WebSearchTool implements ITool<WebSearchInput, WebSearchOutput> {
24
+ public readonly id = 'web-search-v1';
25
+ /** Tool call name used by the LLM / ToolExecutor. */
26
+ public readonly name = 'web_search';
27
+ public readonly displayName = 'Web Search';
28
+ public readonly description =
29
+ 'Search the web using multiple providers (Serper, SerpAPI, Brave, SearXNG, DuckDuckGo fallback). Set multiSearch=true to query ALL providers in parallel for higher-confidence, deduplicated results.';
30
+ public readonly category = 'research';
31
+ public readonly hasSideEffects = false;
32
+
33
+ public readonly inputSchema: JSONSchemaObject = {
34
+ type: 'object',
35
+ required: ['query'],
36
+ properties: {
37
+ query: {
38
+ type: 'string',
39
+ description: 'The search query',
40
+ },
41
+ maxResults: {
42
+ type: 'integer',
43
+ description: 'Maximum number of results to return',
44
+ default: 10,
45
+ minimum: 1,
46
+ maximum: 50,
47
+ },
48
+ provider: {
49
+ type: 'string',
50
+ description: 'Specific search provider to use (ignored when multiSearch is true)',
51
+ enum: ['serper', 'serpapi', 'brave', 'searxng', 'duckduckgo'],
52
+ },
53
+ multiSearch: {
54
+ type: 'boolean',
55
+ description:
56
+ 'When true, searches ALL available providers in parallel and returns merged, deduplicated results ranked by cross-provider agreement. Useful for deep research or fact verification.',
57
+ default: false,
58
+ },
59
+ category: {
60
+ type: 'string',
61
+ description: 'Search category (SearXNG only). Options: general, news, images, videos, it, science, files, social_media',
62
+ enum: ['general', 'news', 'images', 'videos', 'it', 'science', 'files', 'social_media'],
63
+ },
64
+ },
65
+ additionalProperties: false,
66
+ };
67
+
68
+ public readonly requiredCapabilities = ['capability:web_search'];
69
+
70
+ constructor(
71
+ private readonly searchService: SearchProviderService,
72
+ private readonly defaultMultiSearch: boolean = false,
73
+ ) {}
74
+
75
+ async execute(input: WebSearchInput, _context: ToolExecutionContext): Promise<ToolExecutionResult<WebSearchOutput>> {
47
76
  try {
77
+ const useMultiSearch = input.multiSearch ?? this.defaultMultiSearch;
78
+
79
+ if (useMultiSearch && !input.provider) {
80
+ const results = await this.searchService.multiSearch(input.query, {
81
+ maxResults: input.maxResults || 10,
82
+ });
83
+ return { success: true, output: results };
84
+ }
85
+
48
86
  const results = await this.searchService.search(input.query, {
49
87
  maxResults: input.maxResults || 10,
50
- provider: input.provider
88
+ provider: input.provider,
89
+ category: input.category,
51
90
  });
52
-
53
- return {
54
- success: true,
55
- output: results
56
- };
91
+ return { success: true, output: results };
57
92
  } catch (error: any) {
58
- return {
59
- success: false,
60
- error: error.message
61
- };
93
+ return { success: false, error: error?.message || String(error) };
62
94
  }
63
95
  }
64
-
65
- /**
66
- * Validates input parameters for the web search
67
- *
68
- * @param {any} input - The input to validate
69
- * @returns {{valid: boolean, errors: string[]}} Validation result
70
- */
71
- validate(input: any): { valid: boolean; errors: string[] } {
96
+
97
+ validateArgs(input: Record<string, any>): { isValid: boolean; errors?: any[] } {
72
98
  const errors: string[] = [];
73
-
99
+
74
100
  if (!input.query) {
75
101
  errors.push('Query is required');
76
102
  } else if (typeof input.query !== 'string') {
77
103
  errors.push('Query must be a string');
78
104
  }
79
-
105
+
80
106
  if (input.maxResults !== undefined) {
81
107
  if (typeof input.maxResults !== 'number' || input.maxResults <= 0) {
82
108
  errors.push('maxResults must be a positive number');
83
109
  }
84
110
  }
85
-
111
+
86
112
  if (input.provider !== undefined) {
87
- const validProviders = ['serper', 'serpapi', 'brave', 'duckduckgo'];
113
+ const validProviders = ['serper', 'serpapi', 'brave', 'searxng', 'duckduckgo'];
88
114
  if (!validProviders.includes(input.provider)) {
89
115
  errors.push('Invalid provider');
90
116
  }
91
117
  }
92
-
93
- return {
94
- valid: errors.length === 0,
95
- errors
96
- };
97
- }
98
-
99
- /**
100
- * Returns the JSON schema for the tool's input
101
- *
102
- * @returns {Object} JSON Schema object
103
- */
104
- getSchema(): any {
105
- return {
106
- type: 'object',
107
- required: ['query'],
108
- properties: {
109
- query: {
110
- type: 'string',
111
- description: 'The search query'
112
- },
113
- maxResults: {
114
- type: 'number',
115
- description: 'Maximum number of results to return',
116
- default: 10,
117
- minimum: 1,
118
- maximum: 50
119
- },
120
- provider: {
121
- type: 'string',
122
- description: 'Specific search provider to use',
123
- enum: ['serper', 'serpapi', 'brave', 'duckduckgo']
124
- }
125
- }
126
- };
118
+
119
+ if (input.multiSearch !== undefined && typeof input.multiSearch !== 'boolean') {
120
+ errors.push('multiSearch must be a boolean');
121
+ }
122
+
123
+ if (input.multiSearch && input.provider) {
124
+ errors.push('Cannot specify both multiSearch and a specific provider');
125
+ }
126
+
127
+ return errors.length === 0 ? { isValid: true } : { isValid: false, errors };
127
128
  }
128
- }
129
+ }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { createExtensionPack } from '../src';
3
3
  import { ExtensionContext } from '@framers/agentos';
4
4
 
@@ -7,6 +7,16 @@ describe('Web Search Extension Integration', () => {
7
7
  let context: ExtensionContext;
8
8
 
9
9
  beforeEach(() => {
10
+ global.fetch = vi.fn().mockResolvedValue({
11
+ ok: true,
12
+ json: async () => ({
13
+ Heading: 'Test',
14
+ AbstractText: 'Test abstract',
15
+ AbstractURL: 'https://example.com',
16
+ RelatedTopics: [],
17
+ }),
18
+ }) as any;
19
+
10
20
  context = {
11
21
  options: {
12
22
  serperApiKey: process.env.SERPER_API_KEY,
@@ -27,15 +37,15 @@ describe('Web Search Extension Integration', () => {
27
37
  describe('Extension Pack Creation', () => {
28
38
  it('should create extension pack with correct metadata', () => {
29
39
  expect(extensionPack.name).toBe('@framers/agentos-ext-web-search');
30
- expect(extensionPack.version).toBe('1.0.0');
40
+ expect(extensionPack.version).toBe('1.1.0');
31
41
  expect(extensionPack.descriptors).toHaveLength(3);
32
42
  });
33
43
 
34
44
  it('should register all three tools', () => {
35
45
  const toolIds = extensionPack.descriptors.map((d: any) => d.id);
36
- expect(toolIds).toContain('webSearch');
37
- expect(toolIds).toContain('researchAggregator');
38
- expect(toolIds).toContain('factCheck');
46
+ expect(toolIds).toContain('web_search');
47
+ expect(toolIds).toContain('research_aggregate');
48
+ expect(toolIds).toContain('fact_check');
39
49
  });
40
50
 
41
51
  it('should set correct tool kinds', () => {
@@ -48,13 +58,17 @@ describe('Web Search Extension Integration', () => {
48
58
  describe('Tool Execution', () => {
49
59
  it('should execute web search tool', async () => {
50
60
  const webSearchDescriptor = extensionPack.descriptors.find(
51
- (d: any) => d.id === 'webSearch'
61
+ (d: any) => d.id === 'web_search'
52
62
  );
53
63
  const webSearchTool = webSearchDescriptor.payload;
54
64
 
55
65
  // Test with DuckDuckGo (no API key required)
56
66
  const result = await webSearchTool.execute({
57
67
  query: 'AgentOS extensions'
68
+ }, {
69
+ gmiId: 'test-gmi',
70
+ personaId: 'test-persona',
71
+ userContext: { userId: 'test-user' },
58
72
  });
59
73
 
60
74
  expect(result.success).toBe(true);
@@ -65,13 +79,17 @@ describe('Web Search Extension Integration', () => {
65
79
 
66
80
  it('should execute research aggregator tool', async () => {
67
81
  const aggregatorDescriptor = extensionPack.descriptors.find(
68
- (d: any) => d.id === 'researchAggregator'
82
+ (d: any) => d.id === 'research_aggregate'
69
83
  );
70
84
  const aggregatorTool = aggregatorDescriptor.payload;
71
85
 
72
86
  const result = await aggregatorTool.execute({
73
87
  topic: 'TypeScript testing',
74
88
  sources: 2
89
+ }, {
90
+ gmiId: 'test-gmi',
91
+ personaId: 'test-persona',
92
+ userContext: { userId: 'test-user' },
75
93
  });
76
94
 
77
95
  expect(result.success).toBe(true);
@@ -82,13 +100,17 @@ describe('Web Search Extension Integration', () => {
82
100
 
83
101
  it('should execute fact check tool', async () => {
84
102
  const factCheckDescriptor = extensionPack.descriptors.find(
85
- (d: any) => d.id === 'factCheck'
103
+ (d: any) => d.id === 'fact_check'
86
104
  );
87
105
  const factCheckTool = factCheckDescriptor.payload;
88
106
 
89
107
  const result = await factCheckTool.execute({
90
108
  statement: 'TypeScript was created by Microsoft',
91
109
  checkSources: true
110
+ }, {
111
+ gmiId: 'test-gmi',
112
+ personaId: 'test-persona',
113
+ userContext: { userId: 'test-user' },
92
114
  });
93
115
 
94
116
  expect(result.success).toBe(true);
@@ -111,6 +133,10 @@ describe('Web Search Extension Integration', () => {
111
133
 
112
134
  const result = await webSearch.execute({
113
135
  query: 'test query'
136
+ }, {
137
+ gmiId: 'test-gmi',
138
+ personaId: 'test-persona',
139
+ userContext: { userId: 'test-user' },
114
140
  });
115
141
 
116
142
  // Should fallback to DuckDuckGo
@@ -120,54 +146,229 @@ describe('Web Search Extension Integration', () => {
120
146
 
121
147
  it('should validate inputs before execution', async () => {
122
148
  const webSearchDescriptor = extensionPack.descriptors.find(
123
- (d: any) => d.id === 'webSearch'
149
+ (d: any) => d.id === 'web_search'
124
150
  );
125
151
  const webSearchTool = webSearchDescriptor.payload;
126
152
 
127
- const validation = webSearchTool.validate({
153
+ const validation = webSearchTool.validateArgs({
128
154
  // Missing required 'query' field
129
155
  maxResults: 5
130
156
  });
131
157
 
132
- expect(validation.valid).toBe(false);
158
+ expect(validation.isValid).toBe(false);
133
159
  expect(validation.errors).toContain('Query is required');
134
160
  });
135
161
  });
136
162
 
163
+ describe('Multi-Search Integration', () => {
164
+ it('should create tools with defaultMultiSearch option', () => {
165
+ const multiContext = {
166
+ options: {
167
+ ...context.options,
168
+ defaultMultiSearch: true,
169
+ },
170
+ logger: context.logger,
171
+ } as any;
172
+
173
+ const multiPack = createExtensionPack(multiContext);
174
+ expect(multiPack.descriptors).toHaveLength(3);
175
+
176
+ // Verify tools were created (defaultMultiSearch is internal, just check they still work)
177
+ const webSearch = multiPack.descriptors.find((d: any) => d.id === 'web_search');
178
+ expect(webSearch).toBeDefined();
179
+ });
180
+
181
+ it('should execute web_search with multiSearch: true', async () => {
182
+ const webSearchTool = extensionPack.descriptors.find(
183
+ (d: any) => d.id === 'web_search'
184
+ ).payload;
185
+
186
+ const result = await webSearchTool.execute({
187
+ query: 'multi search test',
188
+ multiSearch: true,
189
+ }, {
190
+ gmiId: 'test-gmi',
191
+ personaId: 'test-persona',
192
+ userContext: { userId: 'test-user' },
193
+ });
194
+
195
+ expect(result.success).toBe(true);
196
+ // Multi-search returns metadata with provider info
197
+ expect(result.output).toHaveProperty('metadata');
198
+ });
199
+
200
+ it('should execute research_aggregate with multiSearch: true', async () => {
201
+ const aggregatorTool = extensionPack.descriptors.find(
202
+ (d: any) => d.id === 'research_aggregate'
203
+ ).payload;
204
+
205
+ const result = await aggregatorTool.execute({
206
+ topic: 'test topic',
207
+ sources: 1,
208
+ depth: 'quick',
209
+ multiSearch: true,
210
+ }, {
211
+ gmiId: 'test-gmi',
212
+ personaId: 'test-persona',
213
+ userContext: { userId: 'test-user' },
214
+ });
215
+
216
+ expect(result.success).toBe(true);
217
+ expect(result.output).toHaveProperty('aggregatedResults');
218
+ });
219
+
220
+ it('should execute fact_check with multiSearch: true', async () => {
221
+ const factCheckTool = extensionPack.descriptors.find(
222
+ (d: any) => d.id === 'fact_check'
223
+ ).payload;
224
+
225
+ const result = await factCheckTool.execute({
226
+ statement: 'The sky is blue because of Rayleigh scattering',
227
+ multiSearch: true,
228
+ }, {
229
+ gmiId: 'test-gmi',
230
+ personaId: 'test-persona',
231
+ userContext: { userId: 'test-user' },
232
+ });
233
+
234
+ expect(result.success).toBe(true);
235
+ expect(result.output).toHaveProperty('verdict');
236
+ });
237
+
238
+ it('should reject multiSearch + provider on web_search validation', () => {
239
+ const webSearchTool = extensionPack.descriptors.find(
240
+ (d: any) => d.id === 'web_search'
241
+ ).payload;
242
+
243
+ const validation = webSearchTool.validateArgs({
244
+ query: 'test',
245
+ multiSearch: true,
246
+ provider: 'serper',
247
+ });
248
+
249
+ expect(validation.isValid).toBe(false);
250
+ expect(validation.errors).toContain('Cannot specify both multiSearch and a specific provider');
251
+ });
252
+ });
253
+
254
+ describe('SearXNG Integration', () => {
255
+ it('should create extension pack with searxngUrl option', () => {
256
+ const searxngContext = {
257
+ options: {
258
+ searxngUrl: 'http://searxng:8080',
259
+ },
260
+ logger: context.logger,
261
+ } as any;
262
+
263
+ const pack = createExtensionPack(searxngContext);
264
+ expect(pack.descriptors).toHaveLength(3);
265
+ expect(pack.name).toBe('@framers/agentos-ext-web-search');
266
+ });
267
+
268
+ it('should execute web search with searxng provider', async () => {
269
+ const mockFetch = global.fetch as any;
270
+ mockFetch.mockResolvedValue({
271
+ ok: true,
272
+ json: async () => ({
273
+ results: [
274
+ { title: 'SearXNG Result', url: 'https://example.com/searxng', content: 'A result from SearXNG' },
275
+ ],
276
+ }),
277
+ });
278
+
279
+ const searxngContext = {
280
+ options: { searxngUrl: 'http://searxng:8080' },
281
+ logger: context.logger,
282
+ } as any;
283
+
284
+ const pack = createExtensionPack(searxngContext);
285
+ const webSearch = pack.descriptors.find((d: any) => d.id === 'web_search')!.payload;
286
+
287
+ const result = await webSearch.execute(
288
+ { query: 'test', provider: 'searxng' },
289
+ { gmiId: 'test-gmi', personaId: 'test-persona', userContext: { userId: 'test-user' } },
290
+ );
291
+
292
+ expect(result.success).toBe(true);
293
+ expect(result.output.provider).toBe('searxng');
294
+ expect(result.output.results[0].snippet).toBe('A result from SearXNG');
295
+ });
296
+
297
+ it('should pass category through to SearXNG', async () => {
298
+ const mockFetch = global.fetch as any;
299
+ mockFetch.mockResolvedValue({
300
+ ok: true,
301
+ json: async () => ({ results: [] }),
302
+ });
303
+
304
+ const searxngContext = {
305
+ options: { searxngUrl: 'http://searxng:8080' },
306
+ logger: context.logger,
307
+ } as any;
308
+
309
+ const pack = createExtensionPack(searxngContext);
310
+ const webSearch = pack.descriptors.find((d: any) => d.id === 'web_search')!.payload;
311
+
312
+ await webSearch.execute(
313
+ { query: 'AI news', provider: 'searxng', category: 'news' },
314
+ { gmiId: 'test-gmi', personaId: 'test-persona', userContext: { userId: 'test-user' } },
315
+ );
316
+
317
+ const calledUrl = mockFetch.mock.calls[0][0] as string;
318
+ expect(calledUrl).toContain('categories=news');
319
+ });
320
+
321
+ it('should use SearXNG as primary when it is the only configured provider', async () => {
322
+ const mockFetch = global.fetch as any;
323
+ mockFetch.mockResolvedValue({
324
+ ok: true,
325
+ json: async () => ({
326
+ results: [
327
+ { title: 'SearXNG Primary', url: 'https://example.com/primary', content: 'Primary result' },
328
+ ],
329
+ }),
330
+ });
331
+
332
+ const searxngOnlyContext = {
333
+ options: { searxngUrl: 'http://searxng:8080' },
334
+ logger: context.logger,
335
+ } as any;
336
+
337
+ const pack = createExtensionPack(searxngOnlyContext);
338
+ const webSearch = pack.descriptors.find((d: any) => d.id === 'web_search')!.payload;
339
+
340
+ const result = await webSearch.execute(
341
+ { query: 'test' },
342
+ { gmiId: 'test-gmi', personaId: 'test-persona', userContext: { userId: 'test-user' } },
343
+ );
344
+
345
+ expect(result.success).toBe(true);
346
+ // SearXNG should be used as primary (not falling back to DDG)
347
+ expect(result.output.provider).toBe('searxng');
348
+ });
349
+ });
350
+
137
351
  describe('Lifecycle Hooks', () => {
138
352
  it('should call onActivate when extension is activated', async () => {
139
- let activated = false;
140
-
141
- const customPack = createExtensionPack({
142
- ...context,
143
- onActivate: async () => {
144
- activated = true;
145
- }
146
- });
147
-
148
- if (customPack.onActivate) {
149
- await customPack.onActivate();
353
+ const logSpy = vi.fn();
354
+ const lc = { logger: { info: logSpy, warn: vi.fn(), error: vi.fn(), debug: vi.fn() } };
355
+
356
+ if (extensionPack.onActivate) {
357
+ await extensionPack.onActivate(lc);
150
358
  }
151
-
152
- expect(activated).toBe(true);
359
+
360
+ expect(logSpy).toHaveBeenCalledWith('Web Search Extension activated');
153
361
  });
154
-
362
+
155
363
  it('should call onDeactivate when extension is deactivated', async () => {
156
- let deactivated = false;
157
-
158
- const customPack = createExtensionPack({
159
- ...context,
160
- onDeactivate: async () => {
161
- deactivated = true;
162
- }
163
- });
164
-
165
- if (customPack.onDeactivate) {
166
- await customPack.onDeactivate();
364
+ const logSpy = vi.fn();
365
+ const lc = { logger: { info: logSpy, warn: vi.fn(), error: vi.fn(), debug: vi.fn() } };
366
+
367
+ if (extensionPack.onDeactivate) {
368
+ await extensionPack.onDeactivate(lc);
167
369
  }
168
-
169
- expect(deactivated).toBe(true);
370
+
371
+ expect(logSpy).toHaveBeenCalledWith('Web Search Extension deactivated');
170
372
  });
171
373
  });
172
374
  });
173
-