@juspay/neurolink 10.10.3 → 10.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +387 -386
  3. package/dist/context/anthropicLoopGuard.d.ts +46 -0
  4. package/dist/context/anthropicLoopGuard.js +167 -0
  5. package/dist/context/loopGuardCore.d.ts +43 -0
  6. package/dist/context/loopGuardCore.js +145 -0
  7. package/dist/context/openaiCompatLoopGuard.d.ts +42 -0
  8. package/dist/context/openaiCompatLoopGuard.js +172 -0
  9. package/dist/context/stepBudgetGuard.js +32 -3
  10. package/dist/context/toolOutputLimits.d.ts +11 -0
  11. package/dist/context/toolOutputLimits.js +16 -0
  12. package/dist/lib/context/anthropicLoopGuard.d.ts +46 -0
  13. package/dist/lib/context/anthropicLoopGuard.js +168 -0
  14. package/dist/lib/context/loopGuardCore.d.ts +43 -0
  15. package/dist/lib/context/loopGuardCore.js +146 -0
  16. package/dist/lib/context/openaiCompatLoopGuard.d.ts +42 -0
  17. package/dist/lib/context/openaiCompatLoopGuard.js +173 -0
  18. package/dist/lib/context/stepBudgetGuard.js +32 -3
  19. package/dist/lib/context/toolOutputLimits.d.ts +11 -0
  20. package/dist/lib/context/toolOutputLimits.js +16 -0
  21. package/dist/lib/providers/anthropic/client.js +98 -0
  22. package/dist/lib/providers/openaiChatCompletionsBase.js +38 -1
  23. package/dist/lib/types/context.d.ts +73 -0
  24. package/dist/lib/utils/tokenEstimation.d.ts +1 -1
  25. package/dist/lib/utils/tokenEstimation.js +1 -1
  26. package/dist/providers/anthropic/client.js +98 -0
  27. package/dist/providers/openaiChatCompletionsBase.js +38 -1
  28. package/dist/types/context.d.ts +73 -0
  29. package/dist/utils/tokenEstimation.d.ts +1 -1
  30. package/dist/utils/tokenEstimation.js +1 -1
  31. package/package.json +5 -1
@@ -0,0 +1,173 @@
1
+ /**
2
+ * In-turn context guard for the OpenAI-compatible chat-completions loop.
3
+ *
4
+ * `openaiChatCompletionsBase` runs its own hand-written tool loop
5
+ * (`for step < maxSteps` → `streamOneStep` → `executeToolBatch`) and appends an
6
+ * assistant tool-call message plus one `role: "tool"` message per result on
7
+ * every step. Nothing bounded that growth: the loop had no context guard of any
8
+ * kind, so a long agentic run walked into a provider "context length exceeded"
9
+ * after dozens of tool calls and lost every completed step.
10
+ *
11
+ * This is the widest-reach gap in the codebase — the chat-completions base
12
+ * backs LiteLLM, DeepSeek, OpenRouter, NVIDIA NIM, LM Studio, llama.cpp and
13
+ * every `openai-compatible` provider.
14
+ *
15
+ * The reclaim POLICY lives in `loopGuardCore` and is shared with the other
16
+ * provider loops. This module owns only the shape mapping: OpenAI-compatible
17
+ * messages in, neutral entries out, plan applied back.
18
+ */
19
+ import { estimateTokens, TOKENS_PER_MESSAGE, } from "../utils/tokenEstimation.js";
20
+ import { exceedsToolOutputPreviewBudget, generateToolOutputPreview, } from "./toolOutputLimits.js";
21
+ import { planLoopGuardReclaim } from "./loopGuardCore.js";
22
+ import { logger } from "../utils/logger.js";
23
+ /** Preview budget for an old tool output. Matches the AI-SDK guard. */
24
+ const OLD_TOOL_OUTPUT_PREVIEW_BYTES = 2_048;
25
+ const OLD_TOOL_OUTPUT_PREVIEW_LINES = 60;
26
+ /** Marker left where dropped history used to be. */
27
+ const ELISION_NOTE = "[Earlier tool exchanges were removed to fit the context window.]";
28
+ /** Serialize message content to text for estimation. Never throws. */
29
+ function contentToText(content) {
30
+ if (typeof content === "string") {
31
+ return content;
32
+ }
33
+ if (content === null || content === undefined) {
34
+ return "";
35
+ }
36
+ try {
37
+ return JSON.stringify(content) ?? "";
38
+ }
39
+ catch {
40
+ // A payload past V8's string cap is enormous by definition; charge it a
41
+ // large fixed size rather than aborting the estimate and the turn.
42
+ return "x".repeat(200_000);
43
+ }
44
+ }
45
+ /** Cost of one message, including per-message framing. */
46
+ function messageTokens(message, provider) {
47
+ let text = contentToText(message.content);
48
+ if (message.role === "assistant" && message.tool_calls) {
49
+ // Tool-call arguments are a first-class cost here: the model's arguments
50
+ // for a Write/Edit-style tool dwarf the message body.
51
+ text += contentToText(message.tool_calls);
52
+ }
53
+ return estimateTokens(text, provider) + TOKENS_PER_MESSAGE;
54
+ }
55
+ /** Preview cost of a `role: "tool"` message, or undefined when it cannot shrink. */
56
+ function previewTokensFor(message, provider) {
57
+ if (message.role !== "tool") {
58
+ return undefined;
59
+ }
60
+ const text = contentToText(message.content);
61
+ const previewOptions = {
62
+ maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES,
63
+ maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES,
64
+ };
65
+ if (!exceedsToolOutputPreviewBudget(text, previewOptions)) {
66
+ return undefined;
67
+ }
68
+ const { preview } = generateToolOutputPreview(text, previewOptions);
69
+ return estimateTokens(preview, provider) + TOKENS_PER_MESSAGE;
70
+ }
71
+ /** Map the loop's history onto the neutral view the policy operates on. */
72
+ function toEntries(conversation, provider) {
73
+ return conversation.map((message) => {
74
+ const tokens = messageTokens(message, provider);
75
+ if (message.role === "tool") {
76
+ const previewTokens = previewTokensFor(message, provider);
77
+ return {
78
+ kind: "toolResult",
79
+ tokens,
80
+ ...(previewTokens !== undefined ? { previewTokens } : {}),
81
+ };
82
+ }
83
+ if (message.role === "assistant" && message.tool_calls?.length) {
84
+ return { kind: "toolCall", tokens };
85
+ }
86
+ return { kind: "other", tokens };
87
+ });
88
+ }
89
+ /**
90
+ * Reclaim context budget from a chat-completions loop's conversation.
91
+ *
92
+ * Returns a NEW array when it acted, or `undefined` when the loop fits and the
93
+ * history must be left byte-identical — any rewrite invalidates the provider's
94
+ * cached prompt prefix, so "no change" has to mean no change.
95
+ *
96
+ * @param observedPromptTokens the provider's reported prompt token count for
97
+ * the previous step, used to calibrate the char-based estimate for free.
98
+ * @param previousSentEstimate this guard's own estimate for the request that
99
+ * produced `observedPromptTokens`. The two must describe the SAME request.
100
+ * @param onSentEstimate receives the estimate for the request about to be sent,
101
+ * so the caller can feed it back as the next step's `previousSentEstimate`.
102
+ */
103
+ export function guardOpenAICompatConversation(args) {
104
+ const { conversation, availableInputTokens, fixedOverheadTokens, provider, observedPromptTokens, previousSentEstimate, onSentEstimate, } = args;
105
+ const entries = toEntries(conversation, provider);
106
+ const rawEstimate = fixedOverheadTokens + entries.reduce((sum, e) => sum + e.tokens, 0);
107
+ // Calibration compares a real prompt-token count against THIS guard's
108
+ // estimate for the very request that produced it. Dividing by the estimate
109
+ // for the CURRENT conversation would be a category error: the loop has since
110
+ // appended an assistant tool-call message plus its results, so the
111
+ // denominator is always larger than the numerator's request. The ratio then
112
+ // reads below 1 and the `Math.max(1, …)` floor pins calibration at 1 — the
113
+ // correction silently never applies, which is exactly when a dense-code run
114
+ // overflows the window.
115
+ let calibration = 1;
116
+ if (observedPromptTokens &&
117
+ observedPromptTokens > 0 &&
118
+ previousSentEstimate &&
119
+ previousSentEstimate > 0) {
120
+ // Clamped: real tokenizers run up to ~1.3x the char estimate on dense
121
+ // code, and an unbounded ratio would compact the loop into uselessness.
122
+ calibration = Math.min(3, Math.max(1, observedPromptTokens / previousSentEstimate));
123
+ }
124
+ const plan = planLoopGuardReclaim(entries, {
125
+ availableInputTokens,
126
+ fixedOverheadTokens,
127
+ calibration,
128
+ });
129
+ if (!plan.fire) {
130
+ onSentEstimate?.(rawEstimate);
131
+ return undefined;
132
+ }
133
+ const truncateSet = new Set(plan.truncate);
134
+ const dropSet = new Set(plan.drop);
135
+ const result = [];
136
+ for (let i = 0; i < conversation.length; i++) {
137
+ if (dropSet.has(i)) {
138
+ continue;
139
+ }
140
+ const message = conversation[i];
141
+ if (truncateSet.has(i) && message.role === "tool") {
142
+ const { preview } = generateToolOutputPreview(contentToText(message.content), {
143
+ maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES,
144
+ maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES,
145
+ });
146
+ result.push({ ...message, content: preview });
147
+ continue;
148
+ }
149
+ result.push(message);
150
+ }
151
+ if (dropSet.size > 0) {
152
+ // Place the note where the removed history was, i.e. before the first
153
+ // surviving tool message — never at the end, where a "history was removed"
154
+ // cue would land after the content it refers to.
155
+ let noteIndex = result.findIndex((m) => m.role === "tool" || (m.role === "assistant" && m.tool_calls));
156
+ if (noteIndex < 0) {
157
+ noteIndex = Math.min(1, result.length);
158
+ }
159
+ result.splice(noteIndex, 0, { role: "user", content: ELISION_NOTE });
160
+ }
161
+ logger.info("[OpenAICompatLoopGuard] Reclaimed agent-loop context", {
162
+ provider,
163
+ messagesBefore: conversation.length,
164
+ messagesAfter: result.length,
165
+ toolOutputsTruncated: plan.truncate.length,
166
+ messagesDropped: plan.drop.length,
167
+ projectedTokens: plan.projectedTokens,
168
+ calibration,
169
+ });
170
+ onSentEstimate?.(plan.projectedTokens);
171
+ return result;
172
+ }
173
+ //# sourceMappingURL=openaiCompatLoopGuard.js.map
@@ -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
  });
@@ -14,6 +14,17 @@ export declare const DEFAULT_HEAD_RATIO = 0.25;
14
14
  export declare const RETRIEVE_CONTEXT_TOOL_NAME = "retrieve_context";
15
15
  /** Default tail ratio (75% of preview budget) */
16
16
  export declare const DEFAULT_TAIL_RATIO = 0.75;
17
+ /**
18
+ * True when `generateToolOutputPreview` would actually shrink this output.
19
+ *
20
+ * Callers gate on this before paying for a preview. It exists so the gate and
21
+ * the generator can never disagree: the budget is expressed in BYTES, and
22
+ * testing it against `String.length` (UTF-16 code units) under-reports every
23
+ * multibyte payload — CJK or emoji tool output would be judged small enough to
24
+ * leave alone while the generator would have truncated it. Line count matters
25
+ * for the same reason: a short-but-tall output exceeds `maxLines` alone.
26
+ */
27
+ export declare function exceedsToolOutputPreviewBudget(output: string, options?: ToolOutputPreviewOptions): boolean;
17
28
  /**
18
29
  * Generate a head/tail preview of a tool output string.
19
30
  * If the output is within limits, returns it unchanged with truncated: false.
@@ -13,6 +13,22 @@ export const DEFAULT_HEAD_RATIO = 0.25;
13
13
  export const RETRIEVE_CONTEXT_TOOL_NAME = "retrieve_context";
14
14
  /** Default tail ratio (75% of preview budget) */
15
15
  export const DEFAULT_TAIL_RATIO = 0.75;
16
+ /**
17
+ * True when `generateToolOutputPreview` would actually shrink this output.
18
+ *
19
+ * Callers gate on this before paying for a preview. It exists so the gate and
20
+ * the generator can never disagree: the budget is expressed in BYTES, and
21
+ * testing it against `String.length` (UTF-16 code units) under-reports every
22
+ * multibyte payload — CJK or emoji tool output would be judged small enough to
23
+ * leave alone while the generator would have truncated it. Line count matters
24
+ * for the same reason: a short-but-tall output exceeds `maxLines` alone.
25
+ */
26
+ export function exceedsToolOutputPreviewBudget(output, options) {
27
+ const maxBytes = options?.maxBytes ?? DEFAULT_MAX_PREVIEW_BYTES;
28
+ const maxLines = options?.maxLines ?? DEFAULT_MAX_PREVIEW_LINES;
29
+ return (Buffer.byteLength(output, "utf-8") > maxBytes ||
30
+ output.split("\n").length > maxLines);
31
+ }
16
32
  /**
17
33
  * Generate a head/tail preview of a tool output string.
18
34
  * If the output is within limits, returns it unchanged with truncated: false.
@@ -14,6 +14,9 @@ import { createProxyFetch } from "../../proxy/proxyFetch.js";
14
14
  import { getCapturedLimitSnapshot, getCapturedResponseHeaders, logClaudeLimitSnapshot, runInLimitCaptureScope, setLimitSpanAttributes, withLimitCapture, wrapFetchWithLimitCapture, } from "./rateLimitCapture.js";
15
15
  import { AuthenticationError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
16
16
  import { logger } from "../../utils/logger.js";
17
+ import { ANTHROPIC_ELISION_NOTE, planAnthropicLoopReclaim, previewAnthropicToolResultText, } from "../../context/anthropicLoopGuard.js";
18
+ import { getAvailableInputTokens } from "../../constants/contextWindows.js";
19
+ import { estimateTokens } from "../../utils/tokenEstimation.js";
17
20
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
18
21
  import { ANTHROPIC_MAX_CACHE_BREAKPOINTS, applyAnthropicHistoryCacheBreakpoints, countAnthropicCacheMarkers, } from "../../utils/anthropicCacheBreakpoints.js";
19
22
  import { calculateCost } from "../../utils/pricing.js";
@@ -1509,8 +1512,30 @@ export class AnthropicProvider extends BaseProvider {
1509
1512
  // and stay fully incremental.
1510
1513
  let bufferedText = "";
1511
1514
  let finalResultText;
1515
+ /** System prompt + tool definitions: they ride outside `messages`. */
1516
+ const estimateAnthropicFixedOverhead = (system, tools) => {
1517
+ const text = (value) => {
1518
+ if (typeof value === "string") {
1519
+ return value;
1520
+ }
1521
+ try {
1522
+ return JSON.stringify(value) ?? "";
1523
+ }
1524
+ catch {
1525
+ return "";
1526
+ }
1527
+ };
1528
+ return (estimateTokens(text(system), "anthropic") +
1529
+ estimateTokens(text(tools), "anthropic"));
1530
+ };
1512
1531
  const runLoop = async () => {
1513
1532
  const conversation = payload.messages.slice();
1533
+ // The provider's REAL prompt-token count for the previous step,
1534
+ // calibrating the guard's char-based estimate for free, paired with the
1535
+ // guard's own estimate for that same request — a ratio between counts of
1536
+ // two different payloads would be meaningless.
1537
+ let lastObservedPromptTokens;
1538
+ let lastSentEstimate;
1514
1539
  for (let step = 0; step < maxSteps; step++) {
1515
1540
  // Mid-turn discovery sync: search_tools (tools.discovery) hydrates
1516
1541
  // new tools into toolsRecord between steps; Claude only calls tools
@@ -1524,6 +1549,71 @@ export class AnthropicProvider extends BaseProvider {
1524
1549
  logger.info(`[Anthropic] ${Object.keys(hydrated).length} tool(s) hydrated mid-turn via discovery: ${Object.keys(hydrated).join(", ")}`);
1525
1550
  }
1526
1551
  }
1552
+ // In-turn context guard. This loop appends an assistant tool_use
1553
+ // message plus a user tool_result message every step — growth the
1554
+ // pre-dispatch budget check never sees. Without it a long agentic run
1555
+ // overflows the window mid-loop and loses every completed step.
1556
+ // Returns undefined while the request still fits, leaving the history
1557
+ // byte-identical so the rolling cache prefix below stays valid.
1558
+ const reclaim = planAnthropicLoopReclaim({
1559
+ conversation,
1560
+ availableInputTokens: getAvailableInputTokens("anthropic", modelId, options.maxTokens ?? undefined),
1561
+ fixedOverheadTokens: estimateAnthropicFixedOverhead(payload.system, anthropicTools),
1562
+ provider: "anthropic",
1563
+ observedPromptTokens: lastObservedPromptTokens,
1564
+ // Both halves of the calibration ratio must describe the same
1565
+ // request: the tokens the provider reported, and this guard's own
1566
+ // estimate for what was sent to earn them.
1567
+ previousSentEstimate: lastSentEstimate,
1568
+ onSentEstimate: (tokens) => {
1569
+ lastSentEstimate = tokens;
1570
+ },
1571
+ });
1572
+ if (reclaim) {
1573
+ // Applied HERE, in the loop's own concrete types: the guard decides,
1574
+ // the caller mutates. Dropping an assistant tool_use message together
1575
+ // with its user tool_result message is what keeps blocks paired.
1576
+ const dropSet = new Set(reclaim.drop);
1577
+ const truncateSet = new Set(reclaim.truncate);
1578
+ const rebuilt = [];
1579
+ for (let i = 0; i < conversation.length; i++) {
1580
+ if (dropSet.has(i)) {
1581
+ continue;
1582
+ }
1583
+ const message = conversation[i];
1584
+ if (truncateSet.has(i) && Array.isArray(message.content)) {
1585
+ rebuilt.push({
1586
+ ...message,
1587
+ content: message.content.map((block) => block.type === "tool_result"
1588
+ ? {
1589
+ ...block,
1590
+ content: previewAnthropicToolResultText(typeof block.content === "string"
1591
+ ? block.content
1592
+ : (JSON.stringify(block.content) ?? "")),
1593
+ }
1594
+ : block),
1595
+ });
1596
+ continue;
1597
+ }
1598
+ rebuilt.push(message);
1599
+ }
1600
+ if (dropSet.size > 0) {
1601
+ // Anthropic requires user/assistant alternation around tool blocks;
1602
+ // the note is a user turn placed immediately before the first
1603
+ // surviving assistant tool_use turn, which preserves it.
1604
+ let noteIndex = rebuilt.findIndex((m) => Array.isArray(m.content) &&
1605
+ m.content.some((b) => b.type === "tool_use" || b.type === "tool_result"));
1606
+ if (noteIndex < 0) {
1607
+ noteIndex = Math.min(1, rebuilt.length);
1608
+ }
1609
+ rebuilt.splice(noteIndex, 0, {
1610
+ role: "user",
1611
+ content: [{ type: "text", text: ANTHROPIC_ELISION_NOTE }],
1612
+ });
1613
+ }
1614
+ conversation.length = 0;
1615
+ conversation.push(...rebuilt);
1616
+ }
1527
1617
  // Prompt-cache parity with the native Vertex+Claude path — rolling
1528
1618
  // history breakpoints, re-applied per step so the stable prefix
1529
1619
  // stays byte-identical while the breakpoint follows the growing
@@ -1587,6 +1677,14 @@ export class AnthropicProvider extends BaseProvider {
1587
1677
  totalCacheRead += event.message.usage.cache_read_input_tokens ?? 0;
1588
1678
  totalCacheWrite +=
1589
1679
  event.message.usage.cache_creation_input_tokens ?? 0;
1680
+ // Calibration signal for the in-turn guard: the FULL prompt size,
1681
+ // which on this path means uncached input plus both cache tiers.
1682
+ // Using input_tokens alone would read a cache-hit step as tiny and
1683
+ // let the guard drift far under the real cost.
1684
+ lastObservedPromptTokens =
1685
+ (event.message.usage.input_tokens ?? 0) +
1686
+ (event.message.usage.cache_read_input_tokens ?? 0) +
1687
+ (event.message.usage.cache_creation_input_tokens ?? 0);
1590
1688
  }
1591
1689
  else if (event.type === "content_block_start") {
1592
1690
  blockTypes.set(event.index, event.content_block.type);
@@ -17,7 +17,8 @@
17
17
  * Nothing here imports from "ai" or "@ai-sdk/*". The base class is a
18
18
  * direct HTTP client + multi-step tool-execution loop driven by SSE.
19
19
  */
20
- import { getRuntimeContextWindow, getRuntimeOutputCeiling, registerRuntimeContextWindow, } from "../constants/contextWindows.js";
20
+ import { getAvailableInputTokens, getRuntimeContextWindow, getRuntimeOutputCeiling, registerRuntimeContextWindow, } from "../constants/contextWindows.js";
21
+ import { guardOpenAICompatConversation } from "../context/openaiCompatLoopGuard.js";
21
22
  import { isContextOverflowError, parseProviderOverflowDetails, } from "../context/errorDetection.js";
22
23
  import { ContextBudgetExceededError } from "../context/errors.js";
23
24
  import { BaseProvider } from "../core/baseProvider.js";
@@ -770,6 +771,12 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
770
771
  // May grow mid-turn: hydrated tools with wire-unsafe names need
771
772
  // reverse-mapping even when the initial name set required none.
772
773
  let effectiveToolNameFromWire = toolNameFromWire;
774
+ // The provider's REAL prompt-token count for the previous step, used to
775
+ // calibrate the guard's char-based estimate for free, paired with the
776
+ // guard's own estimate for that same request — a ratio between counts of
777
+ // two different payloads would be meaningless.
778
+ let lastObservedPromptTokens;
779
+ let lastSentEstimate;
773
780
  for (let step = 0; step < maxSteps; step++) {
774
781
  // Mid-turn discovery sync: search_tools (tools.discovery) hydrates
775
782
  // new tools into toolsRecord between steps. Dispatch already re-reads
@@ -795,6 +802,35 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
795
802
  logger.info(`${this.providerName}: ${Object.keys(hydrated).length} tool(s) hydrated mid-turn via discovery: ${Object.keys(hydrated).join(", ")}`);
796
803
  }
797
804
  }
805
+ // In-turn context guard. This loop appends an assistant tool-call
806
+ // message plus one tool message per result on every step — growth the
807
+ // pre-dispatch budget check never sees. Without this, a long agentic
808
+ // run walks into a provider "context length exceeded" and loses every
809
+ // completed step. Shares its reclaim policy with the other provider
810
+ // loops via loopGuardCore; returns undefined (leaving `conversation`
811
+ // byte-identical) whenever the request still fits, so a loop that fits
812
+ // never pays a prompt-cache invalidation.
813
+ const guarded = guardOpenAICompatConversation({
814
+ conversation,
815
+ availableInputTokens: getAvailableInputTokens(this.providerName, modelId, options.maxTokens ?? undefined),
816
+ // Tool definitions ride outside the message array. Passing an empty
817
+ // message list yields the tools-only overhead, and reuses the same
818
+ // estimator the wire path already trusts.
819
+ fixedOverheadTokens: estimateWireTokens([], openAITools, this.providerName),
820
+ provider: this.providerName,
821
+ observedPromptTokens: lastObservedPromptTokens,
822
+ // Both halves of the calibration ratio must describe the same
823
+ // request: the tokens the provider reported, and this guard's own
824
+ // estimate for what was sent to earn them.
825
+ previousSentEstimate: lastSentEstimate,
826
+ onSentEstimate: (tokens) => {
827
+ lastSentEstimate = tokens;
828
+ },
829
+ });
830
+ if (guarded) {
831
+ conversation.length = 0;
832
+ conversation.push(...guarded);
833
+ }
798
834
  const stepResult = await this.streamOneStep({
799
835
  modelId,
800
836
  url,
@@ -806,6 +842,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
806
842
  openAIToolChoice,
807
843
  pushChunk,
808
844
  });
845
+ lastObservedPromptTokens = stepResult.usage?.prompt_tokens;
809
846
  stepFinish = stepResult.finishReason;
810
847
  if (stepResult.usage) {
811
848
  stepUsage = mergeUsage(stepUsage, stepResult.usage);
@@ -412,6 +412,79 @@ export type RepairResult = {
412
412
  orphanedCallsFixed: number;
413
413
  orphanedResultsFixed: number;
414
414
  };
415
+ /**
416
+ * Provider-neutral view of ONE message in an agent loop's history.
417
+ *
418
+ * Every native provider loop keeps its history in a different shape (AI-SDK
419
+ * `ModelMessage`, OpenAI-compatible `{role,tool_calls}`, Gemini `contents`
420
+ * parts, Anthropic content blocks). The reclaim POLICY is identical across all
421
+ * of them, so adapters map their own shape onto this view, ask the core what to
422
+ * do, and apply the answer themselves.
423
+ */
424
+ export type LoopGuardEntry = {
425
+ /** `toolCall` and `toolResult` form the batches the policy keeps intact. */
426
+ kind: "other" | "toolCall" | "toolResult";
427
+ /** Estimated tokens this entry currently costs. */
428
+ tokens: number;
429
+ /**
430
+ * Tokens this entry would cost with its payload replaced by a head/tail
431
+ * preview. Omitted when the entry cannot usefully shrink — which is exactly
432
+ * the case that forces the policy to drop batches instead.
433
+ */
434
+ previewTokens?: number;
435
+ };
436
+ /** What the caller should do to reclaim budget. Indices refer to the input array. */
437
+ export type LoopGuardPlan = {
438
+ /** False when the loop is under threshold and nothing should change. */
439
+ fire: boolean;
440
+ /** Entries whose payload should be replaced by a preview. */
441
+ truncate: number[];
442
+ /** Entries to remove entirely — always whole batches, never a partial pair. */
443
+ drop: number[];
444
+ /** Estimated total after applying the plan, including fixed overhead. */
445
+ projectedTokens: number;
446
+ };
447
+ /**
448
+ * Structural view of one Anthropic content block, loose enough to accept the
449
+ * official SDK's `ContentBlockParam` union and NeuroLink's own
450
+ * `VertexAnthropicMessage` blocks without a cast at either call site.
451
+ */
452
+ export type AnthropicGuardBlock = {
453
+ type: string;
454
+ /** Payload of a `tool_result` block. Other block kinds carry other fields. */
455
+ content?: unknown;
456
+ /** Text of a `text` block. */
457
+ text?: string;
458
+ };
459
+ /**
460
+ * Structural view of one Anthropic-shaped message, as used by both the direct
461
+ * Anthropic loop and the native Vertex+Claude path. Tool calls ride as
462
+ * `tool_use` blocks on an assistant message; their answers ride as
463
+ * `tool_result` blocks on the following user message.
464
+ */
465
+ export type AnthropicGuardMessage = {
466
+ /**
467
+ * `system` is included because the installed `@anthropic-ai/sdk` widens
468
+ * `MessageParam["role"]` to accept it; narrowing here would make the SDK's
469
+ * own array unassignable at the call site.
470
+ */
471
+ role: "user" | "assistant" | "system";
472
+ content: string | AnthropicGuardBlock[];
473
+ };
474
+ /** Tuning for {@link planLoopGuardReclaim}. */
475
+ export type LoopGuardPolicy = {
476
+ availableInputTokens: number;
477
+ /** System prompt + tool definitions — rides outside the message array. */
478
+ fixedOverheadTokens: number;
479
+ /** Fraction of the window at which the guard fires. */
480
+ thresholdRatio?: number;
481
+ /** Fraction of the window the guard reclaims down to once it fires. */
482
+ lowWaterRatio?: number;
483
+ /** Newest entries the guard must never modify. */
484
+ protectedTailCount?: number;
485
+ /** Observed/estimated token ratio, used to tighten both marks. */
486
+ calibration?: number;
487
+ };
415
488
  /**
416
489
  * One contiguous tool batch: the run of `tool_call` messages emitted by a
417
490
  * single agent step, plus the run of `tool_result` messages that follows it.
@@ -52,7 +52,7 @@ export declare function estimateTokens(text: string, provider?: string, isCode?:
52
52
  * Includes message framing overhead.
53
53
  *
54
54
  * Counts `content` AND `args`. A `tool_call` is persisted with an EMPTY
55
- * `content` and its entire payload in `args` (see flushPendingToolExecutions),
55
+ * `content` and its entire payload in `args` (see flushPendingToolData),
56
56
  * so a content-only estimate scored a 39 KB Write call at ~28 tokens against a
57
57
  * real cost near 9,750 — the budget checker, the compaction trigger and the
58
58
  * summarization threshold were all blind to the single largest source of
@@ -109,7 +109,7 @@ function serializeForEstimate(value) {
109
109
  * Includes message framing overhead.
110
110
  *
111
111
  * Counts `content` AND `args`. A `tool_call` is persisted with an EMPTY
112
- * `content` and its entire payload in `args` (see flushPendingToolExecutions),
112
+ * `content` and its entire payload in `args` (see flushPendingToolData),
113
113
  * so a content-only estimate scored a 39 KB Write call at ~28 tokens against a
114
114
  * real cost near 9,750 — the budget checker, the compaction trigger and the
115
115
  * summarization threshold were all blind to the single largest source of