@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.
- package/dist/cjs/common/constants.cjs +21 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/events.cjs +10 -1
- package/dist/cjs/events.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +456 -7
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +25 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -1
- package/dist/cjs/hooks/types.cjs +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +8 -0
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +1 -3
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/invoke.cjs +268 -3
- package/dist/cjs/llm/invoke.cjs.map +1 -1
- package/dist/cjs/llm/preempt.cjs +132 -0
- package/dist/cjs/llm/preempt.cjs.map +1 -0
- package/dist/cjs/main.cjs +17 -3
- package/dist/cjs/messages/alternation.cjs +92 -0
- package/dist/cjs/messages/alternation.cjs.map +1 -0
- package/dist/cjs/messages/format.cjs +72 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/handoffCue.cjs +63 -0
- package/dist/cjs/messages/handoffCue.cjs.map +1 -0
- package/dist/cjs/messages/index.cjs +3 -0
- package/dist/cjs/messages/injected.cjs +60 -0
- package/dist/cjs/messages/injected.cjs.map +1 -0
- package/dist/cjs/run.cjs +80 -7
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/session/handlers.cjs +18 -9
- package/dist/cjs/session/handlers.cjs.map +1 -1
- package/dist/cjs/stream.cjs +28 -7
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +3 -23
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
- package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
- package/dist/cjs/tools/search/tool.cjs +9 -1
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/common/constants.mjs +19 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/events.mjs +10 -1
- package/dist/esm/events.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +455 -6
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +25 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -1
- package/dist/esm/hooks/types.mjs +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +9 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +1 -3
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/invoke.mjs +270 -6
- package/dist/esm/llm/invoke.mjs.map +1 -1
- package/dist/esm/llm/preempt.mjs +131 -0
- package/dist/esm/llm/preempt.mjs.map +1 -0
- package/dist/esm/main.mjs +8 -5
- package/dist/esm/messages/alternation.mjs +91 -0
- package/dist/esm/messages/alternation.mjs.map +1 -0
- package/dist/esm/messages/format.mjs +72 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/handoffCue.mjs +61 -0
- package/dist/esm/messages/handoffCue.mjs.map +1 -0
- package/dist/esm/messages/index.mjs +3 -0
- package/dist/esm/messages/injected.mjs +60 -0
- package/dist/esm/messages/injected.mjs.map +1 -0
- package/dist/esm/run.mjs +80 -7
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/session/handlers.mjs +19 -10
- package/dist/esm/session/handlers.mjs.map +1 -1
- package/dist/esm/stream.mjs +27 -8
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +3 -23
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
- package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
- package/dist/esm/tools/search/tool.mjs +9 -1
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/common/constants.d.ts +18 -0
- package/dist/types/graphs/Graph.d.ts +138 -1
- package/dist/types/hooks/HookRegistry.d.ts +15 -0
- package/dist/types/hooks/index.d.ts +12 -1
- package/dist/types/hooks/types.d.ts +45 -6
- package/dist/types/index.d.ts +1 -1
- package/dist/types/llm/invoke.d.ts +7 -0
- package/dist/types/llm/preempt.d.ts +40 -0
- package/dist/types/messages/alternation.d.ts +23 -0
- package/dist/types/messages/handoffCue.d.ts +40 -0
- package/dist/types/messages/index.d.ts +3 -0
- package/dist/types/messages/injected.d.ts +3 -0
- package/dist/types/run.d.ts +7 -0
- package/dist/types/stream.d.ts +14 -0
- package/dist/types/tools/ToolNode.d.ts +0 -7
- package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
- package/dist/types/tools/search/types.d.ts +31 -2
- package/dist/types/types/graph.d.ts +8 -1
- package/dist/types/types/run.d.ts +65 -0
- package/dist/types/types/stream.d.ts +0 -25
- package/package.json +1 -1
- package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
- package/src/common/constants.ts +21 -0
- package/src/events.ts +15 -1
- package/src/graphs/Graph.ts +568 -3
- package/src/graphs/__tests__/Graph.contextOverflow.test.ts +18 -7
- package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
- package/src/hooks/HookRegistry.ts +40 -0
- package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
- package/src/hooks/index.ts +16 -2
- package/src/hooks/types.ts +47 -3
- package/src/index.ts +1 -1
- package/src/langfuse.ts +26 -1
- package/src/llm/bedrock/utils/message_inputs.test.ts +82 -0
- package/src/llm/bedrock/utils/message_inputs.ts +14 -11
- package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
- package/src/llm/invoke.alternation.test.ts +87 -0
- package/src/llm/invoke.handoffCue.test.ts +112 -0
- package/src/llm/invoke.ts +417 -8
- package/src/llm/preempt.test.ts +323 -0
- package/src/llm/preempt.ts +178 -0
- package/src/messages/alternation.test.ts +212 -0
- package/src/messages/alternation.ts +112 -0
- package/src/messages/format.ts +91 -0
- package/src/messages/formatAgentMessages.steer.test.ts +267 -0
- package/src/messages/handoffCue.test.ts +96 -0
- package/src/messages/handoffCue.ts +78 -0
- package/src/messages/index.ts +3 -0
- package/src/messages/injected.test.ts +90 -0
- package/src/messages/injected.ts +74 -0
- package/src/run.ts +91 -6
- package/src/scripts/preempt-probe.ts +330 -0
- package/src/scripts/preempt-scenarios.ts +388 -0
- package/src/session/handlers.ts +32 -12
- package/src/specs/handoffCue.test.ts +165 -0
- package/src/specs/langfuse-callbacks.test.ts +352 -2
- package/src/specs/preemptSeal.test.ts +309 -0
- package/src/stream.dispatch.test.ts +63 -0
- package/src/stream.ts +36 -8
- package/src/tools/ToolNode.ts +3 -31
- package/src/tools/search/keenable-scraper.test.ts +153 -0
- package/src/tools/search/keenable-scraper.ts +137 -0
- package/src/tools/search/tool.ts +13 -2
- package/src/tools/search/types.ts +50 -3
- package/src/types/graph.ts +12 -1
- package/src/types/run.ts +67 -0
- package/src/types/stream.ts +0 -41
- package/dist/cjs/splitStream.cjs +0 -151
- package/dist/cjs/splitStream.cjs.map +0 -1
- package/dist/esm/splitStream.mjs +0 -150
- package/dist/esm/splitStream.mjs.map +0 -1
- package/dist/types/mockStream.d.ts +0 -32
- package/dist/types/splitStream.d.ts +0 -37
- package/src/mockStream.ts +0 -99
- package/src/splitStream.ts +0 -234
|
@@ -1,13 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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',
|