@librechat/agents 3.1.95 → 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 (67) 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/main.cjs +1 -0
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/run.cjs +142 -69
  12. package/dist/cjs/run.cjs.map +1 -1
  13. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +29 -2
  14. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  15. package/dist/cjs/tools/ToolNode.cjs +20 -8
  16. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  17. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +10 -6
  18. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  19. package/dist/esm/graphs/Graph.mjs +56 -23
  20. package/dist/esm/graphs/Graph.mjs.map +1 -1
  21. package/dist/esm/instrumentation.mjs +118 -9
  22. package/dist/esm/instrumentation.mjs.map +1 -1
  23. package/dist/esm/langfuse.mjs +28 -224
  24. package/dist/esm/langfuse.mjs.map +1 -1
  25. package/dist/esm/langfuseToolOutputTracing.mjs +457 -0
  26. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -0
  27. package/dist/esm/main.mjs +1 -1
  28. package/dist/esm/run.mjs +144 -71
  29. package/dist/esm/run.mjs.map +1 -1
  30. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +29 -3
  31. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  32. package/dist/esm/tools/ToolNode.mjs +20 -8
  33. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  34. package/dist/esm/tools/subagent/SubagentExecutor.mjs +10 -6
  35. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  36. package/dist/types/graphs/Graph.d.ts +5 -1
  37. package/dist/types/instrumentation.d.ts +5 -1
  38. package/dist/types/langfuse.d.ts +6 -28
  39. package/dist/types/langfuseToolOutputTracing.d.ts +20 -0
  40. package/dist/types/run.d.ts +5 -1
  41. package/dist/types/tools/BashProgrammaticToolCalling.d.ts +1 -0
  42. package/dist/types/tools/ToolNode.d.ts +4 -1
  43. package/dist/types/tools/subagent/SubagentExecutor.d.ts +2 -0
  44. package/dist/types/types/graph.d.ts +30 -0
  45. package/dist/types/types/run.d.ts +6 -0
  46. package/dist/types/types/tools.d.ts +7 -0
  47. package/package.json +2 -1
  48. package/src/graphs/Graph.ts +90 -34
  49. package/src/instrumentation.ts +172 -11
  50. package/src/langfuse.ts +59 -324
  51. package/src/langfuseToolOutputTracing.ts +683 -0
  52. package/src/run.ts +190 -87
  53. package/src/specs/langfuse-callbacks.test.ts +178 -1
  54. package/src/specs/langfuse-config.test.ts +112 -76
  55. package/src/specs/langfuse-instrumentation.test.ts +283 -0
  56. package/src/specs/langfuse-metadata.test.ts +54 -1
  57. package/src/specs/langfuse-tool-output-tracing.test.ts +588 -0
  58. package/src/tools/BashProgrammaticToolCalling.ts +39 -5
  59. package/src/tools/ToolNode.ts +28 -7
  60. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +54 -0
  61. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +72 -4
  62. package/src/tools/__tests__/SubagentExecutor.test.ts +32 -0
  63. package/src/tools/__tests__/ToolNode.langfuse.test.ts +41 -0
  64. package/src/tools/subagent/SubagentExecutor.ts +11 -6
  65. package/src/types/graph.ts +32 -0
  66. package/src/types/run.ts +6 -0
  67. 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
+ });
@@ -71,7 +71,7 @@ const CORE_RULES = `Rules:
71
71
  - Tools are pre-defined as bash functions—DO NOT redefine them
72
72
  - Each tool function accepts a JSON string argument
73
73
  - Save tool output with raw=$(tool '{}'); printf '%s\n' "$raw" > /mnt/data/file.json; direct tool > file may be empty
74
- - jq: use fromjson? // . on saved tool stdout and again on JSON-string fields; check types since arrays may contain strings
74
+ - Tool stdout is normalized to one compact JSON value when possible; parse saved stdout once, then use fromjson? // . only for JSON-string fields
75
75
  - Only echo/printf output returns to the model
76
76
  - ${CODE_ARTIFACT_PATH_GUIDANCE}
77
77
  - ${BASH_SHELL_GUIDANCE}
@@ -149,6 +149,38 @@ export const BashProgrammaticToolCallingDefinition = {
149
149
  schema: BashProgrammaticToolCallingSchema,
150
150
  } as const;
151
151
 
152
+ function maybeParseJsonResultString(result: unknown): unknown {
153
+ if (typeof result !== 'string') {
154
+ return result;
155
+ }
156
+
157
+ const trimmed = result.trim();
158
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
159
+ return result;
160
+ }
161
+
162
+ try {
163
+ return JSON.parse(trimmed) as unknown;
164
+ } catch {
165
+ return result;
166
+ }
167
+ }
168
+
169
+ export function normalizeBashToolResultsForReplay(
170
+ toolResults: t.PTCToolResult[]
171
+ ): t.PTCToolResult[] {
172
+ return toolResults.map((toolResult) => {
173
+ if (toolResult.is_error) {
174
+ return toolResult;
175
+ }
176
+
177
+ return {
178
+ ...toolResult,
179
+ result: maybeParseJsonResultString(toolResult.result),
180
+ };
181
+ });
182
+ }
183
+
152
184
  // ============================================================================
153
185
  // Helper Functions
154
186
  // ============================================================================
@@ -355,10 +387,12 @@ export function createBashProgrammaticToolCallingTool(
355
387
  );
356
388
  }
357
389
 
358
- const toolResults = await executeTools(
359
- response.tool_calls ?? [],
360
- toolMap,
361
- Constants.BASH_PROGRAMMATIC_TOOL_CALLING
390
+ const toolResults = normalizeBashToolResultsForReplay(
391
+ await executeTools(
392
+ response.tool_calls ?? [],
393
+ toolMap,
394
+ Constants.BASH_PROGRAMMATIC_TOOL_CALLING
395
+ )
362
396
  );
363
397
 
364
398
  response = await makeRequest(