@juspay/neurolink 9.80.3 → 9.81.0

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 (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +352 -352
  3. package/dist/cli/loop/optionsSchema.js +16 -0
  4. package/dist/context/stages/structuredSummarizer.js +15 -4
  5. package/dist/core/analytics.js +22 -0
  6. package/dist/core/constants.d.ts +13 -0
  7. package/dist/core/constants.js +13 -0
  8. package/dist/core/modules/GenerationHandler.js +28 -10
  9. package/dist/core/modules/structuredOutputPolicy.d.ts +19 -0
  10. package/dist/core/modules/structuredOutputPolicy.js +26 -0
  11. package/dist/lib/context/stages/structuredSummarizer.js +15 -4
  12. package/dist/lib/core/analytics.js +22 -0
  13. package/dist/lib/core/constants.d.ts +13 -0
  14. package/dist/lib/core/constants.js +13 -0
  15. package/dist/lib/core/modules/GenerationHandler.js +28 -10
  16. package/dist/lib/core/modules/structuredOutputPolicy.d.ts +19 -0
  17. package/dist/lib/core/modules/structuredOutputPolicy.js +26 -0
  18. package/dist/lib/mcp/externalServerManager.js +21 -6
  19. package/dist/lib/mcp/mcpClientFactory.js +5 -1
  20. package/dist/lib/neurolink.js +88 -28
  21. package/dist/lib/providers/googleNativeGemini3.d.ts +92 -4
  22. package/dist/lib/providers/googleNativeGemini3.js +186 -4
  23. package/dist/lib/providers/googleVertex.d.ts +15 -0
  24. package/dist/lib/providers/googleVertex.js +719 -139
  25. package/dist/lib/types/analytics.d.ts +10 -0
  26. package/dist/lib/types/generate.d.ts +88 -0
  27. package/dist/lib/types/stream.d.ts +21 -1
  28. package/dist/lib/utils/conversationMemory.js +19 -8
  29. package/dist/lib/utils/logSanitize.d.ts +26 -0
  30. package/dist/lib/utils/logSanitize.js +56 -0
  31. package/dist/mcp/externalServerManager.js +21 -6
  32. package/dist/mcp/mcpClientFactory.js +5 -1
  33. package/dist/neurolink.js +88 -28
  34. package/dist/providers/googleNativeGemini3.d.ts +92 -4
  35. package/dist/providers/googleNativeGemini3.js +186 -4
  36. package/dist/providers/googleVertex.d.ts +15 -0
  37. package/dist/providers/googleVertex.js +719 -139
  38. package/dist/types/analytics.d.ts +10 -0
  39. package/dist/types/generate.d.ts +88 -0
  40. package/dist/types/stream.d.ts +21 -1
  41. package/dist/utils/conversationMemory.js +19 -8
  42. package/dist/utils/logSanitize.d.ts +26 -0
  43. package/dist/utils/logSanitize.js +56 -0
  44. package/package.json +1 -1
@@ -5,8 +5,10 @@ import path from "path";
5
5
  import os from "os";
6
6
  import { ErrorCategory, ErrorSeverity, } from "../constants/enums.js";
7
7
  import { BaseProvider } from "../core/baseProvider.js";
8
- import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, IMAGE_GENERATION_MODELS, TOOL_STORAGE_TIMEOUT_MS, } from "../core/constants.js";
8
+ import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_EXECUTION_TIMEOUT_MS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, IMAGE_GENERATION_MODELS, TOOL_STORAGE_TIMEOUT_MS, } from "../core/constants.js";
9
9
  import { ModelConfigurationManager } from "../core/modelConfiguration.js";
10
+ import { isSchemaComplexityError } from "../core/modules/structuredOutputPolicy.js";
11
+ import { stringifyContentSafe } from "../utils/logSanitize.js";
10
12
  import { createProxyFetch } from "../proxy/proxyFetch.js";
11
13
  import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
12
14
  import { ERROR_CODES, NeuroLinkError } from "../utils/errorHandling.js";
@@ -22,7 +24,7 @@ import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } fr
22
24
  import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
23
25
  import { TimeoutError, withTimeout } from "../utils/async/index.js";
24
26
  import { parseTimeout } from "../utils/timeout.js";
25
- import { appendStepText, buildToolLoopCapMessage, createTextChannel, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, } from "./googleNativeGemini3.js";
27
+ import { appendStepText, buildAbortedTurnMessage, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createTextChannel, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, } from "./googleNativeGemini3.js";
26
28
  import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../telemetry/index.js";
27
29
  import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
28
30
  import { calculateCost } from "../utils/pricing.js";
@@ -1326,21 +1328,37 @@ export class GoogleVertexProvider extends BaseProvider {
1326
1328
  const incrementalTextChunks = [];
1327
1329
  // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
1328
1330
  // Gemini SDK cancels via config.abortSignal, so drive an internal
1329
- // AbortController: the caller's signal and a defensive wall-clock timer
1330
- // both trip it, and every request/tool-exec receives effectiveSignal.
1331
+ // AbortController: the caller's signal and the turn clock's watchdogs
1332
+ // (whole-turn deadline + optional stall detector) all trip it, and every
1333
+ // request/tool-exec receives effectiveSignal.
1331
1334
  const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
1335
+ const toolExecTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS;
1336
+ const effectiveTurnDeadlineMs = options.turnTimeoutMs ?? streamTimeoutMs;
1332
1337
  const internalAbort = new AbortController();
1333
1338
  const onCallerAbort = () => internalAbort.abort();
1334
1339
  options.abortSignal?.addEventListener("abort", onCallerAbort);
1335
- const defensiveTimer = setTimeout(() => {
1336
- logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
1337
- internalAbort.abort();
1338
- }, streamTimeoutMs);
1340
+ const turnClock = createTurnClock({
1341
+ turnTimeoutMs: options.turnTimeoutMs,
1342
+ // Preserve the pre-existing defensive whole-turn bound when the caller
1343
+ // sets no explicit turn budget — a turn must never hang forever.
1344
+ defaultTurnTimeoutMs: streamTimeoutMs,
1345
+ stallTimeoutMs: options.stallTimeoutMs,
1346
+ wrapupTimeLeadMs: options.wrapupTimeLeadMs,
1347
+ onDeadline: (kind) => {
1348
+ logger.warn(kind === "timeout"
1349
+ ? `[GoogleVertex] Native Gemini turn exceeded its ${effectiveTurnDeadlineMs}ms time budget — aborting`
1350
+ : `[GoogleVertex] Native Gemini turn made no progress for ${options.stallTimeoutMs}ms — aborting`);
1351
+ internalAbort.abort();
1352
+ },
1353
+ });
1339
1354
  const effectiveSignal = internalAbort.signal;
1340
1355
  if (options.abortSignal?.aborted) {
1341
1356
  internalAbort.abort();
1342
1357
  }
1343
1358
  let wasAborted = false;
1359
+ // One retry per turn for MALFORMED_FUNCTION_CALL steps (see the retry
1360
+ // block after the step drain).
1361
+ let malformedRetryCount = 0;
1344
1362
  // Step-cap flags declared in the outer scope so the terminal block (also
1345
1363
  // inside the try) and the finishReason mapping (after the finally) can
1346
1364
  // both read them.
@@ -1354,6 +1372,7 @@ export class GoogleVertexProvider extends BaseProvider {
1354
1372
  break;
1355
1373
  }
1356
1374
  step++;
1375
+ turnClock.noteProgress();
1357
1376
  logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
1358
1377
  try {
1359
1378
  const stream = await client.models.generateContentStream({
@@ -1364,7 +1383,12 @@ export class GoogleVertexProvider extends BaseProvider {
1364
1383
  const stepFunctionCalls = [];
1365
1384
  // Capture raw response parts including thoughtSignature
1366
1385
  const rawResponseParts = [];
1386
+ // This step's own finish reason (vs the cross-step
1387
+ // lastFinishReason) — drives the single MALFORMED_FUNCTION_CALL
1388
+ // retry below.
1389
+ let stepFinishReason;
1367
1390
  for await (const chunk of stream) {
1391
+ turnClock.noteProgress();
1368
1392
  // Extract raw parts from candidates FIRST
1369
1393
  // This avoids using chunk.text which triggers SDK warning when
1370
1394
  // non-text parts (thoughtSignature, functionCall) are present
@@ -1376,6 +1400,7 @@ export class GoogleVertexProvider extends BaseProvider {
1376
1400
  const chunkFinishReason = firstCandidate?.finishReason;
1377
1401
  if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1378
1402
  lastFinishReason = chunkFinishReason;
1403
+ stepFinishReason = chunkFinishReason;
1379
1404
  }
1380
1405
  const chunkContent = firstCandidate?.content;
1381
1406
  if (chunkContent && Array.isArray(chunkContent.parts)) {
@@ -1411,6 +1436,37 @@ export class GoogleVertexProvider extends BaseProvider {
1411
1436
  .filter((part) => typeof part.text === "string")
1412
1437
  .map((part) => part.text)
1413
1438
  .join("");
1439
+ // MALFORMED_FUNCTION_CALL is usually a transient formatting failure
1440
+ // (the model emitted an unparseable call): retry the step ONCE with
1441
+ // a corrective note instead of hard-ending the turn with empty
1442
+ // content — automated alert-RCA turns were dying at step 2-4 on
1443
+ // this, mislabeled as step-cap exits.
1444
+ if (stepFunctionCalls.length === 0 &&
1445
+ !stepText &&
1446
+ stepFinishReason === "MALFORMED_FUNCTION_CALL" &&
1447
+ malformedRetryCount < 1 &&
1448
+ !effectiveSignal.aborted) {
1449
+ malformedRetryCount++;
1450
+ logger.warn(`[GoogleVertex] Model returned MALFORMED_FUNCTION_CALL at step ${step}/${maxSteps}; retrying once with a corrective note.`);
1451
+ this.emitTurnEvent({ phase: "malformed-retry", step, maxSteps });
1452
+ if (rawResponseParts.length > 0) {
1453
+ currentContents.push({
1454
+ role: "model",
1455
+ parts: rawResponseParts,
1456
+ });
1457
+ }
1458
+ currentContents.push({
1459
+ role: "user",
1460
+ parts: [
1461
+ {
1462
+ text: "Your previous function call was malformed and could not " +
1463
+ "be parsed. Re-issue it as a single valid function call, " +
1464
+ "or answer in plain text.",
1465
+ },
1466
+ ],
1467
+ });
1468
+ continue;
1469
+ }
1414
1470
  // If no function calls, we're done
1415
1471
  if (stepFunctionCalls.length === 0) {
1416
1472
  finalText = stepText;
@@ -1487,7 +1543,11 @@ export class GoogleVertexProvider extends BaseProvider {
1487
1543
  messages: [],
1488
1544
  abortSignal: effectiveSignal,
1489
1545
  };
1490
- const result = await execute(call.args, toolOptions);
1546
+ turnClock.noteProgress();
1547
+ // 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`);
1550
+ turnClock.noteProgress();
1491
1551
  toolExecutions.push({
1492
1552
  name: call.name,
1493
1553
  input: call.args,
@@ -1512,6 +1572,15 @@ export class GoogleVertexProvider extends BaseProvider {
1512
1572
  wasAborted = true;
1513
1573
  break;
1514
1574
  }
1575
+ turnClock.noteProgress();
1576
+ if (error instanceof TimeoutError) {
1577
+ this.emitTurnEvent({
1578
+ phase: "tool-timeout",
1579
+ step,
1580
+ maxSteps,
1581
+ toolName: call.name,
1582
+ });
1583
+ }
1515
1584
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
1516
1585
  // Track this failure
1517
1586
  const currentFailInfo = failedTools.get(call.name) || {
@@ -1601,6 +1670,15 @@ export class GoogleVertexProvider extends BaseProvider {
1601
1670
  });
1602
1671
  });
1603
1672
  }
1673
+ // Time-budget wrap-up nudge (twin of the Anthropic loops' soft
1674
+ // step nudge): with the turn deadline approaching, tell the model
1675
+ // to consolidate. Rides as a trailing text part on the
1676
+ // tool-response user turn.
1677
+ if (turnClock.shouldNudgeWrapup()) {
1678
+ functionResponses.push({
1679
+ text: buildWrapupNudgeText(useFinalResultTool),
1680
+ });
1681
+ }
1604
1682
  // The @google/genai SDK only accepts "user" and "model" as valid
1605
1683
  // roles in contents — function/tool responses must use role: "user"
1606
1684
  // (matching the SDK's automaticFunctionCalling implementation and
@@ -1645,11 +1723,21 @@ export class GoogleVertexProvider extends BaseProvider {
1645
1723
  `returning text already gathered from prior steps.`);
1646
1724
  }
1647
1725
  else if (wasAborted) {
1648
- // Aborted turn skip synth entirely so it can never add +300s after
1649
- // a blown budget. Deliver exactly one graceful cap chunk.
1650
- logger.warn(`[GoogleVertex] Tool call loop aborted mid-turn; ` +
1651
- `returning a graceful cap message.`);
1652
- finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
1726
+ // Turn ended on a time condition or caller abort skip synth
1727
+ // entirely so it can never add +300s after a blown budget, and
1728
+ // deliver exactly one HONEST terminal chunk matching the actual
1729
+ // exit cause (never the step-cap text — a killed healthy turn must
1730
+ // not claim it "reached the step limit").
1731
+ logger.warn(`[GoogleVertex] Tool call loop ended mid-turn ` +
1732
+ `(${turnClock.timedOut ? "turn time limit" : turnClock.stalled ? "stall watchdog" : "caller abort"}); ` +
1733
+ `returning an honest terminal message.`);
1734
+ finalText = this.buildLoopExitMessage({
1735
+ turnClock,
1736
+ wasAborted,
1737
+ stallTimeoutMs: options.stallTimeoutMs,
1738
+ maxSteps,
1739
+ toolCallCount,
1740
+ });
1653
1741
  }
1654
1742
  else {
1655
1743
  logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
@@ -1675,7 +1763,7 @@ export class GoogleVertexProvider extends BaseProvider {
1675
1763
  }
1676
1764
  }
1677
1765
  finally {
1678
- clearTimeout(defensiveTimer);
1766
+ turnClock.dispose();
1679
1767
  options.abortSignal?.removeEventListener("abort", onCallerAbort);
1680
1768
  }
1681
1769
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
@@ -1686,6 +1774,24 @@ export class GoogleVertexProvider extends BaseProvider {
1686
1774
  const resolvedFinishReason = hitStepLimit && !synthesizedFinalAnswer
1687
1775
  ? "tool-calls"
1688
1776
  : mapGeminiFinishReason(lastFinishReason);
1777
+ // Turn-exit discriminator, independent of the provider-shaped
1778
+ // finishReason — consumers branch on this instead of sniffing strings.
1779
+ const stopReason = resolveTurnStopReason({
1780
+ timedOut: turnClock.timedOut,
1781
+ stalled: turnClock.stalled,
1782
+ wasAborted,
1783
+ cappedWithoutAnswer: hitStepLimit && !synthesizedFinalAnswer,
1784
+ finishReason: resolvedFinishReason,
1785
+ });
1786
+ if (stopReason !== "completed") {
1787
+ this.emitTurnEvent({
1788
+ phase: stopReason,
1789
+ step,
1790
+ maxSteps,
1791
+ toolCallCount: allToolCalls.filter((tc) => tc.toolName !== "final_result").length,
1792
+ elapsedMs: turnClock.elapsedMs(),
1793
+ });
1794
+ }
1689
1795
  const responseTime = Date.now() - startTime;
1690
1796
  // Yield each text part separately so the CLI receives multiple stream
1691
1797
  // chunks instead of a single coalesced buffer. The SDK already gave us
@@ -1713,6 +1819,8 @@ export class GoogleVertexProvider extends BaseProvider {
1713
1819
  provider: this.providerName,
1714
1820
  model: modelName,
1715
1821
  finishReason: resolvedFinishReason,
1822
+ stopReason,
1823
+ rawFinishReason: lastFinishReason,
1716
1824
  usage: {
1717
1825
  input: totalInputTokens,
1718
1826
  output: totalOutputTokens,
@@ -1733,6 +1841,9 @@ export class GoogleVertexProvider extends BaseProvider {
1733
1841
  startTime,
1734
1842
  responseTime,
1735
1843
  totalToolExecutions: externalToolCalls.length,
1844
+ stopReason,
1845
+ rawFinishReason: lastFinishReason,
1846
+ stepsUsed: step,
1736
1847
  },
1737
1848
  };
1738
1849
  // Add structured output if final_result tool was used
@@ -2063,21 +2174,37 @@ export class GoogleVertexProvider extends BaseProvider {
2063
2174
  let totalOutputTokens = 0;
2064
2175
  // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
2065
2176
  // Gemini SDK cancels via config.abortSignal, so drive an internal
2066
- // AbortController: the caller's signal and a defensive wall-clock timer
2067
- // both trip it, and every request/tool-exec receives effectiveSignal.
2177
+ // AbortController: the caller's signal and the turn clock's watchdogs
2178
+ // (whole-turn deadline + optional stall detector) all trip it, and every
2179
+ // request/tool-exec receives effectiveSignal.
2068
2180
  const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
2181
+ const toolExecTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS;
2182
+ const effectiveTurnDeadlineMs = options.turnTimeoutMs ?? streamTimeoutMs;
2069
2183
  const internalAbort = new AbortController();
2070
2184
  const onCallerAbort = () => internalAbort.abort();
2071
2185
  options.abortSignal?.addEventListener("abort", onCallerAbort);
2072
- const defensiveTimer = setTimeout(() => {
2073
- logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
2074
- internalAbort.abort();
2075
- }, streamTimeoutMs);
2186
+ const turnClock = createTurnClock({
2187
+ turnTimeoutMs: options.turnTimeoutMs,
2188
+ // Preserve the pre-existing defensive whole-turn bound when the caller
2189
+ // sets no explicit turn budget — a turn must never hang forever.
2190
+ defaultTurnTimeoutMs: streamTimeoutMs,
2191
+ stallTimeoutMs: options.stallTimeoutMs,
2192
+ wrapupTimeLeadMs: options.wrapupTimeLeadMs,
2193
+ onDeadline: (kind) => {
2194
+ logger.warn(kind === "timeout"
2195
+ ? `[GoogleVertex] Native Gemini turn exceeded its ${effectiveTurnDeadlineMs}ms time budget — aborting`
2196
+ : `[GoogleVertex] Native Gemini turn made no progress for ${options.stallTimeoutMs}ms — aborting`);
2197
+ internalAbort.abort();
2198
+ },
2199
+ });
2076
2200
  const effectiveSignal = internalAbort.signal;
2077
2201
  if (options.abortSignal?.aborted) {
2078
2202
  internalAbort.abort();
2079
2203
  }
2080
2204
  let wasAborted = false;
2205
+ // One retry per turn for MALFORMED_FUNCTION_CALL steps (see the retry
2206
+ // block after the step drain).
2207
+ let malformedRetryCount = 0;
2081
2208
  // Step-cap flags declared in the outer scope so the terminal block (also
2082
2209
  // inside the try) and the finishReason mapping (after the finally) can
2083
2210
  // both read them.
@@ -2091,6 +2218,7 @@ export class GoogleVertexProvider extends BaseProvider {
2091
2218
  break;
2092
2219
  }
2093
2220
  step++;
2221
+ turnClock.noteProgress();
2094
2222
  logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
2095
2223
  try {
2096
2224
  // Use generateContentStream and collect all chunks (same as GoogleAIStudio)
@@ -2102,8 +2230,13 @@ export class GoogleVertexProvider extends BaseProvider {
2102
2230
  const stepFunctionCalls = [];
2103
2231
  // Capture raw response parts including thoughtSignature
2104
2232
  const rawResponseParts = [];
2233
+ // This step's own finish reason (vs the cross-step
2234
+ // lastFinishReason) — drives the single MALFORMED_FUNCTION_CALL
2235
+ // retry below.
2236
+ let stepFinishReason;
2105
2237
  // Collect all chunks from stream
2106
2238
  for await (const chunk of stream) {
2239
+ turnClock.noteProgress();
2107
2240
  // Extract raw parts from candidates FIRST
2108
2241
  // This avoids using chunk.text which triggers SDK warning when
2109
2242
  // non-text parts (thoughtSignature, functionCall) are present
@@ -2115,6 +2248,7 @@ export class GoogleVertexProvider extends BaseProvider {
2115
2248
  const chunkFinishReason = firstCandidate?.finishReason;
2116
2249
  if (typeof chunkFinishReason === "string" && chunkFinishReason) {
2117
2250
  lastFinishReason = chunkFinishReason;
2251
+ stepFinishReason = chunkFinishReason;
2118
2252
  }
2119
2253
  const chunkContent = firstCandidate?.content;
2120
2254
  if (chunkContent && Array.isArray(chunkContent.parts)) {
@@ -2145,6 +2279,37 @@ export class GoogleVertexProvider extends BaseProvider {
2145
2279
  .filter((part) => typeof part.text === "string")
2146
2280
  .map((part) => part.text)
2147
2281
  .join("");
2282
+ // MALFORMED_FUNCTION_CALL is usually a transient formatting failure
2283
+ // (the model emitted an unparseable call): retry the step ONCE with
2284
+ // a corrective note instead of hard-ending the turn with empty
2285
+ // content — automated alert-RCA turns were dying at step 2-4 on
2286
+ // this, mislabeled as step-cap exits.
2287
+ if (stepFunctionCalls.length === 0 &&
2288
+ !stepText &&
2289
+ stepFinishReason === "MALFORMED_FUNCTION_CALL" &&
2290
+ malformedRetryCount < 1 &&
2291
+ !effectiveSignal.aborted) {
2292
+ malformedRetryCount++;
2293
+ logger.warn(`[GoogleVertex] Model returned MALFORMED_FUNCTION_CALL at step ${step}/${maxSteps}; retrying once with a corrective note.`);
2294
+ this.emitTurnEvent({ phase: "malformed-retry", step, maxSteps });
2295
+ if (rawResponseParts.length > 0) {
2296
+ currentContents.push({
2297
+ role: "model",
2298
+ parts: rawResponseParts,
2299
+ });
2300
+ }
2301
+ currentContents.push({
2302
+ role: "user",
2303
+ parts: [
2304
+ {
2305
+ text: "Your previous function call was malformed and could not " +
2306
+ "be parsed. Re-issue it as a single valid function call, " +
2307
+ "or answer in plain text.",
2308
+ },
2309
+ ],
2310
+ });
2311
+ continue;
2312
+ }
2148
2313
  // If no function calls, we're done
2149
2314
  if (stepFunctionCalls.length === 0) {
2150
2315
  finalText = stepText;
@@ -2220,7 +2385,11 @@ export class GoogleVertexProvider extends BaseProvider {
2220
2385
  messages: [],
2221
2386
  abortSignal: effectiveSignal,
2222
2387
  };
2223
- const execResult = await execute(call.args, toolOptions);
2388
+ turnClock.noteProgress();
2389
+ // Bound the execute() await — a wedged tool costs one step
2390
+ // (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`);
2392
+ turnClock.noteProgress();
2224
2393
  // Track execution
2225
2394
  toolExecutions.push({
2226
2395
  name: call.name,
@@ -2245,6 +2414,15 @@ export class GoogleVertexProvider extends BaseProvider {
2245
2414
  wasAborted = true;
2246
2415
  break;
2247
2416
  }
2417
+ turnClock.noteProgress();
2418
+ if (error instanceof TimeoutError) {
2419
+ this.emitTurnEvent({
2420
+ phase: "tool-timeout",
2421
+ step,
2422
+ maxSteps,
2423
+ toolName: call.name,
2424
+ });
2425
+ }
2248
2426
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
2249
2427
  // Track this failure
2250
2428
  const currentFailInfo = failedTools.get(call.name) || {
@@ -2331,6 +2509,15 @@ export class GoogleVertexProvider extends BaseProvider {
2331
2509
  });
2332
2510
  });
2333
2511
  }
2512
+ // Time-budget wrap-up nudge (twin of the Anthropic loops' soft
2513
+ // step nudge): with the turn deadline approaching, tell the model
2514
+ // to consolidate. Rides as a trailing text part on the
2515
+ // tool-response user turn.
2516
+ if (turnClock.shouldNudgeWrapup()) {
2517
+ functionResponses.push({
2518
+ text: buildWrapupNudgeText(useFinalResultTool),
2519
+ });
2520
+ }
2334
2521
  // The @google/genai SDK only accepts "user" and "model" as valid
2335
2522
  // roles in contents — function/tool responses must use role: "user"
2336
2523
  // (matching the SDK's automaticFunctionCalling implementation and
@@ -2351,7 +2538,25 @@ export class GoogleVertexProvider extends BaseProvider {
2351
2538
  wasAborted = true;
2352
2539
  break;
2353
2540
  }
2354
- logger.error("[GoogleVertex] Native SDK generate error", error);
2541
+ logger.error("[GoogleVertex] Native SDK generate error", {
2542
+ error,
2543
+ model: modelName,
2544
+ location: effectiveLocation,
2545
+ status: error?.status,
2546
+ });
2547
+ // Best-effort request context for formatProviderError —
2548
+ // this.modelName can be stale when options.model overrides the
2549
+ // instance default.
2550
+ try {
2551
+ if (error && typeof error === "object") {
2552
+ const e = error;
2553
+ e.requestModel = modelName;
2554
+ e.requestRegion = effectiveLocation;
2555
+ }
2556
+ }
2557
+ catch {
2558
+ /* frozen/sealed error — context stays best-effort */
2559
+ }
2355
2560
  throw this.handleProviderError(error);
2356
2561
  }
2357
2562
  }
@@ -2369,11 +2574,20 @@ export class GoogleVertexProvider extends BaseProvider {
2369
2574
  finalText = accumulatedText;
2370
2575
  }
2371
2576
  else if (wasAborted) {
2372
- // Aborted turn skip synth entirely so it can never add +300s after
2373
- // a blown budget. Deliver a graceful cap message as ordinary prose.
2374
- logger.warn(`[GoogleVertex] Generate tool call loop aborted mid-turn; ` +
2375
- `returning a graceful cap message.`);
2376
- finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
2577
+ // Turn ended on a time condition or caller abort skip synth
2578
+ // entirely so it can never add +300s after a blown budget, and
2579
+ // answer with an HONEST message matching the actual exit cause
2580
+ // (never the step-cap text).
2581
+ logger.warn(`[GoogleVertex] Generate tool call loop ended mid-turn ` +
2582
+ `(${turnClock.timedOut ? "turn time limit" : turnClock.stalled ? "stall watchdog" : "caller abort"}); ` +
2583
+ `returning an honest terminal message.`);
2584
+ finalText = this.buildLoopExitMessage({
2585
+ turnClock,
2586
+ wasAborted,
2587
+ stallTimeoutMs: options.stallTimeoutMs,
2588
+ maxSteps,
2589
+ toolCallCount,
2590
+ });
2377
2591
  }
2378
2592
  else {
2379
2593
  // Pure functionCall turns leave no text — make one tools-disabled call
@@ -2401,7 +2615,7 @@ export class GoogleVertexProvider extends BaseProvider {
2401
2615
  }
2402
2616
  }
2403
2617
  finally {
2404
- clearTimeout(defensiveTimer);
2618
+ turnClock.dispose();
2405
2619
  options.abortSignal?.removeEventListener("abort", onCallerAbort);
2406
2620
  }
2407
2621
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
@@ -2412,6 +2626,24 @@ export class GoogleVertexProvider extends BaseProvider {
2412
2626
  const resolvedFinishReason = hitStepLimit && !synthesizedFinalAnswer
2413
2627
  ? "tool-calls"
2414
2628
  : mapGeminiFinishReason(lastFinishReason);
2629
+ // Turn-exit discriminator, independent of the provider-shaped
2630
+ // finishReason — consumers branch on this instead of sniffing strings.
2631
+ const stopReason = resolveTurnStopReason({
2632
+ timedOut: turnClock.timedOut,
2633
+ stalled: turnClock.stalled,
2634
+ wasAborted,
2635
+ cappedWithoutAnswer: hitStepLimit && !synthesizedFinalAnswer,
2636
+ finishReason: resolvedFinishReason,
2637
+ });
2638
+ if (stopReason !== "completed") {
2639
+ this.emitTurnEvent({
2640
+ phase: stopReason,
2641
+ step,
2642
+ maxSteps,
2643
+ toolCallCount: allToolCalls.filter((tc) => tc.toolName !== "final_result").length,
2644
+ elapsedMs: turnClock.elapsedMs(),
2645
+ });
2646
+ }
2415
2647
  const responseTime = Date.now() - startTime;
2416
2648
  // Filter out final_result from tool calls and executions as it's an internal pattern
2417
2649
  const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
@@ -2422,6 +2654,9 @@ export class GoogleVertexProvider extends BaseProvider {
2422
2654
  provider: this.providerName,
2423
2655
  model: modelName,
2424
2656
  finishReason: resolvedFinishReason,
2657
+ stopReason,
2658
+ rawFinishReason: lastFinishReason,
2659
+ stepsUsed: step,
2425
2660
  usage: {
2426
2661
  input: totalInputTokens,
2427
2662
  output: totalOutputTokens,
@@ -2585,9 +2820,7 @@ export class GoogleVertexProvider extends BaseProvider {
2585
2820
  if (msg.role === "user" || msg.role === "assistant") {
2586
2821
  messages.push({
2587
2822
  role: msg.role,
2588
- content: typeof msg.content === "string"
2589
- ? msg.content
2590
- : JSON.stringify(msg.content),
2823
+ content: stringifyContentSafe(msg.content),
2591
2824
  });
2592
2825
  }
2593
2826
  }
@@ -2861,6 +3094,8 @@ export class GoogleVertexProvider extends BaseProvider {
2861
3094
  // via a getter (consumers read it after draining the stream), mirroring
2862
3095
  // the Gemini stream path's finishReason field.
2863
3096
  const finishReasonRef = {};
3097
+ const stopReasonRef = {};
3098
+ const rawFinishReasonRef = {};
2864
3099
  // Langfuse/OTel: the native SDK bypasses the Vercel AI SDK's
2865
3100
  // experimental_telemetry, so emit spans manually — one turn span, one
2866
3101
  // generation span per API call, one tool span per execution — all carrying
@@ -2909,8 +3144,8 @@ export class GoogleVertexProvider extends BaseProvider {
2909
3144
  creation5m: 0,
2910
3145
  creation1h: 0,
2911
3146
  };
2912
- // Track the active Anthropic stream so options.abortSignal can cancel it
2913
- // mid-flight (pre-rewrite code had no abort handling — fixed for free).
3147
+ // Track the active Anthropic stream so aborts can cancel it mid-flight
3148
+ // (pre-rewrite code had no abort handling — fixed for free).
2914
3149
  let activeStream;
2915
3150
  const abortHandler = () => {
2916
3151
  try {
@@ -2920,15 +3155,35 @@ export class GoogleVertexProvider extends BaseProvider {
2920
3155
  /* ignore — stream may already be finalized */
2921
3156
  }
2922
3157
  };
2923
- options.abortSignal?.addEventListener("abort", abortHandler);
2924
- // Defensive upper bound: if neither the caller nor the SDK ever fires,
2925
- // abort the stream after the configured timeout so a stalled
2926
- // Vertex/Anthropic endpoint can't hang forever. options.timeout wins
2927
- // if set; otherwise 5 min — generous for tool-heavy turns.
2928
- const streamTimeoutHandle = setTimeout(() => {
2929
- logger.warn(`[GoogleVertex] Anthropic stream exceeded ${streamTimeoutMs}ms — aborting`);
2930
- abortHandler();
2931
- }, streamTimeoutMs);
3158
+ // Internal abort fan-in: the caller's signal and the turn clock's
3159
+ // watchdogs (whole-turn deadline + optional stall detector) all trip it;
3160
+ // it cancels the in-flight SDK stream and is the signal tool executions
3161
+ // receive.
3162
+ const internalAbort = new AbortController();
3163
+ internalAbort.signal.addEventListener("abort", abortHandler);
3164
+ const onCallerAbort = () => internalAbort.abort();
3165
+ options.abortSignal?.addEventListener("abort", onCallerAbort);
3166
+ if (options.abortSignal?.aborted) {
3167
+ internalAbort.abort();
3168
+ }
3169
+ const toolExecTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS;
3170
+ const effectiveTurnDeadlineMs = options.turnTimeoutMs ?? streamTimeoutMs;
3171
+ // Whole-turn deadline + optional stall watchdog. When the caller sets no
3172
+ // explicit turn budget, keep the pre-existing defensive bound
3173
+ // (options.timeout, else 5 min) so a stalled Vertex/Anthropic endpoint
3174
+ // can't hang forever.
3175
+ const turnClock = createTurnClock({
3176
+ turnTimeoutMs: options.turnTimeoutMs,
3177
+ defaultTurnTimeoutMs: streamTimeoutMs,
3178
+ stallTimeoutMs: options.stallTimeoutMs,
3179
+ wrapupTimeLeadMs: options.wrapupTimeLeadMs,
3180
+ onDeadline: (kind) => {
3181
+ logger.warn(kind === "timeout"
3182
+ ? `[GoogleVertex] Anthropic stream turn exceeded its ${effectiveTurnDeadlineMs}ms time budget — aborting`
3183
+ : `[GoogleVertex] Anthropic stream turn made no progress for ${options.stallTimeoutMs}ms — aborting`);
3184
+ internalAbort.abort();
3185
+ },
3186
+ });
2932
3187
  const loopPromise = (async () => {
2933
3188
  let step = 0;
2934
3189
  const currentMessages = [...messages];
@@ -2942,15 +3197,17 @@ export class GoogleVertexProvider extends BaseProvider {
2942
3197
  let lastStopReason;
2943
3198
  try {
2944
3199
  while (step < agenticStepBudget) {
2945
- // Honor caller aborts BETWEEN steps: break into terminal handling
2946
- // (one graceful cap chunk, clean close) instead of throwing —
2947
- // channel.error would surface the caller's own abort as a stream
2948
- // failure and route consumers into fallback retries.
2949
- if (options.abortSignal?.aborted) {
3200
+ // Honor aborts BETWEEN steps (caller signal OR the turn clock's
3201
+ // watchdogs all fan into internalAbort): break into terminal
3202
+ // handling (one honest terminal chunk, clean close) instead of
3203
+ // throwing channel.error would surface the caller's own abort as
3204
+ // a stream failure and route consumers into fallback retries.
3205
+ if (internalAbort.signal.aborted) {
2950
3206
  wasAborted = true;
2951
3207
  break;
2952
3208
  }
2953
3209
  step++;
3210
+ turnClock.noteProgress();
2954
3211
  // One generation observation per API call: request in, content + usage out.
2955
3212
  const generationSpan = tracers.generation.startSpan("anthropic.messages.stream", {
2956
3213
  kind: SpanKind.CLIENT,
@@ -3006,6 +3263,7 @@ export class GoogleVertexProvider extends BaseProvider {
3006
3263
  // giving Langfuse the generation's time-to-first-token.
3007
3264
  let firstDeltaSeen = false;
3008
3265
  stream.on("text", (delta) => {
3266
+ turnClock.noteProgress();
3009
3267
  if (delta.length > 0) {
3010
3268
  if (!firstDeltaSeen) {
3011
3269
  firstDeltaSeen = true;
@@ -3032,11 +3290,12 @@ export class GoogleVertexProvider extends BaseProvider {
3032
3290
  generationSpan.recordException(modelCallError);
3033
3291
  }
3034
3292
  generationSpan.end();
3035
- // A mid-flight abort (caller signal or the defensive timer
3036
- // tripping abortHandler) rejects finalMessage() with an
3037
- // abort-shaped error. Break gracefully into the terminal handling
3038
- // instead of routing it through channel.error as a failure.
3039
- if (options.abortSignal?.aborted || isAbortError(modelCallError)) {
3293
+ // A mid-flight abort (caller signal or a turn-clock watchdog
3294
+ // tripping internalAbort/abortHandler) rejects finalMessage()
3295
+ // with an abort-shaped error. Break gracefully into the terminal
3296
+ // handling instead of routing it through channel.error as a
3297
+ // failure.
3298
+ if (internalAbort.signal.aborted || isAbortError(modelCallError)) {
3040
3299
  activeStream = undefined;
3041
3300
  wasAborted = true;
3042
3301
  break;
@@ -3171,12 +3430,16 @@ export class GoogleVertexProvider extends BaseProvider {
3171
3430
  const toolOptions = {
3172
3431
  toolCallId: toolUse.id,
3173
3432
  messages: [],
3174
- abortSignal: options.abortSignal,
3433
+ abortSignal: internalAbort.signal,
3175
3434
  };
3435
+ turnClock.noteProgress();
3176
3436
  // Run with toolSpan active so spans inside execute
3177
3437
  // (neurolink.tool.execute) nest under this observation instead
3178
- // of becoming disconnected siblings.
3179
- const result = await otelContext.with(otelTrace.setSpan(turnContext, toolSpan), () => execute(toolUse.input, toolOptions));
3438
+ // of becoming disconnected siblings. Bound the await — a
3439
+ // 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`);
3442
+ turnClock.noteProgress();
3180
3443
  // MCP failures are returned, not thrown — surface them on
3181
3444
  // the span so failed calls show as ERROR in Langfuse.
3182
3445
  endToolSpan(result, extractMcpToolErrorMessage(result));
@@ -3190,7 +3453,7 @@ export class GoogleVertexProvider extends BaseProvider {
3190
3453
  // so coerce defensively to keep the follow-up turn valid.
3191
3454
  const resultContent = typeof result === "string"
3192
3455
  ? result
3193
- : (JSON.stringify(result ?? null) ?? String(result));
3456
+ : stringifyContentSafe(result ?? null);
3194
3457
  toolResults.push({
3195
3458
  type: "tool_result",
3196
3459
  tool_use_id: toolUse.id,
@@ -3206,7 +3469,7 @@ export class GoogleVertexProvider extends BaseProvider {
3206
3469
  // An aborted tool call is a cancellation, not a tool failure —
3207
3470
  // end the span without recording an error execution/result and
3208
3471
  // break the turn.
3209
- if (options.abortSignal?.aborted || isAbortError(err)) {
3472
+ if (internalAbort.signal.aborted || isAbortError(err)) {
3210
3473
  endToolSpan({ aborted: true });
3211
3474
  // Keep persisted tool history paired: the call row was
3212
3475
  // already pushed above, so record a neutral cancellation
@@ -3220,6 +3483,15 @@ export class GoogleVertexProvider extends BaseProvider {
3220
3483
  wasAborted = true;
3221
3484
  break;
3222
3485
  }
3486
+ turnClock.noteProgress();
3487
+ if (err instanceof TimeoutError) {
3488
+ this.emitTurnEvent({
3489
+ phase: "tool-timeout",
3490
+ step,
3491
+ maxSteps,
3492
+ toolName: toolUse.name,
3493
+ });
3494
+ }
3223
3495
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3224
3496
  const errorPayload = { error: errMsg };
3225
3497
  endToolSpan(errorPayload, errMsg);
@@ -3283,6 +3555,8 @@ export class GoogleVertexProvider extends BaseProvider {
3283
3555
  // to wrap up so the reserved forced-finalization call below stays a
3284
3556
  // fallback, not the norm. Rides as a trailing text block on the
3285
3557
  // tool_result user turn (cache-safe: it lives in the growing tail).
3558
+ // The time-budget twin fires when the turn deadline is inside the
3559
+ // wrap-up lead window instead.
3286
3560
  const stepsRemaining = agenticStepBudget - step;
3287
3561
  if (stepsRemaining > 0 && stepsRemaining <= 3) {
3288
3562
  toolResults.push({
@@ -3293,6 +3567,12 @@ export class GoogleVertexProvider extends BaseProvider {
3293
3567
  : "provide your final answer."),
3294
3568
  });
3295
3569
  }
3570
+ else if (turnClock.shouldNudgeWrapup()) {
3571
+ toolResults.push({
3572
+ type: "text",
3573
+ text: buildWrapupNudgeText(useFinalResultTool),
3574
+ });
3575
+ }
3296
3576
  // Continue the loop: assistant turn + tool_result user turn.
3297
3577
  // Filter server_tool_use blocks (Anthropic API rejects them in
3298
3578
  // subsequent message turns).
@@ -3316,24 +3596,34 @@ export class GoogleVertexProvider extends BaseProvider {
3316
3596
  // Gemini synthesizeFinalAnswerWithoutTools precedent); their tokens
3317
3597
  // still land in `usage` and the turn-span metadata.
3318
3598
  if (!modelFinished) {
3319
- // A caller abort that landed during the FINAL budgeted step's tool
3599
+ // An abort that landed during the FINAL budgeted step's tool
3320
3600
  // execution leaves wasAborted=false (no loop-entry check runs after
3321
3601
  // a budget exit) — re-check before issuing any terminal model call.
3322
- if (options.abortSignal?.aborted) {
3602
+ if (internalAbort.signal.aborted) {
3323
3603
  wasAborted = true;
3324
3604
  }
3325
3605
  const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3326
3606
  if (wasAborted) {
3327
3607
  // Budget already blown — never issue another model call. Deliver
3328
- // exactly one graceful cap chunk if nothing reached the consumer
3329
- // (live deltas already delivered count as "something reached").
3330
- logger.warn("[GoogleVertex] Native Anthropic stream loop aborted mid-turn; returning a graceful cap message.");
3608
+ // exactly one HONEST terminal chunk matching the actual exit
3609
+ // cause (time limit / stall / caller abort — never the step-cap
3610
+ // text) if nothing reached the consumer (live deltas already
3611
+ // delivered count as "something reached").
3612
+ logger.warn(`[GoogleVertex] Native Anthropic stream loop ended mid-turn ` +
3613
+ `(${turnClock.timedOut ? "turn time limit" : turnClock.stalled ? "stall watchdog" : "caller abort"}); ` +
3614
+ `returning an honest terminal message.`);
3331
3615
  if (aggregatedTurnText.length === 0 &&
3332
3616
  liveTextPushedLength === 0 &&
3333
3617
  !structuredOutputRef.value) {
3334
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3335
- channel.push(capMessage);
3336
- aggregatedTurnText = capMessage;
3618
+ const exitMessage = this.buildLoopExitMessage({
3619
+ turnClock,
3620
+ wasAborted,
3621
+ stallTimeoutMs: options.stallTimeoutMs,
3622
+ maxSteps,
3623
+ toolCallCount: externalToolCallCount,
3624
+ });
3625
+ channel.push(exitMessage);
3626
+ aggregatedTurnText = exitMessage;
3337
3627
  }
3338
3628
  }
3339
3629
  else if (useFinalResultTool) {
@@ -3398,17 +3688,26 @@ export class GoogleVertexProvider extends BaseProvider {
3398
3688
  }
3399
3689
  catch (error) {
3400
3690
  activeStream = undefined;
3401
- // An aborted finalization is a cancellation, not a step-cap
3402
- // turn — keep finishReason mapping from lastStopReason.
3403
- if (options.abortSignal?.aborted || isAbortError(error)) {
3691
+ // An aborted finalization is a cancellation (caller abort or a
3692
+ // turn-clock watchdog), not a step-cap turn — keep finishReason
3693
+ // mapping from lastStopReason and pick the exit message by the
3694
+ // actual cause.
3695
+ if (internalAbort.signal.aborted || isAbortError(error)) {
3404
3696
  hitStepLimit = false;
3697
+ wasAborted = true;
3405
3698
  }
3406
- logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", {
3699
+ logger.warn("[GoogleVertex] Forced finalization call failed; falling back to a terminal message", {
3407
3700
  error: error instanceof Error ? error.message : String(error),
3408
3701
  });
3409
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3410
- channel.push(capMessage);
3411
- aggregatedTurnText += capMessage;
3702
+ const exitMessage = this.buildLoopExitMessage({
3703
+ turnClock,
3704
+ wasAborted,
3705
+ stallTimeoutMs: options.stallTimeoutMs,
3706
+ maxSteps,
3707
+ toolCallCount: externalToolCallCount,
3708
+ });
3709
+ channel.push(exitMessage);
3710
+ aggregatedTurnText += exitMessage;
3412
3711
  }
3413
3712
  }
3414
3713
  else {
@@ -3478,17 +3777,26 @@ export class GoogleVertexProvider extends BaseProvider {
3478
3777
  }
3479
3778
  catch (error) {
3480
3779
  activeStream = undefined;
3481
- // An aborted backstop is a cancellation, not a step-cap
3482
- // turn keep finishReason mapping from lastStopReason.
3483
- if (options.abortSignal?.aborted || isAbortError(error)) {
3780
+ // An aborted backstop is a cancellation (caller abort or a
3781
+ // turn-clock watchdog), not a step-cap turn — keep
3782
+ // finishReason mapping from lastStopReason and pick the exit
3783
+ // message by the actual cause.
3784
+ if (internalAbort.signal.aborted || isAbortError(error)) {
3484
3785
  hitStepLimit = false;
3786
+ wasAborted = true;
3485
3787
  }
3486
- logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to cap message", {
3788
+ logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to a terminal message", {
3487
3789
  error: error instanceof Error ? error.message : String(error),
3488
3790
  });
3489
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3490
- channel.push(capMessage);
3491
- aggregatedTurnText = capMessage;
3791
+ const exitMessage = this.buildLoopExitMessage({
3792
+ turnClock,
3793
+ wasAborted,
3794
+ stallTimeoutMs: options.stallTimeoutMs,
3795
+ maxSteps,
3796
+ toolCallCount: externalToolCallCount,
3797
+ });
3798
+ channel.push(exitMessage);
3799
+ aggregatedTurnText = exitMessage;
3492
3800
  }
3493
3801
  }
3494
3802
  // else: prose already streamed to the consumer across steps —
@@ -3504,9 +3812,15 @@ export class GoogleVertexProvider extends BaseProvider {
3504
3812
  !structuredOutputRef.value;
3505
3813
  const externalToolCallTotal = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3506
3814
  if (deliveredNothing && externalToolCallTotal > 0) {
3507
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallTotal);
3508
- channel.push(capMessage);
3509
- aggregatedTurnText = capMessage;
3815
+ const exitMessage = this.buildLoopExitMessage({
3816
+ turnClock,
3817
+ wasAborted,
3818
+ stallTimeoutMs: options.stallTimeoutMs,
3819
+ maxSteps,
3820
+ toolCallCount: externalToolCallTotal,
3821
+ });
3822
+ channel.push(exitMessage);
3823
+ aggregatedTurnText = exitMessage;
3510
3824
  }
3511
3825
  // Honest finish reason (same mapping as the generate twin): "length"
3512
3826
  // takes precedence, a capped turn without a clean/forced answer is
@@ -3522,6 +3836,30 @@ export class GoogleVertexProvider extends BaseProvider {
3522
3836
  metadata.finishReason = finishReasonRef.value;
3523
3837
  metadata.responseTime = Date.now() - startTime;
3524
3838
  metadata.totalToolExecutions = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3839
+ // Turn-exit discriminator + raw provider stop reason + step count —
3840
+ // mirrored onto metadata (mutable reference) for wrapper spreads,
3841
+ // like finishReason above.
3842
+ const resolvedStopReason = resolveTurnStopReason({
3843
+ timedOut: turnClock.timedOut,
3844
+ stalled: turnClock.stalled,
3845
+ wasAborted,
3846
+ cappedWithoutAnswer: hitStepLimit && !synthesizedFinalAnswer,
3847
+ finishReason: finishReasonRef.value,
3848
+ });
3849
+ stopReasonRef.value = resolvedStopReason;
3850
+ rawFinishReasonRef.value = lastStopReason ?? undefined;
3851
+ metadata.stopReason = resolvedStopReason;
3852
+ metadata.rawFinishReason = rawFinishReasonRef.value;
3853
+ metadata.stepsUsed = step;
3854
+ if (resolvedStopReason !== "completed") {
3855
+ this.emitTurnEvent({
3856
+ phase: resolvedStopReason,
3857
+ step,
3858
+ maxSteps,
3859
+ toolCallCount: metadata.totalToolExecutions,
3860
+ elapsedMs: turnClock.elapsedMs(),
3861
+ });
3862
+ }
3525
3863
  // Surface cache metrics once, after the agentic loop, from the
3526
3864
  // cumulative turnCacheUsage running totals (accumulated via += on every
3527
3865
  // step above). Assigned here — not per-iteration — so the final values
@@ -3576,8 +3914,9 @@ export class GoogleVertexProvider extends BaseProvider {
3576
3914
  }
3577
3915
  finally {
3578
3916
  turnSpan.end();
3579
- options.abortSignal?.removeEventListener("abort", abortHandler);
3580
- clearTimeout(streamTimeoutHandle);
3917
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
3918
+ internalAbort.signal.removeEventListener("abort", abortHandler);
3919
+ turnClock.dispose();
3581
3920
  }
3582
3921
  })();
3583
3922
  // Suppress unhandled-rejection: errors funnel through channel.error()
@@ -3622,6 +3961,16 @@ export class GoogleVertexProvider extends BaseProvider {
3622
3961
  configurable: true,
3623
3962
  get: () => finishReasonRef.value,
3624
3963
  });
3964
+ Object.defineProperty(result, "stopReason", {
3965
+ enumerable: true,
3966
+ configurable: true,
3967
+ get: () => stopReasonRef.value,
3968
+ });
3969
+ Object.defineProperty(result, "rawFinishReason", {
3970
+ enumerable: true,
3971
+ configurable: true,
3972
+ get: () => rawFinishReasonRef.value,
3973
+ });
3625
3974
  return result;
3626
3975
  }
3627
3976
  /**
@@ -3651,9 +4000,7 @@ export class GoogleVertexProvider extends BaseProvider {
3651
4000
  if (msg.role === "user" || msg.role === "assistant") {
3652
4001
  messages.push({
3653
4002
  role: msg.role,
3654
- content: typeof msg.content === "string"
3655
- ? msg.content
3656
- : JSON.stringify(msg.content),
4003
+ content: stringifyContentSafe(msg.content),
3657
4004
  });
3658
4005
  }
3659
4006
  }
@@ -3922,15 +4269,48 @@ export class GoogleVertexProvider extends BaseProvider {
3922
4269
  let synthesizedFinalAnswer = false;
3923
4270
  let modelFinished = false;
3924
4271
  const currentMessages = [...messages];
4272
+ // Internal abort fan-in: the caller's signal and the turn clock's
4273
+ // watchdogs all trip it; it rides every SDK request and tool execution.
4274
+ const internalAbort = new AbortController();
4275
+ const onCallerAbort = () => internalAbort.abort();
4276
+ options.abortSignal?.addEventListener("abort", onCallerAbort);
4277
+ if (options.abortSignal?.aborted) {
4278
+ internalAbort.abort();
4279
+ }
4280
+ const toolExecTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS;
4281
+ // Whole-turn deadline + optional stall watchdog. NOTE: unlike the stream
4282
+ // twin, this path historically had NO whole-turn bound (only the per-call
4283
+ // withTimeout) — so no defensive default is introduced here: without an
4284
+ // explicit turnTimeoutMs, long multi-step turns keep running as before.
4285
+ const turnClock = createTurnClock({
4286
+ turnTimeoutMs: options.turnTimeoutMs,
4287
+ stallTimeoutMs: options.stallTimeoutMs,
4288
+ wrapupTimeLeadMs: options.wrapupTimeLeadMs,
4289
+ onDeadline: (kind) => {
4290
+ logger.warn(kind === "timeout"
4291
+ ? `[GoogleVertex] Anthropic generate turn exceeded its ${options.turnTimeoutMs}ms time budget — aborting`
4292
+ : `[GoogleVertex] Anthropic generate turn made no progress for ${options.stallTimeoutMs}ms — aborting`);
4293
+ internalAbort.abort();
4294
+ },
4295
+ });
4296
+ // No top-level try/finally wraps this loop (pre-existing shape); release
4297
+ // watchdog timers + the caller-signal listener at both exits — the
4298
+ // provider-error throw in the per-step catch and the normal return path.
4299
+ const releaseTurnResources = () => {
4300
+ turnClock.dispose();
4301
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
4302
+ };
3925
4303
  while (step < agenticStepBudget) {
3926
- // Honor caller aborts BETWEEN steps: break into terminal handling
3927
- // instead of throwing (a throw routes consumers into abortSignal-less
3928
- // fallback retries observed in production as a 600s abort no-op).
3929
- if (options.abortSignal?.aborted) {
4304
+ // Honor aborts BETWEEN steps (caller signal OR turn-clock watchdogs —
4305
+ // all fan into internalAbort): break into terminal handling instead of
4306
+ // throwing (a throw routes consumers into abortSignal-less fallback
4307
+ // retries — observed in production as a 600s abort no-op).
4308
+ if (internalAbort.signal.aborted) {
3930
4309
  wasAborted = true;
3931
4310
  break;
3932
4311
  }
3933
4312
  step++;
4313
+ turnClock.noteProgress();
3934
4314
  try {
3935
4315
  // Bound the SDK wait so a stalled Vertex/Anthropic call can't hang
3936
4316
  // generate forever. options.timeout wins if set, otherwise default
@@ -3959,7 +4339,7 @@ export class GoogleVertexProvider extends BaseProvider {
3959
4339
  tools: cachedGenerate.tools,
3960
4340
  }),
3961
4341
  messages: cachedGenerate.messages,
3962
- }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic generate timed out");
4342
+ }, { signal: internalAbort.signal }), generateTimeoutMs, "Anthropic generate timed out");
3963
4343
  // Update token counts. input_tokens is the uncached remainder; cache
3964
4344
  // reads/writes are reported separately and accumulated here so the
3965
4345
  // result reflects the full picture.
@@ -4019,9 +4399,13 @@ export class GoogleVertexProvider extends BaseProvider {
4019
4399
  const toolOptions = {
4020
4400
  toolCallId: toolUse.id,
4021
4401
  messages: [],
4022
- abortSignal: options.abortSignal,
4402
+ abortSignal: internalAbort.signal,
4023
4403
  };
4024
- const result = await execute(toolUse.input, toolOptions);
4404
+ turnClock.noteProgress();
4405
+ // 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`);
4408
+ turnClock.noteProgress();
4025
4409
  toolExecutions.push({
4026
4410
  name: toolUse.name,
4027
4411
  input: toolUse.input,
@@ -4032,7 +4416,7 @@ export class GoogleVertexProvider extends BaseProvider {
4032
4416
  // so coerce defensively to keep the follow-up turn valid.
4033
4417
  const resultContent = typeof result === "string"
4034
4418
  ? result
4035
- : (JSON.stringify(result ?? null) ?? String(result));
4419
+ : stringifyContentSafe(result ?? null);
4036
4420
  toolResults.push({
4037
4421
  type: "tool_result",
4038
4422
  tool_use_id: toolUse.id,
@@ -4047,7 +4431,7 @@ export class GoogleVertexProvider extends BaseProvider {
4047
4431
  catch (err) {
4048
4432
  // An aborted tool call is a cancellation, not a tool failure —
4049
4433
  // break the turn without recording an error execution/result.
4050
- if (options.abortSignal?.aborted || isAbortError(err)) {
4434
+ if (internalAbort.signal.aborted || isAbortError(err)) {
4051
4435
  // Keep persisted tool history paired: the call row was
4052
4436
  // already pushed above, so record a neutral cancellation
4053
4437
  // result (NOT an error) — an unpaired tool_use replayed on
@@ -4060,6 +4444,15 @@ export class GoogleVertexProvider extends BaseProvider {
4060
4444
  wasAborted = true;
4061
4445
  break;
4062
4446
  }
4447
+ turnClock.noteProgress();
4448
+ if (err instanceof TimeoutError) {
4449
+ this.emitTurnEvent({
4450
+ phase: "tool-timeout",
4451
+ step,
4452
+ maxSteps,
4453
+ toolName: toolUse.name,
4454
+ });
4455
+ }
4063
4456
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
4064
4457
  const errorPayload = { error: errMsg };
4065
4458
  toolExecutions.push({
@@ -4122,6 +4515,8 @@ export class GoogleVertexProvider extends BaseProvider {
4122
4515
  // wrap up so the reserved forced-finalization call below stays a
4123
4516
  // fallback, not the norm. Rides as a trailing text block on the
4124
4517
  // tool_result user turn (cache-safe: it lives in the growing tail).
4518
+ // The time-budget twin fires when the turn deadline is inside the
4519
+ // wrap-up lead window instead.
4125
4520
  const stepsRemaining = agenticStepBudget - step;
4126
4521
  if (stepsRemaining > 0 && stepsRemaining <= 3) {
4127
4522
  toolResults.push({
@@ -4132,6 +4527,12 @@ export class GoogleVertexProvider extends BaseProvider {
4132
4527
  : "provide your final answer."),
4133
4528
  });
4134
4529
  }
4530
+ else if (turnClock.shouldNudgeWrapup()) {
4531
+ toolResults.push({
4532
+ type: "text",
4533
+ text: buildWrapupNudgeText(useFinalResultTool),
4534
+ });
4535
+ }
4135
4536
  // Add assistant message and tool results to continue the loop
4136
4537
  // Filter out server_tool_use blocks that the Anthropic API doesn't accept in messages
4137
4538
  const assistantContent = response.content.filter((block) => block.type !== "server_tool_use");
@@ -4149,16 +4550,33 @@ export class GoogleVertexProvider extends BaseProvider {
4149
4550
  }
4150
4551
  catch (error) {
4151
4552
  // A mid-request abort surfaces as an abort-shaped SDK rejection (we
4152
- // pass options.abortSignal as the request signal above). Break
4553
+ // pass internalAbort.signal as the request signal above, and the
4554
+ // caller's signal + turn-clock watchdogs all fan into it). Break
4153
4555
  // gracefully into the terminal handling instead of re-throwing — a
4154
4556
  // re-throw routes the caller's abort into abortSignal-less fallback
4155
- // retries. Dual check as in the Gemini loops: the caller's signal OR
4557
+ // retries. Dual check as in the Gemini loops: the internal signal OR
4156
4558
  // an abort-shaped error either way means "stop", not a real failure.
4157
- if (options.abortSignal?.aborted || isAbortError(error)) {
4559
+ if (internalAbort.signal.aborted || isAbortError(error)) {
4158
4560
  wasAborted = true;
4159
4561
  break;
4160
4562
  }
4161
- logger.error("[GoogleVertex] Native Anthropic SDK generate error", error);
4563
+ logger.error("[GoogleVertex] Native Anthropic SDK generate error", {
4564
+ error,
4565
+ model: modelName,
4566
+ step,
4567
+ status: error?.status,
4568
+ });
4569
+ // Best-effort request context for formatProviderError — see the
4570
+ // native Gemini catch for rationale.
4571
+ try {
4572
+ if (error && typeof error === "object") {
4573
+ error.requestModel = modelName;
4574
+ }
4575
+ }
4576
+ catch {
4577
+ /* frozen/sealed error — context stays best-effort */
4578
+ }
4579
+ releaseTurnResources();
4162
4580
  throw this.handleProviderError(error);
4163
4581
  }
4164
4582
  }
@@ -4168,22 +4586,32 @@ export class GoogleVertexProvider extends BaseProvider {
4168
4586
  // synthesize a plain-text answer on tool turns, or fall back to a
4169
4587
  // graceful cap message. Mirrors the Gemini paths (ed289b7 / PR #1123).
4170
4588
  if (!modelFinished && !finalText) {
4171
- // A caller abort that landed during the FINAL budgeted step's tool
4589
+ // An abort that landed during the FINAL budgeted step's tool
4172
4590
  // execution leaves wasAborted=false (no loop-entry check runs after a
4173
4591
  // budget exit) — re-check before issuing any terminal model call.
4174
- if (options.abortSignal?.aborted) {
4592
+ if (internalAbort.signal.aborted) {
4175
4593
  wasAborted = true;
4176
4594
  }
4177
4595
  const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
4178
4596
  if (wasAborted) {
4179
4597
  // Budget already blown — never issue another model call; prefer the
4180
4598
  // prose the model already produced (mirrors the Gemini abort path),
4181
- // else answer with a graceful cap message. finishReason keeps mapping
4182
- // from lastStopReason (an aborted turn is not a step-cap turn).
4183
- logger.warn("[GoogleVertex] Native Anthropic generate loop aborted mid-turn; returning gathered text or a graceful cap message.");
4599
+ // else answer with an HONEST message matching the actual exit cause
4600
+ // (time limit / stall / caller abort never the step-cap text).
4601
+ // finishReason keeps mapping from lastStopReason (an aborted turn is
4602
+ // not a step-cap turn).
4603
+ logger.warn(`[GoogleVertex] Native Anthropic generate loop ended mid-turn ` +
4604
+ `(${turnClock.timedOut ? "turn time limit" : turnClock.stalled ? "stall watchdog" : "caller abort"}); ` +
4605
+ `returning gathered text or an honest terminal message.`);
4184
4606
  finalText =
4185
4607
  accumulatedStepText ||
4186
- buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4608
+ this.buildLoopExitMessage({
4609
+ turnClock,
4610
+ wasAborted,
4611
+ stallTimeoutMs: options.stallTimeoutMs,
4612
+ maxSteps,
4613
+ toolCallCount: externalToolCallCount,
4614
+ });
4187
4615
  }
4188
4616
  else if (useFinalResultTool) {
4189
4617
  hitStepLimit = true;
@@ -4213,7 +4641,7 @@ export class GoogleVertexProvider extends BaseProvider {
4213
4641
  tools: cachedFinal.tools,
4214
4642
  }),
4215
4643
  messages: cachedFinal.messages,
4216
- }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic finalization call timed out");
4644
+ }, { signal: internalAbort.signal }), generateTimeoutMs, "Anthropic finalization call timed out");
4217
4645
  totalInputTokens += response.usage?.input_tokens || 0;
4218
4646
  totalOutputTokens += response.usage?.output_tokens || 0;
4219
4647
  totalCacheReadTokens += response.usage?.cache_read_input_tokens || 0;
@@ -4232,13 +4660,22 @@ export class GoogleVertexProvider extends BaseProvider {
4232
4660
  }
4233
4661
  }
4234
4662
  catch (error) {
4235
- // An aborted finalization is a cancellation, not a step-cap turn —
4236
- // keep finishReason mapping from lastStopReason, not "tool-calls".
4237
- if (options.abortSignal?.aborted || isAbortError(error)) {
4663
+ // An aborted finalization is a cancellation (caller abort or a
4664
+ // turn-clock watchdog), not a step-cap turn — keep finishReason
4665
+ // mapping from lastStopReason and pick the exit message by the
4666
+ // actual cause.
4667
+ if (internalAbort.signal.aborted || isAbortError(error)) {
4238
4668
  hitStepLimit = false;
4669
+ wasAborted = true;
4239
4670
  }
4240
- logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", { error: error instanceof Error ? error.message : String(error) });
4241
- finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4671
+ 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
+ });
4242
4679
  }
4243
4680
  }
4244
4681
  else {
@@ -4276,7 +4713,7 @@ export class GoogleVertexProvider extends BaseProvider {
4276
4713
  tools: cachedBackstop.tools,
4277
4714
  }),
4278
4715
  messages: cachedBackstop.messages,
4279
- }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic backstop call timed out");
4716
+ }, { signal: internalAbort.signal }), generateTimeoutMs, "Anthropic backstop call timed out");
4280
4717
  totalInputTokens += response.usage?.input_tokens || 0;
4281
4718
  totalOutputTokens += response.usage?.output_tokens || 0;
4282
4719
  totalCacheReadTokens +=
@@ -4297,39 +4734,79 @@ export class GoogleVertexProvider extends BaseProvider {
4297
4734
  }
4298
4735
  }
4299
4736
  catch (error) {
4300
- // An aborted backstop is a cancellation, not a step-cap turn —
4301
- // keep finishReason mapping from lastStopReason, not "tool-calls".
4302
- if (options.abortSignal?.aborted || isAbortError(error)) {
4737
+ // An aborted backstop is a cancellation (caller abort or a
4738
+ // turn-clock watchdog), not a step-cap turn — keep finishReason
4739
+ // mapping from lastStopReason and pick the exit message by the
4740
+ // actual cause.
4741
+ if (internalAbort.signal.aborted || isAbortError(error)) {
4303
4742
  hitStepLimit = false;
4743
+ wasAborted = true;
4304
4744
  }
4305
- logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to cap message", {
4745
+ logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to a terminal message", {
4306
4746
  error: error instanceof Error ? error.message : String(error),
4307
4747
  });
4308
- finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4748
+ finalText = this.buildLoopExitMessage({
4749
+ turnClock,
4750
+ wasAborted,
4751
+ stallTimeoutMs: options.stallTimeoutMs,
4752
+ maxSteps,
4753
+ toolCallCount: externalToolCallCount,
4754
+ });
4309
4755
  }
4310
4756
  }
4311
4757
  }
4312
4758
  }
4759
+ releaseTurnResources();
4313
4760
  const responseTime = Date.now() - startTime;
4314
4761
  const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
4315
4762
  const externalToolExecutions = toolExecutions.filter((te) => te.name !== "final_result");
4316
4763
  // Absolute backstop — no code path may surface empty content on a turn
4317
4764
  // that ran tools (the consumer-facing "empty response" incident shape).
4765
+ // Cause-aware: an aborted/timed-out turn gets the honest exit message,
4766
+ // a genuine budget exhaustion gets the step-cap message.
4318
4767
  if (!finalText && externalToolCalls.length > 0) {
4319
- finalText = buildToolLoopCapMessage(maxSteps, externalToolCalls.length);
4768
+ finalText = this.buildLoopExitMessage({
4769
+ turnClock,
4770
+ wasAborted,
4771
+ stallTimeoutMs: options.stallTimeoutMs,
4772
+ maxSteps,
4773
+ toolCallCount: externalToolCalls.length,
4774
+ });
4775
+ }
4776
+ // Honest finish reason. "length" (token truncation) takes precedence; a
4777
+ // step-cap exhaustion without a clean/forced answer surfaces as
4778
+ // "tool-calls" (aligned with the Gemini paths, so consumers detect
4779
+ // capped turns uniformly); everything else — including a successful
4780
+ // forced finalization or backstop synthesis — is a normal "stop".
4781
+ const resolvedFinishReason = lastStopReason === "max_tokens"
4782
+ ? "length"
4783
+ : hitStepLimit && !synthesizedFinalAnswer
4784
+ ? "tool-calls"
4785
+ : "stop";
4786
+ // Turn-exit discriminator, independent of the provider-shaped
4787
+ // finishReason — consumers branch on this instead of sniffing strings.
4788
+ const stopReason = resolveTurnStopReason({
4789
+ timedOut: turnClock.timedOut,
4790
+ stalled: turnClock.stalled,
4791
+ wasAborted,
4792
+ cappedWithoutAnswer: hitStepLimit && !synthesizedFinalAnswer,
4793
+ finishReason: resolvedFinishReason,
4794
+ });
4795
+ if (stopReason !== "completed") {
4796
+ this.emitTurnEvent({
4797
+ phase: stopReason,
4798
+ step,
4799
+ maxSteps,
4800
+ toolCallCount: externalToolCalls.length,
4801
+ elapsedMs: turnClock.elapsedMs(),
4802
+ });
4320
4803
  }
4321
4804
  const result = {
4322
4805
  content: finalText,
4323
- // Honest finish reason. "length" (token truncation) takes precedence; a
4324
- // step-cap exhaustion without a clean/forced answer surfaces as
4325
- // "tool-calls" (aligned with the Gemini paths, so consumers detect
4326
- // capped turns uniformly); everything else — including a successful
4327
- // forced finalization or backstop synthesis — is a normal "stop".
4328
- finishReason: lastStopReason === "max_tokens"
4329
- ? "length"
4330
- : hitStepLimit && !synthesizedFinalAnswer
4331
- ? "tool-calls"
4332
- : "stop",
4806
+ finishReason: resolvedFinishReason,
4807
+ stopReason,
4808
+ rawFinishReason: lastStopReason ?? undefined,
4809
+ stepsUsed: step,
4333
4810
  provider: this.providerName,
4334
4811
  model: modelName,
4335
4812
  usage: {
@@ -4622,8 +5099,49 @@ export class GoogleVertexProvider extends BaseProvider {
4622
5099
  model: modelName,
4623
5100
  totalToolCount: Object.keys(mergedOptions.tools).length,
4624
5101
  });
4625
- nativeResult =
4626
- await this.executeNativeGemini3Generate(mergedOptions);
5102
+ try {
5103
+ nativeResult =
5104
+ await this.executeNativeGemini3Generate(mergedOptions);
5105
+ }
5106
+ catch (nativeError) {
5107
+ // Vertex rejects over-constrained responseSchemas with a
5108
+ // deterministic 400 ("too many states" — constrained-decoding
5109
+ // state explosion). Re-sending the same schema can never
5110
+ // succeed, so retry ONCE with the schema dropped but JSON
5111
+ // mode kept: output.format "json" still sets
5112
+ // responseMimeType application/json on the no-tools path,
5113
+ // so the model is forced to emit JSON — just without the
5114
+ // offending schema constraints. The NeuroLink layer then
5115
+ // coerces the JSON text into the caller's original schema
5116
+ // (generate({schema}) guarantee holds).
5117
+ const requestedStructured = mergedOptions.schema !== undefined ||
5118
+ mergedOptions.output?.format === "json";
5119
+ // Tools present → the executor skips responseMimeType, so a
5120
+ // schema-less retry would NOT actually run in JSON mode and
5121
+ // the structured-output contract would silently degrade to
5122
+ // prose. Only the no-tools case retries faithfully; with
5123
+ // tools, surface the 400 to the caller instead.
5124
+ const hasTools = !mergedOptions.disableTools &&
5125
+ Object.keys(mergedOptions.tools ?? {}).length > 0;
5126
+ if (requestedStructured &&
5127
+ !hasTools &&
5128
+ isSchemaComplexityError(nativeError)) {
5129
+ logger.warn("[GoogleVertex] responseSchema too complex for constrained decoding — retrying native generate in schema-less JSON mode", {
5130
+ model: modelName,
5131
+ error: nativeError instanceof Error
5132
+ ? nativeError.message
5133
+ : String(nativeError),
5134
+ });
5135
+ nativeResult = await this.executeNativeGemini3Generate({
5136
+ ...mergedOptions,
5137
+ schema: undefined,
5138
+ output: { format: "json" },
5139
+ });
5140
+ }
5141
+ else {
5142
+ throw nativeError;
5143
+ }
5144
+ }
4627
5145
  }
4628
5146
  executionSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(nativeResult?.content ?? ""));
4629
5147
  return nativeResult;
@@ -4921,6 +5439,43 @@ export class GoogleVertexProvider extends BaseProvider {
4921
5439
  true;
4922
5440
  }
4923
5441
  }
5442
+ /**
5443
+ * Emit a `turn:lifecycle` event on the SDK emitter (alongside
5444
+ * tool:start/tool:end) so loop conditions that previously only reached
5445
+ * process logs — step-cap, time-limit, stall, abort, tool timeouts,
5446
+ * malformed-call retries — are observable by consumers' own pipelines.
5447
+ */
5448
+ emitTurnEvent(payload) {
5449
+ try {
5450
+ this.neurolink?.getEventEmitter()?.emit("turn:lifecycle", {
5451
+ provider: this.providerName,
5452
+ timestamp: Date.now(),
5453
+ ...payload,
5454
+ });
5455
+ }
5456
+ catch {
5457
+ /* listener errors are non-fatal */
5458
+ }
5459
+ }
5460
+ /**
5461
+ * Pick the honest terminal message for a native-loop exit by its ACTUAL
5462
+ * cause. The step-cap text is the fallback for genuine budget exhaustion
5463
+ * only — time/stall/abort exits must never claim a step limit was reached
5464
+ * (the 2026-07-03 "reached the 200-step limit" incident was a wall-clock
5465
+ * abort wearing the cap message).
5466
+ */
5467
+ buildLoopExitMessage(params) {
5468
+ if (params.turnClock.timedOut) {
5469
+ return buildTurnTimeoutMessage(params.turnClock.elapsedMs(), params.toolCallCount);
5470
+ }
5471
+ if (params.turnClock.stalled) {
5472
+ return buildTurnStalledMessage(params.stallTimeoutMs ?? 0, params.toolCallCount);
5473
+ }
5474
+ if (params.wasAborted) {
5475
+ return buildAbortedTurnMessage(params.toolCallCount);
5476
+ }
5477
+ return buildToolLoopCapMessage(params.maxSteps, params.toolCallCount);
5478
+ }
4924
5479
  formatProviderError(error) {
4925
5480
  const errorRecord = error;
4926
5481
  if (typeof errorRecord?.name === "string" &&
@@ -4960,17 +5515,42 @@ export class GoogleVertexProvider extends BaseProvider {
4960
5515
  `3. Ensure your project has access to the model ` +
4961
5516
  `4. For Claude models, enable Anthropic integration in Google Cloud Console`, this.providerName);
4962
5517
  }
4963
- // Rate limit and quota errors
5518
+ // Rate limit / quota / capacity errors. Anthropic-on-Vertex capacity
5519
+ // exhaustion surfaces as overloaded_error (HTTP 529) — same operational
5520
+ // meaning as a 429, so classify it here instead of the generic 5xx branch.
4964
5521
  if (message.includes("QUOTA_EXCEEDED") ||
4965
5522
  message.includes("RATE_LIMIT_EXCEEDED") ||
4966
5523
  message.includes("rate limit") ||
4967
5524
  message.includes("429") ||
4968
- statusCode === 429) {
4969
- return new RateLimitError(`Google Vertex AI quota/rate limit exceeded. ` +
4970
- `Solutions: 1. Check your Vertex AI quotas in Google Cloud Console ` +
4971
- `2. Request quota increase if needed ` +
4972
- `3. Try a different model or reduce request frequency ` +
4973
- `4. Consider using a different region`, this.providerName);
5525
+ statusCode === 429 ||
5526
+ statusCode === 529 ||
5527
+ /overloaded/i.test(message)) {
5528
+ // Surface retry guidance when the SDK error carries it. @google/genai
5529
+ // ApiError nests RetryInfo inside the JSON error body's details array,
5530
+ // so fall back to scraping retryDelay out of the raw message.
5531
+ const retryDelay = typeof errorRecord?.retryDelay === "string"
5532
+ ? errorRecord.retryDelay
5533
+ : (/["']?retryDelay["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?s)/.exec(message)?.[1] ?? undefined);
5534
+ // Prefer the per-request context the native catches attach to the
5535
+ // error (this.modelName can be stale when options.model overrides the
5536
+ // instance default). Gemini models are force-routed to the "global"
5537
+ // endpoint regardless of configured location — report the region the
5538
+ // request actually hit.
5539
+ const requestModel = typeof errorRecord?.requestModel === "string"
5540
+ ? errorRecord.requestModel
5541
+ : this.modelName;
5542
+ const effectiveRegion = typeof errorRecord?.requestRegion === "string"
5543
+ ? errorRecord.requestRegion
5544
+ : resolveVertexRegionForModel(requestModel, this.location);
5545
+ return new RateLimitError(`Google Vertex AI rate limit / shared-capacity exhausted (429 RESOURCE_EXHAUSTED / overloaded) ` +
5546
+ `for model '${requestModel}' in region '${effectiveRegion}'.` +
5547
+ (retryDelay
5548
+ ? ` Upstream suggests retrying after ${retryDelay}.`
5549
+ : "") +
5550
+ ` Solutions: 1. Retry with backoff ` +
5551
+ `2. Check your Vertex AI quotas in Google Cloud Console (shared-capacity 429s can occur below quota) ` +
5552
+ `3. Try a different region or model ` +
5553
+ `4. Request provisioned throughput for sustained load`, this.providerName);
4974
5554
  }
4975
5555
  // Network connectivity errors
4976
5556
  if (message.includes("ECONNRESET") ||