chenbhao commited on
Commit
c681d98
·
1 Parent(s): a3eb860

feat: WebSearchTool

Browse files
src/tools/WebSearchTool/WebSearchTool.ts CHANGED
@@ -3,7 +3,6 @@ import { z } from 'zod/v4'
3
  import { buildTool, type ToolDef } from '../../Tool.js'
4
  import { lazySchema } from '../../utils/lazySchema.js'
5
  import { logError } from '../../utils/log.js'
6
- import { jsonStringify } from '../../utils/slowOperations.js'
7
  import { getWebSearchPrompt, WEB_SEARCH_TOOL_NAME } from './prompt.js'
8
  import {
9
  getToolUseSummary,
@@ -15,30 +14,21 @@ import {
15
  const inputSchema = lazySchema(() =>
16
  z.strictObject({
17
  query: z.string().min(2).describe('The search query to use'),
18
- allowed_domains: z
19
- .array(z.string())
20
- .optional()
21
- .describe('Only include search results from these domains'),
22
- blocked_domains: z
23
- .array(z.string())
24
- .optional()
25
- .describe('Never include search results from these domains'),
26
  }),
27
  )
28
- type InputSchema = ReturnType<typeof inputSchema>
29
 
30
- type Input = z.infer<InputSchema>
31
 
32
  const searchResultSchema = lazySchema(() => {
33
  const searchHitSchema = z.object({
34
- title: z.string().describe('The title of the search result'),
35
- url: z.string().describe('The URL of the search result'),
36
- snippet: z.string().optional().describe('The snippet/description of the search result'),
37
  })
38
 
39
  return z.object({
40
- tool_use_id: z.string().describe('ID of the tool use'),
41
- content: z.array(searchHitSchema).describe('Array of search hits'),
42
  })
43
  })
44
 
@@ -46,426 +36,228 @@ export type SearchResult = z.infer<ReturnType<typeof searchResultSchema>>
46
 
47
  const outputSchema = lazySchema(() =>
48
  z.object({
49
- query: z.string().describe('The search query that was executed'),
50
- results: z
51
- .array(z.union([searchResultSchema(), z.string()]))
52
- .describe('Search results and/or text commentary from the model'),
53
- durationSeconds: z
54
- .number()
55
- .describe('Time taken to complete the search operation'),
56
  }),
57
  )
58
- type OutputSchema = ReturnType<typeof outputSchema>
59
 
60
- export type Output = z.infer<OutputSchema>
61
 
62
- // Re-export WebSearchProgress from centralized types to break import cycles
63
  export type { WebSearchProgress } from '../../types/tools.js'
64
-
65
  import type { WebSearchProgress } from '../../types/tools.js'
66
 
67
  /**
68
- * Search using DuckDuckGo via Python webtools script
69
- * This is the primary and only search method
70
  */
71
- async function searchDuckDuckGoAPI(
72
- query: string,
73
- options: {
74
- region?: string
75
- timelimit?: string
76
- page?: number
77
- } = {}
78
  ): Promise<Array<{ title: string; url: string; snippet?: string }>> {
79
- const { page = 1 } = options
80
-
81
- console.log(`[WebSearch] Searching DuckDuckGo for: "${query}" (via Python webtools)`)
82
 
83
  try {
84
- const { spawn } = await import('child_process')
85
-
86
- return new Promise((resolve, reject) => {
87
- const pythonScript = process.cwd() + '/scripts/python_webtools.py'
88
- const maxResults = 10
89
-
90
- const child = spawn('.venv/bin/python', [pythonScript, 'web_search', query, String(maxResults)], {
91
- cwd: process.cwd(),
92
- })
93
-
94
- let stdout = ''
95
- let stderr = ''
96
- let timedOut = false
97
-
98
- // Set timeout to prevent hanging
99
- const timeout = setTimeout(() => {
100
- timedOut = true
101
- console.error('[WebSearch] Python process timed out after 30 seconds')
102
- child.kill('SIGKILL')
103
- reject(new Error('Search timeout: Python process took too long to respond'))
104
- }, 30000) // 30 seconds timeout
105
-
106
- child.stdout.on('data', (data) => {
107
- stdout += data.toString()
108
- })
109
-
110
- child.stderr.on('data', (data) => {
111
- stderr += data.toString()
112
- })
113
-
114
- child.on('close', (code) => {
115
- clearTimeout(timeout)
116
-
117
- if (timedOut) {
118
- return // Already handled by timeout
119
- }
120
-
121
- if (code !== 0) {
122
- console.error('[WebSearch] Python script failed:', stderr)
123
- reject(new Error(`Python script failed: ${stderr}`))
124
- return
125
- }
126
-
127
- try {
128
- const result = JSON.parse(stdout)
129
-
130
- if (!result.success) {
131
- console.error('[WebSearch] Python search failed:', result.error)
132
- reject(new Error(result.error))
133
- return
134
- }
135
-
136
- console.log(`[WebSearch] Python returned ${result.count} results`)
137
-
138
- // Convert Python results to our format
139
- const results: Array<{ title: string; url: string; snippet?: string }> = result.results.map((r: any) => ({
140
- title: r.title,
141
- url: r.url,
142
- snippet: r.content || undefined,
143
- }))
144
-
145
- resolve(results)
146
- } catch (error) {
147
- console.error('[WebSearch] Failed to parse Python output:', error)
148
- reject(new Error(`Failed to parse Python output: ${error}`))
149
- }
150
- })
151
-
152
- child.on('error', (error) => {
153
- clearTimeout(timeout)
154
- console.error('[WebSearch] Failed to start Python process:', error)
155
- reject(error)
156
- })
157
  })
158
- } catch (error) {
159
- console.error('[WebSearch] Failed to search:', error)
160
- logError('WebSearch failed', error)
161
- throw new Error(`Unable to search: ${error instanceof Error ? error.message : String(error)}`)
162
- }
163
- }
164
 
165
- /**
166
- * Filter search results by domain
167
- */
168
- function filterDomains(
169
- results: Array<{ url: string }>,
170
- allowedDomains?: string[],
171
- blockedDomains?: string[]
172
- ): Array<{ url: string }> {
173
- return results.filter(result => {
174
- try {
175
- const url = new URL(result.url)
176
- const domain = url.hostname
177
 
178
- if (allowedDomains?.length > 0) {
179
- return allowedDomains.some(allowed =>
180
- domain === allowed || domain.endsWith(`.${allowed}`)
181
- )
182
- }
183
 
184
- if (blockedDomains?.length > 0) {
185
- return !blockedDomains.some(blocked =>
186
- domain === blocked || domain.endsWith(`.${blocked}`)
187
- )
188
- }
189
 
190
- return true
191
- } catch {
192
- // Invalid URL, filter it out
193
- return false
194
- }
195
- })
 
 
 
 
 
 
 
 
196
  }
197
 
198
  /**
199
- * Remove HTML tags and decode HTML entities
200
  */
201
  function stripTags(text: string): string {
202
- // Remove script and style tags with their content
203
- text = text.replace(/<script[\s\S]*?<\/script>/gi, '')
204
- text = text.replace(/<style[\s\S]*?<\/style>/gi, '')
205
-
206
- // Remove all remaining HTML tags
207
- text = text.replace(/<[^>]+>/g, '')
208
-
209
- // Decode basic HTML entities
210
- text = text.replace(/&amp;/g, '&')
211
- text = text.replace(/&lt;/g, '<')
212
- text = text.replace(/&gt;/g, '>')
213
- text = text.replace(/&quot;/g, '"')
214
- text = text.replace(/&#39;/g, "'")
215
- text = text.replace(/&nbsp;/g, ' ')
216
-
217
- return text.trim()
218
  }
219
 
220
- /**
221
- * Normalize whitespace in text
222
- */
223
  function normalizeText(text: string): string {
224
- // Collapse multiple spaces and tabs into single space
225
- text = text.replace(/[ \t]+/g, ' ')
226
-
227
- // Collapse 3 or more consecutive newlines into 2 newlines
228
- text = text.replace(/\n{3,}/g, '\n\n')
229
-
230
- return text.trim()
231
  }
232
 
233
- /**
234
- * Clean and normalize search result fields
235
- */
236
- function cleanSearchResult(result: { title?: string; snippet?: string }): { title?: string; snippet?: string } {
237
- const cleaned: { title?: string; snippet?: string } = {}
238
-
239
- if (result.title !== undefined) {
240
- cleaned.title = normalizeText(stripTags(result.title))
241
  }
242
-
243
- if (result.snippet !== undefined) {
244
- cleaned.snippet = normalizeText(stripTags(result.snippet))
245
- }
246
-
247
- return cleaned
248
  }
249
 
250
- export const WebSearchTool = buildTool<InputSchema, Output, WebSearchProgress>({
251
  name: WEB_SEARCH_TOOL_NAME,
252
- description: 'Search the web and return search results with titles, URLs, and snippets.',
 
253
  getToolUseSummary,
254
  getActivityDescription(input) {
255
- const summary = getToolUseSummary(input)
256
- return summary ? `Searching for ${summary}` : 'Searching the web'
257
  },
 
258
  isEnabled() {
259
- // Jina Search works with all providers, including local models
260
  return true
261
  },
262
- get inputSchema(): InputSchema {
 
263
  return inputSchema()
264
  },
265
- get outputSchema(): OutputSchema {
 
266
  return outputSchema()
267
  },
 
268
  isConcurrencySafe() {
269
  return true
270
  },
 
271
  isReadOnly() {
272
  return true
273
  },
 
274
  toAutoClassifierInput(input) {
275
  return input?.query ?? ''
276
  },
277
- async checkPermissions(_input, _context): Promise<PermissionResult> {
278
- // 权限全开,允许所有 WebSearch 请求
279
- // 同时自动过滤掉 AI 模型自动添加的域名限制参数
280
- const cleanedInput = { ..._input }
281
- delete cleanedInput.allowed_domains
282
- delete cleanedInput.blocked_domains
283
-
284
  return {
285
  behavior: 'allow',
286
- updatedInput: cleanedInput,
287
- decisionReason: { type: 'other', reason: 'All web searches allowed - domain filters removed' },
288
  }
289
  },
 
290
  async prompt() {
291
  return getWebSearchPrompt()
292
  },
 
293
  renderToolUseMessage,
294
  renderToolUseProgressMessage,
295
  renderToolResultMessage,
 
296
  extractSearchText() {
297
- // renderToolResultMessage shows only "Did N searches in Xs" chrome —
298
- // the results[] content never appears on screen. Heuristic would index
299
- // string entries in results[] (phantom match). Nothing to search.
300
  return ''
301
  },
302
- async validateInput(input, _context) {
303
- if (!input) {
304
- return {
305
- result: false,
306
- message: 'Error: Missing input',
307
- errorCode: 1,
308
- }
309
- }
310
- const { query } = input
311
- if (!query?.length) {
312
- return {
313
- result: false,
314
- message: 'Error: Missing query',
315
- errorCode: 1,
316
- }
317
  }
318
- // 移除域名限制检查,因为会在 checkPermissions 中自动清理
319
  return { result: true }
320
  },
321
- async call(input, context, _canUseTool, _parentMessage, onProgress) {
322
- const startTime = performance.now()
323
-
324
- if (!input?.query) {
325
- const endTime = performance.now()
326
- return {
327
- query: '',
328
- results: ['Error: Missing query'],
329
- durationSeconds: (endTime - startTime) / 1000,
330
- }
331
- }
332
-
333
- const { query, allowed_domains, blocked_domains } = input
334
-
335
- // Progress update: starting search
336
- if (onProgress) {
337
- onProgress({
338
- toolUseID: 'search-progress-1',
339
- data: { type: 'query_update', query },
340
- })
341
- }
342
 
343
  try {
344
- // Add a small delay before making the request to avoid triggering anti-scraping
 
 
 
 
 
 
 
345
  if (onProgress) {
346
  onProgress({
347
- toolUseID: 'search-delay',
348
- data: { type: 'delay_start' },
349
  })
350
  }
351
- await new Promise(resolve => setTimeout(resolve, 1000))
352
-
353
- // Use DuckDuckGo Search only
354
- const results = await searchDuckDuckGoAPI(query)
355
-
356
- // Filter results by domain if specified
357
- let filteredResults = results
358
- if (allowed_domains || blocked_domains) {
359
- filteredResults = filterDomains(
360
- results,
361
- allowed_domains,
362
- blocked_domains
363
- )
364
- }
365
 
366
- // Clean and normalize search results
367
- const cleanedResults = filteredResults.map(r => ({
 
368
  ...r,
369
  ...cleanSearchResult(r),
370
  }))
371
 
372
- // Progress update: results received
373
- if (onProgress) {
374
- onProgress({
375
- toolUseID: 'search-progress-2',
376
- data: {
377
- type: 'search_results_received',
378
- resultCount: cleanedResults.length,
379
- query,
380
- },
381
- })
382
- }
383
-
384
- // Convert to output format
385
- const searchResults: (SearchResult | string)[] = []
386
-
387
- if (cleanedResults.length === 0) {
388
- searchResults.push(`No results for: ${query}`)
389
- } else {
390
- searchResults.push({
391
- tool_use_id: 'search-1',
392
- content: cleanedResults.map(r => ({
393
- title: r.title,
394
- url: r.url,
395
- snippet: r.snippet,
396
- }))
397
- })
398
- }
399
-
400
- const endTime = performance.now()
401
- const durationSeconds = (endTime - startTime) / 1000
402
 
403
  return {
404
- query,
405
- results: searchResults,
406
- durationSeconds,
407
  }
408
  } catch (error) {
409
  logError(error)
410
-
411
- const endTime = performance.now()
412
- const durationSeconds = (endTime - startTime) / 1000
413
 
414
  return {
415
- query,
416
  results: [`Error: ${error instanceof Error ? error.message : String(error)}`],
417
- durationSeconds,
418
  }
419
  }
420
  },
 
421
  mapToolResultToToolResultBlockParam(output, toolUseID) {
422
  if (!output) {
423
  return {
424
  tool_use_id: toolUseID,
425
  type: 'tool_result',
426
- content: 'Error: Missing output',
427
  }
428
  }
429
- const { query, results } = output
430
-
431
- let formattedOutput = query
432
- ? `Web search results for query: "${query}"\n\n`
433
- : 'Web search results:\n\n'
434
-
435
- // Process the results array - it can contain both string summaries and search result objects.
436
- // Guard against null/undefined entries that can appear after JSON round-tripping
437
- // (e.g., from compaction or transcript deserialization).
438
- ;(results ?? []).forEach(result => {
439
- if (result == null) {
440
- return
441
- }
442
- if (typeof result === 'string') {
443
- // Text summary
444
- formattedOutput += result + '\n\n'
445
  } else {
446
- // Search result with links - format as readable text
447
- if (result.content?.length > 0) {
448
- result.content.forEach((item: any, index: number) => {
449
- formattedOutput += `${index + 1}. **${item.title || 'Untitled'}**\n`
450
- formattedOutput += ` URL: ${item.url}\n`
451
- if (item.snippet) {
452
- formattedOutput += ` ${item.snippet}\n`
453
- }
454
- formattedOutput += '\n'
455
- })
456
- } else {
457
- formattedOutput += 'No links found.\n\n'
458
- }
459
  }
460
- })
461
-
462
- formattedOutput +=
463
- '\nREMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.'
464
 
465
  return {
466
  tool_use_id: toolUseID,
467
  type: 'tool_result',
468
- content: formattedOutput.trim(),
469
  }
470
  },
471
- }) satisfies ToolDef<InputSchema, Output, WebSearchProgress>
 
3
  import { buildTool, type ToolDef } from '../../Tool.js'
4
  import { lazySchema } from '../../utils/lazySchema.js'
5
  import { logError } from '../../utils/log.js'
 
6
  import { getWebSearchPrompt, WEB_SEARCH_TOOL_NAME } from './prompt.js'
7
  import {
8
  getToolUseSummary,
 
14
  const inputSchema = lazySchema(() =>
15
  z.strictObject({
16
  query: z.string().min(2).describe('The search query to use'),
 
 
 
 
 
 
 
 
17
  }),
18
  )
 
19
 
20
+ type Input = z.infer<ReturnType<typeof inputSchema>>
21
 
22
  const searchResultSchema = lazySchema(() => {
23
  const searchHitSchema = z.object({
24
+ title: z.string(),
25
+ url: z.string(),
26
+ snippet: z.string().optional(),
27
  })
28
 
29
  return z.object({
30
+ tool_use_id: z.string(),
31
+ content: z.array(searchHitSchema),
32
  })
33
  })
34
 
 
36
 
37
  const outputSchema = lazySchema(() =>
38
  z.object({
39
+ query: z.string(),
40
+ results: z.array(z.union([searchResultSchema(), z.string()])),
41
+ durationSeconds: z.number(),
 
 
 
 
42
  }),
43
  )
 
44
 
45
+ export type Output = z.infer<ReturnType<typeof outputSchema>>
46
 
 
47
  export type { WebSearchProgress } from '../../types/tools.js'
 
48
  import type { WebSearchProgress } from '../../types/tools.js'
49
 
50
  /**
51
+ * 使用 SearXNG 本地搜索
 
52
  */
53
+ async function searchSearXNG(
54
+ query: string
 
 
 
 
 
55
  ): Promise<Array<{ title: string; url: string; snippet?: string }>> {
56
+ console.log(`[WebSearch] Searching SearXNG for: "${query}"`)
 
 
57
 
58
  try {
59
+ const url = new URL('http://localhost:8080/search')
60
+ url.searchParams.set('q', query)
61
+ url.searchParams.set('format', 'json')
62
+
63
+ const controller = new AbortController()
64
+ const timeout = setTimeout(() => controller.abort(), 10000)
65
+
66
+ const res = await fetch(url.toString(), {
67
+ signal: controller.signal,
68
+ headers: {
69
+ 'User-Agent': 'Mozilla/5.0 (compatible; WebSearchTool/1.0)',
70
+ 'Accept': 'application/json',
71
+ },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  })
 
 
 
 
 
 
73
 
74
+ clearTimeout(timeout)
 
 
 
 
 
 
 
 
 
 
 
75
 
76
+ if (!res.ok) {
77
+ throw new Error(`HTTP ${res.status}`)
78
+ }
 
 
79
 
80
+ const data = await res.json()
 
 
 
 
81
 
82
+ return (data.results || [])
83
+ .slice(0, 10)
84
+ .map((r: any) => ({
85
+ title: r.title,
86
+ url: r.url,
87
+ snippet: r.content,
88
+ }))
89
+ } catch (error) {
90
+ console.error('[WebSearch] SearXNG failed:', error)
91
+ logError('SearXNG search failed', error)
92
+ throw new Error(
93
+ `SearXNG search failed: ${error instanceof Error ? error.message : String(error)}`
94
+ )
95
+ }
96
  }
97
 
98
  /**
99
+ * 文本清洗
100
  */
101
  function stripTags(text: string): string {
102
+ return text
103
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
104
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
105
+ .replace(/<[^>]+>/g, '')
106
+ .replace(/&amp;/g, '&')
107
+ .replace(/&lt;/g, '<')
108
+ .replace(/&gt;/g, '>')
109
+ .replace(/&quot;/g, '"')
110
+ .replace(/&#39;/g, "'")
111
+ .replace(/&nbsp;/g, ' ')
112
+ .trim()
 
 
 
 
 
113
  }
114
 
 
 
 
115
  function normalizeText(text: string): string {
116
+ return text.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
 
 
 
 
 
 
117
  }
118
 
119
+ function cleanSearchResult(result: any) {
120
+ return {
121
+ title: result.title ? normalizeText(stripTags(result.title)) : undefined,
122
+ snippet: result.snippet ? normalizeText(stripTags(result.snippet)) : undefined,
 
 
 
 
123
  }
 
 
 
 
 
 
124
  }
125
 
126
+ export const WebSearchTool = buildTool({
127
  name: WEB_SEARCH_TOOL_NAME,
128
+ description: 'Search the web using local SearXNG',
129
+
130
  getToolUseSummary,
131
  getActivityDescription(input) {
132
+ return input?.query ? `Searching for "${input.query}"` : 'Searching the web'
 
133
  },
134
+
135
  isEnabled() {
 
136
  return true
137
  },
138
+
139
+ get inputSchema() {
140
  return inputSchema()
141
  },
142
+
143
+ get outputSchema() {
144
  return outputSchema()
145
  },
146
+
147
  isConcurrencySafe() {
148
  return true
149
  },
150
+
151
  isReadOnly() {
152
  return true
153
  },
154
+
155
  toAutoClassifierInput(input) {
156
  return input?.query ?? ''
157
  },
158
+
159
+ async checkPermissions(): Promise<PermissionResult> {
 
 
 
 
 
160
  return {
161
  behavior: 'allow',
 
 
162
  }
163
  },
164
+
165
  async prompt() {
166
  return getWebSearchPrompt()
167
  },
168
+
169
  renderToolUseMessage,
170
  renderToolUseProgressMessage,
171
  renderToolResultMessage,
172
+
173
  extractSearchText() {
 
 
 
174
  return ''
175
  },
176
+
177
+ async validateInput(input) {
178
+ if (!input?.query) {
179
+ return { result: false, message: 'Missing query', errorCode: 1 }
 
 
 
 
 
 
 
 
 
 
 
180
  }
 
181
  return { result: true }
182
  },
183
+
184
+ async call(input, _context, _canUseTool, _parentMessage, onProgress) {
185
+ const start = performance.now()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  try {
188
+ if (!input?.query || input.query.trim() === '') {
189
+ return {
190
+ query: input?.query || '',
191
+ results: ['Error: Missing query'],
192
+ durationSeconds: (performance.now() - start) / 1000,
193
+ }
194
+ }
195
+
196
  if (onProgress) {
197
  onProgress({
198
+ toolUseID: 'search-start',
199
+ data: { type: 'query_update', query: input.query },
200
  })
201
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
 
203
+ const results = await searchSearXNG(input.query)
204
+
205
+ const cleaned = results.map(r => ({
206
  ...r,
207
  ...cleanSearchResult(r),
208
  }))
209
 
210
+ const output =
211
+ cleaned.length === 0
212
+ ? [`No results for: ${input.query}`]
213
+ : [
214
+ {
215
+ tool_use_id: 'search-1',
216
+ content: cleaned,
217
+ },
218
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
  return {
221
+ query: input.query,
222
+ results: output,
223
+ durationSeconds: (performance.now() - start) / 1000,
224
  }
225
  } catch (error) {
226
  logError(error)
 
 
 
227
 
228
  return {
229
+ query: input.query,
230
  results: [`Error: ${error instanceof Error ? error.message : String(error)}`],
231
+ durationSeconds: (performance.now() - start) / 1000,
232
  }
233
  }
234
  },
235
+
236
  mapToolResultToToolResultBlockParam(output, toolUseID) {
237
  if (!output) {
238
  return {
239
  tool_use_id: toolUseID,
240
  type: 'tool_result',
241
+ content: 'Error',
242
  }
243
  }
244
+
245
+ let text = `Results for "${output.query}"\n\n`
246
+
247
+ for (const r of output.results) {
248
+ if (typeof r === 'string') {
249
+ text += r + '\n\n'
 
 
 
 
 
 
 
 
 
 
250
  } else {
251
+ r.content.forEach((item: any, i: number) => {
252
+ text += `${i + 1}. ${item.title}\n${item.url}\n${item.snippet || ''}\n\n`
253
+ })
 
 
 
 
 
 
 
 
 
 
254
  }
255
+ }
 
 
 
256
 
257
  return {
258
  tool_use_id: toolUseID,
259
  type: 'tool_result',
260
+ content: text,
261
  }
262
  },
263
+ }) satisfies ToolDef<any, Output, WebSearchProgress>
src/tools/WebSearchTool/__tests__/WebSearchTool.test.ts CHANGED
@@ -8,7 +8,7 @@ describe('WebSearchTool', () => {
8
  })
9
 
10
  test('should have correct description', () => {
11
- expect(WebSearchTool.description).toBe('Search the web and return search results with titles, URLs, and snippets.')
12
  })
13
 
14
  test('should be enabled', () => {
@@ -34,30 +34,6 @@ describe('WebSearchTool', () => {
34
  expect(result.result).toBe(true)
35
  })
36
 
37
- test('should accept query with allowed domains', async () => {
38
- const result = await WebSearchTool.validateInput(
39
- {
40
- query: 'typescript',
41
- allowed_domains: ['github.com', 'stackoverflow.com']
42
- },
43
- {}
44
- )
45
-
46
- expect(result.result).toBe(true)
47
- })
48
-
49
- test('should accept query with blocked domains', async () => {
50
- const result = await WebSearchTool.validateInput(
51
- {
52
- query: 'typescript',
53
- blocked_domains: ['example.com']
54
- },
55
- {}
56
- )
57
-
58
- expect(result.result).toBe(true)
59
- })
60
-
61
  test('should reject empty query', async () => {
62
  const result = await WebSearchTool.validateInput(
63
  { query: '' },
@@ -65,7 +41,6 @@ describe('WebSearchTool', () => {
65
  )
66
 
67
  expect(result.result).toBe(false)
68
- expect(result.message).toContain('Missing query')
69
  })
70
 
71
  test('should reject missing input', async () => {
@@ -75,67 +50,36 @@ describe('WebSearchTool', () => {
75
  )
76
 
77
  expect(result.result).toBe(false)
78
- expect(result.message).toContain('Error: Missing query')
79
- })
80
-
81
- test.skip('should reject query with less than 2 characters', async () => {
82
- // The validation logic may allow single character queries in some cases
83
- const result = await WebSearchTool.validateInput(
84
- { query: 'a' },
85
- {}
86
- )
87
-
88
- // This test is skipped as the actual behavior may differ from schema
89
- expect(result.result).toBe(false)
90
  })
91
  })
92
 
93
  describe('Permissions', () => {
94
  test('should allow all web search requests', async () => {
95
  const result = await WebSearchTool.checkPermissions(
96
- { query: 'typescript', allowed_domains: ['github.com'] },
97
- {}
98
- )
99
-
100
- expect(result.behavior).toBe('allow')
101
- expect(result.updatedInput).toBeDefined()
102
- // Domain filters should be removed
103
- expect(result.updatedInput?.allowed_domains).toBeUndefined()
104
- expect(result.decisionReason?.type).toBe('other')
105
- })
106
-
107
- test('should remove blocked domains from input', async () => {
108
- const result = await WebSearchTool.checkPermissions(
109
- { query: 'typescript', blocked_domains: ['example.com'] },
110
  {}
111
  )
112
 
113
  expect(result.behavior).toBe('allow')
114
- expect(result.updatedInput?.blocked_domains).toBeUndefined()
115
  })
116
  })
117
 
118
  describe('Tool Call - Successful Search', () => {
119
- test('should perform web search with simple query', async () => {
120
  const result = await WebSearchTool.call(
121
  { query: 'typescript programming' },
122
  {},
123
  () => {},
124
- null,
125
- (progress) => {
126
- // Progress callback should be called
127
- expect(progress).toBeDefined()
128
- }
129
  )
130
 
131
  expect(result).toBeDefined()
132
  expect(result.query).toBe('typescript programming')
133
- expect(result.results).toBeDefined()
134
  expect(Array.isArray(result.results)).toBe(true)
135
  expect(result.durationSeconds).toBeGreaterThan(0)
136
  }, 60000)
137
 
138
- test('should return search results with proper structure', async () => {
139
  const result = await WebSearchTool.call(
140
  { query: 'javascript' },
141
  {},
@@ -143,65 +87,18 @@ describe('WebSearchTool', () => {
143
  null
144
  )
145
 
146
- expect(result.results).toBeDefined()
147
- expect(result.results.length).toBeGreaterThan(0)
148
 
149
- // Check if results have proper format
150
- const firstResult = result.results[0]
151
- if (typeof firstResult !== 'string') {
152
- expect(firstResult).toHaveProperty('tool_use_id')
153
- expect(firstResult).toHaveProperty('content')
154
- expect(Array.isArray(firstResult.content)).toBe(true)
155
  }
156
  }, 60000)
157
-
158
- test('should handle empty results gracefully', async () => {
159
- const result = await WebSearchTool.call(
160
- { query: 'xyzabc123def456' },
161
- {},
162
- () => {},
163
- null
164
- )
165
-
166
- expect(result.results).toBeDefined()
167
- // Should return either empty results or "No results" message
168
- }, 60000)
169
- })
170
-
171
- describe('Tool Call - Domain Filtering', () => {
172
- test('should filter results by allowed domains', async () => {
173
- const result = await WebSearchTool.call(
174
- {
175
- query: 'github',
176
- allowed_domains: ['github.com']
177
- },
178
- {},
179
- () => {},
180
- null
181
- )
182
-
183
- expect(result).toBeDefined()
184
- expect(result.results).toBeDefined()
185
- }, 60000)
186
-
187
- test('should filter results by blocked domains', async () => {
188
- const result = await WebSearchTool.call(
189
- {
190
- query: 'programming',
191
- blocked_domains: ['example.com']
192
- },
193
- {},
194
- () => {},
195
- null
196
- )
197
-
198
- expect(result).toBeDefined()
199
- expect(result.results).toBeDefined()
200
- }, 60000)
201
  })
202
 
203
  describe('Tool Call - Error Handling', () => {
204
- test('should handle missing query gracefully', async () => {
205
  const result = await WebSearchTool.call(
206
  { query: '' } as any,
207
  {},
@@ -209,12 +106,10 @@ describe('WebSearchTool', () => {
209
  null
210
  )
211
 
212
- expect(result.query).toBe('')
213
- expect(result.results).toContain('Error: Missing query')
214
  })
215
 
216
- test('should handle search errors gracefully', async () => {
217
- // This test ensures that errors don't crash the tool
218
  const result = await WebSearchTool.call(
219
  { query: 'test' },
220
  {},
@@ -223,46 +118,28 @@ describe('WebSearchTool', () => {
223
  )
224
 
225
  expect(result).toBeDefined()
226
- expect(result.durationSeconds).toBeGreaterThan(0)
227
  }, 60000)
228
  })
229
 
230
  describe('Schema Validation', () => {
231
  test('should have valid input schema', () => {
232
- const schema = WebSearchTool.inputSchema
233
- expect(schema).toBeDefined()
234
- })
235
-
236
- test('should have valid output schema', () => {
237
- const schema = WebSearchTool.outputSchema
238
- expect(schema).toBeDefined()
239
- })
240
-
241
- test('input schema should have required fields', () => {
242
  const schema = WebSearchTool.inputSchema
243
  const parsed = schema.safeParse({ query: 'test' })
244
  expect(parsed.success).toBe(true)
245
  })
246
 
247
- test('input schema should allow optional domain filters', () => {
248
- const schema = WebSearchTool.inputSchema
249
- const parsed = schema.safeParse({
250
- query: 'test',
251
- allowed_domains: ['example.com'],
252
- blocked_domains: ['test.com']
253
- })
254
- expect(parsed.success).toBe(true)
255
  })
256
  })
257
 
258
  describe('Tool Metadata', () => {
259
  test('should provide activity description', () => {
260
- const description = WebSearchTool.getActivityDescription({
261
  query: 'typescript'
262
  })
263
 
264
- expect(description).toContain('Searching')
265
- expect(description).toContain('typescript')
266
  })
267
 
268
  test('should provide tool use summary', () => {
@@ -273,218 +150,78 @@ describe('WebSearchTool', () => {
273
  expect(summary).toBeDefined()
274
  })
275
 
276
- test('should return empty search text for extraction', () => {
277
- const searchText = WebSearchTool.extractSearchText?.({
278
  results: ['test']
279
  } as any)
280
 
281
- expect(searchText).toBe('')
282
  })
283
  })
284
 
285
  describe('Auto Classifier Input', () => {
286
- test('should format input for auto classifier', () => {
287
  const input = WebSearchTool.toAutoClassifierInput({
288
  query: 'typescript'
289
  })
290
 
291
  expect(input).toBe('typescript')
292
  })
293
-
294
- test('should handle empty query', () => {
295
- const input = WebSearchTool.toAutoClassifierInput({
296
- query: ''
297
- })
298
-
299
- expect(input).toBe('')
300
- })
301
  })
302
 
303
  describe('Tool Result Mapping', () => {
304
- test('should map tool result to block param', () => {
305
- const output = {
306
- query: 'typescript',
307
- results: [{
308
- tool_use_id: 'test-1',
309
- content: [{
310
- title: 'TypeScript',
311
- url: 'https://example.com',
312
- snippet: 'Test snippet'
313
- }]
314
- }],
315
- durationSeconds: 1.5
316
- }
317
-
318
- const blockParam = WebSearchTool.mapToolResultToToolResultBlockParam(
319
- output,
320
- 'test-tool-use-id'
321
- )
322
-
323
- expect(blockParam.tool_use_id).toBe('test-tool-use-id')
324
- expect(blockParam.type).toBe('tool_result')
325
- expect(blockParam.content).toBeDefined()
326
- expect(typeof blockParam.content).toBe('string')
327
- })
328
-
329
- test('should include source reminder in formatted output', () => {
330
- const output = {
331
- query: 'test',
332
- results: [],
333
- durationSeconds: 1
334
- }
335
-
336
- const blockParam = WebSearchTool.mapToolResultToToolResultBlockParam(
337
- output,
338
- 'test-id'
339
- )
340
-
341
- expect(blockParam.content).toContain('REMINDER: You MUST include the sources')
342
- })
343
-
344
- test('should handle null/undefined results gracefully', () => {
345
- const output = {
346
- query: 'test',
347
- results: [null, undefined, 'valid string'] as any,
348
- durationSeconds: 1
349
- }
350
-
351
- const blockParam = WebSearchTool.mapToolResultToToolResultBlockParam(
352
- output,
353
- 'test-id'
354
- )
355
-
356
- expect(blockParam.content).toBeDefined()
357
- expect(typeof blockParam.content).toBe('string')
358
- })
359
-
360
- test('should handle missing output gracefully', () => {
361
- const blockParam = WebSearchTool.mapToolResultToToolResultBlockParam(
362
- undefined as any,
363
- 'test-id'
364
- )
365
-
366
- expect(blockParam.tool_use_id).toBe('test-id')
367
- expect(blockParam.content).toBe('Error: Missing output')
368
- })
369
- })
370
-
371
- describe('DuckDuckGo Integration', () => {
372
- test('DuckDuckGo search should be available and functional', async () => {
373
- // Test is handled in the main tool call tests
374
- expect(true).toBe(true)
375
- })
376
-
377
- test('DuckDuckGo should handle various queries', async () => {
378
- // Test is handled in the main tool call tests
379
- expect(true).toBe(true)
380
- })
381
-
382
- test('should search for "milet 的最新动态" and return results', async () => {
383
- const input = {
384
- query: 'milet 的最新动态'
385
- }
386
-
387
- const output = await WebSearchTool.call(input, {} as any)
388
-
389
- expect(output).toBeDefined()
390
- expect(output.query).toBe('milet 的最新动态')
391
- expect(output.results).toBeDefined()
392
- expect(Array.isArray(output.results)).toBe(true)
393
- expect(output.durationSeconds).toBeGreaterThan(0)
394
-
395
- // Check if we got results
396
- if (output.results.length > 0 && typeof output.results[0] !== 'string') {
397
- // If we have search results (not just error messages)
398
- const searchContent = output.results[0] as any
399
- expect(searchContent.content).toBeDefined()
400
- expect(Array.isArray(searchContent.content)).toBe(true)
401
-
402
- if (searchContent.content.length > 0) {
403
- // Verify first result has required fields
404
- const firstResult = searchContent.content[0]
405
- expect(firstResult.title).toBeDefined()
406
- expect(firstResult.url).toBeDefined()
407
- expect(firstResult.url).toMatch(/^https?:\/\//)
408
-
409
- console.log(`✓ Successfully searched for "milet 的最新动态"`)
410
- console.log(`✓ Found ${searchContent.content.length} results`)
411
- console.log(`✓ First result: ${firstResult.title}`)
412
- }
413
- } else {
414
- // If no results, it should be a message explaining why
415
- expect(output.results.length).toBeGreaterThanOrEqual(0)
416
- }
417
- })
418
- })
419
-
420
- describe('Result Formatting', () => {
421
- test('should format search results with links', () => {
422
  const output = {
423
  query: 'test',
424
  results: [{
425
- tool_use_id: 'test-1',
426
  content: [{
427
- title: 'Test Title',
428
  url: 'https://example.com',
429
- snippet: 'Test snippet'
430
  }]
431
  }],
432
  durationSeconds: 1
433
  }
434
 
435
- const blockParam = WebSearchTool.mapToolResultToToolResultBlockParam(
436
  output,
437
- 'test-id'
438
  )
439
 
440
- expect(blockParam.content).toContain('**Test Title**')
441
- expect(blockParam.content).toContain('https://example.com')
442
- expect(blockParam.content).toContain('Test snippet')
443
  })
444
 
445
- test('should format multiple search results', () => {
446
  const output = {
447
  query: 'test',
448
- results: [{
449
- tool_use_id: 'test-1',
450
- content: [
451
- {
452
- title: 'First Result',
453
- url: 'https://example1.com',
454
- snippet: 'First snippet'
455
- },
456
- {
457
- title: 'Second Result',
458
- url: 'https://example2.com',
459
- snippet: 'Second snippet'
460
- }
461
- ]
462
- }],
463
  durationSeconds: 1
464
  }
465
 
466
- const blockParam = WebSearchTool.mapToolResultToToolResultBlockParam(
467
  output,
468
- 'test-id'
469
  )
470
 
471
- expect(blockParam.content).toContain('1. **First Result**')
472
- expect(blockParam.content).toContain('2. **Second Result**')
473
  })
 
474
 
475
- test('should handle no results message', () => {
476
- const output = {
477
- query: 'test',
478
- results: ['No results for: test'],
479
- durationSeconds: 1
480
- }
481
-
482
- const blockParam = WebSearchTool.mapToolResultToToolResultBlockParam(
483
- output,
484
- 'test-id'
485
  )
486
 
487
- expect(blockParam.content).toContain('No results for: test')
488
- })
 
 
 
489
  })
490
- })
 
8
  })
9
 
10
  test('should have correct description', () => {
11
+ expect(WebSearchTool.description).toContain('SearXNG')
12
  })
13
 
14
  test('should be enabled', () => {
 
34
  expect(result.result).toBe(true)
35
  })
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  test('should reject empty query', async () => {
38
  const result = await WebSearchTool.validateInput(
39
  { query: '' },
 
41
  )
42
 
43
  expect(result.result).toBe(false)
 
44
  })
45
 
46
  test('should reject missing input', async () => {
 
50
  )
51
 
52
  expect(result.result).toBe(false)
 
 
 
 
 
 
 
 
 
 
 
 
53
  })
54
  })
55
 
56
  describe('Permissions', () => {
57
  test('should allow all web search requests', async () => {
58
  const result = await WebSearchTool.checkPermissions(
59
+ { query: 'typescript' },
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  {}
61
  )
62
 
63
  expect(result.behavior).toBe('allow')
 
64
  })
65
  })
66
 
67
  describe('Tool Call - Successful Search', () => {
68
+ test('should perform web search', async () => {
69
  const result = await WebSearchTool.call(
70
  { query: 'typescript programming' },
71
  {},
72
  () => {},
73
+ null
 
 
 
 
74
  )
75
 
76
  expect(result).toBeDefined()
77
  expect(result.query).toBe('typescript programming')
 
78
  expect(Array.isArray(result.results)).toBe(true)
79
  expect(result.durationSeconds).toBeGreaterThan(0)
80
  }, 60000)
81
 
82
+ test('should return structured results', async () => {
83
  const result = await WebSearchTool.call(
84
  { query: 'javascript' },
85
  {},
 
87
  null
88
  )
89
 
90
+ expect(result.results.length).toBeGreaterThanOrEqual(0)
 
91
 
92
+ const first = result.results[0]
93
+ if (first && typeof first !== 'string') {
94
+ expect(first).toHaveProperty('tool_use_id')
95
+ expect(Array.isArray(first.content)).toBe(true)
 
 
96
  }
97
  }, 60000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  })
99
 
100
  describe('Tool Call - Error Handling', () => {
101
+ test('should handle missing query', async () => {
102
  const result = await WebSearchTool.call(
103
  { query: '' } as any,
104
  {},
 
106
  null
107
  )
108
 
109
+ expect(result.results.some(r => typeof r === 'string' && r.includes('Error'))).toBe(true)
 
110
  })
111
 
112
+ test('should not crash on failure', async () => {
 
113
  const result = await WebSearchTool.call(
114
  { query: 'test' },
115
  {},
 
118
  )
119
 
120
  expect(result).toBeDefined()
 
121
  }, 60000)
122
  })
123
 
124
  describe('Schema Validation', () => {
125
  test('should have valid input schema', () => {
 
 
 
 
 
 
 
 
 
 
126
  const schema = WebSearchTool.inputSchema
127
  const parsed = schema.safeParse({ query: 'test' })
128
  expect(parsed.success).toBe(true)
129
  })
130
 
131
+ test('should have valid output schema', () => {
132
+ expect(WebSearchTool.outputSchema).toBeDefined()
 
 
 
 
 
 
133
  })
134
  })
135
 
136
  describe('Tool Metadata', () => {
137
  test('should provide activity description', () => {
138
+ const desc = WebSearchTool.getActivityDescription({
139
  query: 'typescript'
140
  })
141
 
142
+ expect(desc).toContain('Searching')
 
143
  })
144
 
145
  test('should provide tool use summary', () => {
 
150
  expect(summary).toBeDefined()
151
  })
152
 
153
+ test('should return empty search text', () => {
154
+ const text = WebSearchTool.extractSearchText?.({
155
  results: ['test']
156
  } as any)
157
 
158
+ expect(text).toBe('')
159
  })
160
  })
161
 
162
  describe('Auto Classifier Input', () => {
163
+ test('should format input correctly', () => {
164
  const input = WebSearchTool.toAutoClassifierInput({
165
  query: 'typescript'
166
  })
167
 
168
  expect(input).toBe('typescript')
169
  })
 
 
 
 
 
 
 
 
170
  })
171
 
172
  describe('Tool Result Mapping', () => {
173
+ test('should map tool result correctly', () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  const output = {
175
  query: 'test',
176
  results: [{
177
+ tool_use_id: '1',
178
  content: [{
179
+ title: 'Test',
180
  url: 'https://example.com',
181
+ snippet: 'snippet'
182
  }]
183
  }],
184
  durationSeconds: 1
185
  }
186
 
187
+ const result = WebSearchTool.mapToolResultToToolResultBlockParam(
188
  output,
189
+ 'id'
190
  )
191
 
192
+ expect(result.type).toBe('tool_result')
193
+ expect(typeof result.content).toBe('string')
 
194
  })
195
 
196
+ test('should handle empty results', () => {
197
  const output = {
198
  query: 'test',
199
+ results: [],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  durationSeconds: 1
201
  }
202
 
203
+ const result = WebSearchTool.mapToolResultToToolResultBlockParam(
204
  output,
205
+ 'id'
206
  )
207
 
208
+ expect(result.content).toContain('Results')
 
209
  })
210
+ })
211
 
212
+ describe('SearXNG Integration', () => {
213
+ test('should search using SearXNG', async () => {
214
+ const result = await WebSearchTool.call(
215
+ { query: 'milet 的最新动态' },
216
+ {},
217
+ () => {},
218
+ null
 
 
 
219
  )
220
 
221
+ expect(result).toBeDefined()
222
+ expect(result.query).toBe('milet 的最新动态')
223
+ expect(Array.isArray(result.results)).toBe(true)
224
+ expect(result.durationSeconds).toBeGreaterThan(0)
225
+ }, 60000)
226
  })
227
+ })
src/tools/WebSearchTool/prompt.ts CHANGED
@@ -4,38 +4,40 @@ export const WEB_SEARCH_TOOL_NAME = 'WebSearch'
4
 
5
  export function getWebSearchPrompt(): string {
6
  const currentMonthYear = getLocalMonthYear()
 
7
  return `
8
- - Allows Claude to search the web using DuckDuckGo and use the results to inform responses
9
- - Provides up-to-date information for current events and recent data
10
- - Returns search result information formatted as search result blocks, including links as markdown hyperlinks
11
- - Use this tool for accessing information beyond Claude's knowledge cutoff
12
  - Works with all AI providers including local models
13
-
14
- CRITICAL - DOMAIN FILTERING RULES:
15
- - NEVER use allowed_domains or blocked_domains parameters unless the user EXPLICITLY requests it
16
- - ALWAYS search ALL domains by default - no automatic domain restrictions
17
- - DO NOT infer domain preferences from the query content (e.g., don't limit to social media for "latest news")
18
- - Leave allowed_domains and blocked_domains parameters UNSET (not provided) for normal searches
19
 
20
  Search Strategy:
21
- - Uses DuckDuckGo search to retrieve web search results
22
- - May encounter CAPTCHA challenges on some searches, which will return no results
23
- - Try rephrasing your query if no results are returned
 
24
 
25
  CRITICAL REQUIREMENT - You MUST follow this:
26
  - After answering the user's question, you MUST include a "Sources:" section at the end of your response
27
- - In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL)
28
  - This is MANDATORY - never skip including sources in your response
29
- - Example format:
30
-
31
- [Your answer here]
32
 
33
- Sources:
34
- - [Source Title 1](https://example.com/1)
35
- - [Source Title 2](https://example.com/2)
 
 
36
 
37
  IMPORTANT - Use the correct year in search queries:
38
- - The current month is ${currentMonthYear}. You MUST use this year when searching for recent information, documentation, or current events.
39
- - Example: If the user asks for "latest React docs", search for "React documentation" with the current year, NOT last year
 
 
 
 
 
 
40
  `
41
  }
 
4
 
5
  export function getWebSearchPrompt(): string {
6
  const currentMonthYear = getLocalMonthYear()
7
+
8
  return `
9
+ - Allows VersperClaw to search the web using a local SearXNG search engine and use the results to inform responses
10
+ - Provides up-to-date information for current events, technical documentation, and recent data
11
+ - Returns structured search results including titles, URLs, and snippets
 
12
  - Works with all AI providers including local models
13
+ - Designed for high-recall search to support reasoning and retrieval-augmented generation (RAG)
 
 
 
 
 
14
 
15
  Search Strategy:
16
+ - Uses SearXNG metasearch engine (aggregates multiple sources)
17
+ - Results may vary in quality; prioritize relevance and credibility
18
+ - If results are weak or empty, try rephrasing the query
19
+ - Prefer more specific queries when possible (add keywords, version numbers, or context)
20
 
21
  CRITICAL REQUIREMENT - You MUST follow this:
22
  - After answering the user's question, you MUST include a "Sources:" section at the end of your response
23
+ - In the Sources section, list relevant URLs from the search results as markdown hyperlinks: [Title](URL)
24
  - This is MANDATORY - never skip including sources in your response
25
+ - Only include sources that are actually useful and referenced in your answer
 
 
26
 
27
+ Search Best Practices:
28
+ - Use multiple searches if needed (broad → narrow)
29
+ - Use precise technical terms for programming-related queries
30
+ - For news or recent events, include time context (e.g., year, month)
31
+ - Do NOT assume the first result is correct — synthesize across multiple sources
32
 
33
  IMPORTANT - Use the correct year in search queries:
34
+ - The current month is ${currentMonthYear}
35
+ - You MUST use this year when searching for recent information, documentation, or current events
36
+ - Example: If the user asks for "latest React docs", search with the current year, NOT outdated versions
37
+
38
+ Behavior Guidelines:
39
+ - Focus on gathering information, not filtering it prematurely
40
+ - Prioritize recall first, then rely on reasoning to refine
41
+ - Avoid hallucinating information when search results are insufficient — instead, search again
42
  `
43
  }