Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export type CommitConfig = {

export type ProviderConfig = Partial<Omit<Provider, 'createModel'>>;

export type SearchConfig = {
provider?: 'tavily';
maxResults?: number;
timeout?: number;
};

export type DesktopConfig = {
theme?: 'light' | 'dark' | 'system';
sendMessageWith?: 'enter' | 'cmdEnter';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -121,6 +131,7 @@ const VALID_CONFIG_KEYS = [
'browser',
'temperature',
'httpProxy',
'search',
'extensions',
'tools',
];
Expand All @@ -129,6 +140,7 @@ const OBJECT_CONFIG_KEYS = [
'mcpServers',
'commit',
'provider',
'search',
'extensions',
'tools',
'desktop',
Expand Down
2 changes: 2 additions & 0 deletions src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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 { createTodoTool, type TodoItem } from './tools/todo';
import { createWriteTool } from './tools/write';

Expand All @@ -39,6 +40,7 @@ export async function resolveTools(opts: ResolveToolsOpts) {
createGlobTool({ cwd }),
createGrepTool({ cwd }),
createFetchTool({ model }),
createSearchTool({ context: opts.context }),
];
const askUserQuestionTools = opts.askUserQuestion
? [createAskUserQuestionTool()]
Expand Down
70 changes: 70 additions & 0 deletions src/tools/search/config.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
20 changes: 20 additions & 0 deletions src/tools/search/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
74 changes: 74 additions & 0 deletions src/tools/search/formatter.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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));
}
19 changes: 19 additions & 0 deletions src/tools/search/index.ts
Original file line number Diff line number Diff line change
@@ -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';
151 changes: 151 additions & 0 deletions src/tools/search/providers/base.ts
Original file line number Diff line number Diff line change
@@ -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<SearchResponse> {
// 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<SearchResponse>;

/**
* Health check
*/
async healthCheck(): Promise<boolean> {
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<Response> {
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);
}
}
}
Loading
Loading