@librechat/agents 3.1.95 → 3.1.97

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 (67) hide show
  1. package/dist/cjs/graphs/Graph.cjs +54 -21
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/instrumentation.cjs +120 -9
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuse.cjs +30 -226
  6. package/dist/cjs/langfuse.cjs.map +1 -1
  7. package/dist/cjs/langfuseToolOutputTracing.cjs +465 -0
  8. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -0
  9. package/dist/cjs/main.cjs +1 -0
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/run.cjs +142 -69
  12. package/dist/cjs/run.cjs.map +1 -1
  13. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +29 -2
  14. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  15. package/dist/cjs/tools/ToolNode.cjs +20 -8
  16. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  17. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +10 -6
  18. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +56 -23
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/instrumentation.mjs +118 -9
  22. package/dist/esm/instrumentation.mjs.map +1 -1
  23. package/dist/esm/langfuse.mjs +28 -224
  24. package/dist/esm/langfuse.mjs.map +1 -1
  25. package/dist/esm/langfuseToolOutputTracing.mjs +457 -0
  26. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -0
  27. package/dist/esm/main.mjs +1 -1
  28. package/dist/esm/run.mjs +144 -71
  29. package/dist/esm/run.mjs.map +1 -1
  30. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +29 -3
  31. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  32. package/dist/esm/tools/ToolNode.mjs +20 -8
  33. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  34. package/dist/esm/tools/subagent/SubagentExecutor.mjs +10 -6
  35. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  36. package/dist/types/graphs/Graph.d.ts +5 -1
  37. package/dist/types/instrumentation.d.ts +5 -1
  38. package/dist/types/langfuse.d.ts +6 -28
  39. package/dist/types/langfuseToolOutputTracing.d.ts +20 -0
  40. package/dist/types/run.d.ts +5 -1
  41. package/dist/types/tools/BashProgrammaticToolCalling.d.ts +1 -0
  42. package/dist/types/tools/ToolNode.d.ts +4 -1
  43. package/dist/types/tools/subagent/SubagentExecutor.d.ts +2 -0
  44. package/dist/types/types/graph.d.ts +30 -0
  45. package/dist/types/types/run.d.ts +6 -0
  46. package/dist/types/types/tools.d.ts +7 -0
  47. package/package.json +2 -1
  48. package/src/graphs/Graph.ts +90 -34
  49. package/src/instrumentation.ts +172 -11
  50. package/src/langfuse.ts +59 -324
  51. package/src/langfuseToolOutputTracing.ts +683 -0
  52. package/src/run.ts +190 -87
  53. package/src/specs/langfuse-callbacks.test.ts +178 -1
  54. package/src/specs/langfuse-config.test.ts +112 -76
  55. package/src/specs/langfuse-instrumentation.test.ts +283 -0
  56. package/src/specs/langfuse-metadata.test.ts +54 -1
  57. package/src/specs/langfuse-tool-output-tracing.test.ts +588 -0
  58. package/src/tools/BashProgrammaticToolCalling.ts +39 -5
  59. package/src/tools/ToolNode.ts +28 -7
  60. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +54 -0
  61. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +72 -4
  62. package/src/tools/__tests__/SubagentExecutor.test.ts +32 -0
  63. package/src/tools/__tests__/ToolNode.langfuse.test.ts +41 -0
  64. package/src/tools/subagent/SubagentExecutor.ts +11 -6
  65. package/src/types/graph.ts +32 -0
  66. package/src/types/run.ts +6 -0
  67. package/src/types/tools.ts +7 -0
package/src/run.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/run.ts
2
- import './instrumentation';
2
+ import { initializeLangfuseTracing } from './instrumentation';
3
3
  import { PromptTemplate } from '@langchain/core/prompts';
4
4
  import { RunnableLambda } from '@langchain/core/runnables';
5
5
  import { AzureChatOpenAI, ChatOpenAI } from '@langchain/openai';
@@ -36,15 +36,16 @@ import {
36
36
  type CallbackEntry,
37
37
  } from '@/utils/callbacks';
38
38
  import {
39
- createLegacyLangfuseHandler,
40
39
  createLangfuseTraceMetadata,
41
40
  createLangfuseHandler,
42
41
  disposeLangfuseHandler,
43
42
  getLangfuseTraceName,
44
- hasExplicitLangfuseConfig,
45
- hasLangfuseEnvConfig,
46
43
  isLangfuseCallbackHandler,
47
44
  } from '@/langfuse';
45
+ import {
46
+ resolveLangfuseConfig,
47
+ withLangfuseToolOutputTracingConfig,
48
+ } from '@/langfuseToolOutputTracing';
48
49
  import type { HookRegistry } from '@/hooks';
49
50
 
50
51
  export const defaultOmitOptions = new Set([
@@ -120,6 +121,7 @@ export class Run<_T extends t.BaseGraphState> {
120
121
  private handlerRegistry?: HandlerRegistry;
121
122
  private hookRegistry?: HookRegistry;
122
123
  private humanInTheLoop?: t.HumanInTheLoopConfig;
124
+ private langfuse?: t.LangfuseConfig;
123
125
  private toolOutputReferences?: t.ToolOutputReferencesConfig;
124
126
  private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
125
127
  private toolExecution?: t.ToolExecutionConfig;
@@ -166,6 +168,7 @@ export class Run<_T extends t.BaseGraphState> {
166
168
  this.handlerRegistry = handlerRegistry;
167
169
  this.hookRegistry = config.hooks;
168
170
  this.humanInTheLoop = config.humanInTheLoop;
171
+ this.langfuse = config.langfuse;
169
172
  this.toolOutputReferences = config.toolOutputReferences;
170
173
  this.eagerEventToolExecution = config.eagerEventToolExecution;
171
174
  this.toolExecution = config.toolExecution;
@@ -238,6 +241,7 @@ export class Run<_T extends t.BaseGraphState> {
238
241
  signal,
239
242
  runId: this.id,
240
243
  agents: [agentConfig],
244
+ langfuse: this.langfuse,
241
245
  tokenCounter: this.tokenCounter,
242
246
  indexTokenCountMap: this.indexTokenCountMap,
243
247
  calibrationRatio: this.calibrationRatio,
@@ -264,6 +268,7 @@ export class Run<_T extends t.BaseGraphState> {
264
268
  runId: this.id,
265
269
  agents,
266
270
  edges,
271
+ langfuse: this.langfuse,
267
272
  tokenCounter: this.tokenCounter,
268
273
  indexTokenCountMap: this.indexTokenCountMap,
269
274
  calibrationRatio: this.calibrationRatio,
@@ -546,6 +551,97 @@ export class Run<_T extends t.BaseGraphState> {
546
551
  };
547
552
  }
548
553
 
554
+ private shouldClearHookSession(streamThrew: boolean): boolean {
555
+ return (
556
+ this._interrupt == null || this._haltedReason != null || streamThrew
557
+ );
558
+ }
559
+
560
+ private isAwaitingResume(streamThrew: boolean): boolean {
561
+ return (
562
+ this._interrupt != null && this._haltedReason == null && !streamThrew
563
+ );
564
+ }
565
+
566
+ private getStreamLangfuseConfig(
567
+ graph: StandardGraph | MultiAgentGraph
568
+ ): t.LangfuseConfig | undefined {
569
+ const primaryContext = graph.agentContexts.get(graph.defaultAgentId);
570
+ if (primaryContext != null) {
571
+ return resolveLangfuseConfig(this.langfuse, primaryContext.langfuse);
572
+ }
573
+
574
+ for (const context of graph.agentContexts.values()) {
575
+ const langfuse = resolveLangfuseConfig(this.langfuse, context.langfuse);
576
+ if (langfuse != null) {
577
+ return langfuse;
578
+ }
579
+ }
580
+
581
+ return this.langfuse;
582
+ }
583
+
584
+ private getStreamToolOutputTracingLangfuseConfig(
585
+ graph: StandardGraph | MultiAgentGraph
586
+ ): t.LangfuseConfig | undefined {
587
+ const toolOutputTracingConfigs = Array.from(
588
+ graph.agentContexts.values()
589
+ )
590
+ .map((context) => {
591
+ return resolveLangfuseConfig(this.langfuse, context.langfuse)
592
+ ?.toolOutputTracing;
593
+ })
594
+ .filter((config): config is t.LangfuseToolOutputTracingConfig => {
595
+ return config != null;
596
+ });
597
+
598
+ if (toolOutputTracingConfigs.length === 0) {
599
+ return this.langfuse?.toolOutputTracing != null
600
+ ? { toolOutputTracing: this.langfuse.toolOutputTracing }
601
+ : undefined;
602
+ }
603
+ if (toolOutputTracingConfigs.length === 1) {
604
+ return { toolOutputTracing: toolOutputTracingConfigs[0] };
605
+ }
606
+
607
+ let enabled: boolean | undefined;
608
+ let redactionText: string | undefined;
609
+ let redactedToolNameMatchMode: 'exact' | 'partial' | undefined;
610
+ const redactedToolNames = new Set<string>();
611
+
612
+ for (const config of toolOutputTracingConfigs) {
613
+ if (config.enabled === false) {
614
+ enabled = false;
615
+ } else if (enabled !== false && config.enabled != null) {
616
+ enabled = config.enabled;
617
+ }
618
+
619
+ redactionText ??= config.redactionText;
620
+ if (config.redactedToolNameMatchMode === 'partial') {
621
+ redactedToolNameMatchMode = 'partial';
622
+ } else {
623
+ redactedToolNameMatchMode ??= config.redactedToolNameMatchMode;
624
+ }
625
+
626
+ for (const toolName of config.redactedToolNames ?? []) {
627
+ redactedToolNames.add(toolName);
628
+ }
629
+ }
630
+
631
+ return {
632
+ toolOutputTracing: {
633
+ ...(enabled != null ? { enabled } : {}),
634
+ ...(redactedToolNames.size > 0
635
+ ? { redactedToolNames: Array.from(redactedToolNames) }
636
+ : {}),
637
+ ...(redactedToolNameMatchMode != null
638
+ ? { redactedToolNameMatchMode }
639
+ : {}),
640
+ ...(redactionText != null ? { redactionText } : {}),
641
+ },
642
+ };
643
+ }
644
+
549
645
  async processStream(
550
646
  inputs: t.IState | Command,
551
647
  callerConfig: Partial<RunnableConfig> & {
@@ -564,6 +660,8 @@ export class Run<_T extends t.BaseGraphState> {
564
660
  'Graph not initialized. Make sure to use Run.create() to instantiate the Run.'
565
661
  );
566
662
  }
663
+ const graphRunnable = this.graphRunnable;
664
+ const graph = this.Graph;
567
665
 
568
666
  /**
569
667
  * `Command` inputs (currently only `Command({ resume })`) are
@@ -596,7 +694,7 @@ export class Run<_T extends t.BaseGraphState> {
596
694
  * boundary.
597
695
  */
598
696
  if (!isResume) {
599
- this.Graph.resetValues(streamOptions?.keepContent);
697
+ graph.resetValues(streamOptions?.keepContent);
600
698
  }
601
699
  this._interrupt = undefined;
602
700
  this._haltedReason = undefined;
@@ -619,34 +717,33 @@ export class Run<_T extends t.BaseGraphState> {
619
717
  streamCallbacks ? [streamCallbacks, customHandler] : [customHandler]
620
718
  );
621
719
 
622
- if (
623
- hasLangfuseEnvConfig() &&
624
- !hasExplicitLangfuseConfig(this.Graph.agentContexts.values())
625
- ) {
626
- const userId =
627
- typeof config.configurable?.user_id === 'string'
628
- ? config.configurable.user_id
629
- : undefined;
630
- const sessionId =
631
- typeof config.configurable?.thread_id === 'string'
632
- ? config.configurable.thread_id
633
- : undefined;
634
- const primaryContext = this.Graph.agentContexts.get(
635
- this.Graph.defaultAgentId
636
- );
637
- const traceMetadata = createLangfuseTraceMetadata({
638
- messageId: this.id,
639
- parentMessageId: config.configurable?.requestBody?.parentMessageId,
640
- agentName: primaryContext?.name,
641
- });
642
- const handler = createLegacyLangfuseHandler({
643
- userId,
644
- sessionId,
645
- traceMetadata,
646
- tags: ['librechat', 'agent'],
647
- });
720
+ const primaryContext = graph.agentContexts.get(graph.defaultAgentId);
721
+ const userId =
722
+ typeof config.configurable?.user_id === 'string'
723
+ ? config.configurable.user_id
724
+ : undefined;
725
+ const sessionId =
726
+ typeof config.configurable?.thread_id === 'string'
727
+ ? config.configurable.thread_id
728
+ : undefined;
729
+ const traceMetadata = createLangfuseTraceMetadata({
730
+ messageId: this.id,
731
+ parentMessageId: config.configurable?.requestBody?.parentMessageId,
732
+ agentId: graph.defaultAgentId,
733
+ agentName: primaryContext?.name,
734
+ });
735
+ const streamLangfuseConfig = this.getStreamLangfuseConfig(graph);
736
+ initializeLangfuseTracing(streamLangfuseConfig);
737
+ const langfuseHandler = createLangfuseHandler({
738
+ langfuse: streamLangfuseConfig,
739
+ userId,
740
+ sessionId,
741
+ traceMetadata,
742
+ tags: ['librechat', 'agent'],
743
+ });
744
+ if (langfuseHandler != null) {
648
745
  config.runName = config.runName ?? getLangfuseTraceName(traceMetadata);
649
- config.callbacks = appendCallbacks(config.callbacks, [handler]);
746
+ config.callbacks = appendCallbacks(config.callbacks, [langfuseHandler]);
650
747
  }
651
748
 
652
749
  if (!this.id) {
@@ -671,25 +768,6 @@ export class Run<_T extends t.BaseGraphState> {
671
768
  }
672
769
  }
673
770
 
674
- /**
675
- * `streamEvents` accepts both state inputs and `Command` (resume) at
676
- * runtime, but our `CompiledStateWorkflow` type narrows the first
677
- * arg to `BaseGraphState`. Cast on the call so the resume path
678
- * type-checks without widening the wrapper for every caller.
679
- */
680
- const stream = this.graphRunnable.streamEvents(inputs as t.IState, config, {
681
- raiseError: true,
682
- /**
683
- * Prevent EventStreamCallbackHandler from processing custom events.
684
- * Custom events are already handled via our createCustomEventCallback()
685
- * which routes them through the handlerRegistry.
686
- * Without this flag, EventStreamCallbackHandler throws errors when
687
- * custom events are dispatched for run IDs not in its internal map
688
- * (due to timing issues in parallel execution or after run cleanup).
689
- */
690
- ignoreCustomEvent: true,
691
- });
692
-
693
771
  /**
694
772
  * Tracks whether the stream loop threw. Used by the `finally`
695
773
  * block to decide whether to honor the interrupt-preservation
@@ -701,7 +779,26 @@ export class Run<_T extends t.BaseGraphState> {
701
779
  */
702
780
  let streamThrew = false;
703
781
 
704
- try {
782
+ const consumeStream = async (): Promise<void> => {
783
+ /**
784
+ * `streamEvents` accepts both state inputs and `Command` (resume) at
785
+ * runtime, but our `CompiledStateWorkflow` type narrows the first
786
+ * arg to `BaseGraphState`. Cast on the call so the resume path
787
+ * type-checks without widening the wrapper for every caller.
788
+ */
789
+ const stream = graphRunnable.streamEvents(inputs as t.IState, config, {
790
+ raiseError: true,
791
+ /**
792
+ * Prevent EventStreamCallbackHandler from processing custom events.
793
+ * Custom events are already handled via our createCustomEventCallback()
794
+ * which routes them through the handlerRegistry.
795
+ * Without this flag, EventStreamCallbackHandler throws errors when
796
+ * custom events are dispatched for run IDs not in its internal map
797
+ * (due to timing issues in parallel execution or after run cleanup).
798
+ */
799
+ ignoreCustomEvent: true,
800
+ });
801
+
705
802
  for await (const event of stream) {
706
803
  const { data, metadata, ...info } = event;
707
804
 
@@ -797,9 +894,8 @@ export class Run<_T extends t.BaseGraphState> {
797
894
  hook_event_name: 'Stop',
798
895
  runId: this.id,
799
896
  threadId,
800
- agentId: this.Graph.defaultAgentId,
801
- messages:
802
- this.Graph.getRunMessages() ?? stateInputs?.messages ?? [],
897
+ agentId: graph.defaultAgentId,
898
+ messages: graph.getRunMessages() ?? stateInputs?.messages ?? [],
803
899
  stopHookActive: false, // will be true when stop is triggered by a hook (Phase 2)
804
900
  },
805
901
  sessionId: this.id,
@@ -807,6 +903,14 @@ export class Run<_T extends t.BaseGraphState> {
807
903
  /* Stop hook errors must not masquerade as stream failures */
808
904
  });
809
905
  }
906
+ };
907
+
908
+ try {
909
+ await withLangfuseToolOutputTracingConfig(
910
+ streamLangfuseConfig,
911
+ consumeStream,
912
+ this.getStreamToolOutputTracingLangfuseConfig(graph)
913
+ );
810
914
  } catch (err) {
811
915
  streamThrew = true;
812
916
  if (this.hookRegistry?.hasHookFor('StopFailure', this.id) === true) {
@@ -842,11 +946,7 @@ export class Run<_T extends t.BaseGraphState> {
842
946
  * expected, sessions must drop). Every state where no resume
843
947
  * is expected clears.
844
948
  */
845
- if (
846
- this._interrupt == null ||
847
- this._haltedReason != null ||
848
- streamThrew
849
- ) {
949
+ if (this.shouldClearHookSession(streamThrew)) {
850
950
  this.hookRegistry?.clearSession(this.id);
851
951
  }
852
952
  /**
@@ -858,6 +958,7 @@ export class Run<_T extends t.BaseGraphState> {
858
958
  * unaffected — their entries live under their own session ids.
859
959
  */
860
960
  this.hookRegistry?.clearHaltSignal(this.id);
961
+ await disposeLangfuseHandler(langfuseHandler);
861
962
 
862
963
  /**
863
964
  * Break the reference chain that keeps heavy data alive via
@@ -915,8 +1016,7 @@ export class Run<_T extends t.BaseGraphState> {
915
1016
  * Run from scratch) is a separate concern; see
916
1017
  * `HumanInTheLoopConfig` JSDoc.
917
1018
  */
918
- const awaitingResume =
919
- this._interrupt != null && this._haltedReason == null && !streamThrew;
1019
+ const awaitingResume = this.isAwaitingResume(streamThrew);
920
1020
  if (!this.skipCleanup && !awaitingResume) {
921
1021
  this.Graph.clearHeavyState();
922
1022
  }
@@ -1172,25 +1272,18 @@ export class Run<_T extends t.BaseGraphState> {
1172
1272
  typeof chainOptions.configurable?.thread_id === 'string'
1173
1273
  ? chainOptions.configurable.thread_id
1174
1274
  : undefined;
1175
- const hasExplicitLangfuse =
1176
- this.Graph != null &&
1177
- hasExplicitLangfuseConfig(this.Graph.agentContexts.values());
1178
- if (titleContext?.langfuse != null) {
1179
- titleLangfuseHandler = createLangfuseHandler({
1180
- langfuse: titleContext.langfuse,
1181
- userId,
1182
- sessionId,
1183
- traceMetadata,
1184
- tags: ['librechat', 'title'],
1185
- });
1186
- } else if (hasLangfuseEnvConfig() && !hasExplicitLangfuse) {
1187
- titleLangfuseHandler = createLegacyLangfuseHandler({
1188
- userId,
1189
- sessionId,
1190
- traceMetadata,
1191
- tags: ['librechat', 'title'],
1192
- });
1193
- }
1275
+ const titleLangfuseConfig = resolveLangfuseConfig(
1276
+ this.langfuse,
1277
+ titleContext?.langfuse
1278
+ );
1279
+ initializeLangfuseTracing(titleLangfuseConfig);
1280
+ titleLangfuseHandler = createLangfuseHandler({
1281
+ langfuse: titleLangfuseConfig,
1282
+ userId,
1283
+ sessionId,
1284
+ traceMetadata,
1285
+ tags: ['librechat', 'title'],
1286
+ });
1194
1287
 
1195
1288
  if (titleLangfuseHandler != null) {
1196
1289
  chainOptions.callbacks = appendCallbacks(chainOptions.callbacks, [
@@ -1263,9 +1356,14 @@ export class Run<_T extends t.BaseGraphState> {
1263
1356
 
1264
1357
  try {
1265
1358
  try {
1266
- return await fullChain.invoke(
1267
- { input: inputText, output: response },
1268
- invokeConfig
1359
+ return await withLangfuseToolOutputTracingConfig(
1360
+ this.langfuse,
1361
+ () =>
1362
+ fullChain.invoke(
1363
+ { input: inputText, output: response },
1364
+ invokeConfig
1365
+ ),
1366
+ titleContext?.langfuse
1269
1367
  );
1270
1368
  } catch (_e) {
1271
1369
  // Fallback: strip callbacks to avoid EventStream tracer errors in certain environments
@@ -1278,9 +1376,14 @@ export class Run<_T extends t.BaseGraphState> {
1278
1376
  const safeConfig = Object.assign({}, rest, {
1279
1377
  callbacks: langfuseHandler ? [langfuseHandler] : [],
1280
1378
  });
1281
- return await fullChain.invoke(
1282
- { input: inputText, output: response },
1283
- safeConfig as Partial<RunnableConfig>
1379
+ return await withLangfuseToolOutputTracingConfig(
1380
+ this.langfuse,
1381
+ () =>
1382
+ fullChain.invoke(
1383
+ { input: inputText, output: response },
1384
+ safeConfig as Partial<RunnableConfig>
1385
+ ),
1386
+ titleContext?.langfuse
1284
1387
  );
1285
1388
  }
1286
1389
  } finally {
@@ -6,10 +6,23 @@ import type * as t from '@/types';
6
6
 
7
7
  const mockSpan = {
8
8
  end: jest.fn(),
9
+ spanContext: jest.fn(() => ({
10
+ traceId: 'trace-id',
11
+ spanId: 'span-id',
12
+ traceFlags: 1,
13
+ })),
9
14
  setAttributes: jest.fn(),
10
15
  setStatus: jest.fn(),
11
16
  };
12
17
  const mockStartSpan = jest.fn(() => mockSpan);
18
+ const mockStartActiveSpan = jest.fn(
19
+ (
20
+ _name: string,
21
+ _options: unknown,
22
+ _context: unknown,
23
+ callback: (span: typeof mockSpan) => unknown
24
+ ) => callback(mockSpan)
25
+ );
13
26
  const mockForceFlush = jest.fn();
14
27
  const mockShutdown = jest.fn();
15
28
 
@@ -22,6 +35,7 @@ jest.mock('@opentelemetry/sdk-trace-base', () => ({
22
35
  BasicTracerProvider: jest.fn().mockImplementation(() => ({
23
36
  forceFlush: mockForceFlush,
24
37
  getTracer: jest.fn(() => ({
38
+ startActiveSpan: mockStartActiveSpan,
25
39
  startSpan: mockStartSpan,
26
40
  })),
27
41
  shutdown: mockShutdown,
@@ -70,6 +84,169 @@ describe('Langfuse callback composition', () => {
70
84
 
71
85
  await run.processStream({ messages: [new HumanMessage('hello')] }, config);
72
86
 
73
- expect(mockStartSpan).toHaveBeenCalled();
87
+ expect(mockStartActiveSpan).toHaveBeenCalled();
88
+ expect(mockForceFlush).toHaveBeenCalled();
89
+ });
90
+
91
+ it('attaches Langfuse callbacks for direct graph invocations', async () => {
92
+ const run = await Run.create<t.IState>({
93
+ runId: 'test-langfuse-direct-graph',
94
+ graphConfig: {
95
+ type: 'standard',
96
+ agents: [
97
+ {
98
+ agentId: 'agent_abc123',
99
+ name: 'DWAINE',
100
+ provider: Providers.OPENAI,
101
+ clientOptions: { model: 'gpt-4' },
102
+ tools: [],
103
+ langfuse: {
104
+ enabled: true,
105
+ publicKey: 'pk-test',
106
+ secretKey: 'sk-test',
107
+ },
108
+ },
109
+ ],
110
+ },
111
+ skipCleanup: true,
112
+ });
113
+
114
+ run.Graph?.overrideTestModel(['hello']);
115
+ const workflow = run.Graph?.createWorkflow();
116
+ await workflow?.invoke(
117
+ { messages: [new HumanMessage('hello')] },
118
+ {
119
+ callbacks: [],
120
+ configurable: { thread_id: 'thread-1', user_id: 'user-1' },
121
+ }
122
+ );
123
+
124
+ expect(mockStartActiveSpan).toHaveBeenCalled();
125
+ });
126
+
127
+ it('preserves per-agent Langfuse config when a stream callback already exists', async () => {
128
+ const { LangfuseSpanProcessor } = await import('@langfuse/otel');
129
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
130
+ const { createLangfuseHandler } = await import('@/langfuse');
131
+ initializeLangfuseTracing({
132
+ publicKey: 'pk-run',
133
+ secretKey: 'sk-run',
134
+ baseUrl: 'https://langfuse.run',
135
+ });
136
+ const streamHandler = createLangfuseHandler({
137
+ langfuse: {
138
+ publicKey: 'pk-run',
139
+ secretKey: 'sk-run',
140
+ baseUrl: 'https://langfuse.run',
141
+ },
142
+ });
143
+ const run = await Run.create<t.IState>({
144
+ runId: 'test-langfuse-agent-callback-override',
145
+ graphConfig: {
146
+ type: 'standard',
147
+ agents: [
148
+ {
149
+ agentId: 'agent_abc123',
150
+ name: 'DWAINE',
151
+ provider: Providers.OPENAI,
152
+ clientOptions: { model: 'gpt-4' },
153
+ tools: [],
154
+ langfuse: {
155
+ enabled: true,
156
+ publicKey: 'pk-agent',
157
+ secretKey: 'sk-agent',
158
+ baseUrl: 'https://langfuse.agent',
159
+ },
160
+ },
161
+ ],
162
+ },
163
+ skipCleanup: true,
164
+ });
165
+
166
+ run.Graph?.overrideTestModel(['hello']);
167
+ const workflow = run.Graph?.createWorkflow();
168
+ await workflow?.invoke(
169
+ { messages: [new HumanMessage('hello')] },
170
+ {
171
+ callbacks: streamHandler != null ? [streamHandler] : [],
172
+ configurable: { thread_id: 'thread-1', user_id: 'user-1' },
173
+ }
174
+ );
175
+
176
+ expect(LangfuseSpanProcessor).toHaveBeenCalledWith(
177
+ expect.objectContaining({
178
+ publicKey: 'pk-agent',
179
+ secretKey: 'sk-agent',
180
+ baseUrl: 'https://langfuse.agent',
181
+ })
182
+ );
183
+ });
184
+
185
+ it('adds current agent metadata when a stream Langfuse callback already exists', async () => {
186
+ const metadataSpy = jest.fn();
187
+ const { createLangfuseHandler } = await import('@/langfuse');
188
+ const streamHandler = createLangfuseHandler({
189
+ langfuse: {
190
+ publicKey: 'pk-run',
191
+ secretKey: 'sk-run',
192
+ baseUrl: 'https://langfuse.run',
193
+ },
194
+ });
195
+ const run = await Run.create<t.IState>({
196
+ runId: 'test-langfuse-agent-metadata-with-stream-callback',
197
+ graphConfig: {
198
+ type: 'multi-agent',
199
+ agents: [
200
+ {
201
+ agentId: 'agent_default',
202
+ name: 'Default Agent',
203
+ provider: Providers.OPENAI,
204
+ clientOptions: { model: 'gpt-4' },
205
+ tools: [],
206
+ },
207
+ {
208
+ agentId: 'agent_specialist',
209
+ name: 'Specialist Agent',
210
+ provider: Providers.OPENAI,
211
+ clientOptions: { model: 'gpt-4' },
212
+ tools: [],
213
+ },
214
+ ],
215
+ edges: [],
216
+ },
217
+ skipCleanup: true,
218
+ });
219
+
220
+ run.Graph?.overrideTestModel(['hello from specialist']);
221
+ const agentNode = run.Graph?.createAgentNode('agent_specialist');
222
+ await agentNode?.invoke(
223
+ { messages: [new HumanMessage('hello')] },
224
+ {
225
+ callbacks: [
226
+ ...(streamHandler != null ? [streamHandler] : []),
227
+ {
228
+ handleChatModelStart: async (
229
+ _llm: unknown,
230
+ _messages: unknown,
231
+ _runId: string,
232
+ _parentRunId?: string,
233
+ _extraParams?: unknown,
234
+ _tags?: string[],
235
+ metadata?: Record<string, unknown>
236
+ ): Promise<void> => {
237
+ metadataSpy(metadata);
238
+ },
239
+ },
240
+ ],
241
+ configurable: { thread_id: 'thread-1', user_id: 'user-1' },
242
+ }
243
+ );
244
+
245
+ expect(metadataSpy).toHaveBeenCalledWith(
246
+ expect.objectContaining({
247
+ agentId: 'agent_specialist',
248
+ agentName: 'Specialist Agent',
249
+ })
250
+ );
74
251
  });
75
252
  });