@librechat/agents 3.0.70 → 3.0.72

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.
@@ -1,5 +1,6 @@
1
1
  // src/tools/ToolSearch.ts
2
2
  import { z } from 'zod';
3
+ import BM25 from 'okapibm25';
3
4
  import { config } from 'dotenv';
4
5
  import fetch, { RequestInit } from 'node-fetch';
5
6
  import { HttpsProxyAgent } from 'https-proxy-agent';
@@ -282,13 +283,101 @@ function simplifyParametersForSearch(
282
283
  }
283
284
 
284
285
  /**
285
- * Performs safe local substring search without regex.
286
- * Uses case-insensitive String.includes() for complete safety against ReDoS.
286
+ * Tokenizes a string into lowercase words for BM25.
287
+ * @param text - The text to tokenize
288
+ * @returns Array of lowercase tokens
289
+ */
290
+ function tokenize(text: string): string[] {
291
+ return text
292
+ .toLowerCase()
293
+ .replace(/[^a-z0-9_]/g, ' ')
294
+ .split(/\s+/)
295
+ .filter((token) => token.length > 0);
296
+ }
297
+
298
+ /**
299
+ * Creates a searchable document string from tool metadata.
300
+ * @param tool - The tool metadata
301
+ * @param fields - Which fields to include
302
+ * @returns Combined document string for BM25
303
+ */
304
+ function createToolDocument(tool: t.ToolMetadata, fields: string[]): string {
305
+ const parts: string[] = [];
306
+
307
+ if (fields.includes('name')) {
308
+ const baseName = tool.name.replace(/_/g, ' ');
309
+ parts.push(baseName, baseName);
310
+ }
311
+
312
+ if (fields.includes('description') && tool.description) {
313
+ parts.push(tool.description);
314
+ }
315
+
316
+ if (fields.includes('parameters') && tool.parameters?.properties) {
317
+ const paramNames = Object.keys(tool.parameters.properties).join(' ');
318
+ parts.push(paramNames);
319
+ }
320
+
321
+ return parts.join(' ');
322
+ }
323
+
324
+ /**
325
+ * Determines which field had the best match for a query.
326
+ * @param tool - The tool to check
327
+ * @param queryTokens - Tokenized query
328
+ * @param fields - Fields to check
329
+ * @returns The matched field and a snippet
330
+ */
331
+ function findMatchedField(
332
+ tool: t.ToolMetadata,
333
+ queryTokens: string[],
334
+ fields: string[]
335
+ ): { field: string; snippet: string } {
336
+ if (fields.includes('name')) {
337
+ const nameLower = tool.name.toLowerCase();
338
+ for (const token of queryTokens) {
339
+ if (nameLower.includes(token)) {
340
+ return { field: 'name', snippet: tool.name };
341
+ }
342
+ }
343
+ }
344
+
345
+ if (fields.includes('description') && tool.description) {
346
+ const descLower = tool.description.toLowerCase();
347
+ for (const token of queryTokens) {
348
+ if (descLower.includes(token)) {
349
+ return {
350
+ field: 'description',
351
+ snippet: tool.description.substring(0, 100),
352
+ };
353
+ }
354
+ }
355
+ }
356
+
357
+ if (fields.includes('parameters') && tool.parameters?.properties) {
358
+ const paramNames = Object.keys(tool.parameters.properties);
359
+ const paramLower = paramNames.join(' ').toLowerCase();
360
+ for (const token of queryTokens) {
361
+ if (paramLower.includes(token)) {
362
+ return { field: 'parameters', snippet: paramNames.join(', ') };
363
+ }
364
+ }
365
+ }
366
+
367
+ const fallbackSnippet = tool.description
368
+ ? tool.description.substring(0, 100)
369
+ : tool.name;
370
+ return { field: 'unknown', snippet: fallbackSnippet };
371
+ }
372
+
373
+ /**
374
+ * Performs BM25-based search for better relevance ranking.
375
+ * Uses Okapi BM25 algorithm for term frequency and document length normalization.
287
376
  * @param tools - Array of tool metadata to search
288
- * @param query - The search term (treated as literal substring)
377
+ * @param query - The search query
289
378
  * @param fields - Which fields to search
290
379
  * @param maxResults - Maximum results to return
291
- * @returns Search response with matching tools
380
+ * @returns Search response with matching tools ranked by BM25 score
292
381
  */
293
382
  function performLocalSearch(
294
383
  tools: t.ToolMetadata[],
@@ -296,50 +385,43 @@ function performLocalSearch(
296
385
  fields: string[],
297
386
  maxResults: number
298
387
  ): t.ToolSearchResponse {
299
- const lowerQuery = query.toLowerCase();
300
- const results: t.ToolSearchResult[] = [];
388
+ if (tools.length === 0 || !query.trim()) {
389
+ return {
390
+ tool_references: [],
391
+ total_tools_searched: tools.length,
392
+ pattern_used: query,
393
+ };
394
+ }
301
395
 
302
- for (const tool of tools) {
303
- let bestScore = 0;
304
- let matchedField = '';
305
- let snippet = '';
306
-
307
- if (fields.includes('name')) {
308
- const lowerName = tool.name.toLowerCase();
309
- if (lowerName.includes(lowerQuery)) {
310
- const isExactMatch = lowerName === lowerQuery;
311
- const startsWithQuery = lowerName.startsWith(lowerQuery);
312
- bestScore = isExactMatch ? 1.0 : startsWithQuery ? 0.95 : 0.85;
313
- matchedField = 'name';
314
- snippet = tool.name;
315
- }
316
- }
396
+ const documents = tools.map((tool) => createToolDocument(tool, fields));
397
+ const queryTokens = tokenize(query);
317
398
 
318
- if (fields.includes('description') && tool.description) {
319
- const lowerDesc = tool.description.toLowerCase();
320
- if (lowerDesc.includes(lowerQuery) && bestScore === 0) {
321
- bestScore = 0.7;
322
- matchedField = 'description';
323
- snippet = tool.description.substring(0, 100);
324
- }
325
- }
399
+ if (queryTokens.length === 0) {
400
+ return {
401
+ tool_references: [],
402
+ total_tools_searched: tools.length,
403
+ pattern_used: query,
404
+ };
405
+ }
326
406
 
327
- if (fields.includes('parameters') && tool.parameters?.properties) {
328
- const paramNames = Object.keys(tool.parameters.properties)
329
- .join(' ')
330
- .toLowerCase();
331
- if (paramNames.includes(lowerQuery) && bestScore === 0) {
332
- bestScore = 0.55;
333
- matchedField = 'parameters';
334
- snippet = Object.keys(tool.parameters.properties).join(' ');
335
- }
336
- }
407
+ const scores = BM25(documents, queryTokens, { k1: 1.5, b: 0.75 }) as number[];
408
+
409
+ const maxScore = Math.max(...scores.filter((s) => s > 0), 1);
410
+
411
+ const results: t.ToolSearchResult[] = [];
412
+ for (let i = 0; i < tools.length; i++) {
413
+ if (scores[i] > 0) {
414
+ const { field, snippet } = findMatchedField(
415
+ tools[i],
416
+ queryTokens,
417
+ fields
418
+ );
419
+ const normalizedScore = Math.min(scores[i] / maxScore, 1.0);
337
420
 
338
- if (bestScore > 0) {
339
421
  results.push({
340
- tool_name: tool.name,
341
- match_score: bestScore,
342
- matched_field: matchedField,
422
+ tool_name: tools[i].name,
423
+ match_score: normalizedScore,
424
+ matched_field: field,
343
425
  snippet,
344
426
  });
345
427
  }
@@ -501,6 +583,56 @@ function getBaseToolName(toolName: string): string {
501
583
  return toolName.substring(0, delimiterIndex);
502
584
  }
503
585
 
586
+ /**
587
+ * Generates a compact listing of deferred tools grouped by server.
588
+ * Format: "server: tool1, tool2, tool3"
589
+ * Non-MCP tools are grouped under "other".
590
+ * @param toolRegistry - The tool registry
591
+ * @param onlyDeferred - Whether to only include deferred tools
592
+ * @returns Formatted string with tools grouped by server
593
+ */
594
+ function getDeferredToolsListing(
595
+ toolRegistry: t.LCToolRegistry | undefined,
596
+ onlyDeferred: boolean
597
+ ): string {
598
+ if (!toolRegistry) {
599
+ return '';
600
+ }
601
+
602
+ const toolsByServer: Record<string, string[]> = {};
603
+
604
+ for (const lcTool of toolRegistry.values()) {
605
+ if (onlyDeferred && lcTool.defer_loading !== true) {
606
+ continue;
607
+ }
608
+
609
+ const toolName = lcTool.name;
610
+ const serverName = extractMcpServerName(toolName) ?? 'other';
611
+ const baseName = getBaseToolName(toolName);
612
+
613
+ if (!(serverName in toolsByServer)) {
614
+ toolsByServer[serverName] = [];
615
+ }
616
+ toolsByServer[serverName].push(baseName);
617
+ }
618
+
619
+ const serverNames = Object.keys(toolsByServer).sort((a, b) => {
620
+ if (a === 'other') return 1;
621
+ if (b === 'other') return -1;
622
+ return a.localeCompare(b);
623
+ });
624
+
625
+ if (serverNames.length === 0) {
626
+ return '';
627
+ }
628
+
629
+ const lines = serverNames.map(
630
+ (server) => `${server}: ${toolsByServer[server].join(', ')}`
631
+ );
632
+
633
+ return lines.join('\n');
634
+ }
635
+
504
636
  /**
505
637
  * Formats a server listing response as structured JSON.
506
638
  * NOTE: This is a PREVIEW only - tools are NOT discovered/loaded.
@@ -609,46 +741,36 @@ function createToolSearch(
609
741
  const baseEndpoint = initParams.baseUrl ?? getCodeBaseURL();
610
742
  const EXEC_ENDPOINT = `${baseEndpoint}/exec`;
611
743
 
612
- const availableServers = getAvailableMcpServers(
744
+ const deferredToolsListing = getDeferredToolsListing(
613
745
  initParams.toolRegistry,
614
746
  defaultOnlyDeferred
615
747
  );
616
748
 
617
- const serverListText =
618
- availableServers.length > 0
619
- ? `\n- Available MCP servers: ${availableServers.join(', ')}`
620
- : '';
749
+ const toolsListSection =
750
+ deferredToolsListing.length > 0
751
+ ? `
621
752
 
622
- const mcpInstructions = `
753
+ Deferred tools (search to load):
754
+ ${deferredToolsListing}`
755
+ : '';
623
756
 
624
- MCP Server Tools:
625
- - Tools from MCP servers follow the naming convention: toolName${Constants.MCP_DELIMITER}serverName
626
- - Example: "get_weather${Constants.MCP_DELIMITER}weather-api" is the "get_weather" tool from the "weather-api" server
627
- - Use mcp_server parameter to filter by server (e.g., mcp_server: "weather-api")
628
- - If mcp_server is provided without a query, lists ALL tools from that server${serverListText}`;
757
+ const mcpNote =
758
+ deferredToolsListing.includes(Constants.MCP_DELIMITER) ||
759
+ deferredToolsListing.split('\n').some((line) => !line.startsWith('other:'))
760
+ ? `
761
+ - MCP tools use format: toolName${Constants.MCP_DELIMITER}serverName
762
+ - Use mcp_server param to filter by server`
763
+ : '';
629
764
 
630
765
  const description =
631
766
  mode === 'local'
632
767
  ? `
633
- Searches through available tools to find ones matching your search term.
634
-
635
- Usage:
636
- - Provide a search term to find in tool names and descriptions.
637
- - Uses case-insensitive substring matching (fast and safe).
638
- - Use this when you need to discover tools for a specific task.
639
- - Results include tool names, match quality scores, and snippets showing where the match occurred.
640
- - Higher scores (0.95+) indicate name matches, medium scores (0.70+) indicate description matches.
641
- ${mcpInstructions}
768
+ Searches deferred tools using BM25 ranking. Multi-word queries supported.
769
+ ${mcpNote}${toolsListSection}
642
770
  `.trim()
643
771
  : `
644
- Searches through available tools to find ones matching your query pattern.
645
-
646
- Usage:
647
- - Provide a regex pattern to search tool names and descriptions.
648
- - Use this when you need to discover tools for a specific task.
649
- - Results include tool names, match quality scores, and snippets showing where the match occurred.
650
- - Higher scores (0.9+) indicate name matches, medium scores (0.7+) indicate description matches.
651
- ${mcpInstructions}
772
+ Searches deferred tools by regex pattern.
773
+ ${mcpNote}${toolsListSection}
652
774
  `.trim();
653
775
 
654
776
  return tool<typeof schema>(
@@ -879,6 +1001,7 @@ export {
879
1001
  isFromAnyMcpServer,
880
1002
  normalizeServerFilter,
881
1003
  getAvailableMcpServers,
1004
+ getDeferredToolsListing,
882
1005
  getBaseToolName,
883
1006
  formatServerListing,
884
1007
  sanitizeRegex,
@@ -16,6 +16,7 @@ import {
16
16
  isFromAnyMcpServer,
17
17
  normalizeServerFilter,
18
18
  getAvailableMcpServers,
19
+ getDeferredToolsListing,
19
20
  getBaseToolName,
20
21
  formatServerListing,
21
22
  } from '../ToolSearch';
@@ -280,19 +281,21 @@ describe('ToolSearch', () => {
280
281
  ];
281
282
 
282
283
  it('finds tools by exact name match', () => {
283
- const result = performLocalSearch(mockTools, 'get_weather', ['name'], 10);
284
+ // BM25 tokenizes "get weather" from "get_weather"
285
+ const result = performLocalSearch(mockTools, 'get weather', ['name'], 10);
284
286
 
285
- expect(result.tool_references.length).toBe(1);
287
+ expect(result.tool_references.length).toBeGreaterThan(0);
286
288
  expect(result.tool_references[0].tool_name).toBe('get_weather');
287
- expect(result.tool_references[0].match_score).toBe(1.0);
289
+ expect(result.tool_references[0].match_score).toBeGreaterThan(0.5);
288
290
  expect(result.tool_references[0].matched_field).toBe('name');
289
291
  });
290
292
 
291
- it('finds tools by partial name match (starts with)', () => {
292
- const result = performLocalSearch(mockTools, 'get_', ['name'], 10);
293
+ it('finds tools by partial name match', () => {
294
+ // BM25 finds tools containing "get" token
295
+ const result = performLocalSearch(mockTools, 'get', ['name'], 10);
293
296
 
294
297
  expect(result.tool_references.length).toBe(3);
295
- expect(result.tool_references[0].match_score).toBe(0.95);
298
+ expect(result.tool_references[0].match_score).toBeGreaterThan(0);
296
299
  expect(result.tool_references.map((r) => r.tool_name)).toContain(
297
300
  'get_weather'
298
301
  );
@@ -314,7 +317,7 @@ describe('ToolSearch', () => {
314
317
  expect(result.tool_references.map((r) => r.tool_name)).toContain(
315
318
  'calculate_expense_totals'
316
319
  );
317
- expect(result.tool_references[0].match_score).toBe(0.85);
320
+ expect(result.tool_references[0].match_score).toBeGreaterThan(0);
318
321
  });
319
322
 
320
323
  it('performs case-insensitive search', () => {
@@ -345,16 +348,16 @@ describe('ToolSearch', () => {
345
348
  expect(result.tool_references.length).toBe(1);
346
349
  expect(result.tool_references[0].tool_name).toBe('send_email');
347
350
  expect(result.tool_references[0].matched_field).toBe('description');
348
- expect(result.tool_references[0].match_score).toBe(0.7);
351
+ expect(result.tool_references[0].match_score).toBeGreaterThan(0);
349
352
  });
350
353
 
351
354
  it('searches in parameter names', () => {
352
355
  const result = performLocalSearch(mockTools, 'query', ['parameters'], 10);
353
356
 
354
- expect(result.tool_references.length).toBe(1);
357
+ expect(result.tool_references.length).toBeGreaterThan(0);
355
358
  expect(result.tool_references[0].tool_name).toBe('run_database_query');
356
359
  expect(result.tool_references[0].matched_field).toBe('parameters');
357
- expect(result.tool_references[0].match_score).toBe(0.55);
360
+ expect(result.tool_references[0].match_score).toBeGreaterThan(0);
358
361
  });
359
362
 
360
363
  it('prioritizes name matches over description matches', () => {
@@ -423,7 +426,9 @@ describe('ToolSearch', () => {
423
426
  it('handles empty query gracefully', () => {
424
427
  const result = performLocalSearch(mockTools, '', ['name'], 10);
425
428
 
426
- expect(result.tool_references.length).toBe(mockTools.length);
429
+ // BM25 correctly returns no results for empty queries (no terms to match)
430
+ expect(result.tool_references.length).toBe(0);
431
+ expect(result.total_tools_searched).toBe(mockTools.length);
427
432
  });
428
433
 
429
434
  it('includes correct metadata in response', () => {
@@ -650,6 +655,90 @@ describe('ToolSearch', () => {
650
655
  });
651
656
  });
652
657
 
658
+ describe('getDeferredToolsListing', () => {
659
+ const createRegistry = (): LCToolRegistry => {
660
+ const registry: LCToolRegistry = new Map();
661
+ registry.set('get_weather_mcp_weather-api', {
662
+ name: 'get_weather_mcp_weather-api',
663
+ description: 'Get weather',
664
+ defer_loading: true,
665
+ });
666
+ registry.set('get_forecast_mcp_weather-api', {
667
+ name: 'get_forecast_mcp_weather-api',
668
+ description: 'Get forecast',
669
+ defer_loading: true,
670
+ });
671
+ registry.set('send_email_mcp_gmail', {
672
+ name: 'send_email_mcp_gmail',
673
+ description: 'Send email',
674
+ defer_loading: true,
675
+ });
676
+ registry.set('execute_code', {
677
+ name: 'execute_code',
678
+ description: 'Execute code',
679
+ defer_loading: true,
680
+ });
681
+ registry.set('read_file', {
682
+ name: 'read_file',
683
+ description: 'Read file',
684
+ defer_loading: false,
685
+ });
686
+ return registry;
687
+ };
688
+
689
+ it('groups tools by server with format D', () => {
690
+ const registry = createRegistry();
691
+ const listing = getDeferredToolsListing(registry, true);
692
+
693
+ expect(listing).toContain('gmail: send_email');
694
+ expect(listing).toContain('weather-api: get_weather, get_forecast');
695
+ expect(listing).toContain('other: execute_code');
696
+ });
697
+
698
+ it('sorts servers alphabetically with other last', () => {
699
+ const registry = createRegistry();
700
+ const listing = getDeferredToolsListing(registry, true);
701
+ const lines = listing.split('\n');
702
+
703
+ expect(lines[0]).toMatch(/^gmail:/);
704
+ expect(lines[1]).toMatch(/^weather-api:/);
705
+ expect(lines[2]).toMatch(/^other:/);
706
+ });
707
+
708
+ it('uses base tool names without MCP suffix', () => {
709
+ const registry = createRegistry();
710
+ const listing = getDeferredToolsListing(registry, true);
711
+
712
+ expect(listing).toContain('get_weather');
713
+ expect(listing).not.toContain('get_weather_mcp_weather-api');
714
+ });
715
+
716
+ it('respects onlyDeferred flag', () => {
717
+ const registry = createRegistry();
718
+
719
+ const deferredOnly = getDeferredToolsListing(registry, true);
720
+ expect(deferredOnly).not.toContain('read_file');
721
+
722
+ const allTools = getDeferredToolsListing(registry, false);
723
+ expect(allTools).toContain('read_file');
724
+ });
725
+
726
+ it('returns empty string for undefined registry', () => {
727
+ expect(getDeferredToolsListing(undefined, true)).toBe('');
728
+ });
729
+
730
+ it('returns empty string for registry with no matching tools', () => {
731
+ const registry: LCToolRegistry = new Map();
732
+ registry.set('read_file', {
733
+ name: 'read_file',
734
+ description: 'Read file',
735
+ defer_loading: false,
736
+ });
737
+
738
+ expect(getDeferredToolsListing(registry, true)).toBe('');
739
+ });
740
+ });
741
+
653
742
  describe('performLocalSearch with MCP tools', () => {
654
743
  const mcpTools: ToolMetadata[] = [
655
744
  {
@@ -708,8 +797,9 @@ describe('ToolSearch', () => {
708
797
  );
709
798
  });
710
799
 
711
- it('can search for tools by MCP delimiter', () => {
712
- const result = performLocalSearch(mcpTools, '_mcp_', ['name'], 10);
800
+ it('can search for tools by MCP keyword', () => {
801
+ // BM25 tokenizes queries, so search for "mcp" to find MCP tools
802
+ const result = performLocalSearch(mcpTools, 'mcp', ['name'], 10);
713
803
 
714
804
  expect(result.tool_references.length).toBe(4);
715
805
  expect(result.tool_references.map((r) => r.tool_name)).not.toContain(