@librechat/agents 3.3.7 → 3.3.9

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 (168) hide show
  1. package/dist/cjs/graphs/Graph.cjs +47 -13
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +56 -6
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
  6. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  7. package/dist/cjs/instrumentation.cjs +18 -48
  8. package/dist/cjs/instrumentation.cjs.map +1 -1
  9. package/dist/cjs/langfuse.cjs +174 -29
  10. package/dist/cjs/langfuse.cjs.map +1 -1
  11. package/dist/cjs/langfuseConfig.cjs +12 -0
  12. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  13. package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
  14. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  15. package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
  16. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  17. package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
  18. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
  19. package/dist/cjs/langfuseTraceShaping.cjs +121 -4
  20. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  21. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
  22. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
  24. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  25. package/dist/cjs/llm/init.cjs +3 -3
  26. package/dist/cjs/llm/invoke.cjs +5 -5
  27. package/dist/cjs/llm/openai/index.cjs +1 -1
  28. package/dist/cjs/main.cjs +10 -10
  29. package/dist/cjs/messages/format.cjs +124 -15
  30. package/dist/cjs/messages/format.cjs.map +1 -1
  31. package/dist/cjs/messages/injected.cjs +10 -1
  32. package/dist/cjs/messages/injected.cjs.map +1 -1
  33. package/dist/cjs/messages/prune.cjs +13 -1
  34. package/dist/cjs/messages/prune.cjs.map +1 -1
  35. package/dist/cjs/prompts/activityLabel.cjs +51 -11
  36. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  37. package/dist/cjs/run.cjs +54 -24
  38. package/dist/cjs/run.cjs.map +1 -1
  39. package/dist/cjs/session/messageSerialization.cjs +6 -0
  40. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  41. package/dist/cjs/stream.cjs +21 -10
  42. package/dist/cjs/stream.cjs.map +1 -1
  43. package/dist/cjs/summarization/node.cjs +60 -0
  44. package/dist/cjs/summarization/node.cjs.map +1 -1
  45. package/dist/cjs/tools/ToolNode.cjs +253 -24
  46. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  47. package/dist/cjs/tools/handlers.cjs +1 -1
  48. package/dist/cjs/tools/search/tool.cjs +1 -1
  49. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  50. package/dist/cjs/utils/index.cjs +2 -2
  51. package/dist/esm/graphs/Graph.mjs +48 -14
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs +56 -6
  54. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  55. package/dist/esm/hitl/askUserQuestion.mjs +3 -2
  56. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  57. package/dist/esm/instrumentation.mjs +18 -48
  58. package/dist/esm/instrumentation.mjs.map +1 -1
  59. package/dist/esm/langfuse.mjs +176 -28
  60. package/dist/esm/langfuse.mjs.map +1 -1
  61. package/dist/esm/langfuseConfig.mjs +10 -1
  62. package/dist/esm/langfuseConfig.mjs.map +1 -1
  63. package/dist/esm/langfuseRuntimeContext.mjs +21 -3
  64. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  65. package/dist/esm/langfuseRuntimeScope.mjs +39 -10
  66. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  67. package/dist/esm/langfuseSpanRegistry.mjs +91 -0
  68. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
  69. package/dist/esm/langfuseTraceShaping.mjs +121 -4
  70. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  71. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
  72. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  73. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
  74. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  75. package/dist/esm/llm/init.mjs +2 -2
  76. package/dist/esm/llm/invoke.mjs +5 -5
  77. package/dist/esm/llm/openai/index.mjs +1 -1
  78. package/dist/esm/main.mjs +8 -8
  79. package/dist/esm/messages/format.mjs +124 -15
  80. package/dist/esm/messages/format.mjs.map +1 -1
  81. package/dist/esm/messages/injected.mjs +10 -1
  82. package/dist/esm/messages/injected.mjs.map +1 -1
  83. package/dist/esm/messages/prune.mjs +13 -1
  84. package/dist/esm/messages/prune.mjs.map +1 -1
  85. package/dist/esm/prompts/activityLabel.mjs +51 -11
  86. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  87. package/dist/esm/run.mjs +54 -24
  88. package/dist/esm/run.mjs.map +1 -1
  89. package/dist/esm/session/messageSerialization.mjs +6 -0
  90. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  91. package/dist/esm/stream.mjs +21 -10
  92. package/dist/esm/stream.mjs.map +1 -1
  93. package/dist/esm/summarization/node.mjs +60 -0
  94. package/dist/esm/summarization/node.mjs.map +1 -1
  95. package/dist/esm/tools/ToolNode.mjs +254 -25
  96. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  97. package/dist/esm/tools/handlers.mjs +1 -1
  98. package/dist/esm/tools/search/tool.mjs +1 -1
  99. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  100. package/dist/esm/utils/index.mjs +2 -2
  101. package/dist/types/graphs/Graph.d.ts +19 -0
  102. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  103. package/dist/types/langfuse.d.ts +16 -8
  104. package/dist/types/langfuseConfig.d.ts +6 -0
  105. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  106. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  107. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  108. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  109. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  110. package/dist/types/messages/format.d.ts +9 -8
  111. package/dist/types/prompts/activityLabel.d.ts +8 -1
  112. package/dist/types/run.d.ts +1 -1
  113. package/dist/types/session/types.d.ts +1 -0
  114. package/dist/types/tools/ToolNode.d.ts +7 -1
  115. package/dist/types/types/activityLabel.d.ts +8 -0
  116. package/dist/types/types/hitl.d.ts +8 -0
  117. package/dist/types/types/stream.d.ts +19 -0
  118. package/dist/types/types/tools.d.ts +30 -0
  119. package/package.json +7 -4
  120. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  121. package/src/graphs/Graph.ts +69 -20
  122. package/src/graphs/MultiAgentGraph.ts +74 -6
  123. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  124. package/src/hitl/askUserQuestion.ts +14 -1
  125. package/src/instrumentation.ts +35 -77
  126. package/src/langfuse.ts +320 -43
  127. package/src/langfuseConfig.ts +24 -0
  128. package/src/langfuseRuntimeContext.ts +43 -1
  129. package/src/langfuseRuntimeScope.ts +94 -21
  130. package/src/langfuseSpanRegistry.ts +131 -0
  131. package/src/langfuseTraceShaping.ts +194 -7
  132. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  133. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  134. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  135. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  136. package/src/messages/format.ts +222 -50
  137. package/src/messages/formatAgentMessages.test.ts +308 -6
  138. package/src/messages/injected.test.ts +18 -1
  139. package/src/messages/injected.ts +8 -1
  140. package/src/messages/prune.ts +12 -1
  141. package/src/prompts/activityLabel.ts +67 -2
  142. package/src/run.ts +86 -46
  143. package/src/scripts/activity-labels/captured.json +56 -0
  144. package/src/scripts/activity-labels/checks.cjs +205 -0
  145. package/src/scripts/activity-labels/corpus.cjs +473 -0
  146. package/src/scripts/activity-labels/report.cjs +203 -0
  147. package/src/scripts/activity-labels/rescore.cjs +102 -0
  148. package/src/scripts/activity-labels/run.ts +705 -0
  149. package/src/scripts/activity-labels/variants.ts +71 -0
  150. package/src/session/messageSerialization.ts +12 -1
  151. package/src/session/types.ts +1 -0
  152. package/src/specs/activity-label-prompt.test.ts +109 -0
  153. package/src/specs/agent-handoffs.test.ts +306 -0
  154. package/src/specs/langfuse-callbacks.test.ts +456 -0
  155. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  156. package/src/specs/langfuse-span-registry.test.ts +70 -0
  157. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  158. package/src/specs/prune.test.ts +38 -1
  159. package/src/stream.ts +70 -6
  160. package/src/summarization/__tests__/node.test.ts +188 -0
  161. package/src/summarization/node.ts +72 -0
  162. package/src/tools/ToolNode.ts +400 -9
  163. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  164. package/src/tools/__tests__/hitl.test.ts +58 -0
  165. package/src/types/activityLabel.ts +8 -0
  166. package/src/types/hitl.ts +8 -0
  167. package/src/types/stream.ts +20 -0
  168. package/src/types/tools.ts +35 -1
@@ -1,8 +1,14 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { AIMessage, HumanMessage } from '@langchain/core/messages';
3
+ import type { ToolCall } from '@langchain/core/messages/tool';
3
4
  import type { BaseMessage } from '@langchain/core/messages';
4
- import { _convertMessagesToAnthropicPayload } from './message_inputs';
5
+ import type {
6
+ AnthropicMessageCreateParams,
7
+ AnthropicServerToolUseBlockParam,
8
+ AnthropicToolUseBlockParam,
9
+ } from '@/llm/anthropic/types';
5
10
  import { _makeMessageChunkFromAnthropicEvent } from './message_outputs';
11
+ import { _convertMessagesToAnthropicPayload } from './message_inputs';
6
12
 
7
13
  /**
8
14
  * Regression for @langchain/core >= 1.1.46 streaming aggregation: a tool call's
@@ -40,7 +46,9 @@ describe('_convertMessagesToAnthropicPayload — aggregated streaming tool input
40
46
  ];
41
47
 
42
48
  it('does not throw on the orphaned text-with-input block', () => {
43
- expect(() => _convertMessagesToAnthropicPayload(buildHistory())).not.toThrow();
49
+ expect(() =>
50
+ _convertMessagesToAnthropicPayload(buildHistory())
51
+ ).not.toThrow();
44
52
  });
45
53
 
46
54
  it('restores tool_use input from message.tool_calls and drops the orphan block', () => {
@@ -60,13 +68,16 @@ describe('_convertMessagesToAnthropicPayload — aggregated streaming tool input
60
68
  // No leftover delta: no text block carrying `input`, no input_json_delta.
61
69
  expect(
62
70
  blocks.find(
63
- (b) => (b.type === 'text' && 'input' in b) || b.type === 'input_json_delta'
71
+ (b) =>
72
+ (b.type === 'text' && 'input' in b) || b.type === 'input_json_delta'
64
73
  )
65
74
  ).toBeUndefined();
66
75
 
67
76
  // The real assistant text is preserved.
68
77
  expect(
69
- blocks.some((b) => b.type === 'text' && b.text === 'Let me calculate that.')
78
+ blocks.some(
79
+ (b) => b.type === 'text' && b.text === 'Let me calculate that.'
80
+ )
70
81
  ).toBe(true);
71
82
  });
72
83
 
@@ -94,7 +105,9 @@ describe('_convertMessagesToAnthropicPayload — aggregated streaming tool input
94
105
  ];
95
106
  const payload = _convertMessagesToAnthropicPayload(history);
96
107
  const assistant = payload.messages.find((m: any) => m.role === 'assistant');
97
- const toolUse = (assistant!.content as any[]).find((b) => b.type === 'tool_use');
108
+ const toolUse = (assistant!.content as any[]).find(
109
+ (b) => b.type === 'tool_use'
110
+ );
98
111
  expect(toolUse.input).toEqual({ input: '2 + 2' });
99
112
  });
100
113
 
@@ -105,8 +118,18 @@ describe('_convertMessagesToAnthropicPayload — aggregated streaming tool input
105
118
  new HumanMessage('What\'s the weather in Seattle tomorrow?'),
106
119
  new AIMessage({
107
120
  content: [
108
- { type: 'text', index: 1, text: 'I need to call the get_weather tool' },
109
- { type: 'tool_use', index: 2, name: 'get_weather', id: 'tool_call_id', input: '' },
121
+ {
122
+ type: 'text',
123
+ index: 1,
124
+ text: 'I need to call the get_weather tool',
125
+ },
126
+ {
127
+ type: 'tool_use',
128
+ index: 2,
129
+ name: 'get_weather',
130
+ id: 'tool_call_id',
131
+ input: '',
132
+ },
110
133
  { type: 'input_json_delta', index: 2, input: '{"city": "' },
111
134
  { type: 'input_json_delta', index: 2, input: 'Seattle", "da' },
112
135
  { type: 'input_json_delta', index: 2, input: 'te": "to' },
@@ -129,6 +152,145 @@ describe('_convertMessagesToAnthropicPayload — aggregated streaming tool input
129
152
  });
130
153
  });
131
154
 
155
+ /**
156
+ * Regression for the summarization-CI flake: context-pressure truncation
157
+ * (`preFlightTruncateToolCallInputs` under a tight budget) used to null BOTH a
158
+ * tool_use block's inline `input` and its `tool_calls` args in graph state.
159
+ * Replaying that message shipped `"input": null` and Anthropic rejected the
160
+ * request with 400 `tool_use.input: Input should be an object`. The payload
161
+ * conversion must never emit a non-object input, whatever shape history is in.
162
+ */
163
+ describe('_convertMessagesToAnthropicPayload — non-object tool_use input replay', () => {
164
+ /** History shapes context-pressure truncation can leave behind in state. */
165
+ type DegradedToolInput = string | Record<string, unknown> | null | undefined;
166
+
167
+ const buildHistory = (
168
+ input: DegradedToolInput,
169
+ args: DegradedToolInput
170
+ ): BaseMessage[] => [
171
+ new HumanMessage('What is 9 * 9?'),
172
+ new AIMessage({
173
+ content: [{ type: 'tool_use', id: 'toolu_x', name: 'calculator', input }],
174
+ tool_calls: [
175
+ {
176
+ id: 'toolu_x',
177
+ name: 'calculator',
178
+ args: args as ToolCall['args'],
179
+ type: 'tool_call',
180
+ },
181
+ ],
182
+ }),
183
+ ];
184
+
185
+ const findAssistantBlock = (
186
+ payload: AnthropicMessageCreateParams,
187
+ type: 'tool_use' | 'server_tool_use'
188
+ ): AnthropicToolUseBlockParam | AnthropicServerToolUseBlockParam => {
189
+ const assistant = payload.messages.find((m) => m.role === 'assistant');
190
+ const content = assistant?.content;
191
+ const block = (Array.isArray(content) ? content : []).find(
192
+ (b): b is AnthropicToolUseBlockParam | AnthropicServerToolUseBlockParam =>
193
+ b.type === type
194
+ );
195
+ expect(block).toBeDefined();
196
+ return block!;
197
+ };
198
+
199
+ const getToolUse = (
200
+ history: BaseMessage[]
201
+ ): AnthropicToolUseBlockParam | AnthropicServerToolUseBlockParam =>
202
+ findAssistantBlock(_convertMessagesToAnthropicPayload(history), 'tool_use');
203
+
204
+ it('ships an empty object when input and args were both truncated to null', () => {
205
+ const toolUse = getToolUse(buildHistory(null, null));
206
+ expect(toolUse.input).toEqual({});
207
+ });
208
+
209
+ it('restores object args when only the inline input was nulled', () => {
210
+ const toolUse = getToolUse(buildHistory(null, { input: '9 * 9' }));
211
+ expect(toolUse.input).toEqual({ input: '9 * 9' });
212
+ });
213
+
214
+ it('restores intact args when the inline input degraded to an empty object', () => {
215
+ // Asymmetric truncation: the raw string input serializes longer than the
216
+ // args object, so a near-envelope cap can degrade the inline input to {}
217
+ // while the tool_calls mirror survives. Replay must prefer the mirror.
218
+ const toolUse = getToolUse(
219
+ buildHistory({}, { input: '670592745 / 99991' })
220
+ );
221
+ expect(toolUse.input).toEqual({ input: '670592745 / 99991' });
222
+ });
223
+
224
+ it('ships an empty object when the inline input is {} and args were nulled too', () => {
225
+ const toolUse = getToolUse(buildHistory({}, null));
226
+ expect(toolUse.input).toEqual({});
227
+ });
228
+
229
+ it('coerces non-object inputs on the srvtoolu_ server-tool normalization branch', () => {
230
+ const cases: Array<[DegradedToolInput, Record<string, unknown>]> = [
231
+ ['123', {}],
232
+ ['[1,2]', {}],
233
+ ['{"query": "x"}', { query: 'x' }],
234
+ [null, {}],
235
+ ];
236
+ for (const [raw, expected] of cases) {
237
+ const history: BaseMessage[] = [
238
+ new HumanMessage('search'),
239
+ new AIMessage({
240
+ content: [
241
+ {
242
+ type: 'server_tool_use',
243
+ id: 'srvtoolu_abc',
244
+ name: 'web_search',
245
+ input: raw,
246
+ },
247
+ ],
248
+ }),
249
+ ];
250
+ const block = findAssistantBlock(
251
+ _convertMessagesToAnthropicPayload(history),
252
+ 'server_tool_use'
253
+ );
254
+ expect(block.input).toEqual(expected);
255
+ }
256
+ });
257
+
258
+ it('coerces a string input that parses to a non-object', () => {
259
+ for (const raw of ['123', '[1,2]', '"text"', 'null']) {
260
+ const toolUse = getToolUse(buildHistory(raw, undefined));
261
+ expect(toolUse.input).toEqual({});
262
+ }
263
+ });
264
+
265
+ it('still parses a complete JSON-object string input', () => {
266
+ const toolUse = getToolUse(buildHistory('{"input": "9 * 9"}', undefined));
267
+ expect(toolUse.input).toEqual({ input: '9 * 9' });
268
+ });
269
+
270
+ it('coerces non-object args on the string-content tool_calls path', () => {
271
+ const history: BaseMessage[] = [
272
+ new HumanMessage('What is 9 * 9?'),
273
+ new AIMessage({
274
+ content: '',
275
+ tool_calls: [
276
+ {
277
+ id: 'toolu_x',
278
+ name: 'calculator',
279
+ args: null as unknown as ToolCall['args'],
280
+ type: 'tool_call',
281
+ },
282
+ ],
283
+ }),
284
+ ];
285
+ const payload = _convertMessagesToAnthropicPayload(history);
286
+ const assistant = payload.messages.find((m: any) => m.role === 'assistant');
287
+ const toolUse = (assistant!.content as any[]).find(
288
+ (b) => b.type === 'tool_use'
289
+ );
290
+ expect(toolUse.input).toEqual({});
291
+ });
292
+ });
293
+
132
294
  describe('_makeMessageChunkFromAnthropicEvent — streamed tool input merges into content', () => {
133
295
  const fields = { streamUsage: true, coerceContentToString: false };
134
296
 
@@ -137,7 +299,12 @@ describe('_makeMessageChunkFromAnthropicEvent — streamed tool input merges int
137
299
  {
138
300
  type: 'content_block_start',
139
301
  index: 0,
140
- content_block: { type: 'tool_use', id: 'toolu_1', name: 'calculator', input: {} },
302
+ content_block: {
303
+ type: 'tool_use',
304
+ id: 'toolu_1',
305
+ name: 'calculator',
306
+ input: {},
307
+ },
141
308
  },
142
309
  {
143
310
  type: 'content_block_delta',
@@ -168,13 +335,21 @@ describe('_makeMessageChunkFromAnthropicEvent — streamed tool input merges int
168
335
  const blocks = merged.content as any[];
169
336
 
170
337
  const toolUse = blocks.find((b) => b.type === 'tool_use');
171
- expect(toolUse).toMatchObject({ type: 'tool_use', id: 'toolu_1', name: 'calculator' });
338
+ expect(toolUse).toMatchObject({
339
+ type: 'tool_use',
340
+ id: 'toolu_1',
341
+ name: 'calculator',
342
+ });
172
343
  const parsed =
173
- typeof toolUse.input === 'string' ? JSON.parse(toolUse.input) : toolUse.input;
344
+ typeof toolUse.input === 'string'
345
+ ? JSON.parse(toolUse.input)
346
+ : toolUse.input;
174
347
  expect(parsed).toEqual({ input: '2 + 2' });
175
348
 
176
349
  // no orphaned delta block survives aggregation
177
- expect(blocks.filter((b) => b.type !== 'tool_use' && 'input' in b)).toHaveLength(0);
350
+ expect(
351
+ blocks.filter((b) => b.type !== 'tool_use' && 'input' in b)
352
+ ).toHaveLength(0);
178
353
 
179
354
  // tool_calls remain correctly aggregated
180
355
  expect(merged.tool_calls?.[0]).toMatchObject({
@@ -79,6 +79,120 @@ describe('convertToConverseMessages — Anthropic tool replay', () => {
79
79
  });
80
80
  });
81
81
 
82
+ /**
83
+ * Bedrock Converse requires `toolUse.input` to be a JSON object. History can
84
+ * carry non-object values: pre-3.x context-pressure truncation persisted
85
+ * `null` onto both a block's inline input and its `tool_calls` args (see the
86
+ * Anthropic-replay fix in PR #369), and Anthropic-shaped inline blocks keep
87
+ * the raw streamed JSON string. These must coerce — never ship as-is, never
88
+ * throw the whole request away.
89
+ */
90
+ describe('convertToConverseMessages — non-object toolUse input coercion', () => {
91
+ const toolUseBlocks = (result: ConverseResult): ConverseBlock[] =>
92
+ assistantContent(result).filter((b) => b.toolUse != null);
93
+
94
+ it('coerces null args to {} when materializing from tool_calls', () => {
95
+ const messages: BaseMessage[] = [
96
+ new HumanMessage('What is 9 * 9?'),
97
+ new AIMessage({
98
+ content: toLangChainContent([
99
+ {
100
+ type: 'tool_use',
101
+ id: 'call_null',
102
+ name: 'calculator',
103
+ input: null,
104
+ },
105
+ ]),
106
+ tool_calls: [
107
+ {
108
+ id: 'call_null',
109
+ name: 'calculator',
110
+ args: null as never,
111
+ },
112
+ ],
113
+ }),
114
+ ];
115
+
116
+ const [block] = toolUseBlocks(convertToConverseMessages(messages));
117
+ expect(block.toolUse?.input).toEqual({});
118
+ });
119
+
120
+ it('parses a complete raw-string input on an unmirrored inline block instead of throwing', () => {
121
+ const messages: BaseMessage[] = [
122
+ new HumanMessage('Search'),
123
+ new AIMessage({
124
+ content: toLangChainContent([
125
+ {
126
+ type: 'tool_use',
127
+ id: 'call_str',
128
+ name: 'search',
129
+ input: '{"query": "test"}',
130
+ },
131
+ ]),
132
+ tool_calls: [],
133
+ }),
134
+ ];
135
+
136
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
137
+ const [block] = toolUseBlocks(convertToConverseMessages(messages));
138
+ expect(block.toolUse?.input).toEqual({ query: 'test' });
139
+ });
140
+
141
+ it('degrades non-object inline inputs (partial string, number-string, null) to {}', () => {
142
+ for (const input of ['{"query": "tru', '123', null]) {
143
+ const messages: BaseMessage[] = [
144
+ new HumanMessage('Search'),
145
+ new AIMessage({
146
+ content: toLangChainContent([
147
+ { type: 'tool_use', id: 'call_bad', name: 'search', input },
148
+ ]),
149
+ tool_calls: [],
150
+ }),
151
+ ];
152
+
153
+ const [block] = toolUseBlocks(convertToConverseMessages(messages));
154
+ expect(block.toolUse?.input).toEqual({});
155
+ }
156
+ });
157
+
158
+ it('still rejects a tool_use block missing its id or name', () => {
159
+ const messages: BaseMessage[] = [
160
+ new HumanMessage('Search'),
161
+ new AIMessage({
162
+ content: toLangChainContent([
163
+ { type: 'tool_use', name: 'search', input: { query: 'x' } },
164
+ ]),
165
+ tool_calls: [],
166
+ }),
167
+ ];
168
+
169
+ expect(() => convertToConverseMessages(messages)).toThrow(
170
+ 'Invalid Anthropic tool_use content block'
171
+ );
172
+ });
173
+
174
+ it('coerces null args on the v1 tool_call and tool_calls fallback paths', () => {
175
+ const messages: BaseMessage[] = [
176
+ new HumanMessage('Run both'),
177
+ new AIMessage({
178
+ content: toLangChainContent([
179
+ { type: 'tool_call', id: 'v1_block', name: 'search', args: null },
180
+ ]),
181
+ tool_calls: [
182
+ { id: 'v1_fallback', name: 'lookup', args: null as never },
183
+ ],
184
+ response_metadata: { output_version: 'v1' },
185
+ }),
186
+ ];
187
+
188
+ const blocks = toolUseBlocks(convertToConverseMessages(messages));
189
+ expect(blocks).toHaveLength(2);
190
+ for (const block of blocks) {
191
+ expect(block.toolUse?.input).toEqual({});
192
+ }
193
+ });
194
+ });
195
+
82
196
  describe('convertToConverseMessages — native Bedrock reasoning serialization', () => {
83
197
  it('drops a signature-only reasoning block, keeping text and tool calls', () => {
84
198
  const messages: BaseMessage[] = [
@@ -491,9 +605,9 @@ describe('convertToConverseMessages — user-role run merging', () => {
491
605
  'toolResult' in block ? 'toolResult' : 'text'
492
606
  );
493
607
  expect(blockKinds).toEqual(['toolResult', 'text']);
494
- expect(
495
- (merged.content ?? []).find((block) => 'text' in block)?.text
496
- ).toBe('Actually, focus on the second result.');
608
+ expect((merged.content ?? []).find((block) => 'text' in block)?.text).toBe(
609
+ 'Actually, focus on the second result.'
610
+ );
497
611
  });
498
612
 
499
613
  it('still merges adjacent tool-result-only turns', () => {
@@ -513,7 +627,9 @@ describe('convertToConverseMessages — user-role run merging', () => {
513
627
 
514
628
  expect(converseMessages.map((m) => m.role)).toEqual(['assistant', 'user']);
515
629
  const toolResultIds = (converseMessages[1].content ?? [])
516
- .map((block) => ('toolResult' in block ? block.toolResult?.toolUseId : undefined))
630
+ .map((block) =>
631
+ 'toolResult' in block ? block.toolResult?.toolUseId : undefined
632
+ )
517
633
  .filter(Boolean);
518
634
  expect(toolResultIds).toEqual(['call_1', 'call_2']);
519
635
  });
@@ -684,6 +684,33 @@ function convertSystemMessageToConverseMessage(
684
684
  /**
685
685
  * Convert an AI message to a Bedrock message.
686
686
  */
687
+ /**
688
+ * Bedrock Converse requires `toolUse.input` to be a JSON object document.
689
+ * History can carry non-object values: streaming leaves the raw partial-JSON
690
+ * string on Anthropic-shaped inline blocks, and context-pressure truncation
691
+ * (pre-3.x `createBoundedTruncationValue`) could persist `null` onto BOTH a
692
+ * block's inline input and its `tool_calls` args. A string is parsed when it
693
+ * forms a complete JSON object; every other shape degrades to `{}` — the call
694
+ * already executed, so the replayed input is informational. Twin of
695
+ * `coerceAnthropicToolUseInput` in the Anthropic fork; duplicated so each
696
+ * fork stays self-contained against its upstream.
697
+ */
698
+ function coerceBedrockToolUseInput(input: unknown): Record<string, unknown> {
699
+ let candidate: unknown = input;
700
+ if (typeof candidate === 'string') {
701
+ try {
702
+ candidate = JSON.parse(candidate);
703
+ } catch {
704
+ return {};
705
+ }
706
+ }
707
+ return typeof candidate === 'object' &&
708
+ candidate !== null &&
709
+ !Array.isArray(candidate)
710
+ ? (candidate as Record<string, unknown>)
711
+ : {};
712
+ }
713
+
687
714
  function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
688
715
  // Check for v1 format from other providers (PR #9766 fix)
689
716
  const responseMetadata = msg.response_metadata as
@@ -731,9 +758,7 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
731
758
  }
732
759
  if (
733
760
  typeof toolUse.id !== 'string' ||
734
- typeof toolUse.name !== 'string' ||
735
- toolUse.input == null ||
736
- typeof toolUse.input !== 'object'
761
+ typeof toolUse.name !== 'string'
737
762
  ) {
738
763
  throw new Error('Invalid Anthropic tool_use content block');
739
764
  }
@@ -741,7 +766,7 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
741
766
  toolUse: {
742
767
  toolUseId: toolUse.id,
743
768
  name: toolUse.name,
744
- input: toolUse.input as Record<string, unknown>,
769
+ input: coerceBedrockToolUseInput(toolUse.input),
745
770
  },
746
771
  } as BedrockContentBlock);
747
772
  } else if (block.type === 'reasoning_content') {
@@ -804,7 +829,7 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
804
829
  toolUse: {
805
830
  toolUseId: toolCall.id,
806
831
  name: toolCall.name,
807
- input: toolCall.args as Record<string, unknown>,
832
+ input: coerceBedrockToolUseInput(toolCall.args),
808
833
  },
809
834
  }));
810
835
  assistantMsg.content = [
@@ -862,7 +887,7 @@ function convertFromV1ToChatBedrockConverseMessage(
862
887
  toolUse: {
863
888
  toolUseId: toolCall.id,
864
889
  name: toolCall.name,
865
- input: toolCall.args as Record<string, unknown>,
890
+ input: coerceBedrockToolUseInput(toolCall.args),
866
891
  },
867
892
  } as BedrockContentBlock);
868
893
  } else if (block.type === 'reasoning') {
@@ -914,7 +939,7 @@ function convertFromV1ToChatBedrockConverseMessage(
914
939
  toolUse: {
915
940
  toolUseId: tc.id,
916
941
  name: tc.name,
917
- input: tc.args as Record<string, unknown>,
942
+ input: coerceBedrockToolUseInput(tc.args),
918
943
  },
919
944
  } as BedrockContentBlock);
920
945
  }