215 lines
5.7 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|