@juspay/neurolink 10.10.2 → 10.10.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.
@@ -7,6 +7,22 @@
7
7
  * This runs BEFORE every LLM call to prevent context overflow.
8
8
  */
9
9
  import type { BudgetCheckResult, BudgetCheckParams } from "../types/index.js";
10
+ /**
11
+ * Tokens the CONVERSATION HISTORY may occupy, i.e. the model's available input
12
+ * space minus everything that rides alongside it (system prompt, current
13
+ * prompt, tool definitions, file attachments).
14
+ *
15
+ * This is the number the compactor must target. Passing it the undeducted
16
+ * `availableInputTokens` made every stage gate compare history-only tokens
17
+ * against the WHOLE budget, so compaction only engaged once history alone
18
+ * exceeded the entire window — with a large MCP tool set a request could sit
19
+ * far over budget while the compactor reported "nothing to do" and fell through
20
+ * to emergency truncation.
21
+ *
22
+ * Returns 0 when the fixed overhead already exceeds the window; callers must
23
+ * treat that as unrecoverable rather than compacting to an empty history.
24
+ */
25
+ export declare function resolveHistoryBudget(result: BudgetCheckResult): number;
10
26
  /**
11
27
  * Check whether a request fits within the model's context budget.
12
28
  *
@@ -12,8 +12,40 @@ import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../
12
12
  import { getActiveTraceContext } from "../telemetry/traceContext.js";
13
13
  /** Default compaction threshold (80% of available input) */
14
14
  const DEFAULT_COMPACTION_THRESHOLD = 0.8;
15
+ /**
16
+ * Fraction of the derived history budget actually handed to the compactor.
17
+ * Estimation is char-based and approximate, so the compactor aims slightly
18
+ * under the true ceiling rather than exactly at it.
19
+ */
20
+ const HISTORY_BUDGET_SAFETY_FACTOR = 0.95;
15
21
  /** Estimated tokens per tool definition */
16
22
  const TOKENS_PER_TOOL_DEFINITION = 200;
23
+ /**
24
+ * Tokens the CONVERSATION HISTORY may occupy, i.e. the model's available input
25
+ * space minus everything that rides alongside it (system prompt, current
26
+ * prompt, tool definitions, file attachments).
27
+ *
28
+ * This is the number the compactor must target. Passing it the undeducted
29
+ * `availableInputTokens` made every stage gate compare history-only tokens
30
+ * against the WHOLE budget, so compaction only engaged once history alone
31
+ * exceeded the entire window — with a large MCP tool set a request could sit
32
+ * far over budget while the compactor reported "nothing to do" and fell through
33
+ * to emergency truncation.
34
+ *
35
+ * Returns 0 when the fixed overhead already exceeds the window; callers must
36
+ * treat that as unrecoverable rather than compacting to an empty history.
37
+ */
38
+ export function resolveHistoryBudget(result) {
39
+ const { availableInputTokens, breakdown } = result;
40
+ if (!breakdown) {
41
+ return Math.max(0, Math.floor(availableInputTokens * HISTORY_BUDGET_SAFETY_FACTOR));
42
+ }
43
+ const overhead = breakdown.systemPrompt +
44
+ breakdown.currentPrompt +
45
+ breakdown.toolDefinitions +
46
+ breakdown.fileAttachments;
47
+ return Math.max(0, Math.floor((availableInputTokens - overhead) * HISTORY_BUDGET_SAFETY_FACTOR));
48
+ }
17
49
  /**
18
50
  * Check whether a request fits within the model's context budget.
19
51
  *
@@ -46,6 +46,22 @@ const TOKENS_PER_TOOL_DEFINITION = 200;
46
46
  const MAX_CALIBRATION_RATIO = 3;
47
47
  /** Messages at the end of the conversation the guard never modifies. */
48
48
  const PROTECTED_TAIL_MESSAGES = 4;
49
+ /**
50
+ * Fraction of the context window the guard reclaims DOWN TO once it fires.
51
+ *
52
+ * The high-water mark (`thresholdRatio`) decides *when* to act; this low-water
53
+ * mark decides *how far*. Reclaiming only back to the threshold meant the very
54
+ * next step — which appends an assistant turn plus its tool results — crossed
55
+ * it again, so the guard mutated the message prefix on every single step of a
56
+ * long agentic run. Each of those mutations invalidates the Anthropic
57
+ * `cache_control` prefix from the edit point onward (see
58
+ * anthropicCacheBreakpoints), turning a ~0.1x cached read into full-price
59
+ * input every step.
60
+ *
61
+ * One deeper reclaim every N steps saves the same tokens and leaves the prefix
62
+ * stable in between, which is what makes the cache worth having.
63
+ */
64
+ const CONTEXT_GUARD_LOW_WATER_RATIO = 0.6;
49
65
  /** Stage-1 preview budget for an old tool output (bytes). */
50
66
  const OLD_TOOL_OUTPUT_PREVIEW_BYTES = 2_048;
51
67
  /** Stage-1 preview budget for an old tool output (lines). */
@@ -295,14 +311,22 @@ export function createStepBudgetGuard(config) {
295
311
  lastRawEstimate = rawEstimate;
296
312
  return undefined;
297
313
  }
314
+ // Reclaim down to the LOW-WATER mark, not merely back under the threshold.
315
+ // See CONTEXT_GUARD_LOW_WATER_RATIO: stopping at the threshold guaranteed
316
+ // the next step crossed it again, mutating the cached prefix every step.
317
+ const lowWaterTokens = Math.floor((availableInput * CONTEXT_GUARD_LOW_WATER_RATIO) / calibration);
298
318
  // Stage 1: shrink old tool outputs to previews.
299
319
  const stage1 = truncateOldToolOutputs([...messages]);
300
320
  let compacted = stage1.messages;
301
321
  let newEstimate = overheadTokens + estimateStepMessagesTokens(compacted, provider);
302
- // Stage 2: drop oldest complete tool exchanges if still over.
322
+ // Stage 2: drop oldest complete tool exchanges until under the low-water
323
+ // mark. Stage order is deliberately unchanged — truncating first preserves
324
+ // a preview of each output, and since BOTH stages edit the oldest messages
325
+ // the cache prefix is invalidated at roughly the same point either way.
326
+ // Frequency, not stage order, is what governs cache retention here.
303
327
  let droppedExchanges = 0;
304
- if (newEstimate > effectiveThreshold) {
305
- const stage2 = dropOldestToolExchanges(compacted, effectiveThreshold, overheadTokens, provider);
328
+ if (newEstimate > lowWaterTokens) {
329
+ const stage2 = dropOldestToolExchanges(compacted, lowWaterTokens, overheadTokens, provider);
306
330
  compacted = stage2.messages;
307
331
  droppedExchanges = stage2.droppedExchanges;
308
332
  newEstimate =
@@ -317,8 +341,13 @@ export function createStepBudgetGuard(config) {
317
341
  model,
318
342
  estimatedTokens: rawEstimate,
319
343
  thresholdTokens: effectiveThreshold,
344
+ lowWaterTokens,
320
345
  calibration,
321
346
  afterTokens: newEstimate,
347
+ // Headroom reclaimed below the firing threshold. Roughly how many further
348
+ // steps can run before the guard mutates the prefix again — a value near
349
+ // zero means the cache is being invalidated every step.
350
+ headroomTokens: effectiveThreshold - newEstimate,
322
351
  toolOutputsTruncated: stage1.truncated,
323
352
  exchangesDropped: droppedExchanges,
324
353
  });
@@ -4,10 +4,10 @@
4
4
  * Extracted from ConversationMemoryManager and RedisConversationMemoryManager
5
5
  * to eliminate code duplication. Both managers delegate to this engine.
6
6
  */
7
- import { TokenUtils } from "../constants/tokens.js";
8
7
  import { buildContextFromPointer, generateSummary, } from "../utils/conversationMemory.js";
9
8
  import { RECENT_MESSAGES_RATIO } from "../config/conversationMemory.js";
10
9
  import { snapSplitToBatchBoundary } from "./toolPairRepair.js";
10
+ import { estimateMessageTokens, estimateMessagesTokens, } from "../utils/tokenEstimation.js";
11
11
  import { withSpan } from "../telemetry/withSpan.js";
12
12
  import { tracers } from "../telemetry/tracers.js";
13
13
  import { logger } from "../utils/logger.js";
@@ -122,9 +122,11 @@ export class SummarizationEngine {
122
122
  * @returns Estimated token count
123
123
  */
124
124
  estimateTokens(messages) {
125
- return messages.reduce((total, msg) => {
126
- return total + TokenUtils.estimateTokenCount(msg.content);
127
- }, 0);
125
+ // Delegates to the shared estimator so this threshold agrees with the
126
+ // budget checker and the compactor. The previous content-only sum scored
127
+ // tool calls (whose payload lives in `args`) at zero, so a Write/Edit-heavy
128
+ // session never reached the summarization threshold at all.
129
+ return estimateMessagesTokens(messages);
128
130
  }
129
131
  /**
130
132
  * Find split index to keep recent messages within target token count.
@@ -137,7 +139,7 @@ export class SummarizationEngine {
137
139
  let recentTokens = 0;
138
140
  let splitIndex = messages.length;
139
141
  for (let i = messages.length - 1; i >= 0; i--) {
140
- const msgTokens = TokenUtils.estimateTokenCount(messages[i].content);
142
+ const msgTokens = estimateMessageTokens(messages[i]);
141
143
  if (recentTokens + msgTokens > targetRecentTokens) {
142
144
  splitIndex = i + 1;
143
145
  break;
@@ -7,6 +7,22 @@
7
7
  * This runs BEFORE every LLM call to prevent context overflow.
8
8
  */
9
9
  import type { BudgetCheckResult, BudgetCheckParams } from "../types/index.js";
10
+ /**
11
+ * Tokens the CONVERSATION HISTORY may occupy, i.e. the model's available input
12
+ * space minus everything that rides alongside it (system prompt, current
13
+ * prompt, tool definitions, file attachments).
14
+ *
15
+ * This is the number the compactor must target. Passing it the undeducted
16
+ * `availableInputTokens` made every stage gate compare history-only tokens
17
+ * against the WHOLE budget, so compaction only engaged once history alone
18
+ * exceeded the entire window — with a large MCP tool set a request could sit
19
+ * far over budget while the compactor reported "nothing to do" and fell through
20
+ * to emergency truncation.
21
+ *
22
+ * Returns 0 when the fixed overhead already exceeds the window; callers must
23
+ * treat that as unrecoverable rather than compacting to an empty history.
24
+ */
25
+ export declare function resolveHistoryBudget(result: BudgetCheckResult): number;
10
26
  /**
11
27
  * Check whether a request fits within the model's context budget.
12
28
  *
@@ -12,8 +12,40 @@ import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../
12
12
  import { getActiveTraceContext } from "../telemetry/traceContext.js";
13
13
  /** Default compaction threshold (80% of available input) */
14
14
  const DEFAULT_COMPACTION_THRESHOLD = 0.8;
15
+ /**
16
+ * Fraction of the derived history budget actually handed to the compactor.
17
+ * Estimation is char-based and approximate, so the compactor aims slightly
18
+ * under the true ceiling rather than exactly at it.
19
+ */
20
+ const HISTORY_BUDGET_SAFETY_FACTOR = 0.95;
15
21
  /** Estimated tokens per tool definition */
16
22
  const TOKENS_PER_TOOL_DEFINITION = 200;
23
+ /**
24
+ * Tokens the CONVERSATION HISTORY may occupy, i.e. the model's available input
25
+ * space minus everything that rides alongside it (system prompt, current
26
+ * prompt, tool definitions, file attachments).
27
+ *
28
+ * This is the number the compactor must target. Passing it the undeducted
29
+ * `availableInputTokens` made every stage gate compare history-only tokens
30
+ * against the WHOLE budget, so compaction only engaged once history alone
31
+ * exceeded the entire window — with a large MCP tool set a request could sit
32
+ * far over budget while the compactor reported "nothing to do" and fell through
33
+ * to emergency truncation.
34
+ *
35
+ * Returns 0 when the fixed overhead already exceeds the window; callers must
36
+ * treat that as unrecoverable rather than compacting to an empty history.
37
+ */
38
+ export function resolveHistoryBudget(result) {
39
+ const { availableInputTokens, breakdown } = result;
40
+ if (!breakdown) {
41
+ return Math.max(0, Math.floor(availableInputTokens * HISTORY_BUDGET_SAFETY_FACTOR));
42
+ }
43
+ const overhead = breakdown.systemPrompt +
44
+ breakdown.currentPrompt +
45
+ breakdown.toolDefinitions +
46
+ breakdown.fileAttachments;
47
+ return Math.max(0, Math.floor((availableInputTokens - overhead) * HISTORY_BUDGET_SAFETY_FACTOR));
48
+ }
17
49
  /**
18
50
  * Check whether a request fits within the model's context budget.
19
51
  *
@@ -46,6 +46,22 @@ const TOKENS_PER_TOOL_DEFINITION = 200;
46
46
  const MAX_CALIBRATION_RATIO = 3;
47
47
  /** Messages at the end of the conversation the guard never modifies. */
48
48
  const PROTECTED_TAIL_MESSAGES = 4;
49
+ /**
50
+ * Fraction of the context window the guard reclaims DOWN TO once it fires.
51
+ *
52
+ * The high-water mark (`thresholdRatio`) decides *when* to act; this low-water
53
+ * mark decides *how far*. Reclaiming only back to the threshold meant the very
54
+ * next step — which appends an assistant turn plus its tool results — crossed
55
+ * it again, so the guard mutated the message prefix on every single step of a
56
+ * long agentic run. Each of those mutations invalidates the Anthropic
57
+ * `cache_control` prefix from the edit point onward (see
58
+ * anthropicCacheBreakpoints), turning a ~0.1x cached read into full-price
59
+ * input every step.
60
+ *
61
+ * One deeper reclaim every N steps saves the same tokens and leaves the prefix
62
+ * stable in between, which is what makes the cache worth having.
63
+ */
64
+ const CONTEXT_GUARD_LOW_WATER_RATIO = 0.6;
49
65
  /** Stage-1 preview budget for an old tool output (bytes). */
50
66
  const OLD_TOOL_OUTPUT_PREVIEW_BYTES = 2_048;
51
67
  /** Stage-1 preview budget for an old tool output (lines). */
@@ -295,14 +311,22 @@ export function createStepBudgetGuard(config) {
295
311
  lastRawEstimate = rawEstimate;
296
312
  return undefined;
297
313
  }
314
+ // Reclaim down to the LOW-WATER mark, not merely back under the threshold.
315
+ // See CONTEXT_GUARD_LOW_WATER_RATIO: stopping at the threshold guaranteed
316
+ // the next step crossed it again, mutating the cached prefix every step.
317
+ const lowWaterTokens = Math.floor((availableInput * CONTEXT_GUARD_LOW_WATER_RATIO) / calibration);
298
318
  // Stage 1: shrink old tool outputs to previews.
299
319
  const stage1 = truncateOldToolOutputs([...messages]);
300
320
  let compacted = stage1.messages;
301
321
  let newEstimate = overheadTokens + estimateStepMessagesTokens(compacted, provider);
302
- // Stage 2: drop oldest complete tool exchanges if still over.
322
+ // Stage 2: drop oldest complete tool exchanges until under the low-water
323
+ // mark. Stage order is deliberately unchanged — truncating first preserves
324
+ // a preview of each output, and since BOTH stages edit the oldest messages
325
+ // the cache prefix is invalidated at roughly the same point either way.
326
+ // Frequency, not stage order, is what governs cache retention here.
303
327
  let droppedExchanges = 0;
304
- if (newEstimate > effectiveThreshold) {
305
- const stage2 = dropOldestToolExchanges(compacted, effectiveThreshold, overheadTokens, provider);
328
+ if (newEstimate > lowWaterTokens) {
329
+ const stage2 = dropOldestToolExchanges(compacted, lowWaterTokens, overheadTokens, provider);
306
330
  compacted = stage2.messages;
307
331
  droppedExchanges = stage2.droppedExchanges;
308
332
  newEstimate =
@@ -317,8 +341,13 @@ export function createStepBudgetGuard(config) {
317
341
  model,
318
342
  estimatedTokens: rawEstimate,
319
343
  thresholdTokens: effectiveThreshold,
344
+ lowWaterTokens,
320
345
  calibration,
321
346
  afterTokens: newEstimate,
347
+ // Headroom reclaimed below the firing threshold. Roughly how many further
348
+ // steps can run before the guard mutates the prefix again — a value near
349
+ // zero means the cache is being invalidated every step.
350
+ headroomTokens: effectiveThreshold - newEstimate,
322
351
  toolOutputsTruncated: stage1.truncated,
323
352
  exchangesDropped: droppedExchanges,
324
353
  });
@@ -4,10 +4,10 @@
4
4
  * Extracted from ConversationMemoryManager and RedisConversationMemoryManager
5
5
  * to eliminate code duplication. Both managers delegate to this engine.
6
6
  */
7
- import { TokenUtils } from "../constants/tokens.js";
8
7
  import { buildContextFromPointer, generateSummary, } from "../utils/conversationMemory.js";
9
8
  import { RECENT_MESSAGES_RATIO } from "../config/conversationMemory.js";
10
9
  import { snapSplitToBatchBoundary } from "./toolPairRepair.js";
10
+ import { estimateMessageTokens, estimateMessagesTokens, } from "../utils/tokenEstimation.js";
11
11
  import { withSpan } from "../telemetry/withSpan.js";
12
12
  import { tracers } from "../telemetry/tracers.js";
13
13
  import { logger } from "../utils/logger.js";
@@ -122,9 +122,11 @@ export class SummarizationEngine {
122
122
  * @returns Estimated token count
123
123
  */
124
124
  estimateTokens(messages) {
125
- return messages.reduce((total, msg) => {
126
- return total + TokenUtils.estimateTokenCount(msg.content);
127
- }, 0);
125
+ // Delegates to the shared estimator so this threshold agrees with the
126
+ // budget checker and the compactor. The previous content-only sum scored
127
+ // tool calls (whose payload lives in `args`) at zero, so a Write/Edit-heavy
128
+ // session never reached the summarization threshold at all.
129
+ return estimateMessagesTokens(messages);
128
130
  }
129
131
  /**
130
132
  * Find split index to keep recent messages within target token count.
@@ -137,7 +139,7 @@ export class SummarizationEngine {
137
139
  let recentTokens = 0;
138
140
  let splitIndex = messages.length;
139
141
  for (let i = messages.length - 1; i >= 0; i--) {
140
- const msgTokens = TokenUtils.estimateTokenCount(messages[i].content);
142
+ const msgTokens = estimateMessageTokens(messages[i]);
141
143
  if (recentTokens + msgTokens > targetRecentTokens) {
142
144
  splitIndex = i + 1;
143
145
  break;
@@ -21,7 +21,7 @@ import { EventEmitter } from "events";
21
21
  import pLimit from "p-limit";
22
22
  import { ErrorCategory, ErrorSeverity } from "./constants/enums.js";
23
23
  import { CIRCUIT_BREAKER, CIRCUIT_BREAKER_RESET_MS, MEMORY_THRESHOLDS, NANOSECOND_TO_MS_DIVISOR, PERFORMANCE_THRESHOLDS, PROVIDER_TIMEOUTS, RETRY_ATTEMPTS, RETRY_DELAYS, TOOL_TIMEOUTS, } from "./constants/index.js";
24
- import { checkContextBudget } from "./context/budgetChecker.js";
24
+ import { checkContextBudget, resolveHistoryBudget, } from "./context/budgetChecker.js";
25
25
  import { ContextCompactor } from "./context/contextCompactor.js";
26
26
  import { InvalidToolInputError, NoSuchToolError, } from "./utils/generationErrors.js";
27
27
  import { emergencyContentTruncation } from "./context/emergencyTruncation.js";
@@ -4896,7 +4896,14 @@ Current user's request: ${currentInput}`;
4896
4896
  });
4897
4897
  const actualTokens = actualOverflow?.actualTokens ?? recoveryBudget.estimatedInputTokens;
4898
4898
  const budgetTokens = actualOverflow?.budgetTokens ?? recoveryBudget.availableInputTokens;
4899
- const compactionTarget = Math.floor(budgetTokens * 0.7);
4899
+ // Target the HISTORY's share, not the whole budget: the compactor's stage
4900
+ // gates measure messages only, so handing them the full figure lets an
4901
+ // over-budget request through untouched. The 0.7 factor stays on top as
4902
+ // recovery headroom — this path runs only after the provider has already
4903
+ // rejected the request once, so aiming well under is deliberate.
4904
+ const recoveryOverhead = (recoveryBudget.breakdown?.systemPrompt ?? 0) +
4905
+ (recoveryBudget.breakdown?.currentPrompt ?? 0);
4906
+ const compactionTarget = Math.max(0, Math.floor((budgetTokens - recoveryOverhead) * 0.7));
4900
4907
  const requiredReduction = actualTokens > 0
4901
4908
  ? (actualTokens - compactionTarget) / actualTokens
4902
4909
  : 0.5;
@@ -5534,13 +5541,14 @@ Current user's request: ${currentInput}`;
5534
5541
  availableTools,
5535
5542
  conversationMessages,
5536
5543
  availableInputTokens: budgetResult.availableInputTokens,
5544
+ historyBudget: resolveHistoryBudget(budgetResult),
5537
5545
  usageRatio: budgetResult.usageRatio,
5538
5546
  estimatedInputTokens: budgetResult.estimatedInputTokens,
5539
5547
  compactionSessionId,
5540
5548
  });
5541
5549
  }
5542
5550
  async compactMCPConversationForBudget(context) {
5543
- const { options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages, availableInputTokens, usageRatio, estimatedInputTokens, compactionSessionId, } = context;
5551
+ const { options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages, availableInputTokens, historyBudget, usageRatio, estimatedInputTokens, compactionSessionId, } = context;
5544
5552
  logger.info("[NeuroLink] Context budget exceeded, triggering auto-compaction", {
5545
5553
  usageRatio,
5546
5554
  estimatedTokens: estimatedInputTokens,
@@ -5552,7 +5560,21 @@ Current user's request: ${currentInput}`;
5552
5560
  ?.summarizationProvider,
5553
5561
  summarizationModel: this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
5554
5562
  });
5555
- const compactionResult = await compactor.compact(conversationMessages, availableInputTokens, this.conversationMemoryConfig?.conversationMemory, requestId);
5563
+ // Fixed overhead (system + prompt + tools + files) already exceeds the
5564
+ // window — no amount of history compaction can fit this request, and
5565
+ // compacting to an empty history would only hide the real cause.
5566
+ if (historyBudget <= 0) {
5567
+ throw new ContextBudgetExceededError(`Context exceeds model budget before any history is included. ` +
5568
+ `System prompt, current prompt and tool definitions alone require ` +
5569
+ `more than the ${availableInputTokens}-token input budget. ` +
5570
+ `Reduce the tool set or the prompt size.`, {
5571
+ estimatedTokens: estimatedInputTokens,
5572
+ availableTokens: availableInputTokens,
5573
+ stagesUsed: [],
5574
+ breakdown: {},
5575
+ });
5576
+ }
5577
+ const compactionResult = await compactor.compact(conversationMessages, historyBudget, this.conversationMemoryConfig?.conversationMemory, requestId);
5556
5578
  let compactedMessages = conversationMessages;
5557
5579
  if (compactionResult.compacted) {
5558
5580
  const repairedResult = repairToolPairs(compactionResult.messages);
@@ -5966,7 +5988,7 @@ Current user's request: ${currentInput}`;
5966
5988
  summarizationModel: this.conversationMemoryConfig?.conversationMemory
5967
5989
  ?.summarizationModel,
5968
5990
  });
5969
- const compactionResult = await compactor.compact(conversationMessages, budgetCheck.availableInputTokens, this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
5991
+ const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(budgetCheck), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
5970
5992
  if (compactionResult.compacted) {
5971
5993
  const repairedResult = repairToolPairs(compactionResult.messages);
5972
5994
  conversationMessages = repairedResult.messages;
@@ -8201,7 +8223,7 @@ Current user's request: ${currentInput}`;
8201
8223
  ?.summarizationProvider,
8202
8224
  summarizationModel: this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
8203
8225
  });
8204
- const compactionResult = await compactor.compact(conversationMessages, streamBudget.availableInputTokens, this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
8226
+ const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(streamBudget), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
8205
8227
  if (compactionResult.compacted) {
8206
8228
  const repairedResult = repairToolPairs(compactionResult.messages);
8207
8229
  conversationMessages = repairedResult.messages;
@@ -50,6 +50,18 @@ export declare function estimateTokens(text: string, provider?: string, isCode?:
50
50
  /**
51
51
  * Estimate token count for a single ChatMessage.
52
52
  * Includes message framing overhead.
53
+ *
54
+ * Counts `content` AND `args`. A `tool_call` is persisted with an EMPTY
55
+ * `content` and its entire payload in `args` (see flushPendingToolData),
56
+ * so a content-only estimate scored a 39 KB Write call at ~28 tokens against a
57
+ * real cost near 9,750 — the budget checker, the compaction trigger and the
58
+ * summarization threshold were all blind to the single largest source of
59
+ * context growth in an agentic session.
60
+ *
61
+ * `result` is deliberately NOT counted: `result.result` is re-hydrated FROM
62
+ * `content` at read time (redisConversationMemoryManager), so counting both
63
+ * double-bills the same bytes. Only `result.error`, which has no counterpart in
64
+ * `content`, is included.
53
65
  */
54
66
  export declare function estimateMessageTokens(message: ChatMessage | {
55
67
  role: string;
@@ -33,6 +33,12 @@ export const TOKENS_PER_MESSAGE = 4;
33
33
  export const TOKENS_PER_CONVERSATION = 24;
34
34
  /** Image token estimate (flat) */
35
35
  export const IMAGE_TOKEN_ESTIMATE = 1_024;
36
+ /**
37
+ * Chars charged for a value that cannot be serialized for estimation (V8 max
38
+ * string length). Deliberately large: such a value is enormous by definition,
39
+ * and under-charging it would defeat the budget check it feeds.
40
+ */
41
+ const OVERSIZED_VALUE_FALLBACK_CHARS = 200_000;
36
42
  /**
37
43
  * Per-provider token multipliers.
38
44
  * Applied on top of the base GPT-style character estimate.
@@ -80,9 +86,39 @@ export function estimateTokens(text, provider, isCode) {
80
86
  const safetyBuffer = baseTokens * TOKEN_SAFETY_MARGIN_ADDITIVE;
81
87
  return Math.ceil(providerAdjusted + safetyBuffer);
82
88
  }
89
+ /**
90
+ * Serialize an arbitrary value for estimation. Never throws: `JSON.stringify`
91
+ * raises RangeError once a value exceeds V8's max string length, and a tool
92
+ * argument blob is exactly the shape that gets there. A payload that large is
93
+ * charged at the fallback size rather than aborting the estimate (and with it
94
+ * the whole turn).
95
+ */
96
+ function serializeForEstimate(value) {
97
+ if (typeof value === "string") {
98
+ return value;
99
+ }
100
+ try {
101
+ return JSON.stringify(value) ?? "";
102
+ }
103
+ catch {
104
+ return "x".repeat(OVERSIZED_VALUE_FALLBACK_CHARS);
105
+ }
106
+ }
83
107
  /**
84
108
  * Estimate token count for a single ChatMessage.
85
109
  * Includes message framing overhead.
110
+ *
111
+ * Counts `content` AND `args`. A `tool_call` is persisted with an EMPTY
112
+ * `content` and its entire payload in `args` (see flushPendingToolData),
113
+ * so a content-only estimate scored a 39 KB Write call at ~28 tokens against a
114
+ * real cost near 9,750 — the budget checker, the compaction trigger and the
115
+ * summarization threshold were all blind to the single largest source of
116
+ * context growth in an agentic session.
117
+ *
118
+ * `result` is deliberately NOT counted: `result.result` is re-hydrated FROM
119
+ * `content` at read time (redisConversationMemoryManager), so counting both
120
+ * double-bills the same bytes. Only `result.error`, which has no counterpart in
121
+ * `content`, is included.
86
122
  */
87
123
  export function estimateMessageTokens(message, provider) {
88
124
  let contentStr = "";
@@ -100,8 +136,16 @@ export function estimateMessageTokens(message, provider) {
100
136
  }
101
137
  }
102
138
  }
103
- const contentTokens = estimateTokens(contentStr, provider);
104
- return contentTokens + TOKENS_PER_MESSAGE;
139
+ let total = estimateTokens(contentStr, provider) + TOKENS_PER_MESSAGE;
140
+ const args = message.args;
141
+ if (args) {
142
+ total += estimateTokens(serializeForEstimate(args), provider);
143
+ }
144
+ const resultError = message.result?.error;
145
+ if (resultError) {
146
+ total += estimateTokens(serializeForEstimate(resultError), provider);
147
+ }
148
+ return total;
105
149
  }
106
150
  /**
107
151
  * Estimate total token count for an array of messages.
package/dist/neurolink.js CHANGED
@@ -21,7 +21,7 @@ import { EventEmitter } from "events";
21
21
  import pLimit from "p-limit";
22
22
  import { ErrorCategory, ErrorSeverity } from "./constants/enums.js";
23
23
  import { CIRCUIT_BREAKER, CIRCUIT_BREAKER_RESET_MS, MEMORY_THRESHOLDS, NANOSECOND_TO_MS_DIVISOR, PERFORMANCE_THRESHOLDS, PROVIDER_TIMEOUTS, RETRY_ATTEMPTS, RETRY_DELAYS, TOOL_TIMEOUTS, } from "./constants/index.js";
24
- import { checkContextBudget } from "./context/budgetChecker.js";
24
+ import { checkContextBudget, resolveHistoryBudget, } from "./context/budgetChecker.js";
25
25
  import { ContextCompactor } from "./context/contextCompactor.js";
26
26
  import { InvalidToolInputError, NoSuchToolError, } from "./utils/generationErrors.js";
27
27
  import { emergencyContentTruncation } from "./context/emergencyTruncation.js";
@@ -4896,7 +4896,14 @@ Current user's request: ${currentInput}`;
4896
4896
  });
4897
4897
  const actualTokens = actualOverflow?.actualTokens ?? recoveryBudget.estimatedInputTokens;
4898
4898
  const budgetTokens = actualOverflow?.budgetTokens ?? recoveryBudget.availableInputTokens;
4899
- const compactionTarget = Math.floor(budgetTokens * 0.7);
4899
+ // Target the HISTORY's share, not the whole budget: the compactor's stage
4900
+ // gates measure messages only, so handing them the full figure lets an
4901
+ // over-budget request through untouched. The 0.7 factor stays on top as
4902
+ // recovery headroom — this path runs only after the provider has already
4903
+ // rejected the request once, so aiming well under is deliberate.
4904
+ const recoveryOverhead = (recoveryBudget.breakdown?.systemPrompt ?? 0) +
4905
+ (recoveryBudget.breakdown?.currentPrompt ?? 0);
4906
+ const compactionTarget = Math.max(0, Math.floor((budgetTokens - recoveryOverhead) * 0.7));
4900
4907
  const requiredReduction = actualTokens > 0
4901
4908
  ? (actualTokens - compactionTarget) / actualTokens
4902
4909
  : 0.5;
@@ -5534,13 +5541,14 @@ Current user's request: ${currentInput}`;
5534
5541
  availableTools,
5535
5542
  conversationMessages,
5536
5543
  availableInputTokens: budgetResult.availableInputTokens,
5544
+ historyBudget: resolveHistoryBudget(budgetResult),
5537
5545
  usageRatio: budgetResult.usageRatio,
5538
5546
  estimatedInputTokens: budgetResult.estimatedInputTokens,
5539
5547
  compactionSessionId,
5540
5548
  });
5541
5549
  }
5542
5550
  async compactMCPConversationForBudget(context) {
5543
- const { options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages, availableInputTokens, usageRatio, estimatedInputTokens, compactionSessionId, } = context;
5551
+ const { options, requestId, providerName, enhancedSystemPrompt, availableTools, conversationMessages, availableInputTokens, historyBudget, usageRatio, estimatedInputTokens, compactionSessionId, } = context;
5544
5552
  logger.info("[NeuroLink] Context budget exceeded, triggering auto-compaction", {
5545
5553
  usageRatio,
5546
5554
  estimatedTokens: estimatedInputTokens,
@@ -5552,7 +5560,21 @@ Current user's request: ${currentInput}`;
5552
5560
  ?.summarizationProvider,
5553
5561
  summarizationModel: this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
5554
5562
  });
5555
- const compactionResult = await compactor.compact(conversationMessages, availableInputTokens, this.conversationMemoryConfig?.conversationMemory, requestId);
5563
+ // Fixed overhead (system + prompt + tools + files) already exceeds the
5564
+ // window — no amount of history compaction can fit this request, and
5565
+ // compacting to an empty history would only hide the real cause.
5566
+ if (historyBudget <= 0) {
5567
+ throw new ContextBudgetExceededError(`Context exceeds model budget before any history is included. ` +
5568
+ `System prompt, current prompt and tool definitions alone require ` +
5569
+ `more than the ${availableInputTokens}-token input budget. ` +
5570
+ `Reduce the tool set or the prompt size.`, {
5571
+ estimatedTokens: estimatedInputTokens,
5572
+ availableTokens: availableInputTokens,
5573
+ stagesUsed: [],
5574
+ breakdown: {},
5575
+ });
5576
+ }
5577
+ const compactionResult = await compactor.compact(conversationMessages, historyBudget, this.conversationMemoryConfig?.conversationMemory, requestId);
5556
5578
  let compactedMessages = conversationMessages;
5557
5579
  if (compactionResult.compacted) {
5558
5580
  const repairedResult = repairToolPairs(compactionResult.messages);
@@ -5966,7 +5988,7 @@ Current user's request: ${currentInput}`;
5966
5988
  summarizationModel: this.conversationMemoryConfig?.conversationMemory
5967
5989
  ?.summarizationModel,
5968
5990
  });
5969
- const compactionResult = await compactor.compact(conversationMessages, budgetCheck.availableInputTokens, this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
5991
+ const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(budgetCheck), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
5970
5992
  if (compactionResult.compacted) {
5971
5993
  const repairedResult = repairToolPairs(compactionResult.messages);
5972
5994
  conversationMessages = repairedResult.messages;
@@ -8201,7 +8223,7 @@ Current user's request: ${currentInput}`;
8201
8223
  ?.summarizationProvider,
8202
8224
  summarizationModel: this.conversationMemoryConfig?.conversationMemory?.summarizationModel,
8203
8225
  });
8204
- const compactionResult = await compactor.compact(conversationMessages, streamBudget.availableInputTokens, this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
8226
+ const compactionResult = await compactor.compact(conversationMessages, resolveHistoryBudget(streamBudget), this.conversationMemoryConfig?.conversationMemory, options.context?.requestId);
8205
8227
  if (compactionResult.compacted) {
8206
8228
  const repairedResult = repairToolPairs(compactionResult.messages);
8207
8229
  conversationMessages = repairedResult.messages;
@@ -50,6 +50,18 @@ export declare function estimateTokens(text: string, provider?: string, isCode?:
50
50
  /**
51
51
  * Estimate token count for a single ChatMessage.
52
52
  * Includes message framing overhead.
53
+ *
54
+ * Counts `content` AND `args`. A `tool_call` is persisted with an EMPTY
55
+ * `content` and its entire payload in `args` (see flushPendingToolData),
56
+ * so a content-only estimate scored a 39 KB Write call at ~28 tokens against a
57
+ * real cost near 9,750 — the budget checker, the compaction trigger and the
58
+ * summarization threshold were all blind to the single largest source of
59
+ * context growth in an agentic session.
60
+ *
61
+ * `result` is deliberately NOT counted: `result.result` is re-hydrated FROM
62
+ * `content` at read time (redisConversationMemoryManager), so counting both
63
+ * double-bills the same bytes. Only `result.error`, which has no counterpart in
64
+ * `content`, is included.
53
65
  */
54
66
  export declare function estimateMessageTokens(message: ChatMessage | {
55
67
  role: string;