@framers/agentos-ext-web-search 1.1.1 → 1.2.0

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,129 +0,0 @@
1
- /**
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
7
- */
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>> {
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
-
86
- const results = await this.searchService.search(input.query, {
87
- maxResults: input.maxResults || 10,
88
- provider: input.provider,
89
- category: input.category,
90
- });
91
- return { success: true, output: results };
92
- } catch (error: any) {
93
- return { success: false, error: error?.message || String(error) };
94
- }
95
- }
96
-
97
- validateArgs(input: Record<string, any>): { isValid: boolean; errors?: any[] } {
98
- const errors: string[] = [];
99
-
100
- if (!input.query) {
101
- errors.push('Query is required');
102
- } else if (typeof input.query !== 'string') {
103
- errors.push('Query must be a string');
104
- }
105
-
106
- if (input.maxResults !== undefined) {
107
- if (typeof input.maxResults !== 'number' || input.maxResults <= 0) {
108
- errors.push('maxResults must be a positive number');
109
- }
110
- }
111
-
112
- if (input.provider !== undefined) {
113
- const validProviders = ['serper', 'serpapi', 'brave', 'searxng', 'duckduckgo'];
114
- if (!validProviders.includes(input.provider)) {
115
- errors.push('Invalid provider');
116
- }
117
- }
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 };
128
- }
129
- }
package/src/types.ts DELETED
@@ -1,41 +0,0 @@
1
- /**
2
- * @file Type definitions for search extension
3
- */
4
-
5
- export interface SearchResult {
6
- title: string;
7
- snippet: string;
8
- url: string;
9
- source?: string;
10
- publishedDate?: string;
11
- thumbnail?: string;
12
- }
13
-
14
- export interface SearchResponse {
15
- results: SearchResult[];
16
- query: string;
17
- provider: string;
18
- totalResults?: number;
19
- searchTime?: number;
20
- }
21
-
22
- export interface ResearchResult {
23
- topic: string;
24
- searchCount: number;
25
- aggregatedResults: {
26
- query: string;
27
- results: SearchResult[];
28
- }[];
29
- provider: string;
30
- }
31
-
32
- export interface FactCheckResult {
33
- claim: string;
34
- context?: string;
35
- verificationResults: {
36
- query: string;
37
- results: SearchResult[];
38
- }[];
39
- sourcesChecked: number;
40
- provider: string;
41
- }
@@ -1,374 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import { createExtensionPack } from '../src';
3
- import { ExtensionContext } from '@framers/agentos';
4
-
5
- describe('Web Search Extension Integration', () => {
6
- let extensionPack: any;
7
- let context: ExtensionContext;
8
-
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
-
20
- context = {
21
- options: {
22
- serperApiKey: process.env.SERPER_API_KEY,
23
- serpApiKey: process.env.SERPAPI_API_KEY,
24
- braveApiKey: process.env.BRAVE_API_KEY
25
- },
26
- logger: {
27
- info: () => {},
28
- warn: () => {},
29
- error: () => {},
30
- debug: () => {}
31
- }
32
- } as any;
33
-
34
- extensionPack = createExtensionPack(context);
35
- });
36
-
37
- describe('Extension Pack Creation', () => {
38
- it('should create extension pack with correct metadata', () => {
39
- expect(extensionPack.name).toBe('@framers/agentos-ext-web-search');
40
- expect(extensionPack.version).toBe('1.1.0');
41
- expect(extensionPack.descriptors).toHaveLength(3);
42
- });
43
-
44
- it('should register all three tools', () => {
45
- const toolIds = extensionPack.descriptors.map((d: any) => d.id);
46
- expect(toolIds).toContain('web_search');
47
- expect(toolIds).toContain('research_aggregate');
48
- expect(toolIds).toContain('fact_check');
49
- });
50
-
51
- it('should set correct tool kinds', () => {
52
- extensionPack.descriptors.forEach((descriptor: any) => {
53
- expect(descriptor.kind).toBe('tool');
54
- });
55
- });
56
- });
57
-
58
- describe('Tool Execution', () => {
59
- it('should execute web search tool', async () => {
60
- const webSearchDescriptor = extensionPack.descriptors.find(
61
- (d: any) => d.id === 'web_search'
62
- );
63
- const webSearchTool = webSearchDescriptor.payload;
64
-
65
- // Test with DuckDuckGo (no API key required)
66
- const result = await webSearchTool.execute({
67
- query: 'AgentOS extensions'
68
- }, {
69
- gmiId: 'test-gmi',
70
- personaId: 'test-persona',
71
- userContext: { userId: 'test-user' },
72
- });
73
-
74
- expect(result.success).toBe(true);
75
- expect(result.output).toHaveProperty('provider');
76
- expect(result.output).toHaveProperty('results');
77
- expect(result.output).toHaveProperty('metadata');
78
- });
79
-
80
- it('should execute research aggregator tool', async () => {
81
- const aggregatorDescriptor = extensionPack.descriptors.find(
82
- (d: any) => d.id === 'research_aggregate'
83
- );
84
- const aggregatorTool = aggregatorDescriptor.payload;
85
-
86
- const result = await aggregatorTool.execute({
87
- topic: 'TypeScript testing',
88
- sources: 2
89
- }, {
90
- gmiId: 'test-gmi',
91
- personaId: 'test-persona',
92
- userContext: { userId: 'test-user' },
93
- });
94
-
95
- expect(result.success).toBe(true);
96
- expect(result.output).toHaveProperty('topic');
97
- expect(result.output).toHaveProperty('sources');
98
- expect(result.output).toHaveProperty('aggregatedResults');
99
- });
100
-
101
- it('should execute fact check tool', async () => {
102
- const factCheckDescriptor = extensionPack.descriptors.find(
103
- (d: any) => d.id === 'fact_check'
104
- );
105
- const factCheckTool = factCheckDescriptor.payload;
106
-
107
- const result = await factCheckTool.execute({
108
- statement: 'TypeScript was created by Microsoft',
109
- checkSources: true
110
- }, {
111
- gmiId: 'test-gmi',
112
- personaId: 'test-persona',
113
- userContext: { userId: 'test-user' },
114
- });
115
-
116
- expect(result.success).toBe(true);
117
- expect(result.output).toHaveProperty('statement');
118
- expect(result.output).toHaveProperty('verdict');
119
- expect(result.output).toHaveProperty('confidence');
120
- expect(result.output).toHaveProperty('sources');
121
- });
122
- });
123
-
124
- describe('Error Handling', () => {
125
- it('should handle missing API keys gracefully', async () => {
126
- const noKeysContext = {
127
- options: {},
128
- logger: context.logger
129
- } as any;
130
-
131
- const pack = createExtensionPack(noKeysContext);
132
- const webSearch = pack.descriptors[0].payload;
133
-
134
- const result = await webSearch.execute({
135
- query: 'test query'
136
- }, {
137
- gmiId: 'test-gmi',
138
- personaId: 'test-persona',
139
- userContext: { userId: 'test-user' },
140
- });
141
-
142
- // Should fallback to DuckDuckGo
143
- expect(result.success).toBe(true);
144
- expect(result.output.provider).toBe('duckduckgo');
145
- });
146
-
147
- it('should validate inputs before execution', async () => {
148
- const webSearchDescriptor = extensionPack.descriptors.find(
149
- (d: any) => d.id === 'web_search'
150
- );
151
- const webSearchTool = webSearchDescriptor.payload;
152
-
153
- const validation = webSearchTool.validateArgs({
154
- // Missing required 'query' field
155
- maxResults: 5
156
- });
157
-
158
- expect(validation.isValid).toBe(false);
159
- expect(validation.errors).toContain('Query is required');
160
- });
161
- });
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
-
351
- describe('Lifecycle Hooks', () => {
352
- it('should call onActivate when extension is activated', async () => {
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);
358
- }
359
-
360
- expect(logSpy).toHaveBeenCalledWith('Web Search Extension activated');
361
- });
362
-
363
- it('should call onDeactivate when extension is deactivated', async () => {
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);
369
- }
370
-
371
- expect(logSpy).toHaveBeenCalledWith('Web Search Extension deactivated');
372
- });
373
- });
374
- });