const TIMEOUT_MS = 10_000; const DEFAULT_MAX_RESULTS = 10; const HARD_MAX_RESULTS = 20; type SearxngResult = { title: string; url: string; content?: string; engine?: string; score?: number; }; type SearxngResponse = { query: string; number_of_results: number; results: SearxngResult[]; }; type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void; function update(onUpdate: OnUpdate | undefined, text: string): void { onUpdate?.({ content: [{ type: 'text', text }] }); } function formatResults(results: SearxngResult[]): string { if (results.length === 0) return 'No results found.'; return results .map((r, i) => { const lines = [`${i + 1}. **${r.title}**`, ` ${r.url}`]; if (r.content?.trim()) lines.push(` ${r.content.trim()}`); return lines.join('\n'); }) .join('\n\n'); } export async function execute( _toolCallId: string, params: { query: string; max_results?: number }, _signal: AbortSignal | undefined, onUpdate?: OnUpdate, ) { const searxngUrl = process.env.PI_SEARXNG_URL; if (!searxngUrl) { return { content: [{ type: 'text', text: 'Web search is not configured. PI_SEARXNG_URL is not set.' }], details: { error: 'not_configured' }, isError: true, }; } const { query } = params; const maxResults = Math.min(params.max_results ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS); if (!query?.trim()) { return { content: [{ type: 'text', text: 'Query cannot be empty.' }], details: { error: 'empty_query' }, isError: true, }; } update(onUpdate, `Searching for: ${query}`); const searchUrl = new URL('/search', searxngUrl); searchUrl.searchParams.set('q', query); searchUrl.searchParams.set('format', 'json'); searchUrl.searchParams.set('categories', 'general'); let response: Response; try { response = await fetch(searchUrl.toString(), { signal: AbortSignal.timeout(TIMEOUT_MS), headers: { Accept: 'application/json' }, }); } catch (err) { return { content: [{ type: 'text', text: `Failed to reach SearXNG at ${searxngUrl}: ${String(err)}` }], details: { error: 'fetch_failed', url: searxngUrl }, isError: true, }; } if (!response.ok) { return { content: [{ type: 'text', text: `SearXNG returned HTTP ${response.status}` }], details: { error: 'http_error', status: response.status }, isError: true, }; } let data: SearxngResponse; try { data = (await response.json()) as SearxngResponse; } catch { return { content: [{ type: 'text', text: 'SearXNG returned an invalid response.' }], details: { error: 'invalid_json' }, isError: true, }; } const results = (data.results ?? []).slice(0, maxResults); update(onUpdate, `Found ${data.number_of_results ?? results.length} results, returning top ${results.length}`); const output = [ `Search: "${query}"`, `Results: ${results.length}`, ``, formatResults(results), ].join('\n'); return { content: [{ type: 'text', text: output }], details: { query, total: data.number_of_results ?? results.length, returned: results.length, results: results.map((r) => ({ title: r.title, url: r.url })), }, }; }