@librechat/agents 3.2.35 → 3.2.36

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.
Files changed (66) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +74 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/agents/projection.cjs +25 -0
  4. package/dist/cjs/agents/projection.cjs.map +1 -0
  5. package/dist/cjs/graphs/Graph.cjs +3 -18
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +26 -4
  8. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +20 -0
  10. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/main.cjs +5 -0
  12. package/dist/cjs/messages/budget.cjs +23 -0
  13. package/dist/cjs/messages/budget.cjs.map +1 -0
  14. package/dist/cjs/messages/cache.cjs +1 -0
  15. package/dist/cjs/messages/cache.cjs.map +1 -1
  16. package/dist/cjs/messages/index.cjs +1 -0
  17. package/dist/cjs/tools/search/format.cjs +91 -2
  18. package/dist/cjs/tools/search/format.cjs.map +1 -1
  19. package/dist/cjs/tools/search/tool.cjs +4 -3
  20. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  21. package/dist/esm/agents/AgentContext.mjs +75 -2
  22. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  23. package/dist/esm/agents/projection.mjs +25 -0
  24. package/dist/esm/agents/projection.mjs.map +1 -0
  25. package/dist/esm/graphs/Graph.mjs +1 -16
  26. package/dist/esm/graphs/Graph.mjs.map +1 -1
  27. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +26 -4
  28. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  29. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +20 -0
  30. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  31. package/dist/esm/main.mjs +4 -2
  32. package/dist/esm/messages/budget.mjs +23 -0
  33. package/dist/esm/messages/budget.mjs.map +1 -0
  34. package/dist/esm/messages/cache.mjs +1 -1
  35. package/dist/esm/messages/cache.mjs.map +1 -1
  36. package/dist/esm/messages/index.mjs +1 -0
  37. package/dist/esm/tools/search/format.mjs +91 -2
  38. package/dist/esm/tools/search/format.mjs.map +1 -1
  39. package/dist/esm/tools/search/tool.mjs +4 -3
  40. package/dist/esm/tools/search/tool.mjs.map +1 -1
  41. package/dist/types/agents/AgentContext.d.ts +30 -1
  42. package/dist/types/agents/projection.d.ts +26 -0
  43. package/dist/types/index.d.ts +1 -0
  44. package/dist/types/messages/budget.d.ts +11 -0
  45. package/dist/types/messages/cache.d.ts +7 -0
  46. package/dist/types/messages/index.d.ts +1 -0
  47. package/dist/types/tools/search/format.d.ts +4 -1
  48. package/dist/types/tools/search/types.d.ts +7 -0
  49. package/package.json +1 -1
  50. package/src/agents/AgentContext.ts +103 -2
  51. package/src/agents/__tests__/AgentContext.test.ts +229 -0
  52. package/src/agents/__tests__/projection.test.ts +73 -0
  53. package/src/agents/projection.ts +46 -0
  54. package/src/graphs/Graph.ts +1 -29
  55. package/src/index.ts +3 -0
  56. package/src/llm/anthropic/utils/cross-provider-reasoning.test.ts +317 -0
  57. package/src/llm/anthropic/utils/message_inputs.ts +78 -16
  58. package/src/llm/bedrock/utils/cross-provider-reasoning.test.ts +131 -0
  59. package/src/llm/bedrock/utils/message_inputs.ts +35 -0
  60. package/src/messages/budget.ts +32 -0
  61. package/src/messages/cache.ts +1 -1
  62. package/src/messages/index.ts +1 -0
  63. package/src/tools/search/format.test.ts +242 -0
  64. package/src/tools/search/format.ts +122 -5
  65. package/src/tools/search/tool.ts +5 -1
  66. package/src/tools/search/types.ts +7 -0
@@ -0,0 +1,317 @@
1
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
2
+ import type { BaseMessage } from '@langchain/core/messages';
3
+ import { _convertMessagesToAnthropicPayload } from './message_inputs';
4
+
5
+ /**
6
+ * Regression for cross-provider agent handoffs (e.g. Bedrock → Anthropic): a
7
+ * Bedrock turn that used extended thinking leaves a `reasoning_content` content
8
+ * block ({ reasoningText: { text, signature } }) in the history. The official
9
+ * Anthropic converter has no branch for it and previously threw
10
+ * "Unsupported message content format", crashing the handoff. Only known
11
+ * foreign reasoning (Bedrock `reasoning_content`, Google `reasoning`, LibreChat
12
+ * `think`) is dropped; any other unknown block still throws rather than being
13
+ * silently omitted (real content — user media, Google code-execution — must be
14
+ * surfaced); and a tool call carried only on `tool_calls` survives dropping its
15
+ * reasoning sibling without being duplicated.
16
+ */
17
+ type AnthropicPayload = ReturnType<typeof _convertMessagesToAnthropicPayload>;
18
+
19
+ /** Minimal view of a converted Anthropic content block the assertions read. */
20
+ interface TestBlock {
21
+ type?: string;
22
+ text?: string;
23
+ }
24
+
25
+ const findAssistant = (payload: AnthropicPayload) =>
26
+ payload.messages.find((m) => m.role === 'assistant');
27
+
28
+ const assistantBlocks = (payload: AnthropicPayload): TestBlock[] => {
29
+ const content = findAssistant(payload)?.content;
30
+ return Array.isArray(content) ? (content as TestBlock[]) : [];
31
+ };
32
+
33
+ describe('_convertMessagesToAnthropicPayload — cross-provider reasoning blocks', () => {
34
+ const bedrockHandoffHistory = (): BaseMessage[] => [
35
+ new HumanMessage('research Assort Health'),
36
+ new AIMessage({
37
+ content: [
38
+ {
39
+ type: 'reasoning_content',
40
+ index: 0,
41
+ reasoningText: {
42
+ text: 'Let me search Notion then hand off to the data agent.',
43
+ signature: 'bedrock-signature-not-valid-for-anthropic',
44
+ },
45
+ },
46
+ { type: 'text', text: 'Kicking off the searches now.' },
47
+ {
48
+ type: 'tool_use',
49
+ id: 'tooluse_abc',
50
+ name: 'notion-search',
51
+ input: { query: 'Assort Health' },
52
+ },
53
+ ],
54
+ tool_calls: [
55
+ {
56
+ id: 'tooluse_abc',
57
+ name: 'notion-search',
58
+ args: { query: 'Assort Health' },
59
+ type: 'tool_call',
60
+ },
61
+ ],
62
+ }),
63
+ ];
64
+
65
+ it('does not throw on a Bedrock reasoning_content block', () => {
66
+ expect(() =>
67
+ _convertMessagesToAnthropicPayload(bedrockHandoffHistory())
68
+ ).not.toThrow();
69
+ });
70
+
71
+ it('drops reasoning_content (incl. its foreign signature) but keeps text and tool_use', () => {
72
+ const payload = _convertMessagesToAnthropicPayload(bedrockHandoffHistory());
73
+ expect(findAssistant(payload)).toBeDefined();
74
+ const blocks = assistantBlocks(payload);
75
+
76
+ expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
77
+ expect(
78
+ blocks.find(
79
+ (b) => b.type === 'thinking' || b.type === 'redacted_thinking'
80
+ )
81
+ ).toBeUndefined();
82
+ expect(JSON.stringify(blocks)).not.toContain(
83
+ 'bedrock-signature-not-valid-for-anthropic'
84
+ );
85
+
86
+ expect(
87
+ blocks.some(
88
+ (b) => b.type === 'text' && b.text === 'Kicking off the searches now.'
89
+ )
90
+ ).toBe(true);
91
+ expect(blocks.find((b) => b.type === 'tool_use')).toMatchObject({
92
+ type: 'tool_use',
93
+ id: 'tooluse_abc',
94
+ name: 'notion-search',
95
+ input: { query: 'Assort Health' },
96
+ });
97
+ });
98
+
99
+ it('drops a Google `reasoning` block without throwing', () => {
100
+ const history: BaseMessage[] = [
101
+ new HumanMessage('hi'),
102
+ new AIMessage({
103
+ content: [
104
+ { type: 'reasoning', reasoning: 'internal google chain of thought' },
105
+ { type: 'text', text: 'Hello!' },
106
+ ],
107
+ }),
108
+ ];
109
+ expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
110
+ const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
111
+ expect(blocks.find((b) => b.type === 'reasoning')).toBeUndefined();
112
+ expect(blocks.some((b) => b.type === 'text' && b.text === 'Hello!')).toBe(
113
+ true
114
+ );
115
+ });
116
+
117
+ it('drops a LibreChat `think` block without throwing', () => {
118
+ const history: BaseMessage[] = [
119
+ new HumanMessage('hi'),
120
+ new AIMessage({
121
+ content: [
122
+ { type: 'think', think: 'librechat serialized reasoning' },
123
+ { type: 'text', text: 'Done.' },
124
+ ],
125
+ }),
126
+ ];
127
+ expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
128
+ const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
129
+ expect(blocks.find((b) => b.type === 'think')).toBeUndefined();
130
+ expect(blocks.some((b) => b.type === 'text' && b.text === 'Done.')).toBe(
131
+ true
132
+ );
133
+ });
134
+
135
+ it('drops an unsigned `thinking` block (Google thinking-enabled output) on an assistant turn', () => {
136
+ const history: BaseMessage[] = [
137
+ new HumanMessage('hi'),
138
+ new AIMessage({
139
+ content: [
140
+ {
141
+ type: 'thinking',
142
+ thinking: 'google chain of thought, no signature',
143
+ },
144
+ { type: 'text', text: 'Answer.' },
145
+ ],
146
+ }),
147
+ ];
148
+ expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
149
+ const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
150
+ expect(blocks.find((b) => b.type === 'thinking')).toBeUndefined();
151
+ expect(blocks.some((b) => b.type === 'text' && b.text === 'Answer.')).toBe(
152
+ true
153
+ );
154
+ });
155
+
156
+ it('forwards a signed `thinking` block (Anthropic-native) unchanged', () => {
157
+ const history: BaseMessage[] = [
158
+ new HumanMessage('hi'),
159
+ new AIMessage({
160
+ content: [
161
+ {
162
+ type: 'thinking',
163
+ thinking: 'native reasoning',
164
+ signature: 'valid-sig',
165
+ },
166
+ { type: 'text', text: 'Answer.' },
167
+ ],
168
+ }),
169
+ ];
170
+ const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
171
+ expect(blocks.find((b) => b.type === 'thinking')).toMatchObject({
172
+ type: 'thinking',
173
+ thinking: 'native reasoning',
174
+ signature: 'valid-sig',
175
+ });
176
+ });
177
+
178
+ it('throws (not silently drops) on an unknown assistant block such as Google code execution', () => {
179
+ // executableCode/codeExecutionResult carry real visible content; silently
180
+ // dropping them on a Google → Anthropic handoff would lose evidence.
181
+ const history: BaseMessage[] = [
182
+ new HumanMessage('run some code'),
183
+ new AIMessage({
184
+ content: [
185
+ {
186
+ type: 'executableCode',
187
+ executableCode: { language: 'PYTHON', code: 'print(2+2)' },
188
+ },
189
+ { type: 'text', text: 'Here is the result.' },
190
+ ],
191
+ }),
192
+ ];
193
+ expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
194
+ 'Unsupported message content format'
195
+ );
196
+ });
197
+
198
+ it('throws (not silently drops) on an unsupported user block such as media', () => {
199
+ const history: BaseMessage[] = [
200
+ new HumanMessage({
201
+ content: [
202
+ {
203
+ type: 'video_url',
204
+ video_url: { url: 'https://example.com/v.mp4' },
205
+ },
206
+ { type: 'text', text: 'what is in this video?' },
207
+ ],
208
+ }),
209
+ ];
210
+ expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
211
+ 'Unsupported message content format'
212
+ );
213
+ });
214
+
215
+ it('does not drop a reasoning-typed block on a user turn (only assistant reasoning is dropped)', () => {
216
+ const history: BaseMessage[] = [
217
+ new HumanMessage({
218
+ content: [
219
+ { type: 'reasoning_content', reasoningText: { text: 'user text' } },
220
+ { type: 'text', text: 'hello' },
221
+ ],
222
+ }),
223
+ ];
224
+ expect(() => _convertMessagesToAnthropicPayload(history)).toThrow(
225
+ 'Unsupported message content format'
226
+ );
227
+ });
228
+
229
+ it('preserves a tool call carried only on tool_calls when its reasoning sibling is dropped', () => {
230
+ // Mirrors a Bedrock extended-thinking turn: the tool lives only on
231
+ // `tool_calls`; `content` holds just the reasoning block (no tool_use).
232
+ const history: BaseMessage[] = [
233
+ new HumanMessage('research Assort Health'),
234
+ new AIMessage({
235
+ content: [
236
+ {
237
+ type: 'reasoning_content',
238
+ reasoningText: { text: 'I should hand off now.', signature: 'sig' },
239
+ },
240
+ ],
241
+ tool_calls: [
242
+ {
243
+ id: 'tooluse_transfer',
244
+ name: 'lc_transfer_to_data_agent',
245
+ args: { reason: 'need consumption data' },
246
+ type: 'tool_call',
247
+ },
248
+ ],
249
+ }),
250
+ ];
251
+ expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
252
+ const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
253
+ expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
254
+ expect(blocks.find((b) => b.type === 'tool_use')).toMatchObject({
255
+ type: 'tool_use',
256
+ id: 'tooluse_transfer',
257
+ name: 'lc_transfer_to_data_agent',
258
+ input: { reason: 'need consumption data' },
259
+ });
260
+ // The `_` placeholder must not linger once a real tool_use block is present.
261
+ expect(blocks.some((b) => b.type === 'text' && b.text === '_')).toBe(false);
262
+ });
263
+
264
+ it('does not duplicate a Google functionCall tool call already materialized by _formatContent', () => {
265
+ // _formatContent converts the `functionCall` part into a tool_use; the
266
+ // materialization must recognize it as represented and not append a second.
267
+ const history: BaseMessage[] = [
268
+ new HumanMessage('weather in SF?'),
269
+ new AIMessage({
270
+ content: [
271
+ {
272
+ type: 'functionCall',
273
+ functionCall: { name: 'get_weather', args: { city: 'SF' } },
274
+ },
275
+ ],
276
+ tool_calls: [
277
+ {
278
+ id: 'call_weather_1',
279
+ name: 'get_weather',
280
+ args: { city: 'SF' },
281
+ type: 'tool_call',
282
+ },
283
+ ],
284
+ }),
285
+ ];
286
+ const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
287
+ const toolUses = blocks.filter((b) => b.type === 'tool_use');
288
+ expect(toolUses).toHaveLength(1);
289
+ expect(toolUses[0]).toMatchObject({
290
+ type: 'tool_use',
291
+ id: 'call_weather_1',
292
+ name: 'get_weather',
293
+ });
294
+ });
295
+
296
+ it('falls back to placeholder text when reasoning was the only content', () => {
297
+ const history: BaseMessage[] = [
298
+ new HumanMessage('hi'),
299
+ new AIMessage({
300
+ content: [
301
+ {
302
+ type: 'reasoning_content',
303
+ reasoningText: {
304
+ text: 'only thinking, no visible text',
305
+ signature: 'sig',
306
+ },
307
+ },
308
+ ],
309
+ }),
310
+ ];
311
+ expect(() => _convertMessagesToAnthropicPayload(history)).not.toThrow();
312
+ const blocks = assistantBlocks(_convertMessagesToAnthropicPayload(history));
313
+ expect(blocks.find((b) => b.type === 'reasoning_content')).toBeUndefined();
314
+ expect(blocks.length).toBeGreaterThan(0);
315
+ expect(blocks.every((b) => b.type === 'text')).toBe(true);
316
+ });
317
+ });
@@ -429,6 +429,14 @@ function _formatContent(message: BaseMessage) {
429
429
  'web_search_result',
430
430
  ];
431
431
  const textTypes = ['text', 'text_delta'];
432
+ /**
433
+ * Reasoning blocks emitted by other providers — Bedrock's `reasoning_content`,
434
+ * Google's `reasoning`, and LibreChat's `think`. Their signatures are
435
+ * provider-specific and cannot be validated by Anthropic, so on a
436
+ * cross-provider handoff (e.g. Bedrock → Anthropic) we drop them rather than
437
+ * forwarding an unusable block. The receiving model produces its own thinking.
438
+ */
439
+ const foreignReasoningTypes = ['reasoning_content', 'reasoning', 'think'];
432
440
  const { content } = message;
433
441
 
434
442
  if (typeof content === 'string') {
@@ -568,6 +576,15 @@ function _formatContent(message: BaseMessage) {
568
576
  };
569
577
  } else if (contentPart.type === 'thinking') {
570
578
  const thinkingPart = contentPart as AnthropicThinkingBlockParam;
579
+ // Google thinking-enabled output reuses `type: 'thinking'` but carries
580
+ // no Anthropic signature. Anthropic rejects an unsigned thinking block,
581
+ // so on an assistant turn treat it as foreign reasoning and drop it
582
+ // rather than forward an unusable block. Signed (Anthropic-native)
583
+ // thinking is forwarded as before.
584
+ const signature = (thinkingPart as { signature?: string }).signature;
585
+ if (isAIMessage(message) && (signature == null || signature === '')) {
586
+ return null;
587
+ }
571
588
  const block: AnthropicThinkingBlockParam = {
572
589
  type: 'thinking' as const, // Explicitly setting the type as "thinking"
573
590
  thinking: thinkingPart.thinking,
@@ -651,7 +668,9 @@ function _formatContent(message: BaseMessage) {
651
668
  (contentPartCopy.input === '' || contentPartCopy.input == null)
652
669
  ) {
653
670
  const matchingToolCall = isAIMessage(message)
654
- ? message.tool_calls?.find((toolCall) => toolCall.id === contentPartCopy.id)
671
+ ? message.tool_calls?.find(
672
+ (toolCall) => toolCall.id === contentPartCopy.id
673
+ )
655
674
  : undefined;
656
675
  if (matchingToolCall) {
657
676
  contentPartCopy.input = matchingToolCall.args;
@@ -666,7 +685,10 @@ function _formatContent(message: BaseMessage) {
666
685
  typeof p.input === 'string'
667
686
  );
668
687
  })
669
- .reduce((acc, part) => acc + (part as Record<string, unknown>).input, '');
688
+ .reduce(
689
+ (acc, part) => acc + (part as Record<string, unknown>).input,
690
+ ''
691
+ );
670
692
  if (merged !== '') {
671
693
  contentPartCopy.input = merged;
672
694
  }
@@ -720,6 +742,18 @@ function _formatContent(message: BaseMessage) {
720
742
  name: correspondingToolCall.name,
721
743
  input: functionCallPart.functionCall.args,
722
744
  };
745
+ } else if (
746
+ isAIMessage(message) &&
747
+ foreignReasoningTypes.some((t) => t === contentPart.type)
748
+ ) {
749
+ // Foreign reasoning on an ASSISTANT turn (Bedrock `reasoning_content`,
750
+ // Google `reasoning`, LibreChat `think`) carries provider-specific
751
+ // signatures Anthropic cannot validate; drop it so a cross-provider
752
+ // handoff doesn't crash. The same types on a user/tool turn are real
753
+ // input and fall through to the throw below rather than being silently
754
+ // dropped — as does any other unknown block (user media, Google
755
+ // code-execution), which must be surfaced, not discarded.
756
+ return null;
723
757
  } else {
724
758
  console.error(
725
759
  'Unsupported content part:',
@@ -808,25 +842,53 @@ export function _convertMessagesToAnthropicPayload(
808
842
  };
809
843
  }
810
844
  } else {
811
- const { content } = message;
812
- const hasMismatchedToolCalls = !toolCalls.every(
813
- (toolCall) =>
814
- !!content.find(
815
- (contentPart) =>
816
- (contentPart.type === 'tool_use' ||
817
- contentPart.type === 'input_json_delta' ||
818
- contentPart.type === 'server_tool_use') &&
819
- contentPart.id === toolCall.id
845
+ const formattedContent = _formatContent(message);
846
+ const formattedBlocks = Array.isArray(formattedContent)
847
+ ? formattedContent
848
+ : [];
849
+ // Tool calls already materialized as content blocks by `_formatContent`.
850
+ // Derived from the FORMATTED output (not the raw content by type) so
851
+ // that Google `functionCall` parts — which `_formatContent` converts
852
+ // into `tool_use` — count as represented and are not appended twice.
853
+ const representedToolIds = new Set(
854
+ formattedBlocks
855
+ .filter(
856
+ (block) =>
857
+ block != null &&
858
+ (block.type === 'tool_use' || block.type === 'server_tool_use')
820
859
  )
860
+ .map((block) => (block as { id?: string }).id)
821
861
  );
822
- if (hasMismatchedToolCalls) {
823
- console.warn(
824
- 'The "tool_calls" field on a message is only respected if content is a string.'
825
- );
862
+ // Client tool calls present in `tool_calls` but absent from the
863
+ // formatted content — e.g. a Bedrock extended-thinking turn records the
864
+ // tool only on `tool_calls` and leaves `content` as just the reasoning
865
+ // block. Without materializing them, dropping that reasoning block
866
+ // silently loses the (handoff) tool call instead of forwarding it.
867
+ const unrepresentedToolCalls = toolCalls.filter(
868
+ (toolCall) =>
869
+ !(
870
+ toolCall.id?.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX) ??
871
+ false
872
+ ) && !representedToolIds.has(toolCall.id)
873
+ );
874
+ if (unrepresentedToolCalls.length === 0) {
875
+ return { role, content: formattedContent };
826
876
  }
877
+ const existingBlocks = formattedBlocks.filter(
878
+ (block) =>
879
+ !(
880
+ block != null &&
881
+ block.type === 'text' &&
882
+ 'text' in block &&
883
+ block.text === ANTHROPIC_EMPTY_TEXT_PLACEHOLDER
884
+ )
885
+ );
827
886
  return {
828
887
  role,
829
- content: _formatContent(message),
888
+ content: [
889
+ ...existingBlocks,
890
+ ...unrepresentedToolCalls.map(_convertLangChainToolCallToAnthropic),
891
+ ],
830
892
  };
831
893
  }
832
894
  } else {
@@ -0,0 +1,131 @@
1
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
2
+ import type { BaseMessage } from '@langchain/core/messages';
3
+ import { convertToConverseMessages } from './message_inputs';
4
+
5
+ /**
6
+ * Mirror of the Anthropic-side cross-provider reasoning fix, for the reverse
7
+ * handoff (Anthropic → Bedrock). An Anthropic extended-thinking turn leaves
8
+ * `thinking`/`redacted_thinking` blocks in history; the Bedrock Converse
9
+ * converter has no branch for them and previously threw
10
+ * "Unsupported content block type: thinking", crashing the handoff. Bedrock's
11
+ * native reasoning is `reasoning_content` (still converted); foreign reasoning
12
+ * (`thinking`/`redacted_thinking`/`reasoning`/`think`) is dropped on assistant
13
+ * turns, while any other unknown block still throws rather than being silently
14
+ * omitted.
15
+ */
16
+ type ConverseResult = ReturnType<typeof convertToConverseMessages>;
17
+
18
+ /** Minimal view of a converted Bedrock Converse content block the assertions read. */
19
+ interface ConverseBlock {
20
+ text?: string;
21
+ reasoningContent?: { reasoningText?: { text?: string; signature?: string } };
22
+ toolUse?: {
23
+ toolUseId?: string;
24
+ name?: string;
25
+ input?: Record<string, string>;
26
+ };
27
+ }
28
+
29
+ const assistantContent = (result: ConverseResult): ConverseBlock[] => {
30
+ const msg = result.converseMessages.find((m) => m.role === 'assistant');
31
+ return (msg?.content ?? []) as ConverseBlock[];
32
+ };
33
+
34
+ describe('convertToConverseMessages — cross-provider reasoning (Anthropic → Bedrock)', () => {
35
+ it('drops Anthropic thinking/redacted_thinking on an assistant turn, keeping text and tool calls', () => {
36
+ const messages: BaseMessage[] = [
37
+ new HumanMessage('research Assort Health'),
38
+ new AIMessage({
39
+ content: [
40
+ {
41
+ type: 'thinking',
42
+ thinking: 'Let me hand off to the data agent.',
43
+ signature: 'anthropic-signature-not-valid-for-bedrock',
44
+ },
45
+ { type: 'redacted_thinking', data: 'redacted-blob' },
46
+ { type: 'text', text: 'Handing off now.' },
47
+ ],
48
+ tool_calls: [
49
+ {
50
+ id: 'tooluse_transfer',
51
+ name: 'lc_transfer_to_data_agent',
52
+ args: { reason: 'need consumption data' },
53
+ type: 'tool_call',
54
+ },
55
+ ],
56
+ }),
57
+ ];
58
+
59
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
60
+ const content = assistantContent(convertToConverseMessages(messages));
61
+
62
+ expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
63
+ expect(JSON.stringify(content)).not.toContain(
64
+ 'anthropic-signature-not-valid-for-bedrock'
65
+ );
66
+ expect(JSON.stringify(content)).not.toContain('redacted-blob');
67
+
68
+ expect(content.some((b) => b.text === 'Handing off now.')).toBe(true);
69
+ const toolUse = content.find((b) => b.toolUse != null);
70
+ expect(toolUse?.toolUse).toMatchObject({
71
+ toolUseId: 'tooluse_transfer',
72
+ name: 'lc_transfer_to_data_agent',
73
+ input: { reason: 'need consumption data' },
74
+ });
75
+ });
76
+
77
+ it('emits a placeholder (not empty content) when a reasoning-only turn is fully dropped', () => {
78
+ const messages: BaseMessage[] = [
79
+ new HumanMessage('hi'),
80
+ new AIMessage({
81
+ content: [
82
+ { type: 'thinking', thinking: 'only thinking, no other content' },
83
+ ],
84
+ }),
85
+ ];
86
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
87
+ const content = assistantContent(convertToConverseMessages(messages));
88
+ expect(content.length).toBeGreaterThan(0);
89
+ expect(content.find((b) => b.reasoningContent != null)).toBeUndefined();
90
+ expect(content.every((b) => typeof b.text === 'string')).toBe(true);
91
+ });
92
+
93
+ it('still throws on a genuinely unknown assistant block', () => {
94
+ const messages: BaseMessage[] = [
95
+ new HumanMessage('run code'),
96
+ new AIMessage({
97
+ content: [
98
+ { type: 'some_future_block_type', foo: 'bar' },
99
+ { type: 'text', text: 'done' },
100
+ ],
101
+ }),
102
+ ];
103
+ expect(() => convertToConverseMessages(messages)).toThrow(
104
+ 'Unsupported content block type'
105
+ );
106
+ });
107
+
108
+ it('still converts Bedrock-native reasoning_content (not dropped)', () => {
109
+ const messages: BaseMessage[] = [
110
+ new HumanMessage('hi'),
111
+ new AIMessage({
112
+ content: [
113
+ {
114
+ type: 'reasoning_content',
115
+ reasoningText: {
116
+ text: 'native bedrock reasoning',
117
+ signature: 'sig',
118
+ },
119
+ },
120
+ { type: 'text', text: 'answer' },
121
+ ],
122
+ }),
123
+ ];
124
+ const content = assistantContent(convertToConverseMessages(messages));
125
+ const reasoning = content.find((b) => b.reasoningContent != null);
126
+ expect(reasoning).toBeDefined();
127
+ expect(reasoning?.reasoningContent?.reasoningText?.text).toBe(
128
+ 'native bedrock reasoning'
129
+ );
130
+ });
131
+ });
@@ -28,6 +28,26 @@ import type {
28
28
  MessageContentReasoningBlock,
29
29
  } from '../types';
30
30
 
31
+ /**
32
+ * Reasoning blocks from other providers, relative to Bedrock. Bedrock's native
33
+ * reasoning format is `reasoning_content`; these carry provider-specific
34
+ * signatures Bedrock cannot validate, so they are dropped on a cross-provider
35
+ * handoff (e.g. Anthropic → Bedrock) rather than crashing the conversion.
36
+ */
37
+ const FOREIGN_REASONING_TYPES = [
38
+ 'thinking',
39
+ 'redacted_thinking',
40
+ 'reasoning',
41
+ 'think',
42
+ ];
43
+
44
+ /**
45
+ * Bedrock Converse rejects assistant messages with no content blocks. When
46
+ * filtering (e.g. dropping foreign reasoning) empties an assistant turn that
47
+ * also has no tool calls, fall back to this placeholder text.
48
+ */
49
+ const BEDROCK_EMPTY_TEXT_PLACEHOLDER = '_';
50
+
31
51
  /**
32
52
  * Convert a LangChain reasoning block to a Bedrock reasoning block.
33
53
  */
@@ -644,6 +664,15 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
644
664
  type: 'default',
645
665
  },
646
666
  } as BedrockContentBlock);
667
+ } else if (FOREIGN_REASONING_TYPES.some((t) => t === block.type)) {
668
+ // Reasoning from another provider (Anthropic `thinking`/
669
+ // `redacted_thinking`, Google `reasoning`, LibreChat `think`). Bedrock's
670
+ // native reasoning is `reasoning_content` (handled above); a foreign
671
+ // block carries a signature Bedrock cannot validate, so drop it on a
672
+ // cross-provider handoff (e.g. Anthropic → Bedrock) rather than crash.
673
+ // The Bedrock model produces its own reasoning. Anything else unknown
674
+ // still throws below — real content must be surfaced, not dropped.
675
+ return;
647
676
  } else {
648
677
  const blockValues = Object.fromEntries(
649
678
  Object.entries(block).filter(([key]) => key !== 'type')
@@ -672,6 +701,12 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
672
701
  ] as BedrockContentBlock[];
673
702
  }
674
703
 
704
+ // Bedrock rejects an assistant message with no content blocks; if filtering
705
+ // (e.g. dropping foreign reasoning) left it empty, emit a placeholder.
706
+ if (assistantMsg.content == null || assistantMsg.content.length === 0) {
707
+ assistantMsg.content = [{ text: BEDROCK_EMPTY_TEXT_PLACEHOLDER }];
708
+ }
709
+
675
710
  return assistantMsg;
676
711
  }
677
712
 
@@ -0,0 +1,32 @@
1
+ import type * as t from '@/types';
2
+
3
+ /**
4
+ * Reconciles a context-usage breakdown's instruction/available/message fields
5
+ * from the pruner's budget metrics. `messageTokens` and `availableForMessages`
6
+ * are DERIVED from `contextBudget` / `effectiveInstructionTokens` /
7
+ * `remainingContextTokens` rather than summed from the index map — that map is
8
+ * keyed by pre-prune indices, so summing it over the kept context would missum.
9
+ * Shared by the live snapshot path (`Graph.createCallModel`) and the pre-send
10
+ * projection (`AgentContext.projectContextUsage`) so both yield identical numbers.
11
+ */
12
+ export function syncBudgetDerivedFields(usage: t.ContextUsageEvent): void {
13
+ const { breakdown, contextBudget, effectiveInstructionTokens } = usage;
14
+ if (effectiveInstructionTokens == null) {
15
+ return;
16
+ }
17
+ breakdown.instructionTokens = effectiveInstructionTokens;
18
+ if (contextBudget == null) {
19
+ return;
20
+ }
21
+ breakdown.availableForMessages = Math.max(
22
+ 0,
23
+ contextBudget - effectiveInstructionTokens
24
+ );
25
+ if (usage.remainingContextTokens == null) {
26
+ return;
27
+ }
28
+ breakdown.messageTokens = Math.max(
29
+ 0,
30
+ contextBudget - effectiveInstructionTokens - usage.remainingContextTokens
31
+ );
32
+ }
@@ -41,7 +41,7 @@ function deepCloneContent<T extends string | MessageContentComplex[]>(
41
41
  * in downstream code (e.g., ensureThinkingBlockInMessages).
42
42
  * For plain objects (AnthropicMessage), uses object spread.
43
43
  */
44
- function cloneMessage<T extends MessageWithContent>(
44
+ export function cloneMessage<T extends MessageWithContent>(
45
45
  message: T,
46
46
  content: string | MessageContentComplex[]
47
47
  ): T {