@juspay/neurolink 9.81.1 → 9.81.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +356 -356
  3. package/dist/constants/contextWindows.js +10 -0
  4. package/dist/core/constants.d.ts +16 -0
  5. package/dist/core/constants.js +16 -0
  6. package/dist/core/modules/ToolsManager.d.ts +5 -0
  7. package/dist/core/modules/ToolsManager.js +62 -6
  8. package/dist/lib/constants/contextWindows.js +10 -0
  9. package/dist/lib/core/constants.d.ts +16 -0
  10. package/dist/lib/core/constants.js +16 -0
  11. package/dist/lib/core/modules/ToolsManager.d.ts +5 -0
  12. package/dist/lib/core/modules/ToolsManager.js +62 -6
  13. package/dist/lib/neurolink.js +42 -6
  14. package/dist/lib/providers/googleNativeGemini3.d.ts +43 -0
  15. package/dist/lib/providers/googleNativeGemini3.js +73 -1
  16. package/dist/lib/providers/googleVertex.js +495 -80
  17. package/dist/lib/types/generate.d.ts +5 -1
  18. package/dist/lib/utils/async/index.d.ts +1 -1
  19. package/dist/lib/utils/async/index.js +1 -1
  20. package/dist/lib/utils/async/withTimeout.d.ts +12 -0
  21. package/dist/lib/utils/async/withTimeout.js +45 -0
  22. package/dist/lib/utils/mcpErrorText.d.ts +11 -0
  23. package/dist/lib/utils/mcpErrorText.js +23 -0
  24. package/dist/neurolink.js +42 -6
  25. package/dist/providers/googleNativeGemini3.d.ts +43 -0
  26. package/dist/providers/googleNativeGemini3.js +73 -1
  27. package/dist/providers/googleVertex.js +495 -80
  28. package/dist/types/generate.d.ts +5 -1
  29. package/dist/utils/async/index.d.ts +1 -1
  30. package/dist/utils/async/index.js +1 -1
  31. package/dist/utils/async/withTimeout.d.ts +12 -0
  32. package/dist/utils/async/withTimeout.js +45 -0
  33. package/dist/utils/mcpErrorText.d.ts +11 -0
  34. package/dist/utils/mcpErrorText.js +23 -0
  35. package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +1258 -0
  36. package/package.json +1 -1
@@ -154,6 +154,8 @@ export const MODEL_CONTEXT_WINDOWS = {
154
154
  },
155
155
  anthropic: {
156
156
  _default: 200_000,
157
+ // Claude 5 (mid 2026) — 1M context window
158
+ "claude-sonnet-5": 1_000_000,
157
159
  // Claude 4.6 (Feb 2026) — 1M context window
158
160
  "claude-opus-4-6": 1_000_000,
159
161
  "claude-sonnet-4-6": 1_000_000,
@@ -243,6 +245,7 @@ export const MODEL_CONTEXT_WINDOWS = {
243
245
  vertex: {
244
246
  _default: 1_048_576,
245
247
  // Claude on Vertex
248
+ "claude-sonnet-5": 1_000_000,
246
249
  "claude-opus-4-6": 1_000_000,
247
250
  "claude-sonnet-4-6": 1_000_000,
248
251
  "claude-sonnet-4-5": 200_000,
@@ -252,6 +255,13 @@ export const MODEL_CONTEXT_WINDOWS = {
252
255
  "claude-sonnet-4-20250514": 200_000,
253
256
  "claude-opus-4-20250514": 200_000,
254
257
  "claude-opus-4": 200_000,
258
+ // Catch-all for UNKNOWN Claude models on Vertex (prefix match runs after
259
+ // the specific keys above). Without this, an unlisted Claude model falls
260
+ // to the Gemini-shaped _default (1,048,576) — ABOVE Anthropic's real 1M
261
+ // API cap — so the pre-dispatch budget check and the in-loop context
262
+ // guard both under-guard it (the claude-sonnet-5 1,005,647-token 400s).
263
+ // 200K is the conservative Anthropic floor; list real models explicitly.
264
+ "claude-": 200_000,
255
265
  // Gemini 3.1 on Vertex (all require -preview suffix)
256
266
  "gemini-3.1-pro-preview": 1_048_576,
257
267
  "gemini-3.1-flash-lite-preview": 1_048_576,
@@ -36,6 +36,22 @@ export declare const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300000;
36
36
  * a wrap-up nudge rides the next tool-result turn.
37
37
  */
38
38
  export declare const DEFAULT_WRAPUP_TIME_LEAD_MS = 120000;
39
+ /**
40
+ * In-loop context guard threshold for native agentic loops: when the last
41
+ * model call's actual prompt size (provider-reported usage) plus the
42
+ * estimated growth from this step's tool results crosses this fraction of
43
+ * the model's context window, the loop stops calling tools and synthesizes a
44
+ * final answer instead of stepping into a provider 400 ("prompt is too
45
+ * long") that would destroy the whole turn's work.
46
+ */
47
+ export declare const DEFAULT_CONTEXT_GUARD_RATIO = 0.85;
48
+ /**
49
+ * Floor for the turn budget handed to the post-overflow recovery retry.
50
+ * The retry inherits the REMAINING `turnTimeoutMs` (whole-turn semantics —
51
+ * one generate() must not stack two full budgets), but never less than this,
52
+ * so a compacted retry still gets a workable window.
53
+ */
54
+ export declare const MIN_RECOVERY_TURN_BUDGET_MS = 30000;
39
55
  export declare const TOOL_STORAGE_TIMEOUT_MS = 5000;
40
56
  export declare const STEP_LIMITS: {
41
57
  min: number;
@@ -122,6 +122,22 @@ export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300_000;
122
122
  * a wrap-up nudge rides the next tool-result turn.
123
123
  */
124
124
  export const DEFAULT_WRAPUP_TIME_LEAD_MS = 120_000;
125
+ /**
126
+ * In-loop context guard threshold for native agentic loops: when the last
127
+ * model call's actual prompt size (provider-reported usage) plus the
128
+ * estimated growth from this step's tool results crosses this fraction of
129
+ * the model's context window, the loop stops calling tools and synthesizes a
130
+ * final answer instead of stepping into a provider 400 ("prompt is too
131
+ * long") that would destroy the whole turn's work.
132
+ */
133
+ export const DEFAULT_CONTEXT_GUARD_RATIO = 0.85;
134
+ /**
135
+ * Floor for the turn budget handed to the post-overflow recovery retry.
136
+ * The retry inherits the REMAINING `turnTimeoutMs` (whole-turn semantics —
137
+ * one generate() must not stack two full budgets), but never less than this,
138
+ * so a compacted retry still gets a workable window.
139
+ */
140
+ export const MIN_RECOVERY_TURN_BUDGET_MS = 30_000;
125
141
  // Fire-and-forget tool storage writes (Redis). 5s is generous for a single
126
142
  // Redis write; if breached, the .catch logs a warning.
127
143
  export const TOOL_STORAGE_TIMEOUT_MS = 5000;
@@ -18,6 +18,11 @@ export declare class ToolsManager {
18
18
  /**
19
19
  * BZ-666: Wrap tool execute with output truncation to prevent
20
20
  * context overflow when large results flow into the AI SDK accumulator.
21
+ *
22
+ * Passes the AI-SDK second argument (execution options: abortSignal,
23
+ * toolCallId, messages) through to the inner execute — the native loops
24
+ * provide an abortSignal there, and dropping it at this wrapper made
25
+ * every tool uncancellable (deadline overshoot / ghost executions).
21
26
  */
22
27
  private wrapExecuteWithTruncation;
23
28
  /**
@@ -7,6 +7,48 @@ import { getKeyCount } from "../../utils/transformationUtils.js";
7
7
  import { convertJsonSchemaToZod } from "../../utils/schemaConversion.js";
8
8
  import { generateToolOutputPreview } from "../../context/toolOutputLimits.js";
9
9
  import { tool as createAISDKTool, jsonSchema } from "../../utils/tool.js";
10
+ /** Abort-shaped error so provider loops route it to their cancellation path. */
11
+ function makeToolAbortError() {
12
+ const e = new Error("Tool execution aborted");
13
+ e.name = "AbortError";
14
+ return e;
15
+ }
16
+ /**
17
+ * Race a tool-execution promise against an AbortSignal so the calling loop
18
+ * observes a deadline/caller abort IMMEDIATELY instead of waiting for the
19
+ * tool to finish or its execution timeout to expire. The underlying call is
20
+ * not cancelled (bounded ghost execution — the same tradeoff as the tool
21
+ * timeout); real transport-level cancellation (executeExternalMCPTool → MCP
22
+ * client RequestOptions.signal) is tracked as a follow-up.
23
+ */
24
+ function raceWithAbortSignal(promise, signal) {
25
+ if (signal.aborted) {
26
+ // Swallow the abandoned settlement so it can't become an unhandled
27
+ // rejection later.
28
+ promise.catch(() => {
29
+ // Swallow the abandoned settlement — it must never surface as an
30
+ // unhandled rejection after the race has already been decided.
31
+ });
32
+ return Promise.reject(makeToolAbortError());
33
+ }
34
+ return new Promise((resolve, reject) => {
35
+ const onAbort = () => {
36
+ promise.catch(() => {
37
+ // Swallow the abandoned settlement — it must never surface as an
38
+ // unhandled rejection after the race has already been decided.
39
+ });
40
+ reject(makeToolAbortError());
41
+ };
42
+ signal.addEventListener("abort", onAbort, { once: true });
43
+ promise.then((value) => {
44
+ signal.removeEventListener("abort", onAbort);
45
+ resolve(value);
46
+ }, (error) => {
47
+ signal.removeEventListener("abort", onAbort);
48
+ reject(error);
49
+ });
50
+ });
51
+ }
10
52
  /**
11
53
  * ToolsManager class - Handles all tool management operations
12
54
  */
@@ -32,10 +74,24 @@ export class ToolsManager {
32
74
  /**
33
75
  * BZ-666: Wrap tool execute with output truncation to prevent
34
76
  * context overflow when large results flow into the AI SDK accumulator.
77
+ *
78
+ * Passes the AI-SDK second argument (execution options: abortSignal,
79
+ * toolCallId, messages) through to the inner execute — the native loops
80
+ * provide an abortSignal there, and dropping it at this wrapper made
81
+ * every tool uncancellable (deadline overshoot / ghost executions).
35
82
  */
36
83
  wrapExecuteWithTruncation(toolName, originalExecute) {
37
- return async (params) => {
38
- const result = await originalExecute(params);
84
+ return async (params, execOptions) => {
85
+ const signal = execOptions
86
+ ?.abortSignal;
87
+ const inner = originalExecute(params, execOptions);
88
+ // Inner executes that ignore the signal (external MCP / custom tools —
89
+ // the signal isn't plumbed to their transports yet) still return
90
+ // promptly on abort via the race; the loop's isAbortError handling
91
+ // treats the rejection as a cancellation, not a tool failure.
92
+ const result = signal && typeof signal.addEventListener === "function"
93
+ ? await raceWithAbortSignal(inner, signal)
94
+ : await inner;
39
95
  return this.truncateToolResult(toolName, result);
40
96
  };
41
97
  }
@@ -241,11 +297,11 @@ export class ToolsManager {
241
297
  const guardedExecute = this.wrapExecuteWithTruncation(toolName, originalExecute);
242
298
  tools[toolName] = {
243
299
  ...directTool,
244
- execute: async (params) => {
300
+ execute: async (params, execOptions) => {
245
301
  const startTime = Date.now();
246
302
  this.emitToolEvent("tool:start", toolName, { input: params });
247
303
  try {
248
- const result = await guardedExecute(params);
304
+ const result = await guardedExecute(params, execOptions);
249
305
  this.emitToolEvent("tool:end", toolName, {
250
306
  result,
251
307
  success: true,
@@ -541,11 +597,11 @@ export class ToolsManager {
541
597
  return createAISDKTool({
542
598
  description: tool.description || `External MCP tool ${tool.name}`,
543
599
  inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters)
544
- execute: async (params) => {
600
+ execute: async (params, execOptions) => {
545
601
  const startTime = Date.now();
546
602
  this.emitToolEvent("tool:start", tool.name, { input: params });
547
603
  try {
548
- const result = await guardedExecute(params);
604
+ const result = await guardedExecute(params, execOptions);
549
605
  this.emitToolEvent("tool:end", tool.name, {
550
606
  result,
551
607
  success: true,
@@ -154,6 +154,8 @@ export const MODEL_CONTEXT_WINDOWS = {
154
154
  },
155
155
  anthropic: {
156
156
  _default: 200_000,
157
+ // Claude 5 (mid 2026) — 1M context window
158
+ "claude-sonnet-5": 1_000_000,
157
159
  // Claude 4.6 (Feb 2026) — 1M context window
158
160
  "claude-opus-4-6": 1_000_000,
159
161
  "claude-sonnet-4-6": 1_000_000,
@@ -243,6 +245,7 @@ export const MODEL_CONTEXT_WINDOWS = {
243
245
  vertex: {
244
246
  _default: 1_048_576,
245
247
  // Claude on Vertex
248
+ "claude-sonnet-5": 1_000_000,
246
249
  "claude-opus-4-6": 1_000_000,
247
250
  "claude-sonnet-4-6": 1_000_000,
248
251
  "claude-sonnet-4-5": 200_000,
@@ -252,6 +255,13 @@ export const MODEL_CONTEXT_WINDOWS = {
252
255
  "claude-sonnet-4-20250514": 200_000,
253
256
  "claude-opus-4-20250514": 200_000,
254
257
  "claude-opus-4": 200_000,
258
+ // Catch-all for UNKNOWN Claude models on Vertex (prefix match runs after
259
+ // the specific keys above). Without this, an unlisted Claude model falls
260
+ // to the Gemini-shaped _default (1,048,576) — ABOVE Anthropic's real 1M
261
+ // API cap — so the pre-dispatch budget check and the in-loop context
262
+ // guard both under-guard it (the claude-sonnet-5 1,005,647-token 400s).
263
+ // 200K is the conservative Anthropic floor; list real models explicitly.
264
+ "claude-": 200_000,
255
265
  // Gemini 3.1 on Vertex (all require -preview suffix)
256
266
  "gemini-3.1-pro-preview": 1_048_576,
257
267
  "gemini-3.1-flash-lite-preview": 1_048_576,
@@ -36,6 +36,22 @@ export declare const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300000;
36
36
  * a wrap-up nudge rides the next tool-result turn.
37
37
  */
38
38
  export declare const DEFAULT_WRAPUP_TIME_LEAD_MS = 120000;
39
+ /**
40
+ * In-loop context guard threshold for native agentic loops: when the last
41
+ * model call's actual prompt size (provider-reported usage) plus the
42
+ * estimated growth from this step's tool results crosses this fraction of
43
+ * the model's context window, the loop stops calling tools and synthesizes a
44
+ * final answer instead of stepping into a provider 400 ("prompt is too
45
+ * long") that would destroy the whole turn's work.
46
+ */
47
+ export declare const DEFAULT_CONTEXT_GUARD_RATIO = 0.85;
48
+ /**
49
+ * Floor for the turn budget handed to the post-overflow recovery retry.
50
+ * The retry inherits the REMAINING `turnTimeoutMs` (whole-turn semantics —
51
+ * one generate() must not stack two full budgets), but never less than this,
52
+ * so a compacted retry still gets a workable window.
53
+ */
54
+ export declare const MIN_RECOVERY_TURN_BUDGET_MS = 30000;
39
55
  export declare const TOOL_STORAGE_TIMEOUT_MS = 5000;
40
56
  export declare const STEP_LIMITS: {
41
57
  min: number;
@@ -122,6 +122,22 @@ export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300_000;
122
122
  * a wrap-up nudge rides the next tool-result turn.
123
123
  */
124
124
  export const DEFAULT_WRAPUP_TIME_LEAD_MS = 120_000;
125
+ /**
126
+ * In-loop context guard threshold for native agentic loops: when the last
127
+ * model call's actual prompt size (provider-reported usage) plus the
128
+ * estimated growth from this step's tool results crosses this fraction of
129
+ * the model's context window, the loop stops calling tools and synthesizes a
130
+ * final answer instead of stepping into a provider 400 ("prompt is too
131
+ * long") that would destroy the whole turn's work.
132
+ */
133
+ export const DEFAULT_CONTEXT_GUARD_RATIO = 0.85;
134
+ /**
135
+ * Floor for the turn budget handed to the post-overflow recovery retry.
136
+ * The retry inherits the REMAINING `turnTimeoutMs` (whole-turn semantics —
137
+ * one generate() must not stack two full budgets), but never less than this,
138
+ * so a compacted retry still gets a workable window.
139
+ */
140
+ export const MIN_RECOVERY_TURN_BUDGET_MS = 30_000;
125
141
  // Fire-and-forget tool storage writes (Redis). 5s is generous for a single
126
142
  // Redis write; if breached, the .catch logs a warning.
127
143
  export const TOOL_STORAGE_TIMEOUT_MS = 5000;
@@ -18,6 +18,11 @@ export declare class ToolsManager {
18
18
  /**
19
19
  * BZ-666: Wrap tool execute with output truncation to prevent
20
20
  * context overflow when large results flow into the AI SDK accumulator.
21
+ *
22
+ * Passes the AI-SDK second argument (execution options: abortSignal,
23
+ * toolCallId, messages) through to the inner execute — the native loops
24
+ * provide an abortSignal there, and dropping it at this wrapper made
25
+ * every tool uncancellable (deadline overshoot / ghost executions).
21
26
  */
22
27
  private wrapExecuteWithTruncation;
23
28
  /**
@@ -7,6 +7,48 @@ import { getKeyCount } from "../../utils/transformationUtils.js";
7
7
  import { convertJsonSchemaToZod } from "../../utils/schemaConversion.js";
8
8
  import { generateToolOutputPreview } from "../../context/toolOutputLimits.js";
9
9
  import { tool as createAISDKTool, jsonSchema } from "../../utils/tool.js";
10
+ /** Abort-shaped error so provider loops route it to their cancellation path. */
11
+ function makeToolAbortError() {
12
+ const e = new Error("Tool execution aborted");
13
+ e.name = "AbortError";
14
+ return e;
15
+ }
16
+ /**
17
+ * Race a tool-execution promise against an AbortSignal so the calling loop
18
+ * observes a deadline/caller abort IMMEDIATELY instead of waiting for the
19
+ * tool to finish or its execution timeout to expire. The underlying call is
20
+ * not cancelled (bounded ghost execution — the same tradeoff as the tool
21
+ * timeout); real transport-level cancellation (executeExternalMCPTool → MCP
22
+ * client RequestOptions.signal) is tracked as a follow-up.
23
+ */
24
+ function raceWithAbortSignal(promise, signal) {
25
+ if (signal.aborted) {
26
+ // Swallow the abandoned settlement so it can't become an unhandled
27
+ // rejection later.
28
+ promise.catch(() => {
29
+ // Swallow the abandoned settlement — it must never surface as an
30
+ // unhandled rejection after the race has already been decided.
31
+ });
32
+ return Promise.reject(makeToolAbortError());
33
+ }
34
+ return new Promise((resolve, reject) => {
35
+ const onAbort = () => {
36
+ promise.catch(() => {
37
+ // Swallow the abandoned settlement — it must never surface as an
38
+ // unhandled rejection after the race has already been decided.
39
+ });
40
+ reject(makeToolAbortError());
41
+ };
42
+ signal.addEventListener("abort", onAbort, { once: true });
43
+ promise.then((value) => {
44
+ signal.removeEventListener("abort", onAbort);
45
+ resolve(value);
46
+ }, (error) => {
47
+ signal.removeEventListener("abort", onAbort);
48
+ reject(error);
49
+ });
50
+ });
51
+ }
10
52
  /**
11
53
  * ToolsManager class - Handles all tool management operations
12
54
  */
@@ -32,10 +74,24 @@ export class ToolsManager {
32
74
  /**
33
75
  * BZ-666: Wrap tool execute with output truncation to prevent
34
76
  * context overflow when large results flow into the AI SDK accumulator.
77
+ *
78
+ * Passes the AI-SDK second argument (execution options: abortSignal,
79
+ * toolCallId, messages) through to the inner execute — the native loops
80
+ * provide an abortSignal there, and dropping it at this wrapper made
81
+ * every tool uncancellable (deadline overshoot / ghost executions).
35
82
  */
36
83
  wrapExecuteWithTruncation(toolName, originalExecute) {
37
- return async (params) => {
38
- const result = await originalExecute(params);
84
+ return async (params, execOptions) => {
85
+ const signal = execOptions
86
+ ?.abortSignal;
87
+ const inner = originalExecute(params, execOptions);
88
+ // Inner executes that ignore the signal (external MCP / custom tools —
89
+ // the signal isn't plumbed to their transports yet) still return
90
+ // promptly on abort via the race; the loop's isAbortError handling
91
+ // treats the rejection as a cancellation, not a tool failure.
92
+ const result = signal && typeof signal.addEventListener === "function"
93
+ ? await raceWithAbortSignal(inner, signal)
94
+ : await inner;
39
95
  return this.truncateToolResult(toolName, result);
40
96
  };
41
97
  }
@@ -241,11 +297,11 @@ export class ToolsManager {
241
297
  const guardedExecute = this.wrapExecuteWithTruncation(toolName, originalExecute);
242
298
  tools[toolName] = {
243
299
  ...directTool,
244
- execute: async (params) => {
300
+ execute: async (params, execOptions) => {
245
301
  const startTime = Date.now();
246
302
  this.emitToolEvent("tool:start", toolName, { input: params });
247
303
  try {
248
- const result = await guardedExecute(params);
304
+ const result = await guardedExecute(params, execOptions);
249
305
  this.emitToolEvent("tool:end", toolName, {
250
306
  result,
251
307
  success: true,
@@ -541,11 +597,11 @@ export class ToolsManager {
541
597
  return createAISDKTool({
542
598
  description: tool.description || `External MCP tool ${tool.name}`,
543
599
  inputSchema: finalSchema, // AI SDK v6 uses inputSchema (not parameters)
544
- execute: async (params) => {
600
+ execute: async (params, execOptions) => {
545
601
  const startTime = Date.now();
546
602
  this.emitToolEvent("tool:start", tool.name, { input: params });
547
603
  try {
548
- const result = await guardedExecute(params);
604
+ const result = await guardedExecute(params, execOptions);
549
605
  this.emitToolEvent("tool:end", tool.name, {
550
606
  result,
551
607
  success: true,
@@ -28,7 +28,7 @@ import { emergencyContentTruncation } from "./context/emergencyTruncation.js";
28
28
  import { getContextOverflowProvider, isContextOverflowError, parseProviderOverflowDetails, } from "./context/errorDetection.js";
29
29
  import { ContextBudgetExceededError } from "./context/errors.js";
30
30
  import { repairToolPairs } from "./context/toolPairRepair.js";
31
- import { SYSTEM_LIMITS, DEFAULT_TOOL_ROUTING_TIMEOUT_MS, } from "./core/constants.js";
31
+ import { SYSTEM_LIMITS, DEFAULT_TOOL_ROUTING_TIMEOUT_MS, MIN_RECOVERY_TURN_BUDGET_MS, } from "./core/constants.js";
32
32
  import { ConversationMemoryManager } from "./core/conversationMemoryManager.js";
33
33
  import { buildToolRoutingCatalog, buildRoutingQueryFromHistory, resolveToolRoutingExclusions, } from "./core/toolRouting.js";
34
34
  import { ToolRoutingCache } from "./core/toolRoutingCache.js";
@@ -4277,7 +4277,7 @@ Current user's request: ${currentInput}`;
4277
4277
  return result;
4278
4278
  }
4279
4279
  async handleGenerateTextInternalFailure(options, context, error) {
4280
- const recoveredResult = await this.tryRecoverGenerateTextOverflow(options, context.functionTag, error);
4280
+ const recoveredResult = await this.tryRecoverGenerateTextOverflow(options, context.functionTag, error, context.generateInternalStartTime);
4281
4281
  if (recoveredResult) {
4282
4282
  return recoveredResult;
4283
4283
  }
@@ -4317,7 +4317,7 @@ Current user's request: ${currentInput}`;
4317
4317
  }
4318
4318
  return null;
4319
4319
  }
4320
- async tryRecoverGenerateTextOverflow(options, functionTag, error) {
4320
+ async tryRecoverGenerateTextOverflow(options, functionTag, error, attemptStartTimeMs) {
4321
4321
  // Reviewer Finding #3: drop the `!this.conversationMemory` gate so
4322
4322
  // inline-conversationMessages callers also benefit from post-provider
4323
4323
  // recovery when their pre-dispatch estimate happens to undershoot
@@ -4467,16 +4467,34 @@ Current user's request: ${currentInput}`;
4467
4467
  breakdown: verifiedBudget?.breakdown ?? {},
4468
4468
  });
4469
4469
  }
4470
+ // Whole-turn budget semantics: the retry inherits the REMAINING
4471
+ // turnTimeoutMs, not a fresh clock — attempt 1 already spent part of
4472
+ // the caller's budget, and a fresh clock let one generate() run ~2×
4473
+ // the configured deadline (the 66-minute-turn incident shape).
4474
+ // Floored so a compacted retry still gets a workable window — but the
4475
+ // floor never exceeds the caller's own turnTimeoutMs (a 10s caller
4476
+ // budget must not receive a 30s retry).
4477
+ let retryTurnTimeoutMs = options.turnTimeoutMs;
4478
+ if (typeof options.turnTimeoutMs === "number" &&
4479
+ Number.isFinite(options.turnTimeoutMs) &&
4480
+ attemptStartTimeMs !== undefined) {
4481
+ const elapsedMs = Date.now() - attemptStartTimeMs;
4482
+ retryTurnTimeoutMs = Math.max(options.turnTimeoutMs - elapsedMs, Math.min(MIN_RECOVERY_TURN_BUDGET_MS, options.turnTimeoutMs));
4483
+ }
4470
4484
  logger.info(`[${functionTag}] Smart recovery verified, retrying generation`, {
4471
4485
  tokensSaved: lastCompactionResult.tokensSaved,
4472
4486
  compactionTarget,
4473
4487
  verifiedTokens: verifiedBudget.estimatedInputTokens,
4474
4488
  verifiedBudget: verifiedBudget.availableInputTokens,
4475
4489
  recoveredFraction,
4490
+ retryTurnTimeoutMs,
4476
4491
  });
4477
4492
  return this.directProviderGeneration({
4478
4493
  ...options,
4479
4494
  conversationMessages: compactedMessages,
4495
+ ...(retryTurnTimeoutMs !== undefined && {
4496
+ turnTimeoutMs: retryTurnTimeoutMs,
4497
+ }),
4480
4498
  });
4481
4499
  }
4482
4500
  catch (retryError) {
@@ -10440,11 +10458,29 @@ Current user's request: ${currentInput}`;
10440
10458
  }
10441
10459
  }
10442
10460
  const result = await this.externalServerManager.executeTool(serverId, toolName, parameters, options);
10443
- // BZ-664: Store result in cache after successful execution
10444
- if (cacheEnabled && this.mcpToolResultCache) {
10461
+ // BZ-664: Store result in cache after successful execution.
10462
+ // Only cache SUCCESSFUL results. Caching an error result (isError:true /
10463
+ // success:false — e.g. an upstream 403/timeout surfaced as a tool error)
10464
+ // replays that identical failure to every caller with the same args for
10465
+ // the whole TTL, turning a single transient upstream error into a storm
10466
+ // of cached failures and preventing a retry from re-hitting the server
10467
+ // once it recovers. Errors must always re-execute. (Detection mirrors the
10468
+ // isToolError check used elsewhere in this file.)
10469
+ const resultObj = result && typeof result === "object"
10470
+ ? result
10471
+ : undefined;
10472
+ const isErrorResult = Boolean((resultObj && "isError" in resultObj && resultObj.isError === true) ||
10473
+ (resultObj && "success" in resultObj && resultObj.success === false));
10474
+ // Also skip an `undefined` result: the cache read side treats `undefined`
10475
+ // as a miss (`cached !== undefined`), so storing it is a dead entry that can
10476
+ // never be read back — keep write/read symmetric with the other cache sites.
10477
+ if (cacheEnabled &&
10478
+ this.mcpToolResultCache &&
10479
+ !isErrorResult &&
10480
+ result !== undefined) {
10445
10481
  this.mcpToolResultCache.cacheResult(toolName, cacheKeyArgs, result);
10446
10482
  }
10447
- mcpLogger.debug(`[NeuroLink] External MCP tool executed successfully: ${toolName}`);
10483
+ mcpLogger.debug(`[NeuroLink] External MCP tool ${isErrorResult ? "returned error" : "executed successfully"}: ${toolName}`);
10448
10484
  return result;
10449
10485
  }
10450
10486
  catch (error) {
@@ -230,6 +230,13 @@ export declare function isAbortError(error: unknown): boolean;
230
230
  * this text (a killed 23-step turn once claimed "reached the 200-step limit").
231
231
  */
232
232
  export declare function buildToolLoopCapMessage(maxSteps: number, toolCallCount: number): string;
233
+ /**
234
+ * Honest message for a turn stopped by the in-loop context guard
235
+ * (stopReason "context-cap") without a synthesized answer. Sibling of
236
+ * {@link buildToolLoopCapMessage} — context exits must never claim a step
237
+ * limit was reached.
238
+ */
239
+ export declare function buildContextCapMessage(toolCallCount: number): string;
233
240
  /**
234
241
  * Honest message for a turn ended by the `turnTimeoutMs` wall-clock deadline
235
242
  * (stopReason "time-limit"). Sibling of {@link buildToolLoopCapMessage} —
@@ -266,6 +273,8 @@ export declare function resolveTurnStopReason(params: {
266
273
  wasAborted: boolean;
267
274
  /** Step budget ran out AND no clean/forced answer was produced. */
268
275
  cappedWithoutAnswer: boolean;
276
+ /** Context guard stopped the loop AND no clean/forced answer was produced. */
277
+ contextCappedWithoutAnswer?: boolean;
269
278
  /** The turn's resolved unified finishReason. */
270
279
  finishReason?: string;
271
280
  }): GenerateStopReason;
@@ -307,6 +316,40 @@ export declare function createTurnClock(params: {
307
316
  shouldNudgeWrapup(): boolean;
308
317
  dispose(): void;
309
318
  };
319
+ /**
320
+ * In-loop context guard for native agentic turns.
321
+ *
322
+ * The provider reports the ACTUAL prompt size of every model call
323
+ * (usage.input_tokens + cache reads/writes on Anthropic;
324
+ * usageMetadata.promptTokenCount on Gemini). The guard tracks that number
325
+ * plus an estimate of what the current step appends (assistant output, tool
326
+ * results), and tells the loop to stop calling tools once the projected next
327
+ * prompt crosses `thresholdRatio` of the model's context window — the turn
328
+ * then synthesizes a final answer from what it has instead of stepping into
329
+ * a provider 400 ("prompt is too long") that destroys all completed work.
330
+ *
331
+ * Fail-open: until the first usage report arrives, `shouldStop()` is false.
332
+ */
333
+ export declare function createContextGuard(contextWindowTokens: number, thresholdRatio?: number): {
334
+ /** Tokens at which the guard trips (ratio × window). */
335
+ readonly thresholdTokens: number;
336
+ /** Last observed prompt size plus estimated growth since. */
337
+ readonly projectedNextPromptTokens: number;
338
+ /**
339
+ * Record a model call's reported usage. `promptTokens` must be the FULL
340
+ * prompt size (uncached input + cache read + cache creation for
341
+ * Anthropic). The response's own output is counted as growth — it is
342
+ * appended to the conversation for the next call.
343
+ */
344
+ noteUsage(promptTokens: number, outputTokens: number): void;
345
+ /**
346
+ * Add growth for content appended since the last model call (tool
347
+ * results, nudge text) using the ~4 chars/token heuristic.
348
+ */
349
+ noteAppendedChars(chars: number): void;
350
+ /** True when issuing another model call risks crossing the threshold. */
351
+ shouldStop(): boolean;
352
+ };
310
353
  /**
311
354
  * Push model response parts to conversation history, preserving thoughtSignature
312
355
  * for Gemini 3 multi-turn tool calling.
@@ -11,7 +11,7 @@
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { existsSync, readFileSync } from "node:fs";
13
13
  import { extname } from "node:path";
14
- import { DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../core/constants.js";
14
+ import { DEFAULT_CONTEXT_GUARD_RATIO, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../core/constants.js";
15
15
  import { logger } from "../utils/logger.js";
16
16
  import { convertZodToJsonSchema, ensureNestedSchemaTypes, inlineJsonSchema, isZodSchema, normalizeJsonSchemaObject, } from "../utils/schemaConversion.js";
17
17
  import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
@@ -853,6 +853,20 @@ export function buildToolLoopCapMessage(maxSteps, toolCallCount) {
853
853
  return (`${calls}reached the ${maxSteps}-step limit for a single turn before I could finish. ` +
854
854
  `Please narrow the request or break it into smaller asks and I'll continue.`);
855
855
  }
856
+ /**
857
+ * Honest message for a turn stopped by the in-loop context guard
858
+ * (stopReason "context-cap") without a synthesized answer. Sibling of
859
+ * {@link buildToolLoopCapMessage} — context exits must never claim a step
860
+ * limit was reached.
861
+ */
862
+ export function buildContextCapMessage(toolCallCount) {
863
+ const calls = toolCallCount > 0
864
+ ? `I gathered information across ${toolCallCount} tool call${toolCallCount === 1 ? "" : "s"} but `
865
+ : "I ";
866
+ return (`${calls}had to stop because the gathered material filled this turn's ` +
867
+ `context window before I could finish. Please narrow the request or ` +
868
+ `break it into smaller asks and I'll continue.`);
869
+ }
856
870
  /** Format an elapsed duration as "Xm Ys" (or "Ys" under a minute). */
857
871
  function formatElapsed(elapsedMs) {
858
872
  const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
@@ -923,6 +937,9 @@ export function resolveTurnStopReason(params) {
923
937
  if (params.wasAborted) {
924
938
  return "aborted";
925
939
  }
940
+ if (params.contextCappedWithoutAnswer) {
941
+ return "context-cap";
942
+ }
926
943
  if (params.cappedWithoutAnswer) {
927
944
  return "step-cap";
928
945
  }
@@ -1024,6 +1041,61 @@ export function createTurnClock(params) {
1024
1041
  },
1025
1042
  };
1026
1043
  }
1044
+ /**
1045
+ * In-loop context guard for native agentic turns.
1046
+ *
1047
+ * The provider reports the ACTUAL prompt size of every model call
1048
+ * (usage.input_tokens + cache reads/writes on Anthropic;
1049
+ * usageMetadata.promptTokenCount on Gemini). The guard tracks that number
1050
+ * plus an estimate of what the current step appends (assistant output, tool
1051
+ * results), and tells the loop to stop calling tools once the projected next
1052
+ * prompt crosses `thresholdRatio` of the model's context window — the turn
1053
+ * then synthesizes a final answer from what it has instead of stepping into
1054
+ * a provider 400 ("prompt is too long") that destroys all completed work.
1055
+ *
1056
+ * Fail-open: until the first usage report arrives, `shouldStop()` is false.
1057
+ */
1058
+ export function createContextGuard(contextWindowTokens, thresholdRatio = DEFAULT_CONTEXT_GUARD_RATIO) {
1059
+ const thresholdTokens = Math.floor(contextWindowTokens * thresholdRatio);
1060
+ let observedPromptTokens = 0;
1061
+ let projectedGrowthTokens = 0;
1062
+ return {
1063
+ /** Tokens at which the guard trips (ratio × window). */
1064
+ get thresholdTokens() {
1065
+ return thresholdTokens;
1066
+ },
1067
+ /** Last observed prompt size plus estimated growth since. */
1068
+ get projectedNextPromptTokens() {
1069
+ return observedPromptTokens + projectedGrowthTokens;
1070
+ },
1071
+ /**
1072
+ * Record a model call's reported usage. `promptTokens` must be the FULL
1073
+ * prompt size (uncached input + cache read + cache creation for
1074
+ * Anthropic). The response's own output is counted as growth — it is
1075
+ * appended to the conversation for the next call.
1076
+ */
1077
+ noteUsage(promptTokens, outputTokens) {
1078
+ if (promptTokens > 0) {
1079
+ observedPromptTokens = promptTokens;
1080
+ projectedGrowthTokens = Math.max(0, outputTokens);
1081
+ }
1082
+ },
1083
+ /**
1084
+ * Add growth for content appended since the last model call (tool
1085
+ * results, nudge text) using the ~4 chars/token heuristic.
1086
+ */
1087
+ noteAppendedChars(chars) {
1088
+ if (chars > 0) {
1089
+ projectedGrowthTokens += Math.ceil(chars / 4);
1090
+ }
1091
+ },
1092
+ /** True when issuing another model call risks crossing the threshold. */
1093
+ shouldStop() {
1094
+ return (observedPromptTokens > 0 &&
1095
+ observedPromptTokens + projectedGrowthTokens >= thresholdTokens);
1096
+ },
1097
+ };
1098
+ }
1027
1099
  /**
1028
1100
  * Push model response parts to conversation history, preserving thoughtSignature
1029
1101
  * for Gemini 3 multi-turn tool calling.