@librechat/agents 3.3.0 → 3.3.2

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 (130) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +116 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +2 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +229 -48
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +102 -27
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/index.cjs +11 -2
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/contextOverflowRecovery.cjs +132 -0
  12. package/dist/cjs/llm/contextOverflowRecovery.cjs.map +1 -0
  13. package/dist/cjs/llm/google/index.cjs +1 -1
  14. package/dist/cjs/llm/invoke.cjs +60 -3
  15. package/dist/cjs/llm/invoke.cjs.map +1 -1
  16. package/dist/cjs/main.cjs +20 -7
  17. package/dist/cjs/messages/prune.cjs +16 -5
  18. package/dist/cjs/messages/prune.cjs.map +1 -1
  19. package/dist/cjs/messages/recency.cjs +2 -0
  20. package/dist/cjs/messages/recency.cjs.map +1 -1
  21. package/dist/cjs/run.cjs +15 -2
  22. package/dist/cjs/run.cjs.map +1 -1
  23. package/dist/cjs/stream.cjs +205 -49
  24. package/dist/cjs/stream.cjs.map +1 -1
  25. package/dist/cjs/summarization/node.cjs +55 -7
  26. package/dist/cjs/summarization/node.cjs.map +1 -1
  27. package/dist/cjs/tools/BashExecutor.cjs +2 -2
  28. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  29. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -2
  30. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  31. package/dist/cjs/tools/CodeExecutor.cjs +69 -8
  32. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  33. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +19 -15
  34. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  35. package/dist/cjs/tools/ToolNode.cjs +32 -3
  36. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  37. package/dist/cjs/utils/errors.cjs +317 -52
  38. package/dist/cjs/utils/errors.cjs.map +1 -1
  39. package/dist/esm/agents/AgentContext.mjs +117 -3
  40. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  41. package/dist/esm/common/enum.mjs +2 -0
  42. package/dist/esm/common/enum.mjs.map +1 -1
  43. package/dist/esm/graphs/Graph.mjs +227 -46
  44. package/dist/esm/graphs/Graph.mjs.map +1 -1
  45. package/dist/esm/graphs/MultiAgentGraph.mjs +102 -27
  46. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  47. package/dist/esm/llm/bedrock/index.mjs +11 -2
  48. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  49. package/dist/esm/llm/contextOverflowRecovery.mjs +130 -0
  50. package/dist/esm/llm/contextOverflowRecovery.mjs.map +1 -0
  51. package/dist/esm/llm/google/index.mjs +1 -1
  52. package/dist/esm/llm/invoke.mjs +59 -4
  53. package/dist/esm/llm/invoke.mjs.map +1 -1
  54. package/dist/esm/main.mjs +9 -9
  55. package/dist/esm/messages/prune.mjs +14 -6
  56. package/dist/esm/messages/prune.mjs.map +1 -1
  57. package/dist/esm/messages/recency.mjs +2 -1
  58. package/dist/esm/messages/recency.mjs.map +1 -1
  59. package/dist/esm/run.mjs +15 -2
  60. package/dist/esm/run.mjs.map +1 -1
  61. package/dist/esm/stream.mjs +205 -49
  62. package/dist/esm/stream.mjs.map +1 -1
  63. package/dist/esm/summarization/node.mjs +55 -7
  64. package/dist/esm/summarization/node.mjs.map +1 -1
  65. package/dist/esm/tools/BashExecutor.mjs +3 -3
  66. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  67. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +3 -3
  68. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  69. package/dist/esm/tools/CodeExecutor.mjs +62 -9
  70. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  71. package/dist/esm/tools/ProgrammaticToolCalling.mjs +20 -16
  72. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  73. package/dist/esm/tools/ToolNode.mjs +32 -3
  74. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  75. package/dist/esm/utils/errors.mjs +317 -53
  76. package/dist/esm/utils/errors.mjs.map +1 -1
  77. package/dist/types/agents/AgentContext.d.ts +62 -3
  78. package/dist/types/common/enum.d.ts +2 -0
  79. package/dist/types/graphs/Graph.d.ts +16 -2
  80. package/dist/types/graphs/MultiAgentGraph.d.ts +1 -0
  81. package/dist/types/llm/contextOverflowRecovery.d.ts +85 -0
  82. package/dist/types/llm/invoke.d.ts +35 -6
  83. package/dist/types/messages/prune.d.ts +10 -2
  84. package/dist/types/messages/recency.d.ts +1 -0
  85. package/dist/types/run.d.ts +2 -0
  86. package/dist/types/tools/CodeExecutor.d.ts +14 -1
  87. package/dist/types/types/llm.d.ts +7 -4
  88. package/dist/types/types/stream.d.ts +5 -4
  89. package/dist/types/types/summarize.d.ts +22 -0
  90. package/dist/types/utils/__tests__/fixtures/contextOverflowSignatures.d.ts +40 -0
  91. package/dist/types/utils/errors.d.ts +65 -16
  92. package/dist/types/utils/redactSecrets.d.ts +3 -0
  93. package/package.json +10 -11
  94. package/src/agents/AgentContext.ts +188 -7
  95. package/src/agents/__tests__/AgentContext.overflow.test.ts +205 -0
  96. package/src/common/enum.ts +2 -0
  97. package/src/graphs/Graph.ts +389 -58
  98. package/src/graphs/MultiAgentGraph.ts +184 -46
  99. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +631 -0
  100. package/src/llm/__tests__/contextOverflowRecovery.test.ts +401 -0
  101. package/src/llm/__tests__/fallbackOverflow.test.ts +287 -0
  102. package/src/llm/anthropic/llm.spec.ts +8 -0
  103. package/src/llm/bedrock/index.ts +25 -12
  104. package/src/llm/contextOverflowRecovery.ts +292 -0
  105. package/src/llm/invoke.ts +119 -4
  106. package/src/messages/prune.ts +24 -11
  107. package/src/messages/recency.ts +3 -1
  108. package/src/run.ts +24 -1
  109. package/src/scripts/context-overflow-probe.ts +997 -0
  110. package/src/specs/agent-handoffs.test.ts +903 -1
  111. package/src/specs/context-overflow-recovery.live.test.ts +213 -0
  112. package/src/splitStream.test.ts +882 -0
  113. package/src/stream.ts +315 -51
  114. package/src/summarization/__tests__/aggregator.test.ts +83 -0
  115. package/src/summarization/__tests__/node.test.ts +139 -0
  116. package/src/summarization/node.ts +99 -14
  117. package/src/tools/BashExecutor.ts +4 -2
  118. package/src/tools/BashProgrammaticToolCalling.ts +4 -7
  119. package/src/tools/CodeExecutor.ts +119 -8
  120. package/src/tools/ProgrammaticToolCalling.ts +29 -27
  121. package/src/tools/ToolNode.ts +50 -8
  122. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +297 -3
  123. package/src/types/llm.ts +8 -1
  124. package/src/types/stream.ts +5 -4
  125. package/src/types/summarize.ts +22 -0
  126. package/src/utils/__tests__/errors.test.ts +270 -0
  127. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +336 -0
  128. package/src/utils/__tests__/redactSecrets.test.ts +56 -0
  129. package/src/utils/errors.ts +484 -66
  130. package/src/utils/redactSecrets.ts +61 -0
@@ -18,6 +18,13 @@ import {
18
18
  cloneMessage,
19
19
  type PromptCacheTtl,
20
20
  } from '@/messages/cache';
21
+ import {
22
+ DEFAULT_RESERVE_RATIO,
23
+ ORIGINAL_CONTENT_MAX_CHARS,
24
+ clampCalibrationRatio,
25
+ createPruneMessages,
26
+ syncBudgetDerivedFields,
27
+ } from '@/messages';
21
28
  import {
22
29
  ANTHROPIC_TOOL_TOKEN_MULTIPLIER,
23
30
  DEFAULT_TOOL_TOKEN_MULTIPLIER,
@@ -25,11 +32,6 @@ import {
25
32
  Constants,
26
33
  Providers,
27
34
  } from '@/common';
28
- import {
29
- DEFAULT_RESERVE_RATIO,
30
- createPruneMessages,
31
- syncBudgetDerivedFields,
32
- } from '@/messages';
33
35
  import { createSchemaOnlyTools } from '@/tools/schema';
34
36
  import { apportionTokenCounts } from '@/utils/tokens';
35
37
  import { isThinkingEnabled } from '@/llm/request';
@@ -220,8 +222,22 @@ export class AgentContext {
220
222
  calibrationRatio: number = 1;
221
223
  /** Provider-observed instruction overhead from the pruner's best-variance turn. */
222
224
  resolvedInstructionOverhead?: number;
225
+ private _pendingOriginalToolContent?: Map<number, string>;
226
+ private pendingOriginalToolContentChars = 0;
223
227
  /** Pre-masking tool content keyed by message index, consumed by the summarize node. */
224
- pendingOriginalToolContent?: Map<number, string>;
228
+ get pendingOriginalToolContent(): Map<number, string> | undefined {
229
+ return this._pendingOriginalToolContent;
230
+ }
231
+ set pendingOriginalToolContent(value: Map<number, string> | undefined) {
232
+ this._pendingOriginalToolContent = value;
233
+ this.pendingOriginalToolContentChars = 0;
234
+ if (value != null) {
235
+ for (const content of value.values()) {
236
+ this.pendingOriginalToolContentChars += content.length;
237
+ }
238
+ this.enforcePendingOriginalContentCap();
239
+ }
240
+ }
225
241
 
226
242
  /** Total instruction overhead: system message + tool schemas + pending summary. */
227
243
  get instructionTokens(): number {
@@ -322,6 +338,25 @@ export class AgentContext {
322
338
  * Summarization is allowed to fire again only when new messages appear.
323
339
  */
324
340
  private _lastSummarizationMsgCount: number = 0;
341
+ /**
342
+ * Forced compactions performed after a provider rejected a prompt as too
343
+ * large. Bounds the recovery loop so a model that keeps refusing cannot
344
+ * make the run compact indefinitely.
345
+ */
346
+ private _overflowRecoveryAttempts: number = 0;
347
+ /**
348
+ * Budget in force before the first overflow correction of the current run.
349
+ * Recorded so `reset()` can undo the correction for the next run without
350
+ * disturbing a `maxContextTokens` that no correction ever touched.
351
+ */
352
+ private _preOverflowMaxContextTokens?: number;
353
+ /**
354
+ * Prompt size, normalized into the local counter's uncalibrated units, at
355
+ * the last overflow correction. Keeping both measurements in the same units
356
+ * lets a later overflow prove whether compaction changed anything even when
357
+ * the provider observation updated calibration between attempts.
358
+ */
359
+ private _lastOverflowPromptTokens?: number;
325
360
  /**
326
361
  * Handoff context when this agent receives control via handoff.
327
362
  * Contains source and parallel execution info for system message context.
@@ -964,7 +999,7 @@ export class AgentContext {
964
999
  /**
965
1000
  * Reset context for a new run
966
1001
  */
967
- reset(): void {
1002
+ reset(options?: { preserveOriginalToolContent?: boolean }): void {
968
1003
  this.systemMessageTokens = 0;
969
1004
  this.dynamicInstructionTokens = 0;
970
1005
  this.toolSchemaTokens = 0;
@@ -982,12 +1017,16 @@ export class AgentContext {
982
1017
  this.currentTokenType = ContentTypes.TEXT;
983
1018
  this.discoveredToolNames.clear();
984
1019
  this.handoffContext = undefined;
1020
+ if (options?.preserveOriginalToolContent !== true) {
1021
+ this.pendingOriginalToolContent = undefined;
1022
+ }
985
1023
 
986
1024
  this.summaryText = this._durableSummaryText;
987
1025
  this.summaryTokenCount = this._durableSummaryTokenCount;
988
1026
  this._lastSummarizationMsgCount = 0;
989
1027
  this.lastCallUsage = undefined;
990
1028
  this.totalTokensFresh = false;
1029
+ this.restoreContextBudgetAfterOverflow();
991
1030
 
992
1031
  if (this.tokenCounter) {
993
1032
  this.initializeSystemRunnable();
@@ -1308,6 +1347,148 @@ export class AgentContext {
1308
1347
  this._lastSummarizationMsgCount = msgCount;
1309
1348
  }
1310
1349
 
1350
+ get overflowRecoveryAttempts(): number {
1351
+ return this._overflowRecoveryAttempts;
1352
+ }
1353
+
1354
+ shouldSummarizeOverflow(): boolean {
1355
+ return (
1356
+ this.summarizationEnabled === true &&
1357
+ (this.tokenCounter == null ||
1358
+ this.maxContextTokens == null ||
1359
+ this._overflowRecoveryAttempts > 0)
1360
+ );
1361
+ }
1362
+
1363
+ /** Preserves the earliest full tool output recorded for each message index. */
1364
+ preserveOriginalToolContent(
1365
+ originalToolContent: Map<number, string> | undefined
1366
+ ): void {
1367
+ if (originalToolContent == null || originalToolContent.size === 0) {
1368
+ return;
1369
+ }
1370
+ if (this.pendingOriginalToolContent == null) {
1371
+ this.pendingOriginalToolContent = new Map();
1372
+ }
1373
+ for (const [index, content] of originalToolContent) {
1374
+ if (!this.pendingOriginalToolContent.has(index)) {
1375
+ this.pendingOriginalToolContent.set(index, content);
1376
+ this.pendingOriginalToolContentChars += content.length;
1377
+ }
1378
+ }
1379
+ this.enforcePendingOriginalContentCap();
1380
+ }
1381
+
1382
+ private enforcePendingOriginalContentCap(): void {
1383
+ const pending = this._pendingOriginalToolContent;
1384
+ if (pending == null) {
1385
+ return;
1386
+ }
1387
+ while (
1388
+ this.pendingOriginalToolContentChars > ORIGINAL_CONTENT_MAX_CHARS &&
1389
+ pending.size > 0
1390
+ ) {
1391
+ const oldest = pending.keys().next();
1392
+ if (oldest.done === true) {
1393
+ break;
1394
+ }
1395
+ const removed = pending.get(oldest.value);
1396
+ if (removed != null) {
1397
+ this.pendingOriginalToolContentChars -= removed.length;
1398
+ }
1399
+ pending.delete(oldest.value);
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * Retargets the context budget after a provider rejected the prompt as too
1405
+ * large, and clears the memoized pruner so the next call is planned against
1406
+ * the corrected budget rather than the one that was evidently wrong.
1407
+ *
1408
+ * Also clears the "already summarized at this message count" guard: that
1409
+ * guard exists to stop redundant summarization of an unchanged history, but
1410
+ * here the history has not changed and compaction is exactly what is
1411
+ * needed.
1412
+ */
1413
+ applyContextBudgetCorrection(
1414
+ budgetTokens: number | undefined,
1415
+ promptTokens?: number
1416
+ ): void {
1417
+ if (this._overflowRecoveryAttempts === 0) {
1418
+ this._preOverflowMaxContextTokens = this.maxContextTokens;
1419
+ }
1420
+ if (budgetTokens != null) {
1421
+ this.maxContextTokens = budgetTokens;
1422
+ }
1423
+ this.pruneMessages = undefined;
1424
+ this._lastSummarizationMsgCount = 0;
1425
+ this._lastOverflowPromptTokens =
1426
+ promptTokens != null
1427
+ ? this.normalizePromptTokens(promptTokens)
1428
+ : promptTokens;
1429
+ this._overflowRecoveryAttempts += 1;
1430
+ }
1431
+
1432
+ /** Applies token calibration only when the observation came from this provider. */
1433
+ applyObservedOverflowCalibration(
1434
+ provider: Providers | undefined,
1435
+ observedCalibrationRatio: number | undefined
1436
+ ): void {
1437
+ if (
1438
+ provider !== this.provider ||
1439
+ observedCalibrationRatio == null ||
1440
+ observedCalibrationRatio <= 0
1441
+ ) {
1442
+ return;
1443
+ }
1444
+ this.calibrationRatio = clampCalibrationRatio(observedCalibrationRatio);
1445
+ }
1446
+
1447
+ /**
1448
+ * True when a previous correction failed to make the prompt any smaller —
1449
+ * the signature of a state nothing can compact further (an emptied message
1450
+ * list carrying its content in an injected summary, for example). Retrying
1451
+ * from there resends a byte-identical prompt, so the caller should stop.
1452
+ */
1453
+ overflowRecoveryStalled(currentPromptTokens?: number): boolean {
1454
+ const previous = this._lastOverflowPromptTokens;
1455
+ if (
1456
+ previous == null ||
1457
+ currentPromptTokens == null ||
1458
+ !Number.isFinite(currentPromptTokens)
1459
+ ) {
1460
+ return false;
1461
+ }
1462
+ const rawCurrent = this.normalizePromptTokens(currentPromptTokens);
1463
+ return rawCurrent >= previous;
1464
+ }
1465
+
1466
+ private normalizePromptTokens(promptTokens: number): number {
1467
+ if (this.calibrationRatio <= 0) {
1468
+ return promptTokens;
1469
+ }
1470
+ const messageTokens = Math.max(0, promptTokens - this.instructionTokens);
1471
+ return this.instructionTokens + messageTokens / this.calibrationRatio;
1472
+ }
1473
+
1474
+ /**
1475
+ * Undoes overflow corrections so a reused context starts the next run with
1476
+ * the budget it was configured with and a fresh recovery allowance.
1477
+ *
1478
+ * Without this, a single overflow would permanently shrink the budget for
1479
+ * every later turn, and two would exhaust the per-run allowance for the
1480
+ * lifetime of the context.
1481
+ */
1482
+ private restoreContextBudgetAfterOverflow(): void {
1483
+ if (this._overflowRecoveryAttempts === 0) {
1484
+ return;
1485
+ }
1486
+ this.maxContextTokens = this._preOverflowMaxContextTokens;
1487
+ this._preOverflowMaxContextTokens = undefined;
1488
+ this._lastOverflowPromptTokens = undefined;
1489
+ this._overflowRecoveryAttempts = 0;
1490
+ }
1491
+
1311
1492
  clearSummary(): void {
1312
1493
  if (this.summaryText != null) {
1313
1494
  this.summaryText = undefined;
@@ -0,0 +1,205 @@
1
+ import type * as t from '@/types';
2
+ import { AgentContext } from '@/agents/AgentContext';
3
+ import { Providers } from '@/common';
4
+
5
+ /**
6
+ * The overflow-recovery bookkeeping on AgentContext: the budget correction,
7
+ * its restoration between runs, and the stall detector that stops a recovery
8
+ * loop when a correction demonstrably changed nothing.
9
+ */
10
+ describe('AgentContext overflow recovery state', () => {
11
+ const createContext = (maxContextTokens?: number): AgentContext =>
12
+ AgentContext.fromConfig({
13
+ agentId: 'overflow-agent',
14
+ provider: Providers.ANTHROPIC,
15
+ instructions: 'Test instructions',
16
+ maxContextTokens,
17
+ } as Partial<t.AgentInputs> as t.AgentInputs);
18
+
19
+ it('records the correction and counts the attempt', () => {
20
+ const context = createContext(1_000_000);
21
+ context.applyContextBudgetCorrection(190_000, 274_468);
22
+
23
+ expect(context.maxContextTokens).toBe(190_000);
24
+ expect(context.overflowRecoveryAttempts).toBe(1);
25
+ /** Forces the pruner to be rebuilt against the corrected budget. */
26
+ expect(context.pruneMessages).toBeUndefined();
27
+ });
28
+
29
+ it('summarizes the first overflow when deterministic pruning is unavailable', () => {
30
+ const context = createContext(1_000_000);
31
+ context.summarizationEnabled = true;
32
+
33
+ expect(context.shouldSummarizeOverflow()).toBe(true);
34
+ });
35
+
36
+ it('summarizes immediately when no pruning budget is configured', () => {
37
+ const context = AgentContext.fromConfig(
38
+ {
39
+ agentId: 'overflow-agent',
40
+ provider: Providers.ANTHROPIC,
41
+ instructions: 'Test instructions',
42
+ summarizationEnabled: true,
43
+ } as Partial<t.AgentInputs> as t.AgentInputs,
44
+ () => 1
45
+ );
46
+
47
+ expect(context.shouldSummarizeOverflow()).toBe(true);
48
+ });
49
+
50
+ it('stages deterministic pruning before summarization when a counter exists', () => {
51
+ const context = AgentContext.fromConfig(
52
+ {
53
+ agentId: 'overflow-agent',
54
+ provider: Providers.ANTHROPIC,
55
+ instructions: 'Test instructions',
56
+ maxContextTokens: 1_000_000,
57
+ summarizationEnabled: true,
58
+ } as Partial<t.AgentInputs> as t.AgentInputs,
59
+ () => 1
60
+ );
61
+
62
+ expect(context.shouldSummarizeOverflow()).toBe(false);
63
+ context.applyContextBudgetCorrection(190_000, 274_468);
64
+ expect(context.shouldSummarizeOverflow()).toBe(true);
65
+ });
66
+
67
+ it('preserves the earliest full tool output when masking records collide', () => {
68
+ const context = createContext(1_000_000);
69
+ context.preserveOriginalToolContent(
70
+ new Map([
71
+ [2, 'full output'],
72
+ [4, 'another output'],
73
+ ])
74
+ );
75
+ context.preserveOriginalToolContent(
76
+ new Map([
77
+ [2, 'truncated placeholder'],
78
+ [6, 'new output'],
79
+ ])
80
+ );
81
+
82
+ expect(context.pendingOriginalToolContent).toEqual(
83
+ new Map([
84
+ [2, 'full output'],
85
+ [4, 'another output'],
86
+ [6, 'new output'],
87
+ ])
88
+ );
89
+ });
90
+
91
+ it('releases index-keyed tool output snapshots on reset', () => {
92
+ const context = createContext(1_000_000);
93
+ context.preserveOriginalToolContent(new Map([[2, 'full output']]));
94
+ context.reset();
95
+
96
+ expect(context.pendingOriginalToolContent).toBeUndefined();
97
+ });
98
+
99
+ it('preserves tool output snapshots when checkpointed messages survive reset', () => {
100
+ const context = createContext(1_000_000);
101
+ context.preserveOriginalToolContent(new Map([[2, 'full output']]));
102
+ context.reset({ preserveOriginalToolContent: true });
103
+
104
+ expect(context.pendingOriginalToolContent).toEqual(
105
+ new Map([[2, 'full output']])
106
+ );
107
+ });
108
+
109
+ it('restores the pre-correction budget on reset', () => {
110
+ const context = createContext(1_000_000);
111
+ context.applyContextBudgetCorrection(190_000, 274_468);
112
+ context.applyContextBudgetCorrection(133_000, 180_000);
113
+ context.reset();
114
+
115
+ expect(context.maxContextTokens).toBe(1_000_000);
116
+ expect(context.overflowRecoveryAttempts).toBe(0);
117
+ });
118
+
119
+ it('leaves an untouched budget alone on reset', () => {
120
+ const context = createContext(1_000_000);
121
+ context.maxContextTokens = 500_000;
122
+ context.reset();
123
+
124
+ expect(context.maxContextTokens).toBe(500_000);
125
+ });
126
+
127
+ it('keeps fallback calibration out of the primary agent context', () => {
128
+ const context = createContext(1_000_000);
129
+ context.calibrationRatio = 1.5;
130
+
131
+ context.applyObservedOverflowCalibration(Providers.VERTEXAI, 2);
132
+ expect(context.calibrationRatio).toBe(1.5);
133
+
134
+ context.applyObservedOverflowCalibration(Providers.ANTHROPIC, 2);
135
+ expect(context.calibrationRatio).toBe(2);
136
+ });
137
+
138
+ it('clamps provider-observed calibration to the shared safe range', () => {
139
+ const context = createContext(1_000_000);
140
+
141
+ context.applyObservedOverflowCalibration(Providers.ANTHROPIC, 10);
142
+ expect(context.calibrationRatio).toBe(5);
143
+
144
+ context.applyObservedOverflowCalibration(Providers.ANTHROPIC, 0.1);
145
+ expect(context.calibrationRatio).toBe(0.5);
146
+ });
147
+
148
+ it('records a summary-only recovery without inventing a token budget', () => {
149
+ const context = createContext();
150
+ context.applyContextBudgetCorrection(undefined, undefined);
151
+
152
+ expect(context.maxContextTokens).toBeUndefined();
153
+ expect(context.overflowRecoveryAttempts).toBe(1);
154
+ });
155
+
156
+ it('reports a stall when the prompt did not shrink', () => {
157
+ const context = createContext(1_000_000);
158
+ context.applyContextBudgetCorrection(190_000, 250_000);
159
+
160
+ expect(context.overflowRecoveryStalled(250_000)).toBe(true);
161
+ expect(context.overflowRecoveryStalled(260_000)).toBe(true);
162
+ });
163
+
164
+ it('reports no stall while the prompt is still shrinking', () => {
165
+ const context = createContext(1_000_000);
166
+ context.applyContextBudgetCorrection(190_000, 250_000);
167
+
168
+ expect(context.overflowRecoveryStalled(180_000)).toBe(false);
169
+ });
170
+
171
+ it('compares stall measurements in uncalibrated token units', () => {
172
+ const context = createContext(1_000_000);
173
+ context.applyContextBudgetCorrection(190_000, 250_000);
174
+ context.calibrationRatio = 2;
175
+
176
+ expect(context.overflowRecoveryStalled(360_000)).toBe(false);
177
+ expect(context.overflowRecoveryStalled(500_000)).toBe(true);
178
+ });
179
+
180
+ it('leaves fixed instruction overhead out of calibration normalization', () => {
181
+ const context = createContext(1_000_000);
182
+ context.systemMessageTokens = 100_000;
183
+ context.applyContextBudgetCorrection(190_000, 250_000);
184
+ context.calibrationRatio = 0.5;
185
+
186
+ expect(context.overflowRecoveryStalled(160_000)).toBe(false);
187
+ expect(context.overflowRecoveryStalled(175_000)).toBe(true);
188
+ });
189
+
190
+ it('reports no stall before any correction, or without a measurement', () => {
191
+ const context = createContext(1_000_000);
192
+ expect(context.overflowRecoveryStalled(250_000)).toBe(false);
193
+
194
+ context.applyContextBudgetCorrection(190_000, 250_000);
195
+ expect(context.overflowRecoveryStalled(undefined)).toBe(false);
196
+ });
197
+
198
+ it('clears the stall measurement on reset', () => {
199
+ const context = createContext(1_000_000);
200
+ context.applyContextBudgetCorrection(190_000, 250_000);
201
+ context.reset();
202
+
203
+ expect(context.overflowRecoveryStalled(250_000)).toBe(false);
204
+ });
205
+ });
@@ -186,6 +186,8 @@ export enum Constants {
186
186
  WEB_SEARCH = 'web_search',
187
187
  CONTENT_AND_ARTIFACT = 'content_and_artifact',
188
188
  LC_TRANSFER_TO_ = 'lc_transfer_to_',
189
+ HANDOFF_PARALLEL_BATCH = '__handoff_parallel_batch',
190
+ HANDOFF_GROUP_ID = '__handoff_group_id',
189
191
  /** Delimiter for MCP tools: toolName_mcp_serverName */
190
192
  MCP_DELIMITER = '_mcp_',
191
193
  /** Anthropic server tool ID prefix (web_search, code_execution, etc.) */