Files
flynn/src/tools/builtin/web-search.ts
T

215 lines
5.7 KiB
TypeScript

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<SearchResult[]> {
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<SearchResult[]> {
// 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<ToolResult> => {
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),
};
}
},
};
}