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