@librechat/agents 3.2.65 → 3.2.67
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/hooks/createToolPolicyHook.cjs +5 -5
- package/dist/cjs/hooks/createToolPolicyHook.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/cjs/tools/ToolSearch.cjs +59 -14
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +5 -3
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +15 -2
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/createToolPolicyHook.mjs +5 -5
- package/dist/esm/hooks/createToolPolicyHook.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/esm/tools/ToolSearch.mjs +59 -14
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +5 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/graphs/Graph.d.ts +2 -0
- package/dist/types/hooks/createToolPolicyHook.d.ts +12 -15
- 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/tools/search/types.d.ts +2 -0
- package/dist/types/types/graph.d.ts +10 -3
- package/package.json +1 -1
- package/src/graphs/Graph.ts +21 -3
- package/src/hooks/__tests__/createToolPolicyHook.test.ts +13 -4
- package/src/hooks/createToolPolicyHook.ts +16 -19
- 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/ToolSearch.ts +81 -23
- package/src/tools/__tests__/BashExecutor.test.ts +16 -5
- package/src/tools/__tests__/CodeExecutor.stateful.test.ts +17 -6
- package/src/tools/__tests__/ToolSearch.test.ts +151 -0
- package/src/tools/__tests__/hitl.test.ts +114 -1
- package/src/tools/search/source-processing.test.ts +57 -0
- package/src/tools/search/tool.ts +13 -1
- package/src/tools/search/types.ts +2 -0
- package/src/types/graph.ts +10 -3
|
@@ -5,8 +5,13 @@ const LANGGRAPH_START_NODE = '__start__';
|
|
|
5
5
|
const ANONYMOUS_LAMBDA_NAME = 'RunnableLambda';
|
|
6
6
|
const LANGGRAPH_AGENT_NODE_PREFIX = 'agent=';
|
|
7
7
|
const LANGGRAPH_TOOL_NODE_PREFIX = 'tools=';
|
|
8
|
-
const TOOL_BATCH_RUN_NAME = 'tool_batch';
|
|
9
8
|
const AGENT_NODE_SPAN_NAME = 'agent';
|
|
9
|
+
const TOOL_DISPATCH_SPAN_NAME = 'tool-dispatch';
|
|
10
|
+
const GENERATION_SPAN_NAME = 'llm';
|
|
11
|
+
const ROOT_OBSERVATION_TYPE = 'agent';
|
|
12
|
+
const CHAIN_OBSERVATION_TYPE = 'chain';
|
|
13
|
+
const AGENT_TRACE_TAG = 'agent';
|
|
14
|
+
const TITLE_TRACE_TAG = 'title';
|
|
10
15
|
|
|
11
16
|
type MutableSpan = ReadableSpan & {
|
|
12
17
|
name: string;
|
|
@@ -207,6 +212,8 @@ function isRootSpan(span: ReadableSpan): boolean {
|
|
|
207
212
|
* LangGraph plumbing observations that add noise without information:
|
|
208
213
|
* the duplicated `__start__` channel-seed nodes and anonymous
|
|
209
214
|
* `RunnableLambda` pass-throughs (Langfuse team feedback items 4 & 5).
|
|
215
|
+
* Internal ToolNode batch spans are disabled at their source so traced child
|
|
216
|
+
* tools retain an exported parent. Explicitly traced ToolNodes are preserved.
|
|
210
217
|
*/
|
|
211
218
|
export function shouldDropLangfuseSpan(spanName: string): boolean {
|
|
212
219
|
return (
|
|
@@ -216,18 +223,26 @@ export function shouldDropLangfuseSpan(spanName: string): boolean {
|
|
|
216
223
|
|
|
217
224
|
function shapeToolNodeSpan(span: MutableSpan): void {
|
|
218
225
|
const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
|
|
226
|
+
span.name = TOOL_DISPATCH_SPAN_NAME;
|
|
227
|
+
span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
|
|
228
|
+
CHAIN_OBSERVATION_TYPE;
|
|
219
229
|
const calls = findPendingToolCalls(
|
|
220
230
|
parseAttributeValue(span.attributes[inputKey])
|
|
221
231
|
);
|
|
222
232
|
if (calls.length === 0) {
|
|
223
233
|
return;
|
|
224
234
|
}
|
|
225
|
-
span.name = [...new Set(calls.map((call) => call.name))].join(', ');
|
|
226
235
|
span.attributes[inputKey] = JSON.stringify(
|
|
227
236
|
calls.map(({ name, args }) => ({ name, args }))
|
|
228
237
|
);
|
|
229
238
|
}
|
|
230
239
|
|
|
240
|
+
function shapeAgentNodeSpan(span: MutableSpan): void {
|
|
241
|
+
span.name = AGENT_NODE_SPAN_NAME;
|
|
242
|
+
span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
|
|
243
|
+
ROOT_OBSERVATION_TYPE;
|
|
244
|
+
}
|
|
245
|
+
|
|
231
246
|
function shapeRootSpan(span: MutableSpan): void {
|
|
232
247
|
const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
|
|
233
248
|
const outputKey = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
|
|
@@ -241,11 +256,47 @@ function shapeRootSpan(span: MutableSpan): void {
|
|
|
241
256
|
);
|
|
242
257
|
if (question != null) {
|
|
243
258
|
span.attributes[inputKey] = question;
|
|
244
|
-
span.attributes[LangfuseOtelSpanAttributes.TRACE_INPUT] = question;
|
|
245
259
|
}
|
|
246
260
|
if (answer != null) {
|
|
247
261
|
span.attributes[outputKey] = answer;
|
|
248
|
-
|
|
262
|
+
}
|
|
263
|
+
const traceInput = question ?? span.attributes[inputKey];
|
|
264
|
+
const traceOutput = answer ?? span.attributes[outputKey];
|
|
265
|
+
if (traceInput != null) {
|
|
266
|
+
span.attributes[LangfuseOtelSpanAttributes.TRACE_INPUT] = traceInput;
|
|
267
|
+
}
|
|
268
|
+
if (traceOutput != null) {
|
|
269
|
+
span.attributes[LangfuseOtelSpanAttributes.TRACE_OUTPUT] = traceOutput;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isGenerationSpan(span: MutableSpan): boolean {
|
|
274
|
+
const type = span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE];
|
|
275
|
+
return typeof type === 'string' && type.toLowerCase() === 'generation';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function hasTraceTag(span: MutableSpan, expectedTag: string): boolean {
|
|
279
|
+
const tags = parseAttributeValue(
|
|
280
|
+
span.attributes[LangfuseOtelSpanAttributes.TRACE_TAGS]
|
|
281
|
+
);
|
|
282
|
+
return (
|
|
283
|
+
Array.isArray(tags) &&
|
|
284
|
+
tags.some((tag) => typeof tag === 'string' && tag === expectedTag)
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function shapeRootObservationType(span: MutableSpan): void {
|
|
289
|
+
if (isGenerationSpan(span)) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (hasTraceTag(span, AGENT_TRACE_TAG)) {
|
|
293
|
+
span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
|
|
294
|
+
ROOT_OBSERVATION_TYPE;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (hasTraceTag(span, TITLE_TRACE_TAG)) {
|
|
298
|
+
span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] =
|
|
299
|
+
CHAIN_OBSERVATION_TYPE;
|
|
249
300
|
}
|
|
250
301
|
}
|
|
251
302
|
|
|
@@ -254,27 +305,29 @@ function shapeRootSpan(span: MutableSpan): void {
|
|
|
254
305
|
* - `agent=<id>` / `tools=<id>` node names carry the ephemeral agent id
|
|
255
306
|
* (`provider__model`) — strip it so switching models doesn't break
|
|
256
307
|
* name-based logic (item 1).
|
|
257
|
-
* -
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
308
|
+
* - LLM generation spans keep the provider client class name (`ChatOpenAI`,
|
|
309
|
+
* `AzureChatOpenAI`, …); rename them to a provider-agnostic `llm` so the
|
|
310
|
+
* name reflects the operation, not the model (the model stays on the
|
|
311
|
+
* generation's model attribute).
|
|
312
|
+
* - Agent nodes become `agent` observations, while tool-dispatch nodes become
|
|
313
|
+
* stable `chain` observations whose input is scoped to the pending calls.
|
|
314
|
+
* Individual child calls remain `tool` observations (items 3 & 4).
|
|
315
|
+
* - Agent trace roots become `agent` observations and title trace roots become
|
|
316
|
+
* `chain` observations. Root and trace input/output are reduced to the user
|
|
317
|
+
* question and assistant response when chat messages are available (item 2).
|
|
263
318
|
*/
|
|
264
319
|
export function shapeLangfuseSpan(span: ReadableSpan): void {
|
|
265
320
|
const mutable = span as MutableSpan;
|
|
266
321
|
if (mutable.name.startsWith(LANGGRAPH_AGENT_NODE_PREFIX)) {
|
|
267
|
-
mutable
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
if (
|
|
271
|
-
mutable.name.startsWith(LANGGRAPH_TOOL_NODE_PREFIX) ||
|
|
272
|
-
mutable.name === TOOL_BATCH_RUN_NAME
|
|
273
|
-
) {
|
|
322
|
+
shapeAgentNodeSpan(mutable);
|
|
323
|
+
} else if (mutable.name.startsWith(LANGGRAPH_TOOL_NODE_PREFIX)) {
|
|
274
324
|
shapeToolNodeSpan(mutable);
|
|
275
|
-
|
|
325
|
+
} else if (isGenerationSpan(mutable)) {
|
|
326
|
+
mutable.name = GENERATION_SPAN_NAME;
|
|
276
327
|
}
|
|
277
|
-
if (isRootSpan(span)) {
|
|
278
|
-
|
|
328
|
+
if (!isRootSpan(span)) {
|
|
329
|
+
return;
|
|
279
330
|
}
|
|
331
|
+
shapeRootObservationType(mutable);
|
|
332
|
+
shapeRootSpan(mutable);
|
|
280
333
|
}
|
|
@@ -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);
|