@juspay/neurolink 10.2.2 → 10.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +371 -371
- package/dist/context/stepBudgetGuard.d.ts +47 -0
- package/dist/context/stepBudgetGuard.js +289 -0
- package/dist/core/modules/GenerationHandler.js +53 -5
- package/dist/lib/context/stepBudgetGuard.d.ts +47 -0
- package/dist/lib/context/stepBudgetGuard.js +290 -0
- package/dist/lib/core/modules/GenerationHandler.js +53 -5
- package/dist/lib/types/context.d.ts +22 -0
- package/dist/types/context.d.ts +22 -0
- package/package.json +4 -3
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-step context budget guard for the AI-SDK agent loop.
|
|
3
|
+
*
|
|
4
|
+
* Pre-call budgeting (`checkContextBudget` + compaction) runs ONCE before
|
|
5
|
+
* dispatch and only sees the input/session conversation. The AI-SDK tool loop
|
|
6
|
+
* then appends assistant turns and tool results on every step — growth the
|
|
7
|
+
* pre-call pipeline never sees, which is how long agentic runs overflow the
|
|
8
|
+
* model's real context window mid-loop (provider 400s after dozens of tool
|
|
9
|
+
* calls). The googleVertex native loops already guard this via
|
|
10
|
+
* `createContextGuard`; this module brings the AI-SDK path (every provider
|
|
11
|
+
* that delegates to `generateText`) to parity — and goes one step further:
|
|
12
|
+
* instead of stopping the loop, it deterministically reclaims budget so the
|
|
13
|
+
* loop can CONTINUE.
|
|
14
|
+
*
|
|
15
|
+
* Wired in `GenerationHandler.callGenerateText` through
|
|
16
|
+
* `experimental_prepareStep`, whose result may replace the step's `messages`.
|
|
17
|
+
* The guard operates on `ModelMessage[]` natively (no lossy ChatMessage
|
|
18
|
+
* round-trip) and never makes LLM calls:
|
|
19
|
+
*
|
|
20
|
+
* Stage 1 — truncate OLD tool outputs to head/tail previews
|
|
21
|
+
* (`generateToolOutputPreview`), oldest first, outside the
|
|
22
|
+
* protected recent tail.
|
|
23
|
+
* Stage 2 — drop the oldest complete tool exchanges (assistant tool-call
|
|
24
|
+
* message + its following tool-result messages, as a unit, so
|
|
25
|
+
* call/result pairing stays intact), replacing them with a single
|
|
26
|
+
* elision note.
|
|
27
|
+
*
|
|
28
|
+
* The system prompt and tool definitions ride OUTSIDE the step messages (the
|
|
29
|
+
* handler hoists system into generateText's `system` option), so their cost is
|
|
30
|
+
* passed in as `fixedOverheadTokens`. The first user message (the task) and
|
|
31
|
+
* the most recent messages are never touched.
|
|
32
|
+
*/
|
|
33
|
+
import type { ModelMessage, StepBudgetGuardConfig } from "../types/index.js";
|
|
34
|
+
/** Estimate the token cost of a step's message array. */
|
|
35
|
+
export declare function estimateStepMessagesTokens(messages: readonly ModelMessage[], provider?: string): number;
|
|
36
|
+
/**
|
|
37
|
+
* Estimate the fixed per-request overhead: hoisted system prompt + tool
|
|
38
|
+
* definitions. Mirrors `checkContextBudget`'s categories for the pieces that
|
|
39
|
+
* do not live in the step messages.
|
|
40
|
+
*/
|
|
41
|
+
export declare function estimateFixedOverheadTokens(system: unknown, tools: Record<string, unknown> | undefined, provider?: string): number;
|
|
42
|
+
/**
|
|
43
|
+
* Create a per-step budget guard. Returns a function that, given the step's
|
|
44
|
+
* messages, returns a compacted replacement array when the projected request
|
|
45
|
+
* exceeds the threshold — or `undefined` when no change is needed.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createStepBudgetGuard(config: StepBudgetGuardConfig): (messages: readonly ModelMessage[]) => ModelMessage[] | undefined;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-step context budget guard for the AI-SDK agent loop.
|
|
3
|
+
*
|
|
4
|
+
* Pre-call budgeting (`checkContextBudget` + compaction) runs ONCE before
|
|
5
|
+
* dispatch and only sees the input/session conversation. The AI-SDK tool loop
|
|
6
|
+
* then appends assistant turns and tool results on every step — growth the
|
|
7
|
+
* pre-call pipeline never sees, which is how long agentic runs overflow the
|
|
8
|
+
* model's real context window mid-loop (provider 400s after dozens of tool
|
|
9
|
+
* calls). The googleVertex native loops already guard this via
|
|
10
|
+
* `createContextGuard`; this module brings the AI-SDK path (every provider
|
|
11
|
+
* that delegates to `generateText`) to parity — and goes one step further:
|
|
12
|
+
* instead of stopping the loop, it deterministically reclaims budget so the
|
|
13
|
+
* loop can CONTINUE.
|
|
14
|
+
*
|
|
15
|
+
* Wired in `GenerationHandler.callGenerateText` through
|
|
16
|
+
* `experimental_prepareStep`, whose result may replace the step's `messages`.
|
|
17
|
+
* The guard operates on `ModelMessage[]` natively (no lossy ChatMessage
|
|
18
|
+
* round-trip) and never makes LLM calls:
|
|
19
|
+
*
|
|
20
|
+
* Stage 1 — truncate OLD tool outputs to head/tail previews
|
|
21
|
+
* (`generateToolOutputPreview`), oldest first, outside the
|
|
22
|
+
* protected recent tail.
|
|
23
|
+
* Stage 2 — drop the oldest complete tool exchanges (assistant tool-call
|
|
24
|
+
* message + its following tool-result messages, as a unit, so
|
|
25
|
+
* call/result pairing stays intact), replacing them with a single
|
|
26
|
+
* elision note.
|
|
27
|
+
*
|
|
28
|
+
* The system prompt and tool definitions ride OUTSIDE the step messages (the
|
|
29
|
+
* handler hoists system into generateText's `system` option), so their cost is
|
|
30
|
+
* passed in as `fixedOverheadTokens`. The first user message (the task) and
|
|
31
|
+
* the most recent messages are never touched.
|
|
32
|
+
*/
|
|
33
|
+
import { DEFAULT_CONTEXT_GUARD_RATIO } from "../core/constants.js";
|
|
34
|
+
import { getAvailableInputTokens } from "../constants/contextWindows.js";
|
|
35
|
+
import { estimateTokens, TOKENS_PER_MESSAGE, } from "../utils/tokenEstimation.js";
|
|
36
|
+
import { generateToolOutputPreview } from "./toolOutputLimits.js";
|
|
37
|
+
import { logger } from "../utils/logger.js";
|
|
38
|
+
/** Estimated tokens for a tool definition that fails to serialize. */
|
|
39
|
+
const TOKENS_PER_TOOL_DEFINITION = 200;
|
|
40
|
+
/** Messages at the end of the conversation the guard never modifies. */
|
|
41
|
+
const PROTECTED_TAIL_MESSAGES = 4;
|
|
42
|
+
/** Stage-1 preview budget for an old tool output (bytes). */
|
|
43
|
+
const OLD_TOOL_OUTPUT_PREVIEW_BYTES = 2_048;
|
|
44
|
+
/** Stage-1 preview budget for an old tool output (lines). */
|
|
45
|
+
const OLD_TOOL_OUTPUT_PREVIEW_LINES = 60;
|
|
46
|
+
/**
|
|
47
|
+
* Serialize any ModelMessage content to text for estimation. Tool-call args
|
|
48
|
+
* and tool-result outputs are JSON-stringified; unserializable values fall
|
|
49
|
+
* back to a fixed-size placeholder so estimation never throws.
|
|
50
|
+
*/
|
|
51
|
+
function contentToText(content) {
|
|
52
|
+
if (typeof content === "string") {
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return JSON.stringify(content) ?? "";
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "x".repeat(TOKENS_PER_TOOL_DEFINITION * 4);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Estimate the token cost of a step's message array. */
|
|
63
|
+
export function estimateStepMessagesTokens(messages, provider) {
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (const message of messages) {
|
|
66
|
+
total +=
|
|
67
|
+
estimateTokens(contentToText(message.content), provider) +
|
|
68
|
+
TOKENS_PER_MESSAGE;
|
|
69
|
+
}
|
|
70
|
+
return total;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Estimate the fixed per-request overhead: hoisted system prompt + tool
|
|
74
|
+
* definitions. Mirrors `checkContextBudget`'s categories for the pieces that
|
|
75
|
+
* do not live in the step messages.
|
|
76
|
+
*/
|
|
77
|
+
export function estimateFixedOverheadTokens(system, tools, provider) {
|
|
78
|
+
let total = system
|
|
79
|
+
? estimateTokens(contentToText(system), provider) + TOKENS_PER_MESSAGE
|
|
80
|
+
: 0;
|
|
81
|
+
for (const tool of Object.values(tools ?? {})) {
|
|
82
|
+
try {
|
|
83
|
+
total += estimateTokens(JSON.stringify(tool) ?? "", provider);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
total += TOKENS_PER_TOOL_DEFINITION;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return total;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Serialize a ToolResultOutput to the text the MODEL should see in a preview.
|
|
93
|
+
* Variant-aware: `text`/`error-text` carry their payload in `.value` directly —
|
|
94
|
+
* stringifying the wrapper would put escaped `{"type":"text","value":…}` JSON
|
|
95
|
+
* in front of the model instead of the actual output. `json`/`error-json`/
|
|
96
|
+
* `content` serialize their value; unknown shapes fall back to the wrapper.
|
|
97
|
+
*/
|
|
98
|
+
function toolResultOutputToText(output) {
|
|
99
|
+
const variant = output;
|
|
100
|
+
if (variant && typeof variant === "object" && "type" in variant) {
|
|
101
|
+
if ((variant.type === "text" || variant.type === "error-text") &&
|
|
102
|
+
typeof variant.value === "string") {
|
|
103
|
+
return variant.value;
|
|
104
|
+
}
|
|
105
|
+
if (variant.type === "json" ||
|
|
106
|
+
variant.type === "error-json" ||
|
|
107
|
+
variant.type === "content") {
|
|
108
|
+
return contentToText(variant.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return contentToText(output);
|
|
112
|
+
}
|
|
113
|
+
/** True when the message is an assistant message that issues tool calls. */
|
|
114
|
+
function isToolCallAssistantMessage(message) {
|
|
115
|
+
return (message.role === "assistant" &&
|
|
116
|
+
Array.isArray(message.content) &&
|
|
117
|
+
message.content.some((part) => part?.type === "tool-call"));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Stage 1: replace large tool-result outputs outside the protected tail with
|
|
121
|
+
* head/tail previews. Returns the new array plus how many outputs shrank.
|
|
122
|
+
*/
|
|
123
|
+
function truncateOldToolOutputs(messages) {
|
|
124
|
+
const cutoff = Math.max(0, messages.length - PROTECTED_TAIL_MESSAGES);
|
|
125
|
+
let truncated = 0;
|
|
126
|
+
const next = messages.map((message, index) => {
|
|
127
|
+
if (index >= cutoff || message.role !== "tool") {
|
|
128
|
+
return message;
|
|
129
|
+
}
|
|
130
|
+
if (!Array.isArray(message.content)) {
|
|
131
|
+
return message;
|
|
132
|
+
}
|
|
133
|
+
let changed = false;
|
|
134
|
+
const content = message.content.map((part) => {
|
|
135
|
+
const resultPart = part;
|
|
136
|
+
if (resultPart?.type !== "tool-result") {
|
|
137
|
+
return part;
|
|
138
|
+
}
|
|
139
|
+
const serialized = toolResultOutputToText(resultPart.output);
|
|
140
|
+
if (serialized.length <= OLD_TOOL_OUTPUT_PREVIEW_BYTES) {
|
|
141
|
+
return part;
|
|
142
|
+
}
|
|
143
|
+
const { preview } = generateToolOutputPreview(serialized, {
|
|
144
|
+
maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES,
|
|
145
|
+
maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES,
|
|
146
|
+
});
|
|
147
|
+
changed = true;
|
|
148
|
+
truncated += 1;
|
|
149
|
+
return {
|
|
150
|
+
...resultPart,
|
|
151
|
+
output: { type: "text", value: preview },
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
return changed ? { ...message, content } : message;
|
|
155
|
+
});
|
|
156
|
+
return { messages: next, truncated };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Stage 2: drop the oldest complete tool exchanges — an assistant tool-call
|
|
160
|
+
* message together with ALL directly-following `tool` messages — until the
|
|
161
|
+
* estimate fits or only the protected head/tail remains. The first
|
|
162
|
+
* non-assistant message run (the task) is never dropped. A single elision
|
|
163
|
+
* note replaces everything removed so the model knows history was elided.
|
|
164
|
+
*/
|
|
165
|
+
function dropOldestToolExchanges(messages, budgetTokens, fixedOverheadTokens, provider) {
|
|
166
|
+
const result = [...messages];
|
|
167
|
+
let droppedExchanges = 0;
|
|
168
|
+
// Running-total accounting: estimate each message ONCE, keep the estimates
|
|
169
|
+
// array in lockstep with `result`, and subtract dropped blocks — instead of
|
|
170
|
+
// re-estimating the whole array on every iteration (O(n²) with many drops).
|
|
171
|
+
const estimates = result.map((message) => estimateTokens(contentToText(message.content), provider) +
|
|
172
|
+
TOKENS_PER_MESSAGE);
|
|
173
|
+
let currentTokens = fixedOverheadTokens + estimates.reduce((sum, tokens) => sum + tokens, 0);
|
|
174
|
+
while (currentTokens > budgetTokens) {
|
|
175
|
+
// Find the FIRST (oldest) droppable exchange outside the protected tail.
|
|
176
|
+
const tailStart = Math.max(0, result.length - PROTECTED_TAIL_MESSAGES);
|
|
177
|
+
let exchangeStart = -1;
|
|
178
|
+
for (let i = 0; i < tailStart; i++) {
|
|
179
|
+
if (isToolCallAssistantMessage(result[i])) {
|
|
180
|
+
exchangeStart = i;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (exchangeStart === -1) {
|
|
185
|
+
break; // nothing left the guard is allowed to drop
|
|
186
|
+
}
|
|
187
|
+
let exchangeEnd = exchangeStart + 1;
|
|
188
|
+
while (exchangeEnd < result.length && result[exchangeEnd].role === "tool") {
|
|
189
|
+
exchangeEnd++;
|
|
190
|
+
}
|
|
191
|
+
if (exchangeEnd > tailStart) {
|
|
192
|
+
// The oldest remaining exchange bleeds into the protected tail. Because
|
|
193
|
+
// the scan is oldest-first, every exchange after this one STARTS inside
|
|
194
|
+
// the tail (this one's result chain reaches it), and every exchange
|
|
195
|
+
// before it was already dropped by earlier iterations — so there is
|
|
196
|
+
// nothing else the guard may remove. Stop.
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
const dropped = estimates
|
|
200
|
+
.slice(exchangeStart, exchangeEnd)
|
|
201
|
+
.reduce((sum, tokens) => sum + tokens, 0);
|
|
202
|
+
result.splice(exchangeStart, exchangeEnd - exchangeStart);
|
|
203
|
+
estimates.splice(exchangeStart, exchangeEnd - exchangeStart);
|
|
204
|
+
currentTokens -= dropped;
|
|
205
|
+
droppedExchanges++;
|
|
206
|
+
}
|
|
207
|
+
if (droppedExchanges > 0) {
|
|
208
|
+
// Insert one elision note where history was removed: after the leading
|
|
209
|
+
// non-exchange messages (typically the first user/task message), but
|
|
210
|
+
// never after the protected tail — when every droppable exchange was
|
|
211
|
+
// removed, an uncapped scan would append the note at the END, where the
|
|
212
|
+
// "history was removed" cue lands after the content it refers to.
|
|
213
|
+
let noteIndex = 0;
|
|
214
|
+
while (noteIndex < result.length &&
|
|
215
|
+
!isToolCallAssistantMessage(result[noteIndex])) {
|
|
216
|
+
noteIndex++;
|
|
217
|
+
}
|
|
218
|
+
const tailBoundary = Math.max(0, result.length - PROTECTED_TAIL_MESSAGES);
|
|
219
|
+
result.splice(Math.min(noteIndex, tailBoundary), 0, {
|
|
220
|
+
role: "user",
|
|
221
|
+
content: [
|
|
222
|
+
{
|
|
223
|
+
type: "text",
|
|
224
|
+
text: `[context truncated: ${droppedExchanges} earlier tool exchange(s) were removed to fit the model's context window. Continue from the remaining context.]`,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return { messages: result, droppedExchanges };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Create a per-step budget guard. Returns a function that, given the step's
|
|
233
|
+
* messages, returns a compacted replacement array when the projected request
|
|
234
|
+
* exceeds the threshold — or `undefined` when no change is needed.
|
|
235
|
+
*/
|
|
236
|
+
export function createStepBudgetGuard(config) {
|
|
237
|
+
const { provider, model, maxTokens, fixedOverheadTokens = 0, getFixedOverheadTokens, thresholdRatio = DEFAULT_CONTEXT_GUARD_RATIO, } = config;
|
|
238
|
+
const availableInput = getAvailableInputTokens(provider, model, maxTokens);
|
|
239
|
+
const thresholdTokens = Math.floor(availableInput * thresholdRatio);
|
|
240
|
+
return function guardStepMessages(messages) {
|
|
241
|
+
// Resolve overhead per invocation: the tool set can GROW mid-loop
|
|
242
|
+
// (search_tools hydration adds discovered tools between steps), so a
|
|
243
|
+
// once-captured value would undercount later steps.
|
|
244
|
+
const overheadTokens = getFixedOverheadTokens?.() ?? fixedOverheadTokens;
|
|
245
|
+
const estimate = overheadTokens + estimateStepMessagesTokens(messages, provider);
|
|
246
|
+
// Logger Guard: per-step diagnostics for debugging why a long run does
|
|
247
|
+
// (or does not) trigger compaction — gated so nothing is serialized when
|
|
248
|
+
// debug logging is off.
|
|
249
|
+
if (logger.shouldLog("debug")) {
|
|
250
|
+
logger.debug("[StepBudgetGuard] step estimate", {
|
|
251
|
+
provider,
|
|
252
|
+
model,
|
|
253
|
+
messageCount: messages.length,
|
|
254
|
+
estimatedTokens: estimate,
|
|
255
|
+
thresholdTokens,
|
|
256
|
+
willCompact: estimate > thresholdTokens,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (estimate <= thresholdTokens) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
// Stage 1: shrink old tool outputs to previews.
|
|
263
|
+
const stage1 = truncateOldToolOutputs([...messages]);
|
|
264
|
+
let compacted = stage1.messages;
|
|
265
|
+
let newEstimate = overheadTokens + estimateStepMessagesTokens(compacted, provider);
|
|
266
|
+
// Stage 2: drop oldest complete tool exchanges if still over.
|
|
267
|
+
let droppedExchanges = 0;
|
|
268
|
+
if (newEstimate > thresholdTokens) {
|
|
269
|
+
const stage2 = dropOldestToolExchanges(compacted, thresholdTokens, overheadTokens, provider);
|
|
270
|
+
compacted = stage2.messages;
|
|
271
|
+
droppedExchanges = stage2.droppedExchanges;
|
|
272
|
+
newEstimate =
|
|
273
|
+
overheadTokens + estimateStepMessagesTokens(compacted, provider);
|
|
274
|
+
}
|
|
275
|
+
if (stage1.truncated === 0 && droppedExchanges === 0) {
|
|
276
|
+
return undefined; // nothing actionable (already all-protected)
|
|
277
|
+
}
|
|
278
|
+
logger.info("[StepBudgetGuard] Compacted agent-loop step messages", {
|
|
279
|
+
provider,
|
|
280
|
+
model,
|
|
281
|
+
estimatedTokens: estimate,
|
|
282
|
+
thresholdTokens,
|
|
283
|
+
afterTokens: newEstimate,
|
|
284
|
+
toolOutputsTruncated: stage1.truncated,
|
|
285
|
+
exchangesDropped: droppedExchanges,
|
|
286
|
+
});
|
|
287
|
+
return compacted;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -21,6 +21,7 @@ import { calculateCost } from "../../utils/pricing.js";
|
|
|
21
21
|
import { withProviderRetry } from "../../utils/providerRetry.js";
|
|
22
22
|
import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheReadTokens, extractTokenUsage, } from "../../utils/tokenUtils.js";
|
|
23
23
|
import { DEFAULT_MAX_STEPS } from "../constants.js";
|
|
24
|
+
import { createStepBudgetGuard, estimateFixedOverheadTokens, } from "../../context/stepBudgetGuard.js";
|
|
24
25
|
import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
|
|
25
26
|
import { coerceJsonToSchema } from "../../utils/json/coerce.js";
|
|
26
27
|
import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
|
|
@@ -116,6 +117,37 @@ export class GenerationHandler {
|
|
|
116
117
|
// rather than passing them inside `messages` (deprecated by the AI SDK,
|
|
117
118
|
// rejected in v7). See extractSystemMessages for the rationale. (#1024)
|
|
118
119
|
const { system, messages: nonSystemMessages } = extractSystemMessages(messages);
|
|
120
|
+
// Per-step context budget guard: the tool loop appends assistant turns and
|
|
121
|
+
// tool results on every step — growth the pre-call budget check never
|
|
122
|
+
// sees. Estimate each step's projected request and deterministically
|
|
123
|
+
// reclaim budget (truncate old tool outputs, then drop oldest exchanges)
|
|
124
|
+
// so long agentic runs cannot overflow the model's window mid-loop.
|
|
125
|
+
// Parity with the googleVertex native loops' createContextGuard, upgraded
|
|
126
|
+
// from stop-only to compact-and-continue. The caller's prepareStep result
|
|
127
|
+
// wins on conflicts; the guard only contributes `messages`.
|
|
128
|
+
//
|
|
129
|
+
// Overhead is resolved PER STEP because `toolsWithCache` is deliberately
|
|
130
|
+
// mutable (search_tools hydration adds discovered tools mid-loop) — a
|
|
131
|
+
// once-captured estimate would undercount later steps. Tools are only
|
|
132
|
+
// ever added, so memoizing on tool count keeps the common step O(1).
|
|
133
|
+
let cachedOverhead = { toolCount: -1, tokens: 0 };
|
|
134
|
+
const stepBudgetGuard = createStepBudgetGuard({
|
|
135
|
+
provider: this.providerName ?? "unknown",
|
|
136
|
+
model: this.modelName,
|
|
137
|
+
maxTokens: options.maxTokens,
|
|
138
|
+
getFixedOverheadTokens: () => {
|
|
139
|
+
const toolCount = shouldUseTools
|
|
140
|
+
? Object.keys(toolsWithCache).length
|
|
141
|
+
: 0;
|
|
142
|
+
if (toolCount !== cachedOverhead.toolCount) {
|
|
143
|
+
cachedOverhead = {
|
|
144
|
+
toolCount,
|
|
145
|
+
tokens: estimateFixedOverheadTokens(system, shouldUseTools ? toolsWithCache : undefined, this.providerName),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return cachedOverhead.tokens;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
119
151
|
return await generateText({
|
|
120
152
|
model,
|
|
121
153
|
...(system && { system }),
|
|
@@ -125,11 +157,27 @@ export class GenerationHandler {
|
|
|
125
157
|
stopWhen: stepCountIs(options.maxSteps ?? DEFAULT_MAX_STEPS),
|
|
126
158
|
...(shouldUseTools &&
|
|
127
159
|
options.toolChoice && { toolChoice: options.toolChoice }),
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
160
|
+
experimental_prepareStep: (async (stepOptions) => {
|
|
161
|
+
// Public contract preserved: a caller-supplied prepareStep receives
|
|
162
|
+
// the ORIGINAL AI-SDK step options, exactly as before the guard
|
|
163
|
+
// existed — callers that inspect message history see the real thing.
|
|
164
|
+
const callerResult = prepareStep
|
|
165
|
+
? await prepareStep({
|
|
166
|
+
...stepOptions,
|
|
167
|
+
maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
168
|
+
})
|
|
169
|
+
: undefined;
|
|
170
|
+
// The guard runs LAST, on the messages that will actually be sent:
|
|
171
|
+
// the caller's override when one was returned (out-of-contract for
|
|
172
|
+
// NeuroLink's public prepareStep type, but possible at runtime), else
|
|
173
|
+
// the step's own messages. It never replaces a caller's content
|
|
174
|
+
// choices — it only reclaims budget from whatever was chosen.
|
|
175
|
+
const callerMessages = callerResult?.messages;
|
|
176
|
+
const compacted = stepBudgetGuard(callerMessages ?? stepOptions.messages);
|
|
177
|
+
if (!compacted) {
|
|
178
|
+
return callerResult;
|
|
179
|
+
}
|
|
180
|
+
return { ...(callerResult ?? {}), messages: compacted };
|
|
133
181
|
}),
|
|
134
182
|
temperature: options.temperature,
|
|
135
183
|
maxOutputTokens: options.maxTokens,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-step context budget guard for the AI-SDK agent loop.
|
|
3
|
+
*
|
|
4
|
+
* Pre-call budgeting (`checkContextBudget` + compaction) runs ONCE before
|
|
5
|
+
* dispatch and only sees the input/session conversation. The AI-SDK tool loop
|
|
6
|
+
* then appends assistant turns and tool results on every step — growth the
|
|
7
|
+
* pre-call pipeline never sees, which is how long agentic runs overflow the
|
|
8
|
+
* model's real context window mid-loop (provider 400s after dozens of tool
|
|
9
|
+
* calls). The googleVertex native loops already guard this via
|
|
10
|
+
* `createContextGuard`; this module brings the AI-SDK path (every provider
|
|
11
|
+
* that delegates to `generateText`) to parity — and goes one step further:
|
|
12
|
+
* instead of stopping the loop, it deterministically reclaims budget so the
|
|
13
|
+
* loop can CONTINUE.
|
|
14
|
+
*
|
|
15
|
+
* Wired in `GenerationHandler.callGenerateText` through
|
|
16
|
+
* `experimental_prepareStep`, whose result may replace the step's `messages`.
|
|
17
|
+
* The guard operates on `ModelMessage[]` natively (no lossy ChatMessage
|
|
18
|
+
* round-trip) and never makes LLM calls:
|
|
19
|
+
*
|
|
20
|
+
* Stage 1 — truncate OLD tool outputs to head/tail previews
|
|
21
|
+
* (`generateToolOutputPreview`), oldest first, outside the
|
|
22
|
+
* protected recent tail.
|
|
23
|
+
* Stage 2 — drop the oldest complete tool exchanges (assistant tool-call
|
|
24
|
+
* message + its following tool-result messages, as a unit, so
|
|
25
|
+
* call/result pairing stays intact), replacing them with a single
|
|
26
|
+
* elision note.
|
|
27
|
+
*
|
|
28
|
+
* The system prompt and tool definitions ride OUTSIDE the step messages (the
|
|
29
|
+
* handler hoists system into generateText's `system` option), so their cost is
|
|
30
|
+
* passed in as `fixedOverheadTokens`. The first user message (the task) and
|
|
31
|
+
* the most recent messages are never touched.
|
|
32
|
+
*/
|
|
33
|
+
import type { ModelMessage, StepBudgetGuardConfig } from "../types/index.js";
|
|
34
|
+
/** Estimate the token cost of a step's message array. */
|
|
35
|
+
export declare function estimateStepMessagesTokens(messages: readonly ModelMessage[], provider?: string): number;
|
|
36
|
+
/**
|
|
37
|
+
* Estimate the fixed per-request overhead: hoisted system prompt + tool
|
|
38
|
+
* definitions. Mirrors `checkContextBudget`'s categories for the pieces that
|
|
39
|
+
* do not live in the step messages.
|
|
40
|
+
*/
|
|
41
|
+
export declare function estimateFixedOverheadTokens(system: unknown, tools: Record<string, unknown> | undefined, provider?: string): number;
|
|
42
|
+
/**
|
|
43
|
+
* Create a per-step budget guard. Returns a function that, given the step's
|
|
44
|
+
* messages, returns a compacted replacement array when the projected request
|
|
45
|
+
* exceeds the threshold — or `undefined` when no change is needed.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createStepBudgetGuard(config: StepBudgetGuardConfig): (messages: readonly ModelMessage[]) => ModelMessage[] | undefined;
|