import { Type } from '@sinclair/typebox'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; const DEFAULT_TIMEOUT_MS = 15_000; const MAX_RESULTS = 10; const MAX_RESPONSE_BYTES = 256 * 1024; // 256 KB // ─── Provider helpers ──────────────────────────────────────────────────────── interface SearchResult { title: string; url: string; snippet: string; } interface SearchResponse { provider: string; query: string; results: SearchResult[]; error?: string; } async function fetchWithTimeout( url: string, init: RequestInit, timeoutMs: number, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timer); } } async function readLimited(response: Response): Promise { const reader = response.body?.getReader(); if (!reader) return ''; const chunks: Uint8Array[] = []; let total = 0; while (true) { const { done, value } = await reader.read(); if (done) break; total += value.length; if (total > MAX_RESPONSE_BYTES) { chunks.push(value.subarray(0, MAX_RESPONSE_BYTES - (total - value.length))); reader.cancel(); break; } chunks.push(value); } const combined = new Uint8Array(chunks.reduce((a, c) => a + c.length, 0)); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } return new TextDecoder().decode(combined); } // ─── Brave Search ──────────────────────────────────────────────────────────── async function searchBrave(query: string, limit: number): Promise { const apiKey = process.env['BRAVE_API_KEY']; if (!apiKey) return { provider: 'brave', query, results: [], error: 'BRAVE_API_KEY not set' }; try { const params = new URLSearchParams({ q: query, count: String(Math.min(limit, 20)), }); const res = await fetchWithTimeout( `https://api.search.brave.com/res/v1/web/search?${params}`, { headers: { 'X-Subscription-Token': apiKey, Accept: 'application/json' } }, DEFAULT_TIMEOUT_MS, ); if (!res.ok) { const body = await res.text().catch(() => ''); return { provider: 'brave', query, results: [], error: `HTTP ${res.status}: ${body}` }; } const data = (await res.json()) as { web?: { results?: Array<{ title: string; url: string; description: string }> }; }; const results: SearchResult[] = (data.web?.results ?? []).slice(0, limit).map((r) => ({ title: r.title, url: r.url, snippet: r.description, })); return { provider: 'brave', query, results }; } catch (err) { return { provider: 'brave', query, results: [], error: err instanceof Error ? err.message : String(err), }; } } // ─── Tavily Search ─────────────────────────────────────────────────────────── async function searchTavily(query: string, limit: number): Promise { const apiKey = process.env['TAVILY_API_KEY']; if (!apiKey) return { provider: 'tavily', query, results: [], error: 'TAVILY_API_KEY not set' }; try { const res = await fetchWithTimeout( 'https://api.tavily.com/search', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: apiKey, query, max_results: Math.min(limit, 10), include_answer: false, }), }, DEFAULT_TIMEOUT_MS, ); if (!res.ok) { const body = await res.text().catch(() => ''); return { provider: 'tavily', query, results: [], error: `HTTP ${res.status}: ${body}` }; } const data = (await res.json()) as { results?: Array<{ title: string; url: string; content: string }>; }; const results: SearchResult[] = (data.results ?? []).slice(0, limit).map((r) => ({ title: r.title, url: r.url, snippet: r.content, })); return { provider: 'tavily', query, results }; } catch (err) { return { provider: 'tavily', query, results: [], error: err instanceof Error ? err.message : String(err), }; } } // ─── SearXNG (self-hosted) ─────────────────────────────────────────────────── async function searchSearxng(query: string, limit: number): Promise { const baseUrl = process.env['SEARXNG_URL']; if (!baseUrl) return { provider: 'searxng', query, results: [], error: 'SEARXNG_URL not set' }; try { const params = new URLSearchParams({ q: query, format: 'json', pageno: '1', }); const res = await fetchWithTimeout( `${baseUrl.replace(/\/$/, '')}/search?${params}`, { headers: { Accept: 'application/json' } }, DEFAULT_TIMEOUT_MS, ); if (!res.ok) { const body = await res.text().catch(() => ''); return { provider: 'searxng', query, results: [], error: `HTTP ${res.status}: ${body}` }; } const data = (await res.json()) as { results?: Array<{ title: string; url: string; content: string }>; }; const results: SearchResult[] = (data.results ?? []).slice(0, limit).map((r) => ({ title: r.title, url: r.url, snippet: r.content, })); return { provider: 'searxng', query, results }; } catch (err) { return { provider: 'searxng', query, results: [], error: err instanceof Error ? err.message : String(err), }; } } // ─── DuckDuckGo (lite HTML endpoint) ───────────────────────────────────────── async function searchDuckDuckGo(query: string, limit: number): Promise { try { // Use the DuckDuckGo Instant Answer API (JSON, free, no key) const params = new URLSearchParams({ q: query, format: 'json', no_html: '1', skip_disambig: '1', }); const res = await fetchWithTimeout( `https://api.duckduckgo.com/?${params}`, { headers: { Accept: 'application/json' } }, DEFAULT_TIMEOUT_MS, ); if (!res.ok) { return { provider: 'duckduckgo', query, results: [], error: `HTTP ${res.status}`, }; } const text = await readLimited(res); const data = JSON.parse(text) as { AbstractText?: string; AbstractURL?: string; AbstractSource?: string; RelatedTopics?: Array<{ Text?: string; FirstURL?: string; Result?: string; Topics?: Array<{ Text?: string; FirstURL?: string }>; }>; }; const results: SearchResult[] = []; // Main abstract result if (data.AbstractText && data.AbstractURL) { results.push({ title: data.AbstractSource ?? 'DuckDuckGo Abstract', url: data.AbstractURL, snippet: data.AbstractText, }); } // Related topics for (const topic of data.RelatedTopics ?? []) { if (results.length >= limit) break; if (topic.Text && topic.FirstURL) { results.push({ title: topic.Text.slice(0, 120), url: topic.FirstURL, snippet: topic.Text, }); } // Sub-topics for (const sub of topic.Topics ?? []) { if (results.length >= limit) break; if (sub.Text && sub.FirstURL) { results.push({ title: sub.Text.slice(0, 120), url: sub.FirstURL, snippet: sub.Text, }); } } } return { provider: 'duckduckgo', query, results: results.slice(0, limit) }; } catch (err) { return { provider: 'duckduckgo', query, results: [], error: err instanceof Error ? err.message : String(err), }; } } // ─── Provider resolution ───────────────────────────────────────────────────── type SearchProvider = 'brave' | 'tavily' | 'searxng' | 'duckduckgo' | 'auto'; function getAvailableProviders(): SearchProvider[] { const available: SearchProvider[] = []; if (process.env['BRAVE_API_KEY']) available.push('brave'); if (process.env['TAVILY_API_KEY']) available.push('tavily'); if (process.env['SEARXNG_URL']) available.push('searxng'); // DuckDuckGo is always available (no API key needed) available.push('duckduckgo'); return available; } async function executeSearch( provider: SearchProvider, query: string, limit: number, ): Promise { switch (provider) { case 'brave': return searchBrave(query, limit); case 'tavily': return searchTavily(query, limit); case 'searxng': return searchSearxng(query, limit); case 'duckduckgo': return searchDuckDuckGo(query, limit); case 'auto': { // Try providers in priority order: Brave > Tavily > SearXNG > DuckDuckGo const available = getAvailableProviders(); for (const p of available) { const result = await executeSearch(p, query, limit); if (!result.error && result.results.length > 0) return result; } // Fall back to DuckDuckGo if everything failed return searchDuckDuckGo(query, limit); } } } function formatSearchResults(response: SearchResponse): string { const lines: string[] = []; lines.push(`Search provider: ${response.provider}`); lines.push(`Query: "${response.query}"`); if (response.error) { lines.push(`Error: ${response.error}`); } if (response.results.length === 0) { lines.push('No results found.'); } else { lines.push(`Results (${response.results.length}):\n`); for (let i = 0; i < response.results.length; i++) { const r = response.results[i]!; lines.push(`${i + 1}. ${r.title}`); lines.push(` URL: ${r.url}`); lines.push(` ${r.snippet}`); lines.push(''); } } return lines.join('\n'); } // ─── Tool exports ──────────────────────────────────────────────────────────── export function createSearchTools(): ToolDefinition[] { const webSearch: ToolDefinition = { name: 'web_search', label: 'Web Search', description: 'Search the web using configured search providers. ' + 'Supports Brave, Tavily, SearXNG, and DuckDuckGo. ' + 'Use "auto" provider to pick the best available. ' + 'DuckDuckGo is always available as a fallback (no API key needed).', parameters: Type.Object({ query: Type.String({ description: 'Search query' }), provider: Type.Optional( Type.String({ description: 'Search provider: "auto" (default), "brave", "tavily", "searxng", or "duckduckgo"', }), ), limit: Type.Optional( Type.Number({ description: `Max results to return (default 5, max ${MAX_RESULTS})` }), ), }), async execute(_toolCallId, params) { const { query, provider, limit } = params as { query: string; provider?: string; limit?: number; }; const effectiveProvider = (provider ?? 'auto') as SearchProvider; const validProviders = ['auto', 'brave', 'tavily', 'searxng', 'duckduckgo']; if (!validProviders.includes(effectiveProvider)) { return { content: [ { type: 'text' as const, text: `Invalid provider "${provider}". Valid: ${validProviders.join(', ')}`, }, ], details: undefined, }; } const effectiveLimit = Math.min(Math.max(limit ?? 5, 1), MAX_RESULTS); try { const response = await executeSearch(effectiveProvider, query, effectiveLimit); return { content: [{ type: 'text' as const, text: formatSearchResults(response) }], details: undefined, }; } catch (err) { return { content: [ { type: 'text' as const, text: `Search failed: ${err instanceof Error ? err.message : String(err)}`, }, ], details: undefined, }; } }, }; const webSearchNews: ToolDefinition = { name: 'web_search_news', label: 'Web Search (News)', description: 'Search for recent news articles. Uses Brave News API if available, falls back to standard search with news keywords.', parameters: Type.Object({ query: Type.String({ description: 'News search query' }), limit: Type.Optional( Type.Number({ description: `Max results (default 5, max ${MAX_RESULTS})` }), ), }), async execute(_toolCallId, params) { const { query, limit } = params as { query: string; limit?: number }; const effectiveLimit = Math.min(Math.max(limit ?? 5, 1), MAX_RESULTS); // Try Brave News API first (dedicated news endpoint) const braveKey = process.env['BRAVE_API_KEY']; if (braveKey) { try { const newsParams = new URLSearchParams({ q: query, count: String(effectiveLimit), }); const res = await fetchWithTimeout( `https://api.search.brave.com/res/v1/news/search?${newsParams}`, { headers: { 'X-Subscription-Token': braveKey, Accept: 'application/json', }, }, DEFAULT_TIMEOUT_MS, ); if (res.ok) { const data = (await res.json()) as { results?: Array<{ title: string; url: string; description: string; age?: string; }>; }; const results: SearchResult[] = (data.results ?? []) .slice(0, effectiveLimit) .map((r) => ({ title: r.title + (r.age ? ` (${r.age})` : ''), url: r.url, snippet: r.description, })); const response: SearchResponse = { provider: 'brave-news', query, results }; return { content: [{ type: 'text' as const, text: formatSearchResults(response) }], details: undefined, }; } } catch { // Fall through to generic search } } // Fallback: standard search with "news" appended const newsQuery = `${query} news latest`; const response = await executeSearch('auto', newsQuery, effectiveLimit); return { content: [{ type: 'text' as const, text: formatSearchResults(response) }], details: undefined, }; }, }; const searchProviders: ToolDefinition = { name: 'web_search_providers', label: 'List Search Providers', description: 'List the currently available and configured web search providers.', parameters: Type.Object({}), async execute() { const available = getAvailableProviders(); const allProviders = [ { name: 'brave', configured: !!process.env['BRAVE_API_KEY'], envVar: 'BRAVE_API_KEY' }, { name: 'tavily', configured: !!process.env['TAVILY_API_KEY'], envVar: 'TAVILY_API_KEY' }, { name: 'searxng', configured: !!process.env['SEARXNG_URL'], envVar: 'SEARXNG_URL' }, { name: 'duckduckgo', configured: true, envVar: '(none — always available)' }, ]; const lines = ['Search providers:\n']; for (const p of allProviders) { const status = p.configured ? '✓ configured' : '✗ not configured'; lines.push(` ${p.name}: ${status} (${p.envVar})`); } lines.push(`\nActive providers for "auto" mode: ${available.join(', ')}`); return { content: [{ type: 'text' as const, text: lines.join('\n') }], details: undefined, }; }, }; return [webSearch, webSearchNews, searchProviders]; }