@librechat/agents 3.1.41 → 3.1.43

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
+ }