@librechat/agents 3.2.57 → 3.2.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +7 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +2 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/langfuseToolOutputTracing.cjs +4 -0
  6. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  7. package/dist/cjs/langfuseTraceShaping.cjs +172 -0
  8. package/dist/cjs/langfuseTraceShaping.cjs.map +1 -0
  9. package/dist/cjs/main.cjs +7 -0
  10. package/dist/cjs/run.cjs +2 -2
  11. package/dist/cjs/run.cjs.map +1 -1
  12. package/dist/cjs/stream.cjs +2 -1
  13. package/dist/cjs/stream.cjs.map +1 -1
  14. package/dist/cjs/tools/BashExecutor.cjs +58 -9
  15. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  16. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +4 -2
  17. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  18. package/dist/cjs/tools/CodeExecutor.cjs +57 -7
  19. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  20. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +9 -3
  21. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  22. package/dist/cjs/tools/ToolNode.cjs +19 -1
  23. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  24. package/dist/cjs/tools/eagerEventExecution.cjs +18 -1
  25. package/dist/cjs/tools/eagerEventExecution.cjs.map +1 -1
  26. package/dist/cjs/tools/search/keenable-search.cjs +68 -0
  27. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
  28. package/dist/cjs/tools/search/rerankers.cjs +28 -11
  29. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  30. package/dist/cjs/tools/search/search.cjs +30 -4
  31. package/dist/cjs/tools/search/search.cjs.map +1 -1
  32. package/dist/cjs/tools/search/tool.cjs +14 -6
  33. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  34. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
  35. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  36. package/dist/cjs/utils/title.cjs +9 -9
  37. package/dist/cjs/utils/title.cjs.map +1 -1
  38. package/dist/esm/agents/AgentContext.mjs +7 -1
  39. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  40. package/dist/esm/graphs/Graph.mjs +2 -2
  41. package/dist/esm/graphs/Graph.mjs.map +1 -1
  42. package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
  43. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  44. package/dist/esm/langfuseTraceShaping.mjs +171 -0
  45. package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
  46. package/dist/esm/main.mjs +3 -3
  47. package/dist/esm/run.mjs +2 -2
  48. package/dist/esm/run.mjs.map +1 -1
  49. package/dist/esm/stream.mjs +2 -1
  50. package/dist/esm/stream.mjs.map +1 -1
  51. package/dist/esm/tools/BashExecutor.mjs +56 -10
  52. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  53. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +4 -2
  54. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  55. package/dist/esm/tools/CodeExecutor.mjs +54 -8
  56. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  57. package/dist/esm/tools/ProgrammaticToolCalling.mjs +9 -3
  58. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  59. package/dist/esm/tools/ToolNode.mjs +20 -2
  60. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  61. package/dist/esm/tools/eagerEventExecution.mjs +18 -2
  62. package/dist/esm/tools/eagerEventExecution.mjs.map +1 -1
  63. package/dist/esm/tools/search/keenable-search.mjs +66 -0
  64. package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
  65. package/dist/esm/tools/search/rerankers.mjs +28 -11
  66. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  67. package/dist/esm/tools/search/search.mjs +30 -4
  68. package/dist/esm/tools/search/search.mjs.map +1 -1
  69. package/dist/esm/tools/search/tool.mjs +14 -6
  70. package/dist/esm/tools/search/tool.mjs.map +1 -1
  71. package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
  72. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  73. package/dist/esm/utils/title.mjs +9 -9
  74. package/dist/esm/utils/title.mjs.map +1 -1
  75. package/dist/types/langfuseTraceShaping.d.ts +20 -0
  76. package/dist/types/tools/BashExecutor.d.ts +13 -0
  77. package/dist/types/tools/CodeExecutor.d.ts +14 -0
  78. package/dist/types/tools/ToolNode.d.ts +3 -0
  79. package/dist/types/tools/eagerEventExecution.d.ts +8 -0
  80. package/dist/types/tools/search/keenable-search.d.ts +4 -0
  81. package/dist/types/tools/search/rerankers.d.ts +7 -2
  82. package/dist/types/tools/search/types.d.ts +38 -1
  83. package/dist/types/types/graph.d.ts +22 -0
  84. package/dist/types/types/tools.d.ts +58 -0
  85. package/package.json +1 -1
  86. package/src/agents/AgentContext.ts +9 -0
  87. package/src/agents/__tests__/AgentContext.test.ts +40 -0
  88. package/src/graphs/Graph.ts +25 -2
  89. package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
  90. package/src/langfuseToolOutputTracing.ts +11 -0
  91. package/src/langfuseTraceShaping.ts +280 -0
  92. package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
  93. package/src/run.ts +3 -3
  94. package/src/specs/langfuse-routing.integration.test.ts +1 -1
  95. package/src/specs/langfuse-trace-shaping.test.ts +194 -0
  96. package/src/stream.ts +17 -1
  97. package/src/tools/BashExecutor.ts +107 -14
  98. package/src/tools/BashProgrammaticToolCalling.ts +20 -1
  99. package/src/tools/CodeExecutor.ts +113 -9
  100. package/src/tools/ProgrammaticToolCalling.ts +27 -1
  101. package/src/tools/ToolNode.ts +36 -4
  102. package/src/tools/__tests__/BashExecutor.test.ts +39 -0
  103. package/src/tools/__tests__/CodeExecutor.stateful.test.ts +113 -0
  104. package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
  105. package/src/tools/__tests__/ToolNode.session.test.ts +86 -0
  106. package/src/tools/__tests__/eagerEventExecution.session.test.ts +92 -0
  107. package/src/tools/__tests__/hitl.test.ts +85 -0
  108. package/src/tools/eagerEventExecution.ts +32 -5
  109. package/src/tools/search/jina-reranker.test.ts +70 -1
  110. package/src/tools/search/keenable-search.ts +98 -0
  111. package/src/tools/search/keenable.test.ts +183 -0
  112. package/src/tools/search/rerankers.ts +41 -4
  113. package/src/tools/search/search.ts +58 -2
  114. package/src/tools/search/source-processing.test.ts +86 -0
  115. package/src/tools/search/tool.ts +29 -4
  116. package/src/tools/search/types.ts +42 -1
  117. package/src/tools/subagent/SubagentExecutor.ts +11 -0
  118. package/src/types/graph.ts +22 -0
  119. package/src/types/tools.ts +65 -0
  120. package/src/utils/title.ts +9 -9
@@ -0,0 +1,280 @@
1
+ import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
2
+ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
3
+
4
+ const LANGGRAPH_START_NODE = '__start__';
5
+ const ANONYMOUS_LAMBDA_NAME = 'RunnableLambda';
6
+ const LANGGRAPH_AGENT_NODE_PREFIX = 'agent=';
7
+ const LANGGRAPH_TOOL_NODE_PREFIX = 'tools=';
8
+ const TOOL_BATCH_RUN_NAME = 'tool_batch';
9
+ const AGENT_NODE_SPAN_NAME = 'agent';
10
+
11
+ type MutableSpan = ReadableSpan & {
12
+ name: string;
13
+ attributes: Record<string, unknown>;
14
+ };
15
+
16
+ type SerializedToolCall = {
17
+ name: string;
18
+ args: unknown;
19
+ id?: string;
20
+ };
21
+
22
+ function isRecord(value: unknown): value is Record<string, unknown> {
23
+ return value != null && typeof value === 'object' && !Array.isArray(value);
24
+ }
25
+
26
+ function parseAttributeValue(value: unknown): unknown {
27
+ if (typeof value !== 'string') {
28
+ return value;
29
+ }
30
+ const trimmed = value.trim();
31
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
32
+ return value;
33
+ }
34
+ try {
35
+ return JSON.parse(value) as unknown;
36
+ } catch {
37
+ return value;
38
+ }
39
+ }
40
+
41
+ function getMessageArray(
42
+ value: unknown
43
+ ): Record<string, unknown>[] | undefined {
44
+ if (Array.isArray(value)) {
45
+ const records = value.filter(isRecord);
46
+ return records.length > 0 ? records : undefined;
47
+ }
48
+ if (!isRecord(value)) {
49
+ return undefined;
50
+ }
51
+ return (
52
+ getMessageArray(value.messages) ??
53
+ getMessageArray(value.input) ??
54
+ getMessageArray(value.output)
55
+ );
56
+ }
57
+
58
+ function getMessageRole(message: Record<string, unknown>): string | undefined {
59
+ const id = message.id;
60
+ if (Array.isArray(id)) {
61
+ const className = id[id.length - 1];
62
+ if (typeof className === 'string') {
63
+ if (className.includes('Human')) {
64
+ return 'user';
65
+ }
66
+ if (className.includes('AI')) {
67
+ return 'assistant';
68
+ }
69
+ if (className.includes('System')) {
70
+ return 'system';
71
+ }
72
+ if (className.includes('Tool')) {
73
+ return 'tool';
74
+ }
75
+ }
76
+ }
77
+ const rawRole =
78
+ message.type ?? message._type ?? message.role ?? message.sender;
79
+ if (typeof rawRole !== 'string') {
80
+ const kwargs = message.kwargs;
81
+ return isRecord(kwargs) ? getMessageRole(kwargs) : undefined;
82
+ }
83
+ const normalized = rawRole.toLowerCase();
84
+ if (normalized === 'human') {
85
+ return 'user';
86
+ }
87
+ if (normalized === 'ai') {
88
+ return 'assistant';
89
+ }
90
+ return normalized;
91
+ }
92
+
93
+ function getMessageText(message: Record<string, unknown>): string | undefined {
94
+ const content =
95
+ message.content ??
96
+ (isRecord(message.kwargs) ? message.kwargs.content : undefined) ??
97
+ (isRecord(message.data) ? message.data.content : undefined);
98
+ if (typeof content === 'string') {
99
+ return content;
100
+ }
101
+ if (!Array.isArray(content)) {
102
+ return undefined;
103
+ }
104
+ const text = content
105
+ .filter(isRecord)
106
+ .map((part) => (typeof part.text === 'string' ? part.text : ''))
107
+ .join('');
108
+ return text === '' ? undefined : text;
109
+ }
110
+
111
+ function findLastMessageText(value: unknown, role: string): string | undefined {
112
+ const messages = getMessageArray(value);
113
+ if (messages == null) {
114
+ return undefined;
115
+ }
116
+ for (let i = messages.length - 1; i >= 0; i--) {
117
+ if (getMessageRole(messages[i]) !== role) {
118
+ continue;
119
+ }
120
+ const text = getMessageText(messages[i]);
121
+ if (text != null && text.trim() !== '') {
122
+ return text;
123
+ }
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ function normalizeToolCall(value: unknown): SerializedToolCall | undefined {
129
+ if (!isRecord(value)) {
130
+ return undefined;
131
+ }
132
+ const fn = value.function;
133
+ if (isRecord(fn) && typeof fn.name === 'string') {
134
+ return {
135
+ name: fn.name,
136
+ args: parseAttributeValue(fn.arguments),
137
+ ...(typeof value.id === 'string' ? { id: value.id } : {}),
138
+ };
139
+ }
140
+ if (typeof value.name !== 'string') {
141
+ return undefined;
142
+ }
143
+ return {
144
+ name: value.name,
145
+ args: value.args ?? {},
146
+ ...(typeof value.id === 'string' ? { id: value.id } : {}),
147
+ };
148
+ }
149
+
150
+ function getMessageToolCalls(
151
+ message: Record<string, unknown>
152
+ ): SerializedToolCall[] {
153
+ const rawCalls =
154
+ message.tool_calls ??
155
+ (isRecord(message.kwargs) ? message.kwargs.tool_calls : undefined) ??
156
+ (isRecord(message.additional_kwargs)
157
+ ? message.additional_kwargs.tool_calls
158
+ : undefined) ??
159
+ (isRecord(message.data) ? message.data.tool_calls : undefined);
160
+ if (!Array.isArray(rawCalls)) {
161
+ return [];
162
+ }
163
+ const calls: SerializedToolCall[] = [];
164
+ for (const rawCall of rawCalls) {
165
+ const call = normalizeToolCall(rawCall);
166
+ if (call != null) {
167
+ calls.push(call);
168
+ }
169
+ }
170
+ return calls;
171
+ }
172
+
173
+ /** Latest assistant turn's tool calls — the calls this tool node is executing. */
174
+ function findPendingToolCalls(value: unknown): SerializedToolCall[] {
175
+ const messages = getMessageArray(value);
176
+ if (messages == null) {
177
+ return [];
178
+ }
179
+ for (let i = messages.length - 1; i >= 0; i--) {
180
+ if (getMessageRole(messages[i]) !== 'assistant') {
181
+ continue;
182
+ }
183
+ const calls = getMessageToolCalls(messages[i]);
184
+ if (calls.length > 0) {
185
+ return calls;
186
+ }
187
+ }
188
+ return [];
189
+ }
190
+
191
+ function getRootSpanParentId(span: ReadableSpan): string | undefined {
192
+ const legacyParent = (span as { parentSpanId?: string }).parentSpanId;
193
+ if (typeof legacyParent === 'string' && legacyParent !== '') {
194
+ return legacyParent;
195
+ }
196
+ const parentContext = (span as { parentSpanContext?: { spanId?: string } })
197
+ .parentSpanContext;
198
+ const spanId = parentContext?.spanId;
199
+ return typeof spanId === 'string' && spanId !== '' ? spanId : undefined;
200
+ }
201
+
202
+ function isRootSpan(span: ReadableSpan): boolean {
203
+ return getRootSpanParentId(span) == null;
204
+ }
205
+
206
+ /**
207
+ * LangGraph plumbing observations that add noise without information:
208
+ * the duplicated `__start__` channel-seed nodes and anonymous
209
+ * `RunnableLambda` pass-throughs (Langfuse team feedback items 4 & 5).
210
+ */
211
+ export function shouldDropLangfuseSpan(spanName: string): boolean {
212
+ return (
213
+ spanName === LANGGRAPH_START_NODE || spanName === ANONYMOUS_LAMBDA_NAME
214
+ );
215
+ }
216
+
217
+ function shapeToolNodeSpan(span: MutableSpan): void {
218
+ const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
219
+ const calls = findPendingToolCalls(
220
+ parseAttributeValue(span.attributes[inputKey])
221
+ );
222
+ if (calls.length === 0) {
223
+ return;
224
+ }
225
+ span.name = [...new Set(calls.map((call) => call.name))].join(', ');
226
+ span.attributes[inputKey] = JSON.stringify(
227
+ calls.map(({ name, args }) => ({ name, args }))
228
+ );
229
+ }
230
+
231
+ function shapeRootSpan(span: MutableSpan): void {
232
+ const inputKey = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
233
+ const outputKey = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
234
+ const question = findLastMessageText(
235
+ parseAttributeValue(span.attributes[inputKey]),
236
+ 'user'
237
+ );
238
+ const answer = findLastMessageText(
239
+ parseAttributeValue(span.attributes[outputKey]),
240
+ 'assistant'
241
+ );
242
+ if (question != null) {
243
+ span.attributes[inputKey] = question;
244
+ span.attributes[LangfuseOtelSpanAttributes.TRACE_INPUT] = question;
245
+ }
246
+ if (answer != null) {
247
+ span.attributes[outputKey] = answer;
248
+ span.attributes[LangfuseOtelSpanAttributes.TRACE_OUTPUT] = answer;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Reshapes spans per Langfuse-team feedback before export:
254
+ * - `agent=<id>` / `tools=<id>` node names carry the ephemeral agent id
255
+ * (`provider__model`) — strip it so switching models doesn't break
256
+ * name-based logic (item 1).
257
+ * - Tool node spans are renamed to the actual tool name(s) and their
258
+ * input scoped to the pending tool-call args instead of the whole
259
+ * chat history (items 3 & 4).
260
+ * - The root span (and trace) input/output become the user question and
261
+ * assistant response so the session view reads as a conversation
262
+ * (item 2).
263
+ */
264
+ export function shapeLangfuseSpan(span: ReadableSpan): void {
265
+ const mutable = span as MutableSpan;
266
+ if (mutable.name.startsWith(LANGGRAPH_AGENT_NODE_PREFIX)) {
267
+ mutable.name = AGENT_NODE_SPAN_NAME;
268
+ return;
269
+ }
270
+ if (
271
+ mutable.name.startsWith(LANGGRAPH_TOOL_NODE_PREFIX) ||
272
+ mutable.name === TOOL_BATCH_RUN_NAME
273
+ ) {
274
+ shapeToolNodeSpan(mutable);
275
+ return;
276
+ }
277
+ if (isRootSpan(span)) {
278
+ shapeRootSpan(mutable);
279
+ }
280
+ }
@@ -32,7 +32,6 @@ import { HumanMessage } from '@langchain/core/messages';
32
32
  import { ChatModelStream } from '@langchain/core/language_models/stream';
33
33
  import type { BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
34
34
  import type { ChatModelStreamEvent } from '@langchain/core/language_models/event';
35
- import type { Stream } from '@anthropic-ai/sdk/streaming';
36
35
  import type {
37
36
  AnthropicMessageStreamEvent,
38
37
  AnthropicRequestOptions,
@@ -57,10 +56,14 @@ class MockStreamChatAnthropic extends ChatAnthropic {
57
56
  this.mockEvents = mockEvents;
58
57
  }
59
58
 
59
+ // Inherit the base method's own Stream identity instead of importing
60
+ // @anthropic-ai/sdk/streaming here: the SDK ships dual .d.ts/.d.mts types,
61
+ // and an independent import can resolve to the other format's identity
62
+ // depending on compile order (flaky TS2416 in sharded CI).
60
63
  protected override async createStreamWithRetry(
61
64
  request: AnthropicStreamingMessageCreateParams,
62
65
  _options?: AnthropicRequestOptions
63
- ): Promise<Stream<AnthropicMessageStreamEvent>> {
66
+ ): ReturnType<ChatAnthropic['createStreamWithRetry']> {
64
67
  this.capturedRequest = request;
65
68
  const events = this.mockEvents;
66
69
  return {
@@ -70,7 +73,7 @@ class MockStreamChatAnthropic extends ChatAnthropic {
70
73
  yield event as AnthropicMessageStreamEvent;
71
74
  }
72
75
  },
73
- } as unknown as Stream<AnthropicMessageStreamEvent>;
76
+ } as unknown as Awaited<ReturnType<ChatAnthropic['createStreamWithRetry']>>;
74
77
  }
75
78
  }
76
79
 
package/src/run.ts CHANGED
@@ -1403,7 +1403,7 @@ export class Run<_T extends t.BaseGraphState> {
1403
1403
  inputText,
1404
1404
  skipLanguage,
1405
1405
  }),
1406
- }).withConfig({ runName: 'ConvoTransform' });
1406
+ }).withConfig({ runName: 'PrepareTitleInput' });
1407
1407
 
1408
1408
  const titleChain =
1409
1409
  titleMethod === TitleMethod.COMPLETION
@@ -1412,10 +1412,10 @@ export class Run<_T extends t.BaseGraphState> {
1412
1412
 
1413
1413
  /** Pipes `convoTemplate` -> `transformer` -> `titleChain` */
1414
1414
  const fullChain = convoTemplate
1415
- .withConfig({ runName: 'ConvoTemplate' })
1415
+ .withConfig({ runName: 'FormatConversation' })
1416
1416
  .pipe(convoToTitleInput)
1417
1417
  .pipe(titleChain)
1418
- .withConfig({ runName: 'TitleChain' });
1418
+ .withConfig({ runName: 'GenerateConversationTitle' });
1419
1419
 
1420
1420
  const invokeConfig = Object.assign({}, chainOptions, {
1421
1421
  run_id: this.id,
@@ -469,7 +469,7 @@ describe('Langfuse per-run routing integration', () => {
469
469
  expectNamedSpansUseTraceId({
470
470
  starts,
471
471
  traceId: titleTraceId,
472
- names: [`LibreChat Title: Parent ${tenantId}`, 'CompletionTitleChain'],
472
+ names: [`LibreChat Title: Parent ${tenantId}`, 'GenerateTitle'],
473
473
  });
474
474
  expectNoCrossTenantTrace({
475
475
  tenantId,
@@ -0,0 +1,194 @@
1
+ import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
2
+ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
3
+ import {
4
+ shapeLangfuseSpan,
5
+ shouldDropLangfuseSpan,
6
+ } from '@/langfuseTraceShaping';
7
+
8
+ type TestSpan = ReadableSpan & {
9
+ name: string;
10
+ attributes: Record<string, unknown>;
11
+ };
12
+
13
+ function createSpan(
14
+ name: string,
15
+ attributes: Record<string, unknown> = {},
16
+ parentSpanId?: string
17
+ ): TestSpan {
18
+ return {
19
+ name,
20
+ attributes,
21
+ ...(parentSpanId != null ? { parentSpanId } : {}),
22
+ } as unknown as TestSpan;
23
+ }
24
+
25
+ const INPUT = LangfuseOtelSpanAttributes.OBSERVATION_INPUT;
26
+ const OUTPUT = LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT;
27
+ const TRACE_INPUT = LangfuseOtelSpanAttributes.TRACE_INPUT;
28
+ const TRACE_OUTPUT = LangfuseOtelSpanAttributes.TRACE_OUTPUT;
29
+
30
+ describe('shouldDropLangfuseSpan', () => {
31
+ it('drops langgraph __start__ seed spans', () => {
32
+ expect(shouldDropLangfuseSpan('__start__')).toBe(true);
33
+ });
34
+
35
+ it('drops anonymous RunnableLambda pass-throughs', () => {
36
+ expect(shouldDropLangfuseSpan('RunnableLambda')).toBe(true);
37
+ });
38
+
39
+ it('keeps named observations', () => {
40
+ expect(shouldDropLangfuseSpan('GenerateTitle')).toBe(false);
41
+ expect(shouldDropLangfuseSpan('agent=openAI__gpt-5.4')).toBe(false);
42
+ expect(shouldDropLangfuseSpan('ChatOpenAI')).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe('shapeLangfuseSpan', () => {
47
+ it('strips the ephemeral agent id (provider__model) from agent node names', () => {
48
+ const span = createSpan('agent=openAI__gpt-5.4', {}, 'parent-1');
49
+ shapeLangfuseSpan(span);
50
+ expect(span.name).toBe('agent');
51
+ });
52
+
53
+ it('renames tool node spans to the pending tool names and scopes input to args', () => {
54
+ const messages = [
55
+ { type: 'human', content: 'hello' },
56
+ {
57
+ type: 'ai',
58
+ content: '',
59
+ tool_calls: [
60
+ {
61
+ name: 'get_service_details',
62
+ args: { path: 'organizations/1' },
63
+ id: 'call_1',
64
+ },
65
+ ],
66
+ },
67
+ ];
68
+ const span = createSpan(
69
+ 'tools=openAI__gpt-5.4',
70
+ { [INPUT]: JSON.stringify({ messages }) },
71
+ 'parent-1'
72
+ );
73
+ shapeLangfuseSpan(span);
74
+ expect(span.name).toBe('get_service_details');
75
+ expect(JSON.parse(span.attributes[INPUT] as string)).toEqual([
76
+ { name: 'get_service_details', args: { path: 'organizations/1' } },
77
+ ]);
78
+ });
79
+
80
+ it('joins multiple pending tool names and dedupes repeats', () => {
81
+ const messages = [
82
+ {
83
+ type: 'ai',
84
+ tool_calls: [
85
+ { name: 'web_search', args: { q: 'a' }, id: '1' },
86
+ { name: 'web_search', args: { q: 'b' }, id: '2' },
87
+ { name: 'execute_code', args: { code: '1+1' }, id: '3' },
88
+ ],
89
+ },
90
+ ];
91
+ const span = createSpan(
92
+ 'tool_batch',
93
+ { [INPUT]: JSON.stringify({ messages }) },
94
+ 'parent-1'
95
+ );
96
+ shapeLangfuseSpan(span);
97
+ expect(span.name).toBe('web_search, execute_code');
98
+ });
99
+
100
+ it('reads tool calls from serialized langchain message kwargs', () => {
101
+ const messages = [
102
+ {
103
+ lc: 1,
104
+ type: 'constructor',
105
+ id: ['langchain_core', 'messages', 'AIMessage'],
106
+ kwargs: {
107
+ content: '',
108
+ tool_calls: [{ name: 'lookup', args: { id: 7 }, id: 'call_7' }],
109
+ },
110
+ },
111
+ ];
112
+ const span = createSpan(
113
+ 'tools=agent_abc',
114
+ { [INPUT]: JSON.stringify({ messages }) },
115
+ 'parent-1'
116
+ );
117
+ shapeLangfuseSpan(span);
118
+ expect(span.name).toBe('lookup');
119
+ });
120
+
121
+ it('leaves tool node spans untouched when no tool calls are found', () => {
122
+ const original = JSON.stringify({
123
+ messages: [{ type: 'human', content: 'hi' }],
124
+ });
125
+ const span = createSpan(
126
+ 'tools=agent_abc',
127
+ { [INPUT]: original },
128
+ 'parent-1'
129
+ );
130
+ shapeLangfuseSpan(span);
131
+ expect(span.name).toBe('tools=agent_abc');
132
+ expect(span.attributes[INPUT]).toBe(original);
133
+ });
134
+
135
+ it('sets root span and trace input/output to the question and answer', () => {
136
+ const span = createSpan('LibreChat Agent', {
137
+ [INPUT]: JSON.stringify({
138
+ messages: [
139
+ { type: 'system', content: 'You are helpful.' },
140
+ { type: 'human', content: 'What is ClickHouse?' },
141
+ ],
142
+ }),
143
+ [OUTPUT]: JSON.stringify({
144
+ messages: [
145
+ { type: 'human', content: 'What is ClickHouse?' },
146
+ { type: 'ai', content: 'A columnar OLAP database.' },
147
+ ],
148
+ }),
149
+ });
150
+ shapeLangfuseSpan(span);
151
+ expect(span.attributes[INPUT]).toBe('What is ClickHouse?');
152
+ expect(span.attributes[TRACE_INPUT]).toBe('What is ClickHouse?');
153
+ expect(span.attributes[OUTPUT]).toBe('A columnar OLAP database.');
154
+ expect(span.attributes[TRACE_OUTPUT]).toBe('A columnar OLAP database.');
155
+ });
156
+
157
+ it('extracts answer text from content part arrays', () => {
158
+ const span = createSpan('LibreChat Agent', {
159
+ [INPUT]: JSON.stringify([{ type: 'human', content: 'hi' }]),
160
+ [OUTPUT]: JSON.stringify({
161
+ messages: [
162
+ {
163
+ id: ['langchain_core', 'messages', 'AIMessage'],
164
+ kwargs: {
165
+ content: [
166
+ { type: 'text', text: 'Hello ' },
167
+ { type: 'text', text: 'there.' },
168
+ ],
169
+ },
170
+ },
171
+ ],
172
+ }),
173
+ });
174
+ shapeLangfuseSpan(span);
175
+ expect(span.attributes[INPUT]).toBe('hi');
176
+ expect(span.attributes[OUTPUT]).toBe('Hello there.');
177
+ });
178
+
179
+ it('does not rewrite non-root spans with message payloads', () => {
180
+ const original = JSON.stringify({
181
+ messages: [{ type: 'human', content: 'hi' }],
182
+ });
183
+ const span = createSpan('ChatOpenAI', { [INPUT]: original }, 'parent-1');
184
+ shapeLangfuseSpan(span);
185
+ expect(span.attributes[INPUT]).toBe(original);
186
+ });
187
+
188
+ it('preserves root attributes when extraction finds nothing', () => {
189
+ const span = createSpan('LibreChat Agent', { [INPUT]: 'plain text' });
190
+ shapeLangfuseSpan(span);
191
+ expect(span.attributes[INPUT]).toBe('plain text');
192
+ expect(span.attributes[TRACE_INPUT]).toBeUndefined();
193
+ });
194
+ });
package/src/stream.ts CHANGED
@@ -142,7 +142,18 @@ function isEagerExecutionExcludedTool(
142
142
  // side-effecting: never prestart it speculatively (a revised/superseded turn
143
143
  // would leave the write applied). Implies exclusion without the host having
144
144
  // to also list the name in excludeToolNames.
145
- return graph.codeSessionToolNames?.includes(name) === true;
145
+ if (graph.codeSessionToolNames?.includes(name) === true) {
146
+ return true;
147
+ }
148
+ // With stateful sessions on, execute_code/bash run against a DURABLE warm
149
+ // runtime. Speculative prestart there is unsafe: if the model revises the
150
+ // args, ToolNode discards the eager result but the mutation has already
151
+ // landed in the session workspace, corrupting later runs. Stateless mode
152
+ // uses a throwaway VM per call, so eager prestart stays safe there.
153
+ return (
154
+ graph.toolExecution?.sandbox?.statefulSessions === true &&
155
+ CODE_EXECUTION_TOOLS.has(name)
156
+ );
146
157
  }
147
158
 
148
159
  function isDirectGraphTool(
@@ -668,6 +679,11 @@ function createEagerToolExecutionPlan(args: {
668
679
  return undefined;
669
680
  }
670
681
 
682
+ /* No runtimeSessionHint here on purpose: the eager path is speculative, and
683
+ * code-session tools (the only ones that carry a hint) are excluded from
684
+ * eager prestart entirely when stateful sessions are on — see
685
+ * isEagerExecutionExcludedTool. The durable runtime is only ever touched by
686
+ * the committed ToolNode path. */
671
687
  const plan = buildToolExecutionRequestPlan({
672
688
  toolCalls: candidateToolCalls.map((toolCall) => ({
673
689
  id: toolCall.id,