@librechat/agents 3.3.8 → 3.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/dist/cjs/graphs/Graph.cjs +47 -13
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/graphs/MultiAgentGraph.cjs +35 -2
  4. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  5. package/dist/cjs/hitl/askUserQuestion.cjs +3 -2
  6. package/dist/cjs/hitl/askUserQuestion.cjs.map +1 -1
  7. package/dist/cjs/instrumentation.cjs +18 -48
  8. package/dist/cjs/instrumentation.cjs.map +1 -1
  9. package/dist/cjs/langfuse.cjs +174 -29
  10. package/dist/cjs/langfuse.cjs.map +1 -1
  11. package/dist/cjs/langfuseConfig.cjs +12 -0
  12. package/dist/cjs/langfuseConfig.cjs.map +1 -1
  13. package/dist/cjs/langfuseRuntimeContext.cjs +23 -2
  14. package/dist/cjs/langfuseRuntimeContext.cjs.map +1 -1
  15. package/dist/cjs/langfuseRuntimeScope.cjs +39 -8
  16. package/dist/cjs/langfuseRuntimeScope.cjs.map +1 -1
  17. package/dist/cjs/langfuseSpanRegistry.cjs +95 -0
  18. package/dist/cjs/langfuseSpanRegistry.cjs.map +1 -0
  19. package/dist/cjs/langfuseTraceShaping.cjs +121 -4
  20. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
  21. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +39 -15
  22. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +25 -5
  24. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  25. package/dist/cjs/llm/init.cjs +3 -3
  26. package/dist/cjs/llm/invoke.cjs +5 -5
  27. package/dist/cjs/llm/openai/index.cjs +1 -1
  28. package/dist/cjs/main.cjs +10 -10
  29. package/dist/cjs/messages/prune.cjs +13 -1
  30. package/dist/cjs/messages/prune.cjs.map +1 -1
  31. package/dist/cjs/prompts/activityLabel.cjs +24 -12
  32. package/dist/cjs/prompts/activityLabel.cjs.map +1 -1
  33. package/dist/cjs/run.cjs +47 -22
  34. package/dist/cjs/run.cjs.map +1 -1
  35. package/dist/cjs/session/messageSerialization.cjs +6 -0
  36. package/dist/cjs/session/messageSerialization.cjs.map +1 -1
  37. package/dist/cjs/stream.cjs +21 -10
  38. package/dist/cjs/stream.cjs.map +1 -1
  39. package/dist/cjs/summarization/node.cjs +5 -0
  40. package/dist/cjs/summarization/node.cjs.map +1 -1
  41. package/dist/cjs/tools/ToolNode.cjs +253 -24
  42. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  43. package/dist/cjs/tools/handlers.cjs +1 -1
  44. package/dist/cjs/tools/search/tool.cjs +1 -1
  45. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -1
  46. package/dist/cjs/utils/index.cjs +2 -2
  47. package/dist/esm/graphs/Graph.mjs +48 -14
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/graphs/MultiAgentGraph.mjs +35 -2
  50. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  51. package/dist/esm/hitl/askUserQuestion.mjs +3 -2
  52. package/dist/esm/hitl/askUserQuestion.mjs.map +1 -1
  53. package/dist/esm/instrumentation.mjs +18 -48
  54. package/dist/esm/instrumentation.mjs.map +1 -1
  55. package/dist/esm/langfuse.mjs +176 -28
  56. package/dist/esm/langfuse.mjs.map +1 -1
  57. package/dist/esm/langfuseConfig.mjs +10 -1
  58. package/dist/esm/langfuseConfig.mjs.map +1 -1
  59. package/dist/esm/langfuseRuntimeContext.mjs +21 -3
  60. package/dist/esm/langfuseRuntimeContext.mjs.map +1 -1
  61. package/dist/esm/langfuseRuntimeScope.mjs +39 -10
  62. package/dist/esm/langfuseRuntimeScope.mjs.map +1 -1
  63. package/dist/esm/langfuseSpanRegistry.mjs +91 -0
  64. package/dist/esm/langfuseSpanRegistry.mjs.map +1 -0
  65. package/dist/esm/langfuseTraceShaping.mjs +121 -4
  66. package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
  67. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +39 -15
  68. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  69. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +25 -5
  70. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  71. package/dist/esm/llm/init.mjs +2 -2
  72. package/dist/esm/llm/invoke.mjs +5 -5
  73. package/dist/esm/llm/openai/index.mjs +1 -1
  74. package/dist/esm/main.mjs +8 -8
  75. package/dist/esm/messages/prune.mjs +13 -1
  76. package/dist/esm/messages/prune.mjs.map +1 -1
  77. package/dist/esm/prompts/activityLabel.mjs +24 -12
  78. package/dist/esm/prompts/activityLabel.mjs.map +1 -1
  79. package/dist/esm/run.mjs +47 -22
  80. package/dist/esm/run.mjs.map +1 -1
  81. package/dist/esm/session/messageSerialization.mjs +6 -0
  82. package/dist/esm/session/messageSerialization.mjs.map +1 -1
  83. package/dist/esm/stream.mjs +21 -10
  84. package/dist/esm/stream.mjs.map +1 -1
  85. package/dist/esm/summarization/node.mjs +5 -0
  86. package/dist/esm/summarization/node.mjs.map +1 -1
  87. package/dist/esm/tools/ToolNode.mjs +254 -25
  88. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  89. package/dist/esm/tools/handlers.mjs +1 -1
  90. package/dist/esm/tools/search/tool.mjs +1 -1
  91. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -1
  92. package/dist/esm/utils/index.mjs +2 -2
  93. package/dist/types/graphs/Graph.d.ts +19 -0
  94. package/dist/types/hitl/askUserQuestion.d.ts +11 -1
  95. package/dist/types/langfuse.d.ts +16 -8
  96. package/dist/types/langfuseConfig.d.ts +6 -0
  97. package/dist/types/langfuseRuntimeContext.d.ts +27 -1
  98. package/dist/types/langfuseRuntimeScope.d.ts +17 -2
  99. package/dist/types/langfuseSpanRegistry.d.ts +17 -0
  100. package/dist/types/langfuseTraceShaping.d.ts +2 -1
  101. package/dist/types/llm/anthropic/utils/message_inputs.d.ts +1 -0
  102. package/dist/types/session/types.d.ts +1 -0
  103. package/dist/types/tools/ToolNode.d.ts +7 -1
  104. package/dist/types/types/hitl.d.ts +8 -0
  105. package/dist/types/types/tools.d.ts +30 -0
  106. package/package.json +7 -4
  107. package/src/__tests__/stream.eagerArgsDivergence.test.ts +753 -0
  108. package/src/graphs/Graph.ts +69 -20
  109. package/src/graphs/MultiAgentGraph.ts +56 -2
  110. package/src/graphs/__tests__/composition.smoke.test.ts +4 -0
  111. package/src/hitl/askUserQuestion.ts +14 -1
  112. package/src/instrumentation.ts +35 -77
  113. package/src/langfuse.ts +320 -43
  114. package/src/langfuseConfig.ts +24 -0
  115. package/src/langfuseRuntimeContext.ts +43 -1
  116. package/src/langfuseRuntimeScope.ts +94 -21
  117. package/src/langfuseSpanRegistry.ts +131 -0
  118. package/src/langfuseTraceShaping.ts +194 -7
  119. package/src/llm/anthropic/utils/message_inputs.ts +70 -19
  120. package/src/llm/anthropic/utils/streaming-tool-input.test.ts +186 -11
  121. package/src/llm/bedrock/utils/message_inputs.test.ts +120 -4
  122. package/src/llm/bedrock/utils/message_inputs.ts +32 -7
  123. package/src/messages/prune.ts +12 -1
  124. package/src/prompts/activityLabel.ts +23 -6
  125. package/src/run.ts +76 -45
  126. package/src/scripts/activity-labels/captured.json +56 -0
  127. package/src/scripts/activity-labels/checks.cjs +205 -0
  128. package/src/scripts/activity-labels/corpus.cjs +473 -0
  129. package/src/scripts/activity-labels/report.cjs +203 -0
  130. package/src/scripts/activity-labels/rescore.cjs +102 -0
  131. package/src/scripts/activity-labels/run.ts +705 -0
  132. package/src/scripts/activity-labels/variants.ts +71 -0
  133. package/src/session/messageSerialization.ts +12 -1
  134. package/src/session/types.ts +1 -0
  135. package/src/specs/activity-label-prompt.test.ts +26 -10
  136. package/src/specs/agent-handoffs.test.ts +306 -0
  137. package/src/specs/langfuse-callbacks.test.ts +456 -0
  138. package/src/specs/langfuse-routing.integration.test.ts +138 -1
  139. package/src/specs/langfuse-span-registry.test.ts +70 -0
  140. package/src/specs/langfuse-trace-shaping.test.ts +294 -0
  141. package/src/specs/prune.test.ts +38 -1
  142. package/src/stream.ts +70 -6
  143. package/src/summarization/node.ts +5 -0
  144. package/src/tools/ToolNode.ts +400 -9
  145. package/src/tools/__tests__/ToolNode.invalidToolCalls.test.ts +757 -0
  146. package/src/tools/__tests__/hitl.test.ts +58 -0
  147. package/src/types/hitl.ts +8 -0
  148. package/src/types/tools.ts +35 -1
@@ -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');
@@ -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
+ });