@librechat/agents 3.3.8 → 3.3.10

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 (156) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +4 -0
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +66 -13
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs +35 -2
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  7. package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
  8. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  9. package/dist/cjs/instrumentation.cjs +18 -48
  10. package/dist/cjs/instrumentation.cjs.map +1 -1
  11. package/dist/cjs/langfuse.cjs +174 -29
  12. package/dist/cjs/langfuse.cjs.map +1 -1
  13. package/dist/cjs/langfuseConfig.cjs +12 -0
  14. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  15. package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
  16. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  17. package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
  18. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  19. package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
  20. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
  21. package/dist/cjs/langfuseTraceShaping.cjs +121 -4
  22. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  23. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
  24. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  25. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
  26. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  27. package/dist/cjs/llm/init.cjs +3 -3
  28. package/dist/cjs/llm/invoke.cjs +5 -5
  29. package/dist/cjs/llm/openai/index.cjs +1 -1
  30. package/dist/cjs/main.cjs +10 -10
  31. package/dist/cjs/messages/prune.cjs +13 -1
  32. package/dist/cjs/messages/prune.cjs.map +1 -1
  33. package/dist/cjs/prompts/activityLabel.cjs +24 -12
  34. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  35. package/dist/cjs/run.cjs +57 -22
  36. package/dist/cjs/run.cjs.map +1 -1
  37. package/dist/cjs/session/messageSerialization.cjs +6 -0
  38. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  39. package/dist/cjs/stream.cjs +21 -10
  40. package/dist/cjs/stream.cjs.map +1 -1
  41. package/dist/cjs/summarization/node.cjs +5 -0
  42. package/dist/cjs/summarization/node.cjs.map +1 -1
  43. package/dist/cjs/tools/ToolNode.cjs +253 -24
  44. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  45. package/dist/cjs/tools/handlers.cjs +1 -1
  46. package/dist/cjs/tools/search/tool.cjs +1 -1
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  48. package/dist/cjs/utils/index.cjs +2 -2
  49. package/dist/esm/agents/AgentContext.mjs +4 -0
  50. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  51. package/dist/esm/graphs/Graph.mjs +67 -14
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs +35 -2
  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/prune.mjs +13 -1
  80. package/dist/esm/messages/prune.mjs.map +1 -1
  81. package/dist/esm/prompts/activityLabel.mjs +24 -12
  82. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  83. package/dist/esm/run.mjs +57 -22
  84. package/dist/esm/run.mjs.map +1 -1
  85. package/dist/esm/session/messageSerialization.mjs +6 -0
  86. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  87. package/dist/esm/stream.mjs +21 -10
  88. package/dist/esm/stream.mjs.map +1 -1
  89. package/dist/esm/summarization/node.mjs +5 -0
  90. package/dist/esm/summarization/node.mjs.map +1 -1
  91. package/dist/esm/tools/ToolNode.mjs +254 -25
  92. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  93. package/dist/esm/tools/handlers.mjs +1 -1
  94. package/dist/esm/tools/search/tool.mjs +1 -1
  95. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  96. package/dist/esm/utils/index.mjs +2 -2
  97. package/dist/types/agents/AgentContext.d.ts +2 -0
  98. package/dist/types/graphs/Graph.d.ts +24 -0
  99. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  100. package/dist/types/langfuse.d.ts +16 -8
  101. package/dist/types/langfuseConfig.d.ts +6 -0
  102. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  103. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  104. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  105. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  106. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  107. package/dist/types/run.d.ts +7 -0
  108. package/dist/types/session/types.d.ts +1 -0
  109. package/dist/types/tools/ToolNode.d.ts +7 -1
  110. package/dist/types/types/hitl.d.ts +8 -0
  111. package/dist/types/types/tools.d.ts +30 -0
  112. package/package.json +7 -4
  113. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  114. package/src/agents/AgentContext.ts +5 -0
  115. package/src/graphs/Graph.ts +108 -20
  116. package/src/graphs/MultiAgentGraph.ts +56 -2
  117. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  118. package/src/hitl/askUserQuestion.ts +14 -1
  119. package/src/instrumentation.ts +35 -77
  120. package/src/langfuse.ts +320 -43
  121. package/src/langfuseConfig.ts +24 -0
  122. package/src/langfuseRuntimeContext.ts +43 -1
  123. package/src/langfuseRuntimeScope.ts +94 -21
  124. package/src/langfuseSpanRegistry.ts +131 -0
  125. package/src/langfuseTraceShaping.ts +194 -7
  126. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  127. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  128. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  129. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  130. package/src/messages/prune.ts +12 -1
  131. package/src/prompts/activityLabel.ts +23 -6
  132. package/src/run.ts +91 -45
  133. package/src/scripts/activity-labels/captured.json +56 -0
  134. package/src/scripts/activity-labels/checks.cjs +205 -0
  135. package/src/scripts/activity-labels/corpus.cjs +473 -0
  136. package/src/scripts/activity-labels/report.cjs +203 -0
  137. package/src/scripts/activity-labels/rescore.cjs +102 -0
  138. package/src/scripts/activity-labels/run.ts +705 -0
  139. package/src/scripts/activity-labels/variants.ts +71 -0
  140. package/src/session/messageSerialization.ts +12 -1
  141. package/src/session/types.ts +1 -0
  142. package/src/specs/activity-label-prompt.test.ts +26 -10
  143. package/src/specs/agent-handoffs.test.ts +306 -0
  144. package/src/specs/discovered-tools.test.ts +217 -0
  145. package/src/specs/langfuse-callbacks.test.ts +456 -0
  146. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  147. package/src/specs/langfuse-span-registry.test.ts +70 -0
  148. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  149. package/src/specs/prune.test.ts +38 -1
  150. package/src/stream.ts +70 -6
  151. package/src/summarization/node.ts +5 -0
  152. package/src/tools/ToolNode.ts +400 -9
  153. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  154. package/src/tools/__tests__/hitl.test.ts +58 -0
  155. package/src/types/hitl.ts +8 -0
  156. package/src/types/tools.ts +35 -1
@@ -256,7 +256,9 @@ function expectChildSpanParentName({
256
256
  const children = starts.filter((record) => record.name === childName);
257
257
  expect(children).not.toHaveLength(0);
258
258
  for (const child of children) {
259
- const parent = starts.find((record) => record.spanId === child.parentSpanId);
259
+ const parent = starts.find(
260
+ (record) => record.spanId === child.parentSpanId
261
+ );
260
262
  expect(parent?.name.startsWith(parentNamePrefix)).toBe(true);
261
263
  }
262
264
  }
@@ -560,6 +562,141 @@ describe('Langfuse per-run routing integration', () => {
560
562
  }
561
563
  });
562
564
 
565
+ it('detaches root observations from foreign ambient spans', async () => {
566
+ const tenantId = 'tenant-ambient';
567
+ const foreignTraceId = 'f0e1d2c3b4a5968778695a4b3c2d1e0f';
568
+ const foreignSpanId = 'a1b2c3d4e5f60718';
569
+ initializeLangfuseTracing(tenantLangfuse(tenantId));
570
+
571
+ // Simulates a host running agent code inside its own OpenTelemetry span
572
+ // (HTTP server auto-instrumentation on the global provider): the span is
573
+ // never exported to Langfuse, so inheriting it would orphan the trace
574
+ // root, merge concurrent runs in one request into a single trace, and
575
+ // bypass deterministic trace ids.
576
+ const foreignSpan = otelTrace.wrapSpanContext({
577
+ traceId: foreignTraceId,
578
+ spanId: foreignSpanId,
579
+ traceFlags: 1,
580
+ });
581
+ await otelContext.with(
582
+ otelTrace.setSpan(otelContext.active(), foreignSpan),
583
+ () => runTenantFlow(tenantId)
584
+ );
585
+
586
+ const starts = startsForTenant(tenantId);
587
+ expect(starts).not.toHaveLength(0);
588
+ expect(
589
+ starts.filter(
590
+ (record) =>
591
+ record.traceId === foreignTraceId ||
592
+ record.parentSpanId === foreignSpanId
593
+ )
594
+ ).toHaveLength(0);
595
+
596
+ const agentRoot = starts.find(
597
+ (record) => record.name === `LibreChat Agent: Parent ${tenantId}`
598
+ );
599
+ expect(agentRoot?.traceId).toBe(traceIdFromSeed(`routing-${tenantId}`));
600
+ expect(agentRoot?.parentSpanId).toBeUndefined();
601
+
602
+ const titleRoot = starts.find(
603
+ (record) => record.name === `LibreChat Title: Parent ${tenantId}`
604
+ );
605
+ expect(titleRoot?.traceId).toBe(
606
+ traceIdFromSeed(`title-routing-${tenantId}`)
607
+ );
608
+ expect(titleRoot?.parentSpanId).toBeUndefined();
609
+ });
610
+
611
+ it('keeps root observations nested under Langfuse-managed ambient spans', async () => {
612
+ const tenantId = 'tenant-managed';
613
+ const langfuse = tenantLangfuse(tenantId);
614
+ initializeLangfuseTracing(langfuse);
615
+
616
+ let hostSpan: MockSpan | undefined;
617
+ await withLangfuseRuntimeScope({ langfuse }, async () => {
618
+ hostSpan = createMockSpan('host-group');
619
+ await otelContext.with(
620
+ otelTrace.setSpan(otelContext.active(), hostSpan as never),
621
+ () => runTenantFlow(tenantId)
622
+ );
623
+ });
624
+
625
+ const hostSpanContext = hostSpan?.spanContext() as {
626
+ traceId: string;
627
+ spanId: string;
628
+ };
629
+ const agentRoot = startsForTenant(tenantId).find(
630
+ (record) => record.name === `LibreChat Agent: Parent ${tenantId}`
631
+ );
632
+ expect(agentRoot?.traceId).toBe(hostSpanContext.traceId);
633
+ expect(agentRoot?.parentSpanId).toBe(hostSpanContext.spanId);
634
+ });
635
+
636
+ it('detaches root observations from managed ambient spans of another destination', async () => {
637
+ const hostTenantId = 'tenant-managed-a';
638
+ const runTenantId = 'tenant-managed-b';
639
+ const hostLangfuse = tenantLangfuse(hostTenantId);
640
+ initializeLangfuseTracing(hostLangfuse);
641
+ initializeLangfuseTracing(tenantLangfuse(runTenantId));
642
+
643
+ let hostSpan: MockSpan | undefined;
644
+ await withLangfuseRuntimeScope({ langfuse: hostLangfuse }, async () => {
645
+ hostSpan = createMockSpan('host-group');
646
+ });
647
+
648
+ // A managed span is only a safe parent for runs exporting to the SAME
649
+ // destination; nesting tenant-B under tenant-A's span would leave B's
650
+ // trace dangling in B's project with A's trace id.
651
+ await otelContext.with(
652
+ otelTrace.setSpan(otelContext.active(), hostSpan as never),
653
+ () => runTenantFlow(runTenantId)
654
+ );
655
+
656
+ const hostSpanContext = hostSpan?.spanContext() as {
657
+ traceId: string;
658
+ spanId: string;
659
+ };
660
+ const agentRoot = startsForTenant(runTenantId).find(
661
+ (record) => record.name === `LibreChat Agent: Parent ${runTenantId}`
662
+ );
663
+ expect(agentRoot?.traceId).toBe(traceIdFromSeed(`routing-${runTenantId}`));
664
+ expect(agentRoot?.traceId).not.toBe(hostSpanContext.traceId);
665
+ expect(agentRoot?.parentSpanId).toBeUndefined();
666
+ });
667
+
668
+ it('generates a scope stamp for directly-constructed graphs without a run id', async () => {
669
+ const { StandardGraph } = await import('@/graphs/Graph');
670
+ const buildGraph = (): { langfuseScopeRunId: string } =>
671
+ new StandardGraph({
672
+ agents: [createAgent('stampless')],
673
+ langfuse: tenantLangfuse('stampless'),
674
+ }) as unknown as { langfuseScopeRunId: string };
675
+
676
+ const graphA = buildGraph();
677
+ const graphB = buildGraph();
678
+ expect(graphA.langfuseScopeRunId).toEqual(expect.stringMatching(/^graph:/));
679
+ expect(graphB.langfuseScopeRunId).toEqual(expect.stringMatching(/^graph:/));
680
+ expect(graphA.langfuseScopeRunId).not.toBe(graphB.langfuseScopeRunId);
681
+ });
682
+
683
+ it('stamps concurrent executions of the same public run id distinctly', async () => {
684
+ const { StandardGraph } = await import('@/graphs/Graph');
685
+ const buildGraph = (): { langfuseScopeRunId: string } =>
686
+ new StandardGraph({
687
+ runId: 'duplicate-run',
688
+ agents: [createAgent('duplicate')],
689
+ langfuse: tenantLangfuse('duplicate'),
690
+ }) as unknown as { langfuseScopeRunId: string };
691
+
692
+ const first = buildGraph();
693
+ const second = buildGraph();
694
+ expect(first.langfuseScopeRunId).toEqual(
695
+ expect.stringMatching(/^duplicate-run:/)
696
+ );
697
+ expect(first.langfuseScopeRunId).not.toBe(second.langfuseScopeRunId);
698
+ });
699
+
563
700
  it('routes spans from captured OTel context after ALS scope exits', () => {
564
701
  const langfuse = tenantLangfuse('tenant-otel');
565
702
  initializeLangfuseTracing(langfuse);
@@ -0,0 +1,70 @@
1
+ import type { Span } from '@opentelemetry/api';
2
+ import type * as t from '@/types';
3
+ import {
4
+ getLangfuseManagedSpanDestination,
5
+ registerLangfuseManagedSpan,
6
+ resolveLangfuseDestinationKey,
7
+ } from '@/langfuseSpanRegistry';
8
+
9
+ const ORIGINAL_ENV = { ...process.env };
10
+
11
+ function tenantConfig(
12
+ overrides: Partial<t.LangfuseConfig> = {}
13
+ ): t.LangfuseConfig {
14
+ return {
15
+ enabled: true,
16
+ publicKey: 'pk-registry',
17
+ secretKey: 'sk-registry',
18
+ baseUrl: 'https://langfuse.registry',
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe('Langfuse span registry', () => {
24
+ afterEach(() => {
25
+ process.env = { ...ORIGINAL_ENV };
26
+ });
27
+
28
+ it('tracks managed spans by destination', () => {
29
+ const span = { name: 'managed' } as unknown as Span;
30
+ const key = resolveLangfuseDestinationKey(tenantConfig());
31
+ expect(key).toBeDefined();
32
+ registerLangfuseManagedSpan(span, key as string);
33
+ expect(getLangfuseManagedSpanDestination(span)).toBe(key);
34
+
35
+ const untracked = { name: 'foreign' } as unknown as Span;
36
+ expect(getLangfuseManagedSpanDestination(untracked)).toBeUndefined();
37
+ });
38
+
39
+ it('treats processor policy as irrelevant to destination identity', () => {
40
+ const base = resolveLangfuseDestinationKey(tenantConfig());
41
+ const redacting = resolveLangfuseDestinationKey(
42
+ tenantConfig({ toolOutputTracing: { enabled: false } })
43
+ );
44
+ expect(redacting).toBe(base);
45
+ });
46
+
47
+ it('separates destinations by credentials, endpoint, and environment', () => {
48
+ const base = resolveLangfuseDestinationKey(tenantConfig());
49
+ expect(
50
+ resolveLangfuseDestinationKey(tenantConfig({ publicKey: 'pk-other' }))
51
+ ).not.toBe(base);
52
+ expect(
53
+ resolveLangfuseDestinationKey(
54
+ tenantConfig({ baseUrl: 'https://langfuse.other' })
55
+ )
56
+ ).not.toBe(base);
57
+ expect(
58
+ resolveLangfuseDestinationKey(tenantConfig({ environment: 'staging' }))
59
+ ).not.toBe(base);
60
+ });
61
+
62
+ it('resolves no destination without credentials', () => {
63
+ delete process.env.LANGFUSE_PUBLIC_KEY;
64
+ delete process.env.LANGFUSE_SECRET_KEY;
65
+ expect(resolveLangfuseDestinationKey(undefined)).toBeUndefined();
66
+ expect(
67
+ resolveLangfuseDestinationKey(tenantConfig({ enabled: false }))
68
+ ).toBeUndefined();
69
+ });
70
+ });
@@ -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', () => {