@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
@@ -28,6 +28,13 @@ const TRACE_INPUT = LangfuseOtelSpanAttributes.TRACE_INPUT;
28
28
  const TRACE_OUTPUT = LangfuseOtelSpanAttributes.TRACE_OUTPUT;
29
29
  const OBSERVATION_TYPE = LangfuseOtelSpanAttributes.OBSERVATION_TYPE;
30
30
  const TRACE_TAGS = LangfuseOtelSpanAttributes.TRACE_TAGS;
31
+ const METADATA_LANGGRAPH_NODE = `${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langgraph_node`;
32
+
33
+ /** The outer workflow node: a non-root LangGraph node span whose
34
+ * `langgraph_node` metadata equals its name. */
35
+ function createWorkflowNodeSpan(name: string): TestSpan {
36
+ return createSpan(name, { [METADATA_LANGGRAPH_NODE]: name }, 'parent-1');
37
+ }
31
38
 
32
39
  describe('shouldDropLangfuseSpan', () => {
33
40
  it('drops langgraph __start__ seed spans', () => {
@@ -127,6 +134,177 @@ describe('shapeLangfuseSpan', () => {
127
134
  expect(span.name).toBe('tool-dispatch');
128
135
  });
129
136
 
137
+ it('counts id-bearing invalid_tool_calls in the dispatch input (mixed and invalid-only)', () => {
138
+ /** ToolNode pairs attributable invalid calls with synthesized error
139
+ * results (and routes invalid-only turns on them alone), so the span
140
+ * input must include them — invalid-only used to find zero calls and
141
+ * keep the full serialized graph state as the input. */
142
+ const mixed = [
143
+ {
144
+ type: 'ai',
145
+ id: 'ai_mixed_span',
146
+ tool_calls: [{ name: 'echo', args: { command: 'hi' }, id: 'tc_ok' }],
147
+ invalid_tool_calls: [
148
+ {
149
+ name: 'echo',
150
+ args: '"raw unparsed',
151
+ id: 'tc_bad',
152
+ error: 'Malformed args.',
153
+ type: 'invalid_tool_call',
154
+ },
155
+ { name: 'echo', args: 'no-id — excluded', error: 'Malformed args.' },
156
+ {
157
+ name: 'echo',
158
+ args: 'empty-id — excluded',
159
+ id: '',
160
+ error: 'Malformed args.',
161
+ },
162
+ {
163
+ name: 'web_search',
164
+ args: 'server-tool — excluded',
165
+ id: 'srvtoolu_xyz',
166
+ error: 'Malformed args.',
167
+ },
168
+ {
169
+ args: 'nameless — included with the unknown fallback',
170
+ id: 'tc_nameless',
171
+ error: 'Malformed args.',
172
+ },
173
+ ],
174
+ },
175
+ ];
176
+ const mixedSpan = createSpan(
177
+ 'tools=agent_abc',
178
+ { [INPUT]: JSON.stringify({ messages: mixed }) },
179
+ 'parent-1'
180
+ );
181
+ shapeLangfuseSpan(mixedSpan);
182
+ expect(JSON.parse(mixedSpan.attributes[INPUT] as string)).toEqual([
183
+ { name: 'echo', args: { command: 'hi' } },
184
+ { name: 'echo', args: '"raw unparsed' },
185
+ { name: 'unknown', args: 'nameless — included with the unknown fallback' },
186
+ ]);
187
+
188
+ const invalidOnly = [
189
+ {
190
+ type: 'ai',
191
+ id: 'ai_invalid_only_span',
192
+ tool_calls: [],
193
+ invalid_tool_calls: [
194
+ {
195
+ name: 'echo',
196
+ args: 'garbage',
197
+ id: 'tc_solo',
198
+ error: 'Malformed args.',
199
+ type: 'invalid_tool_call',
200
+ },
201
+ ],
202
+ },
203
+ ];
204
+ const invalidOnlySpan = createSpan(
205
+ 'tools=agent_abc',
206
+ { [INPUT]: JSON.stringify({ messages: invalidOnly }) },
207
+ 'parent-1'
208
+ );
209
+ shapeLangfuseSpan(invalidOnlySpan);
210
+ expect(JSON.parse(invalidOnlySpan.attributes[INPUT] as string)).toEqual([
211
+ { name: 'echo', args: 'garbage' },
212
+ ]);
213
+ });
214
+
215
+ it('excludes invalid calls when ToolNode would skip them (array state / id-less message)', () => {
216
+ /** Mirrors ToolNode's canPromoteInvalidCalls gate: a bare-array state
217
+ * returns a plain output list (invalid handling skipped) and an id-less
218
+ * message cannot take the reducer upsert — the span must not report
219
+ * those calls as pending work. Valid calls still count. */
220
+ const invalidCall = {
221
+ name: 'echo',
222
+ args: 'garbage',
223
+ id: 'tc_gated',
224
+ error: 'Malformed args.',
225
+ type: 'invalid_tool_call',
226
+ };
227
+ const arrayStateSpan = createSpan(
228
+ 'tools=agent_abc',
229
+ {
230
+ [INPUT]: JSON.stringify([
231
+ {
232
+ type: 'ai',
233
+ id: 'ai_array_span',
234
+ tool_calls: [{ name: 'echo', args: { command: 'hi' }, id: 'tc_ok' }],
235
+ invalid_tool_calls: [invalidCall],
236
+ },
237
+ ]),
238
+ },
239
+ 'parent-1'
240
+ );
241
+ shapeLangfuseSpan(arrayStateSpan);
242
+ expect(JSON.parse(arrayStateSpan.attributes[INPUT] as string)).toEqual([
243
+ { name: 'echo', args: { command: 'hi' } },
244
+ ]);
245
+
246
+ const idlessSpan = createSpan(
247
+ 'tools=agent_abc',
248
+ {
249
+ [INPUT]: JSON.stringify({
250
+ messages: [
251
+ {
252
+ type: 'ai',
253
+ tool_calls: [{ name: 'echo', args: { command: 'hi' }, id: 'tc_ok' }],
254
+ invalid_tool_calls: [invalidCall],
255
+ },
256
+ ],
257
+ }),
258
+ },
259
+ 'parent-1'
260
+ );
261
+ shapeLangfuseSpan(idlessSpan);
262
+ expect(JSON.parse(idlessSpan.attributes[INPUT] as string)).toEqual([
263
+ { name: 'echo', args: { command: 'hi' } },
264
+ ]);
265
+ });
266
+
267
+ it('excludes invalid calls already answered by a ToolMessage in the state', () => {
268
+ /** Mirrors ToolNode's !toolMessageIds.has(id) execution filter: an
269
+ * answered invalid call is not pending work, even when the same turn
270
+ * still has a pending valid call. */
271
+ const span = createSpan(
272
+ 'tools=agent_abc',
273
+ {
274
+ [INPUT]: JSON.stringify({
275
+ messages: [
276
+ {
277
+ type: 'ai',
278
+ id: 'ai_answered_invalid',
279
+ tool_calls: [
280
+ { name: 'echo', args: { command: 'hi' }, id: 'tc_pending' },
281
+ ],
282
+ invalid_tool_calls: [
283
+ {
284
+ name: 'echo',
285
+ args: 'garbage',
286
+ id: 'tc_answered_invalid',
287
+ error: 'Malformed args.',
288
+ type: 'invalid_tool_call',
289
+ },
290
+ ],
291
+ },
292
+ {
293
+ type: 'tool',
294
+ tool_call_id: 'tc_answered_invalid',
295
+ content: 'Error: Malformed args.',
296
+ },
297
+ ],
298
+ }),
299
+ },
300
+ 'parent-1'
301
+ );
302
+ shapeLangfuseSpan(span);
303
+ expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
304
+ { name: 'echo', args: { command: 'hi' } },
305
+ ]);
306
+ });
307
+
130
308
  it('keeps a stable tool-dispatch shape when no tool calls are found', () => {
131
309
  const original = JSON.stringify({
132
310
  messages: [{ type: 'human', content: 'hi' }],
@@ -142,6 +320,75 @@ describe('shapeLangfuseSpan', () => {
142
320
  expect(span.attributes[INPUT]).toBe(original);
143
321
  });
144
322
 
323
+ it('reduces ephemeral workflow-agent node ids to agent observations named by sender', () => {
324
+ const span = createWorkflowNodeSpan(
325
+ 'bedrock__claude-sonnet-5___ClickHouse Agent'
326
+ );
327
+ shapeLangfuseSpan(span);
328
+ expect(span.name).toBe('ClickHouse Agent');
329
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('agent');
330
+ });
331
+
332
+ it('strips parallel-instance index suffixes from ephemeral agent ids', () => {
333
+ const span = createWorkflowNodeSpan('openAI__gpt-4o___GPT-4o____1');
334
+ shapeLangfuseSpan(span);
335
+ expect(span.name).toBe('GPT-4o');
336
+ });
337
+
338
+ it('restores encoded colons in ephemeral agent sender names', () => {
339
+ const span = createWorkflowNodeSpan('openAI__gpt-4o___alias__variant');
340
+ shapeLangfuseSpan(span);
341
+ expect(span.name).toBe('alias:variant');
342
+ });
343
+
344
+ it('keeps persisted agent ids and senderless ephemeral ids unchanged', () => {
345
+ const persisted = createWorkflowNodeSpan('agent_okvkCroi6wXM4-7BY4ud1');
346
+ shapeLangfuseSpan(persisted);
347
+ expect(persisted.name).toBe('agent_okvkCroi6wXM4-7BY4ud1');
348
+
349
+ const senderless = createWorkflowNodeSpan('openAI__gpt-4o');
350
+ shapeLangfuseSpan(senderless);
351
+ expect(senderless.name).toBe('openAI__gpt-4o');
352
+ });
353
+
354
+ it('does not rename tool observations whose names embed triple underscores', () => {
355
+ const span = createSpan(
356
+ 'server__toolkit___lookup',
357
+ { [OBSERVATION_TYPE]: 'tool' },
358
+ 'parent-1'
359
+ );
360
+ shapeLangfuseSpan(span);
361
+ expect(span.name).toBe('server__toolkit___lookup');
362
+ });
363
+
364
+ it('only renames spans carrying matching langgraph node metadata', () => {
365
+ const runName = createSpan('LibreChat Agent: Ops___EU', {}, 'parent-1');
366
+ shapeLangfuseSpan(runName);
367
+ expect(runName.name).toBe('LibreChat Agent: Ops___EU');
368
+
369
+ const ordinaryChain = createSpan('pipeline__stage___EU', {}, 'parent-1');
370
+ shapeLangfuseSpan(ordinaryChain);
371
+ expect(ordinaryChain.name).toBe('pipeline__stage___EU');
372
+ expect(ordinaryChain.attributes[OBSERVATION_TYPE]).toBeUndefined();
373
+
374
+ const mismatchedNode = createSpan(
375
+ 'pipeline__stage___EU',
376
+ { [METADATA_LANGGRAPH_NODE]: 'some-other-node' },
377
+ 'parent-1'
378
+ );
379
+ shapeLangfuseSpan(mismatchedNode);
380
+ expect(mismatchedNode.name).toBe('pipeline__stage___EU');
381
+ });
382
+
383
+ it('never renames root observations, even with an encoded-id shape', () => {
384
+ const span = createSpan('bedrock__claude-sonnet-5___ClickHouse Agent', {
385
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
386
+ [METADATA_LANGGRAPH_NODE]: 'bedrock__claude-sonnet-5___ClickHouse Agent',
387
+ });
388
+ shapeLangfuseSpan(span);
389
+ expect(span.name).toBe('bedrock__claude-sonnet-5___ClickHouse Agent');
390
+ });
391
+
145
392
  it('sets root span and trace input/output to the question and answer', () => {
146
393
  const span = createSpan('LibreChat Agent', {
147
394
  [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
@@ -262,4 +509,51 @@ describe('shapeLangfuseSpan', () => {
262
509
  expect(span.attributes[TRACE_INPUT]).toBe('Generate a title');
263
510
  expect(span.attributes[TRACE_OUTPUT]).toBe('A useful title');
264
511
  });
512
+
513
+ it('keeps a generation root\'s full observation input while reducing trace input', () => {
514
+ /** The activity-label path: a bare model.invoke traces the generation
515
+ * as its own root, so the observation input is the ONLY record of
516
+ * the system prompt. The exact shape @langfuse/langchain exports. */
517
+ const originalInput = JSON.stringify([
518
+ { role: 'system', content: 'Write a short label describing…' },
519
+ { role: 'user', content: 'Tool calls:\n- bash(ls) → ok\n\nLabel:' },
520
+ ]);
521
+ const originalOutput = JSON.stringify({
522
+ role: 'assistant',
523
+ content: 'Confirmed /mnt/data persists',
524
+ });
525
+ const span = createSpan('LibreChat Activity Label', {
526
+ [OBSERVATION_TYPE]: 'generation',
527
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'activity-label']),
528
+ [INPUT]: originalInput,
529
+ [OUTPUT]: originalOutput,
530
+ });
531
+
532
+ shapeLangfuseSpan(span);
533
+
534
+ expect(span.name).toBe('llm');
535
+ expect(span.attributes[INPUT]).toBe(originalInput);
536
+ expect(span.attributes[OUTPUT]).toBe(originalOutput);
537
+ expect(span.attributes[TRACE_INPUT]).toBe(
538
+ 'Tool calls:\n- bash(ls) → ok\n\nLabel:'
539
+ );
540
+ expect(span.attributes[TRACE_OUTPUT]).toBe(originalOutput);
541
+ });
542
+
543
+ it('still reduces observation input on non-generation roots', () => {
544
+ const span = createSpan('LibreChat Agent', {
545
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
546
+ [INPUT]: JSON.stringify({
547
+ messages: [
548
+ { type: 'system', content: 'You are helpful.' },
549
+ { type: 'human', content: 'What is ClickHouse?' },
550
+ ],
551
+ }),
552
+ });
553
+
554
+ shapeLangfuseSpan(span);
555
+
556
+ expect(span.attributes[INPUT]).toBe('What is ClickHouse?');
557
+ expect(span.attributes[TRACE_INPUT]).toBe('What is ClickHouse?');
558
+ });
265
559
  });
@@ -1444,7 +1444,44 @@ describe('Prune Messages Tests', () => {
1444
1444
  expect(calculateMaxToolCallInputChars(0)).toBe(200_000);
1445
1445
  expect(calculateMaxToolCallInputChars(1_000)).toBe(600);
1446
1446
  expect(calculateMaxToolCallInputChars(1_000_000)).toBe(200_000);
1447
- expect(serializeToolCallInput(undefined, 4)).toBe('null');
1447
+ // Even below the envelope floor, serialized args stay a JSON object —
1448
+ // 'null' here poisoned replayed tool calls (Anthropic 400s a non-object
1449
+ // tool_use.input).
1450
+ expect(serializeToolCallInput(undefined, 4)).toBe('{}');
1451
+ });
1452
+
1453
+ it('never nulls an input when the cap is below the truncation envelope', () => {
1454
+ // Regression: a tight summarization budget can shrink the per-input cap
1455
+ // below the `{_truncated, _originalChars}` envelope size (~38 chars).
1456
+ // The projection used to return `null` for BOTH the inline block input
1457
+ // and the tool_calls args; the nulls were written back into graph state
1458
+ // by preFlightTruncateToolCallInputs and later replayed to Anthropic as
1459
+ // `tool_use.input: null` → 400 "Input should be an object".
1460
+ const chunk = new AIMessageChunk({
1461
+ content: [
1462
+ {
1463
+ type: 'tool_use',
1464
+ id: 'tiny-cap-call',
1465
+ name: 'calculator',
1466
+ // Streaming leaves the raw JSON string on the block.
1467
+ input: '{"input": "670592745 / 99991"}',
1468
+ },
1469
+ ],
1470
+ tool_calls: [
1471
+ {
1472
+ id: 'tiny-cap-call',
1473
+ name: 'calculator',
1474
+ args: { input: '670592745 / 99991' },
1475
+ },
1476
+ ],
1477
+ });
1478
+
1479
+ const [projected] = projectToolCallInputs([chunk], 20);
1480
+ const block = (
1481
+ (projected as AIMessageChunk).content as Array<Record<string, unknown>>
1482
+ )[0];
1483
+ expect(block.input).toEqual({});
1484
+ expect((projected as AIMessageChunk).tool_calls?.[0].args).toEqual({});
1448
1485
  });
1449
1486
 
1450
1487
  it('returns the original array when every input is already safe and bounded', () => {
package/src/stream.ts CHANGED
@@ -162,6 +162,13 @@ function isEagerExecutionExcludedTool(
162
162
  if (excluded != null && excluded.includes(name)) {
163
163
  return true;
164
164
  }
165
+ // Run-scoped circuit breaker: once a prestart for this tool diverged from
166
+ // the final request ("changed after eager execution started"), stop
167
+ // prestarting it so the model's retry executes normally instead of
168
+ // re-diverging in a loop (LibreChat#14371).
169
+ if (graph.eagerEventToolSuppressions?.has(name) === true) {
170
+ return true;
171
+ }
165
172
  // A code-session participant writes to the shared sandbox, so it is
166
173
  // side-effecting: never prestart it speculatively (a revised/superseded turn
167
174
  // would leave the write applied). Implies exclusion without the host having
@@ -1006,8 +1013,9 @@ function recordEagerToolCallChunks(args: {
1006
1013
  graph: StandardGraph;
1007
1014
  stepKey: string;
1008
1015
  toolCallChunks?: ToolCallChunk[];
1016
+ seal?: StreamedToolCallSeal;
1009
1017
  }): void {
1010
- const { graph, stepKey, toolCallChunks } = args;
1018
+ const { graph, stepKey, toolCallChunks, seal } = args;
1011
1019
  if (toolCallChunks == null || toolCallChunks.length === 0) {
1012
1020
  return;
1013
1021
  }
@@ -1054,13 +1062,33 @@ function recordEagerToolCallChunks(args: {
1054
1062
  const argsText = isRepeatedObservedFragment
1055
1063
  ? existing.argsText
1056
1064
  : mergeToolCallArgsText(existing.argsText, incomingArgs);
1065
+ const index = getEagerToolChunkIndex(toolCallChunk) ?? existing.index;
1066
+ // Only a chunk whose explicit adapter seal covers THIS call may supply a
1067
+ // full-args restatement (OpenAI Responses `arguments.done`). Pure-signal
1068
+ // seals carry empty args and never set this.
1069
+ const sealCoversChunk =
1070
+ seal != null &&
1071
+ (seal.kind === 'all' ||
1072
+ (seal.kind === 'single' &&
1073
+ ((seal.id != null && seal.id === id) ||
1074
+ (seal.index != null && seal.index === index))));
1057
1075
  const next = {
1058
1076
  id,
1059
1077
  name,
1060
1078
  argsText,
1061
- index: getEagerToolChunkIndex(toolCallChunk) ?? existing.index,
1079
+ // Canonical accumulation length: LangChain concats fragments verbatim
1080
+ // to build the final request, and every reconciliation branch above
1081
+ // yields text no longer than that concat — equal exactly when every
1082
+ // merge was a pure append. Tracking the length (not the text) keeps
1083
+ // cumulative/restating streams from retaining every prefix.
1084
+ rawArgsLength: (existing.rawArgsLength ?? 0) + incomingArgs.length,
1085
+ index,
1062
1086
  lastArgsFragment:
1063
1087
  incomingArgs !== '' ? incomingArgs : existing.lastArgsFragment,
1088
+ sealedArgsFragment:
1089
+ sealCoversChunk && incomingArgs !== ''
1090
+ ? incomingArgs
1091
+ : existing.sealedArgsFragment,
1064
1092
  };
1065
1093
  graph.eagerEventToolCallChunks.set(key, next);
1066
1094
  }
@@ -1095,6 +1123,7 @@ function getStreamedReadyToolCalls(args: {
1095
1123
  const readyEntries: Array<{
1096
1124
  key: string;
1097
1125
  state: t.EagerEventToolCallChunkState;
1126
+ sealedByAdapter: boolean;
1098
1127
  }> = [];
1099
1128
 
1100
1129
  for (const [key, state] of graph.eagerEventToolCallChunks) {
@@ -1124,7 +1153,11 @@ function getStreamedReadyToolCalls(args: {
1124
1153
  isSealedByLaterChunk ||
1125
1154
  isSealedExplicitly
1126
1155
  ) {
1127
- readyEntries.push({ key, state });
1156
+ readyEntries.push({
1157
+ key,
1158
+ state,
1159
+ sealedByAdapter: isSealedExplicitly || seal?.kind === 'all',
1160
+ });
1128
1161
  }
1129
1162
  }
1130
1163
 
@@ -1143,11 +1176,37 @@ function getStreamedReadyToolCalls(args: {
1143
1176
 
1144
1177
  return readyEntries
1145
1178
  .sort((left, right) => (left.state.index ?? 0) - (right.state.index ?? 0))
1146
- .flatMap(({ state }) => {
1179
+ .flatMap(({ state, sealedByAdapter }) => {
1147
1180
  const args = coerceRecordArgs(state.argsText);
1148
1181
  if (args == null) {
1149
1182
  return [];
1150
1183
  }
1184
+ // The final request's args come from LangChain's canonical verbatim
1185
+ // concatenation of fragments, while `argsText` reconciles provider
1186
+ // quirks with lossy heuristics that can also swallow legitimately
1187
+ // repetitive payload fragments (LibreChat#14371). `argsText` can never
1188
+ // be LONGER than the plain concat, so length equality proves it IS the
1189
+ // canonical accumulation.
1190
+ const isCanonicalAccumulation =
1191
+ state.rawArgsLength != null &&
1192
+ state.argsText.length === state.rawArgsLength;
1193
+ // Adapter seals may instead restate the finished call's full args on
1194
+ // the seal chunk itself (OpenAI Responses
1195
+ // `function_call_arguments.done`). Only when the seal-carrying chunk
1196
+ // supplied that fragment AND the accumulated text IS that restatement
1197
+ // has the adapter vouched for it — plain concatenation intentionally
1198
+ // differs there. Pure-signal seals (Bedrock contentBlockStop,
1199
+ // `args: ''`) never qualify.
1200
+ const isAuthoritativeRestatement =
1201
+ sealedByAdapter &&
1202
+ state.sealedArgsFragment != null &&
1203
+ state.sealedArgsFragment === state.argsText;
1204
+ // Prestarting an unconfirmed snapshot trips the "changed after eager
1205
+ // execution started" guard and burns a retry loop — leave unconfirmed
1206
+ // calls to normal ToolNode execution with final args.
1207
+ if (!isCanonicalAccumulation && !isAuthoritativeRestatement) {
1208
+ return [];
1209
+ }
1151
1210
  return [
1152
1211
  {
1153
1212
  id: state.id,
@@ -1586,6 +1645,7 @@ export class ChatModelStreamHandler implements t.EventHandler {
1586
1645
  graph,
1587
1646
  stepKey,
1588
1647
  toolCallChunks: chunk.tool_call_chunks,
1648
+ seal: streamedToolCallSeal,
1589
1649
  });
1590
1650
  }
1591
1651
  await handleToolCallChunks({
@@ -2325,7 +2385,9 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2325
2385
  return;
2326
2386
  }
2327
2387
 
2328
- for (const contentPart of getDeltaContentParts(messageDelta.delta.content)) {
2388
+ for (const contentPart of getDeltaContentParts(
2389
+ messageDelta.delta.content
2390
+ )) {
2329
2391
  updateContent(runStep.index, contentPart);
2330
2392
  }
2331
2393
  } else if (
@@ -2354,7 +2416,9 @@ export function createContentAggregator(): t.ContentAggregatorResult {
2354
2416
  return;
2355
2417
  }
2356
2418
 
2357
- for (const contentPart of getDeltaContentParts(reasoningDelta.delta.content)) {
2419
+ for (const contentPart of getDeltaContentParts(
2420
+ reasoningDelta.delta.content
2421
+ )) {
2358
2422
  updateContent(runStep.index, contentPart);
2359
2423
  }
2360
2424
  } else if (event === GraphEvents.ON_RUN_STEP_DELTA) {
@@ -1091,6 +1091,11 @@ export function createSummarizeNode({
1091
1091
  metadata: {
1092
1092
  ...config.metadata,
1093
1093
  agent_id: request.agentId,
1094
+ /** Canonical agent-identity key, overwritten by every component
1095
+ * that stamps identity (graph model path, ToolNode, here) so the
1096
+ * closest stamper always wins via spread order — Langfuse scope
1097
+ * trust relies on it (see `isForeignScope`). */
1098
+ agentId: request.agentId,
1094
1099
  summarization_provider: clientConfig.provider,
1095
1100
  summarization_model: clientConfig.modelName,
1096
1101
  /**