@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +392 -391
- package/dist/context/anthropicLoopGuard.d.ts +46 -0
- package/dist/context/anthropicLoopGuard.js +167 -0
- package/dist/context/loopGuardCore.d.ts +43 -0
- package/dist/context/loopGuardCore.js +145 -0
- package/dist/context/openaiCompatLoopGuard.d.ts +42 -0
- package/dist/context/openaiCompatLoopGuard.js +172 -0
- package/dist/context/toolOutputLimits.d.ts +11 -0
- package/dist/context/toolOutputLimits.js +16 -0
- package/dist/core/conversationMemoryManager.d.ts +25 -0
- package/dist/core/conversationMemoryManager.js +71 -0
- package/dist/lib/context/anthropicLoopGuard.d.ts +46 -0
- package/dist/lib/context/anthropicLoopGuard.js +168 -0
- package/dist/lib/context/loopGuardCore.d.ts +43 -0
- package/dist/lib/context/loopGuardCore.js +146 -0
- package/dist/lib/context/openaiCompatLoopGuard.d.ts +42 -0
- package/dist/lib/context/openaiCompatLoopGuard.js +173 -0
- package/dist/lib/context/toolOutputLimits.d.ts +11 -0
- package/dist/lib/context/toolOutputLimits.js +16 -0
- package/dist/lib/core/conversationMemoryManager.d.ts +25 -0
- package/dist/lib/core/conversationMemoryManager.js +71 -0
- package/dist/lib/neurolink.d.ts +8 -2
- package/dist/lib/neurolink.js +18 -11
- package/dist/lib/providers/anthropic/client.js +98 -0
- package/dist/lib/providers/openaiChatCompletionsBase.js +38 -1
- package/dist/lib/types/context.d.ts +73 -0
- package/dist/lib/types/conversationMemoryInterface.d.ts +22 -0
- package/dist/neurolink.d.ts +8 -2
- package/dist/neurolink.js +18 -11
- package/dist/providers/anthropic/client.js +98 -0
- package/dist/providers/openaiChatCompletionsBase.js +38 -1
- package/dist/types/context.d.ts +73 -0
- package/dist/types/conversationMemoryInterface.d.ts +22 -0
- 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
|
|
@@ -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.
|
|
@@ -80,6 +80,31 @@ export declare class ConversationMemoryManager implements IConversationMemoryMan
|
|
|
80
80
|
* Resets summary pointers since old pointers may reference messages that no longer exist.
|
|
81
81
|
*/
|
|
82
82
|
setSessionMessages(sessionId: string, messages: ChatMessage[], userId?: string): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Persist a step's tool calls and results as `tool_call` / `tool_result`
|
|
85
|
+
* messages, mirroring the Redis manager.
|
|
86
|
+
*
|
|
87
|
+
* Parity fix: this used to exist only on the Redis backend, so an in-memory
|
|
88
|
+
* session never turned tool activity into messages and every downstream path
|
|
89
|
+
* that reasons about tool batches (compaction, pruning, pair repair) saw a
|
|
90
|
+
* different history shape depending on `STORAGE_TYPE`.
|
|
91
|
+
*
|
|
92
|
+
* Calls are written before results — the same order the Redis flush uses —
|
|
93
|
+
* and `toolCallId` is carried on BOTH sides so `repairToolPairs` can match by
|
|
94
|
+
* id rather than adjacency (a parallel batch has no positional pairing).
|
|
95
|
+
*/
|
|
96
|
+
storeToolExecution(sessionId: string, userId: string | undefined, toolCalls: Array<{
|
|
97
|
+
toolCallId?: string;
|
|
98
|
+
toolName?: string;
|
|
99
|
+
args?: Record<string, unknown>;
|
|
100
|
+
[key: string]: unknown;
|
|
101
|
+
}>, toolResults: Array<{
|
|
102
|
+
toolCallId?: string;
|
|
103
|
+
output?: unknown;
|
|
104
|
+
result?: unknown;
|
|
105
|
+
error?: string;
|
|
106
|
+
[key: string]: unknown;
|
|
107
|
+
}>, currentTime?: Date): Promise<void>;
|
|
83
108
|
/** Close/shutdown — no-op for in-memory manager (no external connections to release) */
|
|
84
109
|
close(): Promise<void>;
|
|
85
110
|
}
|
|
@@ -392,6 +392,77 @@ export class ConversationMemoryManager {
|
|
|
392
392
|
session.lastCountedAt = undefined;
|
|
393
393
|
session.lastActivity = Date.now();
|
|
394
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Persist a step's tool calls and results as `tool_call` / `tool_result`
|
|
397
|
+
* messages, mirroring the Redis manager.
|
|
398
|
+
*
|
|
399
|
+
* Parity fix: this used to exist only on the Redis backend, so an in-memory
|
|
400
|
+
* session never turned tool activity into messages and every downstream path
|
|
401
|
+
* that reasons about tool batches (compaction, pruning, pair repair) saw a
|
|
402
|
+
* different history shape depending on `STORAGE_TYPE`.
|
|
403
|
+
*
|
|
404
|
+
* Calls are written before results — the same order the Redis flush uses —
|
|
405
|
+
* and `toolCallId` is carried on BOTH sides so `repairToolPairs` can match by
|
|
406
|
+
* id rather than adjacency (a parallel batch has no positional pairing).
|
|
407
|
+
*/
|
|
408
|
+
async storeToolExecution(sessionId, userId, toolCalls, toolResults, currentTime) {
|
|
409
|
+
await this.ensureInitialized();
|
|
410
|
+
let session = this.sessions.get(sessionId);
|
|
411
|
+
if (!session) {
|
|
412
|
+
session = this.createNewSession(sessionId, userId);
|
|
413
|
+
this.sessions.set(sessionId, session);
|
|
414
|
+
this.enforceSessionLimit();
|
|
415
|
+
}
|
|
416
|
+
const timestamp = (currentTime ?? new Date()).toISOString();
|
|
417
|
+
const toolNameById = new Map();
|
|
418
|
+
for (const toolCall of toolCalls ?? []) {
|
|
419
|
+
const toolCallId = toolCall.toolCallId ?? "";
|
|
420
|
+
const toolName = toolCall.toolName ?? "unknown";
|
|
421
|
+
if (toolCallId) {
|
|
422
|
+
toolNameById.set(toolCallId, toolName);
|
|
423
|
+
}
|
|
424
|
+
session.messages.push({
|
|
425
|
+
id: randomUUID(),
|
|
426
|
+
role: "tool_call",
|
|
427
|
+
content: "", // Tool calls carry their payload in `args`, not content.
|
|
428
|
+
tool: toolName,
|
|
429
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
430
|
+
args: (toolCall.args ?? {}),
|
|
431
|
+
timestamp,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
for (const toolResult of toolResults ?? []) {
|
|
435
|
+
const toolCallId = toolResult.toolCallId ?? "";
|
|
436
|
+
const toolName = (toolCallId ? toolNameById.get(toolCallId) : undefined) ??
|
|
437
|
+
String(toolResult.toolName ?? "unknown");
|
|
438
|
+
const rawOutput = "output" in toolResult ? toolResult.output : toolResult.result;
|
|
439
|
+
let content;
|
|
440
|
+
if (typeof rawOutput === "string") {
|
|
441
|
+
content = rawOutput;
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
try {
|
|
445
|
+
content = JSON.stringify(rawOutput ?? null) ?? "null";
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
content = `[Serialization failed: ${error instanceof Error ? error.message : String(error)}]`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
session.messages.push({
|
|
452
|
+
id: randomUUID(),
|
|
453
|
+
role: "tool_result",
|
|
454
|
+
content,
|
|
455
|
+
tool: toolName,
|
|
456
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
457
|
+
result: {
|
|
458
|
+
success: !toolResult.error,
|
|
459
|
+
...(toolResult.error ? { error: String(toolResult.error) } : {}),
|
|
460
|
+
},
|
|
461
|
+
timestamp,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
session.lastActivity = Date.now();
|
|
465
|
+
}
|
|
395
466
|
/** Close/shutdown — no-op for in-memory manager (no external connections to release) */
|
|
396
467
|
async close() {
|
|
397
468
|
// In-memory manager has nothing to close
|
package/dist/lib/neurolink.d.ts
CHANGED
|
@@ -1787,8 +1787,14 @@ export declare class NeuroLink {
|
|
|
1787
1787
|
[key: string]: unknown;
|
|
1788
1788
|
}>, currentTime?: Date): Promise<void>;
|
|
1789
1789
|
/**
|
|
1790
|
-
* Check if tool execution storage is available
|
|
1791
|
-
*
|
|
1790
|
+
* Check if tool execution storage is available.
|
|
1791
|
+
*
|
|
1792
|
+
* Now capability-based rather than Redis-specific: any configured memory
|
|
1793
|
+
* backend implementing `storeToolExecution` qualifies. The old check
|
|
1794
|
+
* required `STORAGE_TYPE === "redis"` AND a Redis manager by class name, so
|
|
1795
|
+
* in-memory sessions reported false and silently skipped tool persistence.
|
|
1796
|
+
*
|
|
1797
|
+
* @returns whether the active memory backend can persist tool executions
|
|
1792
1798
|
*/
|
|
1793
1799
|
isToolExecutionStorageAvailable(): boolean;
|
|
1794
1800
|
/**
|
package/dist/lib/neurolink.js
CHANGED
|
@@ -11129,11 +11129,16 @@ Current user's request: ${currentInput}`;
|
|
|
11129
11129
|
});
|
|
11130
11130
|
return;
|
|
11131
11131
|
}
|
|
11132
|
-
//
|
|
11133
|
-
|
|
11134
|
-
|
|
11132
|
+
// Any backend that implements storeToolExecution — no longer a Redis cast.
|
|
11133
|
+
// The in-memory manager implements it too, so tool activity becomes
|
|
11134
|
+
// tool_call/tool_result messages regardless of STORAGE_TYPE.
|
|
11135
|
+
const memory = this.conversationMemory;
|
|
11136
|
+
if (!memory?.storeToolExecution) {
|
|
11137
|
+
logger.debug("Tool execution storage not supported by this memory backend");
|
|
11138
|
+
return;
|
|
11139
|
+
}
|
|
11135
11140
|
try {
|
|
11136
|
-
await
|
|
11141
|
+
await memory.storeToolExecution(sessionId, userId, toolCalls, toolResults, currentTime);
|
|
11137
11142
|
}
|
|
11138
11143
|
catch (error) {
|
|
11139
11144
|
logger.warn("Failed to store tool executions", {
|
|
@@ -11145,15 +11150,17 @@ Current user's request: ${currentInput}`;
|
|
|
11145
11150
|
}
|
|
11146
11151
|
}
|
|
11147
11152
|
/**
|
|
11148
|
-
* Check if tool execution storage is available
|
|
11149
|
-
*
|
|
11153
|
+
* Check if tool execution storage is available.
|
|
11154
|
+
*
|
|
11155
|
+
* Now capability-based rather than Redis-specific: any configured memory
|
|
11156
|
+
* backend implementing `storeToolExecution` qualifies. The old check
|
|
11157
|
+
* required `STORAGE_TYPE === "redis"` AND a Redis manager by class name, so
|
|
11158
|
+
* in-memory sessions reported false and silently skipped tool persistence.
|
|
11159
|
+
*
|
|
11160
|
+
* @returns whether the active memory backend can persist tool executions
|
|
11150
11161
|
*/
|
|
11151
11162
|
isToolExecutionStorageAvailable() {
|
|
11152
|
-
|
|
11153
|
-
const hasRedisConversationMemory = this.conversationMemory &&
|
|
11154
|
-
this.conversationMemory.constructor.name ===
|
|
11155
|
-
"RedisConversationMemoryManager";
|
|
11156
|
-
return !!(isRedisStorage && hasRedisConversationMemory);
|
|
11163
|
+
return typeof this.conversationMemory?.storeToolExecution === "function";
|
|
11157
11164
|
}
|
|
11158
11165
|
/**
|
|
11159
11166
|
* Get the raw messages array for a session.
|
|
@@ -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);
|