@librechat/agents 3.0.65 → 3.0.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +12 -10
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +1 -1
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs +22 -7
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +8 -7
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/messages/tools.cjs +2 -2
  10. package/dist/cjs/messages/tools.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +19 -4
  12. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  13. package/dist/cjs/tools/{ToolSearchRegex.cjs → ToolSearch.cjs} +154 -66
  14. package/dist/cjs/tools/ToolSearch.cjs.map +1 -0
  15. package/dist/esm/agents/AgentContext.mjs +12 -10
  16. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  17. package/dist/esm/common/enum.mjs +1 -1
  18. package/dist/esm/common/enum.mjs.map +1 -1
  19. package/dist/esm/graphs/MultiAgentGraph.mjs +22 -7
  20. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  21. package/dist/esm/main.mjs +1 -1
  22. package/dist/esm/messages/tools.mjs +2 -2
  23. package/dist/esm/messages/tools.mjs.map +1 -1
  24. package/dist/esm/tools/ToolNode.mjs +19 -4
  25. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  26. package/dist/esm/tools/{ToolSearchRegex.mjs → ToolSearch.mjs} +153 -66
  27. package/dist/esm/tools/ToolSearch.mjs.map +1 -0
  28. package/dist/types/agents/AgentContext.d.ts +7 -3
  29. package/dist/types/common/enum.d.ts +1 -1
  30. package/dist/types/graphs/MultiAgentGraph.d.ts +3 -3
  31. package/dist/types/index.d.ts +1 -1
  32. package/dist/types/tools/{ToolSearchRegex.d.ts → ToolSearch.d.ts} +33 -23
  33. package/dist/types/types/tools.d.ts +5 -1
  34. package/package.json +2 -2
  35. package/src/agents/AgentContext.ts +20 -12
  36. package/src/common/enum.ts +1 -1
  37. package/src/graphs/MultiAgentGraph.ts +29 -8
  38. package/src/index.ts +1 -1
  39. package/src/messages/__tests__/tools.test.ts +21 -21
  40. package/src/messages/tools.ts +2 -2
  41. package/src/scripts/programmatic_exec_agent.ts +4 -4
  42. package/src/scripts/{tool_search_regex.ts → tool_search.ts} +5 -5
  43. package/src/tools/ToolNode.ts +23 -5
  44. package/src/tools/{ToolSearchRegex.ts → ToolSearch.ts} +195 -74
  45. package/src/tools/__tests__/{ToolSearchRegex.integration.test.ts → ToolSearch.integration.test.ts} +6 -6
  46. package/src/tools/__tests__/{ToolSearchRegex.test.ts → ToolSearch.test.ts} +212 -3
  47. package/src/types/tools.ts +6 -1
  48. package/dist/cjs/tools/ToolSearchRegex.cjs.map +0 -1
  49. package/dist/esm/tools/ToolSearchRegex.mjs.map +0 -1
@@ -1,4 +1,4 @@
1
- // src/tools/ToolSearchRegex.ts
1
+ // src/tools/ToolSearch.ts
2
2
  import { z } from 'zod';
3
3
  import { config } from 'dotenv';
4
4
  import fetch, { RequestInit } from 'node-fetch';
@@ -20,28 +20,43 @@ const MAX_REGEX_COMPLEXITY = 5;
20
20
  /** Default search timeout in milliseconds */
21
21
  const SEARCH_TIMEOUT = 5000;
22
22
 
23
- const ToolSearchRegexSchema = z.object({
24
- query: z
25
- .string()
26
- .min(1)
27
- .max(MAX_PATTERN_LENGTH)
28
- .describe(
29
- 'Regex pattern to search tool names and descriptions. Special regex characters will be sanitized for safety.'
30
- ),
31
- fields: z
32
- .array(z.enum(['name', 'description', 'parameters']))
33
- .optional()
34
- .default(['name', 'description'])
35
- .describe('Which fields to search. Default: name and description'),
36
- max_results: z
37
- .number()
38
- .int()
39
- .min(1)
40
- .max(50)
41
- .optional()
42
- .default(10)
43
- .describe('Maximum number of matching tools to return'),
44
- });
23
+ /** Zod schema type for tool search parameters */
24
+ type ToolSearchSchema = z.ZodObject<{
25
+ query: z.ZodString;
26
+ fields: z.ZodDefault<
27
+ z.ZodOptional<z.ZodArray<z.ZodEnum<['name', 'description', 'parameters']>>>
28
+ >;
29
+ max_results: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
30
+ }>;
31
+
32
+ /**
33
+ * Creates the Zod schema with dynamic query description based on mode.
34
+ * @param mode - The search mode determining query interpretation
35
+ * @returns Zod schema for tool search parameters
36
+ */
37
+ function createToolSearchSchema(mode: t.ToolSearchMode): ToolSearchSchema {
38
+ const queryDescription =
39
+ mode === 'local'
40
+ ? 'Search term to find in tool names and descriptions. Case-insensitive substring matching.'
41
+ : 'Regex pattern to search tool names and descriptions. Special regex characters will be sanitized for safety.';
42
+
43
+ return z.object({
44
+ query: z.string().min(1).max(MAX_PATTERN_LENGTH).describe(queryDescription),
45
+ fields: z
46
+ .array(z.enum(['name', 'description', 'parameters']))
47
+ .optional()
48
+ .default(['name', 'description'])
49
+ .describe('Which fields to search. Default: name and description'),
50
+ max_results: z
51
+ .number()
52
+ .int()
53
+ .min(1)
54
+ .max(50)
55
+ .optional()
56
+ .default(10)
57
+ .describe('Maximum number of matching tools to return'),
58
+ });
59
+ }
45
60
 
46
61
  /**
47
62
  * Escapes special regex characters in a string to use as a literal pattern.
@@ -170,6 +185,80 @@ function simplifyParametersForSearch(
170
185
  return { type: parameters.type };
171
186
  }
172
187
 
188
+ /**
189
+ * Performs safe local substring search without regex.
190
+ * Uses case-insensitive String.includes() for complete safety against ReDoS.
191
+ * @param tools - Array of tool metadata to search
192
+ * @param query - The search term (treated as literal substring)
193
+ * @param fields - Which fields to search
194
+ * @param maxResults - Maximum results to return
195
+ * @returns Search response with matching tools
196
+ */
197
+ function performLocalSearch(
198
+ tools: t.ToolMetadata[],
199
+ query: string,
200
+ fields: string[],
201
+ maxResults: number
202
+ ): t.ToolSearchResponse {
203
+ const lowerQuery = query.toLowerCase();
204
+ const results: t.ToolSearchResult[] = [];
205
+
206
+ for (const tool of tools) {
207
+ let bestScore = 0;
208
+ let matchedField = '';
209
+ let snippet = '';
210
+
211
+ if (fields.includes('name')) {
212
+ const lowerName = tool.name.toLowerCase();
213
+ if (lowerName.includes(lowerQuery)) {
214
+ const isExactMatch = lowerName === lowerQuery;
215
+ const startsWithQuery = lowerName.startsWith(lowerQuery);
216
+ bestScore = isExactMatch ? 1.0 : startsWithQuery ? 0.95 : 0.85;
217
+ matchedField = 'name';
218
+ snippet = tool.name;
219
+ }
220
+ }
221
+
222
+ if (fields.includes('description') && tool.description) {
223
+ const lowerDesc = tool.description.toLowerCase();
224
+ if (lowerDesc.includes(lowerQuery) && bestScore === 0) {
225
+ bestScore = 0.7;
226
+ matchedField = 'description';
227
+ snippet = tool.description.substring(0, 100);
228
+ }
229
+ }
230
+
231
+ if (fields.includes('parameters') && tool.parameters?.properties) {
232
+ const paramNames = Object.keys(tool.parameters.properties)
233
+ .join(' ')
234
+ .toLowerCase();
235
+ if (paramNames.includes(lowerQuery) && bestScore === 0) {
236
+ bestScore = 0.55;
237
+ matchedField = 'parameters';
238
+ snippet = Object.keys(tool.parameters.properties).join(' ');
239
+ }
240
+ }
241
+
242
+ if (bestScore > 0) {
243
+ results.push({
244
+ tool_name: tool.name,
245
+ match_score: bestScore,
246
+ matched_field: matchedField,
247
+ snippet,
248
+ });
249
+ }
250
+ }
251
+
252
+ results.sort((a, b) => b.match_score - a.match_score);
253
+ const topResults = results.slice(0, maxResults);
254
+
255
+ return {
256
+ tool_references: topResults,
257
+ total_tools_searched: tools.length,
258
+ pattern_used: query,
259
+ };
260
+ }
261
+
173
262
  /**
174
263
  * Generates the JavaScript search script to be executed in the sandbox.
175
264
  * Uses plain JavaScript for maximum compatibility with the Code API.
@@ -307,11 +396,15 @@ function formatSearchResults(searchResponse: t.ToolSearchResponse): string {
307
396
  }
308
397
 
309
398
  /**
310
- * Creates a Tool Search Regex tool for discovering tools from a large registry.
399
+ * Creates a Tool Search tool for discovering tools from a large registry.
311
400
  *
312
401
  * This tool enables AI agents to dynamically discover tools from a large library
313
402
  * without loading all tool definitions into the LLM context window. The agent
314
- * can search for relevant tools on-demand using regex patterns.
403
+ * can search for relevant tools on-demand.
404
+ *
405
+ * **Modes:**
406
+ * - `code_interpreter` (default): Uses external sandbox for regex search. Safer for complex patterns.
407
+ * - `local`: Uses safe substring matching locally. No network call, faster, completely safe from ReDoS.
315
408
  *
316
409
  * The tool registry can be provided either:
317
410
  * 1. At initialization time via params.toolRegistry
@@ -321,36 +414,52 @@ function formatSearchResults(searchResponse: t.ToolSearchResponse): string {
321
414
  * @returns A LangChain DynamicStructuredTool for tool searching
322
415
  *
323
416
  * @example
324
- * // Option 1: Registry at initialization
325
- * const tool = createToolSearchRegexTool({ apiKey, toolRegistry });
326
- * await tool.invoke({ query: 'expense' });
417
+ * // Option 1: Code interpreter mode (regex via sandbox)
418
+ * const tool = createToolSearch({ apiKey, toolRegistry });
419
+ * await tool.invoke({ query: 'expense.*report' });
327
420
  *
328
421
  * @example
329
- * // Option 2: Registry at runtime
330
- * const tool = createToolSearchRegexTool({ apiKey });
331
- * await tool.invoke(
332
- * { query: 'expense' },
333
- * { configurable: { toolRegistry, onlyDeferred: true } }
334
- * );
422
+ * // Option 2: Local mode (safe substring search, no API key needed)
423
+ * const tool = createToolSearch({ mode: 'local', toolRegistry });
424
+ * await tool.invoke({ query: 'expense' });
335
425
  */
336
- function createToolSearchRegexTool(
337
- initParams: t.ToolSearchRegexParams = {}
338
- ): DynamicStructuredTool<typeof ToolSearchRegexSchema> {
339
- const apiKey: string =
340
- (initParams[EnvVar.CODE_API_KEY] as string | undefined) ??
341
- initParams.apiKey ??
342
- getEnvironmentVariable(EnvVar.CODE_API_KEY) ??
343
- '';
426
+ function createToolSearch(
427
+ initParams: t.ToolSearchParams = {}
428
+ ): DynamicStructuredTool<ReturnType<typeof createToolSearchSchema>> {
429
+ const mode: t.ToolSearchMode = initParams.mode ?? 'code_interpreter';
430
+ const defaultOnlyDeferred = initParams.onlyDeferred ?? true;
431
+ const schema = createToolSearchSchema(mode);
344
432
 
345
- if (!apiKey) {
346
- throw new Error('No API key provided for tool search regex tool.');
433
+ const apiKey: string =
434
+ mode === 'code_interpreter'
435
+ ? ((initParams[EnvVar.CODE_API_KEY] as string | undefined) ??
436
+ initParams.apiKey ??
437
+ getEnvironmentVariable(EnvVar.CODE_API_KEY) ??
438
+ '')
439
+ : '';
440
+
441
+ if (mode === 'code_interpreter' && !apiKey) {
442
+ throw new Error(
443
+ 'No API key provided for tool search in code_interpreter mode. Use mode: "local" to search without an API key.'
444
+ );
347
445
  }
348
446
 
349
447
  const baseEndpoint = initParams.baseUrl ?? getCodeBaseURL();
350
448
  const EXEC_ENDPOINT = `${baseEndpoint}/exec`;
351
- const defaultOnlyDeferred = initParams.onlyDeferred ?? true;
352
449
 
353
- const description = `
450
+ const description =
451
+ mode === 'local'
452
+ ? `
453
+ Searches through available tools to find ones matching your search term.
454
+
455
+ Usage:
456
+ - Provide a search term to find in tool names and descriptions.
457
+ - Uses case-insensitive substring matching (fast and safe).
458
+ - Use this when you need to discover tools for a specific task.
459
+ - Results include tool names, match quality scores, and snippets showing where the match occurred.
460
+ - Higher scores (0.95+) indicate name matches, medium scores (0.70+) indicate description matches.
461
+ `.trim()
462
+ : `
354
463
  Searches through available tools to find ones matching your query pattern.
355
464
 
356
465
  Usage:
@@ -360,7 +469,7 @@ Usage:
360
469
  - Higher scores (0.9+) indicate name matches, medium scores (0.7+) indicate description matches.
361
470
  `.trim();
362
471
 
363
- return tool<typeof ToolSearchRegexSchema>(
472
+ return tool<typeof schema>(
364
473
  async (params, config) => {
365
474
  const {
366
475
  query,
@@ -368,21 +477,11 @@ Usage:
368
477
  max_results = 10,
369
478
  } = params;
370
479
 
371
- // Extra params injected by ToolNode (follows web_search pattern)
372
480
  const {
373
481
  toolRegistry: paramToolRegistry,
374
482
  onlyDeferred: paramOnlyDeferred,
375
483
  } = config.toolCall ?? {};
376
484
 
377
- const { safe: sanitizedPattern, wasEscaped } = sanitizeRegex(query);
378
-
379
- let warningMessage = '';
380
- if (wasEscaped) {
381
- warningMessage =
382
- 'Note: The provided pattern was converted to a literal search for safety.\n\n';
383
- }
384
-
385
- // Priority: ToolNode injection (via config.toolCall) > initialization params
386
485
  const toolRegistry = paramToolRegistry ?? initParams.toolRegistry;
387
486
  const onlyDeferred =
388
487
  paramOnlyDeferred !== undefined
@@ -391,12 +490,12 @@ Usage:
391
490
 
392
491
  if (toolRegistry == null) {
393
492
  return [
394
- `${warningMessage}Error: No tool registry provided. Configure toolRegistry at agent level or initialization.`,
493
+ 'Error: No tool registry provided. Configure toolRegistry at agent level or initialization.',
395
494
  {
396
495
  tool_references: [],
397
496
  metadata: {
398
497
  total_searched: 0,
399
- pattern: sanitizedPattern,
498
+ pattern: query,
400
499
  error: 'No tool registry provided',
401
500
  },
402
501
  },
@@ -404,14 +503,10 @@ Usage:
404
503
  }
405
504
 
406
505
  const toolsArray: t.LCTool[] = Array.from(toolRegistry.values());
407
-
408
506
  const deferredTools: t.ToolMetadata[] = toolsArray
409
- .filter((lcTool) => {
410
- if (onlyDeferred === true) {
411
- return lcTool.defer_loading === true;
412
- }
413
- return true;
414
- })
507
+ .filter((lcTool) =>
508
+ onlyDeferred === true ? lcTool.defer_loading === true : true
509
+ )
415
510
  .map((lcTool) => ({
416
511
  name: lcTool.name,
417
512
  description: lcTool.description ?? '',
@@ -420,17 +515,42 @@ Usage:
420
515
 
421
516
  if (deferredTools.length === 0) {
422
517
  return [
423
- `${warningMessage}No tools available to search. The tool registry is empty or no deferred tools are registered.`,
518
+ 'No tools available to search. The tool registry is empty or no deferred tools are registered.',
424
519
  {
425
520
  tool_references: [],
521
+ metadata: { total_searched: 0, pattern: query },
522
+ },
523
+ ];
524
+ }
525
+
526
+ if (mode === 'local') {
527
+ const searchResponse = performLocalSearch(
528
+ deferredTools,
529
+ query,
530
+ fields,
531
+ max_results
532
+ );
533
+ const formattedOutput = formatSearchResults(searchResponse);
534
+
535
+ return [
536
+ formattedOutput,
537
+ {
538
+ tool_references: searchResponse.tool_references,
426
539
  metadata: {
427
- total_searched: 0,
428
- pattern: sanitizedPattern,
540
+ total_searched: searchResponse.total_tools_searched,
541
+ pattern: searchResponse.pattern_used,
429
542
  },
430
543
  },
431
544
  ];
432
545
  }
433
546
 
547
+ const { safe: sanitizedPattern, wasEscaped } = sanitizeRegex(query);
548
+ let warningMessage = '';
549
+ if (wasEscaped) {
550
+ warningMessage =
551
+ 'Note: The provided pattern was converted to a literal search for safety.\n\n';
552
+ }
553
+
434
554
  const searchScript = generateSearchScript(
435
555
  deferredTools,
436
556
  fields,
@@ -468,7 +588,7 @@ Usage:
468
588
 
469
589
  if (result.stderr && result.stderr.trim()) {
470
590
  // eslint-disable-next-line no-console
471
- console.warn('[ToolSearchRegex] stderr:', result.stderr);
591
+ console.warn('[ToolSearch] stderr:', result.stderr);
472
592
  }
473
593
 
474
594
  if (!result.stdout || !result.stdout.trim()) {
@@ -499,7 +619,7 @@ Usage:
499
619
  ];
500
620
  } catch (error) {
501
621
  // eslint-disable-next-line no-console
502
- console.error('[ToolSearchRegex] Error:', error);
622
+ console.error('[ToolSearch] Error:', error);
503
623
 
504
624
  const errorMessage =
505
625
  error instanceof Error ? error.message : String(error);
@@ -517,16 +637,17 @@ Usage:
517
637
  }
518
638
  },
519
639
  {
520
- name: Constants.TOOL_SEARCH_REGEX,
640
+ name: Constants.TOOL_SEARCH,
521
641
  description,
522
- schema: ToolSearchRegexSchema,
642
+ schema,
523
643
  responseFormat: Constants.CONTENT_AND_ARTIFACT,
524
644
  }
525
645
  );
526
646
  }
527
647
 
528
648
  export {
529
- createToolSearchRegexTool,
649
+ createToolSearch,
650
+ performLocalSearch,
530
651
  sanitizeRegex,
531
652
  escapeRegexSpecialChars,
532
653
  isDangerousPattern,
@@ -1,19 +1,19 @@
1
- // src/tools/__tests__/ToolSearchRegex.integration.test.ts
1
+ // src/tools/__tests__/ToolSearch.integration.test.ts
2
2
  /**
3
3
  * Integration tests for Tool Search Regex.
4
4
  * These tests hit the LIVE Code API and verify end-to-end search functionality.
5
5
  *
6
- * Run with: npm test -- ToolSearchRegex.integration.test.ts
6
+ * Run with: npm test -- ToolSearch.integration.test.ts
7
7
  */
8
8
  import { config as dotenvConfig } from 'dotenv';
9
9
  dotenvConfig();
10
10
 
11
11
  import { describe, it, expect, beforeAll } from '@jest/globals';
12
- import { createToolSearchRegexTool } from '../ToolSearchRegex';
12
+ import { createToolSearch } from '../ToolSearch';
13
13
  import { createToolSearchToolRegistry } from '@/test/mockTools';
14
14
 
15
- describe('ToolSearchRegex - Live API Integration', () => {
16
- let searchTool: ReturnType<typeof createToolSearchRegexTool>;
15
+ describe('ToolSearch - Live API Integration', () => {
16
+ let searchTool: ReturnType<typeof createToolSearch>;
17
17
  const toolRegistry = createToolSearchToolRegistry();
18
18
 
19
19
  beforeAll(() => {
@@ -24,7 +24,7 @@ describe('ToolSearchRegex - Live API Integration', () => {
24
24
  );
25
25
  }
26
26
 
27
- searchTool = createToolSearchRegexTool({ apiKey, toolRegistry });
27
+ searchTool = createToolSearch({ apiKey, toolRegistry });
28
28
  });
29
29
 
30
30
  it('searches for expense-related tools', async () => {
@@ -1,4 +1,4 @@
1
- // src/tools/__tests__/ToolSearchRegex.test.ts
1
+ // src/tools/__tests__/ToolSearch.test.ts
2
2
  /**
3
3
  * Unit tests for Tool Search Regex.
4
4
  * Tests helper functions and sanitization logic without hitting the API.
@@ -10,9 +10,11 @@ import {
10
10
  isDangerousPattern,
11
11
  countNestedGroups,
12
12
  hasNestedQuantifiers,
13
- } from '../ToolSearchRegex';
13
+ performLocalSearch,
14
+ } from '../ToolSearch';
15
+ import type { ToolMetadata } from '@/types';
14
16
 
15
- describe('ToolSearchRegex', () => {
17
+ describe('ToolSearch', () => {
16
18
  describe('escapeRegexSpecialChars', () => {
17
19
  it('escapes special regex characters', () => {
18
20
  expect(escapeRegexSpecialChars('hello.world')).toBe('hello\\.world');
@@ -229,4 +231,211 @@ describe('ToolSearchRegex', () => {
229
231
  }
230
232
  });
231
233
  });
234
+
235
+ describe('performLocalSearch', () => {
236
+ const mockTools: ToolMetadata[] = [
237
+ {
238
+ name: 'get_weather',
239
+ description: 'Get current weather data',
240
+ parameters: undefined,
241
+ },
242
+ {
243
+ name: 'get_forecast',
244
+ description: 'Get weather forecast for multiple days',
245
+ parameters: undefined,
246
+ },
247
+ {
248
+ name: 'send_email',
249
+ description: 'Send an email message',
250
+ parameters: undefined,
251
+ },
252
+ {
253
+ name: 'get_expenses',
254
+ description: 'Retrieve expense reports',
255
+ parameters: undefined,
256
+ },
257
+ {
258
+ name: 'calculate_expense_totals',
259
+ description: 'Sum up expenses by category',
260
+ parameters: undefined,
261
+ },
262
+ {
263
+ name: 'run_database_query',
264
+ description: 'Execute a database query',
265
+ parameters: {
266
+ type: 'object',
267
+ properties: {
268
+ query: { type: 'string' },
269
+ timeout: { type: 'number' },
270
+ },
271
+ },
272
+ },
273
+ ];
274
+
275
+ it('finds tools by exact name match', () => {
276
+ const result = performLocalSearch(mockTools, 'get_weather', ['name'], 10);
277
+
278
+ expect(result.tool_references.length).toBe(1);
279
+ expect(result.tool_references[0].tool_name).toBe('get_weather');
280
+ expect(result.tool_references[0].match_score).toBe(1.0);
281
+ expect(result.tool_references[0].matched_field).toBe('name');
282
+ });
283
+
284
+ it('finds tools by partial name match (starts with)', () => {
285
+ const result = performLocalSearch(mockTools, 'get_', ['name'], 10);
286
+
287
+ expect(result.tool_references.length).toBe(3);
288
+ expect(result.tool_references[0].match_score).toBe(0.95);
289
+ expect(result.tool_references.map((r) => r.tool_name)).toContain(
290
+ 'get_weather'
291
+ );
292
+ expect(result.tool_references.map((r) => r.tool_name)).toContain(
293
+ 'get_forecast'
294
+ );
295
+ expect(result.tool_references.map((r) => r.tool_name)).toContain(
296
+ 'get_expenses'
297
+ );
298
+ });
299
+
300
+ it('finds tools by substring match in name', () => {
301
+ const result = performLocalSearch(mockTools, 'expense', ['name'], 10);
302
+
303
+ expect(result.tool_references.length).toBe(2);
304
+ expect(result.tool_references.map((r) => r.tool_name)).toContain(
305
+ 'get_expenses'
306
+ );
307
+ expect(result.tool_references.map((r) => r.tool_name)).toContain(
308
+ 'calculate_expense_totals'
309
+ );
310
+ expect(result.tool_references[0].match_score).toBe(0.85);
311
+ });
312
+
313
+ it('performs case-insensitive search', () => {
314
+ const result = performLocalSearch(
315
+ mockTools,
316
+ 'WEATHER',
317
+ ['name', 'description'],
318
+ 10
319
+ );
320
+
321
+ expect(result.tool_references.length).toBe(2);
322
+ expect(result.tool_references.map((r) => r.tool_name)).toContain(
323
+ 'get_weather'
324
+ );
325
+ expect(result.tool_references.map((r) => r.tool_name)).toContain(
326
+ 'get_forecast'
327
+ );
328
+ });
329
+
330
+ it('searches in description field', () => {
331
+ const result = performLocalSearch(
332
+ mockTools,
333
+ 'email',
334
+ ['description'],
335
+ 10
336
+ );
337
+
338
+ expect(result.tool_references.length).toBe(1);
339
+ expect(result.tool_references[0].tool_name).toBe('send_email');
340
+ expect(result.tool_references[0].matched_field).toBe('description');
341
+ expect(result.tool_references[0].match_score).toBe(0.7);
342
+ });
343
+
344
+ it('searches in parameter names', () => {
345
+ const result = performLocalSearch(mockTools, 'query', ['parameters'], 10);
346
+
347
+ expect(result.tool_references.length).toBe(1);
348
+ expect(result.tool_references[0].tool_name).toBe('run_database_query');
349
+ expect(result.tool_references[0].matched_field).toBe('parameters');
350
+ expect(result.tool_references[0].match_score).toBe(0.55);
351
+ });
352
+
353
+ it('prioritizes name matches over description matches', () => {
354
+ const result = performLocalSearch(
355
+ mockTools,
356
+ 'weather',
357
+ ['name', 'description'],
358
+ 10
359
+ );
360
+
361
+ const weatherTool = result.tool_references.find(
362
+ (r) => r.tool_name === 'get_weather'
363
+ );
364
+ const forecastTool = result.tool_references.find(
365
+ (r) => r.tool_name === 'get_forecast'
366
+ );
367
+
368
+ expect(weatherTool?.matched_field).toBe('name');
369
+ expect(forecastTool?.matched_field).toBe('description');
370
+ expect(weatherTool!.match_score).toBeGreaterThan(
371
+ forecastTool!.match_score
372
+ );
373
+ });
374
+
375
+ it('limits results to max_results', () => {
376
+ const result = performLocalSearch(mockTools, 'get', ['name'], 2);
377
+
378
+ expect(result.tool_references.length).toBe(2);
379
+ expect(result.total_tools_searched).toBe(mockTools.length);
380
+ });
381
+
382
+ it('returns empty array when no matches found', () => {
383
+ const result = performLocalSearch(
384
+ mockTools,
385
+ 'nonexistent_xyz_123',
386
+ ['name', 'description'],
387
+ 10
388
+ );
389
+
390
+ expect(result.tool_references.length).toBe(0);
391
+ expect(result.total_tools_searched).toBe(mockTools.length);
392
+ });
393
+
394
+ it('sorts results by score descending', () => {
395
+ const result = performLocalSearch(
396
+ mockTools,
397
+ 'expense',
398
+ ['name', 'description'],
399
+ 10
400
+ );
401
+
402
+ for (let i = 1; i < result.tool_references.length; i++) {
403
+ expect(
404
+ result.tool_references[i - 1].match_score
405
+ ).toBeGreaterThanOrEqual(result.tool_references[i].match_score);
406
+ }
407
+ });
408
+
409
+ it('handles empty tools array', () => {
410
+ const result = performLocalSearch([], 'test', ['name'], 10);
411
+
412
+ expect(result.tool_references.length).toBe(0);
413
+ expect(result.total_tools_searched).toBe(0);
414
+ });
415
+
416
+ it('handles empty query gracefully', () => {
417
+ const result = performLocalSearch(mockTools, '', ['name'], 10);
418
+
419
+ expect(result.tool_references.length).toBe(mockTools.length);
420
+ });
421
+
422
+ it('includes correct metadata in response', () => {
423
+ const result = performLocalSearch(mockTools, 'weather', ['name'], 10);
424
+
425
+ expect(result.total_tools_searched).toBe(mockTools.length);
426
+ expect(result.pattern_used).toBe('weather');
427
+ });
428
+
429
+ it('provides snippet in results', () => {
430
+ const result = performLocalSearch(
431
+ mockTools,
432
+ 'database',
433
+ ['description'],
434
+ 10
435
+ );
436
+
437
+ expect(result.tool_references[0].snippet).toBeTruthy();
438
+ expect(result.tool_references[0].snippet.length).toBeGreaterThan(0);
439
+ });
440
+ });
232
441
  });
@@ -128,12 +128,17 @@ export type LCToolRegistry = Map<string, LCTool>;
128
128
 
129
129
  export type ProgrammaticCache = { toolMap: ToolMap; toolDefs: LCTool[] };
130
130
 
131
+ /** Search mode: code_interpreter uses external sandbox, local uses safe substring matching */
132
+ export type ToolSearchMode = 'code_interpreter' | 'local';
133
+
131
134
  /** Parameters for creating a Tool Search Regex tool */
132
- export type ToolSearchRegexParams = {
135
+ export type ToolSearchParams = {
133
136
  apiKey?: string;
134
137
  toolRegistry?: LCToolRegistry;
135
138
  onlyDeferred?: boolean;
136
139
  baseUrl?: string;
140
+ /** Search mode: 'code_interpreter' (default) uses sandbox for regex, 'local' uses safe substring matching */
141
+ mode?: ToolSearchMode;
137
142
  [key: string]: unknown;
138
143
  };
139
144