Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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()]
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';
Loading
Loading