@librechat/agents 3.2.65 → 3.2.66

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 (56) hide show
  1. package/dist/cjs/graphs/Graph.cjs +15 -2
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/instrumentation.cjs +15 -3
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuseToolOutputTracing.cjs +1 -2
  6. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  7. package/dist/cjs/langfuseTraceShaping.cjs +51 -24
  8. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  9. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +8 -0
  10. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +8 -0
  12. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/tools/BashExecutor.cjs +9 -8
  14. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  15. package/dist/cjs/tools/CodeExecutor.cjs +9 -7
  16. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  17. package/dist/esm/graphs/Graph.mjs +15 -2
  18. package/dist/esm/graphs/Graph.mjs.map +1 -1
  19. package/dist/esm/instrumentation.mjs +15 -3
  20. package/dist/esm/instrumentation.mjs.map +1 -1
  21. package/dist/esm/langfuseToolOutputTracing.mjs +1 -2
  22. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  23. package/dist/esm/langfuseTraceShaping.mjs +51 -24
  24. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  25. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +8 -0
  26. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  27. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +8 -0
  28. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  29. package/dist/esm/tools/BashExecutor.mjs +9 -8
  30. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  31. package/dist/esm/tools/CodeExecutor.mjs +9 -7
  32. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  33. package/dist/types/graphs/Graph.d.ts +2 -0
  34. package/dist/types/langfuseTraceShaping.d.ts +12 -6
  35. package/dist/types/tools/BashExecutor.d.ts +7 -6
  36. package/dist/types/tools/CodeExecutor.d.ts +7 -5
  37. package/dist/types/types/graph.d.ts +10 -3
  38. package/package.json +1 -1
  39. package/src/graphs/Graph.ts +21 -3
  40. package/src/instrumentation.ts +20 -0
  41. package/src/langfuseToolOutputTracing.ts +2 -4
  42. package/src/langfuseTraceShaping.ts +73 -20
  43. package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +110 -0
  44. package/src/llm/anthropic/utils/message_inputs.ts +15 -0
  45. package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +122 -0
  46. package/src/llm/bedrock/utils/message_inputs.ts +13 -0
  47. package/src/specs/langfuse-instrumentation.test.ts +64 -0
  48. package/src/specs/langfuse-routing.integration.test.ts +49 -4
  49. package/src/specs/langfuse-tool-output-tracing.test.ts +5 -4
  50. package/src/specs/langfuse-trace-shaping.test.ts +80 -9
  51. package/src/specs/subagent.test.ts +180 -0
  52. package/src/tools/BashExecutor.ts +9 -8
  53. package/src/tools/CodeExecutor.ts +9 -7
  54. package/src/tools/__tests__/BashExecutor.test.ts +16 -5
  55. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +17 -6
  56. package/src/types/graph.ts +10 -3
@@ -0,0 +1,110 @@
1
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
2
+ import type { BaseMessage } from '@langchain/core/messages';
3
+ import { _convertMessagesToAnthropicPayload } from './message_inputs';
4
+
5
+ /**
6
+ * Regression for cross-provider agent handoffs (Google → Anthropic): a Gemini
7
+ * turn that used a server-side tool (URL context, Google Search) leaves
8
+ * `toolCall`/`toolResponse` content blocks in history. The Anthropic converter
9
+ * has no branch for them and previously threw
10
+ * "Unsupported message content format", crashing the handoff. Only Google can
11
+ * execute these blocks or validate their thought signatures, so they are
12
+ * dropped on assistant turns; any other unknown block still throws.
13
+ */
14
+ type AnthropicPayload = ReturnType<typeof _convertMessagesToAnthropicPayload>;
15
+
16
+ /** Minimal view of a converted Anthropic content block the assertions read. */
17
+ interface TestBlock {
18
+ type?: string;
19
+ text?: string;
20
+ }
21
+
22
+ const assistantBlocks = (payload: AnthropicPayload): TestBlock[] => {
23
+ const content = payload.messages.find((m) => m.role === 'assistant')?.content;
24
+ return Array.isArray(content) ? (content as TestBlock[]) : [];
25
+ };
26
+
27
+ describe('_convertMessagesToAnthropicPayload — Google server-side tool blocks', () => {
28
+ it('drops toolCall/toolResponse on an assistant turn, keeping text', () => {
29
+ const messages: BaseMessage[] = [
30
+ new HumanMessage('summarize this article'),
31
+ new AIMessage({
32
+ content: [
33
+ {
34
+ type: 'toolCall',
35
+ thoughtSignature: 'google-signature-not-valid-for-anthropic',
36
+ toolCall: {
37
+ toolType: 'URL_CONTEXT',
38
+ args: { urls: ['https://example.com/report'] },
39
+ id: 'j7pfyr6k',
40
+ },
41
+ },
42
+ {
43
+ type: 'toolResponse',
44
+ toolResponse: {
45
+ toolType: 'URL_CONTEXT',
46
+ id: 'j7pfyr6k',
47
+ result: { status: 'SUCCESS' },
48
+ },
49
+ },
50
+ {
51
+ type: 'text',
52
+ text: 'The article argues for dollar-cost averaging.',
53
+ },
54
+ ],
55
+ }),
56
+ ];
57
+
58
+ expect(() => _convertMessagesToAnthropicPayload(messages)).not.toThrow();
59
+ const blocks = assistantBlocks(
60
+ _convertMessagesToAnthropicPayload(messages)
61
+ );
62
+
63
+ const serialized = JSON.stringify(blocks);
64
+ expect(serialized).not.toContain('URL_CONTEXT');
65
+ expect(serialized).not.toContain(
66
+ 'google-signature-not-valid-for-anthropic'
67
+ );
68
+ expect(
69
+ blocks.some(
70
+ (b) =>
71
+ b.type === 'text' &&
72
+ b.text === 'The article argues for dollar-cost averaging.'
73
+ )
74
+ ).toBe(true);
75
+ });
76
+
77
+ it('emits a placeholder (not empty content) when a server-tool-only turn is fully dropped', () => {
78
+ const messages: BaseMessage[] = [
79
+ new HumanMessage('hi'),
80
+ new AIMessage({
81
+ content: [
82
+ {
83
+ type: 'toolCall',
84
+ toolCall: { toolType: 'URL_CONTEXT', args: { urls: [] }, id: 'x1' },
85
+ },
86
+ ],
87
+ }),
88
+ ];
89
+ expect(() => _convertMessagesToAnthropicPayload(messages)).not.toThrow();
90
+ const blocks = assistantBlocks(
91
+ _convertMessagesToAnthropicPayload(messages)
92
+ );
93
+ expect(blocks.length).toBeGreaterThan(0);
94
+ });
95
+
96
+ it('still throws on a genuinely unknown assistant block', () => {
97
+ const messages: BaseMessage[] = [
98
+ new HumanMessage('run code'),
99
+ new AIMessage({
100
+ content: [
101
+ { type: 'some_future_block_type', foo: 'bar' },
102
+ { type: 'text', text: 'done' },
103
+ ],
104
+ }),
105
+ ];
106
+ expect(() => _convertMessagesToAnthropicPayload(messages)).toThrow(
107
+ 'Unsupported message content format'
108
+ );
109
+ });
110
+ });
@@ -473,6 +473,13 @@ function _formatContent(message: BaseMessage) {
473
473
  * forwarding an unusable block. The receiving model produces its own thinking.
474
474
  */
475
475
  const foreignReasoningTypes = ['reasoning_content', 'reasoning', 'think'];
476
+ /**
477
+ * Google server-side tool blocks (`toolCall`/`toolResponse` parts from e.g.
478
+ * URL context or Google Search). Only Google can execute these and validate
479
+ * their thought signatures, so they are dropped on a cross-provider handoff
480
+ * (e.g. Google → Anthropic); the assistant's answer text is kept.
481
+ */
482
+ const foreignServerToolTypes = ['toolCall', 'toolResponse'];
476
483
  const { content } = message;
477
484
 
478
485
  if (typeof content === 'string') {
@@ -855,6 +862,14 @@ function _formatContent(message: BaseMessage) {
855
862
  // dropped — as does any other unknown block (user media, Google
856
863
  // code-execution), which must be surfaced, not discarded.
857
864
  return null;
865
+ } else if (
866
+ isAIMessage(message) &&
867
+ foreignServerToolTypes.some((t) => t === contentPart.type)
868
+ ) {
869
+ // Google server-side tool call/response (e.g. URL context) — only
870
+ // Google can execute it or validate its thought signature; drop it
871
+ // on a cross-provider handoff rather than crash.
872
+ return null;
858
873
  } else {
859
874
  console.error(
860
875
  'Unsupported content part:',
@@ -0,0 +1,122 @@
1
+ import { AIMessage, HumanMessage } from '@langchain/core/messages';
2
+ import type { BaseMessage } from '@langchain/core/messages';
3
+ import { convertToConverseMessages } from './message_inputs';
4
+
5
+ /**
6
+ * Regression for cross-provider agent handoffs (Google → Bedrock): a Gemini
7
+ * turn that used a server-side tool (URL context, Google Search) leaves
8
+ * `toolCall`/`toolResponse` content blocks in history. The Bedrock Converse
9
+ * converter has no branch for them and previously threw
10
+ * "Unsupported content block type: toolCall", crashing the handoff. Only
11
+ * Google can execute these blocks or validate their thought signatures, so
12
+ * they are dropped on assistant turns; any other unknown block still throws.
13
+ */
14
+ type ConverseResult = ReturnType<typeof convertToConverseMessages>;
15
+
16
+ /** Minimal view of a converted Bedrock Converse content block the assertions read. */
17
+ interface ConverseBlock {
18
+ text?: string;
19
+ toolUse?: {
20
+ toolUseId?: string;
21
+ name?: string;
22
+ input?: Record<string, string>;
23
+ };
24
+ }
25
+
26
+ const assistantContent = (result: ConverseResult): ConverseBlock[] => {
27
+ const msg = result.converseMessages.find((m) => m.role === 'assistant');
28
+ return (msg?.content ?? []) as ConverseBlock[];
29
+ };
30
+
31
+ describe('convertToConverseMessages — Google server-side tool blocks (Google → Bedrock)', () => {
32
+ it('drops toolCall/toolResponse on an assistant turn, keeping text and tool calls', () => {
33
+ const messages: BaseMessage[] = [
34
+ new HumanMessage('summarize this article'),
35
+ new AIMessage({
36
+ content: [
37
+ {
38
+ type: 'toolCall',
39
+ thoughtSignature: 'google-signature-not-valid-for-bedrock',
40
+ toolCall: {
41
+ toolType: 'URL_CONTEXT',
42
+ args: { urls: ['https://example.com/report'] },
43
+ id: 'j7pfyr6k',
44
+ },
45
+ },
46
+ {
47
+ type: 'toolResponse',
48
+ toolResponse: {
49
+ toolType: 'URL_CONTEXT',
50
+ id: 'j7pfyr6k',
51
+ result: { status: 'SUCCESS' },
52
+ },
53
+ },
54
+ {
55
+ type: 'text',
56
+ text: 'The article argues for dollar-cost averaging.',
57
+ },
58
+ ],
59
+ tool_calls: [
60
+ {
61
+ id: 'call_client_tool',
62
+ name: 'save_note',
63
+ args: { note: 'DCA summary' },
64
+ type: 'tool_call',
65
+ },
66
+ ],
67
+ }),
68
+ ];
69
+
70
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
71
+ const content = assistantContent(convertToConverseMessages(messages));
72
+
73
+ const serialized = JSON.stringify(content);
74
+ expect(serialized).not.toContain('URL_CONTEXT');
75
+ expect(serialized).not.toContain('google-signature-not-valid-for-bedrock');
76
+
77
+ expect(
78
+ content.some(
79
+ (b) => b.text === 'The article argues for dollar-cost averaging.'
80
+ )
81
+ ).toBe(true);
82
+ const toolUse = content.find((b) => b.toolUse != null);
83
+ expect(toolUse?.toolUse).toMatchObject({
84
+ toolUseId: 'call_client_tool',
85
+ name: 'save_note',
86
+ input: { note: 'DCA summary' },
87
+ });
88
+ });
89
+
90
+ it('emits a placeholder (not empty content) when a server-tool-only turn is fully dropped', () => {
91
+ const messages: BaseMessage[] = [
92
+ new HumanMessage('hi'),
93
+ new AIMessage({
94
+ content: [
95
+ {
96
+ type: 'toolCall',
97
+ toolCall: { toolType: 'URL_CONTEXT', args: { urls: [] }, id: 'x1' },
98
+ },
99
+ ],
100
+ }),
101
+ ];
102
+ expect(() => convertToConverseMessages(messages)).not.toThrow();
103
+ const content = assistantContent(convertToConverseMessages(messages));
104
+ expect(content.length).toBeGreaterThan(0);
105
+ expect(content.every((b) => typeof b.text === 'string')).toBe(true);
106
+ });
107
+
108
+ it('still throws on a genuinely unknown assistant block', () => {
109
+ const messages: BaseMessage[] = [
110
+ new HumanMessage('run code'),
111
+ new AIMessage({
112
+ content: [
113
+ { type: 'some_future_block_type', foo: 'bar' },
114
+ { type: 'text', text: 'done' },
115
+ ],
116
+ }),
117
+ ];
118
+ expect(() => convertToConverseMessages(messages)).toThrow(
119
+ 'Unsupported content block type'
120
+ );
121
+ });
122
+ });
@@ -41,6 +41,14 @@ const FOREIGN_REASONING_TYPES = [
41
41
  'think',
42
42
  ];
43
43
 
44
+ /**
45
+ * Google server-side tool blocks (`toolCall`/`toolResponse` parts from e.g.
46
+ * URL context or Google Search). Only Google can execute these and validate
47
+ * their thought signatures, so they are dropped on a cross-provider handoff
48
+ * (e.g. Google → Bedrock); the assistant's answer text is kept.
49
+ */
50
+ const FOREIGN_SERVER_TOOL_TYPES = ['toolCall', 'toolResponse'];
51
+
44
52
  /**
45
53
  * Bedrock Converse rejects assistant messages with no content blocks. When
46
54
  * filtering (e.g. dropping foreign reasoning) empties an assistant turn that
@@ -727,6 +735,11 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
727
735
  // than crash. The Bedrock model produces its own reasoning. Anything
728
736
  // else unknown still throws below — real content must be surfaced.
729
737
  return;
738
+ } else if (FOREIGN_SERVER_TOOL_TYPES.some((t) => t === block.type)) {
739
+ // Google server-side tool call/response (e.g. URL context) — only
740
+ // Google can execute it or validate its thought signature; drop it
741
+ // on a cross-provider handoff rather than crash.
742
+ return;
730
743
  } else {
731
744
  const blockValues = Object.fromEntries(
732
745
  Object.entries(block).filter(([key]) => key !== 'type')
@@ -184,6 +184,70 @@ describe('Langfuse instrumentation', () => {
184
184
  expect(mockBasicTracerProvider).toHaveBeenCalledTimes(1);
185
185
  });
186
186
 
187
+ it('resolves environment from LANGFUSE_TRACING_ENVIRONMENT', async () => {
188
+ process.env.LANGFUSE_TRACING_ENVIRONMENT = 'staging';
189
+
190
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
191
+ initializeLangfuseTracing({
192
+ publicKey: 'pk-config',
193
+ secretKey: 'sk-config',
194
+ baseUrl: 'https://langfuse.config',
195
+ });
196
+
197
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
198
+ expect.objectContaining({ environment: 'staging' })
199
+ );
200
+ });
201
+
202
+ it('falls back to NODE_ENV when LANGFUSE_TRACING_ENVIRONMENT is unset', async () => {
203
+ delete process.env.LANGFUSE_TRACING_ENVIRONMENT;
204
+ process.env.NODE_ENV = 'production';
205
+
206
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
207
+ initializeLangfuseTracing({
208
+ publicKey: 'pk-config',
209
+ secretKey: 'sk-config',
210
+ baseUrl: 'https://langfuse.config',
211
+ });
212
+
213
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
214
+ expect.objectContaining({ environment: 'production' })
215
+ );
216
+ });
217
+
218
+ it('falls through blank environment overrides and trims the selected value', async () => {
219
+ process.env.LANGFUSE_TRACING_ENVIRONMENT = ' ';
220
+ process.env.NODE_ENV = ' production ';
221
+
222
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
223
+ initializeLangfuseTracing({
224
+ publicKey: 'pk-config',
225
+ secretKey: 'sk-config',
226
+ baseUrl: 'https://langfuse.config',
227
+ environment: '',
228
+ });
229
+
230
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
231
+ expect.objectContaining({ environment: 'production' })
232
+ );
233
+ });
234
+
235
+ it('prefers an explicit config environment over environment variables', async () => {
236
+ process.env.LANGFUSE_TRACING_ENVIRONMENT = 'staging';
237
+
238
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
239
+ initializeLangfuseTracing({
240
+ publicKey: 'pk-config',
241
+ secretKey: 'sk-config',
242
+ baseUrl: 'https://langfuse.config',
243
+ environment: 'canary',
244
+ });
245
+
246
+ expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
247
+ expect.objectContaining({ environment: 'canary' })
248
+ );
249
+ });
250
+
187
251
  it('does not replace the global provider when explicit credentials change', async () => {
188
252
  const { initializeLangfuseTracing } = await import('@/instrumentation');
189
253
  initializeLangfuseTracing({
@@ -25,6 +25,8 @@ type SpanStartRecord = {
25
25
  name: string;
26
26
  params: ProcessorParams;
27
27
  traceId: string;
28
+ spanId: string;
29
+ parentSpanId?: string;
28
30
  };
29
31
 
30
32
  const spanStarts: SpanStartRecord[] = [];
@@ -140,11 +142,15 @@ jest.mock('@langfuse/otel', () => ({
140
142
  LangfuseSpanProcessor: jest.fn().mockImplementation((params) => ({
141
143
  forceFlush: jest.fn(),
142
144
  onEnd: jest.fn(),
143
- onStart: jest.fn((span) => {
145
+ onStart: jest.fn((span, parentContext) => {
146
+ const spanContext = span.spanContext();
147
+ const parentSpanId = otelTrace.getSpanContext(parentContext)?.spanId;
144
148
  spanStarts.push({
145
149
  name: span.name,
146
150
  params,
147
- traceId: span.spanContext().traceId,
151
+ traceId: spanContext.traceId,
152
+ spanId: spanContext.spanId,
153
+ ...(parentSpanId != null ? { parentSpanId } : {}),
148
154
  });
149
155
  }),
150
156
  shutdown: jest.fn(),
@@ -190,7 +196,6 @@ function tenantLangfuse(tenantId: string): t.LangfuseConfig {
190
196
  deterministicTraceId: true,
191
197
  metadata: { tenantId },
192
198
  tags: [`tenant:${tenantId}`],
193
- toolNodeTracing: { enabled: true },
194
199
  toolOutputTracing: { enabled: true },
195
200
  };
196
201
  }
@@ -239,6 +244,23 @@ function expectNamedSpansUseTraceId({
239
244
  }
240
245
  }
241
246
 
247
+ function expectChildSpanParentName({
248
+ starts,
249
+ childName,
250
+ parentNamePrefix,
251
+ }: {
252
+ starts: SpanStartRecord[];
253
+ childName: string;
254
+ parentNamePrefix: string;
255
+ }): void {
256
+ const children = starts.filter((record) => record.name === childName);
257
+ expect(children).not.toHaveLength(0);
258
+ for (const child of children) {
259
+ const parent = starts.find((record) => record.spanId === child.parentSpanId);
260
+ expect(parent?.name.startsWith(parentNamePrefix)).toBe(true);
261
+ }
262
+ }
263
+
242
264
  function expectOnlyTraceIds(
243
265
  starts: SpanStartRecord[],
244
266
  allowedTraceIds: string[]
@@ -446,6 +468,23 @@ describe('Langfuse per-run routing integration', () => {
446
468
  getChatModelClassSpy.mockRestore();
447
469
  });
448
470
 
471
+ it('keeps tool observations attached to the exported dispatch parent', async () => {
472
+ await runTenantFlow('tenant-hierarchy');
473
+
474
+ const starts = startsForTenant('tenant-hierarchy');
475
+ expect(starts.some((record) => record.name === 'tool_batch')).toBe(false);
476
+ expectChildSpanParentName({
477
+ starts,
478
+ childName: 'echo',
479
+ parentNamePrefix: 'tools=',
480
+ });
481
+ expectChildSpanParentName({
482
+ starts,
483
+ childName: 'subagent',
484
+ parentNamePrefix: 'tools=',
485
+ });
486
+ });
487
+
449
488
  it('routes parallel root, model, tool, subagent, and title spans to each run config', async () => {
450
489
  await Promise.all([runTenantFlow('tenant-a'), runTenantFlow('tenant-b')]);
451
490
 
@@ -462,10 +501,16 @@ describe('Langfuse per-run routing integration', () => {
462
501
  names: [
463
502
  `LibreChat Agent: Parent ${tenantId}`,
464
503
  'FakeChatModel',
465
- 'tool_batch',
504
+ 'echo',
466
505
  'subagent',
467
506
  ],
468
507
  });
508
+ expect(starts.some((record) => record.name === 'tool_batch')).toBe(false);
509
+ expectChildSpanParentName({
510
+ starts,
511
+ childName: 'echo',
512
+ parentNamePrefix: 'tools=',
513
+ });
469
514
  expectNamedSpansUseTraceId({
470
515
  starts,
471
516
  traceId: titleTraceId,
@@ -118,7 +118,7 @@ describe('Langfuse tool output tracing redaction', () => {
118
118
  process.env = originalEnv;
119
119
  });
120
120
 
121
- it('enables ToolNode tracing only when Langfuse is active by default', () => {
121
+ it('keeps internal ToolNode batch tracing opt-in', () => {
122
122
  delete process.env.LANGFUSE_SECRET_KEY;
123
123
  delete process.env.LANGFUSE_PUBLIC_KEY;
124
124
  delete process.env.LANGFUSE_BASE_URL;
@@ -132,7 +132,7 @@ describe('Langfuse tool output tracing redaction', () => {
132
132
  secretKey: 'sk-run',
133
133
  },
134
134
  })
135
- ).toBe(true);
135
+ ).toBe(false);
136
136
  expect(
137
137
  shouldTraceToolNodeForLangfuse({
138
138
  agentLangfuse: {
@@ -149,7 +149,7 @@ describe('Langfuse tool output tracing redaction', () => {
149
149
  process.env.LANGFUSE_PUBLIC_KEY = 'pk-test';
150
150
  process.env.LANGFUSE_BASE_URL = 'https://langfuse.test';
151
151
 
152
- expect(shouldTraceToolNodeForLangfuse({})).toBe(true);
152
+ expect(shouldTraceToolNodeForLangfuse({})).toBe(false);
153
153
  expect(
154
154
  shouldTraceToolNodeForLangfuse({
155
155
  runLangfuse: { toolNodeTracing: { enabled: true } },
@@ -162,7 +162,7 @@ describe('Langfuse tool output tracing redaction', () => {
162
162
  ).toBe(false);
163
163
  });
164
164
 
165
- it('lets agent Langfuse enablement override disabled run defaults for ToolNode tracing', () => {
165
+ it('lets an agent explicitly opt into ToolNode batch tracing', () => {
166
166
  delete process.env.LANGFUSE_SECRET_KEY;
167
167
  delete process.env.LANGFUSE_PUBLIC_KEY;
168
168
  delete process.env.LANGFUSE_BASE_URL;
@@ -177,6 +177,7 @@ describe('Langfuse tool output tracing redaction', () => {
177
177
  publicKey: 'pk-agent',
178
178
  secretKey: 'sk-agent',
179
179
  baseUrl: 'https://langfuse.test',
180
+ toolNodeTracing: { enabled: true },
180
181
  },
181
182
  })
182
183
  ).toBe(true);
@@ -26,6 +26,8 @@ const INPUT = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
26
26
  const OUTPUT = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
27
27
  const TRACE_INPUT = LangfuseOtelSpanAttributes.TRACE_INPUT;
28
28
  const TRACE_OUTPUT = LangfuseOtelSpanAttributes.TRACE_OUTPUT;
29
+ const OBSERVATION_TYPE = LangfuseOtelSpanAttributes.OBSERVATION_TYPE;
30
+ const TRACE_TAGS = LangfuseOtelSpanAttributes.TRACE_TAGS;
29
31
 
30
32
  describe('shouldDropLangfuseSpan', () => {
31
33
  it('drops langgraph __start__ seed spans', () => {
@@ -40,6 +42,7 @@ describe('shouldDropLangfuseSpan', () => {
40
42
  expect(shouldDropLangfuseSpan('GenerateTitle')).toBe(false);
41
43
  expect(shouldDropLangfuseSpan('agent=openAI__gpt-5.4')).toBe(false);
42
44
  expect(shouldDropLangfuseSpan('ChatOpenAI')).toBe(false);
45
+ expect(shouldDropLangfuseSpan('tool_batch')).toBe(false);
43
46
  });
44
47
  });
45
48
 
@@ -48,9 +51,10 @@ describe('shapeLangfuseSpan', () => {
48
51
  const span = createSpan('agent=openAI__gpt-5.4', {}, 'parent-1');
49
52
  shapeLangfuseSpan(span);
50
53
  expect(span.name).toBe('agent');
54
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('agent');
51
55
  });
52
56
 
53
- it('renames tool node spans to the pending tool names and scopes input to args', () => {
57
+ it('shapes tool nodes as stable dispatch chains with scoped call inputs', () => {
54
58
  const messages = [
55
59
  { type: 'human', content: 'hello' },
56
60
  {
@@ -71,13 +75,14 @@ describe('shapeLangfuseSpan', () => {
71
75
  'parent-1'
72
76
  );
73
77
  shapeLangfuseSpan(span);
74
- expect(span.name).toBe('get_service_details');
78
+ expect(span.name).toBe('tool-dispatch');
79
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
75
80
  expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
76
81
  { name: 'get_service_details', args: { path: 'organizations/1' } },
77
82
  ]);
78
83
  });
79
84
 
80
- it('joins multiple pending tool names and dedupes repeats', () => {
85
+ it('preserves every pending call in a multi-tool dispatch input', () => {
81
86
  const messages = [
82
87
  {
83
88
  type: 'ai',
@@ -89,12 +94,16 @@ describe('shapeLangfuseSpan', () => {
89
94
  },
90
95
  ];
91
96
  const span = createSpan(
92
- 'tool_batch',
97
+ 'tools=openAI__gpt-5.4',
93
98
  { [INPUT]: JSON.stringify({ messages }) },
94
99
  'parent-1'
95
100
  );
96
101
  shapeLangfuseSpan(span);
97
- expect(span.name).toBe('web_search, execute_code');
102
+ expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
103
+ { name: 'web_search', args: { q: 'a' } },
104
+ { name: 'web_search', args: { q: 'b' } },
105
+ { name: 'execute_code', args: { code: '1+1' } },
106
+ ]);
98
107
  });
99
108
 
100
109
  it('reads tool calls from serialized langchain message kwargs', () => {
@@ -115,10 +124,10 @@ describe('shapeLangfuseSpan', () => {
115
124
  'parent-1'
116
125
  );
117
126
  shapeLangfuseSpan(span);
118
- expect(span.name).toBe('lookup');
127
+ expect(span.name).toBe('tool-dispatch');
119
128
  });
120
129
 
121
- it('leaves tool node spans untouched when no tool calls are found', () => {
130
+ it('keeps a stable tool-dispatch shape when no tool calls are found', () => {
122
131
  const original = JSON.stringify({
123
132
  messages: [{ type: 'human', content: 'hi' }],
124
133
  });
@@ -128,12 +137,14 @@ describe('shapeLangfuseSpan', () => {
128
137
  'parent-1'
129
138
  );
130
139
  shapeLangfuseSpan(span);
131
- expect(span.name).toBe('tools=agent_abc');
140
+ expect(span.name).toBe('tool-dispatch');
141
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
132
142
  expect(span.attributes[INPUT]).toBe(original);
133
143
  });
134
144
 
135
145
  it('sets root span and trace input/output to the question and answer', () => {
136
146
  const span = createSpan('LibreChat Agent', {
147
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
137
148
  [INPUT]: JSON.stringify({
138
149
  messages: [
139
150
  { type: 'system', content: 'You are helpful.' },
@@ -156,6 +167,7 @@ describe('shapeLangfuseSpan', () => {
156
167
 
157
168
  it('extracts answer text from content part arrays', () => {
158
169
  const span = createSpan('LibreChat Agent', {
170
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
159
171
  [INPUT]: JSON.stringify([{ type: 'human', content: 'hi' }]),
160
172
  [OUTPUT]: JSON.stringify({
161
173
  messages: [
@@ -189,6 +201,65 @@ describe('shapeLangfuseSpan', () => {
189
201
  const span = createSpan('LibreChat Agent', { [INPUT]: 'plain text' });
190
202
  shapeLangfuseSpan(span);
191
203
  expect(span.attributes[INPUT]).toBe('plain text');
192
- expect(span.attributes[TRACE_INPUT]).toBeUndefined();
204
+ expect(span.attributes[TRACE_INPUT]).toBe('plain text');
205
+ });
206
+
207
+ it('renames generation spans to a provider-agnostic name', () => {
208
+ const span = createSpan(
209
+ 'ChatOpenAI',
210
+ { [OBSERVATION_TYPE]: 'generation' },
211
+ 'parent-1'
212
+ );
213
+ shapeLangfuseSpan(span);
214
+ expect(span.name).toBe('llm');
215
+ });
216
+
217
+ it('marks only agent-tagged root spans as agent observations', () => {
218
+ const span = createSpan('LibreChat Agent', {
219
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
220
+ [INPUT]: JSON.stringify({
221
+ messages: [{ type: 'human', content: 'hi' }],
222
+ }),
223
+ });
224
+ shapeLangfuseSpan(span);
225
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('agent');
226
+ });
227
+
228
+ it('marks title-tagged root spans as chain observations', () => {
229
+ const span = createSpan('LibreChat Title', {
230
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'title']),
231
+ [INPUT]: 'Conversation text',
232
+ [OUTPUT]: 'Conversation title',
233
+ });
234
+
235
+ shapeLangfuseSpan(span);
236
+
237
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
238
+ expect(span.attributes[TRACE_INPUT]).toBe('Conversation text');
239
+ expect(span.attributes[TRACE_OUTPUT]).toBe('Conversation title');
240
+ });
241
+
242
+ it('does not classify untagged root spans as agents', () => {
243
+ const span = createSpan('Custom root', { [INPUT]: 'input' });
244
+
245
+ shapeLangfuseSpan(span);
246
+
247
+ expect(span.attributes[OBSERVATION_TYPE]).toBeUndefined();
248
+ });
249
+
250
+ it('shapes standalone generation roots without replacing their type', () => {
251
+ const span = createSpan('ChatOpenAI', {
252
+ [OBSERVATION_TYPE]: 'generation',
253
+ [TRACE_TAGS]: JSON.stringify(['librechat', 'title']),
254
+ [INPUT]: 'Generate a title',
255
+ [OUTPUT]: 'A useful title',
256
+ });
257
+
258
+ shapeLangfuseSpan(span);
259
+
260
+ expect(span.name).toBe('llm');
261
+ expect(span.attributes[OBSERVATION_TYPE]).toBe('generation');
262
+ expect(span.attributes[TRACE_INPUT]).toBe('Generate a title');
263
+ expect(span.attributes[TRACE_OUTPUT]).toBe('A useful title');
193
264
  });
194
265
  });