@librechat/agents 3.1.26 → 3.1.28

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,90 @@
1
1
  import { DynamicStructuredTool } from '@langchain/core/tools';
2
2
  import type * as t from '@/types';
3
+ import { Constants } from '@/common';
4
+ export declare const ToolSearchToolName = Constants.TOOL_SEARCH;
5
+ export declare const ToolSearchToolDescription = "Searches deferred tools using BM25 ranking. Multi-word queries supported. Use mcp_server param to filter by server.";
6
+ export declare const ToolSearchToolSchema: {
7
+ readonly type: "object";
8
+ readonly properties: {
9
+ readonly query: {
10
+ readonly type: "string";
11
+ readonly maxLength: 200;
12
+ readonly default: "";
13
+ readonly description: "Search term to find in tool names and descriptions. Case-insensitive substring matching. Optional if mcp_server is provided.";
14
+ };
15
+ readonly fields: {
16
+ readonly type: "array";
17
+ readonly items: {
18
+ readonly type: "string";
19
+ readonly enum: readonly ["name", "description", "parameters"];
20
+ };
21
+ readonly default: readonly ["name", "description"];
22
+ readonly description: "Which fields to search. Default: name and description";
23
+ };
24
+ readonly max_results: {
25
+ readonly type: "integer";
26
+ readonly minimum: 1;
27
+ readonly maximum: 50;
28
+ readonly default: 10;
29
+ readonly description: "Maximum number of matching tools to return";
30
+ };
31
+ readonly mcp_server: {
32
+ readonly oneOf: readonly [{
33
+ readonly type: "string";
34
+ }, {
35
+ readonly type: "array";
36
+ readonly items: {
37
+ readonly type: "string";
38
+ };
39
+ }];
40
+ readonly description: "Filter to tools from specific MCP server(s). Can be a single server name or array of names. If provided without a query, lists all tools from those servers.";
41
+ };
42
+ };
43
+ readonly required: readonly [];
44
+ };
45
+ export declare const ToolSearchToolDefinition: {
46
+ readonly name: Constants.TOOL_SEARCH;
47
+ readonly description: "Searches deferred tools using BM25 ranking. Multi-word queries supported. Use mcp_server param to filter by server.";
48
+ readonly schema: {
49
+ readonly type: "object";
50
+ readonly properties: {
51
+ readonly query: {
52
+ readonly type: "string";
53
+ readonly maxLength: 200;
54
+ readonly default: "";
55
+ readonly description: "Search term to find in tool names and descriptions. Case-insensitive substring matching. Optional if mcp_server is provided.";
56
+ };
57
+ readonly fields: {
58
+ readonly type: "array";
59
+ readonly items: {
60
+ readonly type: "string";
61
+ readonly enum: readonly ["name", "description", "parameters"];
62
+ };
63
+ readonly default: readonly ["name", "description"];
64
+ readonly description: "Which fields to search. Default: name and description";
65
+ };
66
+ readonly max_results: {
67
+ readonly type: "integer";
68
+ readonly minimum: 1;
69
+ readonly maximum: 50;
70
+ readonly default: 10;
71
+ readonly description: "Maximum number of matching tools to return";
72
+ };
73
+ readonly mcp_server: {
74
+ readonly oneOf: readonly [{
75
+ readonly type: "string";
76
+ }, {
77
+ readonly type: "array";
78
+ readonly items: {
79
+ readonly type: "string";
80
+ };
81
+ }];
82
+ readonly description: "Filter to tools from specific MCP server(s). Can be a single server name or array of names. If provided without a query, lists all tools from those servers.";
83
+ };
84
+ };
85
+ readonly required: readonly [];
86
+ };
87
+ };
3
88
  /**
4
89
  * Extracts the MCP server name from a tool name.
5
90
  * MCP tools follow the pattern: toolName_mcp_serverName
@@ -262,4 +262,10 @@ export interface AgentInputs {
262
262
  * ON_TOOL_EXECUTE events instead of invoking tools directly.
263
263
  */
264
264
  toolDefinitions?: LCTool[];
265
+ /**
266
+ * Tool names discovered from previous conversation history.
267
+ * These tools will be pre-marked as discovered so they're included
268
+ * in tool binding without requiring tool_search.
269
+ */
270
+ discoveredTools?: string[];
265
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.1.26",
3
+ "version": "3.1.28",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -42,6 +42,7 @@ export class AgentContext {
42
42
  maxContextTokens,
43
43
  reasoningKey,
44
44
  useLegacyContent,
45
+ discoveredTools,
45
46
  } = agentConfig;
46
47
 
47
48
  const agentContext = new AgentContext({
@@ -62,6 +63,7 @@ export class AgentContext {
62
63
  instructionTokens: 0,
63
64
  tokenCounter,
64
65
  useLegacyContent,
66
+ discoveredTools,
65
67
  });
66
68
 
67
69
  if (tokenCounter) {
@@ -186,6 +188,7 @@ export class AgentContext {
186
188
  toolEnd,
187
189
  instructionTokens,
188
190
  useLegacyContent,
191
+ discoveredTools,
189
192
  }: {
190
193
  agentId: string;
191
194
  name?: string;
@@ -204,6 +207,7 @@ export class AgentContext {
204
207
  toolEnd?: boolean;
205
208
  instructionTokens?: number;
206
209
  useLegacyContent?: boolean;
210
+ discoveredTools?: string[];
207
211
  }) {
208
212
  this.agentId = agentId;
209
213
  this.name = name;
@@ -229,6 +233,12 @@ export class AgentContext {
229
233
  }
230
234
 
231
235
  this.useLegacyContent = useLegacyContent ?? false;
236
+
237
+ if (discoveredTools && discoveredTools.length > 0) {
238
+ for (const toolName of discoveredTools) {
239
+ this.discoveredToolNames.add(toolName);
240
+ }
241
+ }
232
242
  }
233
243
 
234
244
  /**
@@ -19,7 +19,7 @@ import type {
19
19
  TPayload,
20
20
  TMessage,
21
21
  } from '@/types';
22
- import { Providers, ContentTypes } from '@/common';
22
+ import { Providers, ContentTypes, Constants } from '@/common';
23
23
 
24
24
  interface MediaMessageParams {
25
25
  message: {
@@ -297,7 +297,7 @@ function formatAssistantMessage(
297
297
  if (currentContent.length > 0) {
298
298
  let content = currentContent.reduce((acc, curr) => {
299
299
  if (curr.type === ContentTypes.TEXT) {
300
- return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
300
+ return `${acc}${String(curr[ContentTypes.TEXT] ?? '')}\n`;
301
301
  }
302
302
  return acc;
303
303
  }, '');
@@ -384,7 +384,7 @@ function formatAssistantMessage(
384
384
  const content = currentContent
385
385
  .reduce((acc, curr) => {
386
386
  if (curr.type === ContentTypes.TEXT) {
387
- return `${acc}${curr[ContentTypes.TEXT] || ''}\n`;
387
+ return `${acc}${String(curr[ContentTypes.TEXT] ?? '')}\n`;
388
388
  }
389
389
  return acc;
390
390
  }, '')
@@ -620,6 +620,48 @@ export const labelContentByAgent = (
620
620
  return result;
621
621
  };
622
622
 
623
+ /** Extracts tool names from a tool_search output JSON string. */
624
+ function extractToolNamesFromSearchOutput(output: string): string[] {
625
+ try {
626
+ const parsed: unknown = JSON.parse(output);
627
+ if (
628
+ typeof parsed === 'object' &&
629
+ parsed !== null &&
630
+ Array.isArray((parsed as Record<string, unknown>).tools)
631
+ ) {
632
+ return (
633
+ (parsed as Record<string, unknown>).tools as Array<{ name?: string }>
634
+ )
635
+ .map((t) => t.name)
636
+ .filter((name): name is string => typeof name === 'string');
637
+ }
638
+ } catch {
639
+ /** Output may have warnings prepended, try to find JSON within it */
640
+ const jsonMatch = output.match(/\{[\s\S]*\}/);
641
+ if (jsonMatch) {
642
+ try {
643
+ const parsed: unknown = JSON.parse(jsonMatch[0]);
644
+ if (
645
+ typeof parsed === 'object' &&
646
+ parsed !== null &&
647
+ Array.isArray((parsed as Record<string, unknown>).tools)
648
+ ) {
649
+ return (
650
+ (parsed as Record<string, unknown>).tools as Array<{
651
+ name?: string;
652
+ }>
653
+ )
654
+ .map((t) => t.name)
655
+ .filter((name): name is string => typeof name === 'string');
656
+ }
657
+ } catch {
658
+ /* ignore */
659
+ }
660
+ }
661
+ }
662
+ return [];
663
+ }
664
+
623
665
  /**
624
666
  * Formats an array of messages for LangChain, handling tool calls and creating ToolMessage instances.
625
667
  *
@@ -644,6 +686,13 @@ export const formatAgentMessages = (
644
686
  // Keep track of the mapping from original payload indices to result indices
645
687
  const indexMapping: Record<number, number[] | undefined> = {};
646
688
 
689
+ /**
690
+ * Create a mutable copy of the tools set that can be expanded dynamically.
691
+ * When we encounter tool_search results, we add discovered tools to this set,
692
+ * making their subsequent tool calls valid.
693
+ */
694
+ const discoveredTools = tools ? new Set(tools) : undefined;
695
+
647
696
  // Process messages with tool conversion if tools set is provided
648
697
  for (let i = 0; i < payload.length; i++) {
649
698
  const message = payload[i];
@@ -670,96 +719,140 @@ export const formatAgentMessages = (
670
719
  // For assistant messages, track the starting index before processing
671
720
  const startMessageIndex = messages.length;
672
721
 
673
- // If tools set is provided, we need to check if we need to convert tool messages to a string
674
- if (tools) {
675
- // First, check if this message contains tool calls
676
- let hasToolCalls = false;
677
- let hasInvalidTool = false;
678
- const toolNames: string[] = [];
679
-
722
+ /**
723
+ * If tools set is provided, process tool_calls:
724
+ * - Keep valid tool_calls (tools in the set or dynamically discovered)
725
+ * - Convert invalid tool_calls to string representation for context preservation
726
+ * - Dynamically expand the set when tool_search results are encountered
727
+ */
728
+ let processedMessage = message;
729
+ if (discoveredTools) {
680
730
  const content = message.content;
681
731
  if (content && Array.isArray(content)) {
732
+ const filteredContent: typeof content = [];
733
+ const invalidToolCallIds = new Set<string>();
734
+ const invalidToolStrings: string[] = [];
735
+
682
736
  for (const part of content) {
683
- if (part.type === ContentTypes.TOOL_CALL) {
684
- hasToolCalls = true;
685
- if (tools.size === 0) {
686
- hasInvalidTool = true;
687
- break;
688
- }
689
- // Protect against malformed tool call entries
737
+ if (part.type !== ContentTypes.TOOL_CALL) {
738
+ filteredContent.push(part);
739
+ continue;
740
+ }
741
+
742
+ /** Skip malformed tool_call entries */
743
+ if (
744
+ part.tool_call == null ||
745
+ part.tool_call.name == null ||
746
+ part.tool_call.name === ''
747
+ ) {
690
748
  if (
691
- part.tool_call == null ||
692
- part.tool_call.name == null ||
693
- part.tool_call.name === ''
749
+ typeof part.tool_call?.id === 'string' &&
750
+ part.tool_call.id !== ''
694
751
  ) {
695
- hasInvalidTool = true;
696
- continue;
697
- }
698
- const toolName = part.tool_call.name;
699
- toolNames.push(toolName);
700
- if (!tools.has(toolName)) {
701
- hasInvalidTool = true;
752
+ invalidToolCallIds.add(part.tool_call.id);
702
753
  }
754
+ continue;
703
755
  }
704
- }
705
- }
706
756
 
707
- // If this message has tool calls and at least one is invalid, we need to convert it
708
- if (hasToolCalls && hasInvalidTool) {
709
- // We need to collect all related messages (this message and any subsequent tool messages)
710
- const toolSequence: BaseMessage[] = [];
711
- let sequenceEndIndex = i;
712
-
713
- // Process the current assistant message to get the AIMessage with tool calls
714
- const formattedMessages = formatAssistantMessage(message);
715
- toolSequence.push(...formattedMessages);
716
-
717
- // Look ahead for any subsequent assistant messages that might be part of this tool sequence
718
- let j = i + 1;
719
- while (j < payload.length && payload[j].role === 'assistant') {
720
- // Check if this is a continuation of the tool sequence
721
- let isToolResponse = false;
722
- const content = payload[j].content;
723
- if (content != null && Array.isArray(content)) {
724
- for (const part of content) {
725
- if (part.type === ContentTypes.TOOL_CALL) {
726
- isToolResponse = true;
727
- break;
728
- }
757
+ const toolName = part.tool_call.name;
758
+
759
+ /**
760
+ * If this is a tool_search result with output, extract discovered tool names
761
+ * and add them to the discoveredTools set for subsequent validation.
762
+ */
763
+ if (
764
+ toolName === Constants.TOOL_SEARCH &&
765
+ typeof part.tool_call.output === 'string' &&
766
+ part.tool_call.output !== ''
767
+ ) {
768
+ const extracted = extractToolNamesFromSearchOutput(
769
+ part.tool_call.output
770
+ );
771
+ for (const name of extracted) {
772
+ discoveredTools.add(name);
729
773
  }
730
774
  }
731
775
 
732
- if (isToolResponse) {
733
- // This is part of the tool sequence, add it
734
- const nextMessages = formatAssistantMessage(payload[j]);
735
- toolSequence.push(...nextMessages);
736
- sequenceEndIndex = j;
737
- j++;
776
+ if (discoveredTools.has(toolName)) {
777
+ /** Valid tool - keep it */
778
+ filteredContent.push(part);
738
779
  } else {
739
- // This is not part of the tool sequence, stop looking
740
- break;
780
+ /** Invalid tool - convert to string for context preservation */
781
+ if (
782
+ typeof part.tool_call.id === 'string' &&
783
+ part.tool_call.id !== ''
784
+ ) {
785
+ invalidToolCallIds.add(part.tool_call.id);
786
+ }
787
+ const output = part.tool_call.output ?? '';
788
+ invalidToolStrings.push(`Tool: ${toolName}, ${output}`);
741
789
  }
742
790
  }
743
791
 
744
- // Convert the sequence to a string
745
- const bufferString = getBufferString(toolSequence);
746
- messages.push(new AIMessage({ content: bufferString }));
792
+ /** Remove tool_call_ids references to invalid tools from text parts */
793
+ if (invalidToolCallIds.size > 0) {
794
+ for (const part of filteredContent) {
795
+ if (
796
+ part.type === ContentTypes.TEXT &&
797
+ Array.isArray(part.tool_call_ids)
798
+ ) {
799
+ part.tool_call_ids = part.tool_call_ids.filter(
800
+ (id: string) => !invalidToolCallIds.has(id)
801
+ );
802
+ if (part.tool_call_ids.length === 0) {
803
+ delete part.tool_call_ids;
804
+ }
805
+ }
806
+ }
807
+ }
747
808
 
748
- // Skip the messages we've already processed
749
- i = sequenceEndIndex;
809
+ /** Append invalid tool strings to the content for context preservation */
810
+ if (invalidToolStrings.length > 0) {
811
+ /** Find the last text part or create one */
812
+ let lastTextPartIndex = -1;
813
+ for (let j = filteredContent.length - 1; j >= 0; j--) {
814
+ if (filteredContent[j].type === ContentTypes.TEXT) {
815
+ lastTextPartIndex = j;
816
+ break;
817
+ }
818
+ }
750
819
 
751
- // Update the index mapping for this sequence
752
- const resultIndices = [messages.length - 1];
753
- for (let k = i; k >= i && k <= sequenceEndIndex; k++) {
754
- indexMapping[k] = resultIndices;
820
+ const invalidToolText = invalidToolStrings.join('\n');
821
+ if (lastTextPartIndex >= 0) {
822
+ const lastTextPart = filteredContent[lastTextPartIndex] as {
823
+ type: string;
824
+ [ContentTypes.TEXT]?: string;
825
+ text?: string;
826
+ };
827
+ const existingText =
828
+ lastTextPart[ContentTypes.TEXT] ?? lastTextPart.text ?? '';
829
+ filteredContent[lastTextPartIndex] = {
830
+ ...lastTextPart,
831
+ [ContentTypes.TEXT]: existingText
832
+ ? `${existingText}\n${invalidToolText}`
833
+ : invalidToolText,
834
+ };
835
+ } else {
836
+ /** No text part exists, create one */
837
+ filteredContent.push({
838
+ type: ContentTypes.TEXT,
839
+ [ContentTypes.TEXT]: invalidToolText,
840
+ });
841
+ }
755
842
  }
756
843
 
757
- continue;
844
+ /** Use filtered content if we made any changes */
845
+ if (
846
+ filteredContent.length !== content.length ||
847
+ invalidToolStrings.length > 0
848
+ ) {
849
+ processedMessage = { ...message, content: filteredContent };
850
+ }
758
851
  }
759
852
  }
760
853
 
761
854
  // Process the assistant message using the helper function
762
- const formattedMessages = formatAssistantMessage(message);
855
+ const formattedMessages = formatAssistantMessage(processedMessage);
763
856
  messages.push(...formattedMessages);
764
857
 
765
858
  // Update the index mapping for this assistant message