@librechat/agents 3.3.3 → 3.3.4

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 (156) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +2 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +342 -109
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
  8. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +37 -10
  10. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/google/utils/common.cjs +19 -7
  12. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  13. package/dist/cjs/llm/invoke.cjs +64 -3
  14. package/dist/cjs/llm/invoke.cjs.map +1 -1
  15. package/dist/cjs/llm/openai/index.cjs +41 -5
  16. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  17. package/dist/cjs/llm/openai/utils/index.cjs +25 -4
  18. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  19. package/dist/cjs/llm/openrouter/index.cjs +4 -1
  20. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  21. package/dist/cjs/main.cjs +19 -2
  22. package/dist/cjs/messages/cache.cjs +54 -33
  23. package/dist/cjs/messages/cache.cjs.map +1 -1
  24. package/dist/cjs/messages/contextPruning.cjs +17 -43
  25. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  26. package/dist/cjs/messages/core.cjs +315 -23
  27. package/dist/cjs/messages/core.cjs.map +1 -1
  28. package/dist/cjs/messages/format.cjs +218 -60
  29. package/dist/cjs/messages/format.cjs.map +1 -1
  30. package/dist/cjs/messages/index.cjs +1 -1
  31. package/dist/cjs/messages/prune.cjs +503 -127
  32. package/dist/cjs/messages/prune.cjs.map +1 -1
  33. package/dist/cjs/stream.cjs +7 -3
  34. package/dist/cjs/stream.cjs.map +1 -1
  35. package/dist/cjs/summarization/node.cjs +28 -14
  36. package/dist/cjs/summarization/node.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +54 -41
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
  40. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  41. package/dist/cjs/utils/index.cjs +1 -1
  42. package/dist/cjs/utils/llm.cjs +1 -1
  43. package/dist/cjs/utils/llm.cjs.map +1 -1
  44. package/dist/cjs/utils/tokens.cjs +307 -20
  45. package/dist/cjs/utils/tokens.cjs.map +1 -1
  46. package/dist/cjs/utils/toolContent.cjs +1514 -0
  47. package/dist/cjs/utils/toolContent.cjs.map +1 -0
  48. package/dist/esm/agents/AgentContext.mjs +2 -1
  49. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  50. package/dist/esm/graphs/Graph.mjs +349 -116
  51. package/dist/esm/graphs/Graph.mjs.map +1 -1
  52. package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  54. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
  55. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  56. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +37 -10
  57. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  58. package/dist/esm/llm/google/utils/common.mjs +19 -7
  59. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  60. package/dist/esm/llm/invoke.mjs +65 -5
  61. package/dist/esm/llm/invoke.mjs.map +1 -1
  62. package/dist/esm/llm/openai/index.mjs +41 -5
  63. package/dist/esm/llm/openai/index.mjs.map +1 -1
  64. package/dist/esm/llm/openai/utils/index.mjs +25 -4
  65. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  66. package/dist/esm/llm/openrouter/index.mjs +4 -1
  67. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  68. package/dist/esm/main.mjs +7 -7
  69. package/dist/esm/messages/cache.mjs +55 -34
  70. package/dist/esm/messages/cache.mjs.map +1 -1
  71. package/dist/esm/messages/contextPruning.mjs +17 -43
  72. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  73. package/dist/esm/messages/core.mjs +305 -24
  74. package/dist/esm/messages/core.mjs.map +1 -1
  75. package/dist/esm/messages/format.mjs +218 -61
  76. package/dist/esm/messages/format.mjs.map +1 -1
  77. package/dist/esm/messages/index.mjs +1 -1
  78. package/dist/esm/messages/prune.mjs +500 -128
  79. package/dist/esm/messages/prune.mjs.map +1 -1
  80. package/dist/esm/stream.mjs +7 -3
  81. package/dist/esm/stream.mjs.map +1 -1
  82. package/dist/esm/summarization/node.mjs +28 -14
  83. package/dist/esm/summarization/node.mjs.map +1 -1
  84. package/dist/esm/tools/ToolNode.mjs +54 -41
  85. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  86. package/dist/esm/tools/toolOutputReferences.mjs +6 -0
  87. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  88. package/dist/esm/utils/index.mjs +1 -1
  89. package/dist/esm/utils/llm.mjs +1 -1
  90. package/dist/esm/utils/llm.mjs.map +1 -1
  91. package/dist/esm/utils/tokens.mjs +307 -21
  92. package/dist/esm/utils/tokens.mjs.map +1 -1
  93. package/dist/esm/utils/toolContent.mjs +1503 -0
  94. package/dist/esm/utils/toolContent.mjs.map +1 -0
  95. package/dist/types/graphs/Graph.d.ts +1 -1
  96. package/dist/types/llm/invoke.d.ts +27 -1
  97. package/dist/types/llm/openai/index.d.ts +4 -0
  98. package/dist/types/llm/openai/utils/index.d.ts +2 -0
  99. package/dist/types/llm/openrouter/index.d.ts +5 -0
  100. package/dist/types/messages/contextPruning.d.ts +2 -1
  101. package/dist/types/messages/core.d.ts +36 -0
  102. package/dist/types/messages/format.d.ts +6 -0
  103. package/dist/types/messages/prune.d.ts +16 -12
  104. package/dist/types/types/stream.d.ts +1 -1
  105. package/dist/types/utils/tokens.d.ts +7 -0
  106. package/dist/types/utils/toolContent.d.ts +107 -0
  107. package/package.json +1 -1
  108. package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
  109. package/src/agents/AgentContext.ts +1 -0
  110. package/src/graphs/Graph.ts +698 -200
  111. package/src/graphs/MultiAgentGraph.ts +6 -1
  112. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1128 -4
  113. package/src/llm/anthropic/utils/message_inputs.ts +158 -12
  114. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
  115. package/src/llm/bedrock/utils/message_inputs.test.ts +49 -1
  116. package/src/llm/bedrock/utils/message_inputs.ts +86 -13
  117. package/src/llm/google/utils/common.test.ts +75 -9
  118. package/src/llm/google/utils/common.ts +34 -3
  119. package/src/llm/invoke.test.ts +488 -0
  120. package/src/llm/invoke.ts +196 -2
  121. package/src/llm/openai/index.ts +106 -6
  122. package/src/llm/openai/utils/index.ts +109 -53
  123. package/src/llm/openai/utils/messages.test.ts +330 -1
  124. package/src/llm/openrouter/index.ts +12 -2
  125. package/src/llm/openrouter/reasoning.test.ts +314 -0
  126. package/src/messages/__tests__/observationMasking.test.ts +93 -2
  127. package/src/messages/cache.tail.test.ts +193 -0
  128. package/src/messages/cache.test.ts +113 -0
  129. package/src/messages/cache.ts +92 -60
  130. package/src/messages/contextPruning.test.ts +184 -0
  131. package/src/messages/contextPruning.ts +49 -42
  132. package/src/messages/core.ts +653 -40
  133. package/src/messages/ensureThinkingBlock.test.ts +49 -1
  134. package/src/messages/foldToollessToolBlocks.test.ts +163 -5
  135. package/src/messages/format.ts +363 -91
  136. package/src/messages/formatAgentMessages.test.ts +604 -14
  137. package/src/messages/formatAgentMessages.tools.test.ts +103 -0
  138. package/src/messages/prune.ts +996 -183
  139. package/src/specs/prune.test.ts +1083 -6
  140. package/src/specs/summarization-unit.test.ts +105 -0
  141. package/src/specs/tokens.test.ts +609 -32
  142. package/src/stream.ts +23 -15
  143. package/src/summarization/__tests__/node.test.ts +77 -0
  144. package/src/summarization/node.ts +46 -18
  145. package/src/tools/ToolNode.ts +147 -68
  146. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
  147. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
  148. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
  149. package/src/tools/__tests__/directToolHooks.test.ts +255 -7
  150. package/src/tools/toolOutputReferences.ts +6 -0
  151. package/src/types/stream.ts +1 -1
  152. package/src/utils/llm.test.ts +18 -0
  153. package/src/utils/llm.ts +4 -1
  154. package/src/utils/tokens.ts +586 -31
  155. package/src/utils/toolContent.test.ts +1432 -0
  156. package/src/utils/toolContent.ts +2707 -0
@@ -9,6 +9,7 @@ import {
9
9
  isBaseMessage,
10
10
  SystemMessage,
11
11
  AIMessageChunk,
12
+ ChatMessage,
12
13
  } from '@langchain/core/messages';
13
14
  import type { RunnableConfig } from '@langchain/core/runnables';
14
15
  import type { UsageMetadata } from '@langchain/core/messages';
@@ -20,7 +21,10 @@ import {
20
21
  sanitizeOrphanToolBlocks,
21
22
  enforceOriginalContentCap,
22
23
  ORIGINAL_CONTENT_MAX_CHARS,
24
+ calculateMaxToolCallInputChars,
23
25
  createPruneMessages,
26
+ projectToolCallInputs,
27
+ serializeToolCallInput,
24
28
  } from '@/messages/prune';
25
29
  import { getLLMConfig } from '@/utils/llmConfig';
26
30
  import { ensureThinkingBlockInMessages } from '@/messages/format';
@@ -430,7 +434,269 @@ describe('Prune Messages Tests', () => {
430
434
  expect(result.context.length).toBe(3);
431
435
  expect(result.context).toEqual(messages);
432
436
  expect(result.messagesToRefine).toEqual([]);
433
- expect(result.remainingContextTokens).toBeGreaterThan(0);
437
+ expect(result.remainingContextTokens).toBe(
438
+ 95 -
439
+ Object.values(indexTokenCountMap).reduce(
440
+ (total, count) => total + count,
441
+ 0
442
+ ) -
443
+ 3
444
+ );
445
+ });
446
+
447
+ it('recounts and compacts omitted structured tool results before sending', () => {
448
+ const tokenCounter: t.TokenCounter = (message) => {
449
+ const content =
450
+ typeof message.content === 'string'
451
+ ? message.content
452
+ : JSON.stringify(message.content);
453
+ return content.length;
454
+ };
455
+ const toolCallId = 'tc-structured';
456
+ const messages: BaseMessage[] = [
457
+ new HumanMessage('query the table'),
458
+ new AIMessage({
459
+ content: '',
460
+ tool_calls: [
461
+ {
462
+ id: toolCallId,
463
+ name: 'run_select_query',
464
+ args: {},
465
+ type: 'tool_call',
466
+ },
467
+ ],
468
+ }),
469
+ new ToolMessage({
470
+ content: [
471
+ {
472
+ type: ContentTypes.TEXT,
473
+ text: JSON.stringify(
474
+ Array.from({ length: 20 }, (_, index) => ({
475
+ id: index,
476
+ value: `${'x'.repeat(100)}-${index}`,
477
+ }))
478
+ ),
479
+ },
480
+ ],
481
+ tool_call_id: toolCallId,
482
+ name: 'run_select_query',
483
+ }),
484
+ new AIMessage('The query returned 20 rows.'),
485
+ new HumanMessage('compact context'),
486
+ ];
487
+ const indexTokenCountMap: Record<string, number | undefined> = {
488
+ 0: tokenCounter(messages[0]),
489
+ 1: tokenCounter(messages[1]),
490
+ 3: tokenCounter(messages[3]),
491
+ 4: tokenCounter(messages[4]),
492
+ };
493
+ const pruneMessages = createPruneMessages({
494
+ maxTokens: 2_000,
495
+ startIndex: messages.length,
496
+ tokenCounter,
497
+ indexTokenCountMap,
498
+ reserveRatio: 0,
499
+ });
500
+
501
+ const result = pruneMessages({ messages });
502
+
503
+ const structuredResult = result.context.find(
504
+ (message) => message.getType() === 'tool'
505
+ );
506
+ expect(structuredResult).toBeDefined();
507
+ expect(typeof structuredResult?.content).toBe('string');
508
+ expect(structuredResult?.content).toContain('truncated');
509
+ expect(result.indexTokenCountMap[2]).toBeGreaterThan(0);
510
+ });
511
+
512
+ it('reconciles a stale cached legacy function-call count', () => {
513
+ const tokenCounter: t.TokenCounter = (message) => {
514
+ const functionCall = message.additional_kwargs.function_call;
515
+ return (
516
+ (typeof message.content === 'string' ? message.content.length : 0) +
517
+ (functionCall != null ? JSON.stringify(functionCall).length : 0) +
518
+ 1
519
+ );
520
+ };
521
+ const message = new AIMessage({
522
+ content: '',
523
+ additional_kwargs: {
524
+ function_call: {
525
+ name: 'legacy_lookup',
526
+ arguments: `{"query":"${'x'.repeat(10_000)}"}`,
527
+ },
528
+ },
529
+ });
530
+ const messages: BaseMessage[] = [message];
531
+ const pruneMessages = createPruneMessages({
532
+ maxTokens: 2_000,
533
+ startIndex: messages.length,
534
+ tokenCounter,
535
+ indexTokenCountMap: { 0: 1 },
536
+ reserveRatio: 0,
537
+ });
538
+
539
+ const result = pruneMessages({ messages });
540
+ const projectedFunctionCall = result.context[0].additional_kwargs
541
+ .function_call as { arguments: string };
542
+
543
+ expect(result.context[0]).not.toBe(message);
544
+ expect(projectedFunctionCall.arguments.length).toBeLessThanOrEqual(
545
+ calculateMaxToolCallInputChars(2_000)
546
+ );
547
+ expect(result.indexTokenCountMap[0]).toBe(
548
+ tokenCounter(result.context[0])
549
+ );
550
+ expect(result.indexTokenCountMap[0]).toBeGreaterThan(1);
551
+ });
552
+
553
+ it('preserves provider output usage for an uncounted current legacy call', () => {
554
+ const message = new AIMessage({
555
+ content: '',
556
+ additional_kwargs: {
557
+ function_call: {
558
+ name: 'legacy_lookup',
559
+ arguments: '{}',
560
+ },
561
+ },
562
+ });
563
+ const pruneMessages = createPruneMessages({
564
+ maxTokens: 2_000,
565
+ startIndex: 0,
566
+ tokenCounter: () => 123,
567
+ indexTokenCountMap: {},
568
+ reserveRatio: 0,
569
+ });
570
+
571
+ const result = pruneMessages({
572
+ messages: [message],
573
+ usageMetadata: {
574
+ input_tokens: 10,
575
+ output_tokens: 7,
576
+ total_tokens: 17,
577
+ },
578
+ });
579
+
580
+ expect(result.indexTokenCountMap[0]).toBe(7);
581
+ });
582
+
583
+ it('excludes a corrected tiny new tool count from calibration input', () => {
584
+ const tokenCounter = createTestTokenCounter();
585
+ const history: BaseMessage[] = [
586
+ new HumanMessage('h'.repeat(50)),
587
+ new AIMessage('a'.repeat(50)),
588
+ ];
589
+ const indexTokenCountMap: Record<string, number | undefined> = {
590
+ 0: 50,
591
+ 1: 50,
592
+ };
593
+ const pruneMessages = createPruneMessages({
594
+ maxTokens: 10_000,
595
+ startIndex: history.length,
596
+ tokenCounter,
597
+ indexTokenCountMap,
598
+ reserveRatio: 0,
599
+ });
600
+
601
+ const first = pruneMessages({
602
+ messages: history,
603
+ usageMetadata: {
604
+ input_tokens: 100,
605
+ output_tokens: 10,
606
+ total_tokens: 110,
607
+ },
608
+ });
609
+ expect(first.calibrationRatio).toBe(1);
610
+
611
+ const toolCallId = 'new-output-with-tiny-count';
612
+ const secondMessages: BaseMessage[] = [
613
+ ...history,
614
+ new HumanMessage('q'.repeat(50)),
615
+ new AIMessage({
616
+ content: '',
617
+ tool_calls: [
618
+ {
619
+ id: toolCallId,
620
+ name: 'run_select_query',
621
+ args: {},
622
+ type: 'tool_call',
623
+ },
624
+ ],
625
+ }),
626
+ new ToolMessage({
627
+ content: 'r'.repeat(50),
628
+ tool_call_id: toolCallId,
629
+ }),
630
+ ];
631
+ indexTokenCountMap[2] = 50;
632
+ indexTokenCountMap[4] = 1;
633
+
634
+ const second = pruneMessages({
635
+ messages: secondMessages,
636
+ usageMetadata: {
637
+ input_tokens: 100,
638
+ output_tokens: 10,
639
+ total_tokens: 110,
640
+ },
641
+ });
642
+
643
+ expect(second.indexTokenCountMap[4]).toBe(50);
644
+ expect(second.calibrationRatio).toBe(1);
645
+ });
646
+
647
+ it('preserves full structured output for summarization before masking', () => {
648
+ const tokenCounter: t.TokenCounter = (message) => {
649
+ const content =
650
+ typeof message.content === 'string'
651
+ ? message.content
652
+ : JSON.stringify(message.content);
653
+ return content.length;
654
+ };
655
+ const fullResult = JSON.stringify(
656
+ Array.from({ length: 50 }, (_, index) => ({
657
+ id: index,
658
+ value: `${'x'.repeat(100)}-${index}`,
659
+ }))
660
+ );
661
+ const messages: BaseMessage[] = [
662
+ new HumanMessage('query the table'),
663
+ new AIMessage({
664
+ content: '',
665
+ tool_calls: [
666
+ {
667
+ id: 'tc-summary-structured',
668
+ name: 'run_select_query',
669
+ args: {},
670
+ type: 'tool_call',
671
+ },
672
+ ],
673
+ }),
674
+ new ToolMessage({
675
+ content: [{ type: ContentTypes.TEXT, text: fullResult }],
676
+ tool_call_id: 'tc-summary-structured',
677
+ }),
678
+ new AIMessage('The query returned 50 rows.'),
679
+ new HumanMessage('summarize the context'),
680
+ ];
681
+ const indexTokenCountMap: Record<string, number | undefined> = {};
682
+ for (let i = 0; i < messages.length; i++) {
683
+ indexTokenCountMap[i] = tokenCounter(messages[i]);
684
+ }
685
+ const pruneMessages = createPruneMessages({
686
+ maxTokens: 500,
687
+ startIndex: messages.length,
688
+ tokenCounter,
689
+ indexTokenCountMap,
690
+ reserveRatio: 0,
691
+ summarizationEnabled: true,
692
+ });
693
+
694
+ const result = pruneMessages({ messages });
695
+
696
+ expect(result.newOriginalToolContent?.get(2)).toBe(fullResult);
697
+ expect(messages[2].content).not.toEqual([
698
+ { type: ContentTypes.TEXT, text: fullResult },
699
+ ]);
434
700
  });
435
701
 
436
702
  it('should prune messages when over token limit', () => {
@@ -718,6 +984,179 @@ describe('Prune Messages Tests', () => {
718
984
  expect(repaired.reclaimedTokens).toBeGreaterThan(0);
719
985
  });
720
986
 
987
+ it('strips orphan raw Responses computer calls with parsed tool calls', () => {
988
+ const tokenCounter = createTestTokenCounter();
989
+ const computerCall = new AIMessage({
990
+ content: 'Taking a screenshot.',
991
+ tool_calls: [
992
+ {
993
+ id: 'computer-orphan',
994
+ name: 'computer_use',
995
+ args: { action: { type: 'screenshot' } },
996
+ type: 'tool_call',
997
+ },
998
+ ],
999
+ response_metadata: {
1000
+ output: [
1001
+ {
1002
+ type: 'computer_call',
1003
+ id: 'computer-item',
1004
+ call_id: 'computer-orphan',
1005
+ action: { type: 'screenshot' },
1006
+ },
1007
+ ],
1008
+ },
1009
+ });
1010
+ const context: BaseMessage[] = [
1011
+ new HumanMessage('take a screenshot'),
1012
+ computerCall,
1013
+ ];
1014
+
1015
+ const repaired = repairOrphanedToolMessages({
1016
+ context,
1017
+ allMessages: context,
1018
+ tokenCounter,
1019
+ indexTokenCountMap: {
1020
+ 0: tokenCounter(context[0]),
1021
+ 1: tokenCounter(context[1]),
1022
+ },
1023
+ });
1024
+ const repairedAI = repaired.context[1] as AIMessage;
1025
+
1026
+ expect(repairedAI.tool_calls ?? []).toHaveLength(0);
1027
+ expect(repairedAI.response_metadata.output).toEqual([]);
1028
+ });
1029
+
1030
+ it('strips orphan computer calls from the LangChain tool_outputs fallback', () => {
1031
+ const tokenCounter = createTestTokenCounter();
1032
+ const computerCall = new AIMessage({
1033
+ content: 'Taking a screenshot.',
1034
+ additional_kwargs: {
1035
+ tool_outputs: [
1036
+ {
1037
+ type: 'computer_call',
1038
+ call_id: 'computer-fallback-orphan',
1039
+ action: { type: 'screenshot' },
1040
+ },
1041
+ ],
1042
+ },
1043
+ });
1044
+ const context: BaseMessage[] = [
1045
+ new HumanMessage('take a screenshot'),
1046
+ computerCall,
1047
+ ];
1048
+
1049
+ const repaired = repairOrphanedToolMessages({
1050
+ context,
1051
+ allMessages: context,
1052
+ tokenCounter,
1053
+ indexTokenCountMap: {
1054
+ 0: tokenCounter(context[0]),
1055
+ 1: tokenCounter(context[1]),
1056
+ },
1057
+ });
1058
+ const repairedAI = repaired.context[1] as AIMessage;
1059
+
1060
+ expect(repairedAI.additional_kwargs.tool_outputs).toEqual([]);
1061
+ });
1062
+
1063
+ it('retains an empty-content AI parent when one raw call remains paired', () => {
1064
+ const tokenCounter = createTestTokenCounter();
1065
+ const computerCall = new AIMessage({
1066
+ content: [],
1067
+ tool_calls: [
1068
+ {
1069
+ id: 'computer-keep',
1070
+ name: 'computer_use',
1071
+ args: { action: { type: 'screenshot' } },
1072
+ type: 'tool_call',
1073
+ },
1074
+ {
1075
+ id: 'computer-drop',
1076
+ name: 'computer_use',
1077
+ args: { action: { type: 'screenshot' } },
1078
+ type: 'tool_call',
1079
+ },
1080
+ ],
1081
+ response_metadata: {
1082
+ output: [
1083
+ {
1084
+ type: 'computer_call',
1085
+ call_id: 'computer-keep',
1086
+ action: { type: 'screenshot' },
1087
+ },
1088
+ {
1089
+ type: 'computer_call',
1090
+ call_id: 'computer-drop',
1091
+ action: { type: 'screenshot' },
1092
+ },
1093
+ ],
1094
+ },
1095
+ });
1096
+ const computerOutput = new ToolMessage({
1097
+ content: 'data:image/png;base64,AA==',
1098
+ tool_call_id: 'computer-keep',
1099
+ additional_kwargs: { type: 'computer_call_output' },
1100
+ });
1101
+ const context: BaseMessage[] = [computerCall, computerOutput];
1102
+
1103
+ const repaired = repairOrphanedToolMessages({
1104
+ context,
1105
+ allMessages: context,
1106
+ tokenCounter,
1107
+ indexTokenCountMap: {
1108
+ 0: tokenCounter(computerCall),
1109
+ 1: tokenCounter(computerOutput),
1110
+ },
1111
+ });
1112
+
1113
+ expect(repaired.context).toHaveLength(2);
1114
+ const repairedAI = repaired.context[0] as AIMessage;
1115
+ expect(repairedAI.tool_calls?.map((call) => call.id)).toEqual([
1116
+ 'computer-keep',
1117
+ ]);
1118
+ expect(repairedAI.response_metadata.output).toEqual([
1119
+ expect.objectContaining({ call_id: 'computer-keep' }),
1120
+ ]);
1121
+ expect(repaired.context[1]).toBe(computerOutput);
1122
+ });
1123
+
1124
+ it('recognizes raw computer calls on generic assistant messages', () => {
1125
+ const tokenCounter = createTestTokenCounter();
1126
+ const computerCall = new ChatMessage({
1127
+ role: 'assistant',
1128
+ content: '',
1129
+ response_metadata: {
1130
+ output: [
1131
+ {
1132
+ type: 'computer_call',
1133
+ call_id: 'generic-computer',
1134
+ action: { type: 'screenshot' },
1135
+ },
1136
+ ],
1137
+ },
1138
+ });
1139
+ const computerOutput = new ToolMessage({
1140
+ content: 'data:image/png;base64,AA==',
1141
+ tool_call_id: 'generic-computer',
1142
+ additional_kwargs: { type: 'computer_call_output' },
1143
+ });
1144
+ const context: BaseMessage[] = [computerCall, computerOutput];
1145
+
1146
+ const repaired = repairOrphanedToolMessages({
1147
+ context,
1148
+ allMessages: context,
1149
+ tokenCounter,
1150
+ indexTokenCountMap: {
1151
+ 0: tokenCounter(computerCall),
1152
+ 1: tokenCounter(computerOutput),
1153
+ },
1154
+ });
1155
+
1156
+ expect(repaired.context).toEqual(context);
1157
+ expect(repaired.droppedOrphanCount).toBe(0);
1158
+ });
1159
+
721
1160
  it('should drop AI message entirely when it has only tool_use blocks with no text', () => {
722
1161
  const tokenCounter = createTestTokenCounter();
723
1162
  const context = [
@@ -999,6 +1438,505 @@ describe('Prune Messages Tests', () => {
999
1438
  });
1000
1439
  });
1001
1440
 
1441
+ describe('projectToolCallInputs', () => {
1442
+ it('uses the shared 15%-of-context cap with a 200K ceiling', () => {
1443
+ expect(calculateMaxToolCallInputChars()).toBe(200_000);
1444
+ expect(calculateMaxToolCallInputChars(0)).toBe(200_000);
1445
+ expect(calculateMaxToolCallInputChars(1_000)).toBe(600);
1446
+ expect(calculateMaxToolCallInputChars(1_000_000)).toBe(200_000);
1447
+ expect(serializeToolCallInput(undefined, 4)).toBe('null');
1448
+ });
1449
+
1450
+ it('returns the original array when every input is already safe and bounded', () => {
1451
+ const messages: BaseMessage[] = [
1452
+ new HumanMessage('Run it'),
1453
+ new AIMessage({
1454
+ content: '',
1455
+ tool_calls: [
1456
+ { id: 'safe-call', name: 'lookup', args: { query: 'safe' } },
1457
+ ],
1458
+ }),
1459
+ ];
1460
+
1461
+ const projected = projectToolCallInputs(messages, 1_000);
1462
+
1463
+ expect(projected).toBe(messages);
1464
+ expect(projected[1]).toBe(messages[1]);
1465
+ });
1466
+
1467
+ it('copy-on-write projects inline input and tool_calls args on AIMessageChunk', () => {
1468
+ let toJSONCalls = 0;
1469
+ const chunk = new AIMessageChunk({
1470
+ content: [
1471
+ { type: 'text', text: 'Running.' },
1472
+ {
1473
+ type: 'tool_use',
1474
+ id: 'chunk-call',
1475
+ name: 'evaluate_script',
1476
+ input: { code: 'x'.repeat(2_000) },
1477
+ },
1478
+ ],
1479
+ id: 'chunk-id',
1480
+ additional_kwargs: { marker: 'keep' },
1481
+ response_metadata: { model: 'chunk-model' },
1482
+ tool_calls: [
1483
+ {
1484
+ id: 'chunk-call',
1485
+ name: 'evaluate_script',
1486
+ args: {
1487
+ query: 'safe',
1488
+ toJSON() {
1489
+ toJSONCalls++;
1490
+ return { code: 'y'.repeat(2_000_000) };
1491
+ },
1492
+ },
1493
+ },
1494
+ ],
1495
+ });
1496
+ chunk.tool_call_chunks = [
1497
+ {
1498
+ id: 'chunk-call',
1499
+ index: 0,
1500
+ name: 'evaluate_script',
1501
+ args: '',
1502
+ type: 'tool_call_chunk',
1503
+ },
1504
+ ];
1505
+ const messages: BaseMessage[] = [chunk];
1506
+
1507
+ const projected = projectToolCallInputs(messages, 200);
1508
+
1509
+ expect(projected).not.toBe(messages);
1510
+ expect(projected[0]).not.toBe(chunk);
1511
+ expect(projected[0]).toBeInstanceOf(AIMessageChunk);
1512
+ expect(toJSONCalls).toBe(0);
1513
+ const projectedChunk = projected[0] as AIMessageChunk;
1514
+ expect(projectedChunk.id).toBe('chunk-id');
1515
+ expect(projectedChunk.additional_kwargs).toEqual({ marker: 'keep' });
1516
+ expect(projectedChunk.response_metadata).toEqual({
1517
+ model: 'chunk-model',
1518
+ });
1519
+ expect(projectedChunk.tool_call_chunks).toEqual(chunk.tool_call_chunks);
1520
+ const inlineToolUse = (
1521
+ projectedChunk.content as Array<Record<string, unknown>>
1522
+ ).find((block) => block.type === 'tool_use');
1523
+ expect(inlineToolUse?.input).toMatchObject({
1524
+ _truncated: expect.stringContaining('truncated'),
1525
+ _originalChars: expect.any(Number),
1526
+ });
1527
+ expect(projectedChunk.tool_calls?.[0].args).toEqual({ query: 'safe' });
1528
+ expect(
1529
+ (
1530
+ (chunk.content as Array<Record<string, unknown>>)[1].input as {
1531
+ code: string;
1532
+ }
1533
+ ).code
1534
+ ).toHaveLength(2_000);
1535
+ });
1536
+
1537
+ it('normalizes cyclic and bigint args into provider-safe JSON values', () => {
1538
+ const cyclic: Record<string, unknown> = { query: 'safe' };
1539
+ cyclic.self = cyclic;
1540
+ const messages: BaseMessage[] = [
1541
+ new AIMessage({
1542
+ content: '',
1543
+ tool_calls: [
1544
+ {
1545
+ id: 'non-json-call',
1546
+ name: 'lookup',
1547
+ args: { cyclic, count: 7n },
1548
+ },
1549
+ ],
1550
+ }),
1551
+ ];
1552
+
1553
+ const projected = projectToolCallInputs(messages, 1_000);
1554
+
1555
+ expect(projected).not.toBe(messages);
1556
+ const args = (projected[0] as AIMessage).tool_calls?.[0].args as {
1557
+ cyclic: { self: string };
1558
+ count: string;
1559
+ };
1560
+ expect(args.cyclic.self).toBe('[Circular]');
1561
+ expect(args.count).toBe('7');
1562
+ expect(() => JSON.stringify(args)).not.toThrow();
1563
+ });
1564
+
1565
+ it('bounds legacy additional_kwargs.function_call arguments', () => {
1566
+ const message = new AIMessage({
1567
+ content: '',
1568
+ additional_kwargs: {
1569
+ marker: 'preserved',
1570
+ function_call: {
1571
+ name: 'legacy_lookup',
1572
+ arguments: `{"query":"${'x'.repeat(2_000)}"}`,
1573
+ },
1574
+ },
1575
+ });
1576
+ const messages: BaseMessage[] = [message];
1577
+
1578
+ const projected = projectToolCallInputs(messages, 200);
1579
+
1580
+ expect(projected).not.toBe(messages);
1581
+ expect(projected[0]).not.toBe(message);
1582
+ expect(projected[0].additional_kwargs.marker).toBe('preserved');
1583
+ const projectedFunctionCall = projected[0].additional_kwargs
1584
+ .function_call as {
1585
+ name: string;
1586
+ arguments: string;
1587
+ };
1588
+ expect(projectedFunctionCall.name).toBe('legacy_lookup');
1589
+ expect(projectedFunctionCall.arguments.length).toBeLessThanOrEqual(200);
1590
+ expect(() => JSON.parse(projectedFunctionCall.arguments)).not.toThrow();
1591
+ expect(
1592
+ (
1593
+ message.additional_kwargs.function_call as {
1594
+ arguments: string;
1595
+ }
1596
+ ).arguments.length
1597
+ ).toBeGreaterThan(2_000);
1598
+ });
1599
+
1600
+ it('normalizes legacy function calls without invoking serialization hooks', () => {
1601
+ const toJSON = jest.fn(() => ({
1602
+ name: 'legacy_lookup',
1603
+ arguments: 'x'.repeat(500_000),
1604
+ }));
1605
+ const originalFunctionCall = {
1606
+ name: 'legacy_lookup',
1607
+ arguments: '{}',
1608
+ toJSON,
1609
+ };
1610
+ const message = new AIMessage({
1611
+ content: '',
1612
+ additional_kwargs: {
1613
+ function_call: originalFunctionCall,
1614
+ },
1615
+ });
1616
+
1617
+ const [projected] = projectToolCallInputs([message], 200);
1618
+ const projectedFunctionCall = projected.additional_kwargs
1619
+ .function_call as {
1620
+ name: string;
1621
+ arguments: string;
1622
+ };
1623
+ const serialized = JSON.stringify(projected.additional_kwargs);
1624
+
1625
+ expect(projected).not.toBe(message);
1626
+ expect(projectedFunctionCall).toEqual({
1627
+ name: 'legacy_lookup',
1628
+ arguments: '{}',
1629
+ });
1630
+ expect(Object.getPrototypeOf(projectedFunctionCall)).toBe(
1631
+ Object.prototype
1632
+ );
1633
+ expect(serialized.length).toBeLessThanOrEqual(300);
1634
+ expect(toJSON).not.toHaveBeenCalled();
1635
+ expect(projectToolCallInputs([projected], 200)[0]).toBe(projected);
1636
+ });
1637
+
1638
+ it('omits a proxied legacy function call without propagating descriptor traps', () => {
1639
+ let descriptorCalls = 0;
1640
+ const legacyProxy = new Proxy(
1641
+ {},
1642
+ {
1643
+ getOwnPropertyDescriptor() {
1644
+ descriptorCalls++;
1645
+ throw new Error('descriptor trap');
1646
+ },
1647
+ }
1648
+ );
1649
+ const message = new AIMessage({
1650
+ content: '',
1651
+ additional_kwargs: {},
1652
+ });
1653
+ (message.additional_kwargs as Record<string, unknown>).function_call =
1654
+ legacyProxy;
1655
+
1656
+ expect(() => projectToolCallInputs([message], 200)).not.toThrow();
1657
+ const [projected] = projectToolCallInputs([message], 200);
1658
+
1659
+ expect(projected.additional_kwargs.function_call).toBeUndefined();
1660
+ expect(descriptorCalls).toBe(0);
1661
+ expect(projectToolCallInputs([projected], 200)[0]).toBe(projected);
1662
+ });
1663
+
1664
+ it('shadows proxy-backed legacy metadata without invoking its get trap', () => {
1665
+ let getCalls = 0;
1666
+ const hugeFunctionCall = {
1667
+ name: 'legacy_lookup',
1668
+ arguments: 'x'.repeat(500_000),
1669
+ };
1670
+ const message = new AIMessage({
1671
+ content: '',
1672
+ additional_kwargs: {},
1673
+ });
1674
+ message.additional_kwargs = new Proxy(
1675
+ {},
1676
+ {
1677
+ get(_target, property) {
1678
+ getCalls++;
1679
+ return property === 'function_call' ? hugeFunctionCall : undefined;
1680
+ },
1681
+ getOwnPropertyDescriptor() {
1682
+ return undefined;
1683
+ },
1684
+ getPrototypeOf() {
1685
+ return null;
1686
+ },
1687
+ }
1688
+ );
1689
+
1690
+ const [projected] = projectToolCallInputs([message], 200);
1691
+
1692
+ expect(getCalls).toBe(0);
1693
+ expect(projected).not.toBe(message);
1694
+ expect(projected.additional_kwargs.function_call).toBeUndefined();
1695
+ expect(projectToolCallInputs([projected], 200)[0]).toBe(projected);
1696
+ });
1697
+
1698
+ it('shadows legacy calls beyond the bounded prototype walk', () => {
1699
+ const inherited = {
1700
+ function_call: {
1701
+ name: 'legacy_lookup',
1702
+ arguments: 'x'.repeat(500_000),
1703
+ },
1704
+ };
1705
+ let additionalKwargs = inherited;
1706
+ for (let i = 0; i < 101; i++) {
1707
+ additionalKwargs = Object.create(additionalKwargs) as typeof inherited;
1708
+ }
1709
+ const message = new AIMessage({
1710
+ content: '',
1711
+ additional_kwargs: {},
1712
+ });
1713
+ message.additional_kwargs = additionalKwargs;
1714
+
1715
+ const [projected] = projectToolCallInputs([message], 200);
1716
+
1717
+ expect(projected).not.toBe(message);
1718
+ expect(
1719
+ Object.prototype.hasOwnProperty.call(
1720
+ projected.additional_kwargs,
1721
+ 'function_call'
1722
+ )
1723
+ ).toBe(true);
1724
+ expect(projected.additional_kwargs.function_call).toBeUndefined();
1725
+ expect(projectToolCallInputs([projected], 200)[0]).toBe(projected);
1726
+ });
1727
+
1728
+ it('stabilizes an omitted invalid legacy function call', () => {
1729
+ const message = new AIMessage({
1730
+ content: '',
1731
+ additional_kwargs: {},
1732
+ });
1733
+ (message.additional_kwargs as Record<string, unknown>).function_call =
1734
+ null;
1735
+
1736
+ const [first] = projectToolCallInputs([message], 200);
1737
+ const [second] = projectToolCallInputs([first], 200);
1738
+
1739
+ expect(first).not.toBe(message);
1740
+ expect(first.additional_kwargs.function_call).toBeUndefined();
1741
+ expect(second).toBe(first);
1742
+ });
1743
+
1744
+ it('projects every provider-consumed tool-call representation to the same bounded wire args', () => {
1745
+ let getterCalls = 0;
1746
+ let toJSONCalls = 0;
1747
+ const adversarialArgs: Record<string, unknown> = {
1748
+ query: 'safe',
1749
+ payload: 'x'.repeat(2_000),
1750
+ };
1751
+ Object.defineProperty(adversarialArgs, 'expanded', {
1752
+ enumerable: true,
1753
+ get() {
1754
+ getterCalls++;
1755
+ return 'y'.repeat(2_000_000);
1756
+ },
1757
+ });
1758
+ adversarialArgs.self = adversarialArgs;
1759
+ adversarialArgs.toJSON = (): Record<string, string> => {
1760
+ toJSONCalls++;
1761
+ return { payload: 'z'.repeat(2_000_000) };
1762
+ };
1763
+
1764
+ const message = new AIMessageChunk({
1765
+ content: [
1766
+ {
1767
+ type: 'tool_use',
1768
+ id: 'inline-use',
1769
+ name: 'evaluate',
1770
+ input: { code: 'i'.repeat(2_000) },
1771
+ },
1772
+ {
1773
+ type: 'tool_call',
1774
+ id: 'inline-call',
1775
+ name: 'evaluate',
1776
+ args: { code: 'a'.repeat(2_000) },
1777
+ tool_call: {
1778
+ type: 'tool_call',
1779
+ id: 'nested-call',
1780
+ name: 'evaluate',
1781
+ args: { code: 'n'.repeat(2_000) },
1782
+ },
1783
+ },
1784
+ ],
1785
+ tool_calls: [
1786
+ {
1787
+ id: 'shared-call',
1788
+ name: 'evaluate',
1789
+ args: adversarialArgs,
1790
+ },
1791
+ {
1792
+ id: 'custom-call',
1793
+ name: 'shell',
1794
+ args: { input: 'c'.repeat(2_000) },
1795
+ },
1796
+ {
1797
+ id: 'computer-call',
1798
+ name: 'computer_use',
1799
+ args: { action: { type: 'type', text: 't'.repeat(2_000) } },
1800
+ },
1801
+ ],
1802
+ additional_kwargs: {
1803
+ tool_calls: [
1804
+ {
1805
+ id: 'shared-call',
1806
+ type: 'function',
1807
+ function: {
1808
+ name: 'evaluate',
1809
+ arguments: `{"code":"${'r'.repeat(2_000)}"}`,
1810
+ },
1811
+ },
1812
+ {
1813
+ id: 'raw-only',
1814
+ type: 'function',
1815
+ function: {
1816
+ name: 'evaluate',
1817
+ arguments: `{"code":"${'o'.repeat(2_000)}"}`,
1818
+ },
1819
+ },
1820
+ ],
1821
+ },
1822
+ response_metadata: {
1823
+ output: [
1824
+ {
1825
+ type: 'function_call',
1826
+ id: 'fc_shared',
1827
+ call_id: 'shared-call',
1828
+ name: 'evaluate',
1829
+ arguments: `{"code":"${'w'.repeat(2_000)}"}`,
1830
+ },
1831
+ {
1832
+ type: 'function_call',
1833
+ id: 'fc_raw',
1834
+ call_id: 'response-only',
1835
+ name: 'evaluate',
1836
+ arguments: `{"code":"${'q'.repeat(2_000)}"}`,
1837
+ },
1838
+ {
1839
+ type: 'custom_tool_call',
1840
+ id: 'ctc_shared',
1841
+ call_id: 'custom-call',
1842
+ name: 'shell',
1843
+ input: 'c'.repeat(2_000),
1844
+ },
1845
+ {
1846
+ type: 'computer_call',
1847
+ id: 'cc_shared',
1848
+ call_id: 'computer-call',
1849
+ action: { type: 'type', text: 't'.repeat(2_000) },
1850
+ },
1851
+ ],
1852
+ },
1853
+ });
1854
+ Object.defineProperty(message, 'projectionMarker', {
1855
+ configurable: false,
1856
+ enumerable: false,
1857
+ value: 'preserved',
1858
+ writable: false,
1859
+ });
1860
+ const messages: BaseMessage[] = [message];
1861
+
1862
+ const projected = projectToolCallInputs(messages, 200);
1863
+
1864
+ expect(projected).not.toBe(messages);
1865
+ expect(projected[0]).toBeInstanceOf(AIMessageChunk);
1866
+ expect(getterCalls).toBe(0);
1867
+ expect(toJSONCalls).toBe(0);
1868
+ expect(
1869
+ Object.getOwnPropertyDescriptor(projected[0], 'projectionMarker')
1870
+ ).toEqual(Object.getOwnPropertyDescriptor(message, 'projectionMarker'));
1871
+
1872
+ const projectedMessage = projected[0] as AIMessageChunk;
1873
+ const content = projectedMessage.content as Array<
1874
+ Record<string, unknown>
1875
+ >;
1876
+ const inlineUse = content[0];
1877
+ const inlineCall = content[1];
1878
+ const nestedCall = inlineCall.tool_call as Record<string, unknown>;
1879
+ for (const input of [
1880
+ inlineUse.input,
1881
+ inlineCall.args,
1882
+ nestedCall.args,
1883
+ projectedMessage.tool_calls?.[0].args,
1884
+ ]) {
1885
+ const serialized = serializeToolCallInput(input, 200);
1886
+ expect(serialized.length).toBeLessThanOrEqual(200);
1887
+ expect(() => JSON.parse(serialized)).not.toThrow();
1888
+ }
1889
+
1890
+ const canonicalArguments = serializeToolCallInput(
1891
+ projectedMessage.tool_calls?.[0].args,
1892
+ 200
1893
+ );
1894
+ const rawToolCalls = projectedMessage.additional_kwargs
1895
+ .tool_calls as Array<{
1896
+ id: string;
1897
+ function: { arguments: string };
1898
+ }>;
1899
+ expect(rawToolCalls[0].function.arguments).toBe(canonicalArguments);
1900
+ expect(rawToolCalls[1].function.arguments.length).toBeLessThanOrEqual(
1901
+ 200
1902
+ );
1903
+ expect(() =>
1904
+ JSON.parse(rawToolCalls[1].function.arguments)
1905
+ ).not.toThrow();
1906
+
1907
+ const responseOutput = projectedMessage.response_metadata
1908
+ .output as Array<{
1909
+ type: string;
1910
+ call_id: string;
1911
+ arguments?: string;
1912
+ input?: string;
1913
+ action?: unknown;
1914
+ }>;
1915
+ expect(responseOutput[0].arguments).toBe(canonicalArguments);
1916
+ expect(responseOutput[1].arguments?.length).toBeLessThanOrEqual(200);
1917
+ expect(() => JSON.parse(responseOutput[1].arguments ?? '')).not.toThrow();
1918
+ expect(responseOutput[2].input?.length).toBeLessThanOrEqual(200);
1919
+ expect(
1920
+ serializeToolCallInput(responseOutput[3].action, 200).length
1921
+ ).toBeLessThanOrEqual(200);
1922
+
1923
+ expect(
1924
+ (
1925
+ message.additional_kwargs.tool_calls as Array<{
1926
+ function: { arguments: string };
1927
+ }>
1928
+ )[0].function.arguments.length
1929
+ ).toBeGreaterThan(2_000);
1930
+ expect(
1931
+ (
1932
+ message.response_metadata.output as Array<{
1933
+ arguments: string;
1934
+ }>
1935
+ )[0].arguments.length
1936
+ ).toBeGreaterThan(2_000);
1937
+ });
1938
+ });
1939
+
1002
1940
  describe('preFlightTruncateToolCallInputs', () => {
1003
1941
  it('should truncate oversized tool_use input fields in AI messages', () => {
1004
1942
  const tokenCounter = createTestTokenCounter();
@@ -1066,6 +2004,88 @@ describe('Prune Messages Tests', () => {
1066
2004
  expect(tc.args).toHaveProperty('_truncated');
1067
2005
  });
1068
2006
 
2007
+ it('truncates tool_calls-only args and preserves AI message metadata', () => {
2008
+ let recounts = 0;
2009
+ const tokenCounter: t.TokenCounter = () => {
2010
+ recounts++;
2011
+ return 17;
2012
+ };
2013
+ const messages: BaseMessage[] = [
2014
+ new AIMessage({
2015
+ content: '',
2016
+ id: 'ai-tool-call-only',
2017
+ name: 'assistant',
2018
+ additional_kwargs: { trace_marker: 'keep' },
2019
+ response_metadata: { model: 'test-model' },
2020
+ tool_calls: [
2021
+ {
2022
+ id: 'tool-exec',
2023
+ name: 'evaluate_script',
2024
+ args: { code: 'x'.repeat(5_000) },
2025
+ },
2026
+ ],
2027
+ }),
2028
+ ];
2029
+ const indexTokenCountMap: Record<string, number | undefined> = { 0: 1 };
2030
+
2031
+ const truncated = preFlightTruncateToolCallInputs({
2032
+ messages,
2033
+ maxContextTokens: 1_000,
2034
+ indexTokenCountMap,
2035
+ tokenCounter,
2036
+ });
2037
+
2038
+ expect(truncated).toBe(1);
2039
+ expect(recounts).toBe(1);
2040
+ expect(indexTokenCountMap[0]).toBe(17);
2041
+ const projected = messages[0] as AIMessage;
2042
+ expect(projected.content).toBe('');
2043
+ expect(projected.id).toBe('ai-tool-call-only');
2044
+ expect(projected.name).toBe('assistant');
2045
+ expect(projected.additional_kwargs).toEqual({ trace_marker: 'keep' });
2046
+ expect(projected.response_metadata).toEqual({ model: 'test-model' });
2047
+ expect(projected.tool_calls?.[0].args).toMatchObject({
2048
+ _truncated: expect.stringContaining('truncated'),
2049
+ _originalChars: expect.any(Number),
2050
+ });
2051
+ });
2052
+
2053
+ it('neutralizes adversarial tool-call args without invoking toJSON', () => {
2054
+ let toJSONCalls = 0;
2055
+ const messages: BaseMessage[] = [
2056
+ new AIMessage({
2057
+ content: '',
2058
+ tool_calls: [
2059
+ {
2060
+ id: 'tool-adversarial',
2061
+ name: 'lookup',
2062
+ args: {
2063
+ query: 'safe',
2064
+ toJSON() {
2065
+ toJSONCalls++;
2066
+ return { query: 'x'.repeat(2_000_000) };
2067
+ },
2068
+ },
2069
+ },
2070
+ ],
2071
+ }),
2072
+ ];
2073
+ const indexTokenCountMap: Record<string, number | undefined> = { 0: 1 };
2074
+
2075
+ const changed = preFlightTruncateToolCallInputs({
2076
+ messages,
2077
+ maxContextTokens: 1_000,
2078
+ indexTokenCountMap,
2079
+ tokenCounter: () => 5,
2080
+ });
2081
+
2082
+ expect(changed).toBe(1);
2083
+ expect(toJSONCalls).toBe(0);
2084
+ expect((messages[0] as AIMessage).tool_calls?.[0].args).toEqual({
2085
+ query: 'safe',
2086
+ });
2087
+ });
2088
+
1069
2089
  it('should not truncate inputs that fit within the budget', () => {
1070
2090
  const tokenCounter = createTestTokenCounter();
1071
2091
  const messages: BaseMessage[] = [
@@ -1147,9 +2167,9 @@ describe('Prune Messages Tests', () => {
1147
2167
 
1148
2168
  // Total message tokens: 11 + 2 + 12 + 4 = 29
1149
2169
  // Instruction tokens: 20 (simulating system prompt overhead)
1150
- // Effective budget for messages: 50 - 20 = 30 fits all 29 tokens
2170
+ // Effective budget: 52 - 20 - 3 reply primer = 29 → exact fit
1151
2171
  const pruneMessages = createPruneMessages({
1152
- maxTokens: 50,
2172
+ maxTokens: 52,
1153
2173
  startIndex: 0,
1154
2174
  tokenCounter,
1155
2175
  indexTokenCountMap,
@@ -1159,7 +2179,7 @@ describe('Prune Messages Tests', () => {
1159
2179
 
1160
2180
  const result = pruneMessages({ messages });
1161
2181
 
1162
- // All messages should fit: 29 message tokens + 20 instruction = 49 ≤ 50
2182
+ // All messages fit exactly with instructions and reply framing.
1163
2183
  expect(result.context.length).toBe(4);
1164
2184
  expect(result.context).toEqual(messages);
1165
2185
  expect(result.messagesToRefine).toEqual([]);
@@ -1418,6 +2438,63 @@ describe('Prune Messages Tests', () => {
1418
2438
  const types = result.context.map((m) => m.getType());
1419
2439
  expect(types).toContain('human');
1420
2440
  });
2441
+
2442
+ it('emergency-truncates tool_calls-only args when content is empty', () => {
2443
+ const tokenCounter: t.TokenCounter = (message) => {
2444
+ let chars =
2445
+ typeof message.content === 'string' ? message.content.length : 0;
2446
+ if (message.getType() === 'ai') {
2447
+ for (const toolCall of (message as AIMessage).tool_calls ?? []) {
2448
+ chars +=
2449
+ toolCall.name.length + JSON.stringify(toolCall.args).length;
2450
+ }
2451
+ }
2452
+ return Math.max(1, Math.ceil(chars / 4));
2453
+ };
2454
+ const messages: BaseMessage[] = [
2455
+ new AIMessage({
2456
+ content: '',
2457
+ tool_calls: [
2458
+ {
2459
+ id: 'tool-call-only',
2460
+ name: 'evaluate_script',
2461
+ args: { code: 'x'.repeat(4_000) },
2462
+ },
2463
+ ],
2464
+ }),
2465
+ new ToolMessage({
2466
+ content: 'OK',
2467
+ tool_call_id: 'tool-call-only',
2468
+ name: 'evaluate_script',
2469
+ }),
2470
+ ];
2471
+ const indexTokenCountMap: Record<string, number | undefined> = {
2472
+ 0: tokenCounter(messages[0]),
2473
+ 1: tokenCounter(messages[1]),
2474
+ };
2475
+ const pruneMessages = createPruneMessages({
2476
+ maxTokens: 1_000,
2477
+ startIndex: 0,
2478
+ tokenCounter,
2479
+ indexTokenCountMap,
2480
+ getInstructionTokens: () => 900,
2481
+ reserveRatio: 0,
2482
+ });
2483
+
2484
+ const result = pruneMessages({ messages });
2485
+
2486
+ const projectedAI = result.context.find(
2487
+ (message) => message.getType() === 'ai'
2488
+ ) as AIMessage | undefined;
2489
+ expect(projectedAI).toBeDefined();
2490
+ expect(projectedAI?.content).toBe('');
2491
+ const projectedArgs = projectedAI?.tool_calls?.[0].args as
2492
+ | { _truncated?: string; _originalChars?: number }
2493
+ | undefined;
2494
+ expect(projectedArgs?._truncated).toContain('truncated');
2495
+ expect(projectedArgs?._truncated?.length).toBeLessThanOrEqual(200);
2496
+ expect(projectedArgs?._originalChars).toBeGreaterThan(200);
2497
+ });
1421
2498
  });
1422
2499
 
1423
2500
  describe('Empty messages guard', () => {
@@ -1444,10 +2521,10 @@ describe('Prune Messages Tests', () => {
1444
2521
  expect(result.context).toEqual([]);
1445
2522
  expect(result.messagesToRefine).toEqual([]);
1446
2523
  expect(result.prePruneContextTokens).toBe(0);
1447
- /** Reserve-adjusted budget (8000 5%) minus instruction overhead */
2524
+ /** Reserve-adjusted budget minus instructions and reply primer */
1448
2525
  expect(result.contextBudget).toBe(7600);
1449
2526
  expect(result.effectiveInstructionTokens).toBe(4000);
1450
- expect(result.remainingContextTokens).toBe(3600);
2527
+ expect(result.remainingContextTokens).toBe(3597);
1451
2528
  });
1452
2529
  });
1453
2530