@librechat/agents 3.1.42 → 3.1.44

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.
@@ -89,6 +89,213 @@ describe('ensureThinkingBlockInMessages', () => {
89
89
  );
90
90
  });
91
91
 
92
+ test('should not modify AI message when reasoning_content is not the first block (Bedrock whitespace artifact)', () => {
93
+ // Bedrock emits a "\n\n" text chunk before the thinking block,
94
+ // pushing reasoning_content to content[1] instead of content[0].
95
+ const messages = [
96
+ new HumanMessage({ content: 'Do something' }),
97
+ new AIMessage({
98
+ content: [
99
+ { type: 'text', text: '\n\n' },
100
+ {
101
+ type: ContentTypes.REASONING_CONTENT,
102
+ reasoningText: { text: 'Let me think about this' },
103
+ },
104
+ { type: 'text', text: 'Let me help!' },
105
+ ],
106
+ tool_calls: [
107
+ {
108
+ id: 'call_bedrock',
109
+ name: 'some_tool',
110
+ args: { x: 1 },
111
+ type: 'tool_call' as const,
112
+ },
113
+ ],
114
+ }),
115
+ new ToolMessage({
116
+ content: 'tool result',
117
+ tool_call_id: 'call_bedrock',
118
+ }),
119
+ ];
120
+
121
+ const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
122
+
123
+ expect(result).toHaveLength(3);
124
+ expect(result[0]).toBeInstanceOf(HumanMessage);
125
+ expect(result[1]).toBeInstanceOf(AIMessage);
126
+ expect(result[2]).toBeInstanceOf(ToolMessage);
127
+ // The AI message should be preserved, not converted to a HumanMessage
128
+ expect(result[1].content).toEqual(messages[1].content);
129
+ });
130
+
131
+ test('should not convert follow-up tool calls in a thinking-enabled chain (Bedrock multi-step)', () => {
132
+ // Bedrock reasoning models produce reasoning on the first AI response,
133
+ // then subsequent tool calls in the same chain have content: "" with no
134
+ // reasoning block. These should NOT be converted because the chain
135
+ // already has a thinking block upstream.
136
+ const messages = [
137
+ new HumanMessage({ content: 'show me something cool' }),
138
+ new AIMessage({
139
+ content: [
140
+ { type: 'text', text: '\n\n' },
141
+ {
142
+ type: ContentTypes.REASONING_CONTENT,
143
+ reasoningText: { text: 'Let me navigate to a page' },
144
+ },
145
+ { type: 'text', text: 'Let me whip up something fun!' },
146
+ ],
147
+ tool_calls: [
148
+ {
149
+ id: 'call_nav',
150
+ name: 'navigate_page',
151
+ args: { url: 'about:blank' },
152
+ type: 'tool_call' as const,
153
+ },
154
+ ],
155
+ }),
156
+ new ToolMessage({
157
+ content: 'Navigated to about:blank',
158
+ tool_call_id: 'call_nav',
159
+ }),
160
+ // Follow-up: content: "", tool calls, NO reasoning block
161
+ new AIMessage({
162
+ content: '',
163
+ tool_calls: [
164
+ {
165
+ id: 'call_eval',
166
+ name: 'evaluate_script',
167
+ args: { script: 'document.title = "test"' },
168
+ type: 'tool_call' as const,
169
+ },
170
+ ],
171
+ }),
172
+ new ToolMessage({
173
+ content: 'Script executed',
174
+ tool_call_id: 'call_eval',
175
+ }),
176
+ ];
177
+
178
+ const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
179
+
180
+ // All 5 messages preserved — the follow-up AI message at index 3 is NOT converted
181
+ expect(result).toHaveLength(5);
182
+ expect(result[0]).toBeInstanceOf(HumanMessage);
183
+ expect(result[1]).toBeInstanceOf(AIMessage);
184
+ expect(result[2]).toBeInstanceOf(ToolMessage);
185
+ expect(result[3]).toBeInstanceOf(AIMessage);
186
+ expect(result[3].content).toBe('');
187
+ expect((result[3] as AIMessage).tool_calls).toHaveLength(1);
188
+ expect(result[4]).toBeInstanceOf(ToolMessage);
189
+ });
190
+
191
+ test('should not convert multiple follow-up tool calls in a long chain', () => {
192
+ // Three AI→Tool rounds: only the first has reasoning
193
+ const messages = [
194
+ new HumanMessage({ content: 'do stuff' }),
195
+ new AIMessage({
196
+ content: [
197
+ {
198
+ type: ContentTypes.REASONING_CONTENT,
199
+ reasoningText: { text: 'Planning...' },
200
+ },
201
+ ],
202
+ tool_calls: [
203
+ { id: 'c1', name: 'step1', args: {}, type: 'tool_call' as const },
204
+ ],
205
+ }),
206
+ new ToolMessage({ content: 'r1', tool_call_id: 'c1' }),
207
+ new AIMessage({
208
+ content: '',
209
+ tool_calls: [
210
+ { id: 'c2', name: 'step2', args: {}, type: 'tool_call' as const },
211
+ ],
212
+ }),
213
+ new ToolMessage({ content: 'r2', tool_call_id: 'c2' }),
214
+ new AIMessage({
215
+ content: '',
216
+ tool_calls: [
217
+ { id: 'c3', name: 'step3', args: {}, type: 'tool_call' as const },
218
+ ],
219
+ }),
220
+ new ToolMessage({ content: 'r3', tool_call_id: 'c3' }),
221
+ ];
222
+
223
+ const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
224
+
225
+ expect(result).toHaveLength(7);
226
+ expect(result[1]).toBeInstanceOf(AIMessage);
227
+ expect(result[3]).toBeInstanceOf(AIMessage);
228
+ expect(result[5]).toBeInstanceOf(AIMessage);
229
+ });
230
+
231
+ test('should still convert non-thinking agent tool calls after a human message boundary', () => {
232
+ // A chain with thinking, then a new human message, then a chain WITHOUT thinking
233
+ const messages = [
234
+ new HumanMessage({ content: 'first request' }),
235
+ new AIMessage({
236
+ content: [
237
+ {
238
+ type: ContentTypes.REASONING_CONTENT,
239
+ reasoningText: { text: 'Thinking...' },
240
+ },
241
+ ],
242
+ tool_calls: [
243
+ { id: 'c1', name: 'tool1', args: {}, type: 'tool_call' as const },
244
+ ],
245
+ }),
246
+ new ToolMessage({ content: 'r1', tool_call_id: 'c1' }),
247
+ new HumanMessage({ content: 'second request' }),
248
+ // This chain has NO thinking blocks — should be converted
249
+ new AIMessage({
250
+ content: 'Using a tool',
251
+ tool_calls: [
252
+ { id: 'c2', name: 'tool2', args: {}, type: 'tool_call' as const },
253
+ ],
254
+ }),
255
+ new ToolMessage({ content: 'r2', tool_call_id: 'c2' }),
256
+ ];
257
+
258
+ const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
259
+
260
+ // First chain preserved (3 msgs), human preserved, second chain converted (1 HumanMessage)
261
+ expect(result).toHaveLength(5);
262
+ expect(result[0]).toBeInstanceOf(HumanMessage);
263
+ expect(result[1]).toBeInstanceOf(AIMessage); // reasoning chain — kept
264
+ expect(result[2]).toBeInstanceOf(ToolMessage);
265
+ expect(result[3]).toBeInstanceOf(HumanMessage); // user message
266
+ expect(result[4]).toBeInstanceOf(HumanMessage); // converted — no thinking in this chain
267
+ expect(result[4].content).toContain('[Previous agent context]');
268
+ });
269
+
270
+ test('should detect thinking via additional_kwargs.reasoning_content in chain', () => {
271
+ const messages = [
272
+ new HumanMessage({ content: 'hello' }),
273
+ new AIMessage({
274
+ content: '',
275
+ additional_kwargs: {
276
+ reasoning_content: 'Some reasoning...',
277
+ },
278
+ tool_calls: [
279
+ { id: 'c1', name: 'tool1', args: {}, type: 'tool_call' as const },
280
+ ],
281
+ }),
282
+ new ToolMessage({ content: 'r1', tool_call_id: 'c1' }),
283
+ new AIMessage({
284
+ content: '',
285
+ tool_calls: [
286
+ { id: 'c2', name: 'tool2', args: {}, type: 'tool_call' as const },
287
+ ],
288
+ }),
289
+ new ToolMessage({ content: 'r2', tool_call_id: 'c2' }),
290
+ ];
291
+
292
+ const result = ensureThinkingBlockInMessages(messages, Providers.BEDROCK);
293
+
294
+ // Index 3 should NOT be converted — index 1 has reasoning in additional_kwargs
295
+ expect(result).toHaveLength(5);
296
+ expect(result[3]).toBeInstanceOf(AIMessage);
297
+ });
298
+
92
299
  test('should not modify AI message with reasoning block and tool calls', () => {
93
300
  const messages = [
94
301
  new HumanMessage({ content: 'Calculate something' }),
@@ -1011,7 +1011,12 @@ export function ensureThinkingBlockInMessages(
1011
1011
 
1012
1012
  while (i < messages.length) {
1013
1013
  const msg = messages[i];
1014
- const isAI = msg instanceof AIMessage || msg instanceof AIMessageChunk;
1014
+ /** Detect AI messages by instanceof OR by role, in case cache-control cloning
1015
+ produced a plain object that lost the LangChain prototype. */
1016
+ const isAI =
1017
+ msg instanceof AIMessage ||
1018
+ msg instanceof AIMessageChunk ||
1019
+ ('role' in msg && (msg as any).role === 'assistant');
1015
1020
 
1016
1021
  if (!isAI) {
1017
1022
  result.push(msg);
@@ -1025,30 +1030,58 @@ export function ensureThinkingBlockInMessages(
1025
1030
 
1026
1031
  // Check if the message has tool calls or tool_use content
1027
1032
  let hasToolUse = hasToolCalls ?? false;
1028
- let firstContentType: string | undefined;
1033
+ let hasThinkingBlock = false;
1029
1034
 
1030
1035
  if (contentIsArray && aiMsg.content.length > 0) {
1031
1036
  const content = aiMsg.content as ExtendedMessageContent[];
1032
- firstContentType = content[0]?.type;
1033
1037
  hasToolUse =
1034
1038
  hasToolUse ||
1035
1039
  content.some((c) => typeof c === 'object' && c.type === 'tool_use');
1040
+ // Check ALL content blocks for thinking/reasoning, not just [0].
1041
+ // Bedrock may emit a whitespace text chunk before the thinking block,
1042
+ // pushing the reasoning_content to index 1+.
1043
+ hasThinkingBlock = content.some(
1044
+ (c) =>
1045
+ typeof c === 'object' &&
1046
+ (c.type === ContentTypes.THINKING ||
1047
+ c.type === ContentTypes.REASONING_CONTENT ||
1048
+ c.type === ContentTypes.REASONING ||
1049
+ c.type === 'redacted_thinking')
1050
+ );
1036
1051
  }
1037
1052
 
1038
- // If message has tool use but no thinking block, convert to buffer string
1053
+ // Bedrock also stores reasoning in additional_kwargs (may not be in content array)
1039
1054
  if (
1040
- hasToolUse &&
1041
- firstContentType !== ContentTypes.THINKING &&
1042
- firstContentType !== ContentTypes.REASONING_CONTENT &&
1043
- firstContentType !== ContentTypes.REASONING &&
1044
- firstContentType !== 'redacted_thinking'
1055
+ !hasThinkingBlock &&
1056
+ aiMsg.additional_kwargs.reasoning_content != null
1045
1057
  ) {
1058
+ hasThinkingBlock = true;
1059
+ }
1060
+
1061
+ // If message has tool use but no thinking block, check whether this is a
1062
+ // continuation of a thinking-enabled agent's chain before converting.
1063
+ // Bedrock reasoning models can produce multiple AI→Tool rounds after an
1064
+ // initial reasoning response: the first AI message has reasoning_content,
1065
+ // but follow-ups have content: "" with only tool_calls. These are the
1066
+ // same agent's turn and must NOT be converted to HumanMessages.
1067
+ if (hasToolUse && !hasThinkingBlock) {
1068
+ // Walk backwards — if an earlier AI message in the same chain (before
1069
+ // the nearest HumanMessage) has a thinking/reasoning block, this is a
1070
+ // continuation of a thinking-enabled turn, not a non-thinking handoff.
1071
+ if (chainHasThinkingBlock(messages, i)) {
1072
+ result.push(msg);
1073
+ i++;
1074
+ continue;
1075
+ }
1076
+
1046
1077
  // Collect the AI message and any following tool messages
1047
1078
  const toolSequence: BaseMessage[] = [msg];
1048
1079
  let j = i + 1;
1049
1080
 
1050
1081
  // Look ahead for tool messages that belong to this AI message
1051
- while (j < messages.length && messages[j] instanceof ToolMessage) {
1082
+ const isToolMsg = (m: BaseMessage): boolean =>
1083
+ m instanceof ToolMessage || ('role' in m && (m as any).role === 'tool');
1084
+ while (j < messages.length && isToolMsg(messages[j])) {
1052
1085
  toolSequence.push(messages[j]);
1053
1086
  j++;
1054
1087
  }
@@ -1073,3 +1106,66 @@ export function ensureThinkingBlockInMessages(
1073
1106
 
1074
1107
  return result;
1075
1108
  }
1109
+
1110
+ /**
1111
+ * Walks backwards from `currentIndex` through the message array to check
1112
+ * whether an earlier AI message in the same "chain" (no HumanMessage boundary)
1113
+ * contains a thinking/reasoning block.
1114
+ *
1115
+ * A "chain" is a contiguous sequence of AI + Tool messages with no intervening
1116
+ * HumanMessage. Bedrock reasoning models produce reasoning on the first AI
1117
+ * response, then issue follow-up tool calls with `content: ""` and no
1118
+ * reasoning block. These follow-ups are part of the same thinking-enabled
1119
+ * turn and should not be converted.
1120
+ */
1121
+ function chainHasThinkingBlock(
1122
+ messages: BaseMessage[],
1123
+ currentIndex: number
1124
+ ): boolean {
1125
+ for (let k = currentIndex - 1; k >= 0; k--) {
1126
+ const prev = messages[k];
1127
+
1128
+ // HumanMessage = turn boundary — stop searching
1129
+ if (
1130
+ prev instanceof HumanMessage ||
1131
+ ('role' in prev && (prev as any).role === 'user')
1132
+ ) {
1133
+ return false;
1134
+ }
1135
+
1136
+ // Check AI messages for thinking/reasoning blocks
1137
+ const isPrevAI =
1138
+ prev instanceof AIMessage ||
1139
+ prev instanceof AIMessageChunk ||
1140
+ ('role' in prev && (prev as any).role === 'assistant');
1141
+
1142
+ if (isPrevAI) {
1143
+ const prevAiMsg = prev as AIMessage | AIMessageChunk;
1144
+
1145
+ if (Array.isArray(prevAiMsg.content) && prevAiMsg.content.length > 0) {
1146
+ const content = prevAiMsg.content as ExtendedMessageContent[];
1147
+ if (
1148
+ content.some(
1149
+ (c) =>
1150
+ typeof c === 'object' &&
1151
+ (c.type === ContentTypes.THINKING ||
1152
+ c.type === ContentTypes.REASONING_CONTENT ||
1153
+ c.type === ContentTypes.REASONING ||
1154
+ c.type === 'redacted_thinking')
1155
+ )
1156
+ ) {
1157
+ return true;
1158
+ }
1159
+ }
1160
+
1161
+ // Bedrock also stores reasoning in additional_kwargs
1162
+ if (prevAiMsg.additional_kwargs.reasoning_content != null) {
1163
+ return true;
1164
+ }
1165
+ }
1166
+
1167
+ // ToolMessages are part of the chain — keep walking back
1168
+ }
1169
+
1170
+ return false;
1171
+ }
@@ -687,9 +687,23 @@ function getBaseToolName(toolName: string): string {
687
687
  return toolName.substring(0, delimiterIndex);
688
688
  }
689
689
 
690
+ /**
691
+ * Checks whether a tool has any defined parameters in its JSON schema.
692
+ * @param parameters - The tool's JSON schema parameters
693
+ * @returns true if the tool has at least one parameter property
694
+ */
695
+ function hasParams(parameters?: t.JsonSchemaType): boolean {
696
+ return (
697
+ parameters?.properties != null &&
698
+ Object.keys(parameters.properties).length > 0
699
+ );
700
+ }
701
+
690
702
  /**
691
703
  * Generates a compact listing of deferred tools grouped by server.
692
- * Format: "server: tool1, tool2, tool3"
704
+ * Format: "server: tool1, tool2(\u2026), tool3"
705
+ * Tools with parameters are annotated with (\u2026) to signal
706
+ * that the LLM should discover the schema via tool_search before calling.
693
707
  * Non-MCP tools are grouped under "other".
694
708
  * @param toolRegistry - The tool registry
695
709
  * @param onlyDeferred - Whether to only include deferred tools
@@ -713,11 +727,14 @@ function getDeferredToolsListing(
713
727
  const toolName = lcTool.name;
714
728
  const serverName = extractMcpServerName(toolName) ?? 'other';
715
729
  const baseName = getBaseToolName(toolName);
730
+ const displayName = hasParams(lcTool.parameters)
731
+ ? `${baseName}(\u2026)`
732
+ : baseName;
716
733
 
717
734
  if (!(serverName in toolsByServer)) {
718
735
  toolsByServer[serverName] = [];
719
736
  }
720
- toolsByServer[serverName].push(baseName);
737
+ toolsByServer[serverName].push(displayName);
721
738
  }
722
739
 
723
740
  const serverNames = Object.keys(toolsByServer).sort((a, b) => {
@@ -862,7 +879,7 @@ function createToolSearch(
862
879
  deferredToolsListing.length > 0
863
880
  ? `
864
881
 
865
- Deferred tools (search to load):
882
+ Deferred tools (search to load; \u2026 = has params, search first):
866
883
  ${deferredToolsListing}`
867
884
  : '';
868
885
 
@@ -664,6 +664,11 @@ describe('ToolSearch', () => {
664
664
  name: 'get_weather_mcp_weather-api',
665
665
  description: 'Get weather',
666
666
  defer_loading: true,
667
+ parameters: {
668
+ type: 'object',
669
+ properties: { location: { type: 'string' } },
670
+ required: ['location'],
671
+ },
667
672
  });
668
673
  registry.set('get_forecast_mcp_weather-api', {
669
674
  name: 'get_forecast_mcp_weather-api',
@@ -674,6 +679,11 @@ describe('ToolSearch', () => {
674
679
  name: 'send_email_mcp_gmail',
675
680
  description: 'Send email',
676
681
  defer_loading: true,
682
+ parameters: {
683
+ type: 'object',
684
+ properties: { to: { type: 'string' }, body: { type: 'string' } },
685
+ required: ['to', 'body'],
686
+ },
677
687
  });
678
688
  registry.set('execute_code', {
679
689
  name: 'execute_code',
@@ -692,8 +702,10 @@ describe('ToolSearch', () => {
692
702
  const registry = createRegistry();
693
703
  const listing = getDeferredToolsListing(registry, true);
694
704
 
695
- expect(listing).toContain('gmail: send_email');
696
- expect(listing).toContain('weather-api: get_weather, get_forecast');
705
+ expect(listing).toContain('gmail: send_email(\u2026)');
706
+ expect(listing).toContain(
707
+ 'weather-api: get_weather(\u2026), get_forecast'
708
+ );
697
709
  expect(listing).toContain('other: execute_code');
698
710
  });
699
711
 
@@ -715,6 +727,54 @@ describe('ToolSearch', () => {
715
727
  expect(listing).not.toContain('get_weather_mcp_weather-api');
716
728
  });
717
729
 
730
+ it('annotates tools with any params with ellipsis', () => {
731
+ const registry = createRegistry();
732
+ const listing = getDeferredToolsListing(registry, true);
733
+
734
+ // Tools with params get (…)
735
+ expect(listing).toContain('get_weather(\u2026)');
736
+ expect(listing).toContain('send_email(\u2026)');
737
+
738
+ // Tools without params stay clean
739
+ expect(listing).toContain('get_forecast');
740
+ expect(listing).not.toContain('get_forecast(\u2026)');
741
+ expect(listing).toContain('execute_code');
742
+ expect(listing).not.toContain('execute_code(\u2026)');
743
+ });
744
+
745
+ it('annotates tools with optional-only params', () => {
746
+ const registry: LCToolRegistry = new Map();
747
+ registry.set('list_items_mcp_api', {
748
+ name: 'list_items_mcp_api',
749
+ description: 'List items',
750
+ defer_loading: true,
751
+ parameters: {
752
+ type: 'object',
753
+ properties: { limit: { type: 'integer' } },
754
+ required: [],
755
+ },
756
+ });
757
+
758
+ const listing = getDeferredToolsListing(registry, true);
759
+ expect(listing).toBe('api: list_items(\u2026)');
760
+ });
761
+
762
+ it('does not annotate tools with empty properties', () => {
763
+ const registry: LCToolRegistry = new Map();
764
+ registry.set('ping_mcp_api', {
765
+ name: 'ping_mcp_api',
766
+ description: 'Ping',
767
+ defer_loading: true,
768
+ parameters: {
769
+ type: 'object',
770
+ properties: {},
771
+ },
772
+ });
773
+
774
+ const listing = getDeferredToolsListing(registry, true);
775
+ expect(listing).toBe('api: ping');
776
+ });
777
+
718
778
  it('respects onlyDeferred flag', () => {
719
779
  const registry = createRegistry();
720
780