@librechat/agents 3.2.62 → 3.2.64

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.
@@ -15,7 +15,10 @@ type ConverseResult = ReturnType<typeof convertToConverseMessages>;
15
15
  /** Minimal view of a converted Bedrock Converse content block the assertions read. */
16
16
  interface ConverseBlock {
17
17
  text?: string;
18
- reasoningContent?: { reasoningText?: { text?: string; signature?: string } };
18
+ reasoningContent?: {
19
+ reasoningText?: { text?: string; signature?: string };
20
+ redactedContent?: Uint8Array;
21
+ };
19
22
  toolUse?: {
20
23
  toolUseId?: string;
21
24
  name?: string;
@@ -127,3 +130,275 @@ describe('convertToConverseMessages — native Bedrock reasoning serialization',
127
130
  );
128
131
  });
129
132
  });
133
+
134
+ /**
135
+ * Same failure class, v1 converter path. Assistant messages carrying
136
+ * `response_metadata.output_version === 'v1'` are converted by
137
+ * `convertFromV1ToChatBedrockConverseMessage`, which serialized `reasoning` /
138
+ * `reasoning_content` blocks without the null/empty-text guard applied to the
139
+ * non-v1 path — a `reasoning` block whose `reasoning` is null/empty (e.g. a
140
+ * model responding with `thinking.display: "omitted"`, the Opus 4.7+ /
141
+ * Sonnet 5 default) reached Bedrock as `reasoningText: { text: null }` and the
142
+ * whole request was rejected with `Member must not be null`.
143
+ */
144
+ describe('convertToConverseMessages — v1 reasoning serialization', () => {
145
+ const v1Metadata = {
146
+ output_version: 'v1',
147
+ model_provider: 'anthropic',
148
+ } as const;
149
+
150
+ it('drops a v1 reasoning block whose reasoning text is missing, keeping text and tool calls', () => {
151
+ const messages: BaseMessage[] = [
152
+ new HumanMessage('what data do you have?'),
153
+ new AIMessage({
154
+ content: [
155
+ { type: 'reasoning', reasoning: undefined } as never,
156
+ { type: 'text', text: 'Let me check your databases.' },
157
+ ],
158
+ tool_calls: [
159
+ {
160
+ id: 'tooluse_list',
161
+ name: 'list_databases',
162
+ args: {},
163
+ type: 'tool_call',
164
+ },
165
+ ],
166
+ response_metadata: v1Metadata,
167
+ }),
168
+ ];
169
+
170
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
171
+ const content = assistantContent(convertToConverseMessages(messages));
172
+
173
+ expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
174
+ expect(content.some((b) => b.text === 'Let me check your databases.')).toBe(
175
+ true
176
+ );
177
+ const toolUse = content.find((b) => b.toolUse != null);
178
+ expect(toolUse?.toolUse).toMatchObject({
179
+ toolUseId: 'tooluse_list',
180
+ name: 'list_databases',
181
+ });
182
+ });
183
+
184
+ it('drops a v1 reasoning block whose reasoning text is empty', () => {
185
+ const messages: BaseMessage[] = [
186
+ new HumanMessage('hi'),
187
+ new AIMessage({
188
+ content: [
189
+ { type: 'reasoning', reasoning: '' },
190
+ { type: 'text', text: 'answer' },
191
+ ],
192
+ response_metadata: v1Metadata,
193
+ }),
194
+ ];
195
+
196
+ const content = assistantContent(convertToConverseMessages(messages));
197
+ expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
198
+ expect(content.some((b) => b.text === 'answer')).toBe(true);
199
+ });
200
+
201
+ it('drops a v1 signature-only reasoning_content block', () => {
202
+ const messages: BaseMessage[] = [
203
+ new HumanMessage('hi'),
204
+ new AIMessage({
205
+ content: [
206
+ {
207
+ type: 'reasoning_content',
208
+ reasoningText: { signature: 'sig-abc' },
209
+ },
210
+ { type: 'text', text: 'answer' },
211
+ ],
212
+ response_metadata: v1Metadata,
213
+ }),
214
+ ];
215
+
216
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
217
+ const content = assistantContent(convertToConverseMessages(messages));
218
+ expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
219
+ expect(JSON.stringify(content)).not.toContain('sig-abc');
220
+ expect(content.some((b) => b.text === 'answer')).toBe(true);
221
+ });
222
+
223
+ it('emits a placeholder (not empty content) when dropping empties a v1 turn', () => {
224
+ const messages: BaseMessage[] = [
225
+ new HumanMessage('hi'),
226
+ new AIMessage({
227
+ content: [{ type: 'reasoning', reasoning: undefined } as never],
228
+ response_metadata: v1Metadata,
229
+ }),
230
+ ];
231
+
232
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
233
+ const content = assistantContent(convertToConverseMessages(messages));
234
+ expect(content.length).toBeGreaterThan(0);
235
+ expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
236
+ expect(content.every((b) => typeof b.text === 'string')).toBe(true);
237
+ });
238
+
239
+ it.each(['', ' \n '])(
240
+ 'emits a placeholder for invalid v1 text %j',
241
+ (text) => {
242
+ const messages: BaseMessage[] = [
243
+ new HumanMessage('hi'),
244
+ new AIMessage({
245
+ content: [{ type: 'text', text }],
246
+ response_metadata: v1Metadata,
247
+ }),
248
+ ];
249
+
250
+ const content = assistantContent(convertToConverseMessages(messages));
251
+ expect(content).toEqual([{ text: '_' }]);
252
+ }
253
+ );
254
+
255
+ it('merges whitespace-only v1 text into the preceding text block', () => {
256
+ const messages: BaseMessage[] = [
257
+ new HumanMessage('hi'),
258
+ new AIMessage({
259
+ content: [
260
+ { type: 'text', text: 'answer' },
261
+ { type: 'text', text: ' \n ' },
262
+ ],
263
+ response_metadata: v1Metadata,
264
+ }),
265
+ ];
266
+
267
+ const content = assistantContent(convertToConverseMessages(messages));
268
+ expect(content).toEqual([{ text: 'answer \n ' }]);
269
+ });
270
+
271
+ it('merges split v1 reasoning_content text and signature blocks before serialization', () => {
272
+ const splitContent = [
273
+ {
274
+ type: 'reasoning_content',
275
+ reasoningText: { text: 'first ' },
276
+ },
277
+ {
278
+ type: 'reasoning_content',
279
+ reasoningText: { text: 'second' },
280
+ },
281
+ {
282
+ type: 'reasoning_content',
283
+ reasoningText: { signature: 'sig-abc' },
284
+ },
285
+ { type: 'text', text: 'answer' },
286
+ ];
287
+ const originalContent = structuredClone(splitContent);
288
+ const messages: BaseMessage[] = [
289
+ new HumanMessage('hi'),
290
+ new AIMessage({
291
+ content: splitContent,
292
+ response_metadata: v1Metadata,
293
+ }),
294
+ ];
295
+
296
+ const content = assistantContent(convertToConverseMessages(messages));
297
+ const reasoning = content.filter((b) => b.reasoningContent != null);
298
+ expect(reasoning).toHaveLength(1);
299
+ expect(reasoning[0].reasoningContent?.reasoningText).toEqual({
300
+ text: 'first second',
301
+ signature: 'sig-abc',
302
+ });
303
+ expect(splitContent).toEqual(originalContent);
304
+ });
305
+
306
+ it('keeps independently signed v1 reasoning blocks separate', () => {
307
+ const messages: BaseMessage[] = [
308
+ new HumanMessage('hi'),
309
+ new AIMessage({
310
+ content: [
311
+ {
312
+ type: 'reasoning_content',
313
+ reasoningText: { text: 'first', signature: 'sig-first' },
314
+ },
315
+ {
316
+ type: 'reasoning_content',
317
+ reasoningText: { text: 'second', signature: 'sig-second' },
318
+ },
319
+ { type: 'text', text: 'answer' },
320
+ ],
321
+ response_metadata: v1Metadata,
322
+ }),
323
+ ];
324
+
325
+ const content = assistantContent(convertToConverseMessages(messages));
326
+ expect(
327
+ content
328
+ .filter((block) => block.reasoningContent != null)
329
+ .map((block) => block.reasoningContent?.reasoningText)
330
+ ).toEqual([
331
+ { text: 'first', signature: 'sig-first' },
332
+ { text: 'second', signature: 'sig-second' },
333
+ ]);
334
+ });
335
+
336
+ it('keeps adjacent redacted v1 reasoning payloads separate', () => {
337
+ const messages: BaseMessage[] = [
338
+ new HumanMessage('hi'),
339
+ new AIMessage({
340
+ content: [
341
+ { type: 'reasoning_content', redactedContent: 'YQ==' },
342
+ { type: 'reasoning_content', redactedContent: 'Yg==' },
343
+ { type: 'text', text: 'answer' },
344
+ ],
345
+ response_metadata: v1Metadata,
346
+ }),
347
+ ];
348
+
349
+ const content = assistantContent(convertToConverseMessages(messages));
350
+ const redacted = content
351
+ .map((block) => block.reasoningContent?.redactedContent)
352
+ .filter((value): value is Uint8Array => value != null)
353
+ .map((value) => Buffer.from(value).toString('utf8'));
354
+ expect(redacted).toEqual(['a', 'b']);
355
+ });
356
+
357
+ it('throws instead of returning empty assistant content for an unhandled v1 block', () => {
358
+ const messages: BaseMessage[] = [
359
+ new HumanMessage('hi'),
360
+ new AIMessage({
361
+ content: [
362
+ {
363
+ type: 'image',
364
+ source_type: 'base64',
365
+ data: 'aGVsbG8=',
366
+ mime_type: 'image/png',
367
+ } as never,
368
+ ],
369
+ response_metadata: v1Metadata,
370
+ }),
371
+ ];
372
+
373
+ expect(() => convertToConverseMessages(messages)).toThrow(
374
+ 'Unsupported v1 content block type: image'
375
+ );
376
+ });
377
+
378
+ it('still converts v1 reasoning and reasoning_content blocks that carry text', () => {
379
+ const messages: BaseMessage[] = [
380
+ new HumanMessage('hi'),
381
+ new AIMessage({
382
+ content: [
383
+ { type: 'reasoning', reasoning: 'v1 standard reasoning' },
384
+ {
385
+ type: 'reasoning_content',
386
+ reasoningText: { text: 'native reasoning', signature: 'sig' },
387
+ },
388
+ { type: 'text', text: 'answer' },
389
+ ],
390
+ response_metadata: v1Metadata,
391
+ }),
392
+ ];
393
+
394
+ const content = assistantContent(convertToConverseMessages(messages));
395
+ const reasoningTexts = content
396
+ .filter((b) => b.reasoningContent != null)
397
+ .map((b) => b.reasoningContent?.reasoningText?.text);
398
+ expect(reasoningTexts).toEqual([
399
+ 'v1 standard reasoning',
400
+ 'native reasoning',
401
+ ]);
402
+ expect(content.some((b) => b.text === 'answer')).toBe(true);
403
+ });
404
+ });
@@ -88,6 +88,29 @@ function isSerializableBedrockReasoningBlock(
88
88
  return content.redactedContent != null && content.redactedContent !== '';
89
89
  }
90
90
 
91
+ function appendSerializableBedrockTextBlock(
92
+ contentBlocks: BedrockContentBlock[],
93
+ text: string
94
+ ): boolean {
95
+ if (text === '') {
96
+ return false;
97
+ }
98
+ const cleanedText = text.replace(/\n/g, '').trim();
99
+ if (cleanedText !== '') {
100
+ contentBlocks.push({ text });
101
+ return true;
102
+ }
103
+ const lastBlock = contentBlocks[contentBlocks.length - 1] as
104
+ | BedrockContentBlock
105
+ | undefined;
106
+ if (lastBlock == null || !('text' in lastBlock)) {
107
+ return false;
108
+ }
109
+ const mergedTextContent = `${lastBlock.text}${text}`;
110
+ (lastBlock as { text: string }).text = mergedTextContent;
111
+ return true;
112
+ }
113
+
91
114
  /**
92
115
  * Concatenate consecutive reasoning blocks in content array.
93
116
  */
@@ -96,48 +119,57 @@ export function concatenateLangchainReasoningBlocks(
96
119
  ): Array<MessageContentComplex | MessageContentReasoningBlock> {
97
120
  const result: Array<MessageContentComplex | MessageContentReasoningBlock> =
98
121
  [];
122
+ let pendingReasoning: MessageContentReasoningBlock | undefined;
123
+
124
+ const flushPendingReasoning = (): void => {
125
+ if (pendingReasoning != null) {
126
+ result.push(pendingReasoning);
127
+ pendingReasoning = undefined;
128
+ }
129
+ };
99
130
 
100
131
  for (const block of content) {
101
- if (block.type === 'reasoning_content') {
102
- const currentReasoning = block as MessageContentReasoningBlock;
103
- const lastIndex = result.length - 1;
104
-
105
- // Check if we can merge with the previous block
106
- if (lastIndex >= 0) {
107
- const lastBlock = result[lastIndex];
108
- if (
109
- lastBlock.type === 'reasoning_content' &&
110
- (lastBlock as MessageContentReasoningBlock).reasoningText != null &&
111
- currentReasoning.reasoningText != null
112
- ) {
113
- const lastReasoning = lastBlock as MessageContentReasoningBlock;
114
- // Merge consecutive reasoning text blocks
115
- const lastText = lastReasoning.reasoningText?.text;
116
- const currentText = currentReasoning.reasoningText.text;
117
- if (
118
- lastText != null &&
119
- lastText !== '' &&
120
- currentText != null &&
121
- currentText !== ''
122
- ) {
123
- lastReasoning.reasoningText!.text = lastText + currentText;
124
- } else if (
125
- currentReasoning.reasoningText.signature != null &&
126
- currentReasoning.reasoningText.signature !== ''
127
- ) {
128
- lastReasoning.reasoningText!.signature =
129
- currentReasoning.reasoningText.signature;
130
- }
131
- continue;
132
- }
132
+ if (block.type !== 'reasoning_content') {
133
+ flushPendingReasoning();
134
+ result.push(block);
135
+ continue;
136
+ }
137
+
138
+ const currentReasoning = block as MessageContentReasoningBlock;
139
+ if (currentReasoning.reasoningText != null) {
140
+ const previousText = pendingReasoning?.reasoningText?.text;
141
+ const previousSignature = pendingReasoning?.reasoningText?.signature;
142
+ const { text, signature } = currentReasoning.reasoningText;
143
+ const mergedReasoningText: { text?: string; signature?: string } = {};
144
+ if (previousText !== undefined || text !== undefined) {
145
+ mergedReasoningText.text = (previousText ?? '') + (text ?? '');
146
+ }
147
+ if (previousSignature !== undefined || signature !== undefined) {
148
+ mergedReasoningText.signature =
149
+ (previousSignature ?? '') + (signature ?? '');
133
150
  }
151
+ pendingReasoning = {
152
+ type: 'reasoning_content',
153
+ reasoningText: mergedReasoningText,
154
+ };
134
155
 
135
- result.push({ ...block } as MessageContentReasoningBlock);
136
- } else {
137
- result.push(block);
156
+ // A signature seals the accumulated reasoning text. Any following
157
+ // reasoning block starts a new independently signed Bedrock block.
158
+ if ('signature' in currentReasoning.reasoningText) {
159
+ flushPendingReasoning();
160
+ }
161
+ }
162
+
163
+ if (currentReasoning.redactedContent != null) {
164
+ flushPendingReasoning();
165
+ result.push({
166
+ type: 'reasoning_content',
167
+ redactedContent: currentReasoning.redactedContent,
168
+ });
138
169
  }
139
170
  }
140
171
 
172
+ flushPendingReasoning();
141
173
  return result;
142
174
  }
143
175
 
@@ -666,23 +698,7 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
666
698
  concatenatedBlocks.forEach((block) => {
667
699
  if (block.type === 'text') {
668
700
  const text = (block as { text?: string }).text ?? '';
669
- // Skip completely empty text blocks (common in AI messages with tool_use blocks)
670
- if (text === '') {
671
- return;
672
- }
673
- // Merge whitespace/newlines with previous text blocks to avoid validation errors.
674
- const cleanedText = text.replace(/\n/g, '').trim();
675
- if (cleanedText === '') {
676
- if (contentBlocks.length > 0) {
677
- const lastBlock = contentBlocks[contentBlocks.length - 1];
678
- if ('text' in lastBlock) {
679
- const mergedTextContent = `${lastBlock.text}${text}`;
680
- (lastBlock as { text: string }).text = mergedTextContent;
681
- }
682
- }
683
- } else {
684
- contentBlocks.push({ text });
685
- }
701
+ appendSerializableBedrockTextBlock(contentBlocks, text);
686
702
  } else if (block.type === 'reasoning_content') {
687
703
  const reasoningBlock = block as MessageContentReasoningBlock;
688
704
  // Bedrock Converse rejects reasoningContent whose reasoningText.text is
@@ -757,26 +773,35 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
757
773
  function convertFromV1ToChatBedrockConverseMessage(
758
774
  msg: BaseMessage
759
775
  ): BedrockMessage {
776
+ const contentBlocks: BedrockContentBlock[] = [];
760
777
  const assistantMsg: BedrockMessage = {
761
778
  role: 'assistant',
762
- content: [],
779
+ content: contentBlocks,
763
780
  };
781
+ let droppedUnserializableContent = false;
782
+ let unconvertedContentType: string | undefined;
764
783
 
765
784
  if (Array.isArray(msg.content)) {
766
- for (const block of msg.content as Array<
767
- MessageContentComplex | MessageContentReasoningBlock
768
- >) {
785
+ const concatenatedBlocks = concatenateLangchainReasoningBlocks(
786
+ msg.content as Array<MessageContentComplex | MessageContentReasoningBlock>
787
+ );
788
+ for (const block of concatenatedBlocks) {
769
789
  if (typeof block === 'string') {
770
- assistantMsg.content?.push({ text: block });
790
+ if (!appendSerializableBedrockTextBlock(contentBlocks, block)) {
791
+ droppedUnserializableContent = true;
792
+ }
771
793
  } else if (block.type === 'text') {
772
- assistantMsg.content?.push({ text: (block as { text: string }).text });
794
+ const text = (block as { text?: string }).text ?? '';
795
+ if (!appendSerializableBedrockTextBlock(contentBlocks, text)) {
796
+ droppedUnserializableContent = true;
797
+ }
773
798
  } else if (block.type === 'tool_call') {
774
799
  const toolCall = block as {
775
800
  id: string;
776
801
  name: string;
777
802
  args: Record<string, unknown>;
778
803
  };
779
- assistantMsg.content?.push({
804
+ contentBlocks.push({
780
805
  toolUse: {
781
806
  toolUseId: toolCall.id,
782
807
  name: toolCall.name,
@@ -784,38 +809,51 @@ function convertFromV1ToChatBedrockConverseMessage(
784
809
  },
785
810
  } as BedrockContentBlock);
786
811
  } else if (block.type === 'reasoning') {
787
- const reasoning = block as { reasoning: string };
788
- assistantMsg.content?.push({
812
+ const reasoning = block as { reasoning?: string };
813
+ /** Bedrock Converse rejects `reasoningText` with a null/empty `text`
814
+ * (`Member must not be null`), e.g. when the producing model omitted
815
+ * reasoning text (`thinking.display: "omitted"`) — drop rather than send. */
816
+ if (reasoning.reasoning == null || reasoning.reasoning === '') {
817
+ droppedUnserializableContent = true;
818
+ continue;
819
+ }
820
+ contentBlocks.push({
789
821
  reasoningContent: {
790
822
  reasoningText: { text: reasoning.reasoning },
791
823
  },
792
824
  } as BedrockContentBlock);
793
825
  } else if (block.type === 'reasoning_content') {
794
- assistantMsg.content?.push({
795
- reasoningContent: langchainReasoningBlockToBedrockReasoningBlock(
796
- block as MessageContentReasoningBlock
797
- ),
826
+ const reasoningBlock = block as MessageContentReasoningBlock;
827
+ if (!isSerializableBedrockReasoningBlock(reasoningBlock)) {
828
+ droppedUnserializableContent = true;
829
+ continue;
830
+ }
831
+ contentBlocks.push({
832
+ reasoningContent:
833
+ langchainReasoningBlockToBedrockReasoningBlock(reasoningBlock),
798
834
  } as BedrockContentBlock);
835
+ } else {
836
+ unconvertedContentType ??= block.type;
799
837
  }
800
838
  }
801
- } else if (typeof msg.content === 'string' && msg.content !== '') {
802
- assistantMsg.content?.push({ text: msg.content });
839
+ } else if (typeof msg.content === 'string') {
840
+ if (!appendSerializableBedrockTextBlock(contentBlocks, msg.content)) {
841
+ droppedUnserializableContent = true;
842
+ }
803
843
  }
804
844
 
805
845
  // Also handle tool_calls from the message
806
846
  if (isAIMessage(msg) && msg.tool_calls != null && msg.tool_calls.length > 0) {
807
847
  // Check if tool calls are already in content
808
848
  const existingToolUseIds = new Set(
809
- assistantMsg.content
810
- ?.filter((c) => 'toolUse' in c)
811
- .map(
812
- (c) => (c as { toolUse: { toolUseId: string } }).toolUse.toolUseId
813
- ) ?? []
849
+ contentBlocks
850
+ .filter((c) => 'toolUse' in c)
851
+ .map((c) => (c as { toolUse: { toolUseId: string } }).toolUse.toolUseId)
814
852
  );
815
853
 
816
854
  for (const tc of msg.tool_calls) {
817
855
  if (!existingToolUseIds.has(tc.id ?? '')) {
818
- assistantMsg.content?.push({
856
+ contentBlocks.push({
819
857
  toolUse: {
820
858
  toolUseId: tc.id,
821
859
  name: tc.name,
@@ -826,6 +864,16 @@ function convertFromV1ToChatBedrockConverseMessage(
826
864
  }
827
865
  }
828
866
 
867
+ const hasNoContent = contentBlocks.length === 0;
868
+ if (hasNoContent && unconvertedContentType != null) {
869
+ throw new Error(
870
+ `Unsupported v1 content block type: ${unconvertedContentType}`
871
+ );
872
+ }
873
+ if (hasNoContent && droppedUnserializableContent) {
874
+ contentBlocks.push({ text: BEDROCK_EMPTY_TEXT_PLACEHOLDER });
875
+ }
876
+
829
877
  return assistantMsg;
830
878
  }
831
879
 
@@ -2746,7 +2746,11 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2746
2746
  const batchRequest: t.ToolExecuteBatchRequest = {
2747
2747
  toolCalls: dispatchRequests,
2748
2748
  userId: config.configurable?.user_id as string | undefined,
2749
- agentId: this.agentId,
2749
+ // Dispatch attribution, NOT the hook subagent-scope marker:
2750
+ // hosts key tool/credential lookup on the owning agent, and
2751
+ // the eager path sends `agentContext.agentId` — this must
2752
+ // match it at the top level too.
2753
+ agentId: this.executingAgentId,
2750
2754
  configurable: config.configurable as
2751
2755
  | Record<string, unknown>
2752
2756
  | undefined,