source dump of claude code
23
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 471 lines 14 kB view raw
1import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 2import memoize from 'lodash-es/memoize.js' 3import { z } from 'zod/v4' 4import { 5 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 6 logEvent, 7} from '../../services/analytics/index.js' 8import { 9 buildTool, 10 findToolByName, 11 type Tool, 12 type ToolDef, 13 type Tools, 14} from '../../Tool.js' 15import { logForDebugging } from '../../utils/debug.js' 16import { lazySchema } from '../../utils/lazySchema.js' 17import { escapeRegExp } from '../../utils/stringUtils.js' 18import { isToolSearchEnabledOptimistic } from '../../utils/toolSearch.js' 19import { getPrompt, isDeferredTool, TOOL_SEARCH_TOOL_NAME } from './prompt.js' 20 21export const inputSchema = lazySchema(() => 22 z.object({ 23 query: z 24 .string() 25 .describe( 26 'Query to find deferred tools. Use "select:<tool_name>" for direct selection, or keywords to search.', 27 ), 28 max_results: z 29 .number() 30 .optional() 31 .default(5) 32 .describe('Maximum number of results to return (default: 5)'), 33 }), 34) 35type InputSchema = ReturnType<typeof inputSchema> 36 37export const outputSchema = lazySchema(() => 38 z.object({ 39 matches: z.array(z.string()), 40 query: z.string(), 41 total_deferred_tools: z.number(), 42 pending_mcp_servers: z.array(z.string()).optional(), 43 }), 44) 45type OutputSchema = ReturnType<typeof outputSchema> 46 47export type Output = z.infer<OutputSchema> 48 49// Track deferred tool names to detect when cache should be cleared 50let cachedDeferredToolNames: string | null = null 51 52/** 53 * Get a cache key representing the current set of deferred tools. 54 */ 55function getDeferredToolsCacheKey(deferredTools: Tools): string { 56 return deferredTools 57 .map(t => t.name) 58 .sort() 59 .join(',') 60} 61 62/** 63 * Get tool description, memoized by tool name. 64 * Used for keyword search scoring. 65 */ 66const getToolDescriptionMemoized = memoize( 67 async (toolName: string, tools: Tools): Promise<string> => { 68 const tool = findToolByName(tools, toolName) 69 if (!tool) { 70 return '' 71 } 72 return tool.prompt({ 73 getToolPermissionContext: async () => ({ 74 mode: 'default' as const, 75 additionalWorkingDirectories: new Map(), 76 alwaysAllowRules: {}, 77 alwaysDenyRules: {}, 78 alwaysAskRules: {}, 79 isBypassPermissionsModeAvailable: false, 80 }), 81 tools, 82 agents: [], 83 }) 84 }, 85 (toolName: string) => toolName, 86) 87 88/** 89 * Invalidate the description cache if deferred tools have changed. 90 */ 91function maybeInvalidateCache(deferredTools: Tools): void { 92 const currentKey = getDeferredToolsCacheKey(deferredTools) 93 if (cachedDeferredToolNames !== currentKey) { 94 logForDebugging( 95 `ToolSearchTool: cache invalidated - deferred tools changed`, 96 ) 97 getToolDescriptionMemoized.cache.clear?.() 98 cachedDeferredToolNames = currentKey 99 } 100} 101 102export function clearToolSearchDescriptionCache(): void { 103 getToolDescriptionMemoized.cache.clear?.() 104 cachedDeferredToolNames = null 105} 106 107/** 108 * Build the search result output structure. 109 */ 110function buildSearchResult( 111 matches: string[], 112 query: string, 113 totalDeferredTools: number, 114 pendingMcpServers?: string[], 115): { data: Output } { 116 return { 117 data: { 118 matches, 119 query, 120 total_deferred_tools: totalDeferredTools, 121 ...(pendingMcpServers && pendingMcpServers.length > 0 122 ? { pending_mcp_servers: pendingMcpServers } 123 : {}), 124 }, 125 } 126} 127 128/** 129 * Parse tool name into searchable parts. 130 * Handles both MCP tools (mcp__server__action) and regular tools (CamelCase). 131 */ 132function parseToolName(name: string): { 133 parts: string[] 134 full: string 135 isMcp: boolean 136} { 137 // Check if it's an MCP tool 138 if (name.startsWith('mcp__')) { 139 const withoutPrefix = name.replace(/^mcp__/, '').toLowerCase() 140 const parts = withoutPrefix.split('__').flatMap(p => p.split('_')) 141 return { 142 parts: parts.filter(Boolean), 143 full: withoutPrefix.replace(/__/g, ' ').replace(/_/g, ' '), 144 isMcp: true, 145 } 146 } 147 148 // Regular tool - split by CamelCase and underscores 149 const parts = name 150 .replace(/([a-z])([A-Z])/g, '$1 $2') // CamelCase to spaces 151 .replace(/_/g, ' ') 152 .toLowerCase() 153 .split(/\s+/) 154 .filter(Boolean) 155 156 return { 157 parts, 158 full: parts.join(' '), 159 isMcp: false, 160 } 161} 162 163/** 164 * Pre-compile word-boundary regexes for all search terms. 165 * Called once per search instead of tools×terms×2 times. 166 */ 167function compileTermPatterns(terms: string[]): Map<string, RegExp> { 168 const patterns = new Map<string, RegExp>() 169 for (const term of terms) { 170 if (!patterns.has(term)) { 171 patterns.set(term, new RegExp(`\\b${escapeRegExp(term)}\\b`)) 172 } 173 } 174 return patterns 175} 176 177/** 178 * Keyword-based search over tool names and descriptions. 179 * Handles both MCP tools (mcp__server__action) and regular tools (CamelCase). 180 * 181 * The model typically queries with: 182 * - Server names when it knows the integration (e.g., "slack", "github") 183 * - Action words when looking for functionality (e.g., "read", "list", "create") 184 * - Tool-specific terms (e.g., "notebook", "shell", "kill") 185 */ 186async function searchToolsWithKeywords( 187 query: string, 188 deferredTools: Tools, 189 tools: Tools, 190 maxResults: number, 191): Promise<string[]> { 192 const queryLower = query.toLowerCase().trim() 193 194 // Fast path: if query matches a tool name exactly, return it directly. 195 // Handles models using a bare tool name instead of select: prefix (seen 196 // from subagents/post-compaction). Checks deferred first, then falls back 197 // to the full tool set — selecting an already-loaded tool is a harmless 198 // no-op that lets the model proceed without retry churn. 199 const exactMatch = 200 deferredTools.find(t => t.name.toLowerCase() === queryLower) ?? 201 tools.find(t => t.name.toLowerCase() === queryLower) 202 if (exactMatch) { 203 return [exactMatch.name] 204 } 205 206 // If query looks like an MCP tool prefix (mcp__server), find matching tools. 207 // Handles models searching by server name with mcp__ prefix. 208 if (queryLower.startsWith('mcp__') && queryLower.length > 5) { 209 const prefixMatches = deferredTools 210 .filter(t => t.name.toLowerCase().startsWith(queryLower)) 211 .slice(0, maxResults) 212 .map(t => t.name) 213 if (prefixMatches.length > 0) { 214 return prefixMatches 215 } 216 } 217 218 const queryTerms = queryLower.split(/\s+/).filter(term => term.length > 0) 219 220 // Partition into required (+prefixed) and optional terms 221 const requiredTerms: string[] = [] 222 const optionalTerms: string[] = [] 223 for (const term of queryTerms) { 224 if (term.startsWith('+') && term.length > 1) { 225 requiredTerms.push(term.slice(1)) 226 } else { 227 optionalTerms.push(term) 228 } 229 } 230 231 const allScoringTerms = 232 requiredTerms.length > 0 ? [...requiredTerms, ...optionalTerms] : queryTerms 233 const termPatterns = compileTermPatterns(allScoringTerms) 234 235 // Pre-filter to tools matching ALL required terms in name or description 236 let candidateTools = deferredTools 237 if (requiredTerms.length > 0) { 238 const matches = await Promise.all( 239 deferredTools.map(async tool => { 240 const parsed = parseToolName(tool.name) 241 const description = await getToolDescriptionMemoized(tool.name, tools) 242 const descNormalized = description.toLowerCase() 243 const hintNormalized = tool.searchHint?.toLowerCase() ?? '' 244 const matchesAll = requiredTerms.every(term => { 245 const pattern = termPatterns.get(term)! 246 return ( 247 parsed.parts.includes(term) || 248 parsed.parts.some(part => part.includes(term)) || 249 pattern.test(descNormalized) || 250 (hintNormalized && pattern.test(hintNormalized)) 251 ) 252 }) 253 return matchesAll ? tool : null 254 }), 255 ) 256 candidateTools = matches.filter((t): t is Tool => t !== null) 257 } 258 259 const scored = await Promise.all( 260 candidateTools.map(async tool => { 261 const parsed = parseToolName(tool.name) 262 const description = await getToolDescriptionMemoized(tool.name, tools) 263 const descNormalized = description.toLowerCase() 264 const hintNormalized = tool.searchHint?.toLowerCase() ?? '' 265 266 let score = 0 267 for (const term of allScoringTerms) { 268 const pattern = termPatterns.get(term)! 269 270 // Exact part match (high weight for MCP server names, tool name parts) 271 if (parsed.parts.includes(term)) { 272 score += parsed.isMcp ? 12 : 10 273 } else if (parsed.parts.some(part => part.includes(term))) { 274 score += parsed.isMcp ? 6 : 5 275 } 276 277 // Full name fallback (for edge cases) 278 if (parsed.full.includes(term) && score === 0) { 279 score += 3 280 } 281 282 // searchHint match — curated capability phrase, higher signal than prompt 283 if (hintNormalized && pattern.test(hintNormalized)) { 284 score += 4 285 } 286 287 // Description match - use word boundary to avoid false positives 288 if (pattern.test(descNormalized)) { 289 score += 2 290 } 291 } 292 293 return { name: tool.name, score } 294 }), 295 ) 296 297 return scored 298 .filter(item => item.score > 0) 299 .sort((a, b) => b.score - a.score) 300 .slice(0, maxResults) 301 .map(item => item.name) 302} 303 304export const ToolSearchTool = buildTool({ 305 isEnabled() { 306 return isToolSearchEnabledOptimistic() 307 }, 308 isConcurrencySafe() { 309 return true 310 }, 311 isReadOnly() { 312 return true 313 }, 314 name: TOOL_SEARCH_TOOL_NAME, 315 maxResultSizeChars: 100_000, 316 async description() { 317 return getPrompt() 318 }, 319 async prompt() { 320 return getPrompt() 321 }, 322 get inputSchema(): InputSchema { 323 return inputSchema() 324 }, 325 get outputSchema(): OutputSchema { 326 return outputSchema() 327 }, 328 async call(input, { options: { tools }, getAppState }) { 329 const { query, max_results = 5 } = input 330 331 const deferredTools = tools.filter(isDeferredTool) 332 maybeInvalidateCache(deferredTools) 333 334 // Check for MCP servers still connecting 335 function getPendingServerNames(): string[] | undefined { 336 const appState = getAppState() 337 const pending = appState.mcp.clients.filter(c => c.type === 'pending') 338 return pending.length > 0 ? pending.map(s => s.name) : undefined 339 } 340 341 // Helper to log search outcome 342 function logSearchOutcome( 343 matches: string[], 344 queryType: 'select' | 'keyword', 345 ): void { 346 logEvent('tengu_tool_search_outcome', { 347 query: 348 query as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 349 queryType: 350 queryType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 351 matchCount: matches.length, 352 totalDeferredTools: deferredTools.length, 353 maxResults: max_results, 354 hasMatches: matches.length > 0, 355 }) 356 } 357 358 // Check for select: prefix — direct tool selection. 359 // Supports comma-separated multi-select: `select:A,B,C`. 360 // If a name isn't in the deferred set but IS in the full tool set, 361 // we still return it — the tool is already loaded, so "selecting" it 362 // is a harmless no-op that lets the model proceed without retry churn. 363 const selectMatch = query.match(/^select:(.+)$/i) 364 if (selectMatch) { 365 const requested = selectMatch[1]! 366 .split(',') 367 .map(s => s.trim()) 368 .filter(Boolean) 369 370 const found: string[] = [] 371 const missing: string[] = [] 372 for (const toolName of requested) { 373 const tool = 374 findToolByName(deferredTools, toolName) ?? 375 findToolByName(tools, toolName) 376 if (tool) { 377 if (!found.includes(tool.name)) found.push(tool.name) 378 } else { 379 missing.push(toolName) 380 } 381 } 382 383 if (found.length === 0) { 384 logForDebugging( 385 `ToolSearchTool: select failed — none found: ${missing.join(', ')}`, 386 ) 387 logSearchOutcome([], 'select') 388 const pendingServers = getPendingServerNames() 389 return buildSearchResult( 390 [], 391 query, 392 deferredTools.length, 393 pendingServers, 394 ) 395 } 396 397 if (missing.length > 0) { 398 logForDebugging( 399 `ToolSearchTool: partial select — found: ${found.join(', ')}, missing: ${missing.join(', ')}`, 400 ) 401 } else { 402 logForDebugging(`ToolSearchTool: selected ${found.join(', ')}`) 403 } 404 logSearchOutcome(found, 'select') 405 return buildSearchResult(found, query, deferredTools.length) 406 } 407 408 // Keyword search 409 const matches = await searchToolsWithKeywords( 410 query, 411 deferredTools, 412 tools, 413 max_results, 414 ) 415 416 logForDebugging( 417 `ToolSearchTool: keyword search for "${query}", found ${matches.length} matches`, 418 ) 419 420 logSearchOutcome(matches, 'keyword') 421 422 // Include pending server info when search finds no matches 423 if (matches.length === 0) { 424 const pendingServers = getPendingServerNames() 425 return buildSearchResult( 426 matches, 427 query, 428 deferredTools.length, 429 pendingServers, 430 ) 431 } 432 433 return buildSearchResult(matches, query, deferredTools.length) 434 }, 435 renderToolUseMessage() { 436 return null 437 }, 438 userFacingName: () => '', 439 /** 440 * Returns a tool_result with tool_reference blocks. 441 * This format works on 1P/Foundry. Bedrock/Vertex may not support 442 * client-side tool_reference expansion yet. 443 */ 444 mapToolResultToToolResultBlockParam( 445 content: Output, 446 toolUseID: string, 447 ): ToolResultBlockParam { 448 if (content.matches.length === 0) { 449 let text = 'No matching deferred tools found' 450 if ( 451 content.pending_mcp_servers && 452 content.pending_mcp_servers.length > 0 453 ) { 454 text += `. Some MCP servers are still connecting: ${content.pending_mcp_servers.join(', ')}. Their tools will become available shortly — try searching again.` 455 } 456 return { 457 type: 'tool_result', 458 tool_use_id: toolUseID, 459 content: text, 460 } 461 } 462 return { 463 type: 'tool_result', 464 tool_use_id: toolUseID, 465 content: content.matches.map(name => ({ 466 type: 'tool_reference' as const, 467 tool_name: name, 468 })), 469 } as unknown as ToolResultBlockParam 470 }, 471} satisfies ToolDef<InputSchema, Output>)