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<
|
| 31 |
|
| 32 |
const searchResultSchema = lazySchema(() => {
|
| 33 |
const searchHitSchema = z.object({
|
| 34 |
-
title: z.string()
|
| 35 |
-
url: z.string()
|
| 36 |
-
snippet: z.string().optional()
|
| 37 |
})
|
| 38 |
|
| 39 |
return z.object({
|
| 40 |
-
tool_use_id: z.string()
|
| 41 |
-
content: z.array(searchHitSchema)
|
| 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()
|
| 50 |
-
results: z
|
| 51 |
-
|
| 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<
|
| 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 |
-
*
|
| 69 |
-
* This is the primary and only search method
|
| 70 |
*/
|
| 71 |
-
async function
|
| 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 |
-
|
| 80 |
-
|
| 81 |
-
console.log(`[WebSearch] Searching DuckDuckGo for: "${query}" (via Python webtools)`)
|
| 82 |
|
| 83 |
try {
|
| 84 |
-
const
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 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 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
)
|
| 182 |
-
}
|
| 183 |
|
| 184 |
-
|
| 185 |
-
return !blockedDomains.some(blocked =>
|
| 186 |
-
domain === blocked || domain.endsWith(`.${blocked}`)
|
| 187 |
-
)
|
| 188 |
-
}
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
/**
|
| 199 |
-
*
|
| 200 |
*/
|
| 201 |
function stripTags(text: string): string {
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
text = text.replace(/"/g, '"')
|
| 214 |
-
text = text.replace(/'/g, "'")
|
| 215 |
-
text = text.replace(/ /g, ' ')
|
| 216 |
-
|
| 217 |
-
return text.trim()
|
| 218 |
}
|
| 219 |
|
| 220 |
-
/**
|
| 221 |
-
* Normalize whitespace in text
|
| 222 |
-
*/
|
| 223 |
function normalizeText(text: string): string {
|
| 224 |
-
//
|
| 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 |
-
|
| 235 |
-
|
| 236 |
-
|
| 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
|
| 251 |
name: WEB_SEARCH_TOOL_NAME,
|
| 252 |
-
description: 'Search the web
|
|
|
|
| 253 |
getToolUseSummary,
|
| 254 |
getActivityDescription(input) {
|
| 255 |
-
|
| 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 |
-
|
|
|
|
| 263 |
return inputSchema()
|
| 264 |
},
|
| 265 |
-
|
|
|
|
| 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 |
-
|
| 278 |
-
|
| 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 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 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 |
-
|
| 322 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
if (onProgress) {
|
| 346 |
onProgress({
|
| 347 |
-
toolUseID: 'search-
|
| 348 |
-
data: { type: '
|
| 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 |
-
|
| 367 |
-
|
|
|
|
| 368 |
...r,
|
| 369 |
...cleanSearchResult(r),
|
| 370 |
}))
|
| 371 |
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 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:
|
| 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
|
| 427 |
}
|
| 428 |
}
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 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 |
-
|
| 447 |
-
|
| 448 |
-
|
| 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:
|
| 469 |
}
|
| 470 |
},
|
| 471 |
-
}) satisfies ToolDef<
|
|
|
|
| 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(/&/g, '&')
|
| 107 |
+
.replace(/</g, '<')
|
| 108 |
+
.replace(/>/g, '>')
|
| 109 |
+
.replace(/"/g, '"')
|
| 110 |
+
.replace(/'/g, "'")
|
| 111 |
+
.replace(/ /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).
|
| 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'
|
| 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
|
| 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
|
| 139 |
const result = await WebSearchTool.call(
|
| 140 |
{ query: 'javascript' },
|
| 141 |
{},
|
|
@@ -143,65 +87,18 @@ describe('WebSearchTool', () => {
|
|
| 143 |
null
|
| 144 |
)
|
| 145 |
|
| 146 |
-
expect(result.results).
|
| 147 |
-
expect(result.results.length).toBeGreaterThan(0)
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
expect(
|
| 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
|
| 205 |
const result = await WebSearchTool.call(
|
| 206 |
{ query: '' } as any,
|
| 207 |
{},
|
|
@@ -209,12 +106,10 @@ describe('WebSearchTool', () => {
|
|
| 209 |
null
|
| 210 |
)
|
| 211 |
|
| 212 |
-
expect(result.
|
| 213 |
-
expect(result.results).toContain('Error: Missing query')
|
| 214 |
})
|
| 215 |
|
| 216 |
-
test('should
|
| 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('
|
| 248 |
-
|
| 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
|
| 261 |
query: 'typescript'
|
| 262 |
})
|
| 263 |
|
| 264 |
-
expect(
|
| 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
|
| 277 |
-
const
|
| 278 |
results: ['test']
|
| 279 |
} as any)
|
| 280 |
|
| 281 |
-
expect(
|
| 282 |
})
|
| 283 |
})
|
| 284 |
|
| 285 |
describe('Auto Classifier Input', () => {
|
| 286 |
-
test('should format input
|
| 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
|
| 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: '
|
| 426 |
content: [{
|
| 427 |
-
title: 'Test
|
| 428 |
url: 'https://example.com',
|
| 429 |
-
snippet: '
|
| 430 |
}]
|
| 431 |
}],
|
| 432 |
durationSeconds: 1
|
| 433 |
}
|
| 434 |
|
| 435 |
-
const
|
| 436 |
output,
|
| 437 |
-
'
|
| 438 |
)
|
| 439 |
|
| 440 |
-
expect(
|
| 441 |
-
expect(
|
| 442 |
-
expect(blockParam.content).toContain('Test snippet')
|
| 443 |
})
|
| 444 |
|
| 445 |
-
test('should
|
| 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
|
| 467 |
output,
|
| 468 |
-
'
|
| 469 |
)
|
| 470 |
|
| 471 |
-
expect(
|
| 472 |
-
expect(blockParam.content).toContain('2. **Second Result**')
|
| 473 |
})
|
|
|
|
| 474 |
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
const blockParam = WebSearchTool.mapToolResultToToolResultBlockParam(
|
| 483 |
-
output,
|
| 484 |
-
'test-id'
|
| 485 |
)
|
| 486 |
|
| 487 |
-
expect(
|
| 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
|
| 9 |
-
- Provides up-to-date information for current events and recent data
|
| 10 |
-
- Returns
|
| 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
|
| 22 |
-
-
|
| 23 |
-
-
|
|
|
|
| 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
|
| 28 |
- This is MANDATORY - never skip including sources in your response
|
| 29 |
-
-
|
| 30 |
-
|
| 31 |
-
[Your answer here]
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
|
| 37 |
IMPORTANT - Use the correct year in search queries:
|
| 38 |
-
- The current month is ${currentMonthYear}
|
| 39 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|