@librechat/agents 3.2.65 → 3.2.67

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 (78) 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/hooks/createToolPolicyHook.cjs +5 -5
  4. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -1
  5. package/dist/cjs/instrumentation.cjs +15 -3
  6. package/dist/cjs/instrumentation.cjs.map +1 -1
  7. package/dist/cjs/langfuseToolOutputTracing.cjs +1 -2
  8. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  9. package/dist/cjs/langfuseTraceShaping.cjs +51 -24
  10. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  11. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +8 -0
  12. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +8 -0
  14. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  15. package/dist/cjs/tools/BashExecutor.cjs +9 -8
  16. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  17. package/dist/cjs/tools/CodeExecutor.cjs +9 -7
  18. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolSearch.cjs +59 -14
  20. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tool.cjs +5 -3
  22. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  23. package/dist/esm/graphs/Graph.mjs +15 -2
  24. package/dist/esm/graphs/Graph.mjs.map +1 -1
  25. package/dist/esm/hooks/createToolPolicyHook.mjs +5 -5
  26. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -1
  27. package/dist/esm/instrumentation.mjs +15 -3
  28. package/dist/esm/instrumentation.mjs.map +1 -1
  29. package/dist/esm/langfuseToolOutputTracing.mjs +1 -2
  30. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  31. package/dist/esm/langfuseTraceShaping.mjs +51 -24
  32. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  33. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +8 -0
  34. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  35. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +8 -0
  36. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  37. package/dist/esm/tools/BashExecutor.mjs +9 -8
  38. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  39. package/dist/esm/tools/CodeExecutor.mjs +9 -7
  40. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  41. package/dist/esm/tools/ToolSearch.mjs +59 -14
  42. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  43. package/dist/esm/tools/search/tool.mjs +5 -3
  44. package/dist/esm/tools/search/tool.mjs.map +1 -1
  45. package/dist/types/graphs/Graph.d.ts +2 -0
  46. package/dist/types/hooks/createToolPolicyHook.d.ts +12 -15
  47. package/dist/types/langfuseTraceShaping.d.ts +12 -6
  48. package/dist/types/tools/BashExecutor.d.ts +7 -6
  49. package/dist/types/tools/CodeExecutor.d.ts +7 -5
  50. package/dist/types/tools/search/types.d.ts +2 -0
  51. package/dist/types/types/graph.d.ts +10 -3
  52. package/package.json +1 -1
  53. package/src/graphs/Graph.ts +21 -3
  54. package/src/hooks/__tests__/createToolPolicyHook.test.ts +13 -4
  55. package/src/hooks/createToolPolicyHook.ts +16 -19
  56. package/src/instrumentation.ts +20 -0
  57. package/src/langfuseToolOutputTracing.ts +2 -4
  58. package/src/langfuseTraceShaping.ts +73 -20
  59. package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +110 -0
  60. package/src/llm/anthropic/utils/message_inputs.ts +15 -0
  61. package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +122 -0
  62. package/src/llm/bedrock/utils/message_inputs.ts +13 -0
  63. package/src/specs/langfuse-instrumentation.test.ts +64 -0
  64. package/src/specs/langfuse-routing.integration.test.ts +49 -4
  65. package/src/specs/langfuse-tool-output-tracing.test.ts +5 -4
  66. package/src/specs/langfuse-trace-shaping.test.ts +80 -9
  67. package/src/specs/subagent.test.ts +180 -0
  68. package/src/tools/BashExecutor.ts +9 -8
  69. package/src/tools/CodeExecutor.ts +9 -7
  70. package/src/tools/ToolSearch.ts +81 -23
  71. package/src/tools/__tests__/BashExecutor.test.ts +16 -5
  72. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +17 -6
  73. package/src/tools/__tests__/ToolSearch.test.ts +151 -0
  74. package/src/tools/__tests__/hitl.test.ts +114 -1
  75. package/src/tools/search/source-processing.test.ts +57 -0
  76. package/src/tools/search/tool.ts +13 -1
  77. package/src/tools/search/types.ts +2 -0
  78. package/src/types/graph.ts +10 -3
@@ -5,8 +5,13 @@ const LANGGRAPH_START_NODE = '__start__';
5
5
  const ANONYMOUS_LAMBDA_NAME = 'RunnableLambda';
6
6
  const LANGGRAPH_AGENT_NODE_PREFIX = 'agent=';
7
7
  const LANGGRAPH_TOOL_NODE_PREFIX = 'tools=';
8
- const TOOL_BATCH_RUN_NAME = 'tool_batch';
9
8
  const AGENT_NODE_SPAN_NAME = 'agent';
9
+ const TOOL_DISPATCH_SPAN_NAME = 'tool-dispatch';
10
+ const GENERATION_SPAN_NAME = 'llm';
11
+ const ROOT_OBSERVATION_TYPE = 'agent';
12
+ const CHAIN_OBSERVATION_TYPE = 'chain';
13
+ const AGENT_TRACE_TAG = 'agent';
14
+ const TITLE_TRACE_TAG = 'title';
10
15
 
11
16
  type MutableSpan = ReadableSpan & {
12
17
  name: string;
@@ -207,6 +212,8 @@ function isRootSpan(span: ReadableSpan): boolean {
207
212
  * LangGraph plumbing observations that add noise without information:
208
213
  * the duplicated `__start__` channel-seed nodes and anonymous
209
214
  * `RunnableLambda` pass-throughs (Langfuse team feedback items 4 & 5).
215
+ * Internal ToolNode batch spans are disabled at their source so traced child
216
+ * tools retain an exported parent. Explicitly traced ToolNodes are preserved.
210
217
  */
211
218
  export function shouldDropLangfuseSpan(spanName: string): boolean {
212
219
  return (
@@ -216,18 +223,26 @@ export function shouldDropLangfuseSpan(spanName: string): boolean {
216
223
 
217
224
  function shapeToolNodeSpan(span: MutableSpan): void {
218
225
  const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
226
+ span.name = TOOL_DISPATCH_SPAN_NAME;
227
+ span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
228
+ CHAIN_OBSERVATION_TYPE;
219
229
  const calls = findPendingToolCalls(
220
230
  parseAttributeValue(span.attributes[inputKey])
221
231
  );
222
232
  if (calls.length === 0) {
223
233
  return;
224
234
  }
225
- span.name = [...new Set(calls.map((call) => call.name))].join(', ');
226
235
  span.attributes[inputKey] = JSON.stringify(
227
236
  calls.map(({ name, args }) => ({ name, args }))
228
237
  );
229
238
  }
230
239
 
240
+ function shapeAgentNodeSpan(span: MutableSpan): void {
241
+ span.name = AGENT_NODE_SPAN_NAME;
242
+ span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
243
+ ROOT_OBSERVATION_TYPE;
244
+ }
245
+
231
246
  function shapeRootSpan(span: MutableSpan): void {
232
247
  const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
233
248
  const outputKey = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
@@ -241,11 +256,47 @@ function shapeRootSpan(span: MutableSpan): void {
241
256
  );
242
257
  if (question != null) {
243
258
  span.attributes[inputKey] = question;
244
- span.attributes[LangfuseOtelSpanAttributes.TRACE_INPUT] = question;
245
259
  }
246
260
  if (answer != null) {
247
261
  span.attributes[outputKey] = answer;
248
- span.attributes[LangfuseOtelSpanAttributes.TRACE_OUTPUT] = answer;
262
+ }
263
+ const traceInput = question ?? span.attributes[inputKey];
264
+ const traceOutput = answer ?? span.attributes[outputKey];
265
+ if (traceInput != null) {
266
+ span.attributes[LangfuseOtelSpanAttributes.TRACE_INPUT] = traceInput;
267
+ }
268
+ if (traceOutput != null) {
269
+ span.attributes[LangfuseOtelSpanAttributes.TRACE_OUTPUT] = traceOutput;
270
+ }
271
+ }
272
+
273
+ function isGenerationSpan(span: MutableSpan): boolean {
274
+ const type = span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE];
275
+ return typeof type === 'string' && type.toLowerCase() === 'generation';
276
+ }
277
+
278
+ function hasTraceTag(span: MutableSpan, expectedTag: string): boolean {
279
+ const tags = parseAttributeValue(
280
+ span.attributes[LangfuseOtelSpanAttributes.TRACE_TAGS]
281
+ );
282
+ return (
283
+ Array.isArray(tags) &&
284
+ tags.some((tag) => typeof tag === 'string' && tag === expectedTag)
285
+ );
286
+ }
287
+
288
+ function shapeRootObservationType(span: MutableSpan): void {
289
+ if (isGenerationSpan(span)) {
290
+ return;
291
+ }
292
+ if (hasTraceTag(span, AGENT_TRACE_TAG)) {
293
+ span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
294
+ ROOT_OBSERVATION_TYPE;
295
+ return;
296
+ }
297
+ if (hasTraceTag(span, TITLE_TRACE_TAG)) {
298
+ span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
299
+ CHAIN_OBSERVATION_TYPE;
249
300
  }
250
301
  }
251
302
 
@@ -254,27 +305,29 @@ function shapeRootSpan(span: MutableSpan): void {
254
305
  * - `agent=<id>` / `tools=<id>` node names carry the ephemeral agent id
255
306
  * (`provider__model`) — strip it so switching models doesn't break
256
307
  * name-based logic (item 1).
257
- * - Tool node spans are renamed to the actual tool name(s) and their
258
- * input scoped to the pending tool-call args instead of the whole
259
- * chat history (items 3 & 4).
260
- * - The root span (and trace) input/output become the user question and
261
- * assistant response so the session view reads as a conversation
262
- * (item 2).
308
+ * - LLM generation spans keep the provider client class name (`ChatOpenAI`,
309
+ * `AzureChatOpenAI`, …); rename them to a provider-agnostic `llm` so the
310
+ * name reflects the operation, not the model (the model stays on the
311
+ * generation's model attribute).
312
+ * - Agent nodes become `agent` observations, while tool-dispatch nodes become
313
+ * stable `chain` observations whose input is scoped to the pending calls.
314
+ * Individual child calls remain `tool` observations (items 3 & 4).
315
+ * - Agent trace roots become `agent` observations and title trace roots become
316
+ * `chain` observations. Root and trace input/output are reduced to the user
317
+ * question and assistant response when chat messages are available (item 2).
263
318
  */
264
319
  export function shapeLangfuseSpan(span: ReadableSpan): void {
265
320
  const mutable = span as MutableSpan;
266
321
  if (mutable.name.startsWith(LANGGRAPH_AGENT_NODE_PREFIX)) {
267
- mutable.name = AGENT_NODE_SPAN_NAME;
268
- return;
269
- }
270
- if (
271
- mutable.name.startsWith(LANGGRAPH_TOOL_NODE_PREFIX) ||
272
- mutable.name === TOOL_BATCH_RUN_NAME
273
- ) {
322
+ shapeAgentNodeSpan(mutable);
323
+ } else if (mutable.name.startsWith(LANGGRAPH_TOOL_NODE_PREFIX)) {
274
324
  shapeToolNodeSpan(mutable);
275
- return;
325
+ } else if (isGenerationSpan(mutable)) {
326
+ mutable.name = GENERATION_SPAN_NAME;
276
327
  }
277
- if (isRootSpan(span)) {
278
- shapeRootSpan(mutable);
328
+ if (!isRootSpan(span)) {
329
+ return;
279
330
  }
331
+ shapeRootObservationType(mutable);
332
+ shapeRootSpan(mutable);
280
333
  }
@@ -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);