@librechat/agents 3.3.3 → 3.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +2 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +342 -109
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  7. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
  8. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +37 -10
  10. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/google/utils/common.cjs +19 -7
  12. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  13. package/dist/cjs/llm/invoke.cjs +64 -3
  14. package/dist/cjs/llm/invoke.cjs.map +1 -1
  15. package/dist/cjs/llm/openai/index.cjs +41 -5
  16. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  17. package/dist/cjs/llm/openai/utils/index.cjs +25 -4
  18. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  19. package/dist/cjs/llm/openrouter/index.cjs +4 -1
  20. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  21. package/dist/cjs/main.cjs +19 -2
  22. package/dist/cjs/messages/cache.cjs +54 -33
  23. package/dist/cjs/messages/cache.cjs.map +1 -1
  24. package/dist/cjs/messages/contextPruning.cjs +17 -43
  25. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  26. package/dist/cjs/messages/core.cjs +315 -23
  27. package/dist/cjs/messages/core.cjs.map +1 -1
  28. package/dist/cjs/messages/format.cjs +218 -60
  29. package/dist/cjs/messages/format.cjs.map +1 -1
  30. package/dist/cjs/messages/index.cjs +1 -1
  31. package/dist/cjs/messages/prune.cjs +503 -127
  32. package/dist/cjs/messages/prune.cjs.map +1 -1
  33. package/dist/cjs/stream.cjs +7 -3
  34. package/dist/cjs/stream.cjs.map +1 -1
  35. package/dist/cjs/summarization/node.cjs +28 -14
  36. package/dist/cjs/summarization/node.cjs.map +1 -1
  37. package/dist/cjs/tools/ToolNode.cjs +54 -41
  38. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  39. package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
  40. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  41. package/dist/cjs/utils/index.cjs +1 -1
  42. package/dist/cjs/utils/llm.cjs +1 -1
  43. package/dist/cjs/utils/llm.cjs.map +1 -1
  44. package/dist/cjs/utils/tokens.cjs +307 -20
  45. package/dist/cjs/utils/tokens.cjs.map +1 -1
  46. package/dist/cjs/utils/toolContent.cjs +1514 -0
  47. package/dist/cjs/utils/toolContent.cjs.map +1 -0
  48. package/dist/esm/agents/AgentContext.mjs +2 -1
  49. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  50. package/dist/esm/graphs/Graph.mjs +349 -116
  51. package/dist/esm/graphs/Graph.mjs.map +1 -1
  52. package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
  53. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  54. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
  55. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  56. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +37 -10
  57. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  58. package/dist/esm/llm/google/utils/common.mjs +19 -7
  59. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  60. package/dist/esm/llm/invoke.mjs +65 -5
  61. package/dist/esm/llm/invoke.mjs.map +1 -1
  62. package/dist/esm/llm/openai/index.mjs +41 -5
  63. package/dist/esm/llm/openai/index.mjs.map +1 -1
  64. package/dist/esm/llm/openai/utils/index.mjs +25 -4
  65. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  66. package/dist/esm/llm/openrouter/index.mjs +4 -1
  67. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  68. package/dist/esm/main.mjs +7 -7
  69. package/dist/esm/messages/cache.mjs +55 -34
  70. package/dist/esm/messages/cache.mjs.map +1 -1
  71. package/dist/esm/messages/contextPruning.mjs +17 -43
  72. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  73. package/dist/esm/messages/core.mjs +305 -24
  74. package/dist/esm/messages/core.mjs.map +1 -1
  75. package/dist/esm/messages/format.mjs +218 -61
  76. package/dist/esm/messages/format.mjs.map +1 -1
  77. package/dist/esm/messages/index.mjs +1 -1
  78. package/dist/esm/messages/prune.mjs +500 -128
  79. package/dist/esm/messages/prune.mjs.map +1 -1
  80. package/dist/esm/stream.mjs +7 -3
  81. package/dist/esm/stream.mjs.map +1 -1
  82. package/dist/esm/summarization/node.mjs +28 -14
  83. package/dist/esm/summarization/node.mjs.map +1 -1
  84. package/dist/esm/tools/ToolNode.mjs +54 -41
  85. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  86. package/dist/esm/tools/toolOutputReferences.mjs +6 -0
  87. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  88. package/dist/esm/utils/index.mjs +1 -1
  89. package/dist/esm/utils/llm.mjs +1 -1
  90. package/dist/esm/utils/llm.mjs.map +1 -1
  91. package/dist/esm/utils/tokens.mjs +307 -21
  92. package/dist/esm/utils/tokens.mjs.map +1 -1
  93. package/dist/esm/utils/toolContent.mjs +1503 -0
  94. package/dist/esm/utils/toolContent.mjs.map +1 -0
  95. package/dist/types/graphs/Graph.d.ts +1 -1
  96. package/dist/types/llm/invoke.d.ts +27 -1
  97. package/dist/types/llm/openai/index.d.ts +4 -0
  98. package/dist/types/llm/openai/utils/index.d.ts +2 -0
  99. package/dist/types/llm/openrouter/index.d.ts +5 -0
  100. package/dist/types/messages/contextPruning.d.ts +2 -1
  101. package/dist/types/messages/core.d.ts +36 -0
  102. package/dist/types/messages/format.d.ts +6 -0
  103. package/dist/types/messages/prune.d.ts +16 -12
  104. package/dist/types/types/stream.d.ts +1 -1
  105. package/dist/types/utils/tokens.d.ts +7 -0
  106. package/dist/types/utils/toolContent.d.ts +107 -0
  107. package/package.json +1 -1
  108. package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
  109. package/src/agents/AgentContext.ts +1 -0
  110. package/src/graphs/Graph.ts +698 -200
  111. package/src/graphs/MultiAgentGraph.ts +6 -1
  112. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1128 -4
  113. package/src/llm/anthropic/utils/message_inputs.ts +158 -12
  114. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
  115. package/src/llm/bedrock/utils/message_inputs.test.ts +49 -1
  116. package/src/llm/bedrock/utils/message_inputs.ts +86 -13
  117. package/src/llm/google/utils/common.test.ts +75 -9
  118. package/src/llm/google/utils/common.ts +34 -3
  119. package/src/llm/invoke.test.ts +488 -0
  120. package/src/llm/invoke.ts +196 -2
  121. package/src/llm/openai/index.ts +106 -6
  122. package/src/llm/openai/utils/index.ts +109 -53
  123. package/src/llm/openai/utils/messages.test.ts +330 -1
  124. package/src/llm/openrouter/index.ts +12 -2
  125. package/src/llm/openrouter/reasoning.test.ts +314 -0
  126. package/src/messages/__tests__/observationMasking.test.ts +93 -2
  127. package/src/messages/cache.tail.test.ts +193 -0
  128. package/src/messages/cache.test.ts +113 -0
  129. package/src/messages/cache.ts +92 -60
  130. package/src/messages/contextPruning.test.ts +184 -0
  131. package/src/messages/contextPruning.ts +49 -42
  132. package/src/messages/core.ts +653 -40
  133. package/src/messages/ensureThinkingBlock.test.ts +49 -1
  134. package/src/messages/foldToollessToolBlocks.test.ts +163 -5
  135. package/src/messages/format.ts +363 -91
  136. package/src/messages/formatAgentMessages.test.ts +604 -14
  137. package/src/messages/formatAgentMessages.tools.test.ts +103 -0
  138. package/src/messages/prune.ts +996 -183
  139. package/src/specs/prune.test.ts +1083 -6
  140. package/src/specs/summarization-unit.test.ts +105 -0
  141. package/src/specs/tokens.test.ts +609 -32
  142. package/src/stream.ts +23 -15
  143. package/src/summarization/__tests__/node.test.ts +77 -0
  144. package/src/summarization/node.ts +46 -18
  145. package/src/tools/ToolNode.ts +147 -68
  146. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
  147. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
  148. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
  149. package/src/tools/__tests__/directToolHooks.test.ts +255 -7
  150. package/src/tools/toolOutputReferences.ts +6 -0
  151. package/src/types/stream.ts +1 -1
  152. package/src/utils/llm.test.ts +18 -0
  153. package/src/utils/llm.ts +4 -1
  154. package/src/utils/tokens.ts +586 -31
  155. package/src/utils/toolContent.test.ts +1432 -0
  156. package/src/utils/toolContent.ts +2707 -0
@@ -1,15 +1,19 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '@langchain/core/tools';
1
3
  import { MemorySaver } from '@langchain/langgraph';
2
- import { describe, expect, it } from '@jest/globals';
3
4
  import { Runnable } from '@langchain/core/runnables';
5
+ import { describe, expect, it, jest } from '@jest/globals';
4
6
  import {
5
7
  AIMessageChunk,
6
8
  HumanMessage,
7
9
  AIMessage,
10
+ ToolMessage,
8
11
  } from '@langchain/core/messages';
9
12
  import type { BaseMessage } from '@langchain/core/messages';
10
13
  import type * as t from '@/types';
11
14
  import { OVERFLOW_SIGNATURES } from '@/utils/__tests__/fixtures/contextOverflowSignatures';
12
- import { GraphEvents, Providers } from '@/common';
15
+ import { ContentTypes, GraphEvents, Providers } from '@/common';
16
+ import * as init from '@/llm/init';
13
17
  import { Run } from '@/run';
14
18
 
15
19
  /**
@@ -64,6 +68,36 @@ class OverflowThenSucceedModel extends Runnable<BaseMessage[], AIMessageChunk> {
64
68
  }
65
69
  }
66
70
 
71
+ class SizeBoundModel extends Runnable<BaseMessage[], AIMessageChunk> {
72
+ lc_namespace = ['tests'];
73
+ readonly toolContentChars: number[] = [];
74
+
75
+ constructor(
76
+ private readonly maxToolContentChars: number,
77
+ private readonly error: Record<string, unknown>
78
+ ) {
79
+ super();
80
+ }
81
+
82
+ async invoke(messages: BaseMessage[]): Promise<AIMessageChunk> {
83
+ let toolContentChars = 0;
84
+ for (const message of messages) {
85
+ if (message.getType() !== 'tool') {
86
+ continue;
87
+ }
88
+ toolContentChars +=
89
+ typeof message.content === 'string'
90
+ ? message.content.length
91
+ : JSON.stringify(message.content).length;
92
+ }
93
+ this.toolContentChars.push(toolContentChars);
94
+ if (toolContentChars > this.maxToolContentChars) {
95
+ throw throwable(this.error);
96
+ }
97
+ return new AIMessageChunk({ content: 'recovered' });
98
+ }
99
+ }
100
+
67
101
  function buildConversation(turns: number): BaseMessage[] {
68
102
  const messages: BaseMessage[] = [];
69
103
  for (let i = 0; i < turns; i++) {
@@ -78,17 +112,31 @@ async function createRun(options: {
78
112
  runId: string;
79
113
  maxContextTokens: number;
80
114
  checkpointer?: boolean;
115
+ provider?: Providers;
116
+ tokenCounter?: t.TokenCounter;
117
+ indexTokenCountMap?: Record<string, number>;
118
+ tools?: t.GraphTools;
119
+ maxToolResultChars?: number;
120
+ model?: string;
121
+ promptCache?: boolean;
122
+ toolOutputReferences?: t.ToolOutputReferencesConfig;
123
+ fallbacks?: t.FallbackConfig[];
81
124
  }): Promise<Run<t.IState>> {
82
125
  return Run.create<t.IState>({
83
126
  runId: options.runId,
84
127
  graphConfig: {
85
128
  type: 'standard',
86
129
  llmConfig: {
87
- provider: Providers.ANTHROPIC,
130
+ provider: options.provider ?? Providers.ANTHROPIC,
131
+ ...(options.model != null ? { model: options.model } : {}),
132
+ ...(options.promptCache === true ? { promptCache: true } : {}),
133
+ ...(options.fallbacks != null ? { fallbacks: options.fallbacks } : {}),
88
134
  disableStreaming: true,
89
135
  streamUsage: false,
90
136
  },
91
137
  maxContextTokens: options.maxContextTokens,
138
+ maxToolResultChars: options.maxToolResultChars,
139
+ tools: options.tools,
92
140
  compileOptions:
93
141
  options.checkpointer === true
94
142
  ? { checkpointer: new MemorySaver() }
@@ -96,7 +144,9 @@ async function createRun(options: {
96
144
  },
97
145
  returnContent: true,
98
146
  skipCleanup: true,
99
- tokenCounter,
147
+ tokenCounter: options.tokenCounter ?? tokenCounter,
148
+ indexTokenCountMap: options.indexTokenCountMap,
149
+ toolOutputReferences: options.toolOutputReferences,
100
150
  });
101
151
  }
102
152
 
@@ -107,6 +157,1080 @@ const streamConfig = {
107
157
  };
108
158
 
109
159
  describe('context overflow recovery', () => {
160
+ it('projects structured OpenAI tool content before the final payload check', async () => {
161
+ const toolCallId = 'tc-openai-structured';
162
+ const toolMessage = new ToolMessage({
163
+ content: [
164
+ { type: ContentTypes.TEXT, text: 'rendered chart' },
165
+ {
166
+ type: 'image_url',
167
+ image_url: {
168
+ url: `data:image/png;base64,${'A'.repeat(2_000)}`,
169
+ },
170
+ },
171
+ ],
172
+ tool_call_id: toolCallId,
173
+ name: 'render_chart',
174
+ });
175
+ const messages: BaseMessage[] = [
176
+ new HumanMessage('render the chart'),
177
+ new AIMessage({
178
+ content: '',
179
+ tool_calls: [
180
+ {
181
+ id: toolCallId,
182
+ name: 'render_chart',
183
+ args: {},
184
+ type: 'tool_call',
185
+ },
186
+ ],
187
+ }),
188
+ toolMessage,
189
+ ];
190
+ const measuredToolContents: string[] = [];
191
+ const projectionCounter: t.TokenCounter = (message) => {
192
+ if (message.getType() === 'tool' && typeof message.content === 'string') {
193
+ measuredToolContents.push(message.content);
194
+ }
195
+ return typeof message.content === 'string'
196
+ ? Math.max(1, Math.ceil(message.content.length / 4))
197
+ : 1;
198
+ };
199
+ const run = await createRun({
200
+ runId: 'openai-structured-final-projection',
201
+ maxContextTokens: 10_000,
202
+ maxToolResultChars: 200,
203
+ provider: Providers.OPENAI,
204
+ tokenCounter: projectionCounter,
205
+ indexTokenCountMap: {
206
+ 0: projectionCounter(messages[0]),
207
+ 1: projectionCounter(messages[1]),
208
+ 2: projectionCounter(messages[2]),
209
+ },
210
+ tools: [
211
+ tool(async () => 'unused', {
212
+ name: 'render_chart',
213
+ description: 'Renders a chart',
214
+ schema: z.object({}),
215
+ }),
216
+ ],
217
+ });
218
+ if (!run.Graph) {
219
+ throw new Error('Expected graph to be initialized');
220
+ }
221
+ const model = new OverflowThenSucceedModel(signatureFor('gpt-4o-mini'), 0);
222
+ run.Graph.overrideModel = model;
223
+
224
+ await run.processStream({ messages }, streamConfig);
225
+
226
+ expect(model.calls).toHaveLength(1);
227
+ const projectedTool = model.calls[0].find(
228
+ (message) => message.getType() === 'tool'
229
+ ) as ToolMessage | undefined;
230
+ expect(typeof projectedTool?.content).toBe('string');
231
+ expect((projectedTool?.content as string).length).toBeLessThanOrEqual(200);
232
+ expect(measuredToolContents).toContain(projectedTool?.content);
233
+ expect(Array.isArray(toolMessage.content)).toBe(true);
234
+ });
235
+
236
+ it('measures the bounded generic-provider tool payload that is invoked', async () => {
237
+ const toolCallId = 'tc-google-structured';
238
+ const structuredContent = Array.from({ length: 25_000 }, () => ({
239
+ type: ContentTypes.TEXT,
240
+ text: 'x',
241
+ }));
242
+ const toolMessage = new ToolMessage({
243
+ content: structuredContent,
244
+ tool_call_id: toolCallId,
245
+ name: 'dense_result',
246
+ });
247
+ const messages: BaseMessage[] = [
248
+ new HumanMessage('return the dense result'),
249
+ new AIMessage({
250
+ content: '',
251
+ tool_calls: [
252
+ {
253
+ id: toolCallId,
254
+ name: 'dense_result',
255
+ args: {},
256
+ type: 'tool_call',
257
+ },
258
+ ],
259
+ }),
260
+ toolMessage,
261
+ ];
262
+ const measuredToolContents: string[] = [];
263
+ const projectionCounter: t.TokenCounter = (message) => {
264
+ if (message.getType() === 'tool' && typeof message.content === 'string') {
265
+ measuredToolContents.push(message.content);
266
+ }
267
+ return typeof message.content === 'string'
268
+ ? Math.max(1, Math.ceil(message.content.length / 4))
269
+ : 1;
270
+ };
271
+ const run = await createRun({
272
+ runId: 'google-structured-final-projection',
273
+ maxContextTokens: 10_000,
274
+ maxToolResultChars: 2_000,
275
+ provider: Providers.GOOGLE,
276
+ tokenCounter: projectionCounter,
277
+ indexTokenCountMap: {
278
+ 0: projectionCounter(messages[0]),
279
+ 1: projectionCounter(messages[1]),
280
+ 2: projectionCounter(messages[2]),
281
+ },
282
+ tools: [
283
+ tool(async () => 'unused', {
284
+ name: 'dense_result',
285
+ description: 'Returns a dense structured result',
286
+ schema: z.object({}),
287
+ }),
288
+ ],
289
+ });
290
+ if (!run.Graph) {
291
+ throw new Error('Expected graph to be initialized');
292
+ }
293
+ const model = new OverflowThenSucceedModel(signatureFor('gpt-4o-mini'), 0);
294
+ run.Graph.overrideModel = model;
295
+
296
+ await run.processStream({ messages }, streamConfig);
297
+
298
+ expect(model.calls).toHaveLength(1);
299
+ const projectedTool = model.calls[0].find(
300
+ (message) => message.getType() === 'tool'
301
+ ) as ToolMessage | undefined;
302
+ expect(typeof projectedTool?.content).toBe('string');
303
+ expect((projectedTool?.content as string).length).toBeLessThanOrEqual(
304
+ 2_000
305
+ );
306
+ expect(measuredToolContents).toContain(projectedTool?.content);
307
+ expect(toolMessage.content).toBe(structuredContent);
308
+ });
309
+
310
+ it('guards a fallback-specific projection before invoking the fallback', async () => {
311
+ const toolCallId = 'tc-fallback-structured';
312
+ const structuredContent = Array.from({ length: 25_000 }, () => ({
313
+ type: ContentTypes.TEXT,
314
+ text: '',
315
+ }));
316
+ const messages: BaseMessage[] = [
317
+ new HumanMessage('return the dense fallback result'),
318
+ new AIMessage({
319
+ content: '',
320
+ tool_calls: [
321
+ {
322
+ id: toolCallId,
323
+ name: 'dense_result',
324
+ args: {},
325
+ type: 'tool_call',
326
+ },
327
+ ],
328
+ }),
329
+ new ToolMessage({
330
+ content: structuredContent,
331
+ tool_call_id: toolCallId,
332
+ name: 'dense_result',
333
+ }),
334
+ ];
335
+ const projectionCounter: t.TokenCounter = (message) =>
336
+ typeof message.content === 'string'
337
+ ? Math.max(1, message.content.length)
338
+ : 1;
339
+ const run = await createRun({
340
+ runId: 'fallback-structured-final-projection',
341
+ maxContextTokens: 1_000_000,
342
+ provider: Providers.ANTHROPIC,
343
+ tokenCounter: projectionCounter,
344
+ indexTokenCountMap: {
345
+ 0: projectionCounter(messages[0]),
346
+ 1: projectionCounter(messages[1]),
347
+ 2: projectionCounter(messages[2]),
348
+ },
349
+ fallbacks: [
350
+ {
351
+ provider: Providers.GOOGLE,
352
+ maxContextTokens: 5_000,
353
+ },
354
+ ],
355
+ tools: [
356
+ tool(async () => 'unused', {
357
+ name: 'dense_result',
358
+ description: 'Returns a dense structured result',
359
+ schema: z.object({}),
360
+ }),
361
+ ],
362
+ });
363
+ if (!run.Graph) {
364
+ throw new Error('Expected graph to be initialized');
365
+ }
366
+ const primary = new OverflowThenSucceedModel(
367
+ { message: '503 primary unavailable' },
368
+ 1
369
+ );
370
+ let fallbackInvocations = 0;
371
+ const fallback = {
372
+ invoke: async (): Promise<AIMessageChunk> => {
373
+ fallbackInvocations++;
374
+ return new AIMessageChunk({ content: 'fallback should not run' });
375
+ },
376
+ } as unknown as ReturnType<typeof init.initializeModel>;
377
+ const initializeSpy = jest
378
+ .spyOn(init, 'initializeModel')
379
+ .mockReturnValue(fallback);
380
+ run.Graph.overrideModel = primary;
381
+
382
+ try {
383
+ const content = await run.processStream({ messages }, streamConfig);
384
+
385
+ expect(content).toEqual([{ type: 'text', text: 'recovered' }]);
386
+ expect(primary.calls).toHaveLength(2);
387
+ expect(fallbackInvocations).toBe(0);
388
+ expect(
389
+ run.Graph.agentContexts.get('default')?.overflowRecoveryAttempts
390
+ ).toBe(1);
391
+ } finally {
392
+ initializeSpy.mockRestore();
393
+ }
394
+ });
395
+
396
+ it('projects unsafe tool-call args before measuring or invoking the provider', async () => {
397
+ let toJSONCalls = 0;
398
+ const toolCallId = 'tc-unsafe-input';
399
+ const unsafeArgs = {
400
+ query: 'safe',
401
+ toJSON() {
402
+ toJSONCalls++;
403
+ return { query: 'x'.repeat(100_000) };
404
+ },
405
+ };
406
+ const messages: BaseMessage[] = [
407
+ new HumanMessage('run the lookup'),
408
+ new AIMessage({
409
+ content: '',
410
+ tool_calls: [
411
+ {
412
+ id: toolCallId,
413
+ name: 'lookup_records',
414
+ args: unsafeArgs,
415
+ type: 'tool_call',
416
+ },
417
+ ],
418
+ }),
419
+ new ToolMessage({
420
+ content: 'done',
421
+ tool_call_id: toolCallId,
422
+ name: 'lookup_records',
423
+ }),
424
+ new AIMessage('The lookup completed.'),
425
+ new HumanMessage('continue'),
426
+ ];
427
+ const run = await createRun({
428
+ runId: 'unsafe-tool-input-final-projection',
429
+ maxContextTokens: 10_000,
430
+ provider: Providers.OPENAI,
431
+ tokenCounter: () => 1,
432
+ indexTokenCountMap: { 0: 1, 1: 1, 2: 1, 3: 1, 4: 1 },
433
+ tools: [
434
+ tool(async () => 'unused', {
435
+ name: 'lookup_records',
436
+ description: 'Looks up records',
437
+ schema: z.object({}),
438
+ }),
439
+ ],
440
+ });
441
+ if (!run.Graph) {
442
+ throw new Error('Expected graph to be initialized');
443
+ }
444
+ const model = new OverflowThenSucceedModel(signatureFor('gpt-4o-mini'), 0);
445
+ run.Graph.overrideModel = model;
446
+
447
+ await run.processStream({ messages }, streamConfig);
448
+
449
+ const projectedCall = (
450
+ model.calls[0].find((message) => message.getType() === 'ai') as AIMessage
451
+ ).tool_calls?.[0];
452
+ expect(projectedCall?.args).toEqual({ query: 'safe' });
453
+ expect(toJSONCalls).toBe(0);
454
+ expect(messages[1]).toBeInstanceOf(AIMessage);
455
+ expect((messages[1] as AIMessage).tool_calls?.[0].args).toBe(unsafeArgs);
456
+ });
457
+
458
+ it('compacts cached structured tool output before the first provider call', async () => {
459
+ const toolCallId = 'tc-structured';
460
+ const messages: BaseMessage[] = [
461
+ new HumanMessage('query the table'),
462
+ new AIMessage({
463
+ content: '',
464
+ tool_calls: [
465
+ {
466
+ id: toolCallId,
467
+ name: 'run_select_query',
468
+ args: {},
469
+ type: 'tool_call',
470
+ },
471
+ ],
472
+ }),
473
+ new ToolMessage({
474
+ content: [
475
+ {
476
+ type: ContentTypes.TEXT,
477
+ text: JSON.stringify(
478
+ Array.from({ length: 240 }, (_, index) => ({
479
+ id: index,
480
+ value: `${'x'.repeat(100)}-${index}`,
481
+ }))
482
+ ),
483
+ },
484
+ ],
485
+ tool_call_id: toolCallId,
486
+ name: 'run_select_query',
487
+ }),
488
+ new AIMessage('The query returned 240 rows.'),
489
+ new HumanMessage('compact context'),
490
+ ];
491
+ const structuredTokenCounter: t.TokenCounter = (message) => {
492
+ const content =
493
+ typeof message.content === 'string'
494
+ ? message.content
495
+ : JSON.stringify(message.content);
496
+ return Math.ceil(content.length / 4);
497
+ };
498
+ const indexTokenCountMap: Record<string, number> = {};
499
+ for (let i = 0; i < messages.length; i++) {
500
+ indexTokenCountMap[i] = i === 2 ? 0 : structuredTokenCounter(messages[i]);
501
+ }
502
+ const run = await createRun({
503
+ runId: 'structured-output-preflight',
504
+ maxContextTokens: 5_000,
505
+ provider: Providers.BEDROCK,
506
+ model: 'anthropic.claude-sonnet-4-5',
507
+ tokenCounter: structuredTokenCounter,
508
+ indexTokenCountMap,
509
+ tools: [
510
+ tool(async () => 'unused', {
511
+ name: 'run_select_query',
512
+ description: 'Queries ClickHouse',
513
+ schema: z.object({}),
514
+ }),
515
+ ],
516
+ });
517
+ if (!run.Graph) {
518
+ throw new Error('Expected graph to be initialized');
519
+ }
520
+ const model = new SizeBoundModel(
521
+ 1_500,
522
+ signatureFor('us.anthropic.claude-sonnet-4-5-20250929-v1:0')
523
+ );
524
+ run.Graph.overrideModel = model;
525
+
526
+ const content = await run.processStream({ messages }, streamConfig);
527
+
528
+ expect(content).toEqual([{ type: 'text', text: 'recovered' }]);
529
+ expect(model.toolContentChars).toHaveLength(1);
530
+ expect(model.toolContentChars[0]).toBeGreaterThan(0);
531
+ expect(model.toolContentChars[0]).toBeLessThanOrEqual(1_500);
532
+ expect(
533
+ run.Graph.agentContexts.get('default')?.overflowRecoveryAttempts
534
+ ).toBe(0);
535
+ });
536
+
537
+ it('compacts an unconsumed structured tool result before its first provider call', async () => {
538
+ const toolCallId = 'tc-unconsumed-structured';
539
+ const messages: BaseMessage[] = [
540
+ new HumanMessage('query the table'),
541
+ new AIMessage({
542
+ content: '',
543
+ tool_calls: [
544
+ {
545
+ id: toolCallId,
546
+ name: 'run_select_query',
547
+ args: {},
548
+ type: 'tool_call',
549
+ },
550
+ ],
551
+ }),
552
+ new ToolMessage({
553
+ content: [
554
+ {
555
+ type: ContentTypes.TEXT,
556
+ text: JSON.stringify(
557
+ Array.from({ length: 240 }, (_, index) => ({
558
+ id: index,
559
+ value: `${'x'.repeat(100)}-${index}`,
560
+ }))
561
+ ),
562
+ },
563
+ ],
564
+ tool_call_id: toolCallId,
565
+ name: 'run_select_query',
566
+ }),
567
+ ];
568
+ const structuredTokenCounter: t.TokenCounter = (message) => {
569
+ const content =
570
+ typeof message.content === 'string'
571
+ ? message.content
572
+ : JSON.stringify(message.content);
573
+ return Math.ceil(content.length / 4);
574
+ };
575
+ const run = await createRun({
576
+ runId: 'unconsumed-structured-output-preflight',
577
+ maxContextTokens: 5_000,
578
+ maxToolResultChars: 1_500,
579
+ provider: Providers.BEDROCK,
580
+ tokenCounter: structuredTokenCounter,
581
+ indexTokenCountMap: {
582
+ 0: structuredTokenCounter(messages[0]),
583
+ 1: structuredTokenCounter(messages[1]),
584
+ 2: 0,
585
+ },
586
+ tools: [
587
+ tool(async () => 'unused', {
588
+ name: 'run_select_query',
589
+ description: 'Queries ClickHouse',
590
+ schema: z.object({}),
591
+ }),
592
+ ],
593
+ });
594
+ if (!run.Graph) {
595
+ throw new Error('Expected graph to be initialized');
596
+ }
597
+ const model = new SizeBoundModel(
598
+ 1_500,
599
+ signatureFor('us.anthropic.claude-sonnet-4-5-20250929-v1:0')
600
+ );
601
+ run.Graph.overrideModel = model;
602
+
603
+ const content = await run.processStream({ messages }, streamConfig);
604
+
605
+ expect(content).toEqual([{ type: 'text', text: 'recovered' }]);
606
+ expect(model.toolContentChars).toHaveLength(1);
607
+ expect(model.toolContentChars[0]).toBeGreaterThan(0);
608
+ expect(model.toolContentChars[0]).toBeLessThanOrEqual(1_500);
609
+ expect(
610
+ run.Graph.agentContexts.get('default')?.overflowRecoveryAttempts
611
+ ).toBe(0);
612
+ });
613
+
614
+ it('trusts the pruner baseline for an unchanged payload at high calibration', async () => {
615
+ const messages: BaseMessage[] = [
616
+ new HumanMessage('earlier question'),
617
+ new AIMessage('provider-counted answer'),
618
+ new HumanMessage('latest question'),
619
+ ];
620
+ const localCounter: t.TokenCounter = (message) =>
621
+ message.getType() === 'ai' ? 120 : 5;
622
+ const providerGroundedTokenMap = { 0: 5, 1: 10, 2: 5 };
623
+ const run = await createRun({
624
+ runId: 'provider-grounded-final-projection',
625
+ maxContextTokens: 200,
626
+ tokenCounter: localCounter,
627
+ indexTokenCountMap: providerGroundedTokenMap,
628
+ });
629
+ if (!run.Graph) {
630
+ throw new Error('Expected graph to be initialized');
631
+ }
632
+ const agentContext = run.Graph.agentContexts.get('default');
633
+ if (agentContext == null) {
634
+ throw new Error('Expected default agent context');
635
+ }
636
+ agentContext.calibrationRatio = 5;
637
+ const model = new OverflowThenSucceedModel(
638
+ signatureFor('claude-haiku-4-5-20251001'),
639
+ 0
640
+ );
641
+ run.Graph.overrideModel = model;
642
+
643
+ const content = await run.processStream({ messages }, streamConfig);
644
+
645
+ expect(content).toEqual([{ type: 'text', text: 'recovered' }]);
646
+ expect(model.calls).toHaveLength(1);
647
+ expect(model.calls[0]).toHaveLength(messages.length);
648
+ expect(agentContext.overflowRecoveryAttempts).toBe(0);
649
+ });
650
+
651
+ it('does not let an unrelated provider-format shrink hide artifact growth', async () => {
652
+ const toolCallId = 'tc-artifact-independent-growth';
653
+ const artifactSentinel = 'ARTIFACT_INDEPENDENT_GROWTH_SENTINEL';
654
+ const toolMessage = new ToolMessage({
655
+ content: 'rendered',
656
+ tool_call_id: toolCallId,
657
+ name: 'render_report',
658
+ artifact: {
659
+ content: [
660
+ {
661
+ type: ContentTypes.TEXT,
662
+ text: artifactSentinel,
663
+ },
664
+ ],
665
+ },
666
+ });
667
+ const messages: BaseMessage[] = [
668
+ new HumanMessage('render the report'),
669
+ new AIMessageChunk({
670
+ content: 'calling render_report',
671
+ tool_calls: [
672
+ {
673
+ id: toolCallId,
674
+ name: 'render_report',
675
+ args: {},
676
+ type: 'tool_call',
677
+ },
678
+ ],
679
+ }),
680
+ toolMessage,
681
+ ];
682
+ const transformCounter: t.TokenCounter = (message) => {
683
+ if (
684
+ message instanceof AIMessageChunk &&
685
+ typeof message.content === 'string'
686
+ ) {
687
+ return 100;
688
+ }
689
+ if (
690
+ message instanceof ToolMessage &&
691
+ JSON.stringify(message.content).includes(artifactSentinel)
692
+ ) {
693
+ return 50;
694
+ }
695
+ return 1;
696
+ };
697
+ const run = await createRun({
698
+ runId: 'artifact-independent-transform-growth',
699
+ maxContextTokens: 50,
700
+ provider: Providers.BEDROCK,
701
+ model: 'anthropic.claude-sonnet-4-5',
702
+ tokenCounter: transformCounter,
703
+ indexTokenCountMap: { 0: 1, 1: 1, 2: 1 },
704
+ tools: [
705
+ tool(async () => 'unused', {
706
+ name: 'render_report',
707
+ description: 'Renders a report',
708
+ schema: z.object({}),
709
+ }),
710
+ ],
711
+ });
712
+ if (!run.Graph) {
713
+ throw new Error('Expected graph to be initialized');
714
+ }
715
+ const model = new OverflowThenSucceedModel(
716
+ signatureFor('us.anthropic.claude-sonnet-4-5-20250929-v1:0'),
717
+ 0
718
+ );
719
+ run.Graph.overrideModel = model;
720
+
721
+ await run.processStream({ messages }, streamConfig);
722
+
723
+ expect(model.calls).toHaveLength(1);
724
+ expect(
725
+ JSON.stringify(model.calls[0].map((message) => message.content))
726
+ ).not.toContain(artifactSentinel);
727
+ expect(toolMessage.artifact.content[0].text).toBe(artifactSentinel);
728
+ });
729
+
730
+ it('preserves provider attribution for an un-IDd AI clone during orphan sanitization', async () => {
731
+ const droppedCallId = 'tc-dropped-ai';
732
+ const missingCallId = 'tc-missing-result';
733
+ const unrelatedResultId = 'tc-unrelated-result';
734
+ const messages: BaseMessage[] = [
735
+ new HumanMessage('earlier question'),
736
+ new AIMessage({
737
+ content: [
738
+ {
739
+ type: 'tool_use',
740
+ id: droppedCallId,
741
+ name: 'declared_tool',
742
+ input: {},
743
+ },
744
+ ],
745
+ tool_calls: [
746
+ {
747
+ id: droppedCallId,
748
+ name: 'declared_tool',
749
+ args: {},
750
+ type: 'tool_call',
751
+ },
752
+ ],
753
+ }),
754
+ new AIMessage({
755
+ content: 'provider-counted answer',
756
+ tool_calls: [
757
+ {
758
+ id: missingCallId,
759
+ name: 'declared_tool',
760
+ args: {},
761
+ type: 'tool_call',
762
+ },
763
+ ],
764
+ }),
765
+ new ToolMessage({
766
+ content: 'orphaned result',
767
+ tool_call_id: unrelatedResultId,
768
+ name: 'declared_tool',
769
+ }),
770
+ new HumanMessage('latest question'),
771
+ ];
772
+ const skewedCounter: t.TokenCounter = (message) => {
773
+ if (message.getType() !== 'ai') {
774
+ return 5;
775
+ }
776
+ return Array.isArray(message.content) ? 1 : 120;
777
+ };
778
+ const run = await createRun({
779
+ runId: 'orphan-sanitize-provider-origin',
780
+ maxContextTokens: 1_000,
781
+ provider: Providers.ANTHROPIC,
782
+ promptCache: true,
783
+ tokenCounter: skewedCounter,
784
+ indexTokenCountMap: { 0: 5, 1: 80, 2: 10, 3: 5, 4: 5 },
785
+ tools: [
786
+ tool(async () => 'unused', {
787
+ name: 'declared_tool',
788
+ description: 'Declared only to prevent tool-less folding',
789
+ schema: z.object({}),
790
+ }),
791
+ ],
792
+ });
793
+ if (!run.Graph) {
794
+ throw new Error('Expected graph to be initialized');
795
+ }
796
+ const agentContext = run.Graph.agentContexts.get('default');
797
+ if (agentContext == null) {
798
+ throw new Error('Expected default agent context');
799
+ }
800
+ agentContext.calibrationRatio = 5;
801
+ const model = new OverflowThenSucceedModel(
802
+ signatureFor('claude-haiku-4-5-20251001'),
803
+ 0
804
+ );
805
+ run.Graph.overrideModel = model;
806
+
807
+ const content = await run.processStream({ messages }, streamConfig);
808
+
809
+ expect(content).toEqual([{ type: 'text', text: 'recovered' }]);
810
+ expect(model.calls).toHaveLength(1);
811
+ expect(model.calls[0]).toHaveLength(3);
812
+ expect(model.calls[0].some((message) => message.getType() === 'tool')).toBe(
813
+ false
814
+ );
815
+ const sanitizedAI = model.calls[0].find(
816
+ (message) => message.getType() === 'ai'
817
+ ) as AIMessage | undefined;
818
+ expect(sanitizedAI?.content).toBe('provider-counted answer');
819
+ expect(sanitizedAI?.tool_calls ?? []).toHaveLength(0);
820
+ expect(agentContext.overflowRecoveryAttempts).toBe(0);
821
+ });
822
+
823
+ it('reserves the reply primer before accepting fast-path context', async () => {
824
+ const messages: BaseMessage[] = [
825
+ new HumanMessage('earlier question'),
826
+ new AIMessage('earlier answer'),
827
+ new HumanMessage('latest question'),
828
+ ];
829
+ const run = await createRun({
830
+ runId: 'fast-path-reply-primer',
831
+ maxContextTokens: 100,
832
+ tokenCounter: () => 31,
833
+ indexTokenCountMap: { 0: 31, 1: 31, 2: 31 },
834
+ });
835
+ if (!run.Graph) {
836
+ throw new Error('Expected graph to be initialized');
837
+ }
838
+ const model = new OverflowThenSucceedModel(
839
+ signatureFor('claude-haiku-4-5-20251001'),
840
+ 0
841
+ );
842
+ run.Graph.overrideModel = model;
843
+
844
+ await run.processStream({ messages }, streamConfig);
845
+
846
+ expect(model.calls).toHaveLength(1);
847
+ expect(model.calls[0].length).toBeLessThan(messages.length);
848
+ expect(3 + model.calls[0].length * 31).toBeLessThanOrEqual(95);
849
+ });
850
+
851
+ it('includes artifact expansion when it fits the post-prune budget', async () => {
852
+ const toolCallId = 'tc-artifact-fits';
853
+ const artifactSentinel = 'ARTIFACT_FITS_SENTINEL';
854
+ const toolMessage = new ToolMessage({
855
+ content: 'rendered',
856
+ tool_call_id: toolCallId,
857
+ name: 'render_report',
858
+ artifact: {
859
+ content: [
860
+ {
861
+ type: ContentTypes.TEXT,
862
+ text: `${artifactSentinel}:complete`,
863
+ },
864
+ ],
865
+ },
866
+ });
867
+ const messages: BaseMessage[] = [
868
+ new HumanMessage('render the report'),
869
+ new AIMessageChunk({
870
+ content: '',
871
+ tool_calls: [
872
+ {
873
+ id: toolCallId,
874
+ name: 'render_report',
875
+ args: {},
876
+ type: 'tool_call',
877
+ },
878
+ ],
879
+ }),
880
+ toolMessage,
881
+ ];
882
+ const artifactTokenCounter: t.TokenCounter = (message) => {
883
+ const content =
884
+ typeof message.content === 'string'
885
+ ? message.content
886
+ : JSON.stringify(message.content);
887
+ return Math.ceil(content.length / 4);
888
+ };
889
+ const run = await createRun({
890
+ runId: 'artifact-budget-control',
891
+ maxContextTokens: 10_000,
892
+ maxToolResultChars: 2_000,
893
+ provider: Providers.BEDROCK,
894
+ model: 'anthropic.claude-sonnet-4-5',
895
+ tokenCounter: artifactTokenCounter,
896
+ indexTokenCountMap: {
897
+ 0: artifactTokenCounter(messages[0]),
898
+ 1: artifactTokenCounter(messages[1]),
899
+ 2: artifactTokenCounter(messages[2]),
900
+ },
901
+ tools: [
902
+ tool(async () => 'unused', {
903
+ name: 'render_report',
904
+ description: 'Renders a report',
905
+ schema: z.object({}),
906
+ }),
907
+ ],
908
+ });
909
+ if (!run.Graph) {
910
+ throw new Error('Expected graph to be initialized');
911
+ }
912
+ const model = new OverflowThenSucceedModel(
913
+ signatureFor('us.anthropic.claude-sonnet-4-5-20250929-v1:0'),
914
+ 0
915
+ );
916
+ run.Graph.overrideModel = model;
917
+
918
+ await run.processStream({ messages }, streamConfig);
919
+
920
+ expect(model.calls).toHaveLength(1);
921
+ expect(
922
+ JSON.stringify(model.calls[0].map((message) => message.content))
923
+ ).toContain(artifactSentinel);
924
+ expect(toolMessage.content).toBe('rendered');
925
+ expect(toolMessage.artifact.content[0].text).toContain(artifactSentinel);
926
+ });
927
+
928
+ it('rechecks artifact expansion after provider message transforms', async () => {
929
+ const toolCallId = 'tc-artifact-final-transform';
930
+ const artifactSentinel = 'ARTIFACT_FINAL_TRANSFORM_SENTINEL';
931
+ const toolMessage = new ToolMessage({
932
+ content: 'rendered',
933
+ tool_call_id: toolCallId,
934
+ name: 'render_report',
935
+ artifact: {
936
+ content: [
937
+ {
938
+ type: ContentTypes.TEXT,
939
+ text: `${artifactSentinel}:complete`,
940
+ },
941
+ ],
942
+ },
943
+ });
944
+ const messages: BaseMessage[] = [
945
+ new HumanMessage('render the report'),
946
+ new AIMessageChunk({
947
+ content: '',
948
+ tool_calls: [
949
+ {
950
+ id: toolCallId,
951
+ name: 'render_report',
952
+ args: {},
953
+ type: 'tool_call',
954
+ },
955
+ ],
956
+ }),
957
+ toolMessage,
958
+ ];
959
+ const transformSensitiveCounter: t.TokenCounter = (message) => {
960
+ const content =
961
+ typeof message.content === 'string'
962
+ ? message.content
963
+ : JSON.stringify(message.content);
964
+ if (
965
+ message instanceof HumanMessage &&
966
+ content.includes(artifactSentinel)
967
+ ) {
968
+ return 10_000;
969
+ }
970
+ return Math.max(1, Math.ceil(content.length / 4));
971
+ };
972
+ const run = await createRun({
973
+ runId: 'artifact-final-transform-guard',
974
+ maxContextTokens: 5_000,
975
+ maxToolResultChars: 2_000,
976
+ provider: Providers.BEDROCK,
977
+ model: 'anthropic.claude-sonnet-4-5',
978
+ tokenCounter: transformSensitiveCounter,
979
+ indexTokenCountMap: {
980
+ 0: transformSensitiveCounter(messages[0]),
981
+ 1: transformSensitiveCounter(messages[1]),
982
+ 2: transformSensitiveCounter(messages[2]),
983
+ },
984
+ });
985
+ if (!run.Graph) {
986
+ throw new Error('Expected graph to be initialized');
987
+ }
988
+ const model = new OverflowThenSucceedModel(
989
+ signatureFor('us.anthropic.claude-sonnet-4-5-20250929-v1:0'),
990
+ 0
991
+ );
992
+ run.Graph.overrideModel = model;
993
+
994
+ await run.processStream({ messages }, streamConfig);
995
+
996
+ expect(model.calls).toHaveLength(1);
997
+ const providerContent = JSON.stringify(
998
+ model.calls[0].map((message) => message.content)
999
+ );
1000
+ expect(providerContent).toContain('[Previous tool interaction]');
1001
+ expect(providerContent).not.toContain(artifactSentinel);
1002
+ expect(toolMessage.artifact.content[0].text).toContain(artifactSentinel);
1003
+ });
1004
+
1005
+ it('compacts expanded synthetic context without an artifact', async () => {
1006
+ const toolCallId = 'tc-final-transform-without-artifact';
1007
+ const messages: BaseMessage[] = [
1008
+ new HumanMessage('query the table'),
1009
+ new AIMessageChunk({
1010
+ content: '',
1011
+ tool_calls: [
1012
+ {
1013
+ id: toolCallId,
1014
+ name: 'run_select_query',
1015
+ args: { query: `SELECT '${'x'.repeat(5_000)}'` },
1016
+ type: 'tool_call',
1017
+ },
1018
+ ],
1019
+ }),
1020
+ new ToolMessage({
1021
+ content: 'query complete',
1022
+ tool_call_id: toolCallId,
1023
+ name: 'run_select_query',
1024
+ }),
1025
+ ];
1026
+ const transformSensitiveCounter: t.TokenCounter = (message) => {
1027
+ const content =
1028
+ typeof message.content === 'string'
1029
+ ? message.content
1030
+ : JSON.stringify(message.content);
1031
+ if (
1032
+ message instanceof HumanMessage &&
1033
+ content.includes('[Previous tool interaction]')
1034
+ ) {
1035
+ return content.length * 10;
1036
+ }
1037
+ return 1;
1038
+ };
1039
+ const run = await createRun({
1040
+ runId: 'final-transform-without-artifact',
1041
+ maxContextTokens: 500,
1042
+ provider: Providers.BEDROCK,
1043
+ model: 'anthropic.claude-sonnet-4-5',
1044
+ tokenCounter: transformSensitiveCounter,
1045
+ indexTokenCountMap: { 0: 1, 1: 1, 2: 1 },
1046
+ });
1047
+ if (!run.Graph) {
1048
+ throw new Error('Expected graph to be initialized');
1049
+ }
1050
+ const model = new OverflowThenSucceedModel(
1051
+ signatureFor('us.anthropic.claude-sonnet-4-5-20250929-v1:0'),
1052
+ 0
1053
+ );
1054
+ run.Graph.overrideModel = model;
1055
+
1056
+ await run.processStream({ messages }, streamConfig);
1057
+
1058
+ expect(model.calls).toHaveLength(1);
1059
+ const humanMessages = model.calls[0].filter(
1060
+ (message) => message instanceof HumanMessage
1061
+ );
1062
+ expect(humanMessages).toHaveLength(2);
1063
+ expect(
1064
+ JSON.stringify(humanMessages[humanMessages.length - 1].content).length
1065
+ ).toBeLessThan(100);
1066
+ expect(
1067
+ JSON.stringify(humanMessages[humanMessages.length - 1].content)
1068
+ ).not.toContain('x'.repeat(1_000));
1069
+ });
1070
+
1071
+ it('counts unresolved-reference annotations before invoking the provider', async () => {
1072
+ const toolCallId = 'tc-unresolved-projection';
1073
+ const unresolvedRefs = Array.from(
1074
+ { length: 1_200 },
1075
+ (_, index) => `missing_tool_${index}_turn_${index}`
1076
+ );
1077
+ const messages: BaseMessage[] = [
1078
+ new HumanMessage(`old question ${'q'.repeat(5_500)}`),
1079
+ new AIMessage({
1080
+ content: '',
1081
+ tool_calls: [
1082
+ {
1083
+ id: toolCallId,
1084
+ name: 'lookup_records',
1085
+ args: {},
1086
+ type: 'tool_call',
1087
+ },
1088
+ ],
1089
+ }),
1090
+ new ToolMessage({
1091
+ content: `old result ${'r'.repeat(3_000)}`,
1092
+ tool_call_id: toolCallId,
1093
+ name: 'lookup_records',
1094
+ additional_kwargs: { _unresolvedRefs: unresolvedRefs },
1095
+ }),
1096
+ new AIMessage(`old answer ${'a'.repeat(4_500)}`),
1097
+ new HumanMessage(`latest question ${'n'.repeat(3_500)}`),
1098
+ ];
1099
+ const projectionCounter: t.TokenCounter = (message) => {
1100
+ const content =
1101
+ typeof message.content === 'string'
1102
+ ? message.content
1103
+ : JSON.stringify(message.content);
1104
+ return Math.max(1, content.length);
1105
+ };
1106
+ const indexTokenCountMap: Record<string, number> = {};
1107
+ for (let i = 0; i < messages.length; i++) {
1108
+ indexTokenCountMap[i] = projectionCounter(messages[i]);
1109
+ }
1110
+ const run = await createRun({
1111
+ runId: 'unresolved-reference-final-projection',
1112
+ maxContextTokens: 20_000,
1113
+ provider: Providers.ANTHROPIC,
1114
+ tokenCounter: projectionCounter,
1115
+ indexTokenCountMap,
1116
+ toolOutputReferences: { enabled: true },
1117
+ tools: [
1118
+ tool(async () => 'unused', {
1119
+ name: 'lookup_records',
1120
+ description: 'Looks up records',
1121
+ schema: z.object({}),
1122
+ }),
1123
+ ],
1124
+ });
1125
+ if (!run.Graph) {
1126
+ throw new Error('Expected graph to be initialized');
1127
+ }
1128
+ const model = new OverflowThenSucceedModel(
1129
+ signatureFor('claude-haiku-4-5-20251001'),
1130
+ 0
1131
+ );
1132
+ run.Graph.overrideModel = model;
1133
+
1134
+ const content = await run.processStream({ messages }, streamConfig);
1135
+
1136
+ expect(content).toEqual([{ type: 'text', text: 'recovered' }]);
1137
+ expect(model.calls).toHaveLength(1);
1138
+ expect(
1139
+ run.Graph.agentContexts.get('default')?.overflowRecoveryAttempts
1140
+ ).toBeGreaterThan(0);
1141
+ const providerPayload = JSON.stringify(
1142
+ model.calls[0].map((message) => message.content)
1143
+ );
1144
+ expect(providerPayload).not.toContain(unresolvedRefs[0]);
1145
+ expect(providerPayload).not.toContain(unresolvedRefs.at(-1));
1146
+ const sentMessageTokens =
1147
+ 3 +
1148
+ model.calls[0].reduce(
1149
+ (total, message) => total + projectionCounter(message),
1150
+ 0
1151
+ );
1152
+ expect(sentMessageTokens).toBeLessThan(
1153
+ run.Graph.agentContexts.get('default')?.maxContextTokens ?? 0
1154
+ );
1155
+ });
1156
+
1157
+ it('omits artifact expansion that would exceed the post-prune budget', async () => {
1158
+ const toolCallId = 'tc-artifact';
1159
+ const artifactSentinel = 'ARTIFACT_SENTINEL';
1160
+ const toolMessage = new ToolMessage({
1161
+ content: 'result'.repeat(100),
1162
+ tool_call_id: toolCallId,
1163
+ name: 'render_report',
1164
+ artifact: {
1165
+ content: [
1166
+ {
1167
+ type: ContentTypes.TEXT,
1168
+ text: `${artifactSentinel}:${'a'.repeat(5_000)}`,
1169
+ },
1170
+ ],
1171
+ },
1172
+ });
1173
+ const messages: BaseMessage[] = [
1174
+ new HumanMessage('h'.repeat(2_400)),
1175
+ new AIMessageChunk({
1176
+ content: '',
1177
+ tool_calls: [
1178
+ {
1179
+ id: toolCallId,
1180
+ name: 'render_report',
1181
+ args: {},
1182
+ type: 'tool_call',
1183
+ },
1184
+ ],
1185
+ }),
1186
+ toolMessage,
1187
+ ];
1188
+ const artifactTokenCounter: t.TokenCounter = (message) => {
1189
+ const content =
1190
+ typeof message.content === 'string'
1191
+ ? message.content
1192
+ : JSON.stringify(message.content);
1193
+ return Math.ceil(content.length / 4);
1194
+ };
1195
+ const indexTokenCountMap: Record<string, number> = {};
1196
+ for (let i = 0; i < messages.length; i++) {
1197
+ indexTokenCountMap[i] = artifactTokenCounter(messages[i]);
1198
+ }
1199
+ const run = await createRun({
1200
+ runId: 'artifact-budget-guard',
1201
+ maxContextTokens: 1_000,
1202
+ maxToolResultChars: 2_000,
1203
+ provider: Providers.BEDROCK,
1204
+ model: 'anthropic.claude-sonnet-4-5',
1205
+ tokenCounter: artifactTokenCounter,
1206
+ indexTokenCountMap,
1207
+ tools: [
1208
+ tool(async () => 'unused', {
1209
+ name: 'render_report',
1210
+ description: 'Renders a report',
1211
+ schema: z.object({}),
1212
+ }),
1213
+ ],
1214
+ });
1215
+ if (!run.Graph) {
1216
+ throw new Error('Expected graph to be initialized');
1217
+ }
1218
+ const model = new OverflowThenSucceedModel(
1219
+ signatureFor('us.anthropic.claude-sonnet-4-5-20250929-v1:0'),
1220
+ 0
1221
+ );
1222
+ run.Graph.overrideModel = model;
1223
+
1224
+ await run.processStream({ messages }, streamConfig);
1225
+
1226
+ expect(model.calls).toHaveLength(1);
1227
+ expect(
1228
+ JSON.stringify(model.calls[0].map((message) => message.content))
1229
+ ).not.toContain(artifactSentinel);
1230
+ expect(toolMessage.content).toBe('result'.repeat(100));
1231
+ expect(toolMessage.artifact.content[0].text).toContain(artifactSentinel);
1232
+ });
1233
+
110
1234
  it('preserves masked tool originals while checkpointed messages survive', async () => {
111
1235
  const run = await createRun({
112
1236
  runId: 'overflow-originals-checkpoint',