@librechat/agents 3.3.8 → 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 (148) 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 +35 -2
  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/prune.cjs +13 -1
  30. package/dist/cjs/messages/prune.cjs.map +1 -1
  31. package/dist/cjs/prompts/activityLabel.cjs +24 -12
  32. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  33. package/dist/cjs/run.cjs +47 -22
  34. package/dist/cjs/run.cjs.map +1 -1
  35. package/dist/cjs/session/messageSerialization.cjs +6 -0
  36. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  37. package/dist/cjs/stream.cjs +21 -10
  38. package/dist/cjs/stream.cjs.map +1 -1
  39. package/dist/cjs/summarization/node.cjs +5 -0
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/ToolNode.cjs +253 -24
  42. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  43. package/dist/cjs/tools/handlers.cjs +1 -1
  44. package/dist/cjs/tools/search/tool.cjs +1 -1
  45. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  46. package/dist/cjs/utils/index.cjs +2 -2
  47. package/dist/esm/graphs/Graph.mjs +48 -14
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/graphs/MultiAgentGraph.mjs +35 -2
  50. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  51. package/dist/esm/hitl/askUserQuestion.mjs +3 -2
  52. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  53. package/dist/esm/instrumentation.mjs +18 -48
  54. package/dist/esm/instrumentation.mjs.map +1 -1
  55. package/dist/esm/langfuse.mjs +176 -28
  56. package/dist/esm/langfuse.mjs.map +1 -1
  57. package/dist/esm/langfuseConfig.mjs +10 -1
  58. package/dist/esm/langfuseConfig.mjs.map +1 -1
  59. package/dist/esm/langfuseRuntimeContext.mjs +21 -3
  60. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  61. package/dist/esm/langfuseRuntimeScope.mjs +39 -10
  62. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  63. package/dist/esm/langfuseSpanRegistry.mjs +91 -0
  64. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
  65. package/dist/esm/langfuseTraceShaping.mjs +121 -4
  66. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  67. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
  68. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  69. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
  70. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  71. package/dist/esm/llm/init.mjs +2 -2
  72. package/dist/esm/llm/invoke.mjs +5 -5
  73. package/dist/esm/llm/openai/index.mjs +1 -1
  74. package/dist/esm/main.mjs +8 -8
  75. package/dist/esm/messages/prune.mjs +13 -1
  76. package/dist/esm/messages/prune.mjs.map +1 -1
  77. package/dist/esm/prompts/activityLabel.mjs +24 -12
  78. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  79. package/dist/esm/run.mjs +47 -22
  80. package/dist/esm/run.mjs.map +1 -1
  81. package/dist/esm/session/messageSerialization.mjs +6 -0
  82. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  83. package/dist/esm/stream.mjs +21 -10
  84. package/dist/esm/stream.mjs.map +1 -1
  85. package/dist/esm/summarization/node.mjs +5 -0
  86. package/dist/esm/summarization/node.mjs.map +1 -1
  87. package/dist/esm/tools/ToolNode.mjs +254 -25
  88. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  89. package/dist/esm/tools/handlers.mjs +1 -1
  90. package/dist/esm/tools/search/tool.mjs +1 -1
  91. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  92. package/dist/esm/utils/index.mjs +2 -2
  93. package/dist/types/graphs/Graph.d.ts +19 -0
  94. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  95. package/dist/types/langfuse.d.ts +16 -8
  96. package/dist/types/langfuseConfig.d.ts +6 -0
  97. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  98. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  99. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  100. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  101. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  102. package/dist/types/session/types.d.ts +1 -0
  103. package/dist/types/tools/ToolNode.d.ts +7 -1
  104. package/dist/types/types/hitl.d.ts +8 -0
  105. package/dist/types/types/tools.d.ts +30 -0
  106. package/package.json +7 -4
  107. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  108. package/src/graphs/Graph.ts +69 -20
  109. package/src/graphs/MultiAgentGraph.ts +56 -2
  110. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  111. package/src/hitl/askUserQuestion.ts +14 -1
  112. package/src/instrumentation.ts +35 -77
  113. package/src/langfuse.ts +320 -43
  114. package/src/langfuseConfig.ts +24 -0
  115. package/src/langfuseRuntimeContext.ts +43 -1
  116. package/src/langfuseRuntimeScope.ts +94 -21
  117. package/src/langfuseSpanRegistry.ts +131 -0
  118. package/src/langfuseTraceShaping.ts +194 -7
  119. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  120. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  121. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  122. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  123. package/src/messages/prune.ts +12 -1
  124. package/src/prompts/activityLabel.ts +23 -6
  125. package/src/run.ts +76 -45
  126. package/src/scripts/activity-labels/captured.json +56 -0
  127. package/src/scripts/activity-labels/checks.cjs +205 -0
  128. package/src/scripts/activity-labels/corpus.cjs +473 -0
  129. package/src/scripts/activity-labels/report.cjs +203 -0
  130. package/src/scripts/activity-labels/rescore.cjs +102 -0
  131. package/src/scripts/activity-labels/run.ts +705 -0
  132. package/src/scripts/activity-labels/variants.ts +71 -0
  133. package/src/session/messageSerialization.ts +12 -1
  134. package/src/session/types.ts +1 -0
  135. package/src/specs/activity-label-prompt.test.ts +26 -10
  136. package/src/specs/agent-handoffs.test.ts +306 -0
  137. package/src/specs/langfuse-callbacks.test.ts +456 -0
  138. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  139. package/src/specs/langfuse-span-registry.test.ts +70 -0
  140. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  141. package/src/specs/prune.test.ts +38 -1
  142. package/src/stream.ts +70 -6
  143. package/src/summarization/node.ts +5 -0
  144. package/src/tools/ToolNode.ts +400 -9
  145. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  146. package/src/tools/__tests__/hitl.test.ts +58 -0
  147. package/src/types/hitl.ts +8 -0
  148. 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
  }
@@ -1403,7 +1403,18 @@ function createBoundedTruncationValue(
1403
1403
  _originalChars: originalChars,
1404
1404
  };
1405
1405
  if (JSON.stringify(emptyEnvelope).length > normalizedMaxChars) {
1406
- return null;
1406
+ /**
1407
+ * Even the empty envelope overflows the cap, so no preview survives —
1408
+ * but the result must still be a JSON OBJECT, never `null`. This value
1409
+ * replaces a `tool_use.input` / tool-call `args` on messages that are
1410
+ * mutated IN PLACE into graph state (`preFlightTruncateToolCallInputs`),
1411
+ * and Anthropic rejects a replayed non-object input with a 400
1412
+ * (`tool_use.input: Input should be an object`). Observed live: a tight
1413
+ * summarization budget shrank the cap below the envelope, nulled a
1414
+ * retained calculator call's input and args, and the next model call
1415
+ * failed on replay.
1416
+ */
1417
+ return {};
1407
1418
  }
1408
1419
 
1409
1420
  let low = 0;
@@ -37,8 +37,8 @@ export function truncateForLabel(value: string, maxLength: number): string {
37
37
  * Reduces a committed label to bounded single-line data.
38
38
  *
39
39
  * Sections in this prompt are delimited by blank lines, so a label carrying
40
- * embedded newlines could otherwise forge an apparent `Tool calls:` or
41
- * `Label:` section. Unlike every other input here, previous labels re-enter
40
+ * embedded newlines could otherwise forge an apparent entries section or
41
+ * `Header:` cue. Unlike every other input here, previous labels re-enter
42
42
  * the prompt on EVERY later batch, so one malformed result — plain model
43
43
  * noncompliance, or injection surfacing through a tool result — would
44
44
  * persistently steer unrelated later labels rather than affecting one. The
@@ -47,7 +47,10 @@ export function truncateForLabel(value: string, maxLength: number): string {
47
47
  * model's window and starve the run of labels entirely.
48
48
  */
49
49
  function sanitizePreviousLabel(label: string): string {
50
- return truncateForLabel(label.replace(/\s+/g, ' ').trim(), PREVIOUS_LABEL_LIMIT);
50
+ return truncateForLabel(
51
+ label.replace(/\s+/g, ' ').trim(),
52
+ PREVIOUS_LABEL_LIMIT
53
+ );
51
54
  }
52
55
 
53
56
  const ABORT_SERIALIZATION = Symbol('abort-label-serialization');
@@ -150,7 +153,11 @@ export function buildActivityLabelPrompt({
150
153
  * mean an earlier header may have been generated under ANOTHER agent's
151
154
  * weaker policy — so they share the excerpts' wholesale drop rather than
152
155
  * letting a handoff leak a looser agent's phrasing into this trace. */
153
- if (!excerptsRedacted && previousLabels != null && previousLabels.length > 0) {
156
+ if (
157
+ !excerptsRedacted &&
158
+ previousLabels != null &&
159
+ previousLabels.length > 0
160
+ ) {
154
161
  const recent = previousLabels
155
162
  .slice(-MAX_PREVIOUS_LABELS)
156
163
  .map(sanitizePreviousLabel)
@@ -192,7 +199,14 @@ export function buildActivityLabelPrompt({
192
199
  const shown = entries.slice(0, MAX_PROMPT_ENTRIES);
193
200
  const omitted = entries.length - shown.length;
194
201
  sections.push(
195
- 'Tool calls:\n' +
202
+ /** Frames the list as reference material, not the thing to
203
+ * transcribe. Ported from LibreChat's fallback builder (its
204
+ * runtime.ts documents that without this the model "hands back a
205
+ * transcription" of the list) after the eval harness measured it
206
+ * across three independent sweeps: fewer template-redundancy and
207
+ * length violations than a bare `Tool calls:` heading, with no
208
+ * per-case regressions (agents #360). */
209
+ 'What it called, and what came back (do not restate these):\n' +
196
210
  shown
197
211
  .map((entry) => {
198
212
  const input = clip(
@@ -220,6 +234,9 @@ export function buildActivityLabelPrompt({
220
234
  : '')
221
235
  );
222
236
  }
223
- sections.push('Label:');
237
+ /** The fallback builder's terminal cue, measured alongside the heading
238
+ * (same sweeps). The default system prompt already describes the
239
+ * output as "the header of a collapsed activity group". */
240
+ sections.push('Header:');
224
241
  return sections.join('\n\n');
225
242
  }