@librechat/agents 3.3.4 → 3.3.5

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 (159) hide show
  1. package/dist/cjs/common/constants.cjs +21 -0
  2. package/dist/cjs/common/constants.cjs.map +1 -1
  3. package/dist/cjs/events.cjs +10 -1
  4. package/dist/cjs/events.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +456 -7
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  8. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  9. package/dist/cjs/hooks/index.cjs +12 -0
  10. package/dist/cjs/hooks/index.cjs.map +1 -1
  11. package/dist/cjs/hooks/types.cjs +1 -0
  12. package/dist/cjs/hooks/types.cjs.map +1 -1
  13. package/dist/cjs/langfuse.cjs +8 -0
  14. package/dist/cjs/langfuse.cjs.map +1 -1
  15. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
  16. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  17. package/dist/cjs/llm/invoke.cjs +268 -3
  18. package/dist/cjs/llm/invoke.cjs.map +1 -1
  19. package/dist/cjs/llm/preempt.cjs +132 -0
  20. package/dist/cjs/llm/preempt.cjs.map +1 -0
  21. package/dist/cjs/main.cjs +17 -3
  22. package/dist/cjs/messages/alternation.cjs +92 -0
  23. package/dist/cjs/messages/alternation.cjs.map +1 -0
  24. package/dist/cjs/messages/format.cjs +72 -0
  25. package/dist/cjs/messages/format.cjs.map +1 -1
  26. package/dist/cjs/messages/handoffCue.cjs +63 -0
  27. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  28. package/dist/cjs/messages/index.cjs +3 -0
  29. package/dist/cjs/messages/injected.cjs +60 -0
  30. package/dist/cjs/messages/injected.cjs.map +1 -0
  31. package/dist/cjs/run.cjs +80 -7
  32. package/dist/cjs/run.cjs.map +1 -1
  33. package/dist/cjs/session/handlers.cjs +18 -9
  34. package/dist/cjs/session/handlers.cjs.map +1 -1
  35. package/dist/cjs/stream.cjs +28 -7
  36. package/dist/cjs/stream.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +3 -23
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  40. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  41. package/dist/cjs/tools/search/tool.cjs +9 -1
  42. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  43. package/dist/esm/common/constants.mjs +19 -1
  44. package/dist/esm/common/constants.mjs.map +1 -1
  45. package/dist/esm/events.mjs +10 -1
  46. package/dist/esm/events.mjs.map +1 -1
  47. package/dist/esm/graphs/Graph.mjs +455 -6
  48. package/dist/esm/graphs/Graph.mjs.map +1 -1
  49. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  50. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  51. package/dist/esm/hooks/index.mjs +12 -1
  52. package/dist/esm/hooks/index.mjs.map +1 -1
  53. package/dist/esm/hooks/types.mjs +1 -0
  54. package/dist/esm/hooks/types.mjs.map +1 -1
  55. package/dist/esm/langfuse.mjs +9 -1
  56. package/dist/esm/langfuse.mjs.map +1 -1
  57. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
  58. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  59. package/dist/esm/llm/invoke.mjs +270 -6
  60. package/dist/esm/llm/invoke.mjs.map +1 -1
  61. package/dist/esm/llm/preempt.mjs +131 -0
  62. package/dist/esm/llm/preempt.mjs.map +1 -0
  63. package/dist/esm/main.mjs +8 -5
  64. package/dist/esm/messages/alternation.mjs +91 -0
  65. package/dist/esm/messages/alternation.mjs.map +1 -0
  66. package/dist/esm/messages/format.mjs +72 -0
  67. package/dist/esm/messages/format.mjs.map +1 -1
  68. package/dist/esm/messages/handoffCue.mjs +61 -0
  69. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  70. package/dist/esm/messages/index.mjs +3 -0
  71. package/dist/esm/messages/injected.mjs +60 -0
  72. package/dist/esm/messages/injected.mjs.map +1 -0
  73. package/dist/esm/run.mjs +80 -7
  74. package/dist/esm/run.mjs.map +1 -1
  75. package/dist/esm/session/handlers.mjs +19 -10
  76. package/dist/esm/session/handlers.mjs.map +1 -1
  77. package/dist/esm/stream.mjs +27 -8
  78. package/dist/esm/stream.mjs.map +1 -1
  79. package/dist/esm/tools/ToolNode.mjs +3 -23
  80. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  81. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  82. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  83. package/dist/esm/tools/search/tool.mjs +9 -1
  84. package/dist/esm/tools/search/tool.mjs.map +1 -1
  85. package/dist/types/common/constants.d.ts +18 -0
  86. package/dist/types/graphs/Graph.d.ts +138 -1
  87. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  88. package/dist/types/hooks/index.d.ts +12 -1
  89. package/dist/types/hooks/types.d.ts +45 -6
  90. package/dist/types/index.d.ts +1 -1
  91. package/dist/types/llm/invoke.d.ts +7 -0
  92. package/dist/types/llm/preempt.d.ts +40 -0
  93. package/dist/types/messages/alternation.d.ts +23 -0
  94. package/dist/types/messages/handoffCue.d.ts +40 -0
  95. package/dist/types/messages/index.d.ts +3 -0
  96. package/dist/types/messages/injected.d.ts +3 -0
  97. package/dist/types/run.d.ts +7 -0
  98. package/dist/types/stream.d.ts +14 -0
  99. package/dist/types/tools/ToolNode.d.ts +0 -7
  100. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  101. package/dist/types/tools/search/types.d.ts +31 -2
  102. package/dist/types/types/graph.d.ts +8 -1
  103. package/dist/types/types/run.d.ts +65 -0
  104. package/dist/types/types/stream.d.ts +0 -25
  105. package/package.json +1 -1
  106. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  107. package/src/common/constants.ts +21 -0
  108. package/src/events.ts +15 -1
  109. package/src/graphs/Graph.ts +568 -3
  110. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
  111. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  112. package/src/hooks/HookRegistry.ts +40 -0
  113. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  114. package/src/hooks/index.ts +16 -2
  115. package/src/hooks/types.ts +47 -3
  116. package/src/index.ts +1 -1
  117. package/src/langfuse.ts +26 -1
  118. package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
  119. package/src/llm/bedrock/utils/message_inputs.ts +14 -11
  120. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  121. package/src/llm/invoke.alternation.test.ts +87 -0
  122. package/src/llm/invoke.handoffCue.test.ts +112 -0
  123. package/src/llm/invoke.ts +417 -8
  124. package/src/llm/preempt.test.ts +323 -0
  125. package/src/llm/preempt.ts +178 -0
  126. package/src/messages/alternation.test.ts +212 -0
  127. package/src/messages/alternation.ts +112 -0
  128. package/src/messages/format.ts +91 -0
  129. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  130. package/src/messages/handoffCue.test.ts +96 -0
  131. package/src/messages/handoffCue.ts +78 -0
  132. package/src/messages/index.ts +3 -0
  133. package/src/messages/injected.test.ts +90 -0
  134. package/src/messages/injected.ts +74 -0
  135. package/src/run.ts +91 -6
  136. package/src/scripts/preempt-probe.ts +330 -0
  137. package/src/scripts/preempt-scenarios.ts +388 -0
  138. package/src/session/handlers.ts +32 -12
  139. package/src/specs/handoffCue.test.ts +165 -0
  140. package/src/specs/langfuse-callbacks.test.ts +352 -2
  141. package/src/specs/preemptSeal.test.ts +309 -0
  142. package/src/stream.dispatch.test.ts +63 -0
  143. package/src/stream.ts +36 -8
  144. package/src/tools/ToolNode.ts +3 -31
  145. package/src/tools/search/keenable-scraper.test.ts +153 -0
  146. package/src/tools/search/keenable-scraper.ts +137 -0
  147. package/src/tools/search/tool.ts +13 -2
  148. package/src/tools/search/types.ts +50 -3
  149. package/src/types/graph.ts +12 -1
  150. package/src/types/run.ts +67 -0
  151. package/src/types/stream.ts +0 -41
  152. package/dist/cjs/splitStream.cjs +0 -151
  153. package/dist/cjs/splitStream.cjs.map +0 -1
  154. package/dist/esm/splitStream.mjs +0 -150
  155. package/dist/esm/splitStream.mjs.map +0 -1
  156. package/dist/types/mockStream.d.ts +0 -32
  157. package/dist/types/splitStream.d.ts +0 -37
  158. package/src/mockStream.ts +0 -99
  159. package/src/splitStream.ts +0 -234
@@ -1,13 +1,29 @@
1
- import { HumanMessage } from '@langchain/core/messages';
2
- import { Command, ParentCommand } from '@langchain/langgraph';
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
3
3
  import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
4
4
  import { CallbackManager } from '@langchain/core/callbacks/manager';
5
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
5
6
  import { context as otelContext, trace as otelTrace } from '@opentelemetry/api';
7
+ import {
8
+ Command,
9
+ END,
10
+ GraphInterrupt,
11
+ MemorySaver,
12
+ MessagesAnnotation,
13
+ NodeInterrupt,
14
+ ParentCommand,
15
+ START,
16
+ StateGraph,
17
+ isGraphInterrupt,
18
+ isInterrupted,
19
+ } from '@langchain/langgraph';
6
20
  import type { ToolCall } from '@langchain/core/messages/tool';
7
21
  import type * as t from '@/types';
8
22
  import { handleConverseStreamMetadata } from '@/llm/bedrock/utils/message_outputs';
9
23
  import { traceIdFromSeed } from '@/langfuseRuntimeContext';
10
24
  import { Constants, Providers } from '@/common';
25
+ import { ToolNode } from '@/tools/ToolNode';
26
+ import { askUserQuestion } from '@/hitl';
11
27
  import { Run } from '@/run';
12
28
 
13
29
  const mockProcessorStarts: Array<{
@@ -15,6 +31,8 @@ const mockProcessorStarts: Array<{
15
31
  traceId: string;
16
32
  }> = [];
17
33
  const mockSpanAttributeSets: Array<Record<string, unknown>> = [];
34
+ let mockSpansStarted = 0;
35
+ let mockSpansEnded = 0;
18
36
  let mockProviderInput:
19
37
  | {
20
38
  spanProcessors?: Array<{
@@ -29,6 +47,7 @@ let mockProviderInput:
29
47
  | undefined;
30
48
 
31
49
  const createMockSpan = (traceIdOverride?: string) => {
50
+ mockSpansStarted += 1;
32
51
  const traceId =
33
52
  traceIdOverride ??
34
53
  mockProviderInput?.idGenerator?.generateTraceId() ??
@@ -36,6 +55,7 @@ const createMockSpan = (traceIdOverride?: string) => {
36
55
  const spanId = mockProviderInput?.idGenerator?.generateSpanId() ?? 'span-id';
37
56
  const span = {
38
57
  end: jest.fn(() => {
58
+ mockSpansEnded += 1;
39
59
  for (const processor of mockProviderInput?.spanProcessors ?? []) {
40
60
  processor.onEnd?.(span);
41
61
  }
@@ -104,6 +124,8 @@ describe('Langfuse callback composition', () => {
104
124
  jest.clearAllMocks();
105
125
  mockProcessorStarts.length = 0;
106
126
  mockSpanAttributeSets.length = 0;
127
+ mockSpansStarted = 0;
128
+ mockSpansEnded = 0;
107
129
  delete process.env.LANGFUSE_PUBLIC_KEY;
108
130
  delete process.env.LANGFUSE_SECRET_KEY;
109
131
  delete process.env.LANGFUSE_BASE_URL;
@@ -430,6 +452,311 @@ describe('Langfuse callback composition', () => {
430
452
  );
431
453
  });
432
454
 
455
+ it('ends nested GraphInterrupt control flow without reporting an error', async () => {
456
+ const { createLangfuseHandler } = await import('@/langfuse');
457
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
458
+ const langfuse = {
459
+ publicKey: 'pk-graph-interrupt',
460
+ secretKey: 'sk-graph-interrupt',
461
+ };
462
+ initializeLangfuseTracing(langfuse);
463
+ const handler = createLangfuseHandler({ langfuse });
464
+ const runId = 'test-langfuse-graph-interrupt';
465
+ const sensitiveQuestion = 'sensitive question payload';
466
+
467
+ await handler?.handleChainStart(
468
+ { lc: 1, type: 'not_implemented', id: ['InterruptedChain'] },
469
+ { input: 'ask' },
470
+ runId,
471
+ 'parent-run'
472
+ );
473
+ await handler?.handleChainError(
474
+ new GraphInterrupt([
475
+ {
476
+ value: {
477
+ type: 'ask_user_question',
478
+ question: sensitiveQuestion,
479
+ },
480
+ },
481
+ ]),
482
+ runId,
483
+ 'parent-run'
484
+ );
485
+
486
+ expect(mockSpanAttributeSets).not.toContainEqual(
487
+ expect.objectContaining({
488
+ [LangfuseOtelSpanAttributes.OBSERVATION_LEVEL]: 'ERROR',
489
+ })
490
+ );
491
+ expect(mockSpanAttributeSets).toContainEqual(
492
+ expect.objectContaining({
493
+ [LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]: JSON.stringify({
494
+ controlFlow: 'GraphInterrupt',
495
+ }),
496
+ })
497
+ );
498
+ expect(JSON.stringify(mockSpanAttributeSets)).not.toContain(
499
+ sensitiveQuestion
500
+ );
501
+ });
502
+
503
+ it('ends nested GraphInterrupt tool control flow without reporting an error', async () => {
504
+ const { createLangfuseHandler } = await import('@/langfuse');
505
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
506
+ const langfuse = {
507
+ publicKey: 'pk-tool-graph-interrupt',
508
+ secretKey: 'sk-tool-graph-interrupt',
509
+ };
510
+ initializeLangfuseTracing(langfuse);
511
+ const handler = createLangfuseHandler({ langfuse });
512
+ const runId = 'test-langfuse-tool-graph-interrupt';
513
+ const sensitiveQuestion = 'sensitive tool question payload';
514
+
515
+ await handler?.handleToolStart(
516
+ { lc: 1, type: 'not_implemented', id: ['AskUserQuestion'] },
517
+ '{}',
518
+ runId,
519
+ 'parent-run'
520
+ );
521
+ await handler?.handleToolError(
522
+ new GraphInterrupt([
523
+ {
524
+ value: {
525
+ type: 'ask_user_question',
526
+ question: sensitiveQuestion,
527
+ },
528
+ },
529
+ ]),
530
+ runId,
531
+ 'parent-run'
532
+ );
533
+
534
+ expect(mockSpanAttributeSets).not.toContainEqual(
535
+ expect.objectContaining({
536
+ [LangfuseOtelSpanAttributes.OBSERVATION_LEVEL]: 'ERROR',
537
+ })
538
+ );
539
+ expect(mockSpanAttributeSets).toContainEqual(
540
+ expect.objectContaining({
541
+ [LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]: JSON.stringify({
542
+ controlFlow: 'GraphInterrupt',
543
+ }),
544
+ })
545
+ );
546
+ expect(JSON.stringify(mockSpanAttributeSets)).not.toContain(
547
+ sensitiveQuestion
548
+ );
549
+ });
550
+
551
+ it('normalizes a real ask_user_question pause and preserves resume behavior', async () => {
552
+ const { createLangfuseHandler } = await import('@/langfuse');
553
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
554
+ const langfuse = {
555
+ publicKey: 'pk-ask-user-question',
556
+ secretKey: 'sk-ask-user-question',
557
+ };
558
+ initializeLangfuseTracing(langfuse);
559
+ const handler = createLangfuseHandler({ langfuse });
560
+ expect(handler).toBeDefined();
561
+
562
+ const sensitiveQuestion = 'Should the workflow continue?';
563
+ const askTool = tool(
564
+ async () => {
565
+ const { answer } = askUserQuestion({ question: sensitiveQuestion });
566
+ return `answered: ${answer}`;
567
+ },
568
+ {
569
+ name: 'ask_user_question',
570
+ description: 'suspends to collect a human answer',
571
+ schema: z.object({}).passthrough(),
572
+ }
573
+ );
574
+ const toolNode = new ToolNode({
575
+ tools: [askTool],
576
+ eventDrivenMode: true,
577
+ directToolNames: new Set(['ask_user_question']),
578
+ interruptingToolNames: new Set(['ask_user_question']),
579
+ });
580
+ const graph = new StateGraph(MessagesAnnotation)
581
+ .addNode('agent', () => ({
582
+ messages: [
583
+ new AIMessage({
584
+ content: '',
585
+ tool_calls: [
586
+ { id: 'ask-call', name: 'ask_user_question', args: {} },
587
+ ],
588
+ }),
589
+ ],
590
+ }))
591
+ .addNode('tools', toolNode)
592
+ .addEdge(START, 'agent')
593
+ .addEdge('agent', 'tools')
594
+ .addEdge('tools', END)
595
+ .compile({ checkpointer: new MemorySaver() });
596
+ const chainErrorSpy = jest.spyOn(handler!, 'handleChainError');
597
+ const toolErrorSpy = jest.spyOn(handler!, 'handleToolError');
598
+ const config = {
599
+ callbacks: [handler!],
600
+ configurable: { thread_id: 'ask-user-question-langfuse' },
601
+ };
602
+
603
+ const first = await graph.invoke({ messages: [] }, config);
604
+
605
+ expect(isInterrupted<t.HumanInterruptPayload>(first)).toBe(true);
606
+ expect(
607
+ (
608
+ first as {
609
+ __interrupt__?: Array<{
610
+ value?: t.HumanInterruptPayload;
611
+ }>;
612
+ }
613
+ ).__interrupt__?.[0]?.value
614
+ ).toMatchObject({
615
+ type: 'ask_user_question',
616
+ question: { question: sensitiveQuestion },
617
+ });
618
+ expect(
619
+ chainErrorSpy.mock.calls.some(
620
+ ([error, , parentRunId]) =>
621
+ isGraphInterrupt(error) && parentRunId != null
622
+ )
623
+ ).toBe(true);
624
+ expect(
625
+ toolErrorSpy.mock.calls.some(
626
+ ([error, , parentRunId]) =>
627
+ isGraphInterrupt(error) && parentRunId != null
628
+ )
629
+ ).toBe(true);
630
+ const controlFlowOutputs = mockSpanAttributeSets.filter(
631
+ (attributes) =>
632
+ attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] ===
633
+ JSON.stringify({ controlFlow: 'GraphInterrupt' })
634
+ );
635
+ expect(controlFlowOutputs.length).toBeGreaterThanOrEqual(2);
636
+ expect(mockSpanAttributeSets).not.toContainEqual(
637
+ expect.objectContaining({
638
+ [LangfuseOtelSpanAttributes.OBSERVATION_LEVEL]: 'ERROR',
639
+ })
640
+ );
641
+ expect(mockSpanAttributeSets).not.toContainEqual(
642
+ expect.objectContaining({
643
+ [LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE]:
644
+ expect.stringContaining('GraphInterrupt'),
645
+ })
646
+ );
647
+ expect(mockSpansEnded).toBe(mockSpansStarted);
648
+
649
+ const second = await graph.invoke(
650
+ new Command({ resume: { answer: 'yes' } }),
651
+ config
652
+ );
653
+
654
+ expect(isInterrupted<t.HumanInterruptPayload>(second)).toBe(false);
655
+ const messages = (second as { messages: ToolMessage[] }).messages;
656
+ expect(
657
+ messages.find(
658
+ (message) =>
659
+ message instanceof ToolMessage && message.name === 'ask_user_question'
660
+ )?.content
661
+ ).toBe('answered: yes');
662
+ expect(mockSpanAttributeSets).not.toContainEqual(
663
+ expect.objectContaining({
664
+ [LangfuseOtelSpanAttributes.OBSERVATION_LEVEL]: 'ERROR',
665
+ })
666
+ );
667
+ expect(mockSpansEnded).toBe(mockSpansStarted);
668
+ });
669
+
670
+ it('defensively reports a synthetic root GraphInterrupt as an error', async () => {
671
+ const { createLangfuseHandler } = await import('@/langfuse');
672
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
673
+ const langfuse = {
674
+ publicKey: 'pk-root-graph-interrupt',
675
+ secretKey: 'sk-root-graph-interrupt',
676
+ };
677
+ initializeLangfuseTracing(langfuse);
678
+ const handler = createLangfuseHandler({ langfuse });
679
+ const runId = 'test-langfuse-root-graph-interrupt';
680
+
681
+ await handler?.handleChainStart(
682
+ { lc: 1, type: 'not_implemented', id: ['RootChain'] },
683
+ { input: 'root interrupt' },
684
+ runId
685
+ );
686
+ await handler?.handleChainError(new GraphInterrupt(), runId);
687
+
688
+ expect(mockSpanAttributeSets).toContainEqual(
689
+ expect.objectContaining({
690
+ [LangfuseOtelSpanAttributes.OBSERVATION_LEVEL]: 'ERROR',
691
+ })
692
+ );
693
+ });
694
+
695
+ it('normalizes nested NodeInterrupt control flow', async () => {
696
+ const { createLangfuseHandler } = await import('@/langfuse');
697
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
698
+ const langfuse = {
699
+ publicKey: 'pk-node-interrupt',
700
+ secretKey: 'sk-node-interrupt',
701
+ };
702
+ initializeLangfuseTracing(langfuse);
703
+ const handler = createLangfuseHandler({ langfuse });
704
+ const runId = 'test-langfuse-node-interrupt';
705
+
706
+ await handler?.handleChainStart(
707
+ { lc: 1, type: 'not_implemented', id: ['InterruptedChain'] },
708
+ { input: 'ask' },
709
+ runId,
710
+ 'parent-run'
711
+ );
712
+ await handler?.handleChainError(
713
+ new NodeInterrupt('pause'),
714
+ runId,
715
+ 'parent-run'
716
+ );
717
+
718
+ expect(mockSpanAttributeSets).not.toContainEqual(
719
+ expect.objectContaining({
720
+ [LangfuseOtelSpanAttributes.OBSERVATION_LEVEL]: 'ERROR',
721
+ })
722
+ );
723
+ expect(mockSpanAttributeSets).toContainEqual(
724
+ expect.objectContaining({
725
+ [LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]: JSON.stringify({
726
+ controlFlow: 'GraphInterrupt',
727
+ }),
728
+ })
729
+ );
730
+ });
731
+
732
+ it('continues reporting genuine nested tool failures as errors', async () => {
733
+ const { createLangfuseHandler } = await import('@/langfuse');
734
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
735
+ const langfuse = {
736
+ publicKey: 'pk-genuine-tool-error',
737
+ secretKey: 'sk-genuine-tool-error',
738
+ };
739
+ initializeLangfuseTracing(langfuse);
740
+ const handler = createLangfuseHandler({ langfuse });
741
+ const runId = 'test-langfuse-genuine-tool-error';
742
+
743
+ await handler?.handleToolStart(
744
+ { lc: 1, type: 'not_implemented', id: ['FailingTool'] },
745
+ '{}',
746
+ runId,
747
+ 'parent-run'
748
+ );
749
+ await handler?.handleToolError(new Error('tool boom'), runId, 'parent-run');
750
+
751
+ expect(mockSpanAttributeSets).toContainEqual(
752
+ expect.objectContaining({
753
+ [LangfuseOtelSpanAttributes.OBSERVATION_LEVEL]: 'ERROR',
754
+ [LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE]:
755
+ 'Error: tool boom',
756
+ })
757
+ );
758
+ });
759
+
433
760
  it('reports a ParentCommand that escapes the root graph as an error', async () => {
434
761
  const { createLangfuseHandler } = await import('@/langfuse');
435
762
  const { initializeLangfuseTracing } = await import('@/instrumentation');
@@ -512,6 +839,29 @@ describe('Langfuse callback composition', () => {
512
839
  ).resolves.toBeUndefined();
513
840
  });
514
841
 
842
+ it('delegates null tool errors without throwing during classification', async () => {
843
+ const { createLangfuseHandler } = await import('@/langfuse');
844
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
845
+ const langfuse = {
846
+ publicKey: 'pk-null-tool-error',
847
+ secretKey: 'sk-null-tool-error',
848
+ };
849
+ initializeLangfuseTracing(langfuse);
850
+ const handler = createLangfuseHandler({ langfuse });
851
+ const runId = 'test-langfuse-null-tool-error';
852
+
853
+ await handler?.handleToolStart(
854
+ { lc: 1, type: 'not_implemented', id: ['NullErrorTool'] },
855
+ '{}',
856
+ runId,
857
+ 'parent-run'
858
+ );
859
+
860
+ await expect(
861
+ handler?.handleToolError(null, runId, 'parent-run')
862
+ ).resolves.toBeUndefined();
863
+ });
864
+
515
865
  it('traces a completed two-agent handoff without false error spans', async () => {
516
866
  const langfuse = {
517
867
  publicKey: 'pk-handoff',