@juspay/neurolink 10.10.4 → 10.10.6

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 (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +392 -391
  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/toolOutputLimits.d.ts +11 -0
  10. package/dist/context/toolOutputLimits.js +16 -0
  11. package/dist/core/conversationMemoryManager.d.ts +25 -0
  12. package/dist/core/conversationMemoryManager.js +71 -0
  13. package/dist/lib/context/anthropicLoopGuard.d.ts +46 -0
  14. package/dist/lib/context/anthropicLoopGuard.js +168 -0
  15. package/dist/lib/context/loopGuardCore.d.ts +43 -0
  16. package/dist/lib/context/loopGuardCore.js +146 -0
  17. package/dist/lib/context/openaiCompatLoopGuard.d.ts +42 -0
  18. package/dist/lib/context/openaiCompatLoopGuard.js +173 -0
  19. package/dist/lib/context/toolOutputLimits.d.ts +11 -0
  20. package/dist/lib/context/toolOutputLimits.js +16 -0
  21. package/dist/lib/core/conversationMemoryManager.d.ts +25 -0
  22. package/dist/lib/core/conversationMemoryManager.js +71 -0
  23. package/dist/lib/neurolink.d.ts +8 -2
  24. package/dist/lib/neurolink.js +18 -11
  25. package/dist/lib/providers/anthropic/client.js +98 -0
  26. package/dist/lib/providers/openaiChatCompletionsBase.js +38 -1
  27. package/dist/lib/types/context.d.ts +73 -0
  28. package/dist/lib/types/conversationMemoryInterface.d.ts +22 -0
  29. package/dist/neurolink.d.ts +8 -2
  30. package/dist/neurolink.js +18 -11
  31. package/dist/providers/anthropic/client.js +98 -0
  32. package/dist/providers/openaiChatCompletionsBase.js +38 -1
  33. package/dist/types/context.d.ts +73 -0
  34. package/dist/types/conversationMemoryInterface.d.ts +22 -0
  35. package/package.json +5 -1
@@ -0,0 +1,46 @@
1
+ /**
2
+ * In-turn context guard for Anthropic-shaped agent loops.
3
+ *
4
+ * The direct Anthropic loop (`providers/anthropic/client.ts`) grows a
5
+ * `conversation` array on every step: an assistant message carrying `tool_use`
6
+ * blocks, then a user message carrying the matching `tool_result` blocks. It
7
+ * had no in-turn guard, so a long agentic run overflowed the window mid-loop
8
+ * and lost every completed step.
9
+ *
10
+ * Granularity differs from the OpenAI-compatible shape: there, each tool result
11
+ * is its own message; here, ONE user message carries every `tool_result` for a
12
+ * step. That makes the message the natural batch unit — dropping an assistant
13
+ * `tool_use` message together with its following `tool_result` message can
14
+ * never orphan a block, which Anthropic rejects outright.
15
+ *
16
+ * The reclaim POLICY lives in `loopGuardCore` and is shared with the other
17
+ * provider loops. This module owns only the shape mapping.
18
+ */
19
+ import type { AnthropicGuardMessage, LoopGuardPlan } from "../types/index.js";
20
+ /** Marker left where dropped history used to be. */
21
+ export declare const ANTHROPIC_ELISION_NOTE = "[Earlier tool exchanges were removed to fit the context window.]";
22
+ /**
23
+ * Text-level preview used for an oversized `tool_result` payload. Exposed so
24
+ * the caller can apply it to its OWN block type — this module never rebuilds
25
+ * the caller's messages, which keeps it free of generic-spread assignability
26
+ * problems and of the double type assertions Critical Rule 14 forbids.
27
+ */
28
+ export declare function previewAnthropicToolResultText(text: string): string;
29
+ /** True when this message carries `tool_result` blocks worth previewing. */
30
+ export declare function isAnthropicToolResultMessage(message: AnthropicGuardMessage): boolean;
31
+ /**
32
+ * Decide what to reclaim from an Anthropic-shaped agent loop.
33
+ *
34
+ * Returns `undefined` when the loop still fits — the caller must then leave its
35
+ * history byte-identical, because any rewrite invalidates the rolling
36
+ * `cache_control` prefix this path depends on.
37
+ */
38
+ export declare function planAnthropicLoopReclaim(args: {
39
+ conversation: readonly AnthropicGuardMessage[];
40
+ availableInputTokens: number;
41
+ fixedOverheadTokens: number;
42
+ provider?: string;
43
+ observedPromptTokens?: number;
44
+ previousSentEstimate?: number;
45
+ onSentEstimate?: (tokens: number) => void;
46
+ }): LoopGuardPlan | undefined;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * In-turn context guard for Anthropic-shaped agent loops.
3
+ *
4
+ * The direct Anthropic loop (`providers/anthropic/client.ts`) grows a
5
+ * `conversation` array on every step: an assistant message carrying `tool_use`
6
+ * blocks, then a user message carrying the matching `tool_result` blocks. It
7
+ * had no in-turn guard, so a long agentic run overflowed the window mid-loop
8
+ * and lost every completed step.
9
+ *
10
+ * Granularity differs from the OpenAI-compatible shape: there, each tool result
11
+ * is its own message; here, ONE user message carries every `tool_result` for a
12
+ * step. That makes the message the natural batch unit — dropping an assistant
13
+ * `tool_use` message together with its following `tool_result` message can
14
+ * never orphan a block, which Anthropic rejects outright.
15
+ *
16
+ * The reclaim POLICY lives in `loopGuardCore` and is shared with the other
17
+ * provider loops. This module owns only the shape mapping.
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 other loop guards. */
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
+ export const ANTHROPIC_ELISION_NOTE = "[Earlier tool exchanges were removed to fit the context window.]";
28
+ /** Serialize any value to text for estimation. Never throws. */
29
+ function toText(value) {
30
+ if (typeof value === "string") {
31
+ return value;
32
+ }
33
+ if (value === null || value === undefined) {
34
+ return "";
35
+ }
36
+ try {
37
+ return JSON.stringify(value) ?? "";
38
+ }
39
+ catch {
40
+ // Past V8's string cap: enormous by definition, so charge a large fixed
41
+ // size rather than aborting the estimate and with it the turn.
42
+ return "x".repeat(200_000);
43
+ }
44
+ }
45
+ function blocksOf(message) {
46
+ return Array.isArray(message.content) ? message.content : [];
47
+ }
48
+ function hasBlockType(message, type) {
49
+ return blocksOf(message).some((block) => block.type === type);
50
+ }
51
+ /** Cost of one message including per-message framing. */
52
+ function messageTokens(message, provider) {
53
+ return estimateTokens(toText(message.content), provider) + TOKENS_PER_MESSAGE;
54
+ }
55
+ /**
56
+ * Rewrite a `tool_result` message so every block's payload is a head/tail
57
+ * preview. `cache_control` and every other block field are preserved — only
58
+ * the payload shrinks.
59
+ */
60
+ function previewToolResultMessage(message) {
61
+ if (!Array.isArray(message.content)) {
62
+ return message;
63
+ }
64
+ const content = message.content.map((block) => {
65
+ if (block.type !== "tool_result") {
66
+ return block;
67
+ }
68
+ const text = toText(block.content);
69
+ const previewOptions = {
70
+ maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES,
71
+ maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES,
72
+ };
73
+ if (!exceedsToolOutputPreviewBudget(text, previewOptions)) {
74
+ return block;
75
+ }
76
+ const { preview } = generateToolOutputPreview(text, previewOptions);
77
+ return { ...block, content: preview };
78
+ });
79
+ return { ...message, content };
80
+ }
81
+ /** Map the loop's history onto the neutral view the policy operates on. */
82
+ function toEntries(conversation, provider) {
83
+ return conversation.map((message) => {
84
+ const tokens = messageTokens(message, provider);
85
+ if (hasBlockType(message, "tool_result")) {
86
+ const previewed = previewToolResultMessage(message);
87
+ const previewTokens = messageTokens(previewed, provider);
88
+ return {
89
+ kind: "toolResult",
90
+ tokens,
91
+ // Only advertise a preview when it actually saves something — an
92
+ // already-small result must fall through to stage 2, not look
93
+ // shrinkable and stall the reclaim.
94
+ ...(previewTokens < tokens ? { previewTokens } : {}),
95
+ };
96
+ }
97
+ if (hasBlockType(message, "tool_use")) {
98
+ return { kind: "toolCall", tokens };
99
+ }
100
+ return { kind: "other", tokens };
101
+ });
102
+ }
103
+ /**
104
+ * Text-level preview used for an oversized `tool_result` payload. Exposed so
105
+ * the caller can apply it to its OWN block type — this module never rebuilds
106
+ * the caller's messages, which keeps it free of generic-spread assignability
107
+ * problems and of the double type assertions Critical Rule 14 forbids.
108
+ */
109
+ export function previewAnthropicToolResultText(text) {
110
+ const { preview } = generateToolOutputPreview(text, {
111
+ maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES,
112
+ maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES,
113
+ });
114
+ return preview;
115
+ }
116
+ /** True when this message carries `tool_result` blocks worth previewing. */
117
+ export function isAnthropicToolResultMessage(message) {
118
+ return hasBlockType(message, "tool_result");
119
+ }
120
+ /**
121
+ * Decide what to reclaim from an Anthropic-shaped agent loop.
122
+ *
123
+ * Returns `undefined` when the loop still fits — the caller must then leave its
124
+ * history byte-identical, because any rewrite invalidates the rolling
125
+ * `cache_control` prefix this path depends on.
126
+ */
127
+ export function planAnthropicLoopReclaim(args) {
128
+ const { conversation, availableInputTokens, fixedOverheadTokens, provider, observedPromptTokens, previousSentEstimate, onSentEstimate, } = args;
129
+ const entries = toEntries(conversation, provider);
130
+ const rawEstimate = fixedOverheadTokens + entries.reduce((sum, e) => sum + e.tokens, 0);
131
+ // Calibration compares a real prompt-token count against THIS guard's
132
+ // estimate for the very request that produced it. Dividing by the estimate
133
+ // for the CURRENT conversation would be a category error: the loop has since
134
+ // appended an assistant tool_use message plus its tool_result, so the
135
+ // denominator is always larger than the numerator's request. The ratio then
136
+ // reads below 1 and the `Math.max(1, …)` floor pins calibration at 1 — the
137
+ // correction silently never applies, which is exactly when a dense-code run
138
+ // overflows the window.
139
+ let calibration = 1;
140
+ if (observedPromptTokens &&
141
+ observedPromptTokens > 0 &&
142
+ previousSentEstimate &&
143
+ previousSentEstimate > 0) {
144
+ // Clamped: real tokenizers run up to ~1.3x the char estimate on dense
145
+ // code, and an unbounded ratio would compact the loop into uselessness.
146
+ calibration = Math.min(3, Math.max(1, observedPromptTokens / previousSentEstimate));
147
+ }
148
+ const plan = planLoopGuardReclaim(entries, {
149
+ availableInputTokens,
150
+ fixedOverheadTokens,
151
+ calibration,
152
+ });
153
+ if (!plan.fire) {
154
+ onSentEstimate?.(rawEstimate);
155
+ return undefined;
156
+ }
157
+ logger.info("[AnthropicLoopGuard] Reclaiming agent-loop context", {
158
+ provider,
159
+ messages: conversation.length,
160
+ toolResultsTruncated: plan.truncate.length,
161
+ messagesDropped: plan.drop.length,
162
+ projectedTokens: plan.projectedTokens,
163
+ calibration,
164
+ });
165
+ onSentEstimate?.(plan.projectedTokens);
166
+ return plan;
167
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Provider-neutral agent-loop reclaim policy.
3
+ *
4
+ * Every native provider loop grows its own history as the model calls tools,
5
+ * and every one of them can overflow the model's window mid-turn. The policy
6
+ * for reclaiming that budget is identical regardless of message shape:
7
+ *
8
+ * Stage 1 — shrink old tool outputs to head/tail previews.
9
+ * Stage 2 — drop the oldest complete tool batches, as units, when previews
10
+ * are not enough (or when outputs are already small enough that
11
+ * truncation buys nothing).
12
+ *
13
+ * What differs per provider is only the message SHAPE. So this module owns the
14
+ * decision and adapters own the mechanics: map your history onto
15
+ * `LoopGuardEntry[]`, call `planLoopGuardReclaim`, apply the returned indices.
16
+ *
17
+ * Two invariants the policy never violates:
18
+ * - entry 0 (the task) is never touched
19
+ * - a tool batch is dropped whole or not at all, so a `tool_call` never
20
+ * loses its `tool_result` (providers hard-reject the orphan)
21
+ */
22
+ import type { LoopGuardEntry, LoopGuardPlan, LoopGuardPolicy } from "../types/index.js";
23
+ /**
24
+ * Fraction of the window the guard reclaims DOWN TO once it fires.
25
+ *
26
+ * The threshold decides *when*; this decides *how far*. Reclaiming only back to
27
+ * the threshold means the next step — which appends an assistant turn plus its
28
+ * tool results — crosses it again, so the guard rewrites the oldest messages on
29
+ * every single step. That mutation invalidates the provider's cached prompt
30
+ * prefix each time. One deeper reclaim every N steps saves the same tokens and
31
+ * leaves the prefix stable in between.
32
+ */
33
+ export declare const DEFAULT_LOOP_GUARD_LOW_WATER_RATIO = 0.6;
34
+ /** Newest entries the guard never modifies. */
35
+ export declare const DEFAULT_LOOP_GUARD_PROTECTED_TAIL = 4;
36
+ /**
37
+ * Decide what to reclaim, if anything.
38
+ *
39
+ * Returns `fire: false` when the projected request is within threshold — the
40
+ * caller must then leave its history completely untouched, so a loop that fits
41
+ * never pays a cache invalidation.
42
+ */
43
+ export declare function planLoopGuardReclaim(entries: readonly LoopGuardEntry[], policy: LoopGuardPolicy): LoopGuardPlan;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Provider-neutral agent-loop reclaim policy.
3
+ *
4
+ * Every native provider loop grows its own history as the model calls tools,
5
+ * and every one of them can overflow the model's window mid-turn. The policy
6
+ * for reclaiming that budget is identical regardless of message shape:
7
+ *
8
+ * Stage 1 — shrink old tool outputs to head/tail previews.
9
+ * Stage 2 — drop the oldest complete tool batches, as units, when previews
10
+ * are not enough (or when outputs are already small enough that
11
+ * truncation buys nothing).
12
+ *
13
+ * What differs per provider is only the message SHAPE. So this module owns the
14
+ * decision and adapters own the mechanics: map your history onto
15
+ * `LoopGuardEntry[]`, call `planLoopGuardReclaim`, apply the returned indices.
16
+ *
17
+ * Two invariants the policy never violates:
18
+ * - entry 0 (the task) is never touched
19
+ * - a tool batch is dropped whole or not at all, so a `tool_call` never
20
+ * loses its `tool_result` (providers hard-reject the orphan)
21
+ */
22
+ import { DEFAULT_CONTEXT_GUARD_RATIO } from "../core/constants.js";
23
+ /**
24
+ * Fraction of the window the guard reclaims DOWN TO once it fires.
25
+ *
26
+ * The threshold decides *when*; this decides *how far*. Reclaiming only back to
27
+ * the threshold means the next step — which appends an assistant turn plus its
28
+ * tool results — crosses it again, so the guard rewrites the oldest messages on
29
+ * every single step. That mutation invalidates the provider's cached prompt
30
+ * prefix each time. One deeper reclaim every N steps saves the same tokens and
31
+ * leaves the prefix stable in between.
32
+ */
33
+ export const DEFAULT_LOOP_GUARD_LOW_WATER_RATIO = 0.6;
34
+ /** Newest entries the guard never modifies. */
35
+ export const DEFAULT_LOOP_GUARD_PROTECTED_TAIL = 4;
36
+ /** True for the two kinds that make up a tool batch. */
37
+ function isToolEntry(entry) {
38
+ return entry?.kind === "toolCall" || entry?.kind === "toolResult";
39
+ }
40
+ /**
41
+ * One agent step's tool batch: the run of calls, then the run of results that
42
+ * answers them. `end` is exclusive.
43
+ *
44
+ * The boundary is calls-then-results, NOT any maximal run of tool entries — a
45
+ * loop's history is `call,result,call,result,…`, so treating a maximal run as
46
+ * one batch would collapse the entire conversation into a single droppable
47
+ * unit and scorch it in one pass. Mirrors `collectBatch` in toolPairRepair.
48
+ */
49
+ function findToolBatches(entries, minIndex, maxIndexExclusive) {
50
+ const batches = [];
51
+ let i = minIndex;
52
+ while (i < maxIndexExclusive) {
53
+ if (!isToolEntry(entries[i])) {
54
+ i++;
55
+ continue;
56
+ }
57
+ const start = i;
58
+ // Scan each run to its TRUE end rather than clamping at the protected-tail
59
+ // boundary. A batch is droppable only as a whole: clamping would emit a
60
+ // batch ending exactly at the boundary, so stage 2 would remove the
61
+ // tool-call entry while the results at or past the boundary survive with
62
+ // nothing to pair against. Providers reject an orphaned tool result, so
63
+ // that turns a reclaim into a hard request failure.
64
+ while (i < entries.length && entries[i].kind === "toolCall") {
65
+ i++;
66
+ }
67
+ while (i < entries.length && entries[i].kind === "toolResult") {
68
+ i++;
69
+ }
70
+ if (i > maxIndexExclusive) {
71
+ // Scanning is oldest-first, so this batch reaching into the tail means
72
+ // every later one starts inside it. Nothing further is droppable.
73
+ break;
74
+ }
75
+ batches.push({ start, end: i });
76
+ }
77
+ return batches;
78
+ }
79
+ /**
80
+ * Decide what to reclaim, if anything.
81
+ *
82
+ * Returns `fire: false` when the projected request is within threshold — the
83
+ * caller must then leave its history completely untouched, so a loop that fits
84
+ * never pays a cache invalidation.
85
+ */
86
+ export function planLoopGuardReclaim(entries, policy) {
87
+ const { availableInputTokens, fixedOverheadTokens, thresholdRatio = DEFAULT_CONTEXT_GUARD_RATIO, lowWaterRatio = DEFAULT_LOOP_GUARD_LOW_WATER_RATIO, protectedTailCount = DEFAULT_LOOP_GUARD_PROTECTED_TAIL, calibration = 1, } = policy;
88
+ const safeCalibration = calibration > 0 ? calibration : 1;
89
+ const thresholdTokens = Math.floor((availableInputTokens * thresholdRatio) / safeCalibration);
90
+ const lowWaterTokens = Math.floor((availableInputTokens * lowWaterRatio) / safeCalibration);
91
+ let total = fixedOverheadTokens;
92
+ for (const entry of entries) {
93
+ total += entry.tokens;
94
+ }
95
+ if (total <= thresholdTokens) {
96
+ return { fire: false, truncate: [], drop: [], projectedTokens: total };
97
+ }
98
+ // Entry 0 is the task and the newest `protectedTailCount` entries are the
99
+ // live working set — the mutable window sits strictly between them.
100
+ const minIndex = 1;
101
+ const maxIndexExclusive = Math.max(minIndex, entries.length - protectedTailCount);
102
+ const truncate = [];
103
+ const dropped = new Set();
104
+ // Stage 1: shrink old tool outputs, oldest first.
105
+ for (let i = minIndex; i < maxIndexExclusive && total > lowWaterTokens; i++) {
106
+ const entry = entries[i];
107
+ if (entry.kind !== "toolResult" || entry.previewTokens === undefined) {
108
+ continue;
109
+ }
110
+ const saving = entry.tokens - entry.previewTokens;
111
+ if (saving <= 0) {
112
+ continue;
113
+ }
114
+ truncate.push(i);
115
+ total -= saving;
116
+ }
117
+ // Stage 2: drop whole batches, oldest first, while still over the low-water
118
+ // mark. This is the only lever when outputs are already smaller than a
119
+ // preview — the regime where truncation alone cannot converge.
120
+ if (total > lowWaterTokens) {
121
+ const batches = findToolBatches(entries, minIndex, maxIndexExclusive);
122
+ for (const batch of batches) {
123
+ if (total <= lowWaterTokens) {
124
+ break;
125
+ }
126
+ for (let i = batch.start; i < batch.end; i++) {
127
+ const entry = entries[i];
128
+ // A truncated entry now costs its preview, not its original payload.
129
+ const effective = truncate.includes(i)
130
+ ? (entry.previewTokens ?? entry.tokens)
131
+ : entry.tokens;
132
+ total -= effective;
133
+ dropped.add(i);
134
+ }
135
+ }
136
+ }
137
+ // Truncating an entry that is being dropped anyway is wasted work.
138
+ const effectiveTruncate = truncate.filter((i) => !dropped.has(i));
139
+ return {
140
+ fire: effectiveTruncate.length > 0 || dropped.size > 0,
141
+ truncate: effectiveTruncate,
142
+ drop: [...dropped].sort((a, b) => a - b),
143
+ projectedTokens: total,
144
+ };
145
+ }
@@ -0,0 +1,42 @@
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 type { OpenAICompatChatMessage } from "../types/index.js";
20
+ /**
21
+ * Reclaim context budget from a chat-completions loop's conversation.
22
+ *
23
+ * Returns a NEW array when it acted, or `undefined` when the loop fits and the
24
+ * history must be left byte-identical — any rewrite invalidates the provider's
25
+ * cached prompt prefix, so "no change" has to mean no change.
26
+ *
27
+ * @param observedPromptTokens the provider's reported prompt token count for
28
+ * the previous step, used to calibrate the char-based estimate for free.
29
+ * @param previousSentEstimate this guard's own estimate for the request that
30
+ * produced `observedPromptTokens`. The two must describe the SAME request.
31
+ * @param onSentEstimate receives the estimate for the request about to be sent,
32
+ * so the caller can feed it back as the next step's `previousSentEstimate`.
33
+ */
34
+ export declare function guardOpenAICompatConversation(args: {
35
+ conversation: OpenAICompatChatMessage[];
36
+ availableInputTokens: number;
37
+ fixedOverheadTokens: number;
38
+ provider?: string;
39
+ observedPromptTokens?: number;
40
+ previousSentEstimate?: number;
41
+ onSentEstimate?: (tokens: number) => void;
42
+ }): OpenAICompatChatMessage[] | undefined;
@@ -0,0 +1,172 @@
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
+ }
@@ -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.