@juspay/neurolink 10.10.2 → 10.10.3
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.
- package/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +355 -355
- package/dist/context/budgetChecker.d.ts +16 -0
- package/dist/context/budgetChecker.js +32 -0
- package/dist/context/summarizationEngine.js +7 -5
- package/dist/lib/context/budgetChecker.d.ts +16 -0
- package/dist/lib/context/budgetChecker.js +32 -0
- package/dist/lib/context/summarizationEngine.js +7 -5
- package/dist/lib/neurolink.js +28 -6
- package/dist/lib/utils/tokenEstimation.d.ts +12 -0
- package/dist/lib/utils/tokenEstimation.js +46 -2
- package/dist/neurolink.js +28 -6
- package/dist/utils/tokenEstimation.d.ts +12 -0
- package/dist/utils/tokenEstimation.js +46 -2
- package/package.json +2 -1
|
@@ -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
|
*
|
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
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
|
*
|
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
142
|
+
const msgTokens = estimateMessageTokens(messages[i]);
|
|
141
143
|
if (recentTokens + msgTokens > targetRecentTokens) {
|
|
142
144
|
splitIndex = i + 1;
|
|
143
145
|
break;
|
package/dist/lib/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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 flushPendingToolExecutions),
|
|
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 flushPendingToolExecutions),
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 flushPendingToolExecutions),
|
|
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 flushPendingToolExecutions),
|
|
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
|
-
|
|
104
|
-
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "10.10.
|
|
3
|
+
"version": "10.10.3",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"test:litellm-parity": "npx tsx test/continuous-test-suite-litellm-parity.ts",
|
|
84
84
|
"test:memory": "npx tsx test/continuous-test-suite-memory.ts",
|
|
85
85
|
"test:tool-pairing": "npx tsx test/continuous-test-suite-tool-pairing.ts",
|
|
86
|
+
"test:token-accounting": "npx tsx test/continuous-test-suite-token-accounting.ts",
|
|
86
87
|
"test:middleware": "npx tsx test/continuous-test-suite-middleware.ts",
|
|
87
88
|
"test:observability": "npx tsx test/continuous-test-suite-observability.ts",
|
|
88
89
|
"test:ppt": "npx tsx test/continuous-test-suite-ppt.ts",
|