feat: add web search and background process tools (Phases 4-5)
Phase 4 - Web search tool: - Brave Search API + SearXNG fallback - Configurable provider, max results - 14 tests Phase 5 - Background process management: - ProcessManager with start/status/output/kill/list tools - Configurable max concurrent, max runtime, buffer size - 28 tests
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import type { Tool, 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,
|
||||
): Promise<SearchResult[]> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
count: String(count),
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?${params.toString()}`,
|
||||
{
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
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,
|
||||
): Promise<SearchResult[]> {
|
||||
// Strip trailing slash from endpoint
|
||||
const baseUrl = endpoint.replace(/\/+$/, '');
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: 'json',
|
||||
categories: 'general',
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/search?${params.toString()}`, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
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.',
|
||||
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): Promise<ToolResult> => {
|
||||
const args = rawArgs as WebSearchArgs;
|
||||
// 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);
|
||||
} else {
|
||||
// SearXNG provider
|
||||
if (!config.endpoint) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'SearXNG endpoint not configured',
|
||||
};
|
||||
}
|
||||
results = await searchSearxng(args.query, count, config.endpoint);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: `No results found for: ${args.query}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: formatResults(results) };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user