import type { Tool, ToolExecutionContext, ToolResult } from '../types.js'; /** Configuration for the web search tool. */ export interface WebSearchConfig { provider: 'brave' | 'searxng'; /** Required for Brave Search API. */ apiKey?: string; /** Required for SearXNG (e.g. 'http://searxng:8080'). */ endpoint?: string; /** Maximum number of results to return (default: 5). */ maxResults?: number; } interface WebSearchArgs { query: string; count?: number; } interface SearchResult { title: string; url: string; snippet: string; } /** Fetch timeout in milliseconds. */ const FETCH_TIMEOUT_MS = 10_000; /** Maximum allowed result count. */ const MAX_RESULTS = 20; /** Default result count. */ const DEFAULT_RESULTS = 5; /** * Format search results as numbered markdown. */ function formatResults(results: SearchResult[]): string { return results .map( (r, i) => `${i + 1}. **${r.title}** \u2014 ${r.url}\n ${r.snippet}`, ) .join('\n\n'); } /** * Search using the Brave Search API. */ async function searchBrave( query: string, count: number, apiKey: string, signal?: AbortSignal, ): Promise { const params = new URLSearchParams({ q: query, count: String(count), }); const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const fetchSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; const response = await fetch( `https://api.search.brave.com/res/v1/web/search?${params.toString()}`, { signal: fetchSignal, headers: { 'Accept': 'application/json', 'X-Subscription-Token': apiKey, }, }, ); if (!response.ok) { const body = await response.text(); throw new Error(`Brave API HTTP ${response.status}: ${body}`); } const data = (await response.json()) as { web?: { results?: Array<{ title: string; url: string; description: string }> }; }; const rawResults = data.web?.results ?? []; return rawResults.map((r) => ({ title: r.title, url: r.url, snippet: r.description, })); } /** * Search using a self-hosted SearXNG instance. */ async function searchSearxng( query: string, count: number, endpoint: string, signal?: AbortSignal, ): Promise { // Strip trailing slash from endpoint const baseUrl = endpoint.replace(/\/+$/, ''); const params = new URLSearchParams({ q: query, format: 'json', categories: 'general', }); const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const fetchSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; const response = await fetch(`${baseUrl}/search?${params.toString()}`, { signal: fetchSignal, headers: { 'Accept': 'application/json', }, }); if (!response.ok) { const body = await response.text(); throw new Error(`SearXNG HTTP ${response.status}: ${body}`); } const data = (await response.json()) as { results?: Array<{ title: string; url: string; content: string }>; }; const rawResults = (data.results ?? []).slice(0, count); return rawResults.map((r) => ({ title: r.title, url: r.url, snippet: r.content, })); } /** * Creates a web.search tool configured for the given search provider. * * Supports Brave Search API and self-hosted SearXNG. */ export function createWebSearchTool(config: WebSearchConfig): Tool { const defaultCount = config.maxResults ?? DEFAULT_RESULTS; return { name: 'web.search', description: 'Search the web for current information. Returns titles, URLs, and snippets from web search results. Use this to find up-to-date information about any topic.', requiredSecretScopes: ['web_search'], inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query' }, count: { type: 'number', description: 'Number of results to return (default 5, max 20)', }, }, required: ['query'], }, execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise => { const args = rawArgs as WebSearchArgs; if (context?.signal?.aborted) { return { success: false, output: '', error: 'Operation aborted' }; } // Clamp count: use provided value (capped at MAX_RESULTS), or fall back to default const count = Math.min(args.count ?? defaultCount, MAX_RESULTS); try { let results: SearchResult[]; if (config.provider === 'brave') { if (!config.apiKey) { return { success: false, output: '', error: 'Brave Search API key not configured', }; } results = await searchBrave(args.query, count, config.apiKey, context?.signal); } else { // SearXNG provider if (!config.endpoint) { return { success: false, output: '', error: 'SearXNG endpoint not configured', }; } results = await searchSearxng(args.query, count, config.endpoint, context?.signal); } if (results.length === 0) { return { success: true, output: `No results found for: ${args.query}`, }; } return { success: true, output: formatResults(results) }; } catch (error) { if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) { return { success: false, output: '', error: 'Operation aborted' }; } return { success: false, output: '', error: error instanceof Error ? error.message : String(error), }; } }, }; }