@juspay/neurolink 9.81.2 → 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 +6 -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 +21 -3
  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 +21 -3
  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
@@ -22,15 +22,16 @@ import { resolveClaudeMaxTokens } from "../utils/tokenLimits.js";
22
22
  import { validateApiKey, createVertexProjectConfig, createGoogleAuthConfig, } from "../utils/providerConfig.js";
23
23
  import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } from "../utils/schemaConversion.js";
24
24
  import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
25
- import { TimeoutError, withTimeout } from "../utils/async/index.js";
25
+ import { TimeoutError, raceWithAbort, withTimeout, } from "../utils/async/index.js";
26
26
  import { parseTimeout } from "../utils/timeout.js";
27
- import { appendStepText, buildAbortedTurnMessage, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createTextChannel, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, } from "./googleNativeGemini3.js";
27
+ import { appendStepText, buildAbortedTurnMessage, buildContextCapMessage, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createContextGuard, createTextChannel, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, } from "./googleNativeGemini3.js";
28
+ import { getContextWindowSize } from "../constants/contextWindows.js";
28
29
  import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../telemetry/index.js";
29
30
  import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
30
31
  import { calculateCost } from "../utils/pricing.js";
31
32
  import { transformToolExecutions } from "../utils/transformationUtils.js";
32
33
  import { sanitizeAnthropicMessagesForTrace } from "../utils/anthropicTraceSanitizer.js";
33
- import { extractMcpToolErrorMessage } from "../utils/mcpErrorText.js";
34
+ import { extractMcpToolErrorMessage, extractToolFailureText, } from "../utils/mcpErrorText.js";
34
35
  // Import proper types for multimodal message handling
35
36
  // Dynamic import helper for native Anthropic Vertex SDK
36
37
  let anthropicVertexModule = null;
@@ -1315,6 +1316,11 @@ export class GoogleVertexProvider extends BaseProvider {
1315
1316
  // Track failed tools to prevent infinite retry loops
1316
1317
  // Key: tool name, Value: { count: retry attempts, lastError: error message }
1317
1318
  const failedTools = new Map();
1319
+ // In-loop context guard: stop calling tools when the accumulated
1320
+ // conversation approaches the model's context window instead of stepping
1321
+ // into a provider "prompt too long" rejection mid-loop.
1322
+ const contextGuard = createContextGuard(getContextWindowSize("vertex", modelName));
1323
+ let hitContextLimit = false;
1318
1324
  // Track token usage across all steps
1319
1325
  // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1320
1326
  let totalInputTokens = 0;
@@ -1371,6 +1377,16 @@ export class GoogleVertexProvider extends BaseProvider {
1371
1377
  wasAborted = true;
1372
1378
  break;
1373
1379
  }
1380
+ // Context guard: stop the tool loop before the accumulated
1381
+ // conversation crosses the window threshold — synthesize from what
1382
+ // we have instead of stepping into a provider rejection.
1383
+ if (contextGuard.shouldStop()) {
1384
+ hitContextLimit = true;
1385
+ logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
1386
+ `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
1387
+ `>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
1388
+ break;
1389
+ }
1374
1390
  step++;
1375
1391
  turnClock.noteProgress();
1376
1392
  logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
@@ -1422,6 +1438,8 @@ export class GoogleVertexProvider extends BaseProvider {
1422
1438
  if (usageMetadata.promptTokenCount !== undefined &&
1423
1439
  usageMetadata.promptTokenCount > 0) {
1424
1440
  totalInputTokens = usageMetadata.promptTokenCount;
1441
+ // Feed the context guard the REAL prompt size of this call.
1442
+ contextGuard.noteUsage(usageMetadata.promptTokenCount, usageMetadata.candidatesTokenCount ?? 0);
1425
1443
  }
1426
1444
  // Take the latest candidatesTokenCount (accumulates through chunks)
1427
1445
  if (usageMetadata.candidatesTokenCount !== undefined &&
@@ -1506,6 +1524,14 @@ export class GoogleVertexProvider extends BaseProvider {
1506
1524
  // Note: tool:start / tool:end events are emitted by ToolsManager's
1507
1525
  // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
1508
1526
  for (const call of stepFunctionCalls) {
1527
+ // Honor a deadline/stall/caller abort BETWEEN tool executions —
1528
+ // without this check a multi-tool step keeps executing its whole
1529
+ // batch (up to N × toolTimeoutMs past the deadline) before the
1530
+ // while-top check finally breaks.
1531
+ if (effectiveSignal.aborted) {
1532
+ wasAborted = true;
1533
+ break;
1534
+ }
1509
1535
  allToolCalls.push({ toolName: call.name, args: call.args });
1510
1536
  stepStorageCalls.push({ toolName: call.name, args: call.args });
1511
1537
  // Check if this tool has already exceeded retry limit
@@ -1545,9 +1571,31 @@ export class GoogleVertexProvider extends BaseProvider {
1545
1571
  };
1546
1572
  turnClock.noteProgress();
1547
1573
  // Bound the execute() await — a wedged tool costs one step
1548
- // (error tool_result), not the whole turn.
1549
- const result = await withTimeout(Promise.resolve(execute(call.args, toolOptions)), toolExecTimeoutMs, `Tool "${call.name}" execution timed out after ${toolExecTimeoutMs}ms`);
1574
+ // (error tool_result), not the whole turn — and race it
1575
+ // against the turn's abort so a deadline/caller abort is
1576
+ // observed IMMEDIATELY instead of after the tool settles.
1577
+ const result = await withTimeout(raceWithAbort(Promise.resolve(execute(call.args, toolOptions)), effectiveSignal), toolExecTimeoutMs, `Tool "${call.name}" execution timed out after ${toolExecTimeoutMs}ms`);
1550
1578
  turnClock.noteProgress();
1579
+ // Error-shaped success (MCP isError / { error } payloads —
1580
+ // e.g. proxy-blocked tools) counts toward the breaker too:
1581
+ // these fail without throwing, and only counting throws lets
1582
+ // the model grind on a blocked tool for the whole budget.
1583
+ const resultErrorText = extractToolFailureText(result);
1584
+ if (resultErrorText) {
1585
+ const info = failedTools.get(call.name) || {
1586
+ count: 0,
1587
+ lastError: "",
1588
+ };
1589
+ info.count++;
1590
+ info.lastError = resultErrorText;
1591
+ failedTools.set(call.name, info);
1592
+ }
1593
+ else {
1594
+ // Genuinely consecutive: a success clears the strike count
1595
+ // (argument-dependent soft errors — file-not-found on
1596
+ // different paths — must not disable a working tool).
1597
+ failedTools.delete(call.name);
1598
+ }
1551
1599
  toolExecutions.push({
1552
1600
  name: call.name,
1553
1601
  input: call.args,
@@ -1620,12 +1668,23 @@ export class GoogleVertexProvider extends BaseProvider {
1620
1668
  }
1621
1669
  }
1622
1670
  else {
1623
- // Tool not found is a permanent error
1671
+ // Tool not found is a permanent error. Count it toward the
1672
+ // breaker too (parity with the Anthropic loops) — a model that
1673
+ // ignores the do_not_retry hint and keeps calling a
1674
+ // hallucinated/stale tool name must not burn the whole step
1675
+ // budget on TOOL_NOT_FOUND round-trips.
1624
1676
  const errorPayload = {
1625
1677
  error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
1626
1678
  status: "permanently_failed",
1627
1679
  do_not_retry: true,
1628
1680
  };
1681
+ const notFoundInfo = failedTools.get(call.name) || {
1682
+ count: 0,
1683
+ lastError: "",
1684
+ };
1685
+ notFoundInfo.count++;
1686
+ notFoundInfo.lastError = errorPayload.error;
1687
+ failedTools.set(call.name, notFoundInfo);
1629
1688
  functionResponses.push({
1630
1689
  functionResponse: {
1631
1690
  name: call.name,
@@ -1689,6 +1748,15 @@ export class GoogleVertexProvider extends BaseProvider {
1689
1748
  role: "user",
1690
1749
  parts: functionResponses,
1691
1750
  });
1751
+ // Project this step's growth for the context guard: the appended
1752
+ // tool results ride the next prompt (Gemini reports usage per call,
1753
+ // but only for content it has already seen).
1754
+ try {
1755
+ contextGuard.noteAppendedChars(JSON.stringify(functionResponses).length);
1756
+ }
1757
+ catch {
1758
+ /* estimation is best-effort — never break the loop */
1759
+ }
1692
1760
  }
1693
1761
  catch (error) {
1694
1762
  // A mid-drain abort surfaces as an AbortError from the `for await`.
@@ -1709,7 +1777,7 @@ export class GoogleVertexProvider extends BaseProvider {
1709
1777
  // cap was reached (or the turn was aborted) while the model was still
1710
1778
  // calling tools. Surface a real answer instead of the canned placeholder
1711
1779
  // (Bug 1) and a meaningful finishReason (Bug 2).
1712
- if (!finalText && (step >= maxSteps || wasAborted)) {
1780
+ if (!finalText && (step >= maxSteps || wasAborted || hitContextLimit)) {
1713
1781
  hitStepLimit = step >= maxSteps && !wasAborted;
1714
1782
  const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
1715
1783
  // The consumer receives text via `incrementalTextChunks`; any text the
@@ -1719,8 +1787,11 @@ export class GoogleVertexProvider extends BaseProvider {
1719
1787
  // exist, leave `finalText` empty so `textPartsToYield` keeps replaying
1720
1788
  // the gathered chunks instead of collapsing to a single one.
1721
1789
  if (incrementalTextChunks.length > 0) {
1722
- logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1723
- `returning text already gathered from prior steps.`);
1790
+ logger.warn(hitContextLimit
1791
+ ? `[GoogleVertex] Tool call loop stopped by the context guard; ` +
1792
+ `returning text already gathered from prior steps.`
1793
+ : `[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1794
+ `returning text already gathered from prior steps.`);
1724
1795
  }
1725
1796
  else if (wasAborted) {
1726
1797
  // Turn ended on a time condition or caller abort — skip synth
@@ -1740,8 +1811,11 @@ export class GoogleVertexProvider extends BaseProvider {
1740
1811
  });
1741
1812
  }
1742
1813
  else {
1743
- logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
1744
- `with no text; synthesizing a final answer with tools disabled.`);
1814
+ logger.warn(hitContextLimit
1815
+ ? `[GoogleVertex] Tool call loop stopped by the context guard ` +
1816
+ `with no text; synthesizing a final answer with tools disabled.`
1817
+ : `[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
1818
+ `with no text; synthesizing a final answer with tools disabled.`);
1745
1819
  // synth self-bounds its connect+drain via withTimeout(timeoutMs) and
1746
1820
  // never throws (returns empty on timeout/error), so it needs no outer
1747
1821
  // withTimeout wrapper — see synthesizeFinalAnswerWithoutTools.
@@ -1757,7 +1831,9 @@ export class GoogleVertexProvider extends BaseProvider {
1757
1831
  }
1758
1832
  }
1759
1833
  else {
1760
- finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
1834
+ finalText = hitContextLimit
1835
+ ? buildContextCapMessage(toolCallCount)
1836
+ : buildToolLoopCapMessage(maxSteps, toolCallCount);
1761
1837
  }
1762
1838
  }
1763
1839
  }
@@ -1781,6 +1857,7 @@ export class GoogleVertexProvider extends BaseProvider {
1781
1857
  stalled: turnClock.stalled,
1782
1858
  wasAborted,
1783
1859
  cappedWithoutAnswer: hitStepLimit && !synthesizedFinalAnswer,
1860
+ contextCappedWithoutAnswer: hitContextLimit && !synthesizedFinalAnswer,
1784
1861
  finishReason: resolvedFinishReason,
1785
1862
  });
1786
1863
  if (stopReason !== "completed") {
@@ -2168,6 +2245,11 @@ export class GoogleVertexProvider extends BaseProvider {
2168
2245
  // Track failed tools to prevent infinite retry loops
2169
2246
  // Key: tool name, Value: { count: retry attempts, lastError: error message }
2170
2247
  const failedTools = new Map();
2248
+ // In-loop context guard: stop calling tools when the accumulated
2249
+ // conversation approaches the model's context window instead of stepping
2250
+ // into a provider "prompt too long" rejection mid-loop.
2251
+ const contextGuard = createContextGuard(getContextWindowSize("vertex", modelName));
2252
+ let hitContextLimit = false;
2171
2253
  // Track token usage across all steps
2172
2254
  // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
2173
2255
  let totalInputTokens = 0;
@@ -2217,6 +2299,16 @@ export class GoogleVertexProvider extends BaseProvider {
2217
2299
  wasAborted = true;
2218
2300
  break;
2219
2301
  }
2302
+ // Context guard: stop the tool loop before the accumulated
2303
+ // conversation crosses the window threshold — synthesize from what
2304
+ // we have instead of stepping into a provider rejection.
2305
+ if (contextGuard.shouldStop()) {
2306
+ hitContextLimit = true;
2307
+ logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
2308
+ `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
2309
+ `>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
2310
+ break;
2311
+ }
2220
2312
  step++;
2221
2313
  turnClock.noteProgress();
2222
2314
  logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
@@ -2265,6 +2357,8 @@ export class GoogleVertexProvider extends BaseProvider {
2265
2357
  if (usageMetadata.promptTokenCount !== undefined &&
2266
2358
  usageMetadata.promptTokenCount > 0) {
2267
2359
  totalInputTokens = usageMetadata.promptTokenCount;
2360
+ // Feed the context guard the REAL prompt size of this call.
2361
+ contextGuard.noteUsage(usageMetadata.promptTokenCount, usageMetadata.candidatesTokenCount ?? 0);
2268
2362
  }
2269
2363
  // Take the latest candidatesTokenCount (accumulates through chunks)
2270
2364
  if (usageMetadata.candidatesTokenCount !== undefined &&
@@ -2353,6 +2447,14 @@ export class GoogleVertexProvider extends BaseProvider {
2353
2447
  // Note: tool:start / tool:end events are emitted by ToolsManager's
2354
2448
  // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
2355
2449
  for (const call of stepFunctionCalls) {
2450
+ // Honor a deadline/stall/caller abort BETWEEN tool executions —
2451
+ // without this check a multi-tool step keeps executing its whole
2452
+ // batch (up to N × toolTimeoutMs past the deadline) before the
2453
+ // while-top check finally breaks.
2454
+ if (effectiveSignal.aborted) {
2455
+ wasAborted = true;
2456
+ break;
2457
+ }
2356
2458
  allToolCalls.push({ toolName: call.name, args: call.args });
2357
2459
  // Check if this tool has already exceeded retry limit
2358
2460
  const failedInfo = failedTools.get(call.name);
@@ -2388,8 +2490,28 @@ export class GoogleVertexProvider extends BaseProvider {
2388
2490
  turnClock.noteProgress();
2389
2491
  // Bound the execute() await — a wedged tool costs one step
2390
2492
  // (error tool_result), not the whole turn.
2391
- const execResult = await withTimeout(Promise.resolve(execute(call.args, toolOptions)), toolExecTimeoutMs, `Tool "${call.name}" execution timed out after ${toolExecTimeoutMs}ms`);
2493
+ const execResult = await withTimeout(raceWithAbort(Promise.resolve(execute(call.args, toolOptions)), effectiveSignal), toolExecTimeoutMs, `Tool "${call.name}" execution timed out after ${toolExecTimeoutMs}ms`);
2392
2494
  turnClock.noteProgress();
2495
+ // Error-shaped success (MCP isError / { error } payloads —
2496
+ // e.g. proxy-blocked tools) counts toward the breaker too:
2497
+ // these fail without throwing, and only counting throws lets
2498
+ // the model grind on a blocked tool for the whole budget.
2499
+ const resultErrorText = extractToolFailureText(execResult);
2500
+ if (resultErrorText) {
2501
+ const info = failedTools.get(call.name) || {
2502
+ count: 0,
2503
+ lastError: "",
2504
+ };
2505
+ info.count++;
2506
+ info.lastError = resultErrorText;
2507
+ failedTools.set(call.name, info);
2508
+ }
2509
+ else {
2510
+ // Genuinely consecutive: a success clears the strike count
2511
+ // (argument-dependent soft errors — file-not-found on
2512
+ // different paths — must not disable a working tool).
2513
+ failedTools.delete(call.name);
2514
+ }
2393
2515
  // Track execution
2394
2516
  toolExecutions.push({
2395
2517
  name: call.name,
@@ -2526,6 +2648,15 @@ export class GoogleVertexProvider extends BaseProvider {
2526
2648
  role: "user",
2527
2649
  parts: functionResponses,
2528
2650
  });
2651
+ // Project this step's growth for the context guard: the appended
2652
+ // tool results ride the next prompt (Gemini reports usage per call,
2653
+ // but only for content it has already seen).
2654
+ try {
2655
+ contextGuard.noteAppendedChars(JSON.stringify(functionResponses).length);
2656
+ }
2657
+ catch {
2658
+ /* estimation is best-effort — never break the loop */
2659
+ }
2529
2660
  }
2530
2661
  catch (error) {
2531
2662
  // A mid-drain abort surfaces as an AbortError from the `for await`.
@@ -2564,13 +2695,16 @@ export class GoogleVertexProvider extends BaseProvider {
2564
2695
  // cap was reached (or the turn was aborted) while the model was still
2565
2696
  // calling tools. Surface a real answer instead of the canned placeholder
2566
2697
  // (Bug 1) and a meaningful finishReason (Bug 2).
2567
- if (!finalText && (step >= maxSteps || wasAborted)) {
2698
+ if (!finalText && (step >= maxSteps || wasAborted || hitContextLimit)) {
2568
2699
  hitStepLimit = step >= maxSteps && !wasAborted;
2569
2700
  const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
2570
2701
  if (accumulatedText) {
2571
2702
  // Prefer the prose the model already produced across steps.
2572
- logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
2573
- `returning text already gathered from prior steps.`);
2703
+ logger.warn(hitContextLimit
2704
+ ? `[GoogleVertex] Generate tool call loop stopped by the context guard; ` +
2705
+ `returning text already gathered from prior steps.`
2706
+ : `[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
2707
+ `returning text already gathered from prior steps.`);
2574
2708
  finalText = accumulatedText;
2575
2709
  }
2576
2710
  else if (wasAborted) {
@@ -2593,8 +2727,11 @@ export class GoogleVertexProvider extends BaseProvider {
2593
2727
  // Pure functionCall turns leave no text — make one tools-disabled call
2594
2728
  // so the model answers from the gathered tool results instead of the
2595
2729
  // canned placeholder.
2596
- logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
2597
- `with no text; synthesizing a final answer with tools disabled.`);
2730
+ logger.warn(hitContextLimit
2731
+ ? `[GoogleVertex] Generate tool call loop stopped by the context guard ` +
2732
+ `with no text; synthesizing a final answer with tools disabled.`
2733
+ : `[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
2734
+ `with no text; synthesizing a final answer with tools disabled.`);
2598
2735
  // synth self-bounds its connect+drain via withTimeout(timeoutMs) and
2599
2736
  // never throws (returns empty on timeout/error), so it needs no outer
2600
2737
  // withTimeout wrapper — see synthesizeFinalAnswerWithoutTools.
@@ -2609,7 +2746,9 @@ export class GoogleVertexProvider extends BaseProvider {
2609
2746
  }
2610
2747
  }
2611
2748
  else {
2612
- finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
2749
+ finalText = hitContextLimit
2750
+ ? buildContextCapMessage(toolCallCount)
2751
+ : buildToolLoopCapMessage(maxSteps, toolCallCount);
2613
2752
  }
2614
2753
  }
2615
2754
  }
@@ -2633,6 +2772,7 @@ export class GoogleVertexProvider extends BaseProvider {
2633
2772
  stalled: turnClock.stalled,
2634
2773
  wasAborted,
2635
2774
  cappedWithoutAnswer: hitStepLimit && !synthesizedFinalAnswer,
2775
+ contextCappedWithoutAnswer: hitContextLimit && !synthesizedFinalAnswer,
2636
2776
  finishReason: resolvedFinishReason,
2637
2777
  });
2638
2778
  if (stopReason !== "completed") {
@@ -3192,9 +3332,14 @@ export class GoogleVertexProvider extends BaseProvider {
3192
3332
  // mapping written into finishReasonRef.
3193
3333
  let wasAborted = false;
3194
3334
  let hitStepLimit = false;
3335
+ let hitContextLimit = false;
3195
3336
  let synthesizedFinalAnswer = false;
3196
3337
  let modelFinished = false;
3197
3338
  let lastStopReason;
3339
+ // In-loop context guard + consecutive-failure breaker — same rationale
3340
+ // as the generate twin (see executeNativeAnthropicGenerate).
3341
+ const contextGuard = createContextGuard(getContextWindowSize("vertex", modelName));
3342
+ const failedTools = new Map();
3198
3343
  try {
3199
3344
  while (step < agenticStepBudget) {
3200
3345
  // Honor aborts BETWEEN steps (caller signal OR the turn clock's
@@ -3206,6 +3351,15 @@ export class GoogleVertexProvider extends BaseProvider {
3206
3351
  wasAborted = true;
3207
3352
  break;
3208
3353
  }
3354
+ // Context guard: stop the tool loop before the accumulated
3355
+ // conversation crosses the window threshold (see generate twin).
3356
+ if (contextGuard.shouldStop()) {
3357
+ hitContextLimit = true;
3358
+ logger.warn(`[GoogleVertex] Anthropic stream turn stopped by the context guard: ` +
3359
+ `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
3360
+ `>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
3361
+ break;
3362
+ }
3209
3363
  step++;
3210
3364
  turnClock.noteProgress();
3211
3365
  // One generation observation per API call: request in, content + usage out.
@@ -3318,6 +3472,11 @@ export class GoogleVertexProvider extends BaseProvider {
3318
3472
  usage.output += response.usage?.output_tokens || 0;
3319
3473
  usage.total = usage.input + usage.output;
3320
3474
  lastStopReason = response.stop_reason;
3475
+ // Feed the context guard the FULL prompt size of this call
3476
+ // (uncached input + cache reads/writes).
3477
+ contextGuard.noteUsage((response.usage?.input_tokens || 0) +
3478
+ stepCacheRead +
3479
+ stepCacheCreation, response.usage?.output_tokens || 0);
3321
3480
  for (const block of response.content) {
3322
3481
  if (block.type === "text" && typeof block.text === "string") {
3323
3482
  aggregatedTurnText += block.text;
@@ -3382,6 +3541,15 @@ export class GoogleVertexProvider extends BaseProvider {
3382
3541
  // Note: tool:start / tool:end events are emitted by ToolsManager's
3383
3542
  // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
3384
3543
  for (const toolUse of toolUseBlocks) {
3544
+ // Honor a deadline/stall/caller abort BETWEEN tool executions —
3545
+ // without this check a multi-tool step keeps executing its whole
3546
+ // batch (up to N × toolTimeoutMs past the deadline) before the
3547
+ // while-top check finally breaks. Skipped tools get neither a
3548
+ // call row nor a result row, so persisted history stays paired.
3549
+ if (internalAbort.signal.aborted) {
3550
+ wasAborted = true;
3551
+ break;
3552
+ }
3385
3553
  allToolCalls.push({
3386
3554
  toolName: toolUse.name,
3387
3555
  args: toolUse.input,
@@ -3392,6 +3560,31 @@ export class GoogleVertexProvider extends BaseProvider {
3392
3560
  toolName: toolUse.name,
3393
3561
  args: toolUse.input,
3394
3562
  });
3563
+ // Consecutive-failure breaker (ports the Gemini loops' failedTools
3564
+ // map): a tool that has already failed DEFAULT_TOOL_MAX_RETRIES
3565
+ // times this turn is short-circuited instead of re-executed.
3566
+ const failedInfo = failedTools.get(toolUse.name);
3567
+ if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
3568
+ logger.warn(`[GoogleVertex] Tool "${toolUse.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
3569
+ const errMsg = `TOOL_PERMANENTLY_FAILED: The tool "${toolUse.name}" has failed ${failedInfo.count} times and will not be retried. Last error: ${failedInfo.lastError}. Please proceed without using this tool or inform the user that this functionality is unavailable.`;
3570
+ const errorPayload = { error: errMsg };
3571
+ toolExecutions.push({
3572
+ name: toolUse.name,
3573
+ input: toolUse.input,
3574
+ output: errorPayload,
3575
+ });
3576
+ toolResults.push({
3577
+ type: "tool_result",
3578
+ tool_use_id: toolUse.id,
3579
+ content: errMsg,
3580
+ });
3581
+ stepStorageResults.push({
3582
+ toolCallId: toolUse.id,
3583
+ toolName: toolUse.name,
3584
+ output: errorPayload,
3585
+ });
3586
+ continue;
3587
+ }
3395
3588
  // One tool observation per execution. ai.toolCall.* names follow the
3396
3589
  // Vercel AI SDK convention so existing tooling keeps working.
3397
3590
  const toolSpan = tracers.mcp.startSpan("ai.toolCall", {
@@ -3437,12 +3630,32 @@ export class GoogleVertexProvider extends BaseProvider {
3437
3630
  // (neurolink.tool.execute) nest under this observation instead
3438
3631
  // of becoming disconnected siblings. Bound the await — a
3439
3632
  // wedged tool costs one step (error tool_result), not the
3440
- // whole turn.
3441
- const result = await withTimeout(otelContext.with(otelTrace.setSpan(turnContext, toolSpan), () => Promise.resolve(execute(toolUse.input, toolOptions))), toolExecTimeoutMs, `Tool "${toolUse.name}" execution timed out after ${toolExecTimeoutMs}ms`);
3633
+ // whole turn — and race it against the turn's abort so a
3634
+ // deadline/caller abort is observed IMMEDIATELY instead of
3635
+ // after the tool settles.
3636
+ const result = await withTimeout(raceWithAbort(otelContext.with(otelTrace.setSpan(turnContext, toolSpan), () => Promise.resolve(execute(toolUse.input, toolOptions))), internalAbort.signal), toolExecTimeoutMs, `Tool "${toolUse.name}" execution timed out after ${toolExecTimeoutMs}ms`);
3442
3637
  turnClock.noteProgress();
3443
3638
  // MCP failures are returned, not thrown — surface them on
3444
3639
  // the span so failed calls show as ERROR in Langfuse.
3445
3640
  endToolSpan(result, extractMcpToolErrorMessage(result));
3641
+ // Error-shaped success (MCP isError / { error } payloads)
3642
+ // counts toward the breaker too — see the generate twin.
3643
+ const resultErrorText = extractToolFailureText(result);
3644
+ if (resultErrorText) {
3645
+ const info = failedTools.get(toolUse.name) || {
3646
+ count: 0,
3647
+ lastError: "",
3648
+ };
3649
+ info.count++;
3650
+ info.lastError = resultErrorText;
3651
+ failedTools.set(toolUse.name, info);
3652
+ }
3653
+ else {
3654
+ // Genuinely consecutive: a success clears the strike count
3655
+ // (argument-dependent soft errors — file-not-found on
3656
+ // different paths — must not disable a working tool).
3657
+ failedTools.delete(toolUse.name);
3658
+ }
3446
3659
  toolExecutions.push({
3447
3660
  name: toolUse.name,
3448
3661
  input: toolUse.input,
@@ -3492,7 +3705,17 @@ export class GoogleVertexProvider extends BaseProvider {
3492
3705
  toolName: toolUse.name,
3493
3706
  });
3494
3707
  }
3495
- const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3708
+ // Count the failure toward the consecutive-failure breaker.
3709
+ const thrownErrorText = err instanceof Error ? err.message : String(err);
3710
+ const info = failedTools.get(toolUse.name) || {
3711
+ count: 0,
3712
+ lastError: "",
3713
+ };
3714
+ info.count++;
3715
+ info.lastError = thrownErrorText;
3716
+ failedTools.set(toolUse.name, info);
3717
+ logger.warn(`[GoogleVertex] Tool "${toolUse.name}" failed (attempt ${info.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${thrownErrorText}`);
3718
+ const errMsg = `Error executing tool "${toolUse.name}": ${thrownErrorText}`;
3496
3719
  const errorPayload = { error: errMsg };
3497
3720
  endToolSpan(errorPayload, errMsg);
3498
3721
  toolExecutions.push({
@@ -3515,6 +3738,16 @@ export class GoogleVertexProvider extends BaseProvider {
3515
3738
  else {
3516
3739
  const errMsg = `TOOL_NOT_FOUND: The tool "${toolUse.name}" does not exist.`;
3517
3740
  const errorPayload = { error: errMsg };
3741
+ // A missing tool counts toward the breaker too — a model
3742
+ // grinding on a hallucinated/stale tool name must not burn the
3743
+ // whole step budget on TOOL_NOT_FOUND round-trips.
3744
+ const notFoundInfo = failedTools.get(toolUse.name) || {
3745
+ count: 0,
3746
+ lastError: "",
3747
+ };
3748
+ notFoundInfo.count++;
3749
+ notFoundInfo.lastError = errMsg;
3750
+ failedTools.set(toolUse.name, notFoundInfo);
3518
3751
  endToolSpan(errorPayload, errMsg);
3519
3752
  toolExecutions.push({
3520
3753
  name: toolUse.name,
@@ -3585,6 +3818,12 @@ export class GoogleVertexProvider extends BaseProvider {
3585
3818
  role: "user",
3586
3819
  content: toolResults,
3587
3820
  });
3821
+ // Project this step's growth for the context guard: everything
3822
+ // just appended (tool results + nudge text) rides the next prompt.
3823
+ contextGuard.noteAppendedChars(toolResults.reduce((sum, block) => sum +
3824
+ ("content" in block
3825
+ ? block.content.length
3826
+ : block.text.length), 0));
3588
3827
  }
3589
3828
  // Terminal handling — the loop exited without a model-initiated
3590
3829
  // finish (step budget exhausted, or the turn was aborted). Never end
@@ -3603,6 +3842,15 @@ export class GoogleVertexProvider extends BaseProvider {
3603
3842
  wasAborted = true;
3604
3843
  }
3605
3844
  const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3845
+ // Clamp the terminal calls' max_tokens so prompt + output stays
3846
+ // inside the model window: pre-4.5 Claude models 400 on
3847
+ // input + max_tokens > window, which would defeat the very
3848
+ // synthesis these calls exist for on context-capped turns. Only
3849
+ // bites when the prompt is near the window (min() is a no-op on
3850
+ // ordinary step-cap turns).
3851
+ const terminalMaxTokens = Math.max(1024, Math.min(requestParams.max_tokens, getContextWindowSize("vertex", modelName) -
3852
+ contextGuard.projectedNextPromptTokens -
3853
+ 4_000));
3606
3854
  if (wasAborted) {
3607
3855
  // Budget already blown — never issue another model call. Deliver
3608
3856
  // exactly one HONEST terminal chunk matching the actual exit
@@ -3627,8 +3875,12 @@ export class GoogleVertexProvider extends BaseProvider {
3627
3875
  }
3628
3876
  }
3629
3877
  else if (useFinalResultTool) {
3630
- hitStepLimit = true;
3631
- logger.warn(`[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
3878
+ if (!hitContextLimit) {
3879
+ hitStepLimit = true;
3880
+ }
3881
+ logger.warn(hitContextLimit
3882
+ ? `[GoogleVertex] Native Anthropic stream loop stopped by the context guard without final_result; forcing a finalization call.`
3883
+ : `[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
3632
3884
  try {
3633
3885
  // Reserved finalization step: identical request except
3634
3886
  // tool_choice pins final_result — Anthropic guarantees a
@@ -3642,6 +3894,7 @@ export class GoogleVertexProvider extends BaseProvider {
3642
3894
  });
3643
3895
  const finalizationStream = await client.messages.stream({
3644
3896
  ...requestParams,
3897
+ max_tokens: terminalMaxTokens,
3645
3898
  tool_choice: {
3646
3899
  type: "tool",
3647
3900
  name: "final_result",
@@ -3681,7 +3934,9 @@ export class GoogleVertexProvider extends BaseProvider {
3681
3934
  logger.debug("[GoogleVertex] Forced finalization returned structured output (stream)", { keys: Object.keys(forcedFinalResult.input) });
3682
3935
  }
3683
3936
  else {
3684
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3937
+ const capMessage = hitContextLimit
3938
+ ? buildContextCapMessage(externalToolCallCount)
3939
+ : buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3685
3940
  channel.push(capMessage);
3686
3941
  aggregatedTurnText += capMessage;
3687
3942
  }
@@ -3699,19 +3954,23 @@ export class GoogleVertexProvider extends BaseProvider {
3699
3954
  logger.warn("[GoogleVertex] Forced finalization call failed; falling back to a terminal message", {
3700
3955
  error: error instanceof Error ? error.message : String(error),
3701
3956
  });
3702
- const exitMessage = this.buildLoopExitMessage({
3703
- turnClock,
3704
- wasAborted,
3705
- stallTimeoutMs: options.stallTimeoutMs,
3706
- maxSteps,
3707
- toolCallCount: externalToolCallCount,
3708
- });
3957
+ const exitMessage = hitContextLimit && !wasAborted
3958
+ ? buildContextCapMessage(externalToolCallCount)
3959
+ : this.buildLoopExitMessage({
3960
+ turnClock,
3961
+ wasAborted,
3962
+ stallTimeoutMs: options.stallTimeoutMs,
3963
+ maxSteps,
3964
+ toolCallCount: externalToolCallCount,
3965
+ });
3709
3966
  channel.push(exitMessage);
3710
3967
  aggregatedTurnText += exitMessage;
3711
3968
  }
3712
3969
  }
3713
3970
  else {
3714
- hitStepLimit = true;
3971
+ if (!hitContextLimit) {
3972
+ hitStepLimit = true;
3973
+ }
3715
3974
  if (aggregatedTurnText.length === 0) {
3716
3975
  // Pure tool-loop with no streamed text: one tools-disabled call
3717
3976
  // so the model answers from the gathered tool results, pushed
@@ -3720,7 +3979,9 @@ export class GoogleVertexProvider extends BaseProvider {
3720
3979
  // histories containing tool_use/tool_result blocks without a
3721
3980
  // tools param — so "tools disabled" is tool_choice:"none",
3722
3981
  // which also keeps the cached tools prefix byte-identical.
3723
- logger.warn(`[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
3982
+ logger.warn(hitContextLimit
3983
+ ? `[GoogleVertex] Native Anthropic stream loop stopped by the context guard with no text; synthesizing a final answer with tools disabled.`
3984
+ : `[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
3724
3985
  try {
3725
3986
  const backstopSystem = (systemPromptWithSchema
3726
3987
  ? systemPromptWithSchema + "\n\n"
@@ -3735,6 +3996,7 @@ export class GoogleVertexProvider extends BaseProvider {
3735
3996
  });
3736
3997
  const backstopStream = await client.messages.stream({
3737
3998
  ...requestParams,
3999
+ max_tokens: terminalMaxTokens,
3738
4000
  tool_choice: { type: "none" },
3739
4001
  system: cachedBackstop.system,
3740
4002
  ...(cachedBackstop.tools &&
@@ -3770,7 +4032,9 @@ export class GoogleVertexProvider extends BaseProvider {
3770
4032
  aggregatedTurnText = backstopText;
3771
4033
  }
3772
4034
  else {
3773
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4035
+ const capMessage = hitContextLimit
4036
+ ? buildContextCapMessage(externalToolCallCount)
4037
+ : buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3774
4038
  channel.push(capMessage);
3775
4039
  aggregatedTurnText = capMessage;
3776
4040
  }
@@ -3788,13 +4052,15 @@ export class GoogleVertexProvider extends BaseProvider {
3788
4052
  logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to a terminal message", {
3789
4053
  error: error instanceof Error ? error.message : String(error),
3790
4054
  });
3791
- const exitMessage = this.buildLoopExitMessage({
3792
- turnClock,
3793
- wasAborted,
3794
- stallTimeoutMs: options.stallTimeoutMs,
3795
- maxSteps,
3796
- toolCallCount: externalToolCallCount,
3797
- });
4055
+ const exitMessage = hitContextLimit && !wasAborted
4056
+ ? buildContextCapMessage(externalToolCallCount)
4057
+ : this.buildLoopExitMessage({
4058
+ turnClock,
4059
+ wasAborted,
4060
+ stallTimeoutMs: options.stallTimeoutMs,
4061
+ maxSteps,
4062
+ toolCallCount: externalToolCallCount,
4063
+ });
3798
4064
  channel.push(exitMessage);
3799
4065
  aggregatedTurnText = exitMessage;
3800
4066
  }
@@ -3812,13 +4078,15 @@ export class GoogleVertexProvider extends BaseProvider {
3812
4078
  !structuredOutputRef.value;
3813
4079
  const externalToolCallTotal = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3814
4080
  if (deliveredNothing && externalToolCallTotal > 0) {
3815
- const exitMessage = this.buildLoopExitMessage({
3816
- turnClock,
3817
- wasAborted,
3818
- stallTimeoutMs: options.stallTimeoutMs,
3819
- maxSteps,
3820
- toolCallCount: externalToolCallTotal,
3821
- });
4081
+ const exitMessage = hitContextLimit && !wasAborted
4082
+ ? buildContextCapMessage(externalToolCallTotal)
4083
+ : this.buildLoopExitMessage({
4084
+ turnClock,
4085
+ wasAborted,
4086
+ stallTimeoutMs: options.stallTimeoutMs,
4087
+ maxSteps,
4088
+ toolCallCount: externalToolCallTotal,
4089
+ });
3822
4090
  channel.push(exitMessage);
3823
4091
  aggregatedTurnText = exitMessage;
3824
4092
  }
@@ -3844,6 +4112,7 @@ export class GoogleVertexProvider extends BaseProvider {
3844
4112
  stalled: turnClock.stalled,
3845
4113
  wasAborted,
3846
4114
  cappedWithoutAnswer: hitStepLimit && !synthesizedFinalAnswer,
4115
+ contextCappedWithoutAnswer: hitContextLimit && !synthesizedFinalAnswer,
3847
4116
  finishReason: finishReasonRef.value,
3848
4117
  });
3849
4118
  stopReasonRef.value = resolvedStopReason;
@@ -4266,9 +4535,21 @@ export class GoogleVertexProvider extends BaseProvider {
4266
4535
  // these to distinguish clean finishes, capped turns, and aborted turns.
4267
4536
  let wasAborted = false;
4268
4537
  let hitStepLimit = false;
4538
+ let hitContextLimit = false;
4269
4539
  let synthesizedFinalAnswer = false;
4270
4540
  let modelFinished = false;
4271
4541
  const currentMessages = [...messages];
4542
+ // In-loop context guard: stop calling tools when the accumulated
4543
+ // conversation approaches the model's real context window, instead of
4544
+ // stepping into an Anthropic 400 "prompt is too long" at step N that
4545
+ // destroys the whole turn's work (claude-sonnet-5 1,005,647-token RCA).
4546
+ const contextGuard = createContextGuard(getContextWindowSize("vertex", modelName));
4547
+ // Consecutive-failure breaker (ports the Gemini loops' failedTools map):
4548
+ // a tool that keeps failing — thrown errors, timeouts, or error-shaped
4549
+ // results like mcp-proxy blocks — is short-circuited after
4550
+ // DEFAULT_TOOL_MAX_RETRIES instead of being retried for the remaining
4551
+ // step budget.
4552
+ const failedTools = new Map();
4272
4553
  // Internal abort fan-in: the caller's signal and the turn clock's
4273
4554
  // watchdogs all trip it; it rides every SDK request and tool execution.
4274
4555
  const internalAbort = new AbortController();
@@ -4309,6 +4590,16 @@ export class GoogleVertexProvider extends BaseProvider {
4309
4590
  wasAborted = true;
4310
4591
  break;
4311
4592
  }
4593
+ // Context guard: the projected next prompt (last call's REAL usage +
4594
+ // this step's appended tool results/output) would cross the window
4595
+ // threshold — stop the tool loop and synthesize from what we have.
4596
+ if (contextGuard.shouldStop()) {
4597
+ hitContextLimit = true;
4598
+ logger.warn(`[GoogleVertex] Anthropic generate turn stopped by the context guard: ` +
4599
+ `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
4600
+ `>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
4601
+ break;
4602
+ }
4312
4603
  step++;
4313
4604
  turnClock.noteProgress();
4314
4605
  try {
@@ -4349,6 +4640,11 @@ export class GoogleVertexProvider extends BaseProvider {
4349
4640
  totalCacheCreationTokens +=
4350
4641
  response.usage?.cache_creation_input_tokens || 0;
4351
4642
  lastStopReason = response.stop_reason;
4643
+ // Feed the context guard the FULL prompt size of this call (uncached
4644
+ // input + cache reads/writes) — the API reports it every step.
4645
+ contextGuard.noteUsage((response.usage?.input_tokens || 0) +
4646
+ (response.usage?.cache_read_input_tokens || 0) +
4647
+ (response.usage?.cache_creation_input_tokens || 0), response.usage?.output_tokens || 0);
4352
4648
  // Check if we need to handle tool use
4353
4649
  const toolUseBlocks = response.content.filter((block) => block.type === "tool_use");
4354
4650
  // Check for final_result tool call (for structured output)
@@ -4384,6 +4680,15 @@ export class GoogleVertexProvider extends BaseProvider {
4384
4680
  // Note: tool:start / tool:end events are emitted by ToolsManager's
4385
4681
  // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
4386
4682
  for (const toolUse of toolUseBlocks) {
4683
+ // Honor a deadline/stall/caller abort BETWEEN tool executions —
4684
+ // without this check a multi-tool step keeps executing its whole
4685
+ // batch (up to N × toolTimeoutMs past the deadline) before the
4686
+ // while-top check finally breaks. Skipped tools get neither a call
4687
+ // row nor a result row, so persisted history stays paired.
4688
+ if (internalAbort.signal.aborted) {
4689
+ wasAborted = true;
4690
+ break;
4691
+ }
4387
4692
  allToolCalls.push({
4388
4693
  toolName: toolUse.name,
4389
4694
  args: toolUse.input,
@@ -4393,6 +4698,32 @@ export class GoogleVertexProvider extends BaseProvider {
4393
4698
  toolName: toolUse.name,
4394
4699
  args: toolUse.input,
4395
4700
  });
4701
+ // Consecutive-failure breaker: stop re-executing a tool that has
4702
+ // already failed DEFAULT_TOOL_MAX_RETRIES times this turn (ports
4703
+ // the Gemini loops' failedTools map — the Anthropic loops let a
4704
+ // blocked tool be retried for the entire remaining step budget).
4705
+ const failedInfo = failedTools.get(toolUse.name);
4706
+ if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
4707
+ logger.warn(`[GoogleVertex] Tool "${toolUse.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
4708
+ const errMsg = `TOOL_PERMANENTLY_FAILED: The tool "${toolUse.name}" has failed ${failedInfo.count} times and will not be retried. Last error: ${failedInfo.lastError}. Please proceed without using this tool or inform the user that this functionality is unavailable.`;
4709
+ const errorPayload = { error: errMsg };
4710
+ toolExecutions.push({
4711
+ name: toolUse.name,
4712
+ input: toolUse.input,
4713
+ output: errorPayload,
4714
+ });
4715
+ toolResults.push({
4716
+ type: "tool_result",
4717
+ tool_use_id: toolUse.id,
4718
+ content: errMsg,
4719
+ });
4720
+ stepStorageResults.push({
4721
+ toolCallId: toolUse.id,
4722
+ toolName: toolUse.name,
4723
+ output: errorPayload,
4724
+ });
4725
+ continue;
4726
+ }
4396
4727
  const execute = executeMap.get(toolUse.name);
4397
4728
  if (execute) {
4398
4729
  try {
@@ -4403,9 +4734,32 @@ export class GoogleVertexProvider extends BaseProvider {
4403
4734
  };
4404
4735
  turnClock.noteProgress();
4405
4736
  // Bound the execute() await — a wedged tool costs one step
4406
- // (error tool_result), not the whole turn.
4407
- const result = await withTimeout(Promise.resolve(execute(toolUse.input, toolOptions)), toolExecTimeoutMs, `Tool "${toolUse.name}" execution timed out after ${toolExecTimeoutMs}ms`);
4737
+ // (error tool_result), not the whole turn — and race it against
4738
+ // the turn's abort so a deadline/caller abort is observed
4739
+ // IMMEDIATELY instead of after the tool settles (live-verified:
4740
+ // an 8s deadline previously waited out a 60s tool).
4741
+ const result = await withTimeout(raceWithAbort(Promise.resolve(execute(toolUse.input, toolOptions)), internalAbort.signal), toolExecTimeoutMs, `Tool "${toolUse.name}" execution timed out after ${toolExecTimeoutMs}ms`);
4408
4742
  turnClock.noteProgress();
4743
+ // Error-shaped success (MCP isError / { error } payloads —
4744
+ // e.g. proxy-blocked tools) counts toward the breaker too:
4745
+ // these fail without throwing, and only counting throws lets
4746
+ // the model grind on a blocked tool for the whole budget.
4747
+ const resultErrorText = extractToolFailureText(result);
4748
+ if (resultErrorText) {
4749
+ const info = failedTools.get(toolUse.name) || {
4750
+ count: 0,
4751
+ lastError: "",
4752
+ };
4753
+ info.count++;
4754
+ info.lastError = resultErrorText;
4755
+ failedTools.set(toolUse.name, info);
4756
+ }
4757
+ else {
4758
+ // Genuinely consecutive: a success clears the strike count
4759
+ // (argument-dependent soft errors — file-not-found on
4760
+ // different paths — must not disable a working tool).
4761
+ failedTools.delete(toolUse.name);
4762
+ }
4409
4763
  toolExecutions.push({
4410
4764
  name: toolUse.name,
4411
4765
  input: toolUse.input,
@@ -4453,7 +4807,17 @@ export class GoogleVertexProvider extends BaseProvider {
4453
4807
  toolName: toolUse.name,
4454
4808
  });
4455
4809
  }
4456
- const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
4810
+ // Count the failure toward the consecutive-failure breaker.
4811
+ const thrownErrorText = err instanceof Error ? err.message : String(err);
4812
+ const info = failedTools.get(toolUse.name) || {
4813
+ count: 0,
4814
+ lastError: "",
4815
+ };
4816
+ info.count++;
4817
+ info.lastError = thrownErrorText;
4818
+ failedTools.set(toolUse.name, info);
4819
+ logger.warn(`[GoogleVertex] Tool "${toolUse.name}" failed (attempt ${info.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${thrownErrorText}`);
4820
+ const errMsg = `Error executing tool "${toolUse.name}": ${thrownErrorText}`;
4457
4821
  const errorPayload = { error: errMsg };
4458
4822
  toolExecutions.push({
4459
4823
  name: toolUse.name,
@@ -4475,6 +4839,16 @@ export class GoogleVertexProvider extends BaseProvider {
4475
4839
  else {
4476
4840
  const errMsg = `TOOL_NOT_FOUND: The tool "${toolUse.name}" does not exist.`;
4477
4841
  const errorPayload = { error: errMsg };
4842
+ // A missing tool counts toward the breaker too — a model
4843
+ // grinding on a hallucinated/stale tool name must not burn the
4844
+ // whole step budget on TOOL_NOT_FOUND round-trips.
4845
+ const notFoundInfo = failedTools.get(toolUse.name) || {
4846
+ count: 0,
4847
+ lastError: "",
4848
+ };
4849
+ notFoundInfo.count++;
4850
+ notFoundInfo.lastError = errMsg;
4851
+ failedTools.set(toolUse.name, notFoundInfo);
4478
4852
  toolExecutions.push({
4479
4853
  name: toolUse.name,
4480
4854
  input: toolUse.input,
@@ -4544,6 +4918,13 @@ export class GoogleVertexProvider extends BaseProvider {
4544
4918
  role: "user",
4545
4919
  content: toolResults,
4546
4920
  });
4921
+ // Project this step's growth for the context guard: everything just
4922
+ // appended (tool results + nudge text) rides the next prompt. The
4923
+ // assistant output was already counted via noteUsage.
4924
+ contextGuard.noteAppendedChars(toolResults.reduce((sum, block) => sum +
4925
+ ("content" in block
4926
+ ? block.content.length
4927
+ : block.text.length), 0));
4547
4928
  // Accumulate the step's prose so a capped turn can still surface it —
4548
4929
  // finalText is reserved for model-initiated finishes.
4549
4930
  accumulatedStepText = appendStepText(accumulatedStepText, responseText);
@@ -4593,6 +4974,14 @@ export class GoogleVertexProvider extends BaseProvider {
4593
4974
  wasAborted = true;
4594
4975
  }
4595
4976
  const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
4977
+ // Clamp the terminal calls' max_tokens so prompt + output stays inside
4978
+ // the model window: pre-4.5 Claude models 400 on input + max_tokens >
4979
+ // window, which would defeat the very synthesis these calls exist for
4980
+ // on context-capped turns. Only bites when the prompt is near the
4981
+ // window (min() is a no-op on ordinary step-cap turns).
4982
+ const terminalMaxTokens = Math.max(1024, Math.min(requestParams.max_tokens, getContextWindowSize("vertex", modelName) -
4983
+ contextGuard.projectedNextPromptTokens -
4984
+ 4_000));
4596
4985
  if (wasAborted) {
4597
4986
  // Budget already blown — never issue another model call; prefer the
4598
4987
  // prose the model already produced (mirrors the Gemini abort path),
@@ -4614,8 +5003,12 @@ export class GoogleVertexProvider extends BaseProvider {
4614
5003
  });
4615
5004
  }
4616
5005
  else if (useFinalResultTool) {
4617
- hitStepLimit = true;
4618
- logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
5006
+ if (!hitContextLimit) {
5007
+ hitStepLimit = true;
5008
+ }
5009
+ logger.warn(hitContextLimit
5010
+ ? `[GoogleVertex] Native Anthropic generate loop stopped by the context guard without final_result; forcing a finalization call.`
5011
+ : `[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
4619
5012
  try {
4620
5013
  // Reserved finalization step: identical request except tool_choice
4621
5014
  // pins final_result, so Anthropic guarantees the response is a
@@ -4629,6 +5022,7 @@ export class GoogleVertexProvider extends BaseProvider {
4629
5022
  });
4630
5023
  const response = await withTimeout(client.messages.create({
4631
5024
  ...requestParams,
5025
+ max_tokens: terminalMaxTokens,
4632
5026
  tool_choice: {
4633
5027
  type: "tool",
4634
5028
  name: "final_result",
@@ -4656,7 +5050,9 @@ export class GoogleVertexProvider extends BaseProvider {
4656
5050
  logger.debug("[GoogleVertex] Forced finalization returned structured output (generate)", { keys: Object.keys(structuredOutput) });
4657
5051
  }
4658
5052
  else {
4659
- finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
5053
+ finalText = hitContextLimit
5054
+ ? buildContextCapMessage(externalToolCallCount)
5055
+ : buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4660
5056
  }
4661
5057
  }
4662
5058
  catch (error) {
@@ -4669,20 +5065,27 @@ export class GoogleVertexProvider extends BaseProvider {
4669
5065
  wasAborted = true;
4670
5066
  }
4671
5067
  logger.warn("[GoogleVertex] Forced finalization call failed; falling back to a terminal message", { error: error instanceof Error ? error.message : String(error) });
4672
- finalText = this.buildLoopExitMessage({
4673
- turnClock,
4674
- wasAborted,
4675
- stallTimeoutMs: options.stallTimeoutMs,
4676
- maxSteps,
4677
- toolCallCount: externalToolCallCount,
4678
- });
5068
+ finalText =
5069
+ hitContextLimit && !wasAborted
5070
+ ? buildContextCapMessage(externalToolCallCount)
5071
+ : this.buildLoopExitMessage({
5072
+ turnClock,
5073
+ wasAborted,
5074
+ stallTimeoutMs: options.stallTimeoutMs,
5075
+ maxSteps,
5076
+ toolCallCount: externalToolCallCount,
5077
+ });
4679
5078
  }
4680
5079
  }
4681
5080
  else {
4682
- hitStepLimit = true;
5081
+ if (!hitContextLimit) {
5082
+ hitStepLimit = true;
5083
+ }
4683
5084
  if (accumulatedStepText) {
4684
5085
  // Prefer the prose the model already produced across steps.
4685
- logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}); returning text already gathered from prior steps.`);
5086
+ logger.warn(hitContextLimit
5087
+ ? `[GoogleVertex] Native Anthropic generate loop stopped by the context guard; returning text already gathered from prior steps.`
5088
+ : `[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}); returning text already gathered from prior steps.`);
4686
5089
  finalText = accumulatedStepText;
4687
5090
  }
4688
5091
  else {
@@ -4693,7 +5096,9 @@ export class GoogleVertexProvider extends BaseProvider {
4693
5096
  // history contains tool_use/tool_result blocks without a tools
4694
5097
  // param — so "tools disabled" is expressed as tool_choice:"none",
4695
5098
  // which also keeps the cached tools prefix byte-identical.
4696
- logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
5099
+ logger.warn(hitContextLimit
5100
+ ? `[GoogleVertex] Native Anthropic generate loop stopped by the context guard with no text; synthesizing a final answer with tools disabled.`
5101
+ : `[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
4697
5102
  try {
4698
5103
  const backstopSystem = (systemPromptWithSchema ? systemPromptWithSchema + "\n\n" : "") +
4699
5104
  "Tool calling is no longer available for this turn. Provide " +
@@ -4706,6 +5111,7 @@ export class GoogleVertexProvider extends BaseProvider {
4706
5111
  });
4707
5112
  const response = await withTimeout(client.messages.create({
4708
5113
  ...requestParams,
5114
+ max_tokens: terminalMaxTokens,
4709
5115
  tool_choice: { type: "none" },
4710
5116
  system: cachedBackstop.system,
4711
5117
  ...(cachedBackstop.tools &&
@@ -4730,7 +5136,9 @@ export class GoogleVertexProvider extends BaseProvider {
4730
5136
  finalText = backstopText;
4731
5137
  }
4732
5138
  else {
4733
- finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
5139
+ finalText = hitContextLimit
5140
+ ? buildContextCapMessage(externalToolCallCount)
5141
+ : buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4734
5142
  }
4735
5143
  }
4736
5144
  catch (error) {
@@ -4745,13 +5153,16 @@ export class GoogleVertexProvider extends BaseProvider {
4745
5153
  logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to a terminal message", {
4746
5154
  error: error instanceof Error ? error.message : String(error),
4747
5155
  });
4748
- finalText = this.buildLoopExitMessage({
4749
- turnClock,
4750
- wasAborted,
4751
- stallTimeoutMs: options.stallTimeoutMs,
4752
- maxSteps,
4753
- toolCallCount: externalToolCallCount,
4754
- });
5156
+ finalText =
5157
+ hitContextLimit && !wasAborted
5158
+ ? buildContextCapMessage(externalToolCallCount)
5159
+ : this.buildLoopExitMessage({
5160
+ turnClock,
5161
+ wasAborted,
5162
+ stallTimeoutMs: options.stallTimeoutMs,
5163
+ maxSteps,
5164
+ toolCallCount: externalToolCallCount,
5165
+ });
4755
5166
  }
4756
5167
  }
4757
5168
  }
@@ -4765,13 +5176,16 @@ export class GoogleVertexProvider extends BaseProvider {
4765
5176
  // Cause-aware: an aborted/timed-out turn gets the honest exit message,
4766
5177
  // a genuine budget exhaustion gets the step-cap message.
4767
5178
  if (!finalText && externalToolCalls.length > 0) {
4768
- finalText = this.buildLoopExitMessage({
4769
- turnClock,
4770
- wasAborted,
4771
- stallTimeoutMs: options.stallTimeoutMs,
4772
- maxSteps,
4773
- toolCallCount: externalToolCalls.length,
4774
- });
5179
+ finalText =
5180
+ hitContextLimit && !wasAborted
5181
+ ? buildContextCapMessage(externalToolCalls.length)
5182
+ : this.buildLoopExitMessage({
5183
+ turnClock,
5184
+ wasAborted,
5185
+ stallTimeoutMs: options.stallTimeoutMs,
5186
+ maxSteps,
5187
+ toolCallCount: externalToolCalls.length,
5188
+ });
4775
5189
  }
4776
5190
  // Honest finish reason. "length" (token truncation) takes precedence; a
4777
5191
  // step-cap exhaustion without a clean/forced answer surfaces as
@@ -4790,6 +5204,7 @@ export class GoogleVertexProvider extends BaseProvider {
4790
5204
  stalled: turnClock.stalled,
4791
5205
  wasAborted,
4792
5206
  cappedWithoutAnswer: hitStepLimit && !synthesizedFinalAnswer,
5207
+ contextCappedWithoutAnswer: hitContextLimit && !synthesizedFinalAnswer,
4793
5208
  finishReason: resolvedFinishReason,
4794
5209
  });
4795
5210
  if (stopReason !== "completed") {