@librechat/agents 3.3.10 → 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 (46) hide show
  1. package/dist/cjs/graphs/Graph.cjs +2 -2
  2. package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
  3. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  4. package/dist/cjs/llm/init.cjs +1 -1
  5. package/dist/cjs/llm/invoke.cjs +14 -7
  6. package/dist/cjs/llm/invoke.cjs.map +1 -1
  7. package/dist/cjs/llm/openai/index.cjs +188 -11
  8. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +4 -3
  10. package/dist/cjs/messages/core.cjs +592 -27
  11. package/dist/cjs/messages/core.cjs.map +1 -1
  12. package/dist/cjs/run.cjs +1 -1
  13. package/dist/cjs/stream.cjs +2 -2
  14. package/dist/cjs/tools/ToolNode.cjs +1 -1
  15. package/dist/cjs/tools/search/tool.cjs +1 -1
  16. package/dist/cjs/utils/index.cjs +1 -1
  17. package/dist/esm/graphs/Graph.mjs +2 -2
  18. package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
  19. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  20. package/dist/esm/llm/init.mjs +1 -1
  21. package/dist/esm/llm/invoke.mjs +14 -7
  22. package/dist/esm/llm/invoke.mjs.map +1 -1
  23. package/dist/esm/llm/openai/index.mjs +190 -13
  24. package/dist/esm/llm/openai/index.mjs.map +1 -1
  25. package/dist/esm/main.mjs +5 -5
  26. package/dist/esm/messages/core.mjs +592 -28
  27. package/dist/esm/messages/core.mjs.map +1 -1
  28. package/dist/esm/run.mjs +1 -1
  29. package/dist/esm/stream.mjs +2 -2
  30. package/dist/esm/tools/ToolNode.mjs +1 -1
  31. package/dist/esm/tools/search/tool.mjs +1 -1
  32. package/dist/esm/utils/index.mjs +1 -1
  33. package/dist/types/langfuseToolOutputTracing.d.ts +1 -0
  34. package/dist/types/llm/invoke.d.ts +1 -1
  35. package/dist/types/messages/core.d.ts +11 -6
  36. package/package.json +1 -1
  37. package/src/langfuseToolOutputTracing.ts +410 -14
  38. package/src/llm/custom-chat-models.smoke.test.ts +747 -0
  39. package/src/llm/invoke.test.ts +98 -0
  40. package/src/llm/invoke.ts +34 -23
  41. package/src/llm/openai/index.ts +334 -25
  42. package/src/llm/openai/llm.spec.ts +107 -6
  43. package/src/messages/core.ts +1290 -42
  44. package/src/messages/formatAgentMessages.test.ts +2623 -0
  45. package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
  46. package/src/specs/preemptSeal.test.ts +374 -5
@@ -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