@librechat/agents 3.3.9 → 3.3.11

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 (61) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +4 -0
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +21 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
  6. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  7. package/dist/cjs/llm/init.cjs +1 -1
  8. package/dist/cjs/llm/invoke.cjs +14 -7
  9. package/dist/cjs/llm/invoke.cjs.map +1 -1
  10. package/dist/cjs/llm/openai/index.cjs +188 -11
  11. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  12. package/dist/cjs/main.cjs +4 -3
  13. package/dist/cjs/messages/core.cjs +592 -27
  14. package/dist/cjs/messages/core.cjs.map +1 -1
  15. package/dist/cjs/run.cjs +11 -1
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/stream.cjs +2 -2
  18. package/dist/cjs/tools/ToolNode.cjs +1 -1
  19. package/dist/cjs/tools/search/tool.cjs +1 -1
  20. package/dist/cjs/utils/index.cjs +1 -1
  21. package/dist/esm/agents/AgentContext.mjs +4 -0
  22. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  23. package/dist/esm/graphs/Graph.mjs +21 -2
  24. package/dist/esm/graphs/Graph.mjs.map +1 -1
  25. package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
  26. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  27. package/dist/esm/llm/init.mjs +1 -1
  28. package/dist/esm/llm/invoke.mjs +14 -7
  29. package/dist/esm/llm/invoke.mjs.map +1 -1
  30. package/dist/esm/llm/openai/index.mjs +190 -13
  31. package/dist/esm/llm/openai/index.mjs.map +1 -1
  32. package/dist/esm/main.mjs +5 -5
  33. package/dist/esm/messages/core.mjs +592 -28
  34. package/dist/esm/messages/core.mjs.map +1 -1
  35. package/dist/esm/run.mjs +11 -1
  36. package/dist/esm/run.mjs.map +1 -1
  37. package/dist/esm/stream.mjs +2 -2
  38. package/dist/esm/tools/ToolNode.mjs +1 -1
  39. package/dist/esm/tools/search/tool.mjs +1 -1
  40. package/dist/esm/utils/index.mjs +1 -1
  41. package/dist/types/agents/AgentContext.d.ts +2 -0
  42. package/dist/types/graphs/Graph.d.ts +5 -0
  43. package/dist/types/langfuseToolOutputTracing.d.ts +1 -0
  44. package/dist/types/llm/invoke.d.ts +1 -1
  45. package/dist/types/messages/core.d.ts +11 -6
  46. package/dist/types/run.d.ts +7 -0
  47. package/package.json +1 -1
  48. package/src/agents/AgentContext.ts +5 -0
  49. package/src/graphs/Graph.ts +39 -0
  50. package/src/langfuseToolOutputTracing.ts +410 -14
  51. package/src/llm/custom-chat-models.smoke.test.ts +747 -0
  52. package/src/llm/invoke.test.ts +98 -0
  53. package/src/llm/invoke.ts +34 -23
  54. package/src/llm/openai/index.ts +334 -25
  55. package/src/llm/openai/llm.spec.ts +107 -6
  56. package/src/messages/core.ts +1290 -42
  57. package/src/messages/formatAgentMessages.test.ts +2623 -0
  58. package/src/run.ts +15 -0
  59. package/src/specs/discovered-tools.test.ts +217 -0
  60. package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
  61. package/src/specs/preemptSeal.test.ts +374 -5
@@ -102,6 +102,13 @@ export declare class Run<_T extends t.BaseGraphState> {
102
102
  private runPreStreamHooks;
103
103
  static create<T extends t.BaseGraphState>(config: t.RunConfig): Promise<Run<T>>;
104
104
  getRunMessages(): BaseMessage[] | undefined;
105
+ /**
106
+ * Returns a defensive snapshot of tools discovered by the current run.
107
+ * Pass an agent id for that context, or omit it for the ordered union across
108
+ * contexts. Interrupted state is available immediately for host persistence;
109
+ * completed runs retain their final snapshot through graph cleanup.
110
+ */
111
+ getDiscoveredTools(agentId?: string): string[];
105
112
  /**
106
113
  * Returns the current calibration ratio (EMA of provider-vs-estimate token ratios).
107
114
  * Hosts should persist this value and pass it back as `RunConfig.calibrationRatio`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.3.9",
3
+ "version": "3.3.11",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -1708,6 +1708,11 @@ export class AgentContext {
1708
1708
  this.totalTokensFresh = false;
1709
1709
  }
1710
1710
 
1711
+ /** Returns a snapshot of the deferred tools discovered in this context. */
1712
+ getDiscoveredTools(): string[] {
1713
+ return Array.from(this.discoveredToolNames);
1714
+ }
1715
+
1711
1716
  /**
1712
1717
  * Marks tools as discovered via tool search.
1713
1718
  * Discovered tools will be included in the next model binding.
@@ -643,6 +643,10 @@ export abstract class Graph<
643
643
  currentToolMap?: t.ToolMap;
644
644
  }): CustomToolNode<T> | ToolNode<T>;
645
645
  abstract getRunMessages(): BaseMessage[] | undefined;
646
+ /** Returns a snapshot of deferred tools discovered by this graph. */
647
+ getDiscoveredTools(_agentId?: string): string[] {
648
+ return [];
649
+ }
646
650
  abstract getContentParts(): t.MessageContentComplex[] | undefined;
647
651
  abstract generateStepId(stepKey: string): [string, number];
648
652
  abstract getKeyList(
@@ -1006,6 +1010,8 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1006
1010
  messages: BaseMessage[] = [];
1007
1011
  /** Cached run messages preserved before clearHeavyState() so getRunMessages() works after cleanup. */
1008
1012
  private cachedRunMessages?: BaseMessage[];
1013
+ /** Per-agent discovery snapshots preserved before contexts are reset on cleanup. */
1014
+ private cachedDiscoveredTools?: Map<string, string[]>;
1009
1015
  /** Ids of AI turns the agent node returned THIS run; see isRunProducedMessage. */
1010
1016
  protected runProducedAiMessageIds = new Set<string>();
1011
1017
  /** Checkpoint scope whose messages match index-keyed tool snapshots. */
@@ -1137,6 +1143,7 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1137
1143
  resetValues(keepContent?: boolean, checkpointScope?: string): void {
1138
1144
  this.messages = [];
1139
1145
  this.cachedRunMessages = undefined;
1146
+ this.cachedDiscoveredTools = undefined;
1140
1147
  this.config = resetIfNotEmpty(this.config, undefined);
1141
1148
  if (keepContent !== true) {
1142
1149
  this.contentData = resetIfNotEmpty(this.contentData, []);
@@ -1203,6 +1210,12 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1203
1210
 
1204
1211
  override clearHeavyState(): void {
1205
1212
  this.cachedRunMessages = this.messages.slice(this.startIndex);
1213
+ this.cachedDiscoveredTools = new Map(
1214
+ Array.from(this.agentContexts, ([agentId, context]) => [
1215
+ agentId,
1216
+ context.getDiscoveredTools(),
1217
+ ])
1218
+ );
1206
1219
  super.clearHeavyState();
1207
1220
  this.messages = [];
1208
1221
  this.overrideModel = undefined;
@@ -1497,6 +1510,32 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
1497
1510
  return this.messages.slice(this.startIndex);
1498
1511
  }
1499
1512
 
1513
+ override getDiscoveredTools(agentId?: string): string[] {
1514
+ if (agentId != null) {
1515
+ const current =
1516
+ this.agentContexts.get(agentId)?.getDiscoveredTools() ?? [];
1517
+ if (current.length > 0 || this.cachedDiscoveredTools == null) {
1518
+ return current;
1519
+ }
1520
+ return [...(this.cachedDiscoveredTools.get(agentId) ?? [])];
1521
+ }
1522
+
1523
+ const discoveredTools = new Set<string>();
1524
+ for (const context of this.agentContexts.values()) {
1525
+ for (const toolName of context.getDiscoveredTools()) {
1526
+ discoveredTools.add(toolName);
1527
+ }
1528
+ }
1529
+ if (discoveredTools.size === 0 && this.cachedDiscoveredTools != null) {
1530
+ for (const snapshot of this.cachedDiscoveredTools.values()) {
1531
+ for (const toolName of snapshot) {
1532
+ discoveredTools.add(toolName);
1533
+ }
1534
+ }
1535
+ }
1536
+ return Array.from(discoveredTools);
1537
+ }
1538
+
1500
1539
  /**
1501
1540
  * True when THIS RUN produced `message` — the provenance the handoff cue
1502
1541
  * gate needs. Tracked as an id set rather than inferred from `startIndex`
@@ -25,6 +25,8 @@ import { resolveToolOutputTracingConfigForSpan } from '@/langfuseRuntimeScope';
25
25
  export { LANGFUSE_TOOL_OUTPUT_REDACTION_TEXT, resolveLangfuseConfig };
26
26
 
27
27
  const LANGGRAPH_TOOL_NODE_PREFIX = 'tools=';
28
+ const SERVER_TOOL_RESULT_PREFIX = '{"serverToolResult":';
29
+ const SERVER_TOOL_RESULT_REPLAY_MARKER_KEY = 'librechatResponsesReplay';
28
30
 
29
31
  const CHAT_ROLES = new Set([
30
32
  'assistant',
@@ -44,11 +46,73 @@ type RedactionResult = {
44
46
  };
45
47
 
46
48
  type RedactionContext = {
49
+ generatedImageData: Set<string>;
50
+ generatedImageIds: Set<string>;
47
51
  toolNamesByCallId: Map<string, string>;
48
52
  };
49
53
 
50
54
  const TOOL_OUTPUT_FIELD_KEYS = ['content', 'artifact'];
51
55
 
56
+ type ResponsesReplayOutputDescriptor = {
57
+ nestedOutputFields?: Readonly<Record<string, readonly string[]>>;
58
+ outputFields: readonly string[];
59
+ resolveToolNameFromCallId?: boolean;
60
+ toolName?: string;
61
+ };
62
+
63
+ const RESPONSES_REPLAY_OUTPUT_DESCRIPTORS: Readonly<
64
+ Record<string, ResponsesReplayOutputDescriptor>
65
+ > = {
66
+ local_shell_call_output: {
67
+ outputFields: ['output'],
68
+ toolName: 'local_shell',
69
+ },
70
+ shell_call_output: { outputFields: ['output'], toolName: 'shell' },
71
+ apply_patch_call_output: {
72
+ outputFields: ['output'],
73
+ toolName: 'apply_patch',
74
+ },
75
+ program_output: { outputFields: ['result'], toolName: 'program' },
76
+ code_interpreter_call: {
77
+ outputFields: ['outputs'],
78
+ toolName: 'code_interpreter',
79
+ },
80
+ mcp_call: { outputFields: ['output', 'error'], toolName: 'mcp' },
81
+ mcp_list_tools: {
82
+ outputFields: ['tools', 'error'],
83
+ toolName: 'mcp_list_tools',
84
+ },
85
+ image_generation_call: {
86
+ outputFields: ['result'],
87
+ toolName: 'image_generation',
88
+ },
89
+ file_search_call: {
90
+ outputFields: ['results'],
91
+ toolName: 'file_search',
92
+ },
93
+ web_search_call: {
94
+ nestedOutputFields: { action: ['sources'] },
95
+ outputFields: ['results'],
96
+ toolName: 'web_search',
97
+ },
98
+ tool_search_output: {
99
+ outputFields: ['tools'],
100
+ toolName: 'tool_search',
101
+ },
102
+ function_call_output: {
103
+ outputFields: ['output'],
104
+ resolveToolNameFromCallId: true,
105
+ },
106
+ custom_tool_call_output: {
107
+ outputFields: ['output'],
108
+ resolveToolNameFromCallId: true,
109
+ },
110
+ computer_call_output: {
111
+ outputFields: ['output'],
112
+ toolName: 'computer_use',
113
+ },
114
+ };
115
+
52
116
  function isRecord(value: unknown): value is Record<string, unknown> {
53
117
  return value != null && typeof value === 'object' && !Array.isArray(value);
54
118
  }
@@ -119,6 +183,8 @@ function getSerializedToolCallId(
119
183
  ): string | undefined {
120
184
  return (
121
185
  getStringField(value, 'tool_call_id') ??
186
+ getStringField(value, 'toolCallId') ??
187
+ getStringField(value, 'call_id') ??
122
188
  getNestedStringField(value, 'kwargs', 'tool_call_id') ??
123
189
  getNestedStringField(value, 'additional_kwargs', 'tool_call_id') ??
124
190
  getNestedStringField(value, 'data', 'tool_call_id') ??
@@ -180,6 +246,26 @@ function hasToolMessageIdentity(value: Record<string, unknown>): boolean {
180
246
  );
181
247
  }
182
248
 
249
+ function hasAssistantMessageIdentity(value: Record<string, unknown>): boolean {
250
+ const role = getStringField(value, 'role');
251
+ if (role?.toLowerCase() === 'assistant') {
252
+ return true;
253
+ }
254
+ const type = getStringField(value, 'type') ?? getStringField(value, '_type');
255
+ if (type === 'ai' || type === 'assistant') {
256
+ return true;
257
+ }
258
+ const id = value.id;
259
+ return (
260
+ Array.isArray(id) &&
261
+ id.some(
262
+ (part) =>
263
+ typeof part === 'string' &&
264
+ (part === 'AIMessage' || part === 'AIMessageChunk')
265
+ )
266
+ );
267
+ }
268
+
183
269
  function redactToolContentFields(
184
270
  value: Record<string, unknown>,
185
271
  config: ResolvedLangfuseToolOutputTracingConfig
@@ -213,13 +299,250 @@ function redactToolContentFields(
213
299
  return next;
214
300
  }
215
301
 
216
- function collectToolCallNames(
302
+ function redactResponsesReplayOutput(
303
+ value: Record<string, unknown>,
304
+ config: ResolvedLangfuseToolOutputTracingConfig,
305
+ redactionContext: RedactionContext
306
+ ): RedactionResult | undefined {
307
+ const type = getStringField(value, 'type');
308
+ if (
309
+ type == null ||
310
+ !Object.prototype.hasOwnProperty.call(
311
+ RESPONSES_REPLAY_OUTPUT_DESCRIPTORS,
312
+ type
313
+ )
314
+ ) {
315
+ return undefined;
316
+ }
317
+ const descriptor =
318
+ RESPONSES_REPLAY_OUTPUT_DESCRIPTORS[
319
+ type as keyof typeof RESPONSES_REPLAY_OUTPUT_DESCRIPTORS
320
+ ];
321
+ const hasDirectOutput = descriptor.outputFields.some(
322
+ (outputField) => outputField in value
323
+ );
324
+ const hasNestedOutput = Object.entries(
325
+ descriptor.nestedOutputFields ?? {}
326
+ ).some(([objectField, outputFields]) => {
327
+ const nested = value[objectField];
328
+ return (
329
+ isRecord(nested) &&
330
+ outputFields.some((outputField) => outputField in nested)
331
+ );
332
+ });
333
+ if (!hasDirectOutput && !hasNestedOutput) {
334
+ return undefined;
335
+ }
336
+ let toolName = descriptor.toolName;
337
+ if (descriptor.resolveToolNameFromCallId === true) {
338
+ toolName = getSerializedToolName(value, redactionContext);
339
+ } else if (type === 'mcp_call') {
340
+ toolName = getStringField(value, 'name') ?? descriptor.toolName;
341
+ }
342
+ if (!shouldRedactTool(toolName, config)) {
343
+ return undefined;
344
+ }
345
+ const redacted = { ...value };
346
+ for (const outputField of descriptor.outputFields) {
347
+ if (outputField in redacted) {
348
+ redacted[outputField] = config.redactionText;
349
+ }
350
+ }
351
+ for (const [objectField, outputFields] of Object.entries(
352
+ descriptor.nestedOutputFields ?? {}
353
+ )) {
354
+ const nested = redacted[objectField];
355
+ if (!isRecord(nested)) {
356
+ continue;
357
+ }
358
+ const redactedNested = { ...nested };
359
+ for (const outputField of outputFields) {
360
+ if (outputField in redactedNested) {
361
+ redactedNested[outputField] = config.redactionText;
362
+ }
363
+ }
364
+ redacted[objectField] = redactedNested;
365
+ }
366
+ return {
367
+ value: redacted,
368
+ changed: true,
369
+ };
370
+ }
371
+
372
+ function redactStandardServerToolResult(
373
+ value: Record<string, unknown>,
374
+ config: ResolvedLangfuseToolOutputTracingConfig,
375
+ redactionContext: RedactionContext
376
+ ): RedactionResult | undefined {
377
+ if (
378
+ getStringField(value, 'type') !== 'server_tool_call_result' ||
379
+ !('output' in value)
380
+ ) {
381
+ return undefined;
382
+ }
383
+ const toolName =
384
+ getNestedStringField(value, 'extras', 'name') ??
385
+ getSerializedToolName(value, redactionContext);
386
+ if (!shouldRedactTool(toolName, config)) {
387
+ return undefined;
388
+ }
389
+ return {
390
+ value: { ...value, output: config.redactionText },
391
+ changed: true,
392
+ };
393
+ }
394
+
395
+ function parseSerializedServerToolResultMarker(
396
+ text: string
397
+ ): { toolName?: string } | undefined {
398
+ if (!text.startsWith(SERVER_TOOL_RESULT_PREFIX)) {
399
+ return undefined;
400
+ }
401
+ try {
402
+ const parsed = JSON.parse(text) as unknown;
403
+ if (!isRecord(parsed) || !isRecord(parsed.serverToolResult)) {
404
+ return undefined;
405
+ }
406
+ const result = parsed.serverToolResult;
407
+ if (result[SERVER_TOOL_RESULT_REPLAY_MARKER_KEY] !== true) {
408
+ return undefined;
409
+ }
410
+ const status = getStringField(result, 'status');
411
+ if (
412
+ (status !== 'error' && status !== 'success') ||
413
+ !Object.prototype.hasOwnProperty.call(result, 'output')
414
+ ) {
415
+ return undefined;
416
+ }
417
+ const toolName = getStringField(result, 'toolName');
418
+ return toolName != null ? { toolName } : {};
419
+ } catch {
420
+ const match =
421
+ /^\{"serverToolResult":\{"librechatResponsesReplay":true,(?:"toolName":("(?:\\.|[^"\\])*"),)?"status":"(?:error|success)","output":/.exec(
422
+ text.slice(0, 512)
423
+ );
424
+ if (match == null) {
425
+ return undefined;
426
+ }
427
+ const encodedToolName = match.at(1);
428
+ if (encodedToolName == null) {
429
+ return {};
430
+ }
431
+ try {
432
+ const toolName = JSON.parse(encodedToolName) as unknown;
433
+ return typeof toolName === 'string' ? { toolName } : {};
434
+ } catch {
435
+ return {};
436
+ }
437
+ }
438
+ }
439
+
440
+ function redactMarkedServerToolResult(
441
+ value: Record<string, unknown>,
442
+ config: ResolvedLangfuseToolOutputTracingConfig,
443
+ allowSerializedMarker: boolean
444
+ ): RedactionResult | undefined {
445
+ const type = getStringField(value, 'type');
446
+ if (type !== 'text' && type !== 'image') {
447
+ return undefined;
448
+ }
449
+ const text = type === 'text' ? getStringField(value, 'text') : undefined;
450
+ const extras = value.extras;
451
+ const marker = isRecord(extras)
452
+ ? extras.librechatServerToolResult
453
+ : undefined;
454
+ const serializedMarker =
455
+ allowSerializedMarker && text != null
456
+ ? parseSerializedServerToolResultMarker(text)
457
+ : undefined;
458
+ if (!isRecord(marker) && serializedMarker == null) {
459
+ return undefined;
460
+ }
461
+ const toolName = isRecord(marker)
462
+ ? getStringField(marker, 'toolName')
463
+ : serializedMarker?.toolName;
464
+ if (!shouldRedactTool(toolName, config)) {
465
+ return undefined;
466
+ }
467
+ if (type === 'image') {
468
+ const redacted = { ...value };
469
+ let changed = false;
470
+ for (const outputField of ['data', 'url', 'fileId']) {
471
+ if (outputField in redacted) {
472
+ redacted[outputField] = config.redactionText;
473
+ changed = true;
474
+ }
475
+ }
476
+ return changed ? { value: redacted, changed: true } : undefined;
477
+ }
478
+ return {
479
+ value: { ...value, text: config.redactionText },
480
+ changed: true,
481
+ };
482
+ }
483
+
484
+ function redactGeneratedImageBlock(
485
+ value: Record<string, unknown>,
486
+ config: ResolvedLangfuseToolOutputTracingConfig,
487
+ redactionContext: RedactionContext,
488
+ isAssistantContent: boolean
489
+ ): RedactionResult | undefined {
490
+ if (
491
+ getStringField(value, 'type') !== 'image' ||
492
+ !shouldRedactTool('image_generation', config)
493
+ ) {
494
+ return undefined;
495
+ }
496
+ const extras = value.extras;
497
+ const explicitMarker = isRecord(extras)
498
+ ? extras.librechatServerToolResult
499
+ : undefined;
500
+ const explicitToolName = isRecord(explicitMarker)
501
+ ? getStringField(explicitMarker, 'toolName')
502
+ : undefined;
503
+ if (
504
+ explicitToolName != null &&
505
+ normalizeToolName(explicitToolName) !== 'image_generation'
506
+ ) {
507
+ return undefined;
508
+ }
509
+ const id = getStringField(value, 'id');
510
+ const data = getStringField(value, 'data');
511
+ const metadata = value.metadata;
512
+ const normalizedStatus = isRecord(metadata)
513
+ ? getStringField(metadata, 'status')
514
+ : undefined;
515
+ const hasNormalizedGeneratedImageIdentity =
516
+ isAssistantContent &&
517
+ id?.startsWith('ig_') === true &&
518
+ isPresent(normalizedStatus);
519
+ const isGeneratedImage =
520
+ explicitToolName != null ||
521
+ hasNormalizedGeneratedImageIdentity ||
522
+ (id != null
523
+ ? redactionContext.generatedImageIds.has(id)
524
+ : data != null && redactionContext.generatedImageData.has(data));
525
+ if (!isGeneratedImage) {
526
+ return undefined;
527
+ }
528
+ const redacted = { ...value };
529
+ let changed = false;
530
+ for (const outputField of ['data', 'url', 'fileId']) {
531
+ if (outputField in redacted) {
532
+ redacted[outputField] = config.redactionText;
533
+ changed = true;
534
+ }
535
+ }
536
+ return changed ? { value: redacted, changed: true } : undefined;
537
+ }
538
+
539
+ function collectRedactionContext(
217
540
  value: unknown,
218
541
  redactionContext: RedactionContext
219
542
  ): void {
220
543
  if (Array.isArray(value)) {
221
544
  for (const item of value) {
222
- collectToolCallNames(item, redactionContext);
545
+ collectRedactionContext(item, redactionContext);
223
546
  }
224
547
  return;
225
548
  }
@@ -234,21 +557,38 @@ function collectToolCallNames(
234
557
  redactionContext.toolNamesByCallId.set(toolCallId, toolName);
235
558
  }
236
559
 
560
+ if (getStringField(value, 'type') === 'image_generation_call') {
561
+ const id = getStringField(value, 'id');
562
+ const result = getStringField(value, 'result');
563
+ if (id != null) {
564
+ redactionContext.generatedImageIds.add(id);
565
+ }
566
+ if (result != null) {
567
+ redactionContext.generatedImageData.add(result);
568
+ }
569
+ }
570
+
237
571
  for (const child of Object.values(value)) {
238
- collectToolCallNames(child, redactionContext);
572
+ collectRedactionContext(child, redactionContext);
239
573
  }
240
574
  }
241
575
 
242
576
  function redactValue(
243
577
  value: unknown,
244
578
  config: ResolvedLangfuseToolOutputTracingConfig,
245
- redactionContext: RedactionContext
579
+ redactionContext: RedactionContext,
580
+ allowSerializedServerToolResult = false
246
581
  ): RedactionResult {
247
582
  if (Array.isArray(value)) {
248
583
  let changed = false;
249
584
  const next: unknown[] = [];
250
585
  for (const item of value) {
251
- const result = redactValue(item, config, redactionContext);
586
+ const result = redactValue(
587
+ item,
588
+ config,
589
+ redactionContext,
590
+ allowSerializedServerToolResult
591
+ );
252
592
  if (result.changed) {
253
593
  changed = true;
254
594
  }
@@ -261,6 +601,43 @@ function redactValue(
261
601
  return { value, changed: false };
262
602
  }
263
603
 
604
+ const allowSerializedMarker =
605
+ allowSerializedServerToolResult || hasAssistantMessageIdentity(value);
606
+
607
+ const replayOutput = redactResponsesReplayOutput(
608
+ value,
609
+ config,
610
+ redactionContext
611
+ );
612
+ if (replayOutput != null) {
613
+ return replayOutput;
614
+ }
615
+ const standardServerToolResult = redactStandardServerToolResult(
616
+ value,
617
+ config,
618
+ redactionContext
619
+ );
620
+ if (standardServerToolResult != null) {
621
+ return standardServerToolResult;
622
+ }
623
+ const markedServerToolResult = redactMarkedServerToolResult(
624
+ value,
625
+ config,
626
+ allowSerializedMarker
627
+ );
628
+ if (markedServerToolResult != null) {
629
+ return markedServerToolResult;
630
+ }
631
+ const generatedImageBlock = redactGeneratedImageBlock(
632
+ value,
633
+ config,
634
+ redactionContext,
635
+ allowSerializedMarker
636
+ );
637
+ if (generatedImageBlock != null) {
638
+ return generatedImageBlock;
639
+ }
640
+
264
641
  const toolName = getSerializedToolName(value, redactionContext);
265
642
  if (hasToolMessageIdentity(value) && shouldRedactTool(toolName, config)) {
266
643
  return {
@@ -272,7 +649,12 @@ function redactValue(
272
649
  let changed = false;
273
650
  const next: Record<string, unknown> = {};
274
651
  for (const [key, child] of Object.entries(value)) {
275
- const result = redactValue(child, config, redactionContext);
652
+ const result = redactValue(
653
+ child,
654
+ config,
655
+ redactionContext,
656
+ allowSerializedMarker
657
+ );
276
658
  if (result.changed) {
277
659
  changed = true;
278
660
  }
@@ -287,10 +669,12 @@ function redactSerializedValue(
287
669
  config: ResolvedLangfuseToolOutputTracingConfig
288
670
  ): RedactionResult {
289
671
  const redactionContext: RedactionContext = {
672
+ generatedImageData: new Set(),
673
+ generatedImageIds: new Set(),
290
674
  toolNamesByCallId: new Map(),
291
675
  };
292
676
  if (typeof value !== 'string') {
293
- collectToolCallNames(value, redactionContext);
677
+ collectRedactionContext(value, redactionContext);
294
678
  return redactValue(value, config, redactionContext);
295
679
  }
296
680
 
@@ -301,13 +685,18 @@ function redactSerializedValue(
301
685
 
302
686
  try {
303
687
  const parsed = JSON.parse(value) as unknown;
304
- collectToolCallNames(parsed, redactionContext);
688
+ collectRedactionContext(parsed, redactionContext);
305
689
  const result = redactValue(parsed, config, redactionContext);
306
690
  return result.changed
307
691
  ? { value: JSON.stringify(result.value), changed: true }
308
692
  : { value, changed: false };
309
693
  } catch {
310
- return { value, changed: false };
694
+ // OpenTelemetry truncates string attributes before span processors run.
695
+ // Langfuse serializes structured message values to JSON first, so a
696
+ // truncated attribute can still contain tool output even though it is no
697
+ // longer parseable. Fail closed for JSON-shaped attributes whenever tool
698
+ // output redaction is active instead of exporting a partial secret.
699
+ return { value: config.redactionText, changed: true };
311
700
  }
312
701
  }
313
702
 
@@ -397,6 +786,17 @@ export function redactLangfuseSpanToolOutputs(
397
786
  }
398
787
  }
399
788
 
789
+ export function prepareLangfuseSpanForExport(
790
+ span: ReadableSpan,
791
+ config?: ResolvedLangfuseToolOutputTracingConfig
792
+ ): void {
793
+ classifyLangfuseToolNodeSpan(span);
794
+ if (config != null) {
795
+ redactLangfuseSpanToolOutputs(span, config);
796
+ }
797
+ shapeLangfuseSpan(span);
798
+ }
799
+
400
800
  class ToolOutputRedactingLangfuseSpanProcessor implements SpanProcessor {
401
801
  private readonly processor: LangfuseSpanProcessor;
402
802
  private readonly fallbackConfig?: ResolvedLangfuseToolOutputTracingConfig;
@@ -430,12 +830,8 @@ class ToolOutputRedactingLangfuseSpanProcessor implements SpanProcessor {
430
830
  if (shouldDropLangfuseSpan(span.name)) {
431
831
  return;
432
832
  }
433
- classifyLangfuseToolNodeSpan(span);
434
- shapeLangfuseSpan(span);
435
833
  const config = this.spanConfigs.get(span) ?? this.fallbackConfig;
436
- if (config != null) {
437
- redactLangfuseSpanToolOutputs(span, config);
438
- }
834
+ prepareLangfuseSpanForExport(span, config);
439
835
  this.processor.onEnd(span);
440
836
  }
441
837