@librechat/agents 3.1.77 → 3.1.78-dev.0

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 (181) hide show
  1. package/dist/cjs/common/enum.cjs +54 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +148 -4
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs +291 -0
  6. package/dist/cjs/hooks/createWorkspacePolicyHook.cjs.map +1 -0
  7. package/dist/cjs/main.cjs +90 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/messages/anthropicToolCache.cjs +102 -0
  10. package/dist/cjs/messages/anthropicToolCache.cjs.map +1 -0
  11. package/dist/cjs/messages/prune.cjs +27 -0
  12. package/dist/cjs/messages/prune.cjs.map +1 -1
  13. package/dist/cjs/messages/recency.cjs +99 -0
  14. package/dist/cjs/messages/recency.cjs.map +1 -0
  15. package/dist/cjs/run.cjs +30 -0
  16. package/dist/cjs/run.cjs.map +1 -1
  17. package/dist/cjs/summarization/node.cjs +100 -6
  18. package/dist/cjs/summarization/node.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +635 -23
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/cjs/tools/local/CompileCheckTool.cjs +227 -0
  22. package/dist/cjs/tools/local/CompileCheckTool.cjs.map +1 -0
  23. package/dist/cjs/tools/local/FileCheckpointer.cjs +90 -0
  24. package/dist/cjs/tools/local/FileCheckpointer.cjs.map +1 -0
  25. package/dist/cjs/tools/local/LocalCodingTools.cjs +1098 -0
  26. package/dist/cjs/tools/local/LocalCodingTools.cjs.map +1 -0
  27. package/dist/cjs/tools/local/LocalExecutionEngine.cjs +1042 -0
  28. package/dist/cjs/tools/local/LocalExecutionEngine.cjs.map +1 -0
  29. package/dist/cjs/tools/local/LocalExecutionTools.cjs +122 -0
  30. package/dist/cjs/tools/local/LocalExecutionTools.cjs.map +1 -0
  31. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs +453 -0
  32. package/dist/cjs/tools/local/LocalProgrammaticToolCalling.cjs.map +1 -0
  33. package/dist/cjs/tools/local/attachments.cjs +183 -0
  34. package/dist/cjs/tools/local/attachments.cjs.map +1 -0
  35. package/dist/cjs/tools/local/bashAst.cjs +129 -0
  36. package/dist/cjs/tools/local/bashAst.cjs.map +1 -0
  37. package/dist/cjs/tools/local/editStrategies.cjs +188 -0
  38. package/dist/cjs/tools/local/editStrategies.cjs.map +1 -0
  39. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs +141 -0
  40. package/dist/cjs/tools/local/resolveLocalExecutionTools.cjs.map +1 -0
  41. package/dist/cjs/tools/local/syntaxCheck.cjs +182 -0
  42. package/dist/cjs/tools/local/syntaxCheck.cjs.map +1 -0
  43. package/dist/cjs/tools/local/textEncoding.cjs +30 -0
  44. package/dist/cjs/tools/local/textEncoding.cjs.map +1 -0
  45. package/dist/cjs/tools/local/workspaceFS.cjs +51 -0
  46. package/dist/cjs/tools/local/workspaceFS.cjs.map +1 -0
  47. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
  48. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  49. package/dist/esm/common/enum.mjs +53 -1
  50. package/dist/esm/common/enum.mjs.map +1 -1
  51. package/dist/esm/graphs/Graph.mjs +149 -5
  52. package/dist/esm/graphs/Graph.mjs.map +1 -1
  53. package/dist/esm/hooks/createWorkspacePolicyHook.mjs +289 -0
  54. package/dist/esm/hooks/createWorkspacePolicyHook.mjs.map +1 -0
  55. package/dist/esm/main.mjs +17 -2
  56. package/dist/esm/main.mjs.map +1 -1
  57. package/dist/esm/messages/anthropicToolCache.mjs +99 -0
  58. package/dist/esm/messages/anthropicToolCache.mjs.map +1 -0
  59. package/dist/esm/messages/prune.mjs +26 -1
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/messages/recency.mjs +97 -0
  62. package/dist/esm/messages/recency.mjs.map +1 -0
  63. package/dist/esm/run.mjs +30 -0
  64. package/dist/esm/run.mjs.map +1 -1
  65. package/dist/esm/summarization/node.mjs +100 -6
  66. package/dist/esm/summarization/node.mjs.map +1 -1
  67. package/dist/esm/tools/ToolNode.mjs +635 -23
  68. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  69. package/dist/esm/tools/local/CompileCheckTool.mjs +223 -0
  70. package/dist/esm/tools/local/CompileCheckTool.mjs.map +1 -0
  71. package/dist/esm/tools/local/FileCheckpointer.mjs +87 -0
  72. package/dist/esm/tools/local/FileCheckpointer.mjs.map +1 -0
  73. package/dist/esm/tools/local/LocalCodingTools.mjs +1075 -0
  74. package/dist/esm/tools/local/LocalCodingTools.mjs.map +1 -0
  75. package/dist/esm/tools/local/LocalExecutionEngine.mjs +1022 -0
  76. package/dist/esm/tools/local/LocalExecutionEngine.mjs.map +1 -0
  77. package/dist/esm/tools/local/LocalExecutionTools.mjs +117 -0
  78. package/dist/esm/tools/local/LocalExecutionTools.mjs.map +1 -0
  79. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs +448 -0
  80. package/dist/esm/tools/local/LocalProgrammaticToolCalling.mjs.map +1 -0
  81. package/dist/esm/tools/local/attachments.mjs +180 -0
  82. package/dist/esm/tools/local/attachments.mjs.map +1 -0
  83. package/dist/esm/tools/local/bashAst.mjs +126 -0
  84. package/dist/esm/tools/local/bashAst.mjs.map +1 -0
  85. package/dist/esm/tools/local/editStrategies.mjs +185 -0
  86. package/dist/esm/tools/local/editStrategies.mjs.map +1 -0
  87. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs +137 -0
  88. package/dist/esm/tools/local/resolveLocalExecutionTools.mjs.map +1 -0
  89. package/dist/esm/tools/local/syntaxCheck.mjs +179 -0
  90. package/dist/esm/tools/local/syntaxCheck.mjs.map +1 -0
  91. package/dist/esm/tools/local/textEncoding.mjs +27 -0
  92. package/dist/esm/tools/local/textEncoding.mjs.map +1 -0
  93. package/dist/esm/tools/local/workspaceFS.mjs +49 -0
  94. package/dist/esm/tools/local/workspaceFS.mjs.map +1 -0
  95. package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
  96. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  97. package/dist/types/common/enum.d.ts +39 -1
  98. package/dist/types/graphs/Graph.d.ts +34 -0
  99. package/dist/types/hooks/createWorkspacePolicyHook.d.ts +95 -0
  100. package/dist/types/hooks/index.d.ts +2 -0
  101. package/dist/types/index.d.ts +1 -0
  102. package/dist/types/messages/anthropicToolCache.d.ts +51 -0
  103. package/dist/types/messages/index.d.ts +2 -0
  104. package/dist/types/messages/prune.d.ts +11 -0
  105. package/dist/types/messages/recency.d.ts +64 -0
  106. package/dist/types/run.d.ts +21 -0
  107. package/dist/types/tools/ToolNode.d.ts +145 -2
  108. package/dist/types/tools/local/CompileCheckTool.d.ts +31 -0
  109. package/dist/types/tools/local/FileCheckpointer.d.ts +39 -0
  110. package/dist/types/tools/local/LocalCodingTools.d.ts +57 -0
  111. package/dist/types/tools/local/LocalExecutionEngine.d.ts +149 -0
  112. package/dist/types/tools/local/LocalExecutionTools.d.ts +9 -0
  113. package/dist/types/tools/local/LocalProgrammaticToolCalling.d.ts +21 -0
  114. package/dist/types/tools/local/attachments.d.ts +84 -0
  115. package/dist/types/tools/local/bashAst.d.ts +11 -0
  116. package/dist/types/tools/local/editStrategies.d.ts +28 -0
  117. package/dist/types/tools/local/index.d.ts +12 -0
  118. package/dist/types/tools/local/resolveLocalExecutionTools.d.ts +38 -0
  119. package/dist/types/tools/local/syntaxCheck.d.ts +42 -0
  120. package/dist/types/tools/local/textEncoding.d.ts +21 -0
  121. package/dist/types/tools/local/workspaceFS.d.ts +49 -0
  122. package/dist/types/types/hitl.d.ts +56 -27
  123. package/dist/types/types/run.d.ts +8 -1
  124. package/dist/types/types/summarize.d.ts +30 -0
  125. package/dist/types/types/tools.d.ts +341 -6
  126. package/package.json +21 -2
  127. package/src/common/enum.ts +54 -0
  128. package/src/graphs/Graph.ts +164 -6
  129. package/src/hooks/__tests__/compactHooks.test.ts +38 -2
  130. package/src/hooks/__tests__/createWorkspacePolicyHook.test.ts +393 -0
  131. package/src/hooks/createWorkspacePolicyHook.ts +355 -0
  132. package/src/hooks/index.ts +6 -0
  133. package/src/index.ts +1 -0
  134. package/src/messages/__tests__/anthropicToolCache.test.ts +125 -0
  135. package/src/messages/__tests__/recency.test.ts +267 -0
  136. package/src/messages/anthropicToolCache.ts +116 -0
  137. package/src/messages/index.ts +2 -0
  138. package/src/messages/prune.ts +27 -1
  139. package/src/messages/recency.ts +155 -0
  140. package/src/run.ts +31 -0
  141. package/src/scripts/compare_pi_vs_ours.ts +840 -0
  142. package/src/scripts/local_engine.ts +166 -0
  143. package/src/scripts/local_engine_checkpointer.ts +205 -0
  144. package/src/scripts/local_engine_compile.ts +263 -0
  145. package/src/scripts/local_engine_hooks.ts +226 -0
  146. package/src/scripts/local_engine_image.ts +201 -0
  147. package/src/scripts/local_engine_ptc.ts +151 -0
  148. package/src/scripts/local_engine_workspace.ts +258 -0
  149. package/src/scripts/summarization-recency.ts +462 -0
  150. package/src/specs/prune.test.ts +39 -0
  151. package/src/summarization/__tests__/node.test.ts +499 -3
  152. package/src/summarization/node.ts +124 -7
  153. package/src/tools/ToolNode.ts +769 -20
  154. package/src/tools/__tests__/LocalExecutionTools.test.ts +2647 -0
  155. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +175 -0
  156. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +114 -0
  157. package/src/tools/__tests__/ToolNode.session.test.ts +84 -0
  158. package/src/tools/__tests__/directToolHITLResumeScope.test.ts +467 -0
  159. package/src/tools/__tests__/directToolHooks.test.ts +411 -0
  160. package/src/tools/__tests__/localToolNames.test.ts +73 -0
  161. package/src/tools/__tests__/workspaceSeam.test.ts +134 -0
  162. package/src/tools/local/CompileCheckTool.ts +278 -0
  163. package/src/tools/local/FileCheckpointer.ts +93 -0
  164. package/src/tools/local/LocalCodingTools.ts +1342 -0
  165. package/src/tools/local/LocalExecutionEngine.ts +1329 -0
  166. package/src/tools/local/LocalExecutionTools.ts +167 -0
  167. package/src/tools/local/LocalProgrammaticToolCalling.ts +594 -0
  168. package/src/tools/local/__tests__/FileCheckpointer.test.ts +120 -0
  169. package/src/tools/local/__tests__/editStrategies.test.ts +134 -0
  170. package/src/tools/local/attachments.ts +251 -0
  171. package/src/tools/local/bashAst.ts +151 -0
  172. package/src/tools/local/editStrategies.ts +188 -0
  173. package/src/tools/local/index.ts +12 -0
  174. package/src/tools/local/resolveLocalExecutionTools.ts +208 -0
  175. package/src/tools/local/syntaxCheck.ts +243 -0
  176. package/src/tools/local/textEncoding.ts +37 -0
  177. package/src/tools/local/workspaceFS.ts +89 -0
  178. package/src/types/hitl.ts +56 -27
  179. package/src/types/run.ts +12 -1
  180. package/src/types/summarize.ts +31 -0
  181. package/src/types/tools.ts +359 -7
@@ -0,0 +1,267 @@
1
+ import {
2
+ AIMessage,
3
+ HumanMessage,
4
+ ToolMessage,
5
+ SystemMessage,
6
+ type BaseMessage,
7
+ } from '@langchain/core/messages';
8
+ import { splitAtRecencyBoundary } from '@/messages/recency';
9
+
10
+ describe('splitAtRecencyBoundary', () => {
11
+ describe('default behavior (turns: 2)', () => {
12
+ it('returns empty head and full tail for an empty array', () => {
13
+ const result = splitAtRecencyBoundary([], { turns: 2 });
14
+ expect(result.head).toEqual([]);
15
+ expect(result.tail).toEqual([]);
16
+ expect(result.tailTurnCount).toBe(0);
17
+ expect(result.tailStartIndex).toBe(0);
18
+ });
19
+
20
+ it('always preserves the most recent turn even with one large message', () => {
21
+ const messages = [new HumanMessage('huge first message'.repeat(1000))];
22
+ const result = splitAtRecencyBoundary(messages, { turns: 2 });
23
+ expect(result.head).toEqual([]);
24
+ expect(result.tail).toEqual(messages);
25
+ expect(result.tailTurnCount).toBe(1);
26
+ });
27
+
28
+ it('keeps a complete user-assistant exchange in the tail', () => {
29
+ const messages = [new HumanMessage('hi'), new AIMessage('hello')];
30
+ const result = splitAtRecencyBoundary(messages, { turns: 2 });
31
+ expect(result.head).toEqual([]);
32
+ expect(result.tail).toEqual(messages);
33
+ expect(result.tailTurnCount).toBe(1);
34
+ });
35
+
36
+ it('places older turns in the head when there are more turns than the cap', () => {
37
+ const messages = [
38
+ new HumanMessage('turn 1'),
39
+ new AIMessage('reply 1'),
40
+ new HumanMessage('turn 2'),
41
+ new AIMessage('reply 2'),
42
+ new HumanMessage('turn 3'),
43
+ new AIMessage('reply 3'),
44
+ ];
45
+ const result = splitAtRecencyBoundary(messages, { turns: 2 });
46
+ expect(result.head).toEqual(messages.slice(0, 2));
47
+ expect(result.tail).toEqual(messages.slice(2));
48
+ expect(result.tailTurnCount).toBe(2);
49
+ expect(result.tailStartIndex).toBe(2);
50
+ });
51
+
52
+ it('preserves tool_use / tool_result pairs across the boundary', () => {
53
+ const messages = [
54
+ new HumanMessage('turn 1'),
55
+ new AIMessage({
56
+ content: '',
57
+ tool_calls: [{ id: 'call_a', name: 'search', args: {} }],
58
+ }),
59
+ new ToolMessage({
60
+ content: 'result A',
61
+ tool_call_id: 'call_a',
62
+ name: 'search',
63
+ }),
64
+ new AIMessage('done with turn 1'),
65
+ new HumanMessage('turn 2'),
66
+ new AIMessage({
67
+ content: '',
68
+ tool_calls: [{ id: 'call_b', name: 'search', args: {} }],
69
+ }),
70
+ new ToolMessage({
71
+ content: 'result B',
72
+ tool_call_id: 'call_b',
73
+ name: 'search',
74
+ }),
75
+ new AIMessage('done with turn 2'),
76
+ new HumanMessage('turn 3'),
77
+ new AIMessage('reply 3'),
78
+ ];
79
+ const result = splitAtRecencyBoundary(messages, { turns: 2 });
80
+ // Head must contain turn 1's complete tool_use → tool_result pair.
81
+ expect(result.head).toHaveLength(4);
82
+ expect(result.head[0]).toBe(messages[0]);
83
+ expect(result.head[3]).toBe(messages[3]);
84
+ // Tail starts cleanly at turn 2's HumanMessage — never mid-pair.
85
+ expect(result.tail[0]).toBe(messages[4]);
86
+ expect(result.tail).toHaveLength(6);
87
+ });
88
+ });
89
+
90
+ describe('disabled (turns: 0)', () => {
91
+ it('puts everything in head when turns is 0', () => {
92
+ const messages = [
93
+ new HumanMessage('one'),
94
+ new AIMessage('two'),
95
+ new HumanMessage('three'),
96
+ ];
97
+ const result = splitAtRecencyBoundary(messages, { turns: 0 });
98
+ expect(result.head).toEqual(messages);
99
+ expect(result.tail).toEqual([]);
100
+ expect(result.tailTurnCount).toBe(0);
101
+ });
102
+
103
+ it('treats negative turns as 0', () => {
104
+ const messages = [new HumanMessage('a'), new AIMessage('b')];
105
+ const result = splitAtRecencyBoundary(messages, { turns: -5 });
106
+ expect(result.tail).toEqual([]);
107
+ expect(result.head).toEqual(messages);
108
+ });
109
+ });
110
+
111
+ describe('token cap', () => {
112
+ it('honors the token cap when adding older turns', () => {
113
+ const messages = [
114
+ new HumanMessage('turn 1'),
115
+ new AIMessage('reply 1'),
116
+ new HumanMessage('turn 2'),
117
+ new AIMessage('reply 2'),
118
+ new HumanMessage('turn 3'),
119
+ new AIMessage('reply 3'),
120
+ ];
121
+ const tokenCounter = (): number => 100;
122
+ const result = splitAtRecencyBoundary(messages, {
123
+ turns: 5,
124
+ tokens: 250,
125
+ tokenCounter,
126
+ });
127
+ // Last turn is always preserved (200 tokens for 2 messages).
128
+ // Adding turn 2 would push to 400, exceeding cap of 250 → stop.
129
+ expect(result.tailTurnCount).toBe(1);
130
+ expect(result.tail).toEqual(messages.slice(4));
131
+ });
132
+
133
+ it('always preserves the most recent turn even when it exceeds the cap', () => {
134
+ const messages = [new HumanMessage('huge'), new AIMessage('also huge')];
135
+ const tokenCounter = (): number => 1_000_000;
136
+ const result = splitAtRecencyBoundary(messages, {
137
+ turns: 2,
138
+ tokens: 10,
139
+ tokenCounter,
140
+ });
141
+ expect(result.head).toEqual([]);
142
+ expect(result.tail).toEqual(messages);
143
+ expect(result.tailTurnCount).toBe(1);
144
+ });
145
+
146
+ it('ignores the token cap when no tokenCounter is provided', () => {
147
+ const messages = [
148
+ new HumanMessage('a'),
149
+ new AIMessage('b'),
150
+ new HumanMessage('c'),
151
+ new AIMessage('d'),
152
+ ];
153
+ const result = splitAtRecencyBoundary(messages, {
154
+ turns: 3,
155
+ tokens: 1, // would force tail to most-recent-only if applied
156
+ });
157
+ // No tokenCounter → fall back to turn-based selection only.
158
+ expect(result.tailTurnCount).toBe(2);
159
+ expect(result.head).toEqual([]);
160
+ expect(result.tail).toEqual(messages);
161
+ });
162
+ });
163
+
164
+ describe('linearity', () => {
165
+ it('calls tokenCounter once per message in visited turns (no quadratic recount)', () => {
166
+ // Build a long history: 200 turns × 10 messages = 2,000 messages.
167
+ // If the boundary search were quadratic in the number of turns,
168
+ // the call count would explode (e.g., 200 × 2,000 = 400,000).
169
+ // The disjoint-slice invariant guarantees one call per visited
170
+ // message, bounded by messages.length even with a generous turn
171
+ // budget that visits every turn.
172
+ const messages: BaseMessage[] = [];
173
+ const turnCount = 200;
174
+ const messagesPerTurn = 10;
175
+ for (let t = 0; t < turnCount; t++) {
176
+ messages.push(new HumanMessage(`turn ${t} query`));
177
+ for (let m = 1; m < messagesPerTurn; m++) {
178
+ messages.push(new AIMessage(`turn ${t} reply ${m}`));
179
+ }
180
+ }
181
+
182
+ let calls = 0;
183
+ const tokenCounter = (): number => {
184
+ calls += 1;
185
+ return 1;
186
+ };
187
+
188
+ // Generous tokens cap so the loop visits every turn.
189
+ // turnsCap also generous so the limit isn't hit early.
190
+ splitAtRecencyBoundary(messages, {
191
+ turns: 1_000_000,
192
+ tokens: 1_000_000,
193
+ tokenCounter,
194
+ });
195
+
196
+ // Strictly bounded by messages.length. No message is counted
197
+ // twice, regardless of how many turns the splitter walks.
198
+ expect(calls).toBeLessThanOrEqual(messages.length);
199
+ expect(calls).toBe(messages.length);
200
+ });
201
+
202
+ it('stops counting once the tokens cap is exceeded (no scan past the boundary)', () => {
203
+ const messages: BaseMessage[] = [];
204
+ for (let t = 0; t < 50; t++) {
205
+ messages.push(new HumanMessage(`turn ${t}`));
206
+ messages.push(new AIMessage(`reply ${t}`));
207
+ }
208
+
209
+ let calls = 0;
210
+ const tokenCounter = (): number => {
211
+ calls += 1;
212
+ return 1; // 1 token per message → 100 tokens total
213
+ };
214
+
215
+ // Cap of 10 tokens lets us include the last 5 turns (10 messages)
216
+ // before the next turn's 2 tokens would overflow.
217
+ const result = splitAtRecencyBoundary(messages, {
218
+ turns: 1_000,
219
+ tokens: 10,
220
+ tokenCounter,
221
+ });
222
+
223
+ // Visited at most: 5 included turns × 2 messages + one over-budget
224
+ // turn × 2 messages (counted then rejected) = 12 messages. Far
225
+ // less than the full 100.
226
+ expect(calls).toBeLessThanOrEqual(12);
227
+ expect(result.tailTurnCount).toBe(5);
228
+ });
229
+ });
230
+
231
+ describe('degenerate inputs', () => {
232
+ it('puts everything in the head when there is no HumanMessage', () => {
233
+ const messages = [
234
+ new SystemMessage('preamble'),
235
+ new AIMessage('starter'),
236
+ ];
237
+ const result = splitAtRecencyBoundary(messages, { turns: 2 });
238
+ expect(result.head).toEqual(messages);
239
+ expect(result.tail).toEqual([]);
240
+ expect(result.tailTurnCount).toBe(0);
241
+ });
242
+
243
+ it('handles a HumanMessage at index 0 with prior non-human messages absent', () => {
244
+ const messages = [new HumanMessage('only')];
245
+ const result = splitAtRecencyBoundary(messages, { turns: 3 });
246
+ expect(result.head).toEqual([]);
247
+ expect(result.tail).toEqual(messages);
248
+ });
249
+
250
+ it('handles tool messages as the very last messages', () => {
251
+ const messages = [
252
+ new HumanMessage('q1'),
253
+ new AIMessage('a1'),
254
+ new HumanMessage('q2'),
255
+ new AIMessage({
256
+ content: '',
257
+ tool_calls: [{ id: 'c1', name: 't', args: {} }],
258
+ }),
259
+ new ToolMessage({ content: 'r', tool_call_id: 'c1', name: 't' }),
260
+ ];
261
+ const result = splitAtRecencyBoundary(messages, { turns: 1 });
262
+ // Most recent turn includes the trailing tool result.
263
+ expect(result.tail).toEqual(messages.slice(2));
264
+ expect(result.head).toEqual(messages.slice(0, 2));
265
+ });
266
+ });
267
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Anthropic prompt-caching helper for the `tools[]` request field.
3
+ *
4
+ * Anthropic accepts `cache_control: { type: 'ephemeral' }` on individual
5
+ * tool definitions. Whichever tool carries the marker becomes the end of
6
+ * a cached prefix: `tools[0..N]` (everything up to and including the
7
+ * marked tool) is cached and rebated on subsequent matching requests.
8
+ *
9
+ * For agents that mix static and deferred (lazily-discovered) tools, the
10
+ * winning configuration is:
11
+ *
12
+ * 1. Stable-partition tools so all *static* (non-deferred) tools come
13
+ * first and discovered-deferred tools come last.
14
+ * 2. Stamp `cache_control` on the LAST static tool.
15
+ *
16
+ * That way, the cached prefix covers exactly the static tool inventory.
17
+ * Discovered tools that show up later (or vary turn-to-turn as new ones
18
+ * get discovered) never invalidate the prefix because they sit *after*
19
+ * the breakpoint.
20
+ *
21
+ * LangChain's Anthropic adapter passes the marker through via
22
+ * `tool.extras.cache_control` (`AnthropicToolExtrasSchema`), so we set
23
+ * it as an `extras` field on a fresh wrapper around the tool — never
24
+ * mutating the original tool instance, since callers may share them
25
+ * across runs.
26
+ */
27
+
28
+ import type { GraphTools } from '@/types';
29
+
30
+ /**
31
+ * Returns a callable that reports whether a given tool *name* is deferred
32
+ * according to the supplied registry of tool definitions. Tools without
33
+ * a registry entry are treated as non-deferred (i.e. static), matching
34
+ * the host-supplied `graphTools` semantics elsewhere in the SDK.
35
+ */
36
+ export function makeIsDeferred(
37
+ toolDefinitions:
38
+ | ReadonlyArray<{ name: string; defer_loading?: boolean }>
39
+ | undefined
40
+ ): (toolName: string) => boolean {
41
+ if (toolDefinitions == null || toolDefinitions.length === 0) {
42
+ return () => false;
43
+ }
44
+ const deferred = new Set<string>();
45
+ for (const def of toolDefinitions) {
46
+ if (def.defer_loading === true) deferred.add(def.name);
47
+ }
48
+ if (deferred.size === 0) return () => false;
49
+ return (name) => deferred.has(name);
50
+ }
51
+
52
+ /**
53
+ * Stable-partition `tools` into [static..., deferred...] and stamp the
54
+ * Anthropic `cache_control: ephemeral` marker on the last static tool.
55
+ *
56
+ * If `tools` is undefined or empty, or no entry has a usable `.name`,
57
+ * returns the input unchanged. If there are no static tools at all,
58
+ * also returns unchanged (nothing to cache).
59
+ *
60
+ * The original tool instances are never mutated. The marked entry is a
61
+ * shallow wrapper that preserves the prototype chain so downstream
62
+ * `instanceof` checks still pass. `extras` is merged so any existing
63
+ * `providerToolDefinition` / other extras the host attached are kept.
64
+ */
65
+ export function partitionAndMarkAnthropicToolCache(
66
+ tools: GraphTools | undefined,
67
+ isDeferred: (toolName: string) => boolean
68
+ ): GraphTools | undefined {
69
+ if (tools == null || tools.length === 0) return tools;
70
+
71
+ // Use unknown[] internally to avoid GraphTools' union-array variance
72
+ // (each member is one of three array types). We cast back to
73
+ // GraphTools when returning.
74
+ const staticTools: unknown[] = [];
75
+ const deferredTools: unknown[] = [];
76
+
77
+ for (const tool of tools) {
78
+ const name = (tool as { name?: unknown }).name;
79
+ if (typeof name === 'string' && isDeferred(name)) {
80
+ deferredTools.push(tool);
81
+ } else {
82
+ staticTools.push(tool);
83
+ }
84
+ }
85
+
86
+ if (staticTools.length === 0) {
87
+ return tools;
88
+ }
89
+
90
+ const last = staticTools[staticTools.length - 1] as {
91
+ extras?: Record<string, unknown>;
92
+ };
93
+ // Already marked? Don't double-clone.
94
+ if (
95
+ last.extras != null &&
96
+ 'cache_control' in last.extras &&
97
+ (last.extras as { cache_control?: unknown }).cache_control != null
98
+ ) {
99
+ if (deferredTools.length === 0) return tools;
100
+ return [...staticTools, ...deferredTools] as GraphTools;
101
+ }
102
+
103
+ const wrapped = Object.assign(
104
+ Object.create(Object.getPrototypeOf(last) ?? Object.prototype),
105
+ last,
106
+ {
107
+ extras: {
108
+ ...((last.extras as Record<string, unknown> | undefined) ?? {}),
109
+ cache_control: { type: 'ephemeral' as const },
110
+ },
111
+ }
112
+ );
113
+
114
+ staticTools[staticTools.length - 1] = wrapped;
115
+ return [...staticTools, ...deferredTools] as GraphTools;
116
+ }
@@ -3,8 +3,10 @@ export * from './ids';
3
3
  export * from './prune';
4
4
  export * from './format';
5
5
  export * from './cache';
6
+ export * from './anthropicToolCache';
6
7
  export * from './content';
7
8
  export * from './tools';
8
9
  export * from './contextPruning';
9
10
  export * from './contextPruningSettings';
10
11
  export * from './reducer';
12
+ export * from './recency';
@@ -50,7 +50,33 @@ const PRESSURE_BANDS: [number, number][] = [
50
50
  const MASKED_RESULT_MAX_CHARS = 300;
51
51
 
52
52
  /** Hard cap for the originalToolContent store (~2 MB estimated from char length). */
53
- const ORIGINAL_CONTENT_MAX_CHARS = 2_000_000;
53
+ export const ORIGINAL_CONTENT_MAX_CHARS = 2_000_000;
54
+
55
+ /**
56
+ * Evicts oldest entries from `map` (in Map-iteration / insertion order) until
57
+ * the cumulative char length of remaining values fits within
58
+ * `ORIGINAL_CONTENT_MAX_CHARS`. Used by the recency-window carry-over merge
59
+ * path in Graph.ts to bound long-running session memory: the pruner enforces
60
+ * the cap inside its own `originalToolContent` map, but a key-wise union with
61
+ * recency carry-over bypasses that cap unless re-applied here.
62
+ */
63
+ export function enforceOriginalContentCap(map: Map<number, string>): void {
64
+ let total = 0;
65
+ for (const v of map.values()) {
66
+ total += v.length;
67
+ }
68
+ while (total > ORIGINAL_CONTENT_MAX_CHARS && map.size > 0) {
69
+ const oldest = map.keys().next();
70
+ if (oldest.done === true) {
71
+ break;
72
+ }
73
+ const removed = map.get(oldest.value);
74
+ if (removed != null) {
75
+ total -= removed.length;
76
+ }
77
+ map.delete(oldest.value);
78
+ }
79
+ }
54
80
 
55
81
  /** Minimum cumulative calibration ratio — provider can't count fewer tokens
56
82
  * than our raw estimate (within reason). Prevents divide-by-zero edge cases. */
@@ -0,0 +1,155 @@
1
+ import type { BaseMessage } from '@langchain/core/messages';
2
+
3
+ /**
4
+ * Configuration for splitting a message list into a head (to be summarized)
5
+ * and a tail (to be preserved verbatim).
6
+ */
7
+ export interface RecencyWindowOptions {
8
+ /**
9
+ * Maximum number of recent user-led turns to keep in the tail. A "turn"
10
+ * begins at a HumanMessage and includes every following AIMessage and
11
+ * ToolMessage up to (but not including) the next HumanMessage. Cutting
12
+ * at turn boundaries guarantees that tool_use / tool_result pairs are
13
+ * never split across the head/tail divide.
14
+ *
15
+ * The most recent turn is always preserved regardless of this value or
16
+ * the token cap, so that a single oversized first message is never
17
+ * destroyed by summarization.
18
+ *
19
+ * Defaults to `2`. A value of `0` disables the recency window (head =
20
+ * everything, tail = empty), restoring the pre-recency-window behavior.
21
+ */
22
+ turns?: number;
23
+ /**
24
+ * Optional cap on tail size in tokens. When set, additional turns
25
+ * beyond the most recent one are added to the tail only while the
26
+ * cumulative token count stays at or below this cap. Turns are added
27
+ * whole — never partially — so a turn that would exceed the cap is
28
+ * left in the head.
29
+ *
30
+ * The most recent turn is always preserved even if it exceeds the cap.
31
+ */
32
+ tokens?: number;
33
+ /** Token-counter used to evaluate the optional `tokens` cap. */
34
+ tokenCounter?: (m: BaseMessage) => number;
35
+ }
36
+
37
+ export interface RecencySplit {
38
+ /** Older messages eligible for summarization. Empty when nothing to summarize. */
39
+ head: BaseMessage[];
40
+ /** Recent messages preserved verbatim. Always contains the most recent turn when any HumanMessage exists. */
41
+ tail: BaseMessage[];
42
+ /** Number of user-led turns retained in the tail (0 if no HumanMessage exists). */
43
+ tailTurnCount: number;
44
+ /** Index in the original `messages` array where the tail begins. */
45
+ tailStartIndex: number;
46
+ }
47
+
48
+ /**
49
+ * Splits `messages` into a head (older, to summarize) and a tail (recent,
50
+ * to preserve verbatim) at user-message boundaries. The most recent
51
+ * user-led turn is always included in the tail; additional older turns
52
+ * are added subject to `turns` and `tokens` caps.
53
+ *
54
+ * Cutting strictly at HumanMessage boundaries ensures that:
55
+ * - tool_use ↔ tool_result pairs are never split (they always live within
56
+ * the same turn);
57
+ * - the first user message is never replaced by a summary, addressing
58
+ * the "first turn destruction" failure mode where a single large
59
+ * user-pasted payload would otherwise be replaced by a generic summary.
60
+ *
61
+ * When `messages` contains no HumanMessage (degenerate state — e.g. system
62
+ * + assistant messages from a programmatic preamble), everything is
63
+ * placed in the head and the tail is empty. The summarize node treats
64
+ * an empty tail as "nothing recent to preserve" and falls through to its
65
+ * existing logic.
66
+ */
67
+ export function splitAtRecencyBoundary(
68
+ messages: BaseMessage[],
69
+ options: RecencyWindowOptions = {}
70
+ ): RecencySplit {
71
+ const turnsCap = options.turns ?? 2;
72
+
73
+ if (messages.length === 0 || turnsCap <= 0) {
74
+ return {
75
+ head: messages,
76
+ tail: [],
77
+ tailTurnCount: 0,
78
+ tailStartIndex: messages.length,
79
+ };
80
+ }
81
+
82
+ const turnStarts: number[] = [];
83
+ for (let i = 0; i < messages.length; i++) {
84
+ if (messages[i].getType() === 'human') {
85
+ turnStarts.push(i);
86
+ }
87
+ }
88
+
89
+ if (turnStarts.length === 0) {
90
+ return {
91
+ head: messages,
92
+ tail: [],
93
+ tailTurnCount: 0,
94
+ tailStartIndex: messages.length,
95
+ };
96
+ }
97
+
98
+ const lastTurnStart = turnStarts[turnStarts.length - 1] as number;
99
+ let tailStartIndex = lastTurnStart;
100
+ let tailTurnCount = 1;
101
+
102
+ const tokensCap = options.tokens;
103
+ const tokenCounter = options.tokenCounter;
104
+ const trackTokens =
105
+ tokensCap != null && Number.isFinite(tokensCap) && tokenCounter != null;
106
+
107
+ /**
108
+ * Token-counting strategy: each candidate turn `t` spans the half-open
109
+ * range `[turnStarts[t], turnStarts[t + 1])` (or `[turnStarts[t], messages.length)`
110
+ * for the most recent turn). Successive iterations of the outer loop
111
+ * walk older turns one at a time and never revisit messages from a
112
+ * later turn — so each message contributes to `tokenCounter` at most
113
+ * once across the entire selection, making the boundary search
114
+ * `O(messages_in_visited_turns)` and bounded by `O(messages.length)`
115
+ * even before the `turnsCap` short-circuit applies. The inner upper
116
+ * bound uses `turnStarts[t + 1]` (a value derived from immutable
117
+ * `turnStarts`) rather than the mutated `tailStartIndex` to make the
118
+ * disjoint-range invariant self-evident.
119
+ */
120
+ let tailTokens = 0;
121
+ if (trackTokens) {
122
+ for (let i = lastTurnStart; i < messages.length; i++) {
123
+ tailTokens += tokenCounter(messages[i] as BaseMessage);
124
+ }
125
+ }
126
+
127
+ for (let t = turnStarts.length - 2; t >= 0; t--) {
128
+ if (tailTurnCount >= turnsCap) {
129
+ break;
130
+ }
131
+ const turnStart = turnStarts[t] as number;
132
+ const turnEnd = turnStarts[t + 1] as number;
133
+
134
+ if (trackTokens) {
135
+ let turnTokens = 0;
136
+ for (let i = turnStart; i < turnEnd; i++) {
137
+ turnTokens += tokenCounter(messages[i] as BaseMessage);
138
+ }
139
+ if (tailTokens + turnTokens > (tokensCap as number)) {
140
+ break;
141
+ }
142
+ tailTokens += turnTokens;
143
+ }
144
+
145
+ tailStartIndex = turnStart;
146
+ tailTurnCount += 1;
147
+ }
148
+
149
+ return {
150
+ head: messages.slice(0, tailStartIndex),
151
+ tail: messages.slice(tailStartIndex),
152
+ tailTurnCount,
153
+ tailStartIndex,
154
+ };
155
+ }
package/src/run.ts CHANGED
@@ -54,6 +54,7 @@ export class Run<_T extends t.BaseGraphState> {
54
54
  private hookRegistry?: HookRegistry;
55
55
  private humanInTheLoop?: t.HumanInTheLoopConfig;
56
56
  private toolOutputReferences?: t.ToolOutputReferencesConfig;
57
+ private toolExecution?: t.ToolExecutionConfig;
57
58
  private indexTokenCountMap?: Record<string, number>;
58
59
  calibrationRatio: number = 1;
59
60
  graphRunnable?: t.CompiledStateWorkflow;
@@ -98,6 +99,7 @@ export class Run<_T extends t.BaseGraphState> {
98
99
  this.hookRegistry = config.hooks;
99
100
  this.humanInTheLoop = config.humanInTheLoop;
100
101
  this.toolOutputReferences = config.toolOutputReferences;
102
+ this.toolExecution = config.toolExecution;
101
103
 
102
104
  if (!config.graphConfig) {
103
105
  throw new Error('Graph config not provided');
@@ -178,6 +180,7 @@ export class Run<_T extends t.BaseGraphState> {
178
180
  standardGraph.hookRegistry = this.hookRegistry;
179
181
  standardGraph.humanInTheLoop = this.humanInTheLoop;
180
182
  standardGraph.toolOutputReferences = this.toolOutputReferences;
183
+ standardGraph.toolExecution = this.toolExecution;
181
184
  this.Graph = standardGraph;
182
185
  return standardGraph.createWorkflow();
183
186
  }
@@ -202,6 +205,7 @@ export class Run<_T extends t.BaseGraphState> {
202
205
  multiAgentGraph.hookRegistry = this.hookRegistry;
203
206
  multiAgentGraph.humanInTheLoop = this.humanInTheLoop;
204
207
  multiAgentGraph.toolOutputReferences = this.toolOutputReferences;
208
+ multiAgentGraph.toolExecution = this.toolExecution;
205
209
  this.Graph = multiAgentGraph;
206
210
  return multiAgentGraph.createWorkflow();
207
211
  }
@@ -898,6 +902,33 @@ export class Run<_T extends t.BaseGraphState> {
898
902
  * graph state from the checkpoint and re-enters the interrupted node
899
903
  * from the start.
900
904
  */
905
+ /**
906
+ * Returns the per-Run file checkpointer when
907
+ * `toolExecution.local.fileCheckpointing === true` was set on the
908
+ * RunConfig. Hosts can capture extra paths or call `rewind()`
909
+ * directly. Returns undefined when checkpointing is disabled.
910
+ *
911
+ * Construction-time invariant: the checkpointer is shared across
912
+ * every ToolNode the graph compiles (single-agent and multi-agent),
913
+ * so a `rewind()` call here unwinds writes made by ANY agent in the
914
+ * run.
915
+ */
916
+ getFileCheckpointer(): t.LocalFileCheckpointer | undefined {
917
+ return this.Graph?.getOrCreateFileCheckpointer();
918
+ }
919
+
920
+ /**
921
+ * Convenience wrapper that calls `rewind()` on the per-Run file
922
+ * checkpointer. Restores every file the local engine snapshotted
923
+ * during this Run to its pre-write content (and deletes any path
924
+ * that didn't exist before being created). Returns the count of
925
+ * paths processed; returns 0 when checkpointing is disabled.
926
+ */
927
+ async rewindFiles(): Promise<number> {
928
+ const cp = this.getFileCheckpointer();
929
+ return cp == null ? 0 : cp.rewind();
930
+ }
931
+
901
932
  async resume<TResume = t.ToolApprovalDecision[] | t.ToolApprovalDecisionMap>(
902
933
  resumeValue: TResume,
903
934
  callerConfig: Partial<RunnableConfig> & {