@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
@@ -0,0 +1,217 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
+ import { MemorySaver } from '@langchain/langgraph';
4
+ import { HumanMessage } from '@langchain/core/messages';
5
+ import { ChatGenerationChunk } from '@langchain/core/outputs';
6
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
7
+ import type { ToolCall, ToolCallChunk } from '@langchain/core/messages/tool';
8
+ import type { BaseMessage } from '@langchain/core/messages';
9
+ import type * as t from '@/types';
10
+ import { FakeChatModel } from '@/llm/fake';
11
+ import { StandardGraph } from '@/graphs';
12
+ import { askUserQuestion } from '@/hitl';
13
+ import { Providers } from '@/common';
14
+ import { Run } from '@/run';
15
+
16
+ const DISCOVERED_TOOL = 'save_issue_mcp_linear';
17
+
18
+ const searchTool = tool(
19
+ async ({ query }) => [
20
+ JSON.stringify({
21
+ found: 1,
22
+ tools: [{ name: DISCOVERED_TOOL }],
23
+ query,
24
+ }),
25
+ { tool_references: [{ tool_name: DISCOVERED_TOOL }] },
26
+ ],
27
+ {
28
+ name: 'tool_search',
29
+ description: 'Find a deferred tool.',
30
+ schema: z.object({ query: z.string() }),
31
+ responseFormat: 'content_and_artifact',
32
+ }
33
+ );
34
+
35
+ const askTool = tool(
36
+ async (input) => {
37
+ const { answer } = askUserQuestion(input);
38
+ return answer;
39
+ },
40
+ {
41
+ name: 'ask_user_question',
42
+ description: 'Pause until the user answers a question.',
43
+ schema: z.object({ question: z.string() }),
44
+ }
45
+ );
46
+
47
+ class DiscoveryThenAskModel extends FakeChatModel {
48
+ private turn = 0;
49
+
50
+ constructor() {
51
+ super({ responses: ['', ''] });
52
+ }
53
+
54
+ async *_streamResponseChunks(
55
+ _messages: BaseMessage[],
56
+ _options: this['ParsedCallOptions'],
57
+ _runManager?: CallbackManagerForLLMRun
58
+ ): AsyncGenerator<ChatGenerationChunk> {
59
+ const calls: ToolCall[][] = [
60
+ [
61
+ {
62
+ name: 'tool_search',
63
+ args: { query: 'save_issue' },
64
+ id: 'search-call',
65
+ type: 'tool_call',
66
+ },
67
+ ],
68
+ [
69
+ {
70
+ name: 'ask_user_question',
71
+ args: { question: 'Proceed?' },
72
+ id: 'ask-call',
73
+ type: 'tool_call',
74
+ },
75
+ ],
76
+ ];
77
+ const toolCallChunks = calls[this.turn++].map(
78
+ (call, index): ToolCallChunk => ({
79
+ name: call.name,
80
+ args: JSON.stringify(call.args),
81
+ id: call.id,
82
+ index,
83
+ type: 'tool_call_chunk',
84
+ })
85
+ );
86
+ yield this._createResponseChunk('', toolCallChunks);
87
+ }
88
+ }
89
+
90
+ describe('Run discovered tools', () => {
91
+ it('exposes discoveries made before an ask-user pause', async () => {
92
+ const run = await Run.create<t.IState>({
93
+ runId: 'discovery-pause-run',
94
+ graphConfig: {
95
+ type: 'standard',
96
+ llmConfig: {
97
+ provider: Providers.OPENAI,
98
+ model: 'gpt-4o-mini',
99
+ streaming: true,
100
+ streamUsage: false,
101
+ },
102
+ instructions: 'Search first, then ask the user.',
103
+ tools: [searchTool, askTool],
104
+ compileOptions: { checkpointer: new MemorySaver() },
105
+ },
106
+ returnContent: true,
107
+ customHandlers: {},
108
+ });
109
+ run.Graph!.overrideModel = new DiscoveryThenAskModel();
110
+
111
+ await run.processStream(
112
+ { messages: [new HumanMessage('Create a Linear issue')] },
113
+ {
114
+ configurable: { thread_id: 'discovery-pause-thread' },
115
+ version: 'v2',
116
+ }
117
+ );
118
+
119
+ expect(run.getInterrupt()?.payload.type).toBe('ask_user_question');
120
+ // The interrupted inner subgraph has not returned its messages to the outer
121
+ // reducer, which is why reconstructing discoveries from run history fails.
122
+ expect(run.getRunMessages()).toEqual([]);
123
+ expect(run.getDiscoveredTools()).toEqual([DISCOVERED_TOOL]);
124
+
125
+ const snapshot = run.getDiscoveredTools();
126
+ snapshot.push('caller-mutation');
127
+ expect(run.getDiscoveredTools()).toEqual([DISCOVERED_TOOL]);
128
+ });
129
+
130
+ it('retains the last snapshot when normal cleanup resets agent contexts', async () => {
131
+ const run = await Run.create<t.IState>({
132
+ runId: 'discovery-cleanup-run',
133
+ graphConfig: {
134
+ type: 'standard',
135
+ llmConfig: {
136
+ provider: Providers.OPENAI,
137
+ model: 'gpt-4o-mini',
138
+ },
139
+ instructions: 'Test discovery cleanup.',
140
+ },
141
+ });
142
+ const graph = run.Graph as StandardGraph;
143
+ graph.agentContexts
144
+ .get(graph.defaultAgentId)
145
+ ?.markToolsAsDiscovered([DISCOVERED_TOOL]);
146
+
147
+ graph.clearHeavyState();
148
+
149
+ expect(run.getDiscoveredTools()).toEqual([DISCOVERED_TOOL]);
150
+ });
151
+
152
+ it('supports per-agent snapshots while returning a deduplicated union by default', async () => {
153
+ const run = await Run.create<t.IState>({
154
+ runId: 'multi-agent-discovery-run',
155
+ graphConfig: {
156
+ type: 'multi-agent',
157
+ agents: [
158
+ {
159
+ agentId: 'researcher',
160
+ provider: Providers.ANTHROPIC,
161
+ clientOptions: {
162
+ modelName: 'claude-haiku-4-5',
163
+ apiKey: 'test-key',
164
+ },
165
+ instructions: 'Research.',
166
+ },
167
+ {
168
+ agentId: 'writer',
169
+ provider: Providers.ANTHROPIC,
170
+ clientOptions: {
171
+ modelName: 'claude-haiku-4-5',
172
+ apiKey: 'test-key',
173
+ },
174
+ instructions: 'Write.',
175
+ },
176
+ ],
177
+ edges: [{ from: 'researcher', to: 'writer', edgeType: 'direct' }],
178
+ },
179
+ });
180
+ const graph = run.Graph as StandardGraph;
181
+ graph.agentContexts
182
+ .get('researcher')
183
+ ?.markToolsAsDiscovered(['shared_tool', 'research_tool']);
184
+ graph.agentContexts
185
+ .get('writer')
186
+ ?.markToolsAsDiscovered(['shared_tool', 'writing_tool']);
187
+
188
+ expect(run.getDiscoveredTools('researcher')).toEqual([
189
+ 'shared_tool',
190
+ 'research_tool',
191
+ ]);
192
+ expect(run.getDiscoveredTools('writer')).toEqual([
193
+ 'shared_tool',
194
+ 'writing_tool',
195
+ ]);
196
+ expect(run.getDiscoveredTools()).toEqual([
197
+ 'shared_tool',
198
+ 'research_tool',
199
+ 'writing_tool',
200
+ ]);
201
+
202
+ graph.clearHeavyState();
203
+ expect(run.getDiscoveredTools('researcher')).toEqual([
204
+ 'shared_tool',
205
+ 'research_tool',
206
+ ]);
207
+ expect(run.getDiscoveredTools('writer')).toEqual([
208
+ 'shared_tool',
209
+ 'writing_tool',
210
+ ]);
211
+ expect(run.getDiscoveredTools()).toEqual([
212
+ 'shared_tool',
213
+ 'research_tool',
214
+ 'writing_tool',
215
+ ]);
216
+ });
217
+ });
@@ -385,6 +385,462 @@ describe('Langfuse callback composition', () => {
385
385
  );
386
386
  });
387
387
 
388
+ it('keeps callback spans on their own run when a foreign run scope is active', async () => {
389
+ const { createLangfuseHandler } = await import('@/langfuse');
390
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
391
+ const { withLangfuseRuntimeScope } = await import('@/langfuseRuntimeScope');
392
+ const tenantA = {
393
+ publicKey: 'pk-tenant-a',
394
+ secretKey: 'sk-tenant-a',
395
+ baseUrl: 'https://langfuse.tenant-a',
396
+ deterministicTraceId: true,
397
+ };
398
+ const tenantB = {
399
+ publicKey: 'pk-tenant-b',
400
+ secretKey: 'sk-tenant-b',
401
+ baseUrl: 'https://langfuse.tenant-b',
402
+ deterministicTraceId: true,
403
+ };
404
+ initializeLangfuseTracing(tenantA);
405
+ initializeLangfuseTracing(tenantB);
406
+ const handlerB = createLangfuseHandler({
407
+ langfuse: tenantB,
408
+ traceIdSeed: 'run-b',
409
+ runId: 'run-b',
410
+ });
411
+
412
+ // LangChain's background callback queue (`consumeCallback`) executes
413
+ // non-awaited callbacks inside whichever concurrent run's async context
414
+ // the queue drain happens to be on — here, tenant-A's. A scope stamped
415
+ // with a different run must never reroute tenant-B's spans.
416
+ await withLangfuseRuntimeScope(
417
+ { langfuse: tenantA, traceIdSeed: 'run-a', runId: 'run-a' },
418
+ () =>
419
+ handlerB?.handleChainStart(
420
+ { lc: 1, type: 'not_implemented', id: ['ForeignScopedChain'] },
421
+ { input: 'foreign scoped' },
422
+ 'lc-foreign-run'
423
+ )
424
+ );
425
+
426
+ expect(mockProcessorStarts).toContainEqual(
427
+ expect.objectContaining({
428
+ params: expect.objectContaining({
429
+ publicKey: 'pk-tenant-b',
430
+ secretKey: 'sk-tenant-b',
431
+ baseUrl: 'https://langfuse.tenant-b',
432
+ }),
433
+ traceId: traceIdFromSeed('run-b'),
434
+ })
435
+ );
436
+ expect(
437
+ mockProcessorStarts.filter(
438
+ (record) =>
439
+ (record.params as { publicKey?: string }).publicKey ===
440
+ 'pk-tenant-a' || record.traceId === traceIdFromSeed('run-a')
441
+ )
442
+ ).toHaveLength(0);
443
+ });
444
+
445
+ it('adopts an agent overlay scope stamped with the handler run', async () => {
446
+ const { createLangfuseHandler } = await import('@/langfuse');
447
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
448
+ const { withLangfuseRuntimeScope } = await import('@/langfuseRuntimeScope');
449
+ const runLangfuse = {
450
+ publicKey: 'pk-run',
451
+ secretKey: 'sk-run',
452
+ baseUrl: 'https://langfuse.run',
453
+ deterministicTraceId: true,
454
+ };
455
+ const agentLangfuse = {
456
+ publicKey: 'pk-agent',
457
+ secretKey: 'sk-agent',
458
+ baseUrl: 'https://langfuse.agent',
459
+ deterministicTraceId: true,
460
+ };
461
+ initializeLangfuseTracing(runLangfuse);
462
+ initializeLangfuseTracing(agentLangfuse);
463
+ const streamHandler = createLangfuseHandler({
464
+ langfuse: runLangfuse,
465
+ traceIdSeed: 'run-seed',
466
+ runId: 'run-1',
467
+ });
468
+
469
+ await withLangfuseRuntimeScope(
470
+ { langfuse: agentLangfuse, traceIdSeed: 'agent-seed', runId: 'run-1' },
471
+ () =>
472
+ streamHandler?.handleChainStart(
473
+ { lc: 1, type: 'not_implemented', id: ['SameRunOverlayChain'] },
474
+ { input: 'same run overlay' },
475
+ 'lc-overlay-run'
476
+ )
477
+ );
478
+
479
+ expect(mockProcessorStarts).toContainEqual(
480
+ expect.objectContaining({
481
+ params: expect.objectContaining({
482
+ publicKey: 'pk-agent',
483
+ secretKey: 'sk-agent',
484
+ baseUrl: 'https://langfuse.agent',
485
+ }),
486
+ traceId: traceIdFromSeed('agent-seed'),
487
+ })
488
+ );
489
+ });
490
+
491
+ it('scopes agent overlays to the callback agent in parallel fan-out', async () => {
492
+ const { createLangfuseHandler } = await import('@/langfuse');
493
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
494
+ const { withLangfuseRuntimeScope } = await import('@/langfuseRuntimeScope');
495
+ const runLangfuse = {
496
+ publicKey: 'pk-run',
497
+ secretKey: 'sk-run',
498
+ baseUrl: 'https://langfuse.run',
499
+ deterministicTraceId: true,
500
+ };
501
+ const agentBLangfuse = {
502
+ publicKey: 'pk-agent-b',
503
+ secretKey: 'sk-agent-b',
504
+ baseUrl: 'https://langfuse.agent-b',
505
+ deterministicTraceId: true,
506
+ };
507
+ initializeLangfuseTracing(runLangfuse);
508
+ initializeLangfuseTracing(agentBLangfuse);
509
+ const streamHandler = createLangfuseHandler({
510
+ langfuse: runLangfuse,
511
+ traceIdSeed: 'run-seed',
512
+ runId: 'run-1',
513
+ });
514
+
515
+ // Agent B's overlay scope is ambient (the background callback queue can
516
+ // interleave concurrent fan-out agents), but this callback reports agent
517
+ // A via inherited langgraph metadata — a sibling's overlay must not
518
+ // capture it.
519
+ await withLangfuseRuntimeScope(
520
+ {
521
+ langfuse: agentBLangfuse,
522
+ traceIdSeed: 'agent-b-seed',
523
+ runId: 'run-1',
524
+ agentId: 'agent-b',
525
+ },
526
+ () =>
527
+ streamHandler?.handleChainStart(
528
+ { lc: 1, type: 'not_implemented', id: ['SiblingAgentChain'] },
529
+ { input: 'sibling agent' },
530
+ 'lc-sibling-run',
531
+ undefined,
532
+ undefined,
533
+ { langgraph_node: 'agent=agent-a' }
534
+ )
535
+ );
536
+
537
+ expect(mockProcessorStarts).toContainEqual(
538
+ expect.objectContaining({
539
+ params: expect.objectContaining({ publicKey: 'pk-run' }),
540
+ traceId: traceIdFromSeed('run-seed'),
541
+ })
542
+ );
543
+
544
+ // The SAME agent's overlay scope is still adopted.
545
+ await withLangfuseRuntimeScope(
546
+ {
547
+ langfuse: agentBLangfuse,
548
+ traceIdSeed: 'agent-b-seed',
549
+ runId: 'run-1',
550
+ agentId: 'agent-b',
551
+ },
552
+ () =>
553
+ streamHandler?.handleChainStart(
554
+ { lc: 1, type: 'not_implemented', id: ['OwnAgentChain'] },
555
+ { input: 'own agent' },
556
+ 'lc-own-agent-run',
557
+ undefined,
558
+ undefined,
559
+ { langgraph_node: 'agent=agent-b' }
560
+ )
561
+ );
562
+
563
+ expect(mockProcessorStarts).toContainEqual(
564
+ expect.objectContaining({
565
+ params: expect.objectContaining({ publicKey: 'pk-agent-b' }),
566
+ traceId: traceIdFromSeed('agent-b-seed'),
567
+ })
568
+ );
569
+
570
+ // Explicit `agentId` metadata (stamped by the graph's model path and
571
+ // ToolNode) beats node-name parsing: agent `research`'s inner node is
572
+ // named `agent=research`, indistinguishable by name from a sibling
573
+ // literally named `agent=research` — the explicit identity disambiguates.
574
+ await withLangfuseRuntimeScope(
575
+ {
576
+ langfuse: agentBLangfuse,
577
+ traceIdSeed: 'prefixed-sibling-seed',
578
+ runId: 'run-1',
579
+ agentId: 'agent=research',
580
+ },
581
+ () =>
582
+ streamHandler?.handleChainStart(
583
+ { lc: 1, type: 'not_implemented', id: ['AmbiguousNodeChain'] },
584
+ { input: 'ambiguous node' },
585
+ 'lc-ambiguous-node-run',
586
+ undefined,
587
+ undefined,
588
+ { langgraph_node: 'agent=research', agentId: 'research' }
589
+ )
590
+ );
591
+
592
+ expect(mockProcessorStarts).toContainEqual(
593
+ expect.objectContaining({
594
+ params: expect.objectContaining({ publicKey: 'pk-run' }),
595
+ traceId: traceIdFromSeed('run-seed'),
596
+ })
597
+ );
598
+
599
+ // An agent id that itself begins with an internal node prefix is carried
600
+ // VERBATIM by its outer workflow node — still recognized as its own scope.
601
+ await withLangfuseRuntimeScope(
602
+ {
603
+ langfuse: agentBLangfuse,
604
+ traceIdSeed: 'prefixed-agent-seed',
605
+ runId: 'run-1',
606
+ agentId: 'agent=research',
607
+ },
608
+ () =>
609
+ streamHandler?.handleChainStart(
610
+ { lc: 1, type: 'not_implemented', id: ['PrefixedAgentChain'] },
611
+ { input: 'prefixed agent' },
612
+ 'lc-prefixed-agent-run',
613
+ undefined,
614
+ undefined,
615
+ { langgraph_node: 'agent=research' }
616
+ )
617
+ );
618
+
619
+ expect(mockProcessorStarts).toContainEqual(
620
+ expect.objectContaining({
621
+ params: expect.objectContaining({ publicKey: 'pk-agent-b' }),
622
+ traceId: traceIdFromSeed('prefixed-agent-seed'),
623
+ })
624
+ );
625
+ });
626
+
627
+ it('rejects a sibling agent overlay for tool callbacks', async () => {
628
+ const { createLangfuseHandler } = await import('@/langfuse');
629
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
630
+ const { withLangfuseRuntimeScope } = await import('@/langfuseRuntimeScope');
631
+ const runLangfuse = {
632
+ publicKey: 'pk-run',
633
+ secretKey: 'sk-run',
634
+ baseUrl: 'https://langfuse.run',
635
+ deterministicTraceId: true,
636
+ };
637
+ const agentBLangfuse = {
638
+ publicKey: 'pk-agent-b',
639
+ secretKey: 'sk-agent-b',
640
+ baseUrl: 'https://langfuse.agent-b',
641
+ deterministicTraceId: true,
642
+ };
643
+ initializeLangfuseTracing(runLangfuse);
644
+ initializeLangfuseTracing(agentBLangfuse);
645
+ const streamHandler = createLangfuseHandler({
646
+ langfuse: runLangfuse,
647
+ traceIdSeed: 'run-seed',
648
+ runId: 'run-1',
649
+ });
650
+
651
+ // Tool supersteps stamp their scope with the executing agent; a tool
652
+ // callback reporting a different agent via `tools=<id>` metadata must
653
+ // not adopt the sibling's overlay.
654
+ await withLangfuseRuntimeScope(
655
+ {
656
+ langfuse: agentBLangfuse,
657
+ traceIdSeed: 'agent-b-seed',
658
+ runId: 'run-1',
659
+ agentId: 'agent-b',
660
+ },
661
+ () =>
662
+ streamHandler?.handleToolStart(
663
+ { lc: 1, type: 'not_implemented', id: ['SiblingTool'] },
664
+ 'sibling tool input',
665
+ 'lc-sibling-tool-run',
666
+ undefined,
667
+ undefined,
668
+ { langgraph_node: 'tools=agent-a' }
669
+ )
670
+ );
671
+
672
+ expect(mockProcessorStarts).toContainEqual(
673
+ expect.objectContaining({
674
+ params: expect.objectContaining({ publicKey: 'pk-run' }),
675
+ traceId: traceIdFromSeed('run-seed'),
676
+ })
677
+ );
678
+ expect(
679
+ mockProcessorStarts.filter(
680
+ (record) =>
681
+ (record.params as { publicKey?: string }).publicKey === 'pk-agent-b'
682
+ )
683
+ ).toHaveLength(0);
684
+ });
685
+
686
+ it('replaces foreign propagated identity attributes with its own', async () => {
687
+ const { createLangfuseHandler } = await import('@/langfuse');
688
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
689
+ const { withLangfuseRuntimeScope } = await import('@/langfuseRuntimeScope');
690
+ const { propagateAttributes } = await import('@langfuse/tracing');
691
+ const { getPropagatedAttributesFromContext } = await import(
692
+ '@langfuse/core'
693
+ );
694
+ const tenantA = {
695
+ publicKey: 'pk-tenant-a',
696
+ secretKey: 'sk-tenant-a',
697
+ baseUrl: 'https://langfuse.tenant-a',
698
+ };
699
+ const tenantB = {
700
+ publicKey: 'pk-tenant-b',
701
+ secretKey: 'sk-tenant-b',
702
+ baseUrl: 'https://langfuse.tenant-b',
703
+ };
704
+ initializeLangfuseTracing(tenantA);
705
+ initializeLangfuseTracing(tenantB);
706
+ const handlerB = createLangfuseHandler({
707
+ langfuse: tenantB,
708
+ userId: 'user-b',
709
+ runId: 'run-b',
710
+ });
711
+
712
+ let observedAttributes: Record<string, unknown> | undefined;
713
+ mockStartActiveSpan.mockImplementationOnce(
714
+ (_name, _options, activeContext, callback) => {
715
+ observedAttributes = getPropagatedAttributesFromContext(
716
+ otelContext.active()
717
+ ) as Record<string, unknown>;
718
+ return callback(
719
+ createMockSpan(otelTrace.getSpanContext(activeContext)?.traceId)
720
+ );
721
+ }
722
+ );
723
+
724
+ // Run A's propagated identity (user, session) is ambient; B's spans must
725
+ // carry B's identity — and NOT retain A's session where B has none.
726
+ await propagateAttributes(
727
+ { userId: 'user-a', sessionId: 'session-a' },
728
+ async () =>
729
+ withLangfuseRuntimeScope({ langfuse: tenantA, runId: 'run-a' }, () =>
730
+ handlerB?.handleChainStart(
731
+ { lc: 1, type: 'not_implemented', id: ['IdentityChain'] },
732
+ { input: 'identity' },
733
+ 'lc-identity-run'
734
+ )
735
+ )
736
+ );
737
+
738
+ expect(observedAttributes?.['user.id']).toBe('user-b');
739
+ expect(observedAttributes?.['session.id']).toBeUndefined();
740
+ });
741
+
742
+ it('clears a foreign scope for env-credential runs instead of inheriting it', async () => {
743
+ const { createLangfuseHandler } = await import('@/langfuse');
744
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
745
+ const { withLangfuseRuntimeScope } = await import('@/langfuseRuntimeScope');
746
+ process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
747
+ process.env.LANGFUSE_SECRET_KEY = 'sk-env';
748
+ const tenantA = {
749
+ publicKey: 'pk-tenant-a',
750
+ secretKey: 'sk-tenant-a',
751
+ baseUrl: 'https://langfuse.tenant-a',
752
+ deterministicTraceId: true,
753
+ };
754
+ initializeLangfuseTracing(tenantA);
755
+ initializeLangfuseTracing();
756
+ // Run B has no explicit config or seed of its own — it relies on env
757
+ // credentials. Rejecting a foreign scope must CLEAR the foreign values
758
+ // rather than let merge inheritance re-adopt them through `undefined`.
759
+ const handlerB = createLangfuseHandler({ runId: 'run-b' });
760
+
761
+ await withLangfuseRuntimeScope(
762
+ { langfuse: tenantA, traceIdSeed: 'run-a', runId: 'run-a' },
763
+ () =>
764
+ handlerB?.handleChainStart(
765
+ { lc: 1, type: 'not_implemented', id: ['EnvCredentialChain'] },
766
+ { input: 'env credentials' },
767
+ 'lc-env-run'
768
+ )
769
+ );
770
+
771
+ expect(mockProcessorStarts).toContainEqual(
772
+ expect.objectContaining({
773
+ params: expect.objectContaining({
774
+ publicKey: 'pk-env',
775
+ secretKey: 'sk-env',
776
+ }),
777
+ })
778
+ );
779
+ expect(
780
+ mockProcessorStarts.filter(
781
+ (record) =>
782
+ (record.params as { publicKey?: string }).publicKey ===
783
+ 'pk-tenant-a' || record.traceId === traceIdFromSeed('run-a')
784
+ )
785
+ ).toHaveLength(0);
786
+ });
787
+
788
+ it('applies its own tool-output policy inside a foreign run scope', async () => {
789
+ const { createLangfuseHandler } = await import('@/langfuse');
790
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
791
+ const { withLangfuseRuntimeScope } = await import('@/langfuseRuntimeScope');
792
+ const { resolveToolOutputTracingConfig } = await import('@/langfuseConfig');
793
+ const { getLangfuseRuntimeToolOutputTracingConfig } = await import(
794
+ '@/langfuseRuntimeContext'
795
+ );
796
+ const tenantA = {
797
+ publicKey: 'pk-tenant-a',
798
+ secretKey: 'sk-tenant-a',
799
+ baseUrl: 'https://langfuse.tenant-a',
800
+ toolOutputTracing: { enabled: true },
801
+ };
802
+ const tenantB = {
803
+ publicKey: 'pk-tenant-b',
804
+ secretKey: 'sk-tenant-b',
805
+ baseUrl: 'https://langfuse.tenant-b',
806
+ toolOutputTracing: { enabled: false },
807
+ };
808
+ initializeLangfuseTracing(tenantA);
809
+ initializeLangfuseTracing(tenantB);
810
+ const handlerB = createLangfuseHandler({
811
+ langfuse: tenantB,
812
+ runId: 'run-b',
813
+ });
814
+
815
+ let observedPolicy: { enabled: boolean } | undefined;
816
+ mockStartActiveSpan.mockImplementationOnce(
817
+ (_name, _options, activeContext, callback) => {
818
+ observedPolicy = getLangfuseRuntimeToolOutputTracingConfig();
819
+ return callback(
820
+ createMockSpan(otelTrace.getSpanContext(activeContext)?.traceId)
821
+ );
822
+ }
823
+ );
824
+
825
+ // Tenant A permits tool output; tenant B redacts. B's callback running
826
+ // inside A's scope must not inherit A's permissive policy.
827
+ await withLangfuseRuntimeScope(
828
+ {
829
+ langfuse: tenantA,
830
+ runId: 'run-a',
831
+ toolOutputTracing: resolveToolOutputTracingConfig(tenantA),
832
+ },
833
+ () =>
834
+ handlerB?.handleToolStart(
835
+ { lc: 1, type: 'not_implemented', id: ['SensitiveTool'] },
836
+ 'sensitive input',
837
+ 'lc-tool-run'
838
+ )
839
+ );
840
+
841
+ expect(observedPolicy?.enabled).toBe(false);
842
+ });
843
+
388
844
  it('attaches configured trace attributes to Langfuse callback spans', async () => {
389
845
  const { createLangfuseHandler } = await import('@/langfuse');
390
846
  const { initializeLangfuseTracing } = await import('@/instrumentation');