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