@librechat/agents 3.1.96 → 3.1.97

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 (56) hide show
  1. package/dist/cjs/graphs/Graph.cjs +54 -21
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/instrumentation.cjs +120 -9
  4. package/dist/cjs/instrumentation.cjs.map +1 -1
  5. package/dist/cjs/langfuse.cjs +30 -226
  6. package/dist/cjs/langfuse.cjs.map +1 -1
  7. package/dist/cjs/langfuseToolOutputTracing.cjs +465 -0
  8. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -0
  9. package/dist/cjs/run.cjs +142 -69
  10. package/dist/cjs/run.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +20 -8
  12. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  13. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +10 -6
  14. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  15. package/dist/esm/graphs/Graph.mjs +56 -23
  16. package/dist/esm/graphs/Graph.mjs.map +1 -1
  17. package/dist/esm/instrumentation.mjs +118 -9
  18. package/dist/esm/instrumentation.mjs.map +1 -1
  19. package/dist/esm/langfuse.mjs +28 -224
  20. package/dist/esm/langfuse.mjs.map +1 -1
  21. package/dist/esm/langfuseToolOutputTracing.mjs +457 -0
  22. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -0
  23. package/dist/esm/run.mjs +144 -71
  24. package/dist/esm/run.mjs.map +1 -1
  25. package/dist/esm/tools/ToolNode.mjs +20 -8
  26. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  27. package/dist/esm/tools/subagent/SubagentExecutor.mjs +10 -6
  28. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  29. package/dist/types/graphs/Graph.d.ts +5 -1
  30. package/dist/types/instrumentation.d.ts +5 -1
  31. package/dist/types/langfuse.d.ts +6 -28
  32. package/dist/types/langfuseToolOutputTracing.d.ts +20 -0
  33. package/dist/types/run.d.ts +5 -1
  34. package/dist/types/tools/ToolNode.d.ts +4 -1
  35. package/dist/types/tools/subagent/SubagentExecutor.d.ts +2 -0
  36. package/dist/types/types/graph.d.ts +30 -0
  37. package/dist/types/types/run.d.ts +6 -0
  38. package/dist/types/types/tools.d.ts +7 -0
  39. package/package.json +2 -1
  40. package/src/graphs/Graph.ts +90 -34
  41. package/src/instrumentation.ts +172 -11
  42. package/src/langfuse.ts +59 -324
  43. package/src/langfuseToolOutputTracing.ts +683 -0
  44. package/src/run.ts +190 -87
  45. package/src/specs/langfuse-callbacks.test.ts +178 -1
  46. package/src/specs/langfuse-config.test.ts +112 -76
  47. package/src/specs/langfuse-instrumentation.test.ts +283 -0
  48. package/src/specs/langfuse-metadata.test.ts +54 -1
  49. package/src/specs/langfuse-tool-output-tracing.test.ts +588 -0
  50. package/src/tools/ToolNode.ts +28 -7
  51. package/src/tools/__tests__/SubagentExecutor.test.ts +32 -0
  52. package/src/tools/__tests__/ToolNode.langfuse.test.ts +41 -0
  53. package/src/tools/subagent/SubagentExecutor.ts +11 -6
  54. package/src/types/graph.ts +32 -0
  55. package/src/types/run.ts +6 -0
  56. package/src/types/tools.ts +7 -0
@@ -0,0 +1,588 @@
1
+ import { AIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages';
2
+ import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
3
+ import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
4
+ import type { BaseMessage } from '@langchain/core/messages';
5
+ import type { TPayload } from '@/types';
6
+ import {
7
+ LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT,
8
+ redactLangfuseSpanToolOutputs,
9
+ resolveLangfuseConfig,
10
+ shouldTraceToolNodeForLangfuse,
11
+ type ResolvedLangfuseToolOutputTracingConfig,
12
+ } from '@/langfuseToolOutputTracing';
13
+ import { formatAgentMessages } from '@/messages/format';
14
+ import { ContentTypes } from '@/common';
15
+
16
+ type SerializedLangfuseChatMessage = {
17
+ content: BaseMessage['content'];
18
+ role?: string;
19
+ additional_kwargs?: BaseMessage['additional_kwargs'];
20
+ tool_calls?:
21
+ | NonNullable<AIMessage['tool_calls']>
22
+ | NonNullable<BaseMessage['additional_kwargs']['tool_calls']>;
23
+ };
24
+
25
+ type RedactedMessage = {
26
+ role?: string;
27
+ content?: string;
28
+ tool_calls?: Array<{
29
+ id?: string;
30
+ name?: string;
31
+ args?: {
32
+ query?: string;
33
+ };
34
+ }>;
35
+ };
36
+
37
+ function createSpan(
38
+ name: string,
39
+ attributes: Record<string, unknown>
40
+ ): ReadableSpan {
41
+ return { name, attributes } as unknown as ReadableSpan;
42
+ }
43
+
44
+ function createConfig(
45
+ overrides: Partial<ResolvedLangfuseToolOutputTracingConfig> = {}
46
+ ): ResolvedLangfuseToolOutputTracingConfig {
47
+ return {
48
+ enabled: true,
49
+ redactedToolNames: new Set<string>(),
50
+ redactedToolNameMatchMode: 'exact',
51
+ redactionText: LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT,
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ function serializeMessageForLangfuse(
57
+ message: BaseMessage
58
+ ): SerializedLangfuseChatMessage {
59
+ if (message instanceof HumanMessage) {
60
+ return { content: message.content, role: 'user' };
61
+ }
62
+
63
+ if (message instanceof AIMessage) {
64
+ const response: SerializedLangfuseChatMessage = {
65
+ content: message.content,
66
+ role: 'assistant',
67
+ };
68
+ if (message.tool_calls != null && message.tool_calls.length > 0) {
69
+ response.tool_calls = message.tool_calls;
70
+ }
71
+ if (message.additional_kwargs.tool_calls != null) {
72
+ response.tool_calls = message.additional_kwargs.tool_calls;
73
+ }
74
+ return response;
75
+ }
76
+
77
+ if (message instanceof ToolMessage) {
78
+ return {
79
+ content: message.content,
80
+ additional_kwargs: message.additional_kwargs,
81
+ role: message.name,
82
+ };
83
+ }
84
+
85
+ return message.name != null
86
+ ? { content: message.content, role: message.name }
87
+ : { content: message.content };
88
+ }
89
+
90
+ function readJsonAttribute<T>(span: ReadableSpan, key: string): T {
91
+ return JSON.parse(span.attributes[key] as string) as T;
92
+ }
93
+
94
+ describe('Langfuse tool output tracing redaction', () => {
95
+ const originalEnv = process.env;
96
+
97
+ beforeEach(() => {
98
+ process.env = { ...originalEnv };
99
+ });
100
+
101
+ afterEach(() => {
102
+ process.env = originalEnv;
103
+ });
104
+
105
+ it('enables ToolNode tracing only when Langfuse is active by default', () => {
106
+ delete process.env.LANGFUSE_SECRET_KEY;
107
+ delete process.env.LANGFUSE_PUBLIC_KEY;
108
+ delete process.env.LANGFUSE_BASE_URL;
109
+
110
+ expect(shouldTraceToolNodeForLangfuse({})).toBe(false);
111
+ expect(
112
+ shouldTraceToolNodeForLangfuse({
113
+ runLangfuse: {
114
+ enabled: true,
115
+ publicKey: 'pk-run',
116
+ secretKey: 'sk-run',
117
+ },
118
+ })
119
+ ).toBe(true);
120
+ expect(
121
+ shouldTraceToolNodeForLangfuse({
122
+ agentLangfuse: {
123
+ enabled: true,
124
+ publicKey: 'pk-agent',
125
+ secretKey: 'sk-agent',
126
+ baseUrl: 'https://langfuse.test',
127
+ toolNodeTracing: { enabled: true },
128
+ },
129
+ })
130
+ ).toBe(true);
131
+
132
+ process.env.LANGFUSE_SECRET_KEY = 'sk-test';
133
+ process.env.LANGFUSE_PUBLIC_KEY = 'pk-test';
134
+ process.env.LANGFUSE_BASE_URL = 'https://langfuse.test';
135
+
136
+ expect(shouldTraceToolNodeForLangfuse({})).toBe(true);
137
+ expect(
138
+ shouldTraceToolNodeForLangfuse({
139
+ runLangfuse: { toolNodeTracing: { enabled: true } },
140
+ })
141
+ ).toBe(true);
142
+ expect(
143
+ shouldTraceToolNodeForLangfuse({
144
+ runLangfuse: { toolNodeTracing: { enabled: false } },
145
+ })
146
+ ).toBe(false);
147
+ });
148
+
149
+ it('lets agent Langfuse enablement override disabled run defaults for ToolNode tracing', () => {
150
+ delete process.env.LANGFUSE_SECRET_KEY;
151
+ delete process.env.LANGFUSE_PUBLIC_KEY;
152
+ delete process.env.LANGFUSE_BASE_URL;
153
+
154
+ expect(
155
+ shouldTraceToolNodeForLangfuse({
156
+ runLangfuse: {
157
+ enabled: false,
158
+ },
159
+ agentLangfuse: {
160
+ enabled: true,
161
+ publicKey: 'pk-agent',
162
+ secretKey: 'sk-agent',
163
+ baseUrl: 'https://langfuse.test',
164
+ },
165
+ })
166
+ ).toBe(true);
167
+ });
168
+
169
+ it('keeps ToolNode tracing disabled when resolved Langfuse is disabled', () => {
170
+ process.env.LANGFUSE_SECRET_KEY = 'sk-test';
171
+ process.env.LANGFUSE_PUBLIC_KEY = 'pk-test';
172
+
173
+ expect(
174
+ shouldTraceToolNodeForLangfuse({
175
+ runLangfuse: {
176
+ enabled: false,
177
+ toolNodeTracing: { enabled: true },
178
+ },
179
+ })
180
+ ).toBe(false);
181
+ });
182
+
183
+ it('redacts raw tool observation output when tool output tracing is disabled', () => {
184
+ const span = createSpan('execute_sql', {
185
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'tool',
186
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: '{"query":"select 1"}',
187
+ [LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]: 'secret rows',
188
+ });
189
+
190
+ redactLangfuseSpanToolOutputs(span, createConfig({ enabled: false }));
191
+
192
+ expect(span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]).toBe(
193
+ LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT
194
+ );
195
+ expect(span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT]).toBe(
196
+ '{"query":"select 1"}'
197
+ );
198
+ });
199
+
200
+ it('redacts ToolMessage content inside serialized generation inputs', () => {
201
+ const messages = [
202
+ { role: 'user', content: 'show tables' },
203
+ {
204
+ role: 'execute_sql',
205
+ content: 'private query result',
206
+ additional_kwargs: {},
207
+ },
208
+ ];
209
+ const span = createSpan('gpt-4o', {
210
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'generation',
211
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: JSON.stringify(messages),
212
+ });
213
+
214
+ redactLangfuseSpanToolOutputs(span, createConfig({ enabled: false }));
215
+
216
+ const redacted = JSON.parse(
217
+ span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] as string
218
+ ) as Array<{ role: string; content: string }>;
219
+ expect(redacted[0].content).toBe('show tables');
220
+ expect(redacted[1].content).toBe(LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT);
221
+ });
222
+
223
+ it('redacts only configured tool names when output tracing stays enabled', () => {
224
+ const messages = [
225
+ { role: 'execute_sql', content: 'private query result' },
226
+ { role: 'bash', content: 'public build log' },
227
+ ];
228
+ const span = createSpan('LangGraph', {
229
+ [LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]: JSON.stringify({
230
+ messages,
231
+ }),
232
+ });
233
+
234
+ redactLangfuseSpanToolOutputs(
235
+ span,
236
+ createConfig({
237
+ redactedToolNames: new Set(['execute_sql']),
238
+ })
239
+ );
240
+
241
+ const redacted = JSON.parse(
242
+ span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] as string
243
+ ) as { messages: Array<{ role: string; content: string }> };
244
+ expect(redacted.messages[0].content).toBe(
245
+ LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT
246
+ );
247
+ expect(redacted.messages[1].content).toBe('public build log');
248
+ });
249
+
250
+ it('uses nested ToolMessage names instead of generic tool role', () => {
251
+ const messages = [
252
+ {
253
+ role: 'tool',
254
+ content: 'private query result',
255
+ kwargs: {
256
+ name: 'execute_sql',
257
+ tool_call_id: 'call_1',
258
+ },
259
+ },
260
+ ];
261
+ const span = createSpan('gpt-4o', {
262
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'generation',
263
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: JSON.stringify(messages),
264
+ });
265
+
266
+ redactLangfuseSpanToolOutputs(
267
+ span,
268
+ createConfig({
269
+ redactedToolNames: new Set(['execute_sql']),
270
+ })
271
+ );
272
+
273
+ const redacted = readJsonAttribute<Array<{ content: string }>>(
274
+ span,
275
+ LangfuseOtelSpanAttributes.OBSERVATION_INPUT
276
+ );
277
+ expect(redacted[0].content).toBe(LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT);
278
+ });
279
+
280
+ it('maps tool_call_id to the preceding tool call name for allowlisted redaction', () => {
281
+ const messages = [
282
+ {
283
+ role: 'assistant',
284
+ content: '',
285
+ tool_calls: [
286
+ {
287
+ id: 'call_sql',
288
+ name: 'execute_sql',
289
+ args: { query: 'select * from private_table' },
290
+ },
291
+ ],
292
+ },
293
+ {
294
+ role: 'tool',
295
+ tool_call_id: 'call_sql',
296
+ content: 'sensitive row output',
297
+ },
298
+ {
299
+ role: 'tool',
300
+ tool_call_id: 'call_bash',
301
+ content: 'public build log',
302
+ },
303
+ ];
304
+ const span = createSpan('gpt-4o', {
305
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'generation',
306
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: JSON.stringify(messages),
307
+ });
308
+
309
+ redactLangfuseSpanToolOutputs(
310
+ span,
311
+ createConfig({
312
+ redactedToolNames: new Set(['execute_sql']),
313
+ })
314
+ );
315
+
316
+ const redacted = readJsonAttribute<RedactedMessage[]>(
317
+ span,
318
+ LangfuseOtelSpanAttributes.OBSERVATION_INPUT
319
+ );
320
+ expect(redacted[0].tool_calls?.[0]?.args?.query).toBe(
321
+ 'select * from private_table'
322
+ );
323
+ expect(redacted[1].content).toBe(LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT);
324
+ expect(redacted[2].content).toBe('public build log');
325
+ });
326
+
327
+ it('does not redact partial tool name matches by default', () => {
328
+ const span = createSpan('clickhouse_execute_sql_prod', {
329
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'tool',
330
+ [LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]: 'secret rows',
331
+ });
332
+
333
+ redactLangfuseSpanToolOutputs(
334
+ span,
335
+ createConfig({
336
+ redactedToolNames: new Set(['execute_sql']),
337
+ })
338
+ );
339
+
340
+ expect(span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]).toBe(
341
+ 'secret rows'
342
+ );
343
+ });
344
+
345
+ it('redacts configured partial tool name matches when enabled', () => {
346
+ const span = createSpan('clickhouse_execute_sql_prod', {
347
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'tool',
348
+ [LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]: 'secret rows',
349
+ });
350
+
351
+ redactLangfuseSpanToolOutputs(
352
+ span,
353
+ createConfig({
354
+ redactedToolNames: new Set(['execute_sql']),
355
+ redactedToolNameMatchMode: 'partial',
356
+ })
357
+ );
358
+
359
+ expect(span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT]).toBe(
360
+ LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT
361
+ );
362
+ });
363
+
364
+ it('redacts prior tool outputs from multi-turn generation inputs', () => {
365
+ const messages = [
366
+ { role: 'user', content: 'run the query' },
367
+ {
368
+ role: 'assistant',
369
+ content: '',
370
+ tool_calls: [
371
+ {
372
+ id: 'call_sql',
373
+ name: 'execute_sql',
374
+ args: { query: 'select * from private_table' },
375
+ },
376
+ ],
377
+ },
378
+ {
379
+ role: 'execute_sql',
380
+ content: 'sensitive row output',
381
+ additional_kwargs: {},
382
+ },
383
+ { role: 'assistant', content: 'I found the answer.' },
384
+ { role: 'user', content: 'explain the first row' },
385
+ ];
386
+ const span = createSpan('gpt-4o', {
387
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'generation',
388
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: JSON.stringify(messages),
389
+ });
390
+
391
+ redactLangfuseSpanToolOutputs(span, createConfig({ enabled: false }));
392
+
393
+ const redacted = readJsonAttribute<RedactedMessage[]>(
394
+ span,
395
+ LangfuseOtelSpanAttributes.OBSERVATION_INPUT
396
+ );
397
+ expect(redacted[0].content).toBe('run the query');
398
+ expect(redacted[1].tool_calls?.[0]?.args?.query).toBe(
399
+ 'select * from private_table'
400
+ );
401
+ expect(redacted[2].content).toBe(LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT);
402
+ expect(redacted[3].content).toBe('I found the answer.');
403
+ expect(redacted[4].content).toBe('explain the first row');
404
+ });
405
+
406
+ it('redacts tool outputs after formatAgentMessages rehydrates content parts', () => {
407
+ const payload: TPayload = [
408
+ { role: 'user', content: 'show me the private numbers' },
409
+ {
410
+ role: 'assistant',
411
+ content: [
412
+ {
413
+ type: ContentTypes.TEXT,
414
+ [ContentTypes.TEXT]: 'I will query ClickHouse.',
415
+ tool_call_ids: ['call_sql'],
416
+ },
417
+ {
418
+ type: ContentTypes.TOOL_CALL,
419
+ tool_call: {
420
+ id: 'call_sql',
421
+ name: 'execute_sql',
422
+ args: '{"query":"select secret_value from prod"}',
423
+ output: 'secret_value: 12345',
424
+ },
425
+ },
426
+ ],
427
+ },
428
+ { role: 'user', content: 'can you summarize it?' },
429
+ ];
430
+ const { messages } = formatAgentMessages(
431
+ payload,
432
+ undefined,
433
+ new Set(['execute_sql'])
434
+ );
435
+ const serialized = messages.map(serializeMessageForLangfuse);
436
+ const span = createSpan('gpt-4o', {
437
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'generation',
438
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]:
439
+ JSON.stringify(serialized),
440
+ });
441
+
442
+ redactLangfuseSpanToolOutputs(
443
+ span,
444
+ createConfig({
445
+ redactedToolNames: new Set(['execute_sql']),
446
+ })
447
+ );
448
+
449
+ const redacted = readJsonAttribute<RedactedMessage[]>(
450
+ span,
451
+ LangfuseOtelSpanAttributes.OBSERVATION_INPUT
452
+ );
453
+ expect(redacted[1].tool_calls?.[0]?.args?.query).toBe(
454
+ 'select secret_value from prod'
455
+ );
456
+ expect(redacted[2].role).toBe('execute_sql');
457
+ expect(redacted[2].content).toBe(LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT);
458
+ expect(JSON.stringify(redacted)).not.toContain('secret_value: 12345');
459
+ });
460
+
461
+ it('redacts constructor-serialized ToolMessages from rehydrated content parts', () => {
462
+ const payload: TPayload = [
463
+ { role: 'user', content: 'show the stored result' },
464
+ {
465
+ role: 'assistant',
466
+ content: [
467
+ {
468
+ type: ContentTypes.TEXT,
469
+ [ContentTypes.TEXT]: 'I will query ClickHouse.',
470
+ tool_call_ids: ['call_sql'],
471
+ },
472
+ {
473
+ type: ContentTypes.TOOL_CALL,
474
+ tool_call: {
475
+ id: 'call_sql',
476
+ name: 'execute_sql',
477
+ args: '{"query":"select constructor_path from prod"}',
478
+ output: 'constructor path secret',
479
+ },
480
+ },
481
+ ],
482
+ },
483
+ ];
484
+ const { messages } = formatAgentMessages(
485
+ payload,
486
+ undefined,
487
+ new Set(['execute_sql'])
488
+ );
489
+ const span = createSpan('gpt-4o', {
490
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'generation',
491
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: JSON.stringify([
492
+ messages,
493
+ ]),
494
+ });
495
+
496
+ redactLangfuseSpanToolOutputs(
497
+ span,
498
+ createConfig({
499
+ redactedToolNames: new Set(['execute_sql']),
500
+ })
501
+ );
502
+
503
+ const redacted = span.attributes[
504
+ LangfuseOtelSpanAttributes.OBSERVATION_INPUT
505
+ ] as string;
506
+ expect(redacted).toContain('select constructor_path from prod');
507
+ expect(redacted).toContain(LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT);
508
+ expect(redacted).not.toContain('constructor path secret');
509
+ });
510
+
511
+ it('redacts ToolMessage artifacts because they are tool output', () => {
512
+ const messages = [
513
+ {
514
+ id: ['langchain_core', 'messages', 'ToolMessage'],
515
+ kwargs: {
516
+ name: 'execute_sql',
517
+ tool_call_id: 'call_sql',
518
+ content: 'safe display content',
519
+ artifact: {
520
+ rows: ['artifact secret row'],
521
+ },
522
+ },
523
+ },
524
+ ];
525
+ const span = createSpan('gpt-4o', {
526
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'generation',
527
+ [LangfuseOtelSpanAttributes.OBSERVATION_INPUT]: JSON.stringify(messages),
528
+ });
529
+
530
+ redactLangfuseSpanToolOutputs(
531
+ span,
532
+ createConfig({
533
+ redactedToolNames: new Set(['execute_sql']),
534
+ })
535
+ );
536
+
537
+ const redacted = readJsonAttribute<
538
+ Array<{
539
+ kwargs: {
540
+ artifact: string;
541
+ content: string;
542
+ };
543
+ }>
544
+ >(span, LangfuseOtelSpanAttributes.OBSERVATION_INPUT);
545
+ expect(redacted[0].kwargs.content).toBe(
546
+ LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT
547
+ );
548
+ expect(redacted[0].kwargs.artifact).toBe(
549
+ LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT
550
+ );
551
+ expect(JSON.stringify(redacted)).not.toContain('artifact secret row');
552
+ });
553
+
554
+ it('merges run Langfuse defaults with agent redaction overrides', () => {
555
+ const resolved = resolveLangfuseConfig(
556
+ {
557
+ enabled: true,
558
+ publicKey: 'pk-run',
559
+ secretKey: 'sk-run',
560
+ baseUrl: 'https://langfuse.test',
561
+ toolNodeTracing: { enabled: true },
562
+ toolOutputTracing: {
563
+ enabled: true,
564
+ redactionText: '[redacted]',
565
+ },
566
+ },
567
+ {
568
+ toolOutputTracing: {
569
+ enabled: false,
570
+ redactedToolNames: ['execute_sql'],
571
+ },
572
+ }
573
+ );
574
+
575
+ expect(resolved).toMatchObject({
576
+ enabled: true,
577
+ publicKey: 'pk-run',
578
+ secretKey: 'sk-run',
579
+ baseUrl: 'https://langfuse.test',
580
+ toolNodeTracing: { enabled: true },
581
+ toolOutputTracing: {
582
+ enabled: false,
583
+ redactedToolNames: ['execute_sql'],
584
+ redactionText: '[redacted]',
585
+ },
586
+ });
587
+ });
588
+ });
@@ -41,6 +41,7 @@ import {
41
41
  import { safeDispatchCustomEvent } from '@/utils/events';
42
42
  import { executeHooks } from '@/hooks';
43
43
  import { toLangChainContent } from '@/messages/langchain';
44
+ import { withLangfuseToolOutputTracingConfig } from '@/langfuseToolOutputTracing';
44
45
  import { Constants, GraphEvents, CODE_EXECUTION_TOOLS } from '@/common';
45
46
  import {
46
47
  buildReferenceKey,
@@ -394,6 +395,8 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
394
395
  private loadRuntimeTools?: t.ToolRefGenerator;
395
396
  handleToolErrors = true;
396
397
  trace = false;
398
+ private runLangfuse?: t.LangfuseConfig;
399
+ private agentLangfuse?: t.LangfuseConfig;
397
400
  toolCallStepIds?: Map<string, string>;
398
401
  errorHandler?: t.ToolNodeConstructorParams['errorHandler'];
399
402
  private toolUsageCount: Map<string, number>;
@@ -473,6 +476,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
473
476
  toolMap,
474
477
  name,
475
478
  tags,
479
+ trace,
480
+ runLangfuse,
481
+ agentLangfuse,
476
482
  errorHandler,
477
483
  toolCallStepIds,
478
484
  handleToolErrors,
@@ -495,6 +501,9 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
495
501
  fileCheckpointer,
496
502
  }: t.ToolNodeConstructorParams) {
497
503
  super({ name, tags, func: (input, config) => this.run(input, config) });
504
+ this.trace = trace ?? this.trace;
505
+ this.runLangfuse = runLangfuse;
506
+ this.agentLangfuse = agentLangfuse;
498
507
  this.toolMap = toolMap ?? new Map(tools.map((tool) => [tool.name, tool]));
499
508
  this.toolCallStepIds = toolCallStepIds;
500
509
  this.handleToolErrors = handleToolErrors ?? this.handleToolErrors;
@@ -545,6 +554,19 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
545
554
  }
546
555
  }
547
556
 
557
+ override async invoke(
558
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
559
+ input: any,
560
+ options?: Partial<RunnableConfig>
561
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
562
+ ): Promise<any> {
563
+ return withLangfuseToolOutputTracingConfig(
564
+ this.runLangfuse,
565
+ () => super.invoke(input, options),
566
+ this.agentLangfuse
567
+ );
568
+ }
569
+
548
570
  /**
549
571
  * Returns the run-scoped tool output registry, or `undefined` when
550
572
  * the feature is disabled.
@@ -2140,13 +2162,12 @@ export class ToolNode<T = any> extends RunnableCallable<T, T> {
2140
2162
 
2141
2163
  /**
2142
2164
  * `interrupt()` reads the current `RunnableConfig` from
2143
- * AsyncLocalStorage, but our `RunnableCallable` sets
2144
- * `trace = false` for ToolNode (intentional avoids LangSmith
2145
- * tracing per tool call). Without the trace path, the upstream
2146
- * `runWithConfig` frame is never established, so we re-anchor
2147
- * here using the node's own `config` Pregel hands us a
2148
- * config that already carries every checkpoint/scratchpad key
2149
- * `interrupt()` needs to suspend and resume.
2165
+ * AsyncLocalStorage. ToolNode usually runs with tracing disabled
2166
+ * (unless Langfuse explicitly enables it), so the upstream
2167
+ * `runWithConfig` frame may not exist. Re-anchor here using the
2168
+ * node's own `config` Pregel hands us a config that already
2169
+ * carries every checkpoint/scratchpad key `interrupt()` needs to
2170
+ * suspend and resume.
2150
2171
  */
2151
2172
  const resumeValue = AsyncLocalStorageProviderSingleton.runWithConfig(
2152
2173
  config,
@@ -424,6 +424,38 @@ describe('SubagentExecutor', () => {
424
424
  expect(clearHeavyState).toHaveBeenCalled();
425
425
  });
426
426
 
427
+ it('passes parent Langfuse config to the child graph', async () => {
428
+ const langfuse = {
429
+ enabled: true,
430
+ publicKey: 'pk-run',
431
+ secretKey: 'sk-run',
432
+ baseUrl: 'https://langfuse.test',
433
+ toolOutputTracing: { enabled: false },
434
+ };
435
+ let observedLangfuse: typeof langfuse | undefined;
436
+ const executor = createExecutor({
437
+ langfuse,
438
+ createChildGraph: (input): StandardGraph => {
439
+ observedLangfuse = input.langfuse as typeof langfuse;
440
+ return {
441
+ createWorkflow: (): { invoke: jest.Mock } => ({
442
+ invoke: jest.fn().mockResolvedValue({
443
+ messages: [new AIMessage('child done')],
444
+ }),
445
+ }),
446
+ clearHeavyState: jest.fn(),
447
+ } as unknown as StandardGraph;
448
+ },
449
+ });
450
+
451
+ await executor.execute({
452
+ description: 'Research this topic',
453
+ subagentType: 'researcher',
454
+ });
455
+
456
+ expect(observedLangfuse).toBe(langfuse);
457
+ });
458
+
427
459
  it('returns error message when child graph throws', async () => {
428
460
  const executor = createExecutor({
429
461
  createChildGraph: makeThrowingGraphFactory(