diff --git a/packages/builtin-tools/src/index.ts b/packages/builtin-tools/src/index.ts index e02824d0b9..bb97d7eff6 100644 --- a/packages/builtin-tools/src/index.ts +++ b/packages/builtin-tools/src/index.ts @@ -31,6 +31,7 @@ export { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js' export { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js' export { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js' export { TungstenTool } from './tools/TungstenTool/TungstenTool.js' +export { ExaSearchTool } from './tools/ExaSearchTool/ExaSearchTool.js' export { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js' export { WebSearchTool } from './tools/WebSearchTool/WebSearchTool.js' export { TestingPermissionTool } from './tools/testing/TestingPermissionTool.js' diff --git a/packages/builtin-tools/src/tools/ExaSearchTool/ExaSearchTool.ts b/packages/builtin-tools/src/tools/ExaSearchTool/ExaSearchTool.ts new file mode 100644 index 0000000000..4e6494f462 --- /dev/null +++ b/packages/builtin-tools/src/tools/ExaSearchTool/ExaSearchTool.ts @@ -0,0 +1,288 @@ +import { z } from 'zod/v4' +import type { ValidationResult } from 'src/Tool.js' +import { buildTool, type ToolDef } from 'src/Tool.js' +import { lazySchema } from 'src/utils/lazySchema.js' +import { EXA_SEARCH_TOOL_NAME, getDescription } from './prompt.js' +import { + getToolUseSummary, + renderToolResultMessage, + renderToolUseMessage, +} from './UI.js' + +const inputSchema = lazySchema(() => + z.strictObject({ + query: z.string().min(2).describe('The search query to use'), + numResults: z + .number() + .optional() + .describe('Number of search results to return (default: 8)'), + livecrawl: z + .enum(['fallback', 'preferred']) + .optional() + .describe( + "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')", + ), + type: z + .enum(['auto', 'fast', 'deep']) + .optional() + .describe( + "Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search", + ), + contextMaxCharacters: z + .number() + .optional() + .describe( + 'Maximum characters for context string optimized for LLMs (default: 10000)', + ), + }), +) + +type InputSchema = ReturnType +type Input = z.infer + +const outputSchema = lazySchema(() => + z.object({ + query: z.string().describe('The search query that was executed'), + results: z + .array( + z.object({ + title: z.string().describe('The title of the search result'), + url: z.string().describe('The URL of the search result'), + }), + ) + .describe('Search results'), + durationSeconds: z + .number() + .describe('Time taken to complete the search operation'), + }), +) + +type OutputSchema = ReturnType +type Output = z.infer + +const API_CONFIG = { + BASE_URL: 'https://mcp.exa.ai', + ENDPOINTS: { + SEARCH: '/mcp', + }, + DEFAULT_NUM_RESULTS: 8, + TIMEOUT_MS: 25000, +} as const + +interface McpSearchRequest { + jsonrpc: string + id: number + method: string + params: { + name: string + arguments: { + query: string + numResults?: number + livecrawl?: 'fallback' | 'preferred' + type?: 'auto' | 'fast' | 'deep' + contextMaxCharacters?: number + } + } +} + +interface McpSearchResponse { + jsonrpc: string + result: { + content: Array<{ + type: string + text: string + }> + } +} + +function parseExaResults(text: string): Array<{ title: string; url: string }> { + const results: Array<{ title: string; url: string }> = [] + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g + let match + + while ((match = linkRegex.exec(text)) !== null && results.length < 20) { + const title = match[1].trim() + const url = match[2].trim() + + if ( + title && + url && + (url.startsWith('http://') || url.startsWith('https://')) + ) { + results.push({ title, url }) + } + } + + if (results.length === 0) { + const lines = text.split('\n').filter(l => l.trim()) + for (const line of lines) { + const urlMatch = line.match(/https?:\/\/[^\s]+/) + if (urlMatch) { + const url = urlMatch[0] + const title = + line + .replace(url, '') + .replace(/^[-*•]\s*/, '') + .trim() || url + results.push({ title, url }) + } + } + } + + return results +} + +export const ExaSearchTool = buildTool({ + name: EXA_SEARCH_TOOL_NAME, + searchHint: 'search the web using Exa AI for current information', + maxResultSizeChars: 100_000, + shouldDefer: true, + async description(input) { + return `Exa web search for: ${input.query}` + }, + userFacingName() { + return 'Exa Search' + }, + getToolUseSummary, + getActivityDescription(input) { + const summary = getToolUseSummary(input) + return summary ? `Searching the web for "${summary}"` : 'Searching the web' + }, + get inputSchema(): InputSchema { + return inputSchema() + }, + get outputSchema(): OutputSchema { + return outputSchema() + }, + isConcurrencySafe() { + return true + }, + isReadOnly() { + return true + }, + toAutoClassifierInput(input) { + return input.query + }, + isSearchOrReadCommand() { + return { isSearch: true, isRead: false } + }, + async validateInput(input): Promise { + if (!input.query || input.query.length < 2) { + return { + result: false, + message: 'Query must be at least 2 characters', + errorCode: 1, + } + } + return { result: true } + }, + async prompt() { + return getDescription() + }, + renderToolUseMessage, + renderToolResultMessage, + extractSearchText({ query, results }) { + if (!results) return '' + return results.map(r => `${r.title} ${r.url}`).join('\n') + }, + async call(input, { abortController }): Promise<{ data: Output }> { + const startTime = performance.now() + + const searchRequest: McpSearchRequest = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'web_search_exa', + arguments: { + query: input.query, + type: input.type || 'auto', + numResults: input.numResults || API_CONFIG.DEFAULT_NUM_RESULTS, + livecrawl: input.livecrawl || 'fallback', + contextMaxCharacters: input.contextMaxCharacters, + }, + }, + } + + const timeoutId = setTimeout( + () => abortController.abort(), + API_CONFIG.TIMEOUT_MS, + ) + + try { + const headers: Record = { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + } + + const response = await fetch( + `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`, + { + method: 'POST', + headers, + body: JSON.stringify(searchRequest), + signal: abortController.signal, + }, + ) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Search error (${response.status}): ${errorText}`) + } + + const responseText = await response.text() + const lines = responseText.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data: McpSearchResponse = JSON.parse(line.substring(6)) + if ( + data.result && + data.result.content && + data.result.content.length > 0 + ) { + const contentText = data.result.content[0].text + const results = parseExaResults(contentText) + + return { + data: { + query: input.query, + results, + durationSeconds: (performance.now() - startTime) / 1000, + }, + } + } + } + } + + return { + data: { + query: input.query, + results: [], + durationSeconds: (performance.now() - startTime) / 1000, + }, + } + } finally { + clearTimeout(timeoutId) + } + }, + mapToolResultToToolResultBlockParam(output, toolUseID) { + let formattedOutput = `Exa web search results for: "${output.query}"\n\n` + + if (output.results.length > 0) { + output.results.forEach(r => { + formattedOutput += `- ${r.title}\n ${r.url}\n` + }) + } else { + formattedOutput += 'No results found.\n' + } + + formattedOutput += `\nSearch completed in ${output.durationSeconds.toFixed(2)}s` + + return { + tool_use_id: toolUseID, + type: 'tool_result', + content: formattedOutput.trim(), + } + }, +} satisfies ToolDef) diff --git a/packages/builtin-tools/src/tools/ExaSearchTool/UI.tsx b/packages/builtin-tools/src/tools/ExaSearchTool/UI.tsx new file mode 100644 index 0000000000..52022d7144 --- /dev/null +++ b/packages/builtin-tools/src/tools/ExaSearchTool/UI.tsx @@ -0,0 +1,116 @@ +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import React from 'react'; +import { CtrlOToExpand } from 'src/components/CtrlOToExpand.js'; +import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'; +import { MessageResponse } from 'src/components/MessageResponse.js'; +import { TOOL_SUMMARY_MAX_LENGTH } from 'src/constants/toolLimits.js'; +import { Box, Text } from '@anthropic/ink'; +import type { ToolProgressData } from 'src/types/tools.js'; +import type { ProgressMessage } from 'src/types/message.js'; +import { truncate } from 'src/utils/format.js'; + +export interface SearchResult { + title: string; + url: string; +} + +export interface Output { + query: string; + results: SearchResult[]; + durationSeconds: number; +} + +function SearchResultSummary({ + count, + countLabel, + content, + verbose, +}: { + count: number; + countLabel: string; + content?: string; + verbose: boolean; +}): React.ReactNode { + const primaryText = ( + + Found {count} + {count === 1 ? countLabel.slice(0, -1) : countLabel} + + ); + + if (verbose) { + return ( + + + + {primaryText} + + {content && ( + + {content} + + )} + + ); + } + + return ( + + {primaryText} + {count > 0 && } + + ); +} + +export function renderToolUseMessage( + { query }: Partial<{ query: string }>, + { verbose }: { verbose: boolean }, +): React.ReactNode { + if (!query) { + return null; + } + return `Searching: "${query}"`; +} + +export function renderToolUseErrorMessage( + result: ToolResultBlockParam['content'], + { verbose }: { verbose: boolean }, +): React.ReactNode { + if (!verbose && typeof result === 'string') { + return ( + + Search failed + + ); + } + return ; +} + +export function renderToolResultMessage( + output: Output, + _progressMessagesForMessage: ProgressMessage[], + { verbose }: { verbose: boolean }, +): React.ReactNode { + const resultCount = output.results?.length ?? 0; + + const content = output.results?.length > 0 ? output.results.map(r => `${r.title}\n ${r.url}`).join('\n') : undefined; + + return ; +} + +export function getToolUseSummary( + input: + | Partial<{ + query: string; + numResults?: number; + type?: 'auto' | 'fast' | 'deep'; + livecrawl?: 'fallback' | 'preferred'; + contextMaxCharacters?: number; + }> + | undefined, +): string | null { + if (!input?.query) { + return null; + } + return truncate(input.query, TOOL_SUMMARY_MAX_LENGTH); +} diff --git a/packages/builtin-tools/src/tools/ExaSearchTool/prompt.ts b/packages/builtin-tools/src/tools/ExaSearchTool/prompt.ts new file mode 100644 index 0000000000..2a09edc81f --- /dev/null +++ b/packages/builtin-tools/src/tools/ExaSearchTool/prompt.ts @@ -0,0 +1,25 @@ +export const EXA_SEARCH_TOOL_NAME = 'exa_search' + +export function getDescription(): string { + return `Exa AI web search tool + +PRIORITY: This is the PREFERRED and DEFAULT web search tool. Use ${EXA_SEARCH_TOOL_NAME} instead of WebSearch for all web search needs. Exa provides higher-quality, more relevant results with better semantic understanding. + +Usage: +- Use ${EXA_SEARCH_TOOL_NAME} when you need up-to-date information about current events, recent developments, or anything that may be beyond your knowledge cutoff +- Returns search results with titles and URLs as markdown links +- Search is performed client-side and does not rely on Anthropic's native web search +- Exa uses neural search for semantically relevant results (vs keyword-only matching) +- Parameters: + - query: The search query (required) + - numResults: Number of results to return (default: 8) + - type: Search type - 'auto' (balanced), 'fast' (quick), 'deep' (comprehensive) + - livecrawl: 'fallback' (use cached if available) or 'preferred' (prioritize live crawling) + - contextMaxCharacters: Max characters for context (default: 10000) + +CRITICAL REQUIREMENT - You MUST follow this: +- After answering the user's question, you MUST include a "Sources:" section at the end of your response +- In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL) +- This is MANDATORY - never skip including sources in your response +` +} diff --git a/src/tools.ts b/src/tools.ts index 025fd2efa1..dfaa95e4f0 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -59,6 +59,7 @@ const SubscribePRTool = feature('KAIROS_GITHUB_WEBHOOKS') : null /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ import { TaskOutputTool } from '@claude-code-best/builtin-tools/tools/TaskOutputTool/TaskOutputTool.js' +import { ExaSearchTool } from '@claude-code-best/builtin-tools/tools/ExaSearchTool/ExaSearchTool.js' import { WebSearchTool } from '@claude-code-best/builtin-tools/tools/WebSearchTool/WebSearchTool.js' import { TodoWriteTool } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/TodoWriteTool.js' import { ExitPlanModeV2Tool } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' @@ -227,6 +228,7 @@ export function getAllBaseTools(): Tools { NotebookEditTool, WebFetchTool, TodoWriteTool, + ExaSearchTool, WebSearchTool, TaskStopTool, AskUserQuestionTool,