@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
@@ -101,19 +101,6 @@ function cloneMessage<T extends MessageWithContent>(
101
101
  return cloned;
102
102
  }
103
103
 
104
- /**
105
- * Checks if a message's content needs cache control stripping.
106
- * Returns true if content has cachePoint blocks or cache_control fields.
107
- */
108
- function needsCacheStripping(content: MessageContentComplex[]): boolean {
109
- for (let i = 0; i < content.length; i++) {
110
- const block = content[i];
111
- if (isCachePoint(block)) return true;
112
- if ('cache_control' in block) return true;
113
- }
114
- return false;
115
- }
116
-
117
104
  /**
118
105
  * Anthropic API: Adds cache control to the appropriate user messages in the payload.
119
106
  * Strips ALL existing cache control (both Anthropic and Bedrock formats) from all messages,
@@ -139,58 +126,63 @@ export function addCacheControl<T extends AnthropicMessage | BaseMessage>(
139
126
  const isUserMessage =
140
127
  ('getType' in originalMessage && originalMessage.getType() === 'human') ||
141
128
  ('role' in originalMessage && originalMessage.role === 'user');
142
-
143
129
  const hasArrayContent = Array.isArray(content);
144
- const needsStripping =
145
- hasArrayContent &&
146
- needsCacheStripping(content as MessageContentComplex[]);
147
130
  const needsCacheAdd =
148
131
  userMessagesModified < 2 &&
149
132
  isUserMessage &&
150
133
  (typeof content === 'string' || hasArrayContent);
151
134
 
152
- if (!needsStripping && !needsCacheAdd) {
135
+ // Skip messages that don't need any work
136
+ if (!needsCacheAdd && !hasArrayContent) {
153
137
  continue;
154
138
  }
155
139
 
156
140
  let workingContent: MessageContentComplex[];
141
+ let modified = false;
157
142
 
158
143
  if (hasArrayContent) {
159
- workingContent = deepCloneContent(
160
- content as MessageContentComplex[]
161
- ).filter((block) => !isCachePoint(block as MessageContentComplex));
162
-
163
- for (let j = 0; j < workingContent.length; j++) {
164
- const block = workingContent[j] as Record<string, unknown>;
165
- if ('cache_control' in block) {
166
- delete block.cache_control;
144
+ // Single pass: clone blocks, strip cache markers and cache points,
145
+ // find last text block index for cache insertion — all at once.
146
+ const src = content as MessageContentComplex[];
147
+ workingContent = [];
148
+ let lastTextIndex = -1;
149
+ for (let j = 0; j < src.length; j++) {
150
+ const block = src[j];
151
+ if (isCachePoint(block)) {
152
+ modified = true;
153
+ continue; // skip cache point blocks
167
154
  }
155
+ const cloned = { ...block };
156
+ if ('cache_control' in cloned) {
157
+ delete (cloned as Record<string, unknown>).cache_control;
158
+ modified = true;
159
+ }
160
+ if ('type' in cloned && cloned.type === 'text') {
161
+ lastTextIndex = workingContent.length;
162
+ }
163
+ workingContent.push(cloned as MessageContentComplex);
168
164
  }
169
- } else if (typeof content === 'string') {
170
- workingContent = [
171
- { type: 'text', text: content },
172
- ] as MessageContentComplex[];
173
- } else {
174
- workingContent = [];
175
- }
176
165
 
177
- if (userMessagesModified >= 2 || !isUserMessage) {
178
- updatedMessages[i] = cloneMessage(
179
- originalMessage as MessageWithContent,
180
- workingContent
181
- ) as T;
182
- continue;
183
- }
166
+ if (!modified && !needsCacheAdd) {
167
+ continue; // nothing to strip and no cache to add
168
+ }
184
169
 
185
- for (let j = workingContent.length - 1; j >= 0; j--) {
186
- const contentPart = workingContent[j];
187
- if ('type' in contentPart && contentPart.type === 'text') {
188
- (contentPart as Anthropic.TextBlockParam).cache_control = {
170
+ // Add cache control to the last text block for user messages
171
+ if (needsCacheAdd && lastTextIndex >= 0) {
172
+ (
173
+ workingContent[lastTextIndex] as Anthropic.TextBlockParam
174
+ ).cache_control = {
189
175
  type: 'ephemeral',
190
176
  };
191
177
  userMessagesModified++;
192
- break;
193
178
  }
179
+ } else if (typeof content === 'string' && needsCacheAdd) {
180
+ workingContent = [
181
+ { type: 'text', text: content, cache_control: { type: 'ephemeral' } },
182
+ ] as unknown as MessageContentComplex[];
183
+ userMessagesModified++;
184
+ } else {
185
+ continue;
194
186
  }
195
187
 
196
188
  updatedMessages[i] = cloneMessage(
@@ -325,9 +317,6 @@ export function addBedrockCacheControl<
325
317
 
326
318
  const content = originalMessage.content;
327
319
  const hasArrayContent = Array.isArray(content);
328
- const needsStripping =
329
- hasArrayContent &&
330
- needsCacheStripping(content as MessageContentComplex[]);
331
320
  const isEmptyString = typeof content === 'string' && content === '';
332
321
  const needsCacheAdd =
333
322
  messagesModified < 2 &&
@@ -335,77 +324,63 @@ export function addBedrockCacheControl<
335
324
  !isEmptyString &&
336
325
  (typeof content === 'string' || hasArrayContent);
337
326
 
338
- if (!needsStripping && !needsCacheAdd) {
327
+ if (!needsCacheAdd && !hasArrayContent) {
339
328
  continue;
340
329
  }
341
330
 
342
331
  let workingContent: MessageContentComplex[];
332
+ let modified = false;
343
333
 
344
334
  if (hasArrayContent) {
345
- workingContent = deepCloneContent(
346
- content as MessageContentComplex[]
347
- ).filter((block) => !isCachePoint(block));
348
-
349
- for (let j = 0; j < workingContent.length; j++) {
350
- const block = workingContent[j] as Record<string, unknown>;
351
- if ('cache_control' in block) {
352
- delete block.cache_control;
353
- }
354
- }
355
- } else if (typeof content === 'string') {
356
- workingContent = [{ type: ContentTypes.TEXT, text: content }];
357
- } else {
335
+ // Single pass: clone blocks, strip cache markers, find last
336
+ // non-empty text block for cache point insertion — all at once.
337
+ const src = content as MessageContentComplex[];
358
338
  workingContent = [];
359
- }
360
-
361
- if (messagesModified >= 2 || isToolMessage || isEmptyString) {
362
- updatedMessages[i] = cloneMessage(originalMessage, workingContent);
363
- continue;
364
- }
365
-
366
- if (workingContent.length === 0) {
367
- continue;
368
- }
369
-
370
- let hasCacheableContent = false;
371
- for (const block of workingContent) {
372
- if (block.type === ContentTypes.TEXT) {
373
- if (typeof block.text === 'string' && block.text.trim() !== '') {
374
- hasCacheableContent = true;
375
- break;
339
+ let lastNonEmptyTextIndex = -1;
340
+ for (let j = 0; j < src.length; j++) {
341
+ const block = src[j];
342
+ if (isCachePoint(block)) {
343
+ modified = true;
344
+ continue;
345
+ }
346
+ const cloned = { ...block };
347
+ if ('cache_control' in cloned) {
348
+ delete (cloned as Record<string, unknown>).cache_control;
349
+ modified = true;
376
350
  }
351
+ const type = (cloned as { type?: string }).type;
352
+ if (type === ContentTypes.TEXT || type === 'text') {
353
+ const text = (cloned as { text?: string }).text;
354
+ if (text != null && text.trim() !== '') {
355
+ lastNonEmptyTextIndex = workingContent.length;
356
+ }
357
+ }
358
+ workingContent.push(cloned as MessageContentComplex);
377
359
  }
378
- }
379
360
 
380
- if (!hasCacheableContent) {
381
- updatedMessages[i] = cloneMessage(originalMessage, workingContent);
382
- continue;
383
- }
361
+ if (!modified && !needsCacheAdd) {
362
+ continue;
363
+ }
384
364
 
385
- let inserted = false;
386
- for (let j = workingContent.length - 1; j >= 0; j--) {
387
- const block = workingContent[j] as MessageContentComplex;
388
- const type = (block as { type?: string }).type;
389
- if (type === ContentTypes.TEXT || type === 'text') {
390
- const text = (block as { text?: string }).text;
391
- if (text === '' || text === undefined || text.trim() === '') {
392
- continue;
393
- }
394
- workingContent.splice(j + 1, 0, {
365
+ // Insert cache point after the last non-empty text block.
366
+ // Skip if no cacheable text content exists (whitespace-only messages).
367
+ if (needsCacheAdd && lastNonEmptyTextIndex >= 0) {
368
+ workingContent.splice(lastNonEmptyTextIndex + 1, 0, {
395
369
  cachePoint: { type: 'default' },
396
370
  } as MessageContentComplex);
397
- inserted = true;
398
- break;
371
+ messagesModified++;
399
372
  }
400
- }
401
- if (!inserted) {
402
- workingContent.push({
403
- cachePoint: { type: 'default' },
404
- } as MessageContentComplex);
373
+ } else if (typeof content === 'string' && needsCacheAdd) {
374
+ workingContent = [
375
+ { type: ContentTypes.TEXT, text: content },
376
+ { cachePoint: { type: 'default' } } as MessageContentComplex,
377
+ ];
378
+ messagesModified++;
379
+ } else {
380
+ continue;
405
381
  }
406
382
 
407
383
  updatedMessages[i] = cloneMessage(originalMessage, workingContent);
408
- messagesModified++;
409
384
  }
410
385
 
411
386
  return updatedMessages;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Position-based context pruning for tool results.
3
+ *
4
+ * Uses position-based age: the distance of a message
5
+ * from the conversation end as a fraction of total messages.
6
+ *
7
+ * Two degradation levels:
8
+ * - Soft-trim: Keep head + tail of tool result content, drop middle.
9
+ * - Hard-clear: Replace entire content with a placeholder.
10
+ *
11
+ * Messages in the "protected zone" (recent assistant turns, system/pre-first-human
12
+ * messages, and messages with image content) are never pruned.
13
+ */
14
+
15
+ import { ToolMessage, type BaseMessage } from '@langchain/core/messages';
16
+ import type { ContextPruningConfig } from '@/types/graph';
17
+ import type { TokenCounter } from '@/types/run';
18
+ import type { ContextPruningSettings } from './contextPruningSettings';
19
+ import { resolveContextPruningSettings } from './contextPruningSettings';
20
+
21
+ /**
22
+ * Checks if a message contains image content blocks.
23
+ * Messages with images are skipped by position-based content degradation
24
+ * because images cannot be meaningfully soft-trimmed or replaced with placeholders.
25
+ */
26
+ function hasImageContent(message: BaseMessage): boolean {
27
+ if (!Array.isArray(message.content)) {
28
+ return false;
29
+ }
30
+ return message.content.some(
31
+ (block) =>
32
+ typeof block === 'object' &&
33
+ 'type' in block &&
34
+ (block.type === 'image_url' || block.type === 'image')
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Applies head+tail soft-trim to tool result content.
40
+ */
41
+ function softTrimContent(
42
+ content: string,
43
+ settings: ContextPruningSettings['softTrim']
44
+ ): string {
45
+ const { headChars, tailChars } = settings;
46
+ const indicator = `\n\n… [soft-trimmed: ${content.length} chars → ${headChars + tailChars} chars, middle removed] …\n\n`;
47
+ return content.slice(0, headChars) + indicator + content.slice(-tailChars);
48
+ }
49
+
50
+ export interface ContextPruningResult {
51
+ /** Number of messages that were soft-trimmed. */
52
+ softTrimmed: number;
53
+ /** Number of messages that were hard-cleared. */
54
+ hardCleared: number;
55
+ }
56
+
57
+ /**
58
+ * Applies position-based context pruning to tool result messages.
59
+ *
60
+ * Modifies messages in-place and updates indexTokenCountMap with recounted
61
+ * token values for modified messages.
62
+ *
63
+ * @param params.messages - The full message array (modified in-place).
64
+ * @param params.indexTokenCountMap - Token count map (updated in-place).
65
+ * @param params.tokenCounter - Function to recount tokens after modification.
66
+ * @param params.config - Partial context pruning config (merged with defaults).
67
+ * @returns Counts of soft-trimmed and hard-cleared messages.
68
+ */
69
+ export function applyContextPruning(params: {
70
+ messages: BaseMessage[];
71
+ indexTokenCountMap: Record<string, number | undefined>;
72
+ tokenCounter: TokenCounter;
73
+ config?: ContextPruningConfig;
74
+ resolvedSettings?: ContextPruningSettings;
75
+ }): ContextPruningResult {
76
+ const {
77
+ messages,
78
+ indexTokenCountMap,
79
+ tokenCounter,
80
+ config,
81
+ resolvedSettings,
82
+ } = params;
83
+ const settings = resolvedSettings ?? resolveContextPruningSettings(config);
84
+
85
+ if (!settings.enabled || messages.length === 0) {
86
+ return { softTrimmed: 0, hardCleared: 0 };
87
+ }
88
+
89
+ const totalMessages = messages.length;
90
+ let softTrimmed = 0;
91
+ let hardCleared = 0;
92
+
93
+ // Find the protected zone: last N assistant turns from the end.
94
+ // An "assistant turn" is a contiguous sequence of AI + Tool messages.
95
+ const protectedIndices = new Set<number>();
96
+
97
+ // Always protect the system message (index 0 if present)
98
+ if (messages[0]?.getType() === 'system') {
99
+ protectedIndices.add(0);
100
+ }
101
+
102
+ // Protect messages before the first human message
103
+ for (let i = 0; i < totalMessages; i++) {
104
+ if (messages[i].getType() === 'human') {
105
+ break;
106
+ }
107
+ protectedIndices.add(i);
108
+ }
109
+
110
+ // Protect the last N assistant turns (walking backwards)
111
+ let assistantTurnsFound = 0;
112
+ let inAssistantSequence = false;
113
+ for (let i = totalMessages - 1; i >= 0; i--) {
114
+ const type = messages[i].getType();
115
+ if (type === 'ai' || type === 'tool') {
116
+ protectedIndices.add(i);
117
+ if (!inAssistantSequence) {
118
+ inAssistantSequence = true;
119
+ }
120
+ } else {
121
+ if (inAssistantSequence) {
122
+ assistantTurnsFound++;
123
+ inAssistantSequence = false;
124
+ if (assistantTurnsFound >= settings.keepLastAssistants) {
125
+ break;
126
+ }
127
+ }
128
+ // Protect the human message between assistant turns in the protected zone
129
+ if (assistantTurnsFound < settings.keepLastAssistants) {
130
+ protectedIndices.add(i);
131
+ }
132
+ }
133
+ }
134
+
135
+ // Process each tool message outside the protected zone
136
+ for (let i = 0; i < totalMessages; i++) {
137
+ const message = messages[i];
138
+ if (message.getType() !== 'tool') {
139
+ continue;
140
+ }
141
+ if (protectedIndices.has(i)) {
142
+ continue;
143
+ }
144
+ if (hasImageContent(message)) {
145
+ continue;
146
+ }
147
+
148
+ const content = message.content;
149
+ if (typeof content !== 'string') {
150
+ continue;
151
+ }
152
+ if (content.length < settings.minPrunableToolChars) {
153
+ continue;
154
+ }
155
+
156
+ // Compute age ratio: how far back from the end (0 = latest, 1 = oldest)
157
+ const ageRatio = (totalMessages - i) / totalMessages;
158
+
159
+ if (ageRatio >= settings.hardClearRatio && settings.hardClear.enabled) {
160
+ // Hard-clear: replace with placeholder
161
+ const cloned = new ToolMessage({
162
+ content: settings.hardClear.placeholder,
163
+ tool_call_id: (message as ToolMessage).tool_call_id,
164
+ name: message.name,
165
+ id: message.id,
166
+ additional_kwargs: message.additional_kwargs,
167
+ response_metadata: message.response_metadata,
168
+ });
169
+ messages[i] = cloned;
170
+ indexTokenCountMap[i] = tokenCounter(cloned);
171
+ hardCleared++;
172
+ } else if (ageRatio >= settings.softTrimRatio) {
173
+ // Soft-trim: keep head + tail
174
+ if (content.length > settings.softTrim.maxChars) {
175
+ const cloned = new ToolMessage({
176
+ content: softTrimContent(content, settings.softTrim),
177
+ tool_call_id: (message as ToolMessage).tool_call_id,
178
+ name: message.name,
179
+ id: message.id,
180
+ additional_kwargs: message.additional_kwargs,
181
+ response_metadata: message.response_metadata,
182
+ });
183
+ messages[i] = cloned;
184
+ indexTokenCountMap[i] = tokenCounter(cloned);
185
+ softTrimmed++;
186
+ }
187
+ }
188
+ }
189
+
190
+ return { softTrimmed, hardCleared };
191
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Default settings for position-based context pruning.
3
+ *
4
+ * These are merged with user-provided overrides so any subset can be customized.
5
+ */
6
+
7
+ export interface ContextPruningSettings {
8
+ /** Whether position-based pruning is enabled. Default: false (opt-in). */
9
+ enabled: boolean;
10
+ /** Number of recent assistant turns to protect from pruning. Default: 3 */
11
+ keepLastAssistants: number;
12
+ /** Age ratio (0-1) at which soft-trim fires. Default: 0.3 */
13
+ softTrimRatio: number;
14
+ /** Age ratio (0-1) at which hard-clear fires. Default: 0.5 */
15
+ hardClearRatio: number;
16
+ /** Minimum tool result size (chars) before pruning applies. Default: 50000 */
17
+ minPrunableToolChars: number;
18
+ softTrim: {
19
+ /** Maximum total chars after soft-trim. Default: 4000 */
20
+ maxChars: number;
21
+ /** Head portion to keep. Default: 1500 */
22
+ headChars: number;
23
+ /** Tail portion to keep. Default: 1500 */
24
+ tailChars: number;
25
+ };
26
+ hardClear: {
27
+ /** Whether hard-clear is enabled. Default: true */
28
+ enabled: boolean;
29
+ /** Placeholder text for hard-cleared content. */
30
+ placeholder: string;
31
+ };
32
+ }
33
+
34
+ export const DEFAULT_CONTEXT_PRUNING_SETTINGS: ContextPruningSettings = {
35
+ enabled: false,
36
+ keepLastAssistants: 3,
37
+ softTrimRatio: 0.3,
38
+ hardClearRatio: 0.5,
39
+ minPrunableToolChars: 50_000,
40
+ softTrim: {
41
+ maxChars: 4_000,
42
+ headChars: 1_500,
43
+ tailChars: 1_500,
44
+ },
45
+ hardClear: {
46
+ enabled: true,
47
+ placeholder: '[Old tool result content cleared]',
48
+ },
49
+ };
50
+
51
+ /**
52
+ * Merges user-provided partial overrides with the defaults.
53
+ */
54
+ export function resolveContextPruningSettings(
55
+ overrides?: Partial<{
56
+ enabled?: boolean;
57
+ keepLastAssistants?: number;
58
+ softTrimRatio?: number;
59
+ hardClearRatio?: number;
60
+ minPrunableToolChars?: number;
61
+ softTrim?: Partial<ContextPruningSettings['softTrim']>;
62
+ hardClear?: Partial<ContextPruningSettings['hardClear']>;
63
+ }>
64
+ ): ContextPruningSettings {
65
+ if (!overrides) {
66
+ return { ...DEFAULT_CONTEXT_PRUNING_SETTINGS };
67
+ }
68
+ return {
69
+ enabled: overrides.enabled ?? DEFAULT_CONTEXT_PRUNING_SETTINGS.enabled,
70
+ keepLastAssistants:
71
+ overrides.keepLastAssistants ??
72
+ DEFAULT_CONTEXT_PRUNING_SETTINGS.keepLastAssistants,
73
+ softTrimRatio:
74
+ overrides.softTrimRatio ?? DEFAULT_CONTEXT_PRUNING_SETTINGS.softTrimRatio,
75
+ hardClearRatio:
76
+ overrides.hardClearRatio ??
77
+ DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClearRatio,
78
+ minPrunableToolChars:
79
+ overrides.minPrunableToolChars ??
80
+ DEFAULT_CONTEXT_PRUNING_SETTINGS.minPrunableToolChars,
81
+ softTrim: {
82
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS.softTrim,
83
+ ...overrides.softTrim,
84
+ },
85
+ hardClear: {
86
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS.hardClear,
87
+ ...overrides.hardClear,
88
+ },
89
+ };
90
+ }
@@ -1,10 +1,10 @@
1
1
  // src/messages.ts
2
2
  import {
3
- AIMessageChunk,
4
- HumanMessage,
5
- ToolMessage,
6
3
  AIMessage,
7
4
  BaseMessage,
5
+ ToolMessage,
6
+ HumanMessage,
7
+ AIMessageChunk,
8
8
  } from '@langchain/core/messages';
9
9
  import type { ToolCall } from '@langchain/core/messages/tool';
10
10
  import type * as t from '@/types';
@@ -54,10 +54,10 @@ const modifyContent = ({
54
54
  provider: Providers;
55
55
  messageType: string;
56
56
  content: t.ExtendedMessageContent[];
57
- }): t.ExtendedMessageContent[] => {
57
+ }): (t.ExtendedMessageContent | null)[] => {
58
58
  const allowedTypes =
59
59
  allowedTypesByProvider[provider] ?? allowedTypesByProvider.default;
60
- return content.map((item) => {
60
+ return content.map((item: t.ExtendedMessageContent | null) => {
61
61
  if (
62
62
  item &&
63
63
  typeof item === 'object' &&
@@ -153,7 +153,7 @@ export function modifyDeltaProperties(
153
153
  provider,
154
154
  messageType,
155
155
  content: obj.content,
156
- });
156
+ }) as t.MessageContentComplex[];
157
157
  }
158
158
  if (
159
159
  (obj as Partial<AIMessageChunk>).lc_kwargs &&
@@ -365,31 +365,29 @@ export function formatAnthropicArtifactContent(messages: BaseMessage[]): void {
365
365
 
366
366
  if (latestAIParentIndex === -1) return;
367
367
 
368
- // Check if any tool message after the AI message has array artifact content
369
- const hasArtifactContent = messages.some(
370
- (msg, i) =>
371
- i > latestAIParentIndex &&
372
- msg instanceof ToolMessage &&
373
- msg.artifact != null &&
374
- msg.artifact?.content != null &&
375
- Array.isArray(msg.artifact.content)
376
- );
377
-
378
- if (!hasArtifactContent) return;
379
-
368
+ // Build tool call ID set and merge artifact content in a single forward pass.
380
369
  const message = messages[latestAIParentIndex] as AIMessageChunk;
381
- const toolCallIds = message.tool_calls?.map((tc) => tc.id) ?? [];
370
+ const toolCallIdSet = new Set<string>();
371
+ if (message.tool_calls) {
372
+ for (const tc of message.tool_calls) {
373
+ if (tc.id != null) {
374
+ toolCallIdSet.add(tc.id);
375
+ }
376
+ }
377
+ }
382
378
 
383
379
  for (let j = latestAIParentIndex + 1; j < messages.length; j++) {
384
380
  const msg = messages[j];
385
381
  if (
386
382
  msg instanceof ToolMessage &&
387
- toolCallIds.includes(msg.tool_call_id) &&
383
+ toolCallIdSet.has(msg.tool_call_id) &&
388
384
  msg.artifact != null &&
389
- Array.isArray(msg.artifact?.content) &&
390
- Array.isArray(msg.content)
385
+ Array.isArray(msg.artifact?.content)
391
386
  ) {
392
- msg.content = msg.content.concat(msg.artifact.content);
387
+ const base = Array.isArray(msg.content)
388
+ ? msg.content
389
+ : [{ type: 'text' as const, text: String(msg.content ?? '') }];
390
+ msg.content = base.concat(msg.artifact.content);
393
391
  }
394
392
  }
395
393
  }
@@ -410,46 +408,27 @@ export function formatArtifactPayload(messages: BaseMessage[]): void {
410
408
 
411
409
  if (latestAIParentIndex === -1) return;
412
410
 
413
- // Check if any tool message after the AI message has array artifact content
414
- const hasArtifactContent = messages.some(
415
- (msg, i) =>
416
- i > latestAIParentIndex &&
417
- msg instanceof ToolMessage &&
418
- msg.artifact != null &&
419
- msg.artifact?.content != null &&
420
- Array.isArray(msg.artifact.content)
421
- );
422
-
423
- if (!hasArtifactContent) return;
424
-
425
- // Collect all relevant tool messages and their artifacts
426
- const relevantMessages = messages
427
- .slice(latestAIParentIndex + 1)
428
- .filter((msg) => msg instanceof ToolMessage) as ToolMessage[];
429
-
430
- // Aggregate all content and artifacts
411
+ // Single pass: collect relevant tool messages with artifacts and aggregate
431
412
  const aggregatedContent: t.MessageContentComplex[] = [];
432
413
 
433
- relevantMessages.forEach((msg) => {
434
- if (!Array.isArray(msg.artifact?.content)) {
435
- return;
414
+ for (let i = latestAIParentIndex + 1; i < messages.length; i++) {
415
+ const msg = messages[i];
416
+ if (
417
+ !(msg instanceof ToolMessage) ||
418
+ !Array.isArray(msg.artifact?.content)
419
+ ) {
420
+ continue;
436
421
  }
437
422
  let currentContent = msg.content;
438
423
  if (!Array.isArray(currentContent)) {
439
- currentContent = [
440
- {
441
- type: 'text',
442
- text: msg.content,
443
- },
444
- ];
424
+ currentContent = [{ type: 'text', text: msg.content }];
445
425
  }
446
- aggregatedContent.push(...currentContent);
426
+ aggregatedContent.push(...(currentContent as t.MessageContentComplex[]));
447
427
  msg.content =
448
428
  'Tool response is included in the next message as a Human message';
449
429
  aggregatedContent.push(...msg.artifact.content);
450
- });
430
+ }
451
431
 
452
- // Add single HumanMessage with all aggregated content
453
432
  if (aggregatedContent.length > 0) {
454
433
  messages.push(new HumanMessage({ content: aggregatedContent }));
455
434
  }