@librechat/agents 3.2.56 → 3.2.58

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 (92) 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 +7 -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/run.cjs +6 -2
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/stream.cjs +3 -2
  12. package/dist/cjs/stream.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +19 -5
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/cjs/tools/search/keenable-search.cjs +68 -0
  16. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -0
  17. package/dist/cjs/tools/search/rerankers.cjs +28 -11
  18. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  19. package/dist/cjs/tools/search/search.cjs +30 -4
  20. package/dist/cjs/tools/search/search.cjs.map +1 -1
  21. package/dist/cjs/tools/search/tool.cjs +14 -6
  22. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  23. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +12 -1
  24. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  25. package/dist/cjs/utils/title.cjs +9 -9
  26. package/dist/cjs/utils/title.cjs.map +1 -1
  27. package/dist/esm/agents/AgentContext.mjs +7 -1
  28. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  29. package/dist/esm/graphs/Graph.mjs +7 -2
  30. package/dist/esm/graphs/Graph.mjs.map +1 -1
  31. package/dist/esm/langfuseToolOutputTracing.mjs +4 -0
  32. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  33. package/dist/esm/langfuseTraceShaping.mjs +171 -0
  34. package/dist/esm/langfuseTraceShaping.mjs.map +1 -0
  35. package/dist/esm/run.mjs +6 -2
  36. package/dist/esm/run.mjs.map +1 -1
  37. package/dist/esm/stream.mjs +3 -2
  38. package/dist/esm/stream.mjs.map +1 -1
  39. package/dist/esm/tools/ToolNode.mjs +19 -5
  40. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  41. package/dist/esm/tools/search/keenable-search.mjs +66 -0
  42. package/dist/esm/tools/search/keenable-search.mjs.map +1 -0
  43. package/dist/esm/tools/search/rerankers.mjs +28 -11
  44. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  45. package/dist/esm/tools/search/search.mjs +30 -4
  46. package/dist/esm/tools/search/search.mjs.map +1 -1
  47. package/dist/esm/tools/search/tool.mjs +14 -6
  48. package/dist/esm/tools/search/tool.mjs.map +1 -1
  49. package/dist/esm/tools/subagent/SubagentExecutor.mjs +12 -1
  50. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  51. package/dist/esm/utils/title.mjs +9 -9
  52. package/dist/esm/utils/title.mjs.map +1 -1
  53. package/dist/types/graphs/Graph.d.ts +1 -0
  54. package/dist/types/langfuseTraceShaping.d.ts +20 -0
  55. package/dist/types/run.d.ts +1 -0
  56. package/dist/types/tools/ToolNode.d.ts +11 -1
  57. package/dist/types/tools/search/keenable-search.d.ts +4 -0
  58. package/dist/types/tools/search/rerankers.d.ts +7 -2
  59. package/dist/types/tools/search/types.d.ts +38 -1
  60. package/dist/types/types/graph.d.ts +22 -0
  61. package/dist/types/types/run.d.ts +9 -0
  62. package/dist/types/types/tools.d.ts +6 -0
  63. package/package.json +1 -1
  64. package/src/__tests__/stream.eagerEventExecution.test.ts +44 -0
  65. package/src/agents/AgentContext.ts +9 -0
  66. package/src/agents/__tests__/AgentContext.test.ts +40 -0
  67. package/src/graphs/Graph.ts +31 -3
  68. package/src/graphs/__tests__/composition.smoke.test.ts +53 -0
  69. package/src/langfuseToolOutputTracing.ts +11 -0
  70. package/src/langfuseTraceShaping.ts +280 -0
  71. package/src/llm/anthropic/inherited-stream-events.spec.ts +6 -3
  72. package/src/run.ts +7 -3
  73. package/src/specs/langfuse-routing.integration.test.ts +1 -1
  74. package/src/specs/langfuse-trace-shaping.test.ts +194 -0
  75. package/src/stream.ts +10 -2
  76. package/src/tools/ToolNode.ts +28 -4
  77. package/src/tools/__tests__/SubagentExecutor.test.ts +44 -0
  78. package/src/tools/__tests__/ToolNode.session.test.ts +160 -0
  79. package/src/tools/__tests__/hitl.test.ts +85 -0
  80. package/src/tools/search/jina-reranker.test.ts +70 -1
  81. package/src/tools/search/keenable-search.ts +98 -0
  82. package/src/tools/search/keenable.test.ts +183 -0
  83. package/src/tools/search/rerankers.ts +41 -4
  84. package/src/tools/search/search.ts +58 -2
  85. package/src/tools/search/source-processing.test.ts +86 -0
  86. package/src/tools/search/tool.ts +29 -4
  87. package/src/tools/search/types.ts +42 -1
  88. package/src/tools/subagent/SubagentExecutor.ts +11 -0
  89. package/src/types/graph.ts +22 -0
  90. package/src/types/run.ts +9 -0
  91. package/src/types/tools.ts +6 -0
  92. 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
@@ -127,6 +127,7 @@ export class Run<_T extends t.BaseGraphState> {
127
127
  private langfuse?: t.LangfuseConfig;
128
128
  private toolOutputReferences?: t.ToolOutputReferencesConfig;
129
129
  private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
130
+ private codeSessionToolNames?: string[];
130
131
  private toolExecution?: t.ToolExecutionConfig;
131
132
  private subagentUsageSink?: t.SubagentUsageSink;
132
133
  private indexTokenCountMap?: Record<string, number>;
@@ -182,6 +183,7 @@ export class Run<_T extends t.BaseGraphState> {
182
183
  this.langfuse = config.langfuse;
183
184
  this.toolOutputReferences = config.toolOutputReferences;
184
185
  this.eagerEventToolExecution = config.eagerEventToolExecution;
186
+ this.codeSessionToolNames = config.codeSessionToolNames;
185
187
  this.toolExecution = config.toolExecution;
186
188
  this.subagentUsageSink = config.subagentUsageSink;
187
189
 
@@ -268,6 +270,7 @@ export class Run<_T extends t.BaseGraphState> {
268
270
  standardGraph.humanInTheLoop = this.humanInTheLoop;
269
271
  standardGraph.toolOutputReferences = this.toolOutputReferences;
270
272
  standardGraph.eagerEventToolExecution = this.eagerEventToolExecution;
273
+ standardGraph.codeSessionToolNames = this.codeSessionToolNames;
271
274
  standardGraph.toolExecution = this.toolExecution;
272
275
  this.Graph = standardGraph;
273
276
  return standardGraph.createWorkflow();
@@ -297,6 +300,7 @@ export class Run<_T extends t.BaseGraphState> {
297
300
  multiAgentGraph.humanInTheLoop = this.humanInTheLoop;
298
301
  multiAgentGraph.toolOutputReferences = this.toolOutputReferences;
299
302
  multiAgentGraph.eagerEventToolExecution = this.eagerEventToolExecution;
303
+ multiAgentGraph.codeSessionToolNames = this.codeSessionToolNames;
300
304
  multiAgentGraph.toolExecution = this.toolExecution;
301
305
  this.Graph = multiAgentGraph;
302
306
  return multiAgentGraph.createWorkflow();
@@ -1399,7 +1403,7 @@ export class Run<_T extends t.BaseGraphState> {
1399
1403
  inputText,
1400
1404
  skipLanguage,
1401
1405
  }),
1402
- }).withConfig({ runName: 'ConvoTransform' });
1406
+ }).withConfig({ runName: 'PrepareTitleInput' });
1403
1407
 
1404
1408
  const titleChain =
1405
1409
  titleMethod === TitleMethod.COMPLETION
@@ -1408,10 +1412,10 @@ export class Run<_T extends t.BaseGraphState> {
1408
1412
 
1409
1413
  /** Pipes `convoTemplate` -> `transformer` -> `titleChain` */
1410
1414
  const fullChain = convoTemplate
1411
- .withConfig({ runName: 'ConvoTemplate' })
1415
+ .withConfig({ runName: 'FormatConversation' })
1412
1416
  .pipe(convoToTitleInput)
1413
1417
  .pipe(titleChain)
1414
- .withConfig({ runName: 'TitleChain' });
1418
+ .withConfig({ runName: 'GenerateConversationTitle' });
1415
1419
 
1416
1420
  const invokeConfig = Object.assign({}, chainOptions, {
1417
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
@@ -135,7 +135,14 @@ function isEagerExecutionExcludedTool(
135
135
  return false;
136
136
  }
137
137
  const excluded = graph.eagerEventToolExecution?.excludeToolNames;
138
- return excluded != null && excluded.includes(name);
138
+ if (excluded != null && excluded.includes(name)) {
139
+ return true;
140
+ }
141
+ // A code-session participant writes to the shared sandbox, so it is
142
+ // side-effecting: never prestart it speculatively (a revised/superseded turn
143
+ // would leave the write applied). Implies exclusion without the host having
144
+ // to also list the name in excludeToolNames.
145
+ return graph.codeSessionToolNames?.includes(name) === true;
139
146
  }
140
147
 
141
148
  function isDirectGraphTool(
@@ -195,7 +202,8 @@ function getCodeSessionContext(
195
202
  if (
196
203
  !CODE_EXECUTION_TOOLS.has(name) &&
197
204
  name !== Constants.SKILL_TOOL &&
198
- name !== Constants.READ_FILE
205
+ name !== Constants.READ_FILE &&
206
+ graph.codeSessionToolNames?.includes(name) !== true
199
207
  ) {
200
208
  return undefined;
201
209
  }
@@ -439,6 +439,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
439
439
  private eventDrivenMode: boolean = false;
440
440
  /** Opt-in stream-layer prestart config for event-driven tools. */
441
441
  private eagerEventToolExecution?: t.EagerEventToolExecutionConfig;
442
+ /** Host tools that write to the code sandbox and share its exec session. */
443
+ private codeSessionToolNames?: ReadonlySet<string>;
442
444
  /** Shared per-run prestarted tool registry populated by ChatModelStreamHandler. */
443
445
  private eagerEventToolExecutions?: Map<string, t.EagerEventToolExecution>;
444
446
  /** Shared per-run per-tool turn counter used by eager and normal event dispatch. */
@@ -515,6 +517,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
515
517
  agentId,
516
518
  executingAgentId,
517
519
  directToolNames,
520
+ codeSessionToolNames,
518
521
  maxContextTokens,
519
522
  maxToolResultChars,
520
523
  hookRegistry,
@@ -552,6 +555,10 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
552
555
  // existing agentId option) still get attribution without knowing the new option.
553
556
  this.executingAgentId = executingAgentId ?? agentId;
554
557
  this.directToolNames = directToolNames;
558
+ this.codeSessionToolNames =
559
+ codeSessionToolNames != null && codeSessionToolNames.length > 0
560
+ ? new Set(codeSessionToolNames)
561
+ : undefined;
555
562
  this.maxToolResultChars =
556
563
  maxToolResultChars ?? calculateMaxToolResultChars(maxContextTokens);
557
564
  this.hookRegistry = hookRegistry;
@@ -973,7 +980,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
973
980
  * still need to travel through `_injected_files`; the legacy
974
981
  * `/files/<session_id>` fallback was removed from the executors.
975
982
  */
976
- if (CODE_EXECUTION_TOOLS.has(call.name)) {
983
+ if (this.participatesInCodeSession(call.name)) {
977
984
  const codeSession = this.sessions?.get(Constants.EXECUTE_CODE) as
978
985
  | t.CodeSessionContext
979
986
  | undefined;
@@ -1759,6 +1766,23 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1759
1766
  * Extracts code execution session context from tool results and stores in Graph.sessions.
1760
1767
  * Mirrors the session storage logic in handleRunToolCompletions for direct execution.
1761
1768
  */
1769
+ /**
1770
+ * True when a tool's successful result should fold its returned exec
1771
+ * `session_id` into the shared code session: built-in `CODE_EXECUTION_TOOLS`,
1772
+ * plus host-declared sandbox-writing tools (`codeSessionToolNames`, e.g.
1773
+ * create_file/edit_file). Kept name-scoped rather than a blanket artifact
1774
+ * opt-in so only host-declared tools can influence the shared session.
1775
+ */
1776
+ private participatesInCodeSession(name: string): boolean {
1777
+ if (name === '') {
1778
+ return false;
1779
+ }
1780
+ return (
1781
+ CODE_EXECUTION_TOOLS.has(name) ||
1782
+ this.codeSessionToolNames?.has(name) === true
1783
+ );
1784
+ }
1785
+
1762
1786
  private storeCodeSessionFromResults(
1763
1787
  results: t.ToolExecuteResult[],
1764
1788
  requestMap: Map<string, t.ToolCallRequest>
@@ -1777,7 +1801,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1777
1801
  if (
1778
1802
  request?.name == null ||
1779
1803
  request.name === '' ||
1780
- (!CODE_EXECUTION_TOOLS.has(request.name) &&
1804
+ (!this.participatesInCodeSession(request.name) &&
1781
1805
  request.name !== Constants.SKILL_TOOL)
1782
1806
  ) {
1783
1807
  continue;
@@ -1830,7 +1854,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
1830
1854
  continue;
1831
1855
  }
1832
1856
 
1833
- if (this.sessions && CODE_EXECUTION_TOOLS.has(call.name)) {
1857
+ if (this.sessions && this.participatesInCodeSession(call.name)) {
1834
1858
  const artifact = toolMessage.artifact as
1835
1859
  | t.CodeExecutionArtifact
1836
1860
  | undefined;
@@ -2463,7 +2487,7 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2463
2487
  const plan = buildToolExecutionRequestPlan({
2464
2488
  toolCalls: approvedEntries.map((entry) => {
2465
2489
  const codeSessionContext =
2466
- CODE_EXECUTION_TOOLS.has(entry.call.name) ||
2490
+ this.participatesInCodeSession(entry.call.name) ||
2467
2491
  entry.call.name === Constants.SKILL_TOOL ||
2468
2492
  entry.call.name === Constants.READ_FILE
2469
2493
  ? this.getCodeSessionContext()