diff --git a/src/config.ts b/src/config.ts index c56fbd69..e7dbe29c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,12 @@ export type CommitConfig = { export type ProviderConfig = Partial>; +export type SearchConfig = { + provider?: 'tavily'; + maxResults?: number; + timeout?: number; +}; + export type DesktopConfig = { theme?: 'light' | 'dark' | 'system'; sendMessageWith?: 'enter' | 'cmdEnter'; @@ -74,6 +80,10 @@ export type Config = { temperature?: number; httpProxy?: string; desktop?: DesktopConfig; + /** + * Web search configuration + */ + search?: SearchConfig; /** * Extensions configuration for third-party custom agents. * Allows arbitrary nested configuration without validation. @@ -121,6 +131,7 @@ const VALID_CONFIG_KEYS = [ 'browser', 'temperature', 'httpProxy', + 'search', 'extensions', 'tools', ]; @@ -129,6 +140,7 @@ const OBJECT_CONFIG_KEYS = [ 'mcpServers', 'commit', 'provider', + 'search', 'extensions', 'tools', 'desktop', diff --git a/src/tool.ts b/src/tool.ts index 504de039..3265def4 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -16,6 +16,8 @@ import { createGlobTool } from './tools/glob'; import { createGrepTool } from './tools/grep'; import { createLSTool } from './tools/ls'; import { createReadTool } from './tools/read'; +import { createSearchTool } from './tools/search'; +import { getProviderApiKey } from './tools/search/config'; import { createTodoTool, type TodoItem } from './tools/todo'; import { createWriteTool } from './tools/write'; @@ -33,12 +35,30 @@ export async function resolveTools(opts: ResolveToolsOpts) { const model = ( await resolveModelWithContext(opts.context.config.model, opts.context) ).model!; + + // Search tool - only register if search is configured and API key is available + const searchTools = (() => { + const searchConfig = opts.context.config.search; + if (!searchConfig) return []; + + const provider = searchConfig.provider || 'tavily'; + const apiKey = getProviderApiKey(provider); + + if (!apiKey) { + // Silent skip if no API key configured + return []; + } + + return [createSearchTool({ context: opts.context })]; + })(); + const readonlyTools = [ createReadTool({ cwd, productName }), createLSTool({ cwd, productName }), createGlobTool({ cwd }), createGrepTool({ cwd }), createFetchTool({ model }), + ...searchTools, ]; const askUserQuestionTools = opts.askUserQuestion ? [createAskUserQuestionTool()] diff --git a/src/tools/search/config.ts b/src/tools/search/config.ts new file mode 100644 index 00000000..5aed9253 --- /dev/null +++ b/src/tools/search/config.ts @@ -0,0 +1,70 @@ +import type { ProviderConfig, SearchConfig, SupportedProvider } from './types'; +import { SEARCH_CONSTANTS } from './constants'; + +/** + * Default configuration + */ +export const DEFAULT_SEARCH_CONFIG: SearchConfig = { + provider: 'tavily', + maxResults: SEARCH_CONSTANTS.DEFAULT_MAX_RESULTS, + timeout: SEARCH_CONSTANTS.DEFAULT_TIMEOUT, +}; + +/** + * Supported providers list + */ +const SUPPORTED_PROVIDERS: SupportedProvider[] = ['tavily']; + +/** + * Get provider API key from environment or config + */ +export function getProviderApiKey( + provider: SupportedProvider, +): string | undefined { + const envKey = `${provider.toUpperCase()}_API_KEY`; + return process.env[envKey]; +} + +/** + * Validate search configuration + */ +export function validateSearchConfig(config: SearchConfig): void { + if (config.maxResults !== undefined) { + if ( + config.maxResults < SEARCH_CONSTANTS.MIN_RESULTS_LIMIT || + config.maxResults > SEARCH_CONSTANTS.MAX_RESULTS_LIMIT + ) { + throw new Error( + `maxResults must be between ${SEARCH_CONSTANTS.MIN_RESULTS_LIMIT} and ${SEARCH_CONSTANTS.MAX_RESULTS_LIMIT}`, + ); + } + } + + if (config.timeout !== undefined && config.timeout <= 0) { + throw new Error('timeout must be greater than 0'); + } + + if (config.provider && !SUPPORTED_PROVIDERS.includes(config.provider)) { + throw new Error( + `Unsupported provider: ${config.provider}. Supported providers: ${SUPPORTED_PROVIDERS.join(', ')}`, + ); + } +} + +/** + * Build provider configuration + */ +export function buildProviderConfig( + searchConfig: SearchConfig, +): ProviderConfig { + validateSearchConfig(searchConfig); + + const provider = searchConfig.provider || DEFAULT_SEARCH_CONFIG.provider!; + const apiKey = getProviderApiKey(provider); + + return { + apiKey, + timeout: searchConfig.timeout || DEFAULT_SEARCH_CONFIG.timeout, + maxRetries: SEARCH_CONSTANTS.DEFAULT_MAX_RETRIES, + }; +} diff --git a/src/tools/search/constants.ts b/src/tools/search/constants.ts new file mode 100644 index 00000000..465d1e3c --- /dev/null +++ b/src/tools/search/constants.ts @@ -0,0 +1,20 @@ +export const SEARCH_CONSTANTS = { + // Default values + DEFAULT_MAX_RESULTS: 5, + DEFAULT_TIMEOUT: 10000, + DEFAULT_MAX_RETRIES: 3, + + // Limits + MAX_QUERY_LENGTH: 500, + MAX_RESULTS_LIMIT: 20, + MIN_RESULTS_LIMIT: 1, + + // Content formatting + MAX_CONTENT_LENGTH: 2000, + CONTENT_TRUNCATE_SUFFIX: '...', + + // API URLs + API_URLS: { + TAVILY: 'https://api.tavily.com/search', + }, +} as const; diff --git a/src/tools/search/formatter.ts b/src/tools/search/formatter.ts new file mode 100644 index 00000000..0ca9e945 --- /dev/null +++ b/src/tools/search/formatter.ts @@ -0,0 +1,74 @@ +import type { SearchResponse, SearchResult } from './types'; +import { SEARCH_CONSTANTS } from './constants'; + +/** + * Truncate content to a maximum length + */ +function truncateContent(content: string, maxLength: number): string { + if (content.length <= maxLength) { + return content; + } + return content.slice(0, maxLength) + SEARCH_CONSTANTS.CONTENT_TRUNCATE_SUFFIX; +} + +/** + * Format search response for LLM + */ +export function formatForLLM(response: SearchResponse): string { + const parts: string[] = []; + + if (response.answer) { + parts.push('## Answer'); + parts.push(response.answer); + parts.push(''); + } + + parts.push('## Search Results'); + parts.push(''); + + response.results.forEach((result, index) => { + parts.push(`### ${index + 1}. ${result.title}`); + parts.push(`**URL:** ${result.url}`); + if (result.publishedDate) { + parts.push(`**Published:** ${result.publishedDate}`); + } + if (result.score !== undefined) { + parts.push(`**Relevance:** ${(result.score * 100).toFixed(1)}%`); + } + parts.push(''); + parts.push( + truncateContent(result.content, SEARCH_CONSTANTS.MAX_CONTENT_LENGTH), + ); + parts.push(''); + parts.push('---'); + parts.push(''); + }); + + parts.push('## Metadata'); + parts.push(`- Query: ${response.query}`); + parts.push(`- Provider: ${response.provider}`); + parts.push(`- Results: ${response.results.length}`); + parts.push(`- Search Time: ${response.searchTime}ms`); + + return parts.join('\n'); +} + +export function formatForDisplay(response: SearchResponse): string { + return `Found ${response.results.length} results in ${response.searchTime}ms`; +} + +export function deduplicateResults(results: SearchResult[]): SearchResult[] { + const seen = new Set(); + return results.filter((result) => { + const key = result.url.toLowerCase(); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +export function sortByRelevance(results: SearchResult[]): SearchResult[] { + return results.sort((a, b) => (b.score || 0) - (a.score || 0)); +} diff --git a/src/tools/search/index.ts b/src/tools/search/index.ts new file mode 100644 index 00000000..3428140c --- /dev/null +++ b/src/tools/search/index.ts @@ -0,0 +1,19 @@ +export { createSearchTool } from './tool'; +export type { + SearchConfig, + SearchOptions, + SearchProvider, + SearchResponse, + SearchResult, + ProviderConfig, + SupportedProvider, +} from './types'; +export { SEARCH_CONSTANTS } from './constants'; +export { defaultRegistry, ProviderRegistry } from './providers/registry'; +export { BaseSearchProvider } from './providers/base'; +export { + formatForLLM, + formatForDisplay, + deduplicateResults, + sortByRelevance, +} from './formatter'; diff --git a/src/tools/search/providers/base.ts b/src/tools/search/providers/base.ts new file mode 100644 index 00000000..01329dac --- /dev/null +++ b/src/tools/search/providers/base.ts @@ -0,0 +1,151 @@ +import type { + ProviderConfig, + SearchOptions, + SearchProvider, + SearchResponse, +} from '../types'; +import { SEARCH_CONSTANTS } from '../constants'; +import { withRetry } from '../utils/retry'; + +export abstract class BaseSearchProvider implements SearchProvider { + abstract readonly name: string; + abstract readonly requiresApiKey: boolean; + + protected config: ProviderConfig; + + constructor(config: ProviderConfig) { + this.config = { + timeout: SEARCH_CONSTANTS.DEFAULT_TIMEOUT, + maxRetries: SEARCH_CONSTANTS.DEFAULT_MAX_RETRIES, + ...config, + }; + } + + /** + * Get API key configuration help message + */ + protected getApiKeyHelpMessage(): string { + const envVar = `${this.name.toUpperCase()}_API_KEY`; + let message = + `API key is required for ${this.name}.\n\n` + + `To configure:\n` + + `1. Set environment variable: export ${envVar}=your_api_key\n` + + `2. Or add to config file:\n` + + ` {\n` + + ` "search": {\n` + + ` "provider": "${this.name}"\n` + + ` }\n` + + ` }`; + + if ('apiKeyUrl' in this && typeof (this as any).apiKeyUrl === 'string') { + message += `\n\nGet your API key at: ${(this as any).apiKeyUrl}`; + } + + return message; + } + + /** + * Search entry point + */ + async search(options: SearchOptions): Promise { + // Validate API key + if (this.requiresApiKey && !this.config.apiKey) { + throw new Error(this.getApiKeyHelpMessage()); + } + + // Validate query + if (!options.query || options.query.trim().length === 0) { + throw new Error('Search query cannot be empty'); + } + + if (options.query.length > SEARCH_CONSTANTS.MAX_QUERY_LENGTH) { + throw new Error( + `Query too long (max ${SEARCH_CONSTANTS.MAX_QUERY_LENGTH} characters)`, + ); + } + + // Execute search with retry logic + return await withRetry(() => this.doSearch(options), { + maxRetries: + this.config.maxRetries || SEARCH_CONSTANTS.DEFAULT_MAX_RETRIES, + }); + } + + /** + * Actual search implementation (implemented by subclasses) + */ + protected abstract doSearch(options: SearchOptions): Promise; + + /** + * Health check + */ + async healthCheck(): Promise { + try { + await this.search({ query: 'test', maxResults: 1 }); + return true; + } catch { + return false; + } + } + + /** + * HTTP request helper with timeout support + */ + protected async fetch( + url: string, + options: RequestInit = {}, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + this.config.timeout || SEARCH_CONSTANTS.DEFAULT_TIMEOUT, + ); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Neovate-Code/1.0', + ...options.headers, + }, + }); + + if (response.status === 429) { + throw new Error( + 'Rate limit exceeded. Please try again later.\n\n' + + 'Tips:\n' + + '- Wait a few minutes before retrying\n' + + '- Check your API plan limits\n' + + '- Consider upgrading your plan for higher rate limits', + ); + } + + if (response.status === 401 || response.status === 403) { + throw new Error( + `Authentication failed (HTTP ${response.status}).\n\n` + + 'Please check:\n' + + '- Your API key is correct\n' + + '- Your API key has not expired\n' + + '- Your account has sufficient credits', + ); + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return response; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error( + `Request timeout after ${this.config.timeout || SEARCH_CONSTANTS.DEFAULT_TIMEOUT}ms`, + ); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } +} diff --git a/src/tools/search/providers/registry.ts b/src/tools/search/providers/registry.ts new file mode 100644 index 00000000..bee310fa --- /dev/null +++ b/src/tools/search/providers/registry.ts @@ -0,0 +1,36 @@ +import type { ProviderConfig, SearchProvider } from '../types'; +import { TavilyProvider } from './tavily'; + +type ProviderConstructor = new (config: ProviderConfig) => SearchProvider; + +export class ProviderRegistry { + private providers: Map = new Map(); + + constructor() { + this.register('tavily', TavilyProvider); + } + + register(name: string, provider: ProviderConstructor): void { + this.providers.set(name, provider); + } + + get(name: string, config: ProviderConfig): SearchProvider { + const ProviderClass = this.providers.get(name); + + if (!ProviderClass) { + throw new Error(`Search provider '${name}' not found`); + } + + return new ProviderClass(config); + } + + has(name: string): boolean { + return this.providers.has(name); + } + + list(): string[] { + return Array.from(this.providers.keys()); + } +} + +export const defaultRegistry = new ProviderRegistry(); diff --git a/src/tools/search/providers/tavily.ts b/src/tools/search/providers/tavily.ts new file mode 100644 index 00000000..ad8b4b9e --- /dev/null +++ b/src/tools/search/providers/tavily.ts @@ -0,0 +1,68 @@ +import type { + ProviderConfig, + SearchOptions, + SearchResponse, + SearchResult, +} from '../types'; +import { BaseSearchProvider } from './base'; +import { SEARCH_CONSTANTS } from '../constants'; + +interface TavilySearchResult { + title: string; + url: string; + content: string; + score: number; + published_date?: string; +} + +interface TavilyResponse { + answer?: string; + results: TavilySearchResult[]; + query: string; +} + +export class TavilyProvider extends BaseSearchProvider { + readonly name = 'tavily'; + readonly requiresApiKey = true; + readonly apiKeyUrl = 'https://tavily.com'; + + constructor(config: ProviderConfig) { + super(config); + } + + protected async doSearch(options: SearchOptions): Promise { + const startTime = Date.now(); + + const requestBody = { + api_key: this.config.apiKey, + query: options.query, + max_results: options.maxResults || SEARCH_CONSTANTS.DEFAULT_MAX_RESULTS, + search_depth: options.searchType === 'code' ? 'advanced' : 'basic', + include_answer: true, + include_raw_content: options.includeRawContent || false, + }; + + const response = await this.fetch(SEARCH_CONSTANTS.API_URLS.TAVILY, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + const data: TavilyResponse = await response.json(); + + const results: SearchResult[] = data.results.map((result) => ({ + title: result.title, + url: result.url, + content: result.content, + score: result.score, + publishedDate: result.published_date, + })); + + return { + results, + query: options.query, + provider: this.name, + searchTime: Date.now() - startTime, + answer: data.answer, + }; + } +} diff --git a/src/tools/search/tool.ts b/src/tools/search/tool.ts new file mode 100644 index 00000000..cddb493f --- /dev/null +++ b/src/tools/search/tool.ts @@ -0,0 +1,103 @@ +import { z } from 'zod'; +import type { Context } from '../../context'; +import { createTool } from '../../tool'; +import type { SearchOptions } from './types'; +import { defaultRegistry } from './providers/registry'; +import { buildProviderConfig, DEFAULT_SEARCH_CONFIG } from './config'; +import { + deduplicateResults, + formatForDisplay, + formatForLLM, + sortByRelevance, +} from './formatter'; +import { SEARCH_CONSTANTS } from './constants'; + +export function createSearchTool(opts: { context: Context }) { + const searchConfig = opts.context.config.search || DEFAULT_SEARCH_CONFIG; + const provider = searchConfig.provider || DEFAULT_SEARCH_CONFIG.provider!; + const providerConfig = buildProviderConfig(searchConfig); + + return createTool({ + name: 'web_search', + description: ` +Search the web for current information, documentation, and answers. + +Features: +- Search provider: ${provider} +- Support for general search, news, and code search +- AI-generated answers + +Usage: +- query: The search query (required) +- maxResults: Number of results to return (default: ${SEARCH_CONSTANTS.DEFAULT_MAX_RESULTS}, max: ${SEARCH_CONSTANTS.MAX_RESULTS_LIMIT}) +- searchType: Type of search - 'general', 'news', or 'code' (default: 'general') + +Examples: +- "latest TypeScript features" +- "React hooks best practices 2025" +- "how to fix CORS error" + `.trim(), + + parameters: z.object({ + query: z.string().describe('The search query'), + maxResults: z + .number() + .min(SEARCH_CONSTANTS.MIN_RESULTS_LIMIT) + .max(SEARCH_CONSTANTS.MAX_RESULTS_LIMIT) + .optional() + .describe( + `Number of results (default: ${SEARCH_CONSTANTS.DEFAULT_MAX_RESULTS})`, + ), + searchType: z + .enum(['general', 'news', 'code']) + .optional() + .describe('Type of search (default: general)'), + }), + + getDescription: ({ params }) => { + if (!params.query || typeof params.query !== 'string') { + return 'Search the web'; + } + return `Search: ${params.query}`; + }, + + execute: async ({ query, maxResults, searchType }) => { + const startTime = Date.now(); + + try { + const options: SearchOptions = { + query, + maxResults: maxResults || searchConfig.maxResults, + searchType: searchType || 'general', + }; + + const providerInstance = defaultRegistry.get(provider, providerConfig); + const response = await providerInstance.search(options); + + response.results = sortByRelevance( + deduplicateResults(response.results), + ); + response.searchTime = Date.now() - startTime; + + return { + llmContent: formatForLLM(response), + returnDisplay: formatForDisplay(response), + }; + } catch (error) { + const errorMessage = + error instanceof Error + ? `Search failed: ${error.message}` + : 'Unknown search error'; + + return { + isError: true, + llmContent: errorMessage, + }; + } + }, + + approval: { + category: 'network', + }, + }); +} diff --git a/src/tools/search/types.ts b/src/tools/search/types.ts new file mode 100644 index 00000000..ac29efea --- /dev/null +++ b/src/tools/search/types.ts @@ -0,0 +1,65 @@ +/** + * Search result + */ +export interface SearchResult { + title: string; + url: string; + content: string; + publishedDate?: string; + score?: number; +} + +/** + * Search options + */ +export interface SearchOptions { + query: string; + maxResults?: number; + searchType?: 'general' | 'news' | 'code'; + includeRawContent?: boolean; +} + +/** + * Search response + */ +export interface SearchResponse { + results: SearchResult[]; + query: string; + provider: string; + searchTime: number; + answer?: string; +} + +/** + * Provider configuration + */ +export interface ProviderConfig { + apiKey?: string; + timeout?: number; + maxRetries?: number; +} + +/** + * Supported search providers + */ +export type SupportedProvider = 'tavily'; + +/** + * Search configuration + */ +export interface SearchConfig { + provider?: SupportedProvider; + maxResults?: number; + timeout?: number; +} + +/** + * Provider interface + */ +export interface SearchProvider { + readonly name: string; + readonly requiresApiKey: boolean; + readonly apiKeyUrl?: string; + search(options: SearchOptions): Promise; + healthCheck(): Promise; +} diff --git a/src/tools/search/utils/retry.ts b/src/tools/search/utils/retry.ts new file mode 100644 index 00000000..02effd1b --- /dev/null +++ b/src/tools/search/utils/retry.ts @@ -0,0 +1,70 @@ +/** + * Retry utility with exponential backoff + */ +export async function withRetry( + fn: () => Promise, + options: { + maxRetries: number; + baseDelay?: number; + maxDelay?: number; + }, +): Promise { + const { maxRetries, baseDelay = 1000, maxDelay = 10000 } = options; + + let lastError: Error | undefined; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry on the last attempt + if (attempt === maxRetries - 1) { + break; + } + + // Don't retry on certain errors + if (isNonRetryableError(lastError)) { + break; + } + + // Calculate delay with exponential backoff + const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); + await sleep(delay); + } + } + + throw lastError || new Error('Retry failed with unknown error'); +} + +/** + * Check if error should not be retried + */ +function isNonRetryableError(error: Error): boolean { + const message = error.message.toLowerCase(); + + // Don't retry validation errors + if ( + message.includes('api key is required') || + message.includes('query cannot be empty') || + message.includes('query too long') || + message.includes('invalid') + ) { + return true; + } + + // Don't retry 4xx errors (except 429 rate limit) + if (message.includes('http 4') && !message.includes('429')) { + return true; + } + + return false; +} + +/** + * Sleep utility + */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +}