@librechat/agents 3.1.97 → 3.1.99

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 (49) hide show
  1. package/dist/cjs/graphs/Graph.cjs +6 -0
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/langfuseToolOutputTracing.cjs +16 -5
  4. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +10 -0
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/llm/bedrock/toolCache.cjs +125 -0
  8. package/dist/cjs/llm/bedrock/toolCache.cjs.map +1 -0
  9. package/dist/cjs/messages/cache.cjs +17 -9
  10. package/dist/cjs/messages/cache.cjs.map +1 -1
  11. package/dist/cjs/messages/prune.cjs +45 -8
  12. package/dist/cjs/messages/prune.cjs.map +1 -1
  13. package/dist/cjs/tools/ToolNode.cjs +6 -1
  14. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  15. package/dist/esm/graphs/Graph.mjs +6 -0
  16. package/dist/esm/graphs/Graph.mjs.map +1 -1
  17. package/dist/esm/langfuseToolOutputTracing.mjs +16 -5
  18. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  19. package/dist/esm/llm/bedrock/index.mjs +10 -0
  20. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  21. package/dist/esm/llm/bedrock/toolCache.mjs +122 -0
  22. package/dist/esm/llm/bedrock/toolCache.mjs.map +1 -0
  23. package/dist/esm/messages/cache.mjs +17 -9
  24. package/dist/esm/messages/cache.mjs.map +1 -1
  25. package/dist/esm/messages/prune.mjs +45 -8
  26. package/dist/esm/messages/prune.mjs.map +1 -1
  27. package/dist/esm/tools/ToolNode.mjs +6 -1
  28. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  29. package/dist/types/llm/bedrock/index.d.ts +16 -0
  30. package/dist/types/llm/bedrock/toolCache.d.ts +4 -0
  31. package/dist/types/messages/cache.d.ts +2 -2
  32. package/dist/types/types/llm.d.ts +2 -2
  33. package/package.json +1 -1
  34. package/src/agents/__tests__/AgentContext.anthropic.live.test.ts +332 -0
  35. package/src/agents/__tests__/AgentContext.bedrock.live.test.ts +504 -0
  36. package/src/graphs/Graph.ts +14 -0
  37. package/src/langfuseToolOutputTracing.ts +26 -7
  38. package/src/llm/bedrock/index.ts +32 -1
  39. package/src/llm/bedrock/llm.spec.ts +154 -1
  40. package/src/llm/bedrock/toolCache.test.ts +131 -0
  41. package/src/llm/bedrock/toolCache.ts +191 -0
  42. package/src/messages/cache.test.ts +97 -38
  43. package/src/messages/cache.ts +18 -10
  44. package/src/messages/prune.ts +55 -17
  45. package/src/specs/langfuse-tool-output-tracing.test.ts +28 -0
  46. package/src/specs/prune.test.ts +193 -0
  47. package/src/tools/ToolNode.ts +7 -1
  48. package/src/tools/__tests__/ToolNode.langfuse.test.ts +6 -0
  49. package/src/types/llm.ts +2 -2
@@ -287,23 +287,28 @@ type TestMsg = {
287
287
  };
288
288
 
289
289
  describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
290
- it('returns input when not enough messages', () => {
290
+ it('returns empty input unchanged and caches a single user message', () => {
291
291
  const empty: TestMsg[] = [];
292
292
  expect(addBedrockCacheControl(empty)).toEqual(empty);
293
293
  const single: TestMsg[] = [{ role: 'user', content: 'only' }];
294
- expect(addBedrockCacheControl(single)).toEqual(single);
294
+ expect(addBedrockCacheControl(single)[0].content).toEqual([
295
+ { type: ContentTypes.TEXT, text: 'only' },
296
+ { cachePoint: { type: 'default' } },
297
+ ]);
295
298
  });
296
299
 
297
- it('wraps string content and appends separate cachePoint block', () => {
300
+ it('wraps latest user string content and appends separate cachePoint block', () => {
298
301
  const messages: TestMsg[] = [
299
302
  { role: 'user', content: 'Hello' },
300
303
  { role: 'assistant', content: [{ type: ContentTypes.TEXT, text: 'Hi' }] },
301
304
  ];
302
305
  const result = addBedrockCacheControl(messages);
303
- const last = result[1].content as MessageContentComplex[];
304
- expect(Array.isArray(last)).toBe(true);
305
- expect(last[0]).toEqual({ type: ContentTypes.TEXT, text: 'Hi' });
306
- expect(last[1]).toEqual({ cachePoint: { type: 'default' } });
306
+ const user = result[0].content as MessageContentComplex[];
307
+ const assistant = result[1].content as MessageContentComplex[];
308
+ expect(Array.isArray(user)).toBe(true);
309
+ expect(user[0]).toEqual({ type: ContentTypes.TEXT, text: 'Hello' });
310
+ expect(user[1]).toEqual({ cachePoint: { type: 'default' } });
311
+ expect(assistant).toEqual([{ type: ContentTypes.TEXT, text: 'Hi' }]);
307
312
  });
308
313
 
309
314
  it('inserts cachePoint after the last text when multiple text blocks exist', () => {
@@ -345,20 +350,21 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
345
350
 
346
351
  expect(second[0]).toEqual({ type: ContentTypes.TEXT, text: 'Reply A' });
347
352
  expect(second[1]).toEqual({ type: ContentTypes.TEXT, text: 'Reply B' });
348
- expect(second[2]).toEqual({ cachePoint: { type: 'default' } });
353
+ expect(second).toHaveLength(2);
349
354
  });
350
355
 
351
- it('skips adding cachePoint when content is an empty array', () => {
356
+ it('skips empty arrays and caches the latest non-empty user message', () => {
352
357
  const messages: TestMsg[] = [
353
358
  { role: 'user', content: [] },
354
359
  { role: 'assistant', content: [] },
355
- { role: 'user', content: 'ignored because only last two are modified' },
360
+ { role: 'user', content: 'latest cacheable user message' },
356
361
  ];
357
362
 
358
363
  const result = addBedrockCacheControl(messages);
359
364
 
360
365
  const first = result[0].content as MessageContentComplex[];
361
366
  const second = result[1].content as MessageContentComplex[];
367
+ const third = result[2].content as MessageContentComplex[];
362
368
 
363
369
  expect(Array.isArray(first)).toBe(true);
364
370
  expect(first.length).toBe(0);
@@ -366,39 +372,51 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
366
372
  expect(Array.isArray(second)).toBe(true);
367
373
  expect(second.length).toBe(0);
368
374
  expect(second[0]).not.toEqual({ cachePoint: { type: 'default' } });
375
+
376
+ expect(third).toEqual([
377
+ { type: ContentTypes.TEXT, text: 'latest cacheable user message' },
378
+ { cachePoint: { type: 'default' } },
379
+ ]);
369
380
  });
370
381
 
371
- it('skips adding cachePoint when content is an empty string', () => {
382
+ it('skips empty strings and caches the latest non-empty user message', () => {
372
383
  const messages: TestMsg[] = [
373
384
  { role: 'user', content: '' },
374
385
  { role: 'assistant', content: '' },
375
- { role: 'user', content: 'ignored because only last two are modified' },
386
+ { role: 'user', content: 'latest cacheable user message' },
376
387
  ];
377
388
 
378
389
  const result = addBedrockCacheControl(messages);
379
390
 
380
391
  expect(result[0].content).toBe('');
381
392
  expect(result[1].content).toBe('');
393
+ expect(result[2].content).toEqual([
394
+ { type: ContentTypes.TEXT, text: 'latest cacheable user message' },
395
+ { cachePoint: { type: 'default' } },
396
+ ]);
382
397
  });
383
398
 
384
399
  /** (I don't think this will ever occur in actual use, but its the only branch left uncovered so I'm covering it */
385
- it('skips messages with non-string, non-array content and still modifies the previous to reach two edits', () => {
400
+ it('skips messages with non-string, non-array content', () => {
386
401
  const messages: TestMsg[] = [
387
402
  {
388
403
  role: 'user',
389
- content: [{ type: ContentTypes.TEXT, text: 'Will be modified' }],
404
+ content: [{ type: ContentTypes.TEXT, text: 'Older user message' }],
390
405
  },
391
406
  { role: 'assistant', content: undefined },
392
407
  {
393
408
  role: 'user',
394
- content: [{ type: ContentTypes.TEXT, text: 'Also modified' }],
409
+ content: [{ type: ContentTypes.TEXT, text: 'Latest user message' }],
395
410
  },
396
411
  ];
397
412
 
398
413
  const result = addBedrockCacheControl(messages);
399
414
 
400
415
  const last = result[2].content as MessageContentComplex[];
401
- expect(last[0]).toEqual({ type: ContentTypes.TEXT, text: 'Also modified' });
416
+ expect(last[0]).toEqual({
417
+ type: ContentTypes.TEXT,
418
+ text: 'Latest user message',
419
+ });
402
420
  expect(last[1]).toEqual({ cachePoint: { type: 'default' } });
403
421
 
404
422
  expect(result[1].content).toBeUndefined();
@@ -406,7 +424,7 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
406
424
  const first = result[0].content as MessageContentComplex[];
407
425
  expect(first[0]).toEqual({
408
426
  type: ContentTypes.TEXT,
409
- text: 'Will be modified',
427
+ text: 'Older user message',
410
428
  });
411
429
  expect(first[1]).toEqual({ cachePoint: { type: 'default' } });
412
430
  });
@@ -511,7 +529,7 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
511
529
  expect(systemContent[2]).toHaveProperty('cache_control');
512
530
  });
513
531
 
514
- it('skips serialized system messages while adding cache points to non-system turns', () => {
532
+ it('skips serialized system messages while adding a cache point to the latest user turn', () => {
515
533
  const messages: TestMsg[] = [
516
534
  {
517
535
  role: 'system',
@@ -575,7 +593,7 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
575
593
  type: ContentTypes.TEXT,
576
594
  text: 'Sure! The capital of France is Paris.',
577
595
  });
578
- expect(assistant[1]).toEqual({ cachePoint: { type: 'default' } });
596
+ expect(assistant).toHaveLength(1);
579
597
  });
580
598
 
581
599
  it('is idempotent - calling multiple times does not add duplicate cache points', () => {
@@ -601,12 +619,11 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
601
619
  });
602
620
  expect(firstContent[1]).toEqual({ cachePoint: { type: 'default' } });
603
621
 
604
- expect(secondContent.length).toBe(2);
622
+ expect(secondContent.length).toBe(1);
605
623
  expect(secondContent[0]).toEqual({
606
624
  type: ContentTypes.TEXT,
607
625
  text: 'First response',
608
626
  });
609
- expect(secondContent[1]).toEqual({ cachePoint: { type: 'default' } });
610
627
 
611
628
  const result2 = addBedrockCacheControl(result1);
612
629
  const firstContentAfter = result2[0].content as MessageContentComplex[];
@@ -619,15 +636,14 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
619
636
  });
620
637
  expect(firstContentAfter[1]).toEqual({ cachePoint: { type: 'default' } });
621
638
 
622
- expect(secondContentAfter.length).toBe(2);
639
+ expect(secondContentAfter.length).toBe(1);
623
640
  expect(secondContentAfter[0]).toEqual({
624
641
  type: ContentTypes.TEXT,
625
642
  text: 'First response',
626
643
  });
627
- expect(secondContentAfter[1]).toEqual({ cachePoint: { type: 'default' } });
628
644
  });
629
645
 
630
- it('skips messages that already have cache points in multi-agent scenarios', () => {
646
+ it('strips stale cache points and caches the latest user messages in multi-agent scenarios', () => {
631
647
  const messages: TestMsg[] = [
632
648
  {
633
649
  role: 'user',
@@ -647,9 +663,17 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
647
663
  ];
648
664
 
649
665
  const result = addBedrockCacheControl(messages);
666
+ const firstContent = result[0].content as MessageContentComplex[];
650
667
  const lastContent = result[2].content as MessageContentComplex[];
651
668
  const secondLastContent = result[1].content as MessageContentComplex[];
652
669
 
670
+ expect(firstContent.length).toBe(2);
671
+ expect(firstContent[0]).toEqual({
672
+ type: ContentTypes.TEXT,
673
+ text: 'Hello',
674
+ });
675
+ expect(firstContent[1]).toEqual({ cachePoint: { type: 'default' } });
676
+
653
677
  expect(lastContent.length).toBe(2);
654
678
  expect(lastContent[0]).toEqual({
655
679
  type: ContentTypes.TEXT,
@@ -657,12 +681,11 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
657
681
  });
658
682
  expect(lastContent[1]).toEqual({ cachePoint: { type: 'default' } });
659
683
 
660
- expect(secondLastContent.length).toBe(2);
684
+ expect(secondLastContent.length).toBe(1);
661
685
  expect(secondLastContent[0]).toEqual({
662
686
  type: ContentTypes.TEXT,
663
687
  text: 'Response from agent 1',
664
688
  });
665
- expect(secondLastContent[1]).toEqual({ cachePoint: { type: 'default' } });
666
689
  });
667
690
 
668
691
  it('skips cachePoint on AI messages with only whitespace text and reasoning (tool-call scenario)', () => {
@@ -705,6 +728,42 @@ describe('addBedrockCacheControl (Bedrock cache checkpoints)', () => {
705
728
  cachePoint: { type: 'default' },
706
729
  });
707
730
  });
731
+
732
+ it('keeps cachePoint on the stable user boundary through tool loops', () => {
733
+ const messages: BaseMessage[] = [
734
+ new HumanMessage('Use the stable prompt context.'),
735
+ new AIMessage({
736
+ content: 'I will call the first tool.',
737
+ tool_calls: [{ id: 'call_1', name: 'lookup', args: { step: 1 } }],
738
+ }),
739
+ new ToolMessage({
740
+ content: 'volatile tool result 1',
741
+ tool_call_id: 'call_1',
742
+ }),
743
+ new AIMessage({
744
+ content: 'I will call the second tool.',
745
+ tool_calls: [{ id: 'call_2', name: 'lookup', args: { step: 2 } }],
746
+ }),
747
+ new ToolMessage({
748
+ content: 'volatile tool result 2',
749
+ tool_call_id: 'call_2',
750
+ }),
751
+ ];
752
+
753
+ const result = addBedrockCacheControl(messages);
754
+
755
+ const userContent = result[0].content as MessageContentComplex[];
756
+ expect(userContent[userContent.length - 1]).toEqual({
757
+ cachePoint: { type: 'default' },
758
+ });
759
+
760
+ for (const message of result.slice(1)) {
761
+ const content = message.content;
762
+ expect(
763
+ Array.isArray(content) && content.some((block) => 'cachePoint' in block)
764
+ ).toBe(false);
765
+ }
766
+ });
708
767
  });
709
768
 
710
769
  describe('stripAnthropicCacheControl', () => {
@@ -947,7 +1006,7 @@ describe('Multi-agent provider interoperability', () => {
947
1006
  expect('cache_control' in secondContent[0]).toBe(false);
948
1007
 
949
1008
  expect(firstContent.some((b) => 'cachePoint' in b)).toBe(true);
950
- expect(secondContent.some((b) => 'cachePoint' in b)).toBe(true);
1009
+ expect(secondContent.some((b) => 'cachePoint' in b)).toBe(false);
951
1010
  });
952
1011
 
953
1012
  it('strips Bedrock cache using separate function (backwards compat)', () => {
@@ -1142,7 +1201,7 @@ describe('Immutability - addBedrockCacheControl does not mutate original message
1142
1201
  expect(typeof originalMessages[1].content).toBe('string');
1143
1202
 
1144
1203
  expect(Array.isArray(result[0].content)).toBe(true);
1145
- expect(Array.isArray(result[1].content)).toBe(true);
1204
+ expect(result[1].content).toBe('Hi there');
1146
1205
  });
1147
1206
 
1148
1207
  it('should not mutate original messages when adding cache points to array content', () => {
@@ -1178,9 +1237,9 @@ describe('Immutability - addBedrockCacheControl does not mutate original message
1178
1237
  const resultFirstContent = result[0].content as MessageContentComplex[];
1179
1238
  const resultSecondContent = result[1].content as MessageContentComplex[];
1180
1239
  expect(resultFirstContent.length).toBe(originalFirstContentLength + 1);
1181
- expect(resultSecondContent.length).toBe(originalSecondContentLength + 1);
1240
+ expect(resultSecondContent.length).toBe(originalSecondContentLength);
1182
1241
  expect(resultFirstContent.some((b) => 'cachePoint' in b)).toBe(true);
1183
- expect(resultSecondContent.some((b) => 'cachePoint' in b)).toBe(true);
1242
+ expect(resultSecondContent.some((b) => 'cachePoint' in b)).toBe(false);
1184
1243
  });
1185
1244
 
1186
1245
  it('should not mutate original messages when stripping existing cache control', () => {
@@ -1339,13 +1398,13 @@ describe('Multi-turn cache cleanup', () => {
1339
1398
  const lastContent = result[3].content as MessageContentComplex[];
1340
1399
  const secondLastContent = result[2].content as MessageContentComplex[];
1341
1400
 
1342
- expect(lastContent.some((b) => 'cachePoint' in b)).toBe(true);
1401
+ expect(lastContent.some((b) => 'cachePoint' in b)).toBe(false);
1343
1402
  expect(secondLastContent.some((b) => 'cachePoint' in b)).toBe(true);
1344
1403
 
1345
1404
  const firstContent = result[0].content as MessageContentComplex[];
1346
1405
  const secondContent = result[1].content as MessageContentComplex[];
1347
1406
 
1348
- expect(firstContent.some((b) => 'cachePoint' in b)).toBe(false);
1407
+ expect(firstContent.some((b) => 'cachePoint' in b)).toBe(true);
1349
1408
  expect(secondContent.some((b) => 'cachePoint' in b)).toBe(false);
1350
1409
  });
1351
1410
 
@@ -1561,14 +1620,14 @@ describe('OpenRouter prompt caching (reuses addCacheControl)', () => {
1561
1620
  const lastUser = converted[2];
1562
1621
 
1563
1622
  expect(Array.isArray(firstUser.content)).toBe(true);
1564
- expect(
1565
- (firstUser.content as CacheControlBlock[])[0]
1566
- ).toHaveProperty('cache_control');
1623
+ expect((firstUser.content as CacheControlBlock[])[0]).toHaveProperty(
1624
+ 'cache_control'
1625
+ );
1567
1626
 
1568
1627
  expect(Array.isArray(lastUser.content)).toBe(true);
1569
- expect(
1570
- (lastUser.content as CacheControlBlock[])[0]
1571
- ).toHaveProperty('cache_control');
1628
+ expect((lastUser.content as CacheControlBlock[])[0]).toHaveProperty(
1629
+ 'cache_control'
1630
+ );
1572
1631
  });
1573
1632
 
1574
1633
  it('strips Bedrock cache before applying OpenRouter/Anthropic cache', () => {
@@ -470,11 +470,11 @@ export function stripBedrockCacheControl<T extends MessageWithContent>(
470
470
  }
471
471
 
472
472
  /**
473
- * Adds Bedrock Converse API cache points to the last two messages.
473
+ * Adds Bedrock Converse API cache points to the latest two user messages.
474
474
  * Inserts `{ cachePoint: { type: 'default' } }` as a separate content block
475
475
  * immediately after the last text block in each targeted message.
476
476
  * Strips ALL existing cache control (both Bedrock and Anthropic formats) from all messages,
477
- * then adds fresh cache points to the last 2 messages in a single backward pass.
477
+ * then adds fresh cache points to the latest two non-tool user messages in a single backward pass.
478
478
  * This ensures we don't accumulate stale cache points across multiple turns.
479
479
  * Returns a new array - only clones messages that require modification.
480
480
  * @param messages - The array of message objects.
@@ -483,12 +483,12 @@ export function stripBedrockCacheControl<T extends MessageWithContent>(
483
483
  export function addBedrockCacheControl<
484
484
  T extends MessageWithContent & { getType?: () => string; role?: string },
485
485
  >(messages: T[]): T[] {
486
- if (!Array.isArray(messages) || messages.length < 2) {
486
+ if (!Array.isArray(messages) || messages.length === 0) {
487
487
  return messages;
488
488
  }
489
489
 
490
490
  const updatedMessages: T[] = [...messages];
491
- let messagesModified = 0;
491
+ let cachePointsAdded = 0;
492
492
 
493
493
  for (let i = updatedMessages.length - 1; i >= 0; i--) {
494
494
  const originalMessage = updatedMessages[i];
@@ -510,21 +510,27 @@ export function addBedrockCacheControl<
510
510
  }
511
511
 
512
512
  const isToolMessage = messageType === 'tool' || messageRole === 'tool';
513
+ const isUserMessage = messageType === 'human' || messageRole === 'user';
513
514
  const content = originalMessage.content;
515
+ const hasSerializationProps =
516
+ 'lc_kwargs' in originalMessage ||
517
+ 'lc_serializable' in originalMessage ||
518
+ 'lc_namespace' in originalMessage;
514
519
  const hasArrayContent = Array.isArray(content);
515
520
  const isEmptyString = typeof content === 'string' && content === '';
516
521
  const needsCacheAdd =
517
- messagesModified < 2 &&
522
+ cachePointsAdded < 2 &&
523
+ isUserMessage &&
518
524
  !isToolMessage &&
519
525
  !isEmptyString &&
520
526
  (typeof content === 'string' || hasArrayContent);
521
527
 
522
- if (!needsCacheAdd && !hasArrayContent) {
528
+ if (!needsCacheAdd && !hasArrayContent && !hasSerializationProps) {
523
529
  continue;
524
530
  }
525
531
 
526
- let workingContent: MessageContentComplex[];
527
- let modified = false;
532
+ let workingContent: string | MessageContentComplex[];
533
+ let modified = hasSerializationProps;
528
534
 
529
535
  if (hasArrayContent) {
530
536
  // Single pass: clone blocks, strip cache markers, find last
@@ -563,14 +569,16 @@ export function addBedrockCacheControl<
563
569
  workingContent.splice(lastNonEmptyTextIndex + 1, 0, {
564
570
  cachePoint: { type: 'default' },
565
571
  } as MessageContentComplex);
566
- messagesModified++;
572
+ cachePointsAdded++;
567
573
  }
568
574
  } else if (typeof content === 'string' && needsCacheAdd) {
569
575
  workingContent = [
570
576
  { type: ContentTypes.TEXT, text: content },
571
577
  { cachePoint: { type: 'default' } } as MessageContentComplex,
572
578
  ];
573
- messagesModified++;
579
+ cachePointsAdded++;
580
+ } else if (typeof content === 'string' && hasSerializationProps) {
581
+ workingContent = content;
574
582
  } else {
575
583
  continue;
576
584
  }
@@ -563,7 +563,7 @@ function addThinkingBlock(
563
563
  },
564
564
  ];
565
565
  /** Edge case, the message already has the thinking block */
566
- if (content[0].type === thinkingBlock.type) {
566
+ if (content[0]?.type === thinkingBlock.type) {
567
567
  return message;
568
568
  }
569
569
  content.unshift(thinkingBlock);
@@ -608,6 +608,33 @@ export type PruningResult = {
608
608
  thinkingStartIndex?: number;
609
609
  };
610
610
 
611
+ /**
612
+ * Locates a reasoning block in assistant content. Reasoning blocks carry
613
+ * provider-specific `type` tags: Anthropic emits `thinking`, while Bedrock and
614
+ * OpenAI-compatible reasoning providers (DeepSeek-R1, DashScope/Qwen-thinking)
615
+ * emit `reasoning_content`. DeepSeek/Qwen route through the `THINKING` default
616
+ * even though their blocks are `reasoning_content` and aren't normalized
617
+ * upstream, so for the `THINKING` case we also accept `reasoning_content` — this
618
+ * is what fixes issue #191.
619
+ *
620
+ * The broadening is intentionally one-directional. A Bedrock run
621
+ * (`REASONING_CONTENT`) must NOT match an Anthropic `thinking` block: the
622
+ * Bedrock input converter rejects `thinking` blocks outright
623
+ * (`src/llm/bedrock/utils/message_inputs.ts`), so reattaching one to a
624
+ * surviving message would make the request fail before it is sent.
625
+ */
626
+ function findReasoningBlock(
627
+ content: MessageContentComplex[],
628
+ reasoningType: ContentTypes
629
+ ): ThinkingContentText | ReasoningContentText | undefined {
630
+ return content.find(
631
+ (part) =>
632
+ part.type === reasoningType ||
633
+ (reasoningType === ContentTypes.THINKING &&
634
+ part.type === ContentTypes.REASONING_CONTENT)
635
+ ) as ThinkingContentText | ReasoningContentText | undefined;
636
+ }
637
+
611
638
  /**
612
639
  * Processes an array of messages and returns a context of messages that fit within a specified token limit.
613
640
  * It iterates over the messages from newest to oldest, adding them to the context until the token limit is reached.
@@ -670,9 +697,7 @@ export function getMessagesWithinTokenLimit({
670
697
  if (_thinkingStartIndex > -1) {
671
698
  const thinkingMessageContent = messages[_thinkingStartIndex]?.content;
672
699
  if (Array.isArray(thinkingMessageContent)) {
673
- thinkingBlock = thinkingMessageContent.find(
674
- (content) => content.type === reasoningType
675
- ) as ThinkingContentText | undefined;
700
+ thinkingBlock = findReasoningBlock(thinkingMessageContent, reasoningType);
676
701
  }
677
702
  }
678
703
 
@@ -705,9 +730,10 @@ export function getMessagesWithinTokenLimit({
705
730
  messageType === 'ai' &&
706
731
  Array.isArray(poppedMessage.content)
707
732
  ) {
708
- thinkingBlock = poppedMessage.content.find(
709
- (content) => content.type === reasoningType
710
- ) as ThinkingContentText | undefined;
733
+ thinkingBlock = findReasoningBlock(
734
+ poppedMessage.content,
735
+ reasoningType
736
+ );
711
737
  thinkingStartIndex = thinkingBlock != null ? currentIndex : -1;
712
738
  }
713
739
  /**
@@ -811,16 +837,28 @@ export function getMessagesWithinTokenLimit({
811
837
  return result;
812
838
  }
813
839
 
814
- if (thinkingEndIndex > -1 && thinkingStartIndex < 0) {
815
- throw new Error(
816
- 'The payload is malformed. There is a thinking sequence but no "AI" messages with thinking blocks.'
817
- );
818
- }
819
-
820
- if (!thinkingBlock) {
821
- throw new Error(
822
- 'The payload is malformed. There is a thinking sequence but no thinking block found.'
823
- );
840
+ /**
841
+ * A trailing reasoning sequence was detected but its block could not be
842
+ * located in the surviving context. Rather than throw which permanently
843
+ * bricks the conversation, re-firing on every retry of the same thread (see
844
+ * issue #191) — return the partially-pruned context and let the provider
845
+ * surface a real, recoverable error if the payload is genuinely malformed.
846
+ * Strict providers (Anthropic) reject it cleanly; lenient ones (DeepSeek,
847
+ * Qwen) proceed. The pruner cannot know which applies, so it must not be the
848
+ * one to make the failure fatal.
849
+ */
850
+ if ((thinkingEndIndex > -1 && thinkingStartIndex < 0) || !thinkingBlock) {
851
+ /**
852
+ * No block was located, so any `thinkingStartIndex` set above came from a
853
+ * stale carried-over index pointing at a block-less message. Drop it:
854
+ * `createPruneMessages` persists the returned index as
855
+ * `runThinkingStartIndex`, and a stale value would suppress the trailing
856
+ * scan (`thinkingStartIndex < 0`) on later turns, causing a real reasoning
857
+ * block to be missed and never reattached.
858
+ */
859
+ delete result.thinkingStartIndex;
860
+ result.context = context.reverse() as BaseMessage[];
861
+ return result;
824
862
  }
825
863
 
826
864
  let assistantIndex = -1;
@@ -180,6 +180,34 @@ describe('Langfuse tool output tracing redaction', () => {
180
180
  ).toBe(false);
181
181
  });
182
182
 
183
+ it('classifies LangGraph tool-node spans as Langfuse tool observations', () => {
184
+ const span = createSpan('tool_batch', {
185
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'span',
186
+ [`${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langgraph_node`]:
187
+ 'tools=agent_1',
188
+ });
189
+
190
+ redactLangfuseSpanToolOutputs(span, createConfig());
191
+
192
+ expect(span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE]).toBe(
193
+ 'tool'
194
+ );
195
+ });
196
+
197
+ it('does not reclassify non-tool LangGraph spans', () => {
198
+ const span = createSpan('agent=agent_1', {
199
+ [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'span',
200
+ [`${LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.langgraph_node`]:
201
+ 'agent=agent_1',
202
+ });
203
+
204
+ redactLangfuseSpanToolOutputs(span, createConfig());
205
+
206
+ expect(span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE]).toBe(
207
+ 'span'
208
+ );
209
+ });
210
+
183
211
  it('redacts raw tool observation output when tool output tracing is disabled', () => {
184
212
  const span = createSpan('execute_sql', {
185
213
  [LangfuseOtelSpanAttributes.OBSERVATION_TYPE]: 'tool',