@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,101 +1,176 @@
1
- import { ITool } from '@framers/agentos';
2
- import { SearchProviderService } from '../services/searchProvider';
3
-
4
1
  /**
5
- * Research aggregator tool that performs multiple searches and synthesizes results
6
- *
7
- * @class ResearchAggregatorTool
8
- * @implements {ITool}
9
- *
10
- * @example
11
- * ```typescript
12
- * const tool = new ResearchAggregatorTool(searchService);
13
- * const result = await tool.execute({
14
- * topic: 'AI safety research',
15
- * sources: 3,
16
- * depth: 'comprehensive'
17
- * });
18
- * ```
2
+ * Research Aggregator Tool — runs multiple searches on a topic and aggregates results.
3
+ *
4
+ * Updated to conform to AgentOS `ITool` (inputSchema + ToolExecutionContext).
5
+ *
6
+ * @module @framers/agentos-ext-web-search
19
7
  */
20
- export class ResearchAggregatorTool implements ITool {
21
- public readonly id = 'researchAggregator';
22
- public readonly name = 'Research Aggregator';
23
- public readonly description = 'Aggregate research from multiple searches on a topic';
24
-
25
- /**
26
- * Creates an instance of ResearchAggregatorTool
27
- * @param {SearchProviderService} searchService - The search provider service
28
- */
29
- constructor(private searchService: SearchProviderService) {}
30
-
31
- /**
32
- * Executes research aggregation on a topic
33
- *
34
- * @param {Object} input - The research parameters
35
- * @param {string} input.topic - The research topic
36
- * @param {number} [input.sources=3] - Number of different search angles to explore
37
- * @param {string} [input.depth='moderate'] - Research depth (quick, moderate, comprehensive)
38
- * @returns {Promise<{success: boolean, output?: any, error?: string}>} Aggregated research results
39
- */
40
- async execute(input: {
41
- topic: string;
42
- sources?: number;
43
- depth?: 'quick' | 'moderate' | 'comprehensive';
44
- }): 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 ResearchAggregatorInput {
14
+ topic: string;
15
+ sources?: number;
16
+ depth?: 'quick' | 'moderate' | 'comprehensive';
17
+ multiSearch?: boolean;
18
+ }
19
+
20
+ export interface ResearchAggregatorOutput {
21
+ topic: string;
22
+ sources: number;
23
+ depth: 'quick' | 'moderate' | 'comprehensive';
24
+ queries: string[];
25
+ aggregatedResults: any;
26
+ timestamp: string;
27
+ }
28
+
29
+ export class ResearchAggregatorTool implements ITool<ResearchAggregatorInput, ResearchAggregatorOutput> {
30
+ public readonly id = 'research-aggregator-v1';
31
+ /** Tool call name used by the LLM / ToolExecutor. */
32
+ public readonly name = 'research_aggregate';
33
+ public readonly displayName = 'Research Aggregator';
34
+ public readonly description = 'Aggregate research from multiple web searches on a topic (deduplicated + categorized).';
35
+ public readonly category = 'research';
36
+ public readonly hasSideEffects = false;
37
+
38
+ public readonly inputSchema: JSONSchemaObject = {
39
+ type: 'object',
40
+ required: ['topic'],
41
+ properties: {
42
+ topic: {
43
+ type: 'string',
44
+ description: 'The research topic to aggregate information about',
45
+ },
46
+ sources: {
47
+ type: 'integer',
48
+ description: 'Number of different search angles to explore',
49
+ default: 3,
50
+ minimum: 1,
51
+ maximum: 10,
52
+ },
53
+ depth: {
54
+ type: 'string',
55
+ description: 'Research depth level',
56
+ enum: ['quick', 'moderate', 'comprehensive'],
57
+ default: 'moderate',
58
+ },
59
+ multiSearch: {
60
+ type: 'boolean',
61
+ description:
62
+ 'When true, each search query fans out to ALL available providers in parallel, returning merged and deduplicated results ranked by cross-provider agreement.',
63
+ default: false,
64
+ },
65
+ },
66
+ additionalProperties: false,
67
+ };
68
+
69
+ public readonly requiredCapabilities = ['capability:web_search'];
70
+
71
+ constructor(
72
+ private readonly searchService: SearchProviderService,
73
+ private readonly defaultMultiSearch: boolean = false,
74
+ ) {}
75
+
76
+ async execute(
77
+ input: ResearchAggregatorInput,
78
+ _context: ToolExecutionContext,
79
+ ): Promise<ToolExecutionResult<ResearchAggregatorOutput>> {
45
80
  try {
46
81
  const sources = input.sources || 3;
47
82
  const depth = input.depth || 'moderate';
48
-
49
- // Generate different search queries for comprehensive coverage
83
+ const useMultiSearch = input.multiSearch ?? this.defaultMultiSearch;
84
+
50
85
  const queries = this.generateSearchQueries(input.topic, sources, depth);
51
-
52
- // Perform searches in parallel
53
- const searchPromises = queries.map(query =>
54
- this.searchService.search(query, {
55
- maxResults: depth === 'quick' ? 5 : depth === 'moderate' ? 10 : 15
56
- }).catch(err => ({
57
- provider: 'error',
58
- results: [],
59
- metadata: { query, error: err.message, timestamp: new Date().toISOString() }
60
- }))
61
- );
62
-
63
- const searchResults = await Promise.all(searchPromises);
64
-
65
- // Aggregate and deduplicate results
66
- const aggregated = this.aggregateResults(searchResults, input.topic);
67
-
86
+
87
+ const perQueryMaxResults = depth === 'quick' ? 5 : depth === 'moderate' ? 10 : 15;
88
+
89
+ let searchResults: ProviderResponse[];
90
+
91
+ if (useMultiSearch) {
92
+ // Fan out each query to ALL providers in parallel, then normalize into ProviderResponse shape
93
+ const multiPromises = queries.map((query) =>
94
+ this.searchService
95
+ .multiSearch(query, { maxResults: perQueryMaxResults })
96
+ .then((multi): ProviderResponse => ({
97
+ provider: 'multi',
98
+ results: multi.results,
99
+ metadata: { query, timestamp: multi.metadata.timestamp },
100
+ }))
101
+ .catch((err) => ({
102
+ provider: 'error',
103
+ results: [],
104
+ metadata: { query, error: err?.message || String(err), timestamp: new Date().toISOString() },
105
+ }))
106
+ );
107
+ searchResults = (await Promise.all(multiPromises)) as ProviderResponse[];
108
+ } else {
109
+ const searchPromises = queries.map((query) =>
110
+ this.searchService
111
+ .search(query, { maxResults: perQueryMaxResults })
112
+ .catch((err) => ({
113
+ provider: 'error',
114
+ results: [],
115
+ metadata: { query, error: err?.message || String(err), timestamp: new Date().toISOString() },
116
+ }))
117
+ );
118
+ searchResults = (await Promise.all(searchPromises)) as ProviderResponse[];
119
+ }
120
+
121
+ const aggregatedResults = this.aggregateResults(searchResults, input.topic);
122
+
68
123
  return {
69
124
  success: true,
70
125
  output: {
71
126
  topic: input.topic,
72
- sources: sources,
73
- depth: depth,
74
- queries: queries,
75
- aggregatedResults: aggregated,
76
- timestamp: new Date().toISOString()
77
- }
127
+ sources,
128
+ depth,
129
+ queries,
130
+ aggregatedResults,
131
+ timestamp: new Date().toISOString(),
132
+ },
78
133
  };
79
134
  } catch (error: any) {
80
- return {
81
- success: false,
82
- error: error.message
83
- };
135
+ return { success: false, error: error?.message || String(error) };
136
+ }
137
+ }
138
+
139
+ validateArgs(input: Record<string, any>): { isValid: boolean; errors?: any[] } {
140
+ const errors: string[] = [];
141
+
142
+ if (!input.topic) {
143
+ errors.push('Topic is required');
144
+ } else if (typeof input.topic !== 'string') {
145
+ errors.push('Topic must be a string');
146
+ }
147
+
148
+ if (input.sources !== undefined) {
149
+ if (typeof input.sources !== 'number' || input.sources < 1 || input.sources > 10) {
150
+ errors.push('Sources must be a number between 1 and 10');
151
+ }
84
152
  }
153
+
154
+ if (input.depth !== undefined) {
155
+ if (!['quick', 'moderate', 'comprehensive'].includes(input.depth)) {
156
+ errors.push('Depth must be quick, moderate, or comprehensive');
157
+ }
158
+ }
159
+
160
+ if (input.multiSearch !== undefined && typeof input.multiSearch !== 'boolean') {
161
+ errors.push('multiSearch must be a boolean');
162
+ }
163
+
164
+ return errors.length === 0 ? { isValid: true } : { isValid: false, errors };
85
165
  }
86
-
87
- /**
88
- * Generates diverse search queries for comprehensive research
89
- *
90
- * @private
91
- * @param {string} topic - The research topic
92
- * @param {number} count - Number of queries to generate
93
- * @param {string} depth - Research depth level
94
- * @returns {string[]} Array of search queries
95
- */
166
+
167
+ // ============================================================================
168
+ // Internals (ported from the previous implementation)
169
+ // ============================================================================
170
+
96
171
  private generateSearchQueries(topic: string, count: number, depth: string): string[] {
97
- const queries: string[] = [topic]; // Start with the original topic
98
-
172
+ const queries: string[] = [topic];
173
+
99
174
  const modifiers = {
100
175
  quick: ['overview', 'summary'],
101
176
  moderate: ['overview', 'recent developments', 'key concepts', 'applications'],
@@ -107,191 +182,108 @@ export class ResearchAggregatorTool implements ITool {
107
182
  'future directions',
108
183
  'industry applications',
109
184
  'academic papers',
110
- 'expert opinions'
111
- ]
185
+ 'expert opinions',
186
+ ],
112
187
  };
113
-
188
+
114
189
  const selectedModifiers = modifiers[depth as keyof typeof modifiers];
115
-
190
+
116
191
  for (let i = 1; i < Math.min(count, selectedModifiers.length + 1); i++) {
117
192
  if (selectedModifiers[i - 1]) {
118
193
  queries.push(`${topic} ${selectedModifiers[i - 1]}`);
119
194
  }
120
195
  }
121
-
196
+
122
197
  return queries;
123
198
  }
124
-
125
- /**
126
- * Aggregates and deduplicates search results
127
- *
128
- * @private
129
- * @param {any[]} searchResults - Array of search results from different queries
130
- * @param {string} topic - The original research topic
131
- * @returns {Object} Aggregated and organized results
132
- */
199
+
133
200
  private aggregateResults(searchResults: any[], topic: string): any {
134
201
  const seenUrls = new Set<string>();
135
202
  const categories = {
136
203
  overview: [] as any[],
137
204
  research: [] as any[],
138
205
  applications: [] as any[],
139
- resources: [] as any[]
206
+ resources: [] as any[],
140
207
  };
141
-
208
+
142
209
  for (const searchResult of searchResults) {
143
210
  if (!searchResult.results) continue;
144
-
211
+
145
212
  for (const result of searchResult.results) {
146
- // Skip duplicates
147
213
  if (seenUrls.has(result.url)) continue;
148
214
  seenUrls.add(result.url);
149
-
150
- // Categorize based on content
151
- const lowerTitle = result.title.toLowerCase();
152
- const lowerSnippet = result.snippet.toLowerCase();
153
-
154
- if (lowerTitle.includes('overview') || lowerTitle.includes('introduction') ||
155
- lowerTitle.includes('what is')) {
215
+
216
+ const lowerTitle = String(result.title || '').toLowerCase();
217
+
218
+ if (lowerTitle.includes('overview') || lowerTitle.includes('introduction') || lowerTitle.includes('what is')) {
156
219
  categories.overview.push(result);
157
- } else if (lowerTitle.includes('research') || lowerTitle.includes('study') ||
158
- lowerTitle.includes('paper') || lowerTitle.includes('journal')) {
220
+ } else if (
221
+ lowerTitle.includes('research') ||
222
+ lowerTitle.includes('study') ||
223
+ lowerTitle.includes('paper') ||
224
+ lowerTitle.includes('journal')
225
+ ) {
159
226
  categories.research.push(result);
160
- } else if (lowerTitle.includes('application') || lowerTitle.includes('use case') ||
161
- lowerTitle.includes('implementation')) {
227
+ } else if (
228
+ lowerTitle.includes('application') ||
229
+ lowerTitle.includes('use case') ||
230
+ lowerTitle.includes('implementation')
231
+ ) {
162
232
  categories.applications.push(result);
163
233
  } else {
164
234
  categories.resources.push(result);
165
235
  }
166
236
  }
167
237
  }
168
-
169
- // Calculate relevance scores
238
+
170
239
  const scoredCategories = Object.entries(categories).reduce((acc, [key, items]) => {
171
- acc[key] = items.map((item: any) => ({
172
- ...item,
173
- relevanceScore: this.calculateRelevance(item, topic)
174
- })).sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
240
+ acc[key] = items
241
+ .map((item: any) => ({
242
+ ...item,
243
+ relevanceScore: this.calculateRelevance(item, topic),
244
+ }))
245
+ .sort((a: any, b: any) => b.relevanceScore - a.relevanceScore);
175
246
  return acc;
176
247
  }, {} as any);
177
-
248
+
178
249
  return {
179
250
  categories: scoredCategories,
180
251
  totalResults: seenUrls.size,
181
- topResults: this.getTopResults(scoredCategories, 10)
252
+ topResults: this.getTopResults(scoredCategories, 10),
182
253
  };
183
254
  }
184
-
185
- /**
186
- * Calculates relevance score for a search result
187
- *
188
- * @private
189
- * @param {any} result - Search result object
190
- * @param {string} topic - Original topic for comparison
191
- * @returns {number} Relevance score (0-100)
192
- */
255
+
193
256
  private calculateRelevance(result: any, topic: string): number {
194
- let score = 50; // Base score
195
-
257
+ let score = 50;
258
+
196
259
  const topicWords = topic.toLowerCase().split(/\s+/);
197
- const titleWords = result.title.toLowerCase();
198
- const snippetWords = result.snippet.toLowerCase();
199
-
200
- // Check title relevance
260
+ const titleWords = String(result.title || '').toLowerCase();
261
+ const snippetWords = String(result.snippet || '').toLowerCase();
262
+
201
263
  for (const word of topicWords) {
202
264
  if (titleWords.includes(word)) score += 15;
203
265
  if (snippetWords.includes(word)) score += 5;
204
266
  }
205
-
206
- // Boost for certain indicators
267
+
207
268
  if (titleWords.includes('official')) score += 10;
208
269
  if (titleWords.includes('guide')) score += 5;
209
270
  if (titleWords.includes('tutorial')) score += 5;
210
-
271
+
272
+ // Bonus for multi-search results with cross-provider agreement
273
+ if (typeof result.confidenceScore === 'number') {
274
+ score += (result.confidenceScore / 100) * 20;
275
+ }
276
+
211
277
  return Math.min(100, score);
212
278
  }
213
-
214
- /**
215
- * Gets top results across all categories
216
- *
217
- * @private
218
- * @param {any} categories - Categorized results
219
- * @param {number} count - Number of top results to return
220
- * @returns {any[]} Top results by relevance
221
- */
279
+
222
280
  private getTopResults(categories: any, count: number): any[] {
223
281
  const allResults: any[] = [];
224
-
282
+
225
283
  for (const items of Object.values(categories)) {
226
284
  allResults.push(...(items as any[]));
227
285
  }
228
-
229
- return allResults
230
- .sort((a, b) => b.relevanceScore - a.relevanceScore)
231
- .slice(0, count);
232
- }
233
-
234
- /**
235
- * Validates input parameters
236
- *
237
- * @param {any} input - Input to validate
238
- * @returns {{valid: boolean, errors: string[]}} Validation result
239
- */
240
- validate(input: any): { valid: boolean; errors: string[] } {
241
- const errors: string[] = [];
242
-
243
- if (!input.topic) {
244
- errors.push('Topic is required');
245
- } else if (typeof input.topic !== 'string') {
246
- errors.push('Topic must be a string');
247
- }
248
-
249
- if (input.sources !== undefined) {
250
- if (typeof input.sources !== 'number' || input.sources < 1 || input.sources > 10) {
251
- errors.push('Sources must be a number between 1 and 10');
252
- }
253
- }
254
-
255
- if (input.depth !== undefined) {
256
- if (!['quick', 'moderate', 'comprehensive'].includes(input.depth)) {
257
- errors.push('Depth must be quick, moderate, or comprehensive');
258
- }
259
- }
260
-
261
- return {
262
- valid: errors.length === 0,
263
- errors
264
- };
265
- }
266
-
267
- /**
268
- * Returns the JSON schema for the tool's input
269
- *
270
- * @returns {Object} JSON Schema object
271
- */
272
- getSchema(): any {
273
- return {
274
- type: 'object',
275
- required: ['topic'],
276
- properties: {
277
- topic: {
278
- type: 'string',
279
- description: 'The research topic to aggregate information about'
280
- },
281
- sources: {
282
- type: 'number',
283
- description: 'Number of different search angles to explore',
284
- default: 3,
285
- minimum: 1,
286
- maximum: 10
287
- },
288
- depth: {
289
- type: 'string',
290
- description: 'Research depth level',
291
- enum: ['quick', 'moderate', 'comprehensive'],
292
- default: 'moderate'
293
- }
294
- }
295
- };
286
+
287
+ return allResults.sort((a, b) => b.relevanceScore - a.relevanceScore).slice(0, count);
296
288
  }
297
- }
289
+ }