@librechat/agents 3.2.65 → 3.2.66
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/graphs/Graph.cjs +15 -2
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/instrumentation.cjs +15 -3
- package/dist/cjs/instrumentation.cjs.map +1 -1
- package/dist/cjs/langfuseToolOutputTracing.cjs +1 -2
- package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
- package/dist/cjs/langfuseTraceShaping.cjs +51 -24
- package/dist/cjs/langfuseTraceShaping.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +8 -0
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +8 -0
- package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/tools/BashExecutor.cjs +9 -8
- package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
- package/dist/cjs/tools/CodeExecutor.cjs +9 -7
- package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +15 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/instrumentation.mjs +15 -3
- package/dist/esm/instrumentation.mjs.map +1 -1
- package/dist/esm/langfuseToolOutputTracing.mjs +1 -2
- package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
- package/dist/esm/langfuseTraceShaping.mjs +51 -24
- package/dist/esm/langfuseTraceShaping.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +8 -0
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs +8 -0
- package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/tools/BashExecutor.mjs +9 -8
- package/dist/esm/tools/BashExecutor.mjs.map +1 -1
- package/dist/esm/tools/CodeExecutor.mjs +9 -7
- package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +2 -0
- package/dist/types/langfuseTraceShaping.d.ts +12 -6
- package/dist/types/tools/BashExecutor.d.ts +7 -6
- package/dist/types/tools/CodeExecutor.d.ts +7 -5
- package/dist/types/types/graph.d.ts +10 -3
- package/package.json +1 -1
- package/src/graphs/Graph.ts +21 -3
- package/src/instrumentation.ts +20 -0
- package/src/langfuseToolOutputTracing.ts +2 -4
- package/src/langfuseTraceShaping.ts +73 -20
- package/src/llm/anthropic/utils/cross-provider-server-tools.test.ts +110 -0
- package/src/llm/anthropic/utils/message_inputs.ts +15 -0
- package/src/llm/bedrock/utils/cross-provider-server-tools.test.ts +122 -0
- package/src/llm/bedrock/utils/message_inputs.ts +13 -0
- package/src/specs/langfuse-instrumentation.test.ts +64 -0
- package/src/specs/langfuse-routing.integration.test.ts +49 -4
- package/src/specs/langfuse-tool-output-tracing.test.ts +5 -4
- package/src/specs/langfuse-trace-shaping.test.ts +80 -9
- package/src/specs/subagent.test.ts +180 -0
- package/src/tools/BashExecutor.ts +9 -8
- package/src/tools/CodeExecutor.ts +9 -7
- package/src/tools/__tests__/BashExecutor.test.ts +16 -5
- package/src/tools/__tests__/CodeExecutor.stateful.test.ts +17 -6
- package/src/types/graph.ts +10 -3
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
|
+
import { _convertMessagesToAnthropicPayload } from './message_inputs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression for cross-provider agent handoffs (Google → Anthropic): a Gemini
|
|
7
|
+
* turn that used a server-side tool (URL context, Google Search) leaves
|
|
8
|
+
* `toolCall`/`toolResponse` content blocks in history. The Anthropic converter
|
|
9
|
+
* has no branch for them and previously threw
|
|
10
|
+
* "Unsupported message content format", crashing the handoff. Only Google can
|
|
11
|
+
* execute these blocks or validate their thought signatures, so they are
|
|
12
|
+
* dropped on assistant turns; any other unknown block still throws.
|
|
13
|
+
*/
|
|
14
|
+
type AnthropicPayload = ReturnType<typeof _convertMessagesToAnthropicPayload>;
|
|
15
|
+
|
|
16
|
+
/** Minimal view of a converted Anthropic content block the assertions read. */
|
|
17
|
+
interface TestBlock {
|
|
18
|
+
type?: string;
|
|
19
|
+
text?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const assistantBlocks = (payload: AnthropicPayload): TestBlock[] => {
|
|
23
|
+
const content = payload.messages.find((m) => m.role === 'assistant')?.content;
|
|
24
|
+
return Array.isArray(content) ? (content as TestBlock[]) : [];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('_convertMessagesToAnthropicPayload — Google server-side tool blocks', () => {
|
|
28
|
+
it('drops toolCall/toolResponse on an assistant turn, keeping text', () => {
|
|
29
|
+
const messages: BaseMessage[] = [
|
|
30
|
+
new HumanMessage('summarize this article'),
|
|
31
|
+
new AIMessage({
|
|
32
|
+
content: [
|
|
33
|
+
{
|
|
34
|
+
type: 'toolCall',
|
|
35
|
+
thoughtSignature: 'google-signature-not-valid-for-anthropic',
|
|
36
|
+
toolCall: {
|
|
37
|
+
toolType: 'URL_CONTEXT',
|
|
38
|
+
args: { urls: ['https://example.com/report'] },
|
|
39
|
+
id: 'j7pfyr6k',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'toolResponse',
|
|
44
|
+
toolResponse: {
|
|
45
|
+
toolType: 'URL_CONTEXT',
|
|
46
|
+
id: 'j7pfyr6k',
|
|
47
|
+
result: { status: 'SUCCESS' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: 'text',
|
|
52
|
+
text: 'The article argues for dollar-cost averaging.',
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
expect(() => _convertMessagesToAnthropicPayload(messages)).not.toThrow();
|
|
59
|
+
const blocks = assistantBlocks(
|
|
60
|
+
_convertMessagesToAnthropicPayload(messages)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const serialized = JSON.stringify(blocks);
|
|
64
|
+
expect(serialized).not.toContain('URL_CONTEXT');
|
|
65
|
+
expect(serialized).not.toContain(
|
|
66
|
+
'google-signature-not-valid-for-anthropic'
|
|
67
|
+
);
|
|
68
|
+
expect(
|
|
69
|
+
blocks.some(
|
|
70
|
+
(b) =>
|
|
71
|
+
b.type === 'text' &&
|
|
72
|
+
b.text === 'The article argues for dollar-cost averaging.'
|
|
73
|
+
)
|
|
74
|
+
).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('emits a placeholder (not empty content) when a server-tool-only turn is fully dropped', () => {
|
|
78
|
+
const messages: BaseMessage[] = [
|
|
79
|
+
new HumanMessage('hi'),
|
|
80
|
+
new AIMessage({
|
|
81
|
+
content: [
|
|
82
|
+
{
|
|
83
|
+
type: 'toolCall',
|
|
84
|
+
toolCall: { toolType: 'URL_CONTEXT', args: { urls: [] }, id: 'x1' },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
}),
|
|
88
|
+
];
|
|
89
|
+
expect(() => _convertMessagesToAnthropicPayload(messages)).not.toThrow();
|
|
90
|
+
const blocks = assistantBlocks(
|
|
91
|
+
_convertMessagesToAnthropicPayload(messages)
|
|
92
|
+
);
|
|
93
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('still throws on a genuinely unknown assistant block', () => {
|
|
97
|
+
const messages: BaseMessage[] = [
|
|
98
|
+
new HumanMessage('run code'),
|
|
99
|
+
new AIMessage({
|
|
100
|
+
content: [
|
|
101
|
+
{ type: 'some_future_block_type', foo: 'bar' },
|
|
102
|
+
{ type: 'text', text: 'done' },
|
|
103
|
+
],
|
|
104
|
+
}),
|
|
105
|
+
];
|
|
106
|
+
expect(() => _convertMessagesToAnthropicPayload(messages)).toThrow(
|
|
107
|
+
'Unsupported message content format'
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -473,6 +473,13 @@ function _formatContent(message: BaseMessage) {
|
|
|
473
473
|
* forwarding an unusable block. The receiving model produces its own thinking.
|
|
474
474
|
*/
|
|
475
475
|
const foreignReasoningTypes = ['reasoning_content', 'reasoning', 'think'];
|
|
476
|
+
/**
|
|
477
|
+
* Google server-side tool blocks (`toolCall`/`toolResponse` parts from e.g.
|
|
478
|
+
* URL context or Google Search). Only Google can execute these and validate
|
|
479
|
+
* their thought signatures, so they are dropped on a cross-provider handoff
|
|
480
|
+
* (e.g. Google → Anthropic); the assistant's answer text is kept.
|
|
481
|
+
*/
|
|
482
|
+
const foreignServerToolTypes = ['toolCall', 'toolResponse'];
|
|
476
483
|
const { content } = message;
|
|
477
484
|
|
|
478
485
|
if (typeof content === 'string') {
|
|
@@ -855,6 +862,14 @@ function _formatContent(message: BaseMessage) {
|
|
|
855
862
|
// dropped — as does any other unknown block (user media, Google
|
|
856
863
|
// code-execution), which must be surfaced, not discarded.
|
|
857
864
|
return null;
|
|
865
|
+
} else if (
|
|
866
|
+
isAIMessage(message) &&
|
|
867
|
+
foreignServerToolTypes.some((t) => t === contentPart.type)
|
|
868
|
+
) {
|
|
869
|
+
// Google server-side tool call/response (e.g. URL context) — only
|
|
870
|
+
// Google can execute it or validate its thought signature; drop it
|
|
871
|
+
// on a cross-provider handoff rather than crash.
|
|
872
|
+
return null;
|
|
858
873
|
} else {
|
|
859
874
|
console.error(
|
|
860
875
|
'Unsupported content part:',
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
|
2
|
+
import type { BaseMessage } from '@langchain/core/messages';
|
|
3
|
+
import { convertToConverseMessages } from './message_inputs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression for cross-provider agent handoffs (Google → Bedrock): a Gemini
|
|
7
|
+
* turn that used a server-side tool (URL context, Google Search) leaves
|
|
8
|
+
* `toolCall`/`toolResponse` content blocks in history. The Bedrock Converse
|
|
9
|
+
* converter has no branch for them and previously threw
|
|
10
|
+
* "Unsupported content block type: toolCall", crashing the handoff. Only
|
|
11
|
+
* Google can execute these blocks or validate their thought signatures, so
|
|
12
|
+
* they are dropped on assistant turns; any other unknown block still throws.
|
|
13
|
+
*/
|
|
14
|
+
type ConverseResult = ReturnType<typeof convertToConverseMessages>;
|
|
15
|
+
|
|
16
|
+
/** Minimal view of a converted Bedrock Converse content block the assertions read. */
|
|
17
|
+
interface ConverseBlock {
|
|
18
|
+
text?: string;
|
|
19
|
+
toolUse?: {
|
|
20
|
+
toolUseId?: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
input?: Record<string, string>;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const assistantContent = (result: ConverseResult): ConverseBlock[] => {
|
|
27
|
+
const msg = result.converseMessages.find((m) => m.role === 'assistant');
|
|
28
|
+
return (msg?.content ?? []) as ConverseBlock[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('convertToConverseMessages — Google server-side tool blocks (Google → Bedrock)', () => {
|
|
32
|
+
it('drops toolCall/toolResponse on an assistant turn, keeping text and tool calls', () => {
|
|
33
|
+
const messages: BaseMessage[] = [
|
|
34
|
+
new HumanMessage('summarize this article'),
|
|
35
|
+
new AIMessage({
|
|
36
|
+
content: [
|
|
37
|
+
{
|
|
38
|
+
type: 'toolCall',
|
|
39
|
+
thoughtSignature: 'google-signature-not-valid-for-bedrock',
|
|
40
|
+
toolCall: {
|
|
41
|
+
toolType: 'URL_CONTEXT',
|
|
42
|
+
args: { urls: ['https://example.com/report'] },
|
|
43
|
+
id: 'j7pfyr6k',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: 'toolResponse',
|
|
48
|
+
toolResponse: {
|
|
49
|
+
toolType: 'URL_CONTEXT',
|
|
50
|
+
id: 'j7pfyr6k',
|
|
51
|
+
result: { status: 'SUCCESS' },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: 'text',
|
|
56
|
+
text: 'The article argues for dollar-cost averaging.',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
tool_calls: [
|
|
60
|
+
{
|
|
61
|
+
id: 'call_client_tool',
|
|
62
|
+
name: 'save_note',
|
|
63
|
+
args: { note: 'DCA summary' },
|
|
64
|
+
type: 'tool_call',
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
expect(() => convertToConverseMessages(messages)).not.toThrow();
|
|
71
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
72
|
+
|
|
73
|
+
const serialized = JSON.stringify(content);
|
|
74
|
+
expect(serialized).not.toContain('URL_CONTEXT');
|
|
75
|
+
expect(serialized).not.toContain('google-signature-not-valid-for-bedrock');
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
content.some(
|
|
79
|
+
(b) => b.text === 'The article argues for dollar-cost averaging.'
|
|
80
|
+
)
|
|
81
|
+
).toBe(true);
|
|
82
|
+
const toolUse = content.find((b) => b.toolUse != null);
|
|
83
|
+
expect(toolUse?.toolUse).toMatchObject({
|
|
84
|
+
toolUseId: 'call_client_tool',
|
|
85
|
+
name: 'save_note',
|
|
86
|
+
input: { note: 'DCA summary' },
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('emits a placeholder (not empty content) when a server-tool-only turn is fully dropped', () => {
|
|
91
|
+
const messages: BaseMessage[] = [
|
|
92
|
+
new HumanMessage('hi'),
|
|
93
|
+
new AIMessage({
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: 'toolCall',
|
|
97
|
+
toolCall: { toolType: 'URL_CONTEXT', args: { urls: [] }, id: 'x1' },
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
}),
|
|
101
|
+
];
|
|
102
|
+
expect(() => convertToConverseMessages(messages)).not.toThrow();
|
|
103
|
+
const content = assistantContent(convertToConverseMessages(messages));
|
|
104
|
+
expect(content.length).toBeGreaterThan(0);
|
|
105
|
+
expect(content.every((b) => typeof b.text === 'string')).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('still throws on a genuinely unknown assistant block', () => {
|
|
109
|
+
const messages: BaseMessage[] = [
|
|
110
|
+
new HumanMessage('run code'),
|
|
111
|
+
new AIMessage({
|
|
112
|
+
content: [
|
|
113
|
+
{ type: 'some_future_block_type', foo: 'bar' },
|
|
114
|
+
{ type: 'text', text: 'done' },
|
|
115
|
+
],
|
|
116
|
+
}),
|
|
117
|
+
];
|
|
118
|
+
expect(() => convertToConverseMessages(messages)).toThrow(
|
|
119
|
+
'Unsupported content block type'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -41,6 +41,14 @@ const FOREIGN_REASONING_TYPES = [
|
|
|
41
41
|
'think',
|
|
42
42
|
];
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Google server-side tool blocks (`toolCall`/`toolResponse` parts from e.g.
|
|
46
|
+
* URL context or Google Search). Only Google can execute these and validate
|
|
47
|
+
* their thought signatures, so they are dropped on a cross-provider handoff
|
|
48
|
+
* (e.g. Google → Bedrock); the assistant's answer text is kept.
|
|
49
|
+
*/
|
|
50
|
+
const FOREIGN_SERVER_TOOL_TYPES = ['toolCall', 'toolResponse'];
|
|
51
|
+
|
|
44
52
|
/**
|
|
45
53
|
* Bedrock Converse rejects assistant messages with no content blocks. When
|
|
46
54
|
* filtering (e.g. dropping foreign reasoning) empties an assistant turn that
|
|
@@ -727,6 +735,11 @@ function convertAIMessageToConverseMessage(msg: BaseMessage): BedrockMessage {
|
|
|
727
735
|
// than crash. The Bedrock model produces its own reasoning. Anything
|
|
728
736
|
// else unknown still throws below — real content must be surfaced.
|
|
729
737
|
return;
|
|
738
|
+
} else if (FOREIGN_SERVER_TOOL_TYPES.some((t) => t === block.type)) {
|
|
739
|
+
// Google server-side tool call/response (e.g. URL context) — only
|
|
740
|
+
// Google can execute it or validate its thought signature; drop it
|
|
741
|
+
// on a cross-provider handoff rather than crash.
|
|
742
|
+
return;
|
|
730
743
|
} else {
|
|
731
744
|
const blockValues = Object.fromEntries(
|
|
732
745
|
Object.entries(block).filter(([key]) => key !== 'type')
|
|
@@ -184,6 +184,70 @@ describe('Langfuse instrumentation', () => {
|
|
|
184
184
|
expect(mockBasicTracerProvider).toHaveBeenCalledTimes(1);
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
it('resolves environment from LANGFUSE_TRACING_ENVIRONMENT', async () => {
|
|
188
|
+
process.env.LANGFUSE_TRACING_ENVIRONMENT = 'staging';
|
|
189
|
+
|
|
190
|
+
const { initializeLangfuseTracing } = await import('@/instrumentation');
|
|
191
|
+
initializeLangfuseTracing({
|
|
192
|
+
publicKey: 'pk-config',
|
|
193
|
+
secretKey: 'sk-config',
|
|
194
|
+
baseUrl: 'https://langfuse.config',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({ environment: 'staging' })
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('falls back to NODE_ENV when LANGFUSE_TRACING_ENVIRONMENT is unset', async () => {
|
|
203
|
+
delete process.env.LANGFUSE_TRACING_ENVIRONMENT;
|
|
204
|
+
process.env.NODE_ENV = 'production';
|
|
205
|
+
|
|
206
|
+
const { initializeLangfuseTracing } = await import('@/instrumentation');
|
|
207
|
+
initializeLangfuseTracing({
|
|
208
|
+
publicKey: 'pk-config',
|
|
209
|
+
secretKey: 'sk-config',
|
|
210
|
+
baseUrl: 'https://langfuse.config',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
|
|
214
|
+
expect.objectContaining({ environment: 'production' })
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('falls through blank environment overrides and trims the selected value', async () => {
|
|
219
|
+
process.env.LANGFUSE_TRACING_ENVIRONMENT = ' ';
|
|
220
|
+
process.env.NODE_ENV = ' production ';
|
|
221
|
+
|
|
222
|
+
const { initializeLangfuseTracing } = await import('@/instrumentation');
|
|
223
|
+
initializeLangfuseTracing({
|
|
224
|
+
publicKey: 'pk-config',
|
|
225
|
+
secretKey: 'sk-config',
|
|
226
|
+
baseUrl: 'https://langfuse.config',
|
|
227
|
+
environment: '',
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
|
|
231
|
+
expect.objectContaining({ environment: 'production' })
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('prefers an explicit config environment over environment variables', async () => {
|
|
236
|
+
process.env.LANGFUSE_TRACING_ENVIRONMENT = 'staging';
|
|
237
|
+
|
|
238
|
+
const { initializeLangfuseTracing } = await import('@/instrumentation');
|
|
239
|
+
initializeLangfuseTracing({
|
|
240
|
+
publicKey: 'pk-config',
|
|
241
|
+
secretKey: 'sk-config',
|
|
242
|
+
baseUrl: 'https://langfuse.config',
|
|
243
|
+
environment: 'canary',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(mockLangfuseSpanProcessor).toHaveBeenCalledWith(
|
|
247
|
+
expect.objectContaining({ environment: 'canary' })
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
187
251
|
it('does not replace the global provider when explicit credentials change', async () => {
|
|
188
252
|
const { initializeLangfuseTracing } = await import('@/instrumentation');
|
|
189
253
|
initializeLangfuseTracing({
|
|
@@ -25,6 +25,8 @@ type SpanStartRecord = {
|
|
|
25
25
|
name: string;
|
|
26
26
|
params: ProcessorParams;
|
|
27
27
|
traceId: string;
|
|
28
|
+
spanId: string;
|
|
29
|
+
parentSpanId?: string;
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
const spanStarts: SpanStartRecord[] = [];
|
|
@@ -140,11 +142,15 @@ jest.mock('@langfuse/otel', () => ({
|
|
|
140
142
|
LangfuseSpanProcessor: jest.fn().mockImplementation((params) => ({
|
|
141
143
|
forceFlush: jest.fn(),
|
|
142
144
|
onEnd: jest.fn(),
|
|
143
|
-
onStart: jest.fn((span) => {
|
|
145
|
+
onStart: jest.fn((span, parentContext) => {
|
|
146
|
+
const spanContext = span.spanContext();
|
|
147
|
+
const parentSpanId = otelTrace.getSpanContext(parentContext)?.spanId;
|
|
144
148
|
spanStarts.push({
|
|
145
149
|
name: span.name,
|
|
146
150
|
params,
|
|
147
|
-
traceId:
|
|
151
|
+
traceId: spanContext.traceId,
|
|
152
|
+
spanId: spanContext.spanId,
|
|
153
|
+
...(parentSpanId != null ? { parentSpanId } : {}),
|
|
148
154
|
});
|
|
149
155
|
}),
|
|
150
156
|
shutdown: jest.fn(),
|
|
@@ -190,7 +196,6 @@ function tenantLangfuse(tenantId: string): t.LangfuseConfig {
|
|
|
190
196
|
deterministicTraceId: true,
|
|
191
197
|
metadata: { tenantId },
|
|
192
198
|
tags: [`tenant:${tenantId}`],
|
|
193
|
-
toolNodeTracing: { enabled: true },
|
|
194
199
|
toolOutputTracing: { enabled: true },
|
|
195
200
|
};
|
|
196
201
|
}
|
|
@@ -239,6 +244,23 @@ function expectNamedSpansUseTraceId({
|
|
|
239
244
|
}
|
|
240
245
|
}
|
|
241
246
|
|
|
247
|
+
function expectChildSpanParentName({
|
|
248
|
+
starts,
|
|
249
|
+
childName,
|
|
250
|
+
parentNamePrefix,
|
|
251
|
+
}: {
|
|
252
|
+
starts: SpanStartRecord[];
|
|
253
|
+
childName: string;
|
|
254
|
+
parentNamePrefix: string;
|
|
255
|
+
}): void {
|
|
256
|
+
const children = starts.filter((record) => record.name === childName);
|
|
257
|
+
expect(children).not.toHaveLength(0);
|
|
258
|
+
for (const child of children) {
|
|
259
|
+
const parent = starts.find((record) => record.spanId === child.parentSpanId);
|
|
260
|
+
expect(parent?.name.startsWith(parentNamePrefix)).toBe(true);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
242
264
|
function expectOnlyTraceIds(
|
|
243
265
|
starts: SpanStartRecord[],
|
|
244
266
|
allowedTraceIds: string[]
|
|
@@ -446,6 +468,23 @@ describe('Langfuse per-run routing integration', () => {
|
|
|
446
468
|
getChatModelClassSpy.mockRestore();
|
|
447
469
|
});
|
|
448
470
|
|
|
471
|
+
it('keeps tool observations attached to the exported dispatch parent', async () => {
|
|
472
|
+
await runTenantFlow('tenant-hierarchy');
|
|
473
|
+
|
|
474
|
+
const starts = startsForTenant('tenant-hierarchy');
|
|
475
|
+
expect(starts.some((record) => record.name === 'tool_batch')).toBe(false);
|
|
476
|
+
expectChildSpanParentName({
|
|
477
|
+
starts,
|
|
478
|
+
childName: 'echo',
|
|
479
|
+
parentNamePrefix: 'tools=',
|
|
480
|
+
});
|
|
481
|
+
expectChildSpanParentName({
|
|
482
|
+
starts,
|
|
483
|
+
childName: 'subagent',
|
|
484
|
+
parentNamePrefix: 'tools=',
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
449
488
|
it('routes parallel root, model, tool, subagent, and title spans to each run config', async () => {
|
|
450
489
|
await Promise.all([runTenantFlow('tenant-a'), runTenantFlow('tenant-b')]);
|
|
451
490
|
|
|
@@ -462,10 +501,16 @@ describe('Langfuse per-run routing integration', () => {
|
|
|
462
501
|
names: [
|
|
463
502
|
`LibreChat Agent: Parent ${tenantId}`,
|
|
464
503
|
'FakeChatModel',
|
|
465
|
-
'
|
|
504
|
+
'echo',
|
|
466
505
|
'subagent',
|
|
467
506
|
],
|
|
468
507
|
});
|
|
508
|
+
expect(starts.some((record) => record.name === 'tool_batch')).toBe(false);
|
|
509
|
+
expectChildSpanParentName({
|
|
510
|
+
starts,
|
|
511
|
+
childName: 'echo',
|
|
512
|
+
parentNamePrefix: 'tools=',
|
|
513
|
+
});
|
|
469
514
|
expectNamedSpansUseTraceId({
|
|
470
515
|
starts,
|
|
471
516
|
traceId: titleTraceId,
|
|
@@ -118,7 +118,7 @@ describe('Langfuse tool output tracing redaction', () => {
|
|
|
118
118
|
process.env = originalEnv;
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
it('
|
|
121
|
+
it('keeps internal ToolNode batch tracing opt-in', () => {
|
|
122
122
|
delete process.env.LANGFUSE_SECRET_KEY;
|
|
123
123
|
delete process.env.LANGFUSE_PUBLIC_KEY;
|
|
124
124
|
delete process.env.LANGFUSE_BASE_URL;
|
|
@@ -132,7 +132,7 @@ describe('Langfuse tool output tracing redaction', () => {
|
|
|
132
132
|
secretKey: 'sk-run',
|
|
133
133
|
},
|
|
134
134
|
})
|
|
135
|
-
).toBe(
|
|
135
|
+
).toBe(false);
|
|
136
136
|
expect(
|
|
137
137
|
shouldTraceToolNodeForLangfuse({
|
|
138
138
|
agentLangfuse: {
|
|
@@ -149,7 +149,7 @@ describe('Langfuse tool output tracing redaction', () => {
|
|
|
149
149
|
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test';
|
|
150
150
|
process.env.LANGFUSE_BASE_URL = 'https://langfuse.test';
|
|
151
151
|
|
|
152
|
-
expect(shouldTraceToolNodeForLangfuse({})).toBe(
|
|
152
|
+
expect(shouldTraceToolNodeForLangfuse({})).toBe(false);
|
|
153
153
|
expect(
|
|
154
154
|
shouldTraceToolNodeForLangfuse({
|
|
155
155
|
runLangfuse: { toolNodeTracing: { enabled: true } },
|
|
@@ -162,7 +162,7 @@ describe('Langfuse tool output tracing redaction', () => {
|
|
|
162
162
|
).toBe(false);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
it('lets agent
|
|
165
|
+
it('lets an agent explicitly opt into ToolNode batch tracing', () => {
|
|
166
166
|
delete process.env.LANGFUSE_SECRET_KEY;
|
|
167
167
|
delete process.env.LANGFUSE_PUBLIC_KEY;
|
|
168
168
|
delete process.env.LANGFUSE_BASE_URL;
|
|
@@ -177,6 +177,7 @@ describe('Langfuse tool output tracing redaction', () => {
|
|
|
177
177
|
publicKey: 'pk-agent',
|
|
178
178
|
secretKey: 'sk-agent',
|
|
179
179
|
baseUrl: 'https://langfuse.test',
|
|
180
|
+
toolNodeTracing: { enabled: true },
|
|
180
181
|
},
|
|
181
182
|
})
|
|
182
183
|
).toBe(true);
|
|
@@ -26,6 +26,8 @@ const INPUT = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
|
|
|
26
26
|
const OUTPUT = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
|
|
27
27
|
const TRACE_INPUT = LangfuseOtelSpanAttributes.TRACE_INPUT;
|
|
28
28
|
const TRACE_OUTPUT = LangfuseOtelSpanAttributes.TRACE_OUTPUT;
|
|
29
|
+
const OBSERVATION_TYPE = LangfuseOtelSpanAttributes.OBSERVATION_TYPE;
|
|
30
|
+
const TRACE_TAGS = LangfuseOtelSpanAttributes.TRACE_TAGS;
|
|
29
31
|
|
|
30
32
|
describe('shouldDropLangfuseSpan', () => {
|
|
31
33
|
it('drops langgraph __start__ seed spans', () => {
|
|
@@ -40,6 +42,7 @@ describe('shouldDropLangfuseSpan', () => {
|
|
|
40
42
|
expect(shouldDropLangfuseSpan('GenerateTitle')).toBe(false);
|
|
41
43
|
expect(shouldDropLangfuseSpan('agent=openAI__gpt-5.4')).toBe(false);
|
|
42
44
|
expect(shouldDropLangfuseSpan('ChatOpenAI')).toBe(false);
|
|
45
|
+
expect(shouldDropLangfuseSpan('tool_batch')).toBe(false);
|
|
43
46
|
});
|
|
44
47
|
});
|
|
45
48
|
|
|
@@ -48,9 +51,10 @@ describe('shapeLangfuseSpan', () => {
|
|
|
48
51
|
const span = createSpan('agent=openAI__gpt-5.4', {}, 'parent-1');
|
|
49
52
|
shapeLangfuseSpan(span);
|
|
50
53
|
expect(span.name).toBe('agent');
|
|
54
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBe('agent');
|
|
51
55
|
});
|
|
52
56
|
|
|
53
|
-
it('
|
|
57
|
+
it('shapes tool nodes as stable dispatch chains with scoped call inputs', () => {
|
|
54
58
|
const messages = [
|
|
55
59
|
{ type: 'human', content: 'hello' },
|
|
56
60
|
{
|
|
@@ -71,13 +75,14 @@ describe('shapeLangfuseSpan', () => {
|
|
|
71
75
|
'parent-1'
|
|
72
76
|
);
|
|
73
77
|
shapeLangfuseSpan(span);
|
|
74
|
-
expect(span.name).toBe('
|
|
78
|
+
expect(span.name).toBe('tool-dispatch');
|
|
79
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
|
|
75
80
|
expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
|
|
76
81
|
{ name: 'get_service_details', args: { path: 'organizations/1' } },
|
|
77
82
|
]);
|
|
78
83
|
});
|
|
79
84
|
|
|
80
|
-
it('
|
|
85
|
+
it('preserves every pending call in a multi-tool dispatch input', () => {
|
|
81
86
|
const messages = [
|
|
82
87
|
{
|
|
83
88
|
type: 'ai',
|
|
@@ -89,12 +94,16 @@ describe('shapeLangfuseSpan', () => {
|
|
|
89
94
|
},
|
|
90
95
|
];
|
|
91
96
|
const span = createSpan(
|
|
92
|
-
'
|
|
97
|
+
'tools=openAI__gpt-5.4',
|
|
93
98
|
{ [INPUT]: JSON.stringify({ messages }) },
|
|
94
99
|
'parent-1'
|
|
95
100
|
);
|
|
96
101
|
shapeLangfuseSpan(span);
|
|
97
|
-
expect(span.
|
|
102
|
+
expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
|
|
103
|
+
{ name: 'web_search', args: { q: 'a' } },
|
|
104
|
+
{ name: 'web_search', args: { q: 'b' } },
|
|
105
|
+
{ name: 'execute_code', args: { code: '1+1' } },
|
|
106
|
+
]);
|
|
98
107
|
});
|
|
99
108
|
|
|
100
109
|
it('reads tool calls from serialized langchain message kwargs', () => {
|
|
@@ -115,10 +124,10 @@ describe('shapeLangfuseSpan', () => {
|
|
|
115
124
|
'parent-1'
|
|
116
125
|
);
|
|
117
126
|
shapeLangfuseSpan(span);
|
|
118
|
-
expect(span.name).toBe('
|
|
127
|
+
expect(span.name).toBe('tool-dispatch');
|
|
119
128
|
});
|
|
120
129
|
|
|
121
|
-
it('
|
|
130
|
+
it('keeps a stable tool-dispatch shape when no tool calls are found', () => {
|
|
122
131
|
const original = JSON.stringify({
|
|
123
132
|
messages: [{ type: 'human', content: 'hi' }],
|
|
124
133
|
});
|
|
@@ -128,12 +137,14 @@ describe('shapeLangfuseSpan', () => {
|
|
|
128
137
|
'parent-1'
|
|
129
138
|
);
|
|
130
139
|
shapeLangfuseSpan(span);
|
|
131
|
-
expect(span.name).toBe('
|
|
140
|
+
expect(span.name).toBe('tool-dispatch');
|
|
141
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
|
|
132
142
|
expect(span.attributes[INPUT]).toBe(original);
|
|
133
143
|
});
|
|
134
144
|
|
|
135
145
|
it('sets root span and trace input/output to the question and answer', () => {
|
|
136
146
|
const span = createSpan('LibreChat Agent', {
|
|
147
|
+
[TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
|
|
137
148
|
[INPUT]: JSON.stringify({
|
|
138
149
|
messages: [
|
|
139
150
|
{ type: 'system', content: 'You are helpful.' },
|
|
@@ -156,6 +167,7 @@ describe('shapeLangfuseSpan', () => {
|
|
|
156
167
|
|
|
157
168
|
it('extracts answer text from content part arrays', () => {
|
|
158
169
|
const span = createSpan('LibreChat Agent', {
|
|
170
|
+
[TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
|
|
159
171
|
[INPUT]: JSON.stringify([{ type: 'human', content: 'hi' }]),
|
|
160
172
|
[OUTPUT]: JSON.stringify({
|
|
161
173
|
messages: [
|
|
@@ -189,6 +201,65 @@ describe('shapeLangfuseSpan', () => {
|
|
|
189
201
|
const span = createSpan('LibreChat Agent', { [INPUT]: 'plain text' });
|
|
190
202
|
shapeLangfuseSpan(span);
|
|
191
203
|
expect(span.attributes[INPUT]).toBe('plain text');
|
|
192
|
-
expect(span.attributes[TRACE_INPUT]).
|
|
204
|
+
expect(span.attributes[TRACE_INPUT]).toBe('plain text');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('renames generation spans to a provider-agnostic name', () => {
|
|
208
|
+
const span = createSpan(
|
|
209
|
+
'ChatOpenAI',
|
|
210
|
+
{ [OBSERVATION_TYPE]: 'generation' },
|
|
211
|
+
'parent-1'
|
|
212
|
+
);
|
|
213
|
+
shapeLangfuseSpan(span);
|
|
214
|
+
expect(span.name).toBe('llm');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('marks only agent-tagged root spans as agent observations', () => {
|
|
218
|
+
const span = createSpan('LibreChat Agent', {
|
|
219
|
+
[TRACE_TAGS]: JSON.stringify(['librechat', 'agent']),
|
|
220
|
+
[INPUT]: JSON.stringify({
|
|
221
|
+
messages: [{ type: 'human', content: 'hi' }],
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
shapeLangfuseSpan(span);
|
|
225
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBe('agent');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('marks title-tagged root spans as chain observations', () => {
|
|
229
|
+
const span = createSpan('LibreChat Title', {
|
|
230
|
+
[TRACE_TAGS]: JSON.stringify(['librechat', 'title']),
|
|
231
|
+
[INPUT]: 'Conversation text',
|
|
232
|
+
[OUTPUT]: 'Conversation title',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
shapeLangfuseSpan(span);
|
|
236
|
+
|
|
237
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBe('chain');
|
|
238
|
+
expect(span.attributes[TRACE_INPUT]).toBe('Conversation text');
|
|
239
|
+
expect(span.attributes[TRACE_OUTPUT]).toBe('Conversation title');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('does not classify untagged root spans as agents', () => {
|
|
243
|
+
const span = createSpan('Custom root', { [INPUT]: 'input' });
|
|
244
|
+
|
|
245
|
+
shapeLangfuseSpan(span);
|
|
246
|
+
|
|
247
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('shapes standalone generation roots without replacing their type', () => {
|
|
251
|
+
const span = createSpan('ChatOpenAI', {
|
|
252
|
+
[OBSERVATION_TYPE]: 'generation',
|
|
253
|
+
[TRACE_TAGS]: JSON.stringify(['librechat', 'title']),
|
|
254
|
+
[INPUT]: 'Generate a title',
|
|
255
|
+
[OUTPUT]: 'A useful title',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
shapeLangfuseSpan(span);
|
|
259
|
+
|
|
260
|
+
expect(span.name).toBe('llm');
|
|
261
|
+
expect(span.attributes[OBSERVATION_TYPE]).toBe('generation');
|
|
262
|
+
expect(span.attributes[TRACE_INPUT]).toBe('Generate a title');
|
|
263
|
+
expect(span.attributes[TRACE_OUTPUT]).toBe('A useful title');
|
|
193
264
|
});
|
|
194
265
|
});
|