@librechat/agents 3.1.57 → 3.1.61

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 (214) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +326 -62
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +13 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +7 -27
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/graphs/Graph.cjs +303 -222
  8. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  9. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +4 -4
  10. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +6 -2
  12. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/init.cjs +60 -0
  14. package/dist/cjs/llm/init.cjs.map +1 -0
  15. package/dist/cjs/llm/invoke.cjs +90 -0
  16. package/dist/cjs/llm/invoke.cjs.map +1 -0
  17. package/dist/cjs/llm/openai/index.cjs +2 -0
  18. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  19. package/dist/cjs/llm/request.cjs +41 -0
  20. package/dist/cjs/llm/request.cjs.map +1 -0
  21. package/dist/cjs/main.cjs +40 -0
  22. package/dist/cjs/main.cjs.map +1 -1
  23. package/dist/cjs/messages/cache.cjs +76 -89
  24. package/dist/cjs/messages/cache.cjs.map +1 -1
  25. package/dist/cjs/messages/contextPruning.cjs +156 -0
  26. package/dist/cjs/messages/contextPruning.cjs.map +1 -0
  27. package/dist/cjs/messages/contextPruningSettings.cjs +53 -0
  28. package/dist/cjs/messages/contextPruningSettings.cjs.map +1 -0
  29. package/dist/cjs/messages/core.cjs +23 -37
  30. package/dist/cjs/messages/core.cjs.map +1 -1
  31. package/dist/cjs/messages/format.cjs +156 -11
  32. package/dist/cjs/messages/format.cjs.map +1 -1
  33. package/dist/cjs/messages/prune.cjs +1161 -49
  34. package/dist/cjs/messages/prune.cjs.map +1 -1
  35. package/dist/cjs/messages/reducer.cjs +87 -0
  36. package/dist/cjs/messages/reducer.cjs.map +1 -0
  37. package/dist/cjs/run.cjs +81 -42
  38. package/dist/cjs/run.cjs.map +1 -1
  39. package/dist/cjs/stream.cjs +54 -7
  40. package/dist/cjs/stream.cjs.map +1 -1
  41. package/dist/cjs/summarization/index.cjs +75 -0
  42. package/dist/cjs/summarization/index.cjs.map +1 -0
  43. package/dist/cjs/summarization/node.cjs +663 -0
  44. package/dist/cjs/summarization/node.cjs.map +1 -0
  45. package/dist/cjs/tools/ToolNode.cjs +16 -8
  46. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  47. package/dist/cjs/tools/handlers.cjs +2 -0
  48. package/dist/cjs/tools/handlers.cjs.map +1 -1
  49. package/dist/cjs/utils/errors.cjs +115 -0
  50. package/dist/cjs/utils/errors.cjs.map +1 -0
  51. package/dist/cjs/utils/events.cjs +17 -0
  52. package/dist/cjs/utils/events.cjs.map +1 -1
  53. package/dist/cjs/utils/handlers.cjs +16 -0
  54. package/dist/cjs/utils/handlers.cjs.map +1 -1
  55. package/dist/cjs/utils/llm.cjs +10 -0
  56. package/dist/cjs/utils/llm.cjs.map +1 -1
  57. package/dist/cjs/utils/tokens.cjs +247 -14
  58. package/dist/cjs/utils/tokens.cjs.map +1 -1
  59. package/dist/cjs/utils/truncation.cjs +107 -0
  60. package/dist/cjs/utils/truncation.cjs.map +1 -0
  61. package/dist/esm/agents/AgentContext.mjs +325 -61
  62. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  63. package/dist/esm/common/enum.mjs +13 -0
  64. package/dist/esm/common/enum.mjs.map +1 -1
  65. package/dist/esm/events.mjs +8 -28
  66. package/dist/esm/events.mjs.map +1 -1
  67. package/dist/esm/graphs/Graph.mjs +307 -226
  68. package/dist/esm/graphs/Graph.mjs.map +1 -1
  69. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +4 -4
  70. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  71. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +6 -2
  72. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  73. package/dist/esm/llm/init.mjs +58 -0
  74. package/dist/esm/llm/init.mjs.map +1 -0
  75. package/dist/esm/llm/invoke.mjs +87 -0
  76. package/dist/esm/llm/invoke.mjs.map +1 -0
  77. package/dist/esm/llm/openai/index.mjs +2 -0
  78. package/dist/esm/llm/openai/index.mjs.map +1 -1
  79. package/dist/esm/llm/request.mjs +38 -0
  80. package/dist/esm/llm/request.mjs.map +1 -0
  81. package/dist/esm/main.mjs +13 -3
  82. package/dist/esm/main.mjs.map +1 -1
  83. package/dist/esm/messages/cache.mjs +76 -89
  84. package/dist/esm/messages/cache.mjs.map +1 -1
  85. package/dist/esm/messages/contextPruning.mjs +154 -0
  86. package/dist/esm/messages/contextPruning.mjs.map +1 -0
  87. package/dist/esm/messages/contextPruningSettings.mjs +50 -0
  88. package/dist/esm/messages/contextPruningSettings.mjs.map +1 -0
  89. package/dist/esm/messages/core.mjs +23 -37
  90. package/dist/esm/messages/core.mjs.map +1 -1
  91. package/dist/esm/messages/format.mjs +156 -11
  92. package/dist/esm/messages/format.mjs.map +1 -1
  93. package/dist/esm/messages/prune.mjs +1158 -52
  94. package/dist/esm/messages/prune.mjs.map +1 -1
  95. package/dist/esm/messages/reducer.mjs +83 -0
  96. package/dist/esm/messages/reducer.mjs.map +1 -0
  97. package/dist/esm/run.mjs +82 -43
  98. package/dist/esm/run.mjs.map +1 -1
  99. package/dist/esm/stream.mjs +54 -7
  100. package/dist/esm/stream.mjs.map +1 -1
  101. package/dist/esm/summarization/index.mjs +73 -0
  102. package/dist/esm/summarization/index.mjs.map +1 -0
  103. package/dist/esm/summarization/node.mjs +659 -0
  104. package/dist/esm/summarization/node.mjs.map +1 -0
  105. package/dist/esm/tools/ToolNode.mjs +16 -8
  106. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  107. package/dist/esm/tools/handlers.mjs +2 -0
  108. package/dist/esm/tools/handlers.mjs.map +1 -1
  109. package/dist/esm/utils/errors.mjs +111 -0
  110. package/dist/esm/utils/errors.mjs.map +1 -0
  111. package/dist/esm/utils/events.mjs +17 -1
  112. package/dist/esm/utils/events.mjs.map +1 -1
  113. package/dist/esm/utils/handlers.mjs +16 -0
  114. package/dist/esm/utils/handlers.mjs.map +1 -1
  115. package/dist/esm/utils/llm.mjs +10 -1
  116. package/dist/esm/utils/llm.mjs.map +1 -1
  117. package/dist/esm/utils/tokens.mjs +245 -15
  118. package/dist/esm/utils/tokens.mjs.map +1 -1
  119. package/dist/esm/utils/truncation.mjs +102 -0
  120. package/dist/esm/utils/truncation.mjs.map +1 -0
  121. package/dist/types/agents/AgentContext.d.ts +124 -6
  122. package/dist/types/common/enum.d.ts +14 -1
  123. package/dist/types/graphs/Graph.d.ts +22 -27
  124. package/dist/types/index.d.ts +5 -0
  125. package/dist/types/llm/init.d.ts +18 -0
  126. package/dist/types/llm/invoke.d.ts +48 -0
  127. package/dist/types/llm/request.d.ts +14 -0
  128. package/dist/types/messages/contextPruning.d.ts +42 -0
  129. package/dist/types/messages/contextPruningSettings.d.ts +44 -0
  130. package/dist/types/messages/core.d.ts +1 -1
  131. package/dist/types/messages/format.d.ts +17 -1
  132. package/dist/types/messages/index.d.ts +3 -0
  133. package/dist/types/messages/prune.d.ts +162 -1
  134. package/dist/types/messages/reducer.d.ts +18 -0
  135. package/dist/types/run.d.ts +12 -1
  136. package/dist/types/summarization/index.d.ts +20 -0
  137. package/dist/types/summarization/node.d.ts +29 -0
  138. package/dist/types/tools/ToolNode.d.ts +3 -1
  139. package/dist/types/types/graph.d.ts +44 -6
  140. package/dist/types/types/index.d.ts +1 -0
  141. package/dist/types/types/run.d.ts +30 -0
  142. package/dist/types/types/stream.d.ts +31 -4
  143. package/dist/types/types/summarize.d.ts +47 -0
  144. package/dist/types/types/tools.d.ts +7 -0
  145. package/dist/types/utils/errors.d.ts +28 -0
  146. package/dist/types/utils/events.d.ts +13 -0
  147. package/dist/types/utils/index.d.ts +2 -0
  148. package/dist/types/utils/llm.d.ts +4 -0
  149. package/dist/types/utils/tokens.d.ts +14 -1
  150. package/dist/types/utils/truncation.d.ts +49 -0
  151. package/package.json +3 -3
  152. package/src/agents/AgentContext.ts +388 -58
  153. package/src/agents/__tests__/AgentContext.test.ts +265 -5
  154. package/src/common/enum.ts +13 -0
  155. package/src/events.ts +9 -39
  156. package/src/graphs/Graph.ts +468 -331
  157. package/src/index.ts +7 -0
  158. package/src/llm/anthropic/llm.spec.ts +3 -3
  159. package/src/llm/anthropic/utils/message_inputs.ts +6 -4
  160. package/src/llm/bedrock/llm.spec.ts +1 -1
  161. package/src/llm/bedrock/utils/message_inputs.ts +6 -2
  162. package/src/llm/init.ts +63 -0
  163. package/src/llm/invoke.ts +144 -0
  164. package/src/llm/request.ts +55 -0
  165. package/src/messages/__tests__/observationMasking.test.ts +221 -0
  166. package/src/messages/cache.ts +77 -102
  167. package/src/messages/contextPruning.ts +191 -0
  168. package/src/messages/contextPruningSettings.ts +90 -0
  169. package/src/messages/core.ts +32 -53
  170. package/src/messages/ensureThinkingBlock.test.ts +39 -39
  171. package/src/messages/format.ts +227 -15
  172. package/src/messages/formatAgentMessages.test.ts +511 -1
  173. package/src/messages/index.ts +3 -0
  174. package/src/messages/prune.ts +1548 -62
  175. package/src/messages/reducer.ts +22 -0
  176. package/src/run.ts +104 -51
  177. package/src/scripts/bedrock-merge-test.ts +1 -1
  178. package/src/scripts/test-thinking-handoff-bedrock.ts +1 -1
  179. package/src/scripts/test-thinking-handoff.ts +1 -1
  180. package/src/scripts/thinking-bedrock.ts +1 -1
  181. package/src/scripts/thinking.ts +1 -1
  182. package/src/specs/anthropic.simple.test.ts +1 -1
  183. package/src/specs/multi-agent-summarization.test.ts +396 -0
  184. package/src/specs/prune.test.ts +1196 -23
  185. package/src/specs/summarization-unit.test.ts +868 -0
  186. package/src/specs/summarization.test.ts +3827 -0
  187. package/src/specs/summarize-prune.test.ts +376 -0
  188. package/src/specs/thinking-handoff.test.ts +10 -10
  189. package/src/specs/thinking-prune.test.ts +7 -4
  190. package/src/specs/token-accounting-e2e.test.ts +1034 -0
  191. package/src/specs/token-accounting-pipeline.test.ts +882 -0
  192. package/src/specs/token-distribution-edge-case.test.ts +25 -26
  193. package/src/splitStream.test.ts +42 -33
  194. package/src/stream.ts +64 -11
  195. package/src/summarization/__tests__/aggregator.test.ts +153 -0
  196. package/src/summarization/__tests__/node.test.ts +708 -0
  197. package/src/summarization/__tests__/trigger.test.ts +50 -0
  198. package/src/summarization/index.ts +102 -0
  199. package/src/summarization/node.ts +982 -0
  200. package/src/tools/ToolNode.ts +25 -3
  201. package/src/types/graph.ts +62 -7
  202. package/src/types/index.ts +1 -0
  203. package/src/types/run.ts +32 -0
  204. package/src/types/stream.ts +45 -5
  205. package/src/types/summarize.ts +58 -0
  206. package/src/types/tools.ts +7 -0
  207. package/src/utils/errors.ts +117 -0
  208. package/src/utils/events.ts +31 -0
  209. package/src/utils/handlers.ts +18 -0
  210. package/src/utils/index.ts +2 -0
  211. package/src/utils/llm.ts +12 -0
  212. package/src/utils/tokens.ts +336 -18
  213. package/src/utils/truncation.ts +124 -0
  214. package/src/scripts/image.ts +0 -180
@@ -0,0 +1,868 @@
1
+ import { HumanMessage, AIMessage, ToolMessage } from '@langchain/core/messages';
2
+ import type { BaseMessage } from '@langchain/core/messages';
3
+ import {
4
+ calculateMaxToolResultChars,
5
+ truncateToolResultContent,
6
+ truncateToolInput,
7
+ HARD_MAX_TOOL_RESULT_CHARS,
8
+ } from '@/utils/truncation';
9
+ import {
10
+ preFlightTruncateToolResults,
11
+ preFlightTruncateToolCallInputs,
12
+ createPruneMessages,
13
+ } from '@/messages/prune';
14
+ import { shouldTriggerSummarization } from '@/summarization/index';
15
+ import { Providers } from '@/common';
16
+ import { SummarizationTrigger } from '@/types';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Shared helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function tokenCounter(msg: { content: unknown }): number {
23
+ const content =
24
+ typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
25
+ return Math.ceil(content.length / 4);
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // calculateMaxToolResultChars
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('calculateMaxToolResultChars', () => {
33
+ it('returns 30% of context window in chars (×4 ratio)', () => {
34
+ // 1000 tokens × 0.3 = 300; 300 × 4 = 1200 chars
35
+ expect(calculateMaxToolResultChars(1000)).toBe(1200);
36
+ });
37
+
38
+ it('caps at HARD_MAX_TOOL_RESULT_CHARS for large contexts', () => {
39
+ expect(calculateMaxToolResultChars(10_000_000)).toBe(
40
+ HARD_MAX_TOOL_RESULT_CHARS
41
+ );
42
+ });
43
+
44
+ it('returns hard max when contextWindowTokens is undefined', () => {
45
+ expect(calculateMaxToolResultChars(undefined)).toBe(
46
+ HARD_MAX_TOOL_RESULT_CHARS
47
+ );
48
+ });
49
+
50
+ it('returns hard max when contextWindowTokens is 0 or negative', () => {
51
+ expect(calculateMaxToolResultChars(0)).toBe(HARD_MAX_TOOL_RESULT_CHARS);
52
+ expect(calculateMaxToolResultChars(-100)).toBe(HARD_MAX_TOOL_RESULT_CHARS);
53
+ });
54
+
55
+ it('handles small context windows', () => {
56
+ // 50 tokens × 0.3 = 15; 15 × 4 = 60
57
+ expect(calculateMaxToolResultChars(50)).toBe(60);
58
+ // 10 tokens × 0.3 = 3; 3 × 4 = 12
59
+ expect(calculateMaxToolResultChars(10)).toBe(12);
60
+ });
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // truncateToolResultContent
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('truncateToolResultContent', () => {
68
+ it('returns content unchanged when within budget', () => {
69
+ const content = 'Short result';
70
+ expect(truncateToolResultContent(content, 100)).toBe(content);
71
+ });
72
+
73
+ it('returns content unchanged when exactly at budget', () => {
74
+ const content = 'x'.repeat(50);
75
+ expect(truncateToolResultContent(content, 50)).toBe(content);
76
+ });
77
+
78
+ it('truncates with head+tail when budget is large enough', () => {
79
+ // Need available >= 200 for head+tail. With 1000-char content and budget=500,
80
+ // indicator ≈ 42 chars, available ≈ 458 — well above the 200 threshold.
81
+ const content = 'A'.repeat(400) + 'B'.repeat(200) + 'C'.repeat(400);
82
+ const result = truncateToolResultContent(content, 500);
83
+
84
+ expect(result.length).toBeLessThanOrEqual(510); // some slack for indicator
85
+ expect(result).toContain('truncated');
86
+ expect(result).toContain('1000'); // original length
87
+ expect(result).toContain('500'); // limit
88
+ // Head preserved (starts with As)
89
+ expect(result.startsWith('A')).toBe(true);
90
+ // Tail preserved (ends with Cs)
91
+ expect(result.endsWith('C')).toBe(true);
92
+ });
93
+
94
+ it('falls back to head-only when budget is very small', () => {
95
+ // With 1000-char content and budget=235, indicator ≈ 37 chars,
96
+ // available ≈ 198 < 200 threshold → head-only path.
97
+ const content = 'A'.repeat(500) + 'B'.repeat(500);
98
+ const result = truncateToolResultContent(content, 235);
99
+
100
+ expect(result).toContain('truncated');
101
+ expect(result.startsWith('A')).toBe(true);
102
+ expect(result).not.toMatch(/B/);
103
+ });
104
+
105
+ it('returns head-only slice when budget is smaller than indicator', () => {
106
+ // When budget is so small the indicator doesn't fit, just returns head slice
107
+ const content = 'Error: ENOENT: no such file or directory';
108
+ const result = truncateToolResultContent(content, 30);
109
+
110
+ expect(result).toBe(content.slice(0, 30));
111
+ });
112
+
113
+ it('preserves the truncation indicator format', () => {
114
+ const content = 'x'.repeat(500);
115
+ const result = truncateToolResultContent(content, 300);
116
+ // Format: [truncated: N chars exceeded M limit]
117
+ expect(result).toMatch(/\[truncated: 500 chars exceeded 300 limit\]/);
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // truncateToolInput
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe('truncateToolInput', () => {
126
+ it('returns unchanged string when within budget', () => {
127
+ const result = truncateToolInput('short input', 100);
128
+ expect(result._truncated).toBe('short input');
129
+ expect(result._originalChars).toBe(11);
130
+ });
131
+
132
+ it('serializes objects to JSON before truncating', () => {
133
+ const input = { key: 'value', nested: { a: 1 } };
134
+ const result = truncateToolInput(input, 10);
135
+ expect(result._originalChars).toBe(JSON.stringify(input).length);
136
+ expect(result._truncated).toContain('truncated');
137
+ });
138
+
139
+ it('truncates long strings with indicator', () => {
140
+ const input = 'x'.repeat(500);
141
+ const result = truncateToolInput(input, 200);
142
+ expect(result._truncated).toContain('truncated');
143
+ expect(result._truncated).toContain('500');
144
+ expect(result._originalChars).toBe(500);
145
+ });
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // preFlightTruncateToolResults
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe('preFlightTruncateToolResults', () => {
153
+ it('truncates oversized tool results and updates token counts', () => {
154
+ const toolMsg = new ToolMessage({
155
+ content: 'x'.repeat(500),
156
+ tool_call_id: 'tc1',
157
+ name: 'big_tool',
158
+ });
159
+ const messages: BaseMessage[] = [
160
+ new HumanMessage('run it'),
161
+ new AIMessage({
162
+ content: '',
163
+ tool_calls: [{ id: 'tc1', name: 'big_tool', args: {} }],
164
+ }),
165
+ toolMsg,
166
+ ];
167
+
168
+ const indexTokenCountMap: Record<string, number | undefined> = {
169
+ 0: 5,
170
+ 1: 10,
171
+ 2: tokenCounter(toolMsg),
172
+ };
173
+ const originalTokenCount = indexTokenCountMap[2]!;
174
+
175
+ const count = preFlightTruncateToolResults({
176
+ messages,
177
+ maxContextTokens: 200, // calculateMaxToolResultChars(200) = 240 chars
178
+ indexTokenCountMap,
179
+ tokenCounter,
180
+ });
181
+
182
+ expect(count).toBe(1);
183
+ // Content was mutated in place
184
+ const truncatedContent = messages[2].content as string;
185
+ expect(truncatedContent.length).toBeLessThan(500);
186
+ expect(truncatedContent).toContain('truncated');
187
+ // Token count was updated
188
+ expect(indexTokenCountMap[2]).toBeLessThan(originalTokenCount);
189
+ });
190
+
191
+ it('does not truncate results that fit within budget', () => {
192
+ const toolMsg = new ToolMessage({
193
+ content: 'OK',
194
+ tool_call_id: 'tc1',
195
+ name: 'small_tool',
196
+ });
197
+ const messages: BaseMessage[] = [toolMsg];
198
+ const indexTokenCountMap: Record<string, number | undefined> = {
199
+ 0: 2,
200
+ };
201
+
202
+ const count = preFlightTruncateToolResults({
203
+ messages,
204
+ maxContextTokens: 1000,
205
+ indexTokenCountMap,
206
+ tokenCounter,
207
+ });
208
+
209
+ expect(count).toBe(0);
210
+ expect(messages[0].content).toBe('OK');
211
+ expect(indexTokenCountMap[0]).toBe(2);
212
+ });
213
+
214
+ it('skips non-tool messages', () => {
215
+ const messages: BaseMessage[] = [
216
+ new HumanMessage('x'.repeat(500)),
217
+ new AIMessage('y'.repeat(500)),
218
+ ];
219
+ const indexTokenCountMap: Record<string, number | undefined> = {
220
+ 0: 125,
221
+ 1: 125,
222
+ };
223
+
224
+ const count = preFlightTruncateToolResults({
225
+ messages,
226
+ maxContextTokens: 10, // very tight budget
227
+ indexTokenCountMap,
228
+ tokenCounter,
229
+ });
230
+
231
+ expect(count).toBe(0);
232
+ expect((messages[0].content as string).length).toBe(500);
233
+ });
234
+
235
+ it('uses raw maxContextTokens (not effective budget) for threshold', () => {
236
+ // This verifies the bug fix: with maxContextTokens=50,
237
+ // calculateMaxToolResultChars(50) = 60 chars.
238
+ // A 60-char tool result should NOT be truncated.
239
+ const content =
240
+ 'Error: ENOENT: no such file or directory, open /src/index.ts'; // 60 chars
241
+ const toolMsg = new ToolMessage({
242
+ content,
243
+ tool_call_id: 'tc1',
244
+ name: 'run_linter',
245
+ status: 'error',
246
+ });
247
+ const messages: BaseMessage[] = [toolMsg];
248
+ const indexTokenCountMap: Record<string, number | undefined> = {
249
+ 0: tokenCounter(toolMsg),
250
+ };
251
+
252
+ const count = preFlightTruncateToolResults({
253
+ messages,
254
+ maxContextTokens: 50,
255
+ indexTokenCountMap,
256
+ tokenCounter,
257
+ });
258
+
259
+ expect(count).toBe(0);
260
+ expect(messages[0].content).toBe(content);
261
+ expect(messages[0].content).toContain('ENOENT');
262
+ });
263
+
264
+ it('handles multiple tool messages, truncating only oversized ones', () => {
265
+ const smallTool = new ToolMessage({
266
+ content: 'ok',
267
+ tool_call_id: 'tc1',
268
+ name: 'tool_a',
269
+ });
270
+ const bigTool = new ToolMessage({
271
+ content: 'x'.repeat(2000),
272
+ tool_call_id: 'tc2',
273
+ name: 'tool_b',
274
+ });
275
+ const messages: BaseMessage[] = [smallTool, bigTool];
276
+ const indexTokenCountMap: Record<string, number | undefined> = {
277
+ 0: tokenCounter(smallTool),
278
+ 1: tokenCounter(bigTool),
279
+ };
280
+
281
+ const count = preFlightTruncateToolResults({
282
+ messages,
283
+ maxContextTokens: 500, // maxChars = 600
284
+ indexTokenCountMap,
285
+ tokenCounter,
286
+ });
287
+
288
+ expect(count).toBe(1);
289
+ expect(messages[0].content).toBe('ok');
290
+ expect((messages[1].content as string).length).toBeLessThan(2000);
291
+ });
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // preFlightTruncateToolResults uses raw maxTokens in pruner
296
+ // ---------------------------------------------------------------------------
297
+
298
+ describe('pre-flight truncation in pruner uses raw maxContextTokens', () => {
299
+ it('preserves small tool results even with tight effective budget', () => {
300
+ // Simulate: maxContextTokens=50, high instruction overhead.
301
+ // Pre-flight should use raw 50 (maxChars=60), not effectiveMaxTokens.
302
+ const content =
303
+ 'Error: ENOENT: no such file or directory, open /src/index.ts'; // 60 chars
304
+ const toolMsg = new ToolMessage({
305
+ content,
306
+ tool_call_id: 'tc1',
307
+ name: 'run_linter',
308
+ status: 'error',
309
+ });
310
+ const aiMsg = new AIMessage({
311
+ content: [
312
+ { type: 'text' as const, text: 'Running linter.' },
313
+ {
314
+ type: 'tool_use' as const,
315
+ id: 'tc1',
316
+ name: 'run_linter',
317
+ input: '{"path":"/src"}',
318
+ },
319
+ ],
320
+ tool_calls: [{ id: 'tc1', name: 'run_linter', args: { path: '/src' } }],
321
+ });
322
+ const messages: BaseMessage[] = [
323
+ new HumanMessage('Run the linter.'),
324
+ aiMsg,
325
+ toolMsg,
326
+ new AIMessage('The linter failed.'),
327
+ ];
328
+
329
+ const indexTokenCountMap: Record<string, number | undefined> = {};
330
+ for (let i = 0; i < messages.length; i++) {
331
+ indexTokenCountMap[i] = tokenCounter(messages[i]);
332
+ }
333
+
334
+ const pruneMessages = createPruneMessages({
335
+ provider: Providers.OPENAI,
336
+ maxTokens: 50,
337
+ startIndex: messages.length,
338
+ tokenCounter,
339
+ indexTokenCountMap,
340
+ getInstructionTokens: () => 15,
341
+ });
342
+
343
+ pruneMessages({ messages });
344
+
345
+ // The 60-char tool result must survive pre-flight truncation.
346
+ // With raw maxTokens=50: calculateMaxToolResultChars(50) = 60, so 60 <= 60 → not truncated.
347
+ // The old bug used effectiveMaxTokens (~32), which gave maxChars=40 and truncated ENOENT.
348
+ expect(toolMsg.content).toBe(content);
349
+ expect(toolMsg.content).toContain('ENOENT');
350
+ });
351
+ });
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // shouldTriggerSummarization
355
+ // ---------------------------------------------------------------------------
356
+
357
+ describe('shouldTriggerSummarization', () => {
358
+ describe('no trigger configured (default)', () => {
359
+ it('returns true when messagesToRefineCount > 0', () => {
360
+ expect(shouldTriggerSummarization({ messagesToRefineCount: 1 })).toBe(
361
+ true
362
+ );
363
+ expect(shouldTriggerSummarization({ messagesToRefineCount: 100 })).toBe(
364
+ true
365
+ );
366
+ });
367
+
368
+ it('returns false when messagesToRefineCount is 0', () => {
369
+ expect(shouldTriggerSummarization({ messagesToRefineCount: 0 })).toBe(
370
+ false
371
+ );
372
+ });
373
+ });
374
+
375
+ describe('token_ratio trigger', () => {
376
+ it('fires when used ratio exceeds threshold', () => {
377
+ expect(
378
+ shouldTriggerSummarization({
379
+ trigger: { type: 'token_ratio', value: 0.8 },
380
+ maxContextTokens: 1000,
381
+ prePruneContextTokens: 900, // 90% used
382
+ messagesToRefineCount: 5,
383
+ })
384
+ ).toBe(true);
385
+ });
386
+
387
+ it('does not fire when used ratio is below threshold', () => {
388
+ expect(
389
+ shouldTriggerSummarization({
390
+ trigger: { type: 'token_ratio', value: 0.8 },
391
+ maxContextTokens: 1000,
392
+ prePruneContextTokens: 500, // 50% used
393
+ messagesToRefineCount: 5,
394
+ })
395
+ ).toBe(false);
396
+ });
397
+
398
+ it('fires at exact boundary', () => {
399
+ expect(
400
+ shouldTriggerSummarization({
401
+ trigger: { type: 'token_ratio', value: 0.8 },
402
+ maxContextTokens: 1000,
403
+ prePruneContextTokens: 800, // exactly 80%
404
+ messagesToRefineCount: 5,
405
+ })
406
+ ).toBe(true);
407
+ });
408
+
409
+ it('does not fire when maxContextTokens is missing', () => {
410
+ expect(
411
+ shouldTriggerSummarization({
412
+ trigger: { type: 'token_ratio', value: 0.8 },
413
+ prePruneContextTokens: 900,
414
+ messagesToRefineCount: 5,
415
+ })
416
+ ).toBe(false);
417
+ });
418
+
419
+ it('falls back to remainingContextTokens when prePruneContextTokens is missing', () => {
420
+ expect(
421
+ shouldTriggerSummarization({
422
+ trigger: { type: 'token_ratio', value: 0.8 },
423
+ maxContextTokens: 1000,
424
+ remainingContextTokens: 100, // 90% used
425
+ messagesToRefineCount: 5,
426
+ })
427
+ ).toBe(true);
428
+ });
429
+ });
430
+
431
+ describe('remaining_tokens trigger', () => {
432
+ it('fires when remaining tokens are at or below threshold', () => {
433
+ expect(
434
+ shouldTriggerSummarization({
435
+ trigger: { type: 'remaining_tokens', value: 200 },
436
+ maxContextTokens: 1000,
437
+ prePruneContextTokens: 850, // remaining = 150
438
+ messagesToRefineCount: 3,
439
+ })
440
+ ).toBe(true);
441
+ });
442
+
443
+ it('does not fire when remaining tokens exceed threshold', () => {
444
+ expect(
445
+ shouldTriggerSummarization({
446
+ trigger: { type: 'remaining_tokens', value: 200 },
447
+ maxContextTokens: 1000,
448
+ prePruneContextTokens: 500, // remaining = 500
449
+ messagesToRefineCount: 3,
450
+ })
451
+ ).toBe(false);
452
+ });
453
+
454
+ it('does not fire when remaining tokens data is missing', () => {
455
+ expect(
456
+ shouldTriggerSummarization({
457
+ trigger: { type: 'remaining_tokens', value: 200 },
458
+ messagesToRefineCount: 3,
459
+ })
460
+ ).toBe(false);
461
+ });
462
+ });
463
+
464
+ describe('messages_to_refine trigger', () => {
465
+ it('fires when messagesToRefineCount meets threshold', () => {
466
+ expect(
467
+ shouldTriggerSummarization({
468
+ trigger: { type: 'messages_to_refine', value: 5 },
469
+ messagesToRefineCount: 5,
470
+ })
471
+ ).toBe(true);
472
+ expect(
473
+ shouldTriggerSummarization({
474
+ trigger: { type: 'messages_to_refine', value: 5 },
475
+ messagesToRefineCount: 10,
476
+ })
477
+ ).toBe(true);
478
+ });
479
+
480
+ it('does not fire when messagesToRefineCount is below threshold', () => {
481
+ expect(
482
+ shouldTriggerSummarization({
483
+ trigger: { type: 'messages_to_refine', value: 5 },
484
+ messagesToRefineCount: 3,
485
+ })
486
+ ).toBe(false);
487
+ });
488
+ });
489
+
490
+ describe('edge cases', () => {
491
+ it('returns false for unrecognized trigger type', () => {
492
+ expect(
493
+ shouldTriggerSummarization({
494
+ trigger: {
495
+ type: 'unknown_type' as SummarizationTrigger['type'],
496
+ value: 1,
497
+ },
498
+ messagesToRefineCount: 10,
499
+ })
500
+ ).toBe(false);
501
+ });
502
+
503
+ it('returns false when trigger value is invalid', () => {
504
+ expect(
505
+ shouldTriggerSummarization({
506
+ trigger: { type: 'token_ratio', value: NaN },
507
+ maxContextTokens: 1000,
508
+ prePruneContextTokens: 900,
509
+ messagesToRefineCount: 5,
510
+ })
511
+ ).toBe(false);
512
+ });
513
+
514
+ it('returns false when messagesToRefineCount is 0 regardless of trigger', () => {
515
+ expect(
516
+ shouldTriggerSummarization({
517
+ trigger: { type: 'messages_to_refine', value: 0 },
518
+ messagesToRefineCount: 0,
519
+ })
520
+ ).toBe(false);
521
+ });
522
+ });
523
+ });
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // preFlightTruncateToolCallInputs
527
+ // ---------------------------------------------------------------------------
528
+
529
+ describe('preFlightTruncateToolCallInputs', () => {
530
+ it('truncates oversized tool_use input fields', () => {
531
+ const bigInput = JSON.stringify({ code: 'x'.repeat(5000) });
532
+ const aiMsg = new AIMessage({
533
+ content: [
534
+ { type: 'text' as const, text: 'Running code.' },
535
+ {
536
+ type: 'tool_use' as const,
537
+ id: 'tc1',
538
+ name: 'execute',
539
+ input: bigInput,
540
+ },
541
+ ],
542
+ tool_calls: [
543
+ { id: 'tc1', name: 'execute', args: { code: 'x'.repeat(5000) } },
544
+ ],
545
+ });
546
+ const messages: BaseMessage[] = [aiMsg];
547
+ const indexTokenCountMap: Record<string, number | undefined> = {
548
+ 0: tokenCounter(aiMsg),
549
+ };
550
+
551
+ const count = preFlightTruncateToolCallInputs({
552
+ messages,
553
+ maxContextTokens: 200, // maxInputChars = floor(200*0.15)*4 = 120
554
+ indexTokenCountMap,
555
+ tokenCounter,
556
+ });
557
+
558
+ expect(count).toBe(1);
559
+ });
560
+
561
+ it('does not truncate small inputs', () => {
562
+ const aiMsg = new AIMessage({
563
+ content: [
564
+ {
565
+ type: 'tool_use' as const,
566
+ id: 'tc1',
567
+ name: 'calc',
568
+ input: '{"a":1}',
569
+ },
570
+ ],
571
+ tool_calls: [{ id: 'tc1', name: 'calc', args: { a: 1 } }],
572
+ });
573
+ const messages: BaseMessage[] = [aiMsg];
574
+ const indexTokenCountMap: Record<string, number | undefined> = {
575
+ 0: tokenCounter(aiMsg),
576
+ };
577
+ const originalCount = indexTokenCountMap[0];
578
+
579
+ const count = preFlightTruncateToolCallInputs({
580
+ messages,
581
+ maxContextTokens: 10000,
582
+ indexTokenCountMap,
583
+ tokenCounter,
584
+ });
585
+
586
+ expect(count).toBe(0);
587
+ expect(indexTokenCountMap[0]).toBe(originalCount);
588
+ });
589
+
590
+ it('skips non-AI messages', () => {
591
+ const messages: BaseMessage[] = [
592
+ new HumanMessage('hello'),
593
+ new ToolMessage({
594
+ content: 'x'.repeat(5000),
595
+ tool_call_id: 'tc1',
596
+ name: 'big',
597
+ }),
598
+ ];
599
+ const indexTokenCountMap: Record<string, number | undefined> = {
600
+ 0: 3,
601
+ 1: 1250,
602
+ };
603
+
604
+ const count = preFlightTruncateToolCallInputs({
605
+ messages,
606
+ maxContextTokens: 10,
607
+ indexTokenCountMap,
608
+ tokenCounter,
609
+ });
610
+
611
+ expect(count).toBe(0);
612
+ });
613
+ });
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // Pruner → summarization routing: messagesToRefine populated correctly
617
+ // ---------------------------------------------------------------------------
618
+
619
+ describe('pruner messagesToRefine for summarization', () => {
620
+ it('populates messagesToRefine with pruned messages when over budget', () => {
621
+ const messages: BaseMessage[] = [];
622
+ for (let i = 0; i < 20; i++) {
623
+ messages.push(new HumanMessage(`Question ${i}: ${'detail '.repeat(20)}`));
624
+ messages.push(new AIMessage(`Answer ${i}: ${'explanation '.repeat(20)}`));
625
+ }
626
+
627
+ const indexTokenCountMap: Record<string, number | undefined> = {};
628
+ for (let i = 0; i < messages.length; i++) {
629
+ indexTokenCountMap[i] = tokenCounter(messages[i]);
630
+ }
631
+
632
+ const pruneMessages = createPruneMessages({
633
+ provider: Providers.OPENAI,
634
+ maxTokens: 200,
635
+ startIndex: messages.length,
636
+ tokenCounter,
637
+ indexTokenCountMap,
638
+ });
639
+
640
+ const result = pruneMessages({ messages });
641
+
642
+ expect(result.messagesToRefine!.length).toBeGreaterThan(0);
643
+ expect(result.context.length).toBeGreaterThan(0);
644
+ expect(result.context.length + result.messagesToRefine!.length).toBe(
645
+ messages.length
646
+ );
647
+ expect(typeof result.remainingContextTokens).toBe('number');
648
+ });
649
+
650
+ it('returns empty messagesToRefine when everything fits', () => {
651
+ const messages: BaseMessage[] = [
652
+ new HumanMessage('Hi'),
653
+ new AIMessage('Hello'),
654
+ ];
655
+
656
+ const indexTokenCountMap: Record<string, number | undefined> = {};
657
+ for (let i = 0; i < messages.length; i++) {
658
+ indexTokenCountMap[i] = tokenCounter(messages[i]);
659
+ }
660
+
661
+ const pruneMessages = createPruneMessages({
662
+ provider: Providers.OPENAI,
663
+ maxTokens: 10000,
664
+ startIndex: messages.length,
665
+ tokenCounter,
666
+ indexTokenCountMap,
667
+ });
668
+
669
+ const result = pruneMessages({ messages });
670
+
671
+ expect(result.messagesToRefine!).toHaveLength(0);
672
+ expect(result.context).toEqual(messages);
673
+ });
674
+
675
+ it('messagesToRefine contains the oldest messages (chronological order)', () => {
676
+ const messages: BaseMessage[] = [
677
+ new HumanMessage('First question - oldest'),
678
+ new AIMessage('First answer - oldest'),
679
+ new HumanMessage('Second question'),
680
+ new AIMessage('Second answer'),
681
+ new HumanMessage(
682
+ 'Third question with much more detail to push token count up significantly'
683
+ ),
684
+ new AIMessage(
685
+ 'Third answer with extensive explanation that uses many tokens in the response'
686
+ ),
687
+ ];
688
+
689
+ const indexTokenCountMap: Record<string, number | undefined> = {};
690
+ for (let i = 0; i < messages.length; i++) {
691
+ indexTokenCountMap[i] = tokenCounter(messages[i]);
692
+ }
693
+
694
+ const totalTokens = Object.values(indexTokenCountMap).reduce(
695
+ (a = 0, b = 0) => a! + b!,
696
+ 0
697
+ ) as number;
698
+
699
+ const pruneMessages = createPruneMessages({
700
+ provider: Providers.OPENAI,
701
+ maxTokens: Math.floor(totalTokens * 0.5),
702
+ startIndex: messages.length,
703
+ tokenCounter,
704
+ indexTokenCountMap,
705
+ });
706
+
707
+ const result = pruneMessages({ messages });
708
+
709
+ expect(result.messagesToRefine!.length).toBeGreaterThan(0);
710
+ // The oldest messages should be in messagesToRefine
711
+ const refinedContent = result.messagesToRefine!.map((m) => m.content);
712
+ expect(refinedContent[0]).toContain('First question');
713
+ });
714
+ });
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // Emergency truncation in pruner
718
+ // ---------------------------------------------------------------------------
719
+
720
+ describe('emergency truncation when pruning produces empty context', () => {
721
+ it('recovers from empty context by truncating tool results', () => {
722
+ // Single large tool result that exceeds the entire budget
723
+ const bigToolMsg = new ToolMessage({
724
+ content: 'x'.repeat(10_000),
725
+ tool_call_id: 'tc1',
726
+ name: 'big_result',
727
+ });
728
+ const aiMsg = new AIMessage({
729
+ content: [
730
+ { type: 'text' as const, text: 'Calling tool.' },
731
+ {
732
+ type: 'tool_use' as const,
733
+ id: 'tc1',
734
+ name: 'big_result',
735
+ input: '{}',
736
+ },
737
+ ],
738
+ tool_calls: [{ id: 'tc1', name: 'big_result', args: {} }],
739
+ });
740
+ const messages: BaseMessage[] = [
741
+ new HumanMessage('Run it'),
742
+ aiMsg,
743
+ bigToolMsg,
744
+ new AIMessage('Done.'),
745
+ new HumanMessage('What happened?'),
746
+ ];
747
+
748
+ const indexTokenCountMap: Record<string, number | undefined> = {};
749
+ for (let i = 0; i < messages.length; i++) {
750
+ indexTokenCountMap[i] = tokenCounter(messages[i]);
751
+ }
752
+
753
+ const pruneMessages = createPruneMessages({
754
+ provider: Providers.OPENAI,
755
+ maxTokens: 100, // Very tight — forces emergency path
756
+ startIndex: messages.length,
757
+ tokenCounter,
758
+ indexTokenCountMap,
759
+ });
760
+
761
+ const result = pruneMessages({ messages });
762
+
763
+ // Emergency truncation should produce a non-empty context
764
+ // (or at minimum, non-empty messagesToRefine)
765
+ const totalReturned =
766
+ result.context.length + (result.messagesToRefine?.length ?? 0);
767
+ expect(totalReturned).toBeGreaterThan(0);
768
+ });
769
+
770
+ it('recovers via emergency truncation after fallback fading when summarizationEnabled=true', () => {
771
+ const bigToolMsg = new ToolMessage({
772
+ content: 'y'.repeat(20_000),
773
+ tool_call_id: 'tc1',
774
+ name: 'huge_result',
775
+ });
776
+ const aiMsg = new AIMessage({
777
+ content: [
778
+ { type: 'text' as const, text: 'Running.' },
779
+ {
780
+ type: 'tool_use' as const,
781
+ id: 'tc1',
782
+ name: 'huge_result',
783
+ input: '{}',
784
+ },
785
+ ],
786
+ tool_calls: [{ id: 'tc1', name: 'huge_result', args: {} }],
787
+ });
788
+ const messages: BaseMessage[] = [
789
+ new HumanMessage('Do it'),
790
+ aiMsg,
791
+ bigToolMsg,
792
+ new AIMessage('Complete.'),
793
+ new HumanMessage('Status?'),
794
+ ];
795
+
796
+ const indexTokenCountMap: Record<string, number | undefined> = {};
797
+ for (let i = 0; i < messages.length; i++) {
798
+ indexTokenCountMap[i] = tokenCounter(messages[i]);
799
+ }
800
+
801
+ const pruneMessages = createPruneMessages({
802
+ provider: Providers.OPENAI,
803
+ maxTokens: 100,
804
+ startIndex: messages.length,
805
+ tokenCounter,
806
+ indexTokenCountMap,
807
+ summarizationEnabled: true,
808
+ });
809
+
810
+ const result = pruneMessages({ messages });
811
+
812
+ const totalReturned =
813
+ result.context.length + (result.messagesToRefine?.length ?? 0);
814
+ expect(totalReturned).toBeGreaterThan(0);
815
+
816
+ if (result.context.length > 0) {
817
+ const toolMsgs = result.context.filter((m) => m.getType() === 'tool');
818
+ for (const tm of toolMsgs) {
819
+ const content = typeof tm.content === 'string' ? tm.content : '';
820
+ expect(content.length).toBeLessThan(20_000);
821
+ }
822
+ }
823
+ });
824
+ });
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // Interaction: pre-flight truncation does not destroy enrichment data
828
+ // ---------------------------------------------------------------------------
829
+
830
+ describe('pre-flight + enrichment interaction', () => {
831
+ it('tool error content survives pre-flight with raw maxContextTokens', () => {
832
+ // Simulate the exact scenario from the bug:
833
+ // maxContextTokens=50, tool message with 60-char error content.
834
+ // Pre-flight should NOT truncate since calculateMaxToolResultChars(50) = 60.
835
+ const errorContent =
836
+ 'Error: ENOENT: no such file or directory, open /src/index.ts';
837
+ expect(errorContent.length).toBe(60);
838
+
839
+ const toolMsg = new ToolMessage({
840
+ content: errorContent,
841
+ tool_call_id: 'tc1',
842
+ name: 'run_linter',
843
+ status: 'error',
844
+ });
845
+
846
+ const messages: BaseMessage[] = [toolMsg];
847
+ const indexTokenCountMap: Record<string, number | undefined> = {
848
+ 0: tokenCounter(toolMsg),
849
+ };
850
+
851
+ // Pre-flight with maxContextTokens=50 → maxChars = 60
852
+ const count = preFlightTruncateToolResults({
853
+ messages,
854
+ maxContextTokens: 50,
855
+ indexTokenCountMap,
856
+ tokenCounter,
857
+ });
858
+
859
+ expect(count).toBe(0);
860
+ expect(messages[0].content).toContain('ENOENT');
861
+
862
+ // Verify: if we had used effectiveMaxTokens (e.g., 37),
863
+ // it WOULD have truncated (maxChars=44 < 60)
864
+ const wouldTruncateMaxChars = calculateMaxToolResultChars(37);
865
+ expect(wouldTruncateMaxChars).toBe(44);
866
+ expect(errorContent.length).toBeGreaterThan(wouldTruncateMaxChars);
867
+ });
868
+ });