@juspay/neurolink 9.80.4 → 9.81.1

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.
@@ -5,7 +5,7 @@ import path from "path";
5
5
  import os from "os";
6
6
  import { AIProviderName, 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
10
  import { isSchemaComplexityError } from "../core/modules/structuredOutputPolicy.js";
11
11
  import { stringifyContentSafe } from "../utils/logSanitize.js";
@@ -24,7 +24,7 @@ import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } fr
24
24
  import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
25
25
  import { TimeoutError, withTimeout } from "../utils/async/index.js";
26
26
  import { parseTimeout } from "../utils/timeout.js";
27
- 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";
28
28
  import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../telemetry/index.js";
29
29
  import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
30
30
  import { calculateCost } from "../utils/pricing.js";
@@ -1328,21 +1328,37 @@ export class GoogleVertexProvider extends BaseProvider {
1328
1328
  const incrementalTextChunks = [];
1329
1329
  // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
1330
1330
  // Gemini SDK cancels via config.abortSignal, so drive an internal
1331
- // AbortController: the caller's signal and a defensive wall-clock timer
1332
- // 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.
1333
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;
1334
1337
  const internalAbort = new AbortController();
1335
1338
  const onCallerAbort = () => internalAbort.abort();
1336
1339
  options.abortSignal?.addEventListener("abort", onCallerAbort);
1337
- const defensiveTimer = setTimeout(() => {
1338
- logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
1339
- internalAbort.abort();
1340
- }, 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
+ });
1341
1354
  const effectiveSignal = internalAbort.signal;
1342
1355
  if (options.abortSignal?.aborted) {
1343
1356
  internalAbort.abort();
1344
1357
  }
1345
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;
1346
1362
  // Step-cap flags declared in the outer scope so the terminal block (also
1347
1363
  // inside the try) and the finishReason mapping (after the finally) can
1348
1364
  // both read them.
@@ -1356,6 +1372,7 @@ export class GoogleVertexProvider extends BaseProvider {
1356
1372
  break;
1357
1373
  }
1358
1374
  step++;
1375
+ turnClock.noteProgress();
1359
1376
  logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
1360
1377
  try {
1361
1378
  const stream = await client.models.generateContentStream({
@@ -1366,7 +1383,12 @@ export class GoogleVertexProvider extends BaseProvider {
1366
1383
  const stepFunctionCalls = [];
1367
1384
  // Capture raw response parts including thoughtSignature
1368
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;
1369
1390
  for await (const chunk of stream) {
1391
+ turnClock.noteProgress();
1370
1392
  // Extract raw parts from candidates FIRST
1371
1393
  // This avoids using chunk.text which triggers SDK warning when
1372
1394
  // non-text parts (thoughtSignature, functionCall) are present
@@ -1378,6 +1400,7 @@ export class GoogleVertexProvider extends BaseProvider {
1378
1400
  const chunkFinishReason = firstCandidate?.finishReason;
1379
1401
  if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1380
1402
  lastFinishReason = chunkFinishReason;
1403
+ stepFinishReason = chunkFinishReason;
1381
1404
  }
1382
1405
  const chunkContent = firstCandidate?.content;
1383
1406
  if (chunkContent && Array.isArray(chunkContent.parts)) {
@@ -1413,6 +1436,37 @@ export class GoogleVertexProvider extends BaseProvider {
1413
1436
  .filter((part) => typeof part.text === "string")
1414
1437
  .map((part) => part.text)
1415
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
+ }
1416
1470
  // If no function calls, we're done
1417
1471
  if (stepFunctionCalls.length === 0) {
1418
1472
  finalText = stepText;
@@ -1489,7 +1543,11 @@ export class GoogleVertexProvider extends BaseProvider {
1489
1543
  messages: [],
1490
1544
  abortSignal: effectiveSignal,
1491
1545
  };
1492
- 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();
1493
1551
  toolExecutions.push({
1494
1552
  name: call.name,
1495
1553
  input: call.args,
@@ -1514,6 +1572,15 @@ export class GoogleVertexProvider extends BaseProvider {
1514
1572
  wasAborted = true;
1515
1573
  break;
1516
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
+ }
1517
1584
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
1518
1585
  // Track this failure
1519
1586
  const currentFailInfo = failedTools.get(call.name) || {
@@ -1603,6 +1670,15 @@ export class GoogleVertexProvider extends BaseProvider {
1603
1670
  });
1604
1671
  });
1605
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
+ }
1606
1682
  // The @google/genai SDK only accepts "user" and "model" as valid
1607
1683
  // roles in contents — function/tool responses must use role: "user"
1608
1684
  // (matching the SDK's automaticFunctionCalling implementation and
@@ -1647,11 +1723,21 @@ export class GoogleVertexProvider extends BaseProvider {
1647
1723
  `returning text already gathered from prior steps.`);
1648
1724
  }
1649
1725
  else if (wasAborted) {
1650
- // Aborted turn skip synth entirely so it can never add +300s after
1651
- // a blown budget. Deliver exactly one graceful cap chunk.
1652
- logger.warn(`[GoogleVertex] Tool call loop aborted mid-turn; ` +
1653
- `returning a graceful cap message.`);
1654
- 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
+ });
1655
1741
  }
1656
1742
  else {
1657
1743
  logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
@@ -1677,7 +1763,7 @@ export class GoogleVertexProvider extends BaseProvider {
1677
1763
  }
1678
1764
  }
1679
1765
  finally {
1680
- clearTimeout(defensiveTimer);
1766
+ turnClock.dispose();
1681
1767
  options.abortSignal?.removeEventListener("abort", onCallerAbort);
1682
1768
  }
1683
1769
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
@@ -1688,6 +1774,24 @@ export class GoogleVertexProvider extends BaseProvider {
1688
1774
  const resolvedFinishReason = hitStepLimit && !synthesizedFinalAnswer
1689
1775
  ? "tool-calls"
1690
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
+ }
1691
1795
  const responseTime = Date.now() - startTime;
1692
1796
  // Yield each text part separately so the CLI receives multiple stream
1693
1797
  // chunks instead of a single coalesced buffer. The SDK already gave us
@@ -1715,6 +1819,8 @@ export class GoogleVertexProvider extends BaseProvider {
1715
1819
  provider: this.providerName,
1716
1820
  model: modelName,
1717
1821
  finishReason: resolvedFinishReason,
1822
+ stopReason,
1823
+ rawFinishReason: lastFinishReason,
1718
1824
  usage: {
1719
1825
  input: totalInputTokens,
1720
1826
  output: totalOutputTokens,
@@ -1735,6 +1841,9 @@ export class GoogleVertexProvider extends BaseProvider {
1735
1841
  startTime,
1736
1842
  responseTime,
1737
1843
  totalToolExecutions: externalToolCalls.length,
1844
+ stopReason,
1845
+ rawFinishReason: lastFinishReason,
1846
+ stepsUsed: step,
1738
1847
  },
1739
1848
  };
1740
1849
  // Add structured output if final_result tool was used
@@ -2065,21 +2174,37 @@ export class GoogleVertexProvider extends BaseProvider {
2065
2174
  let totalOutputTokens = 0;
2066
2175
  // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
2067
2176
  // Gemini SDK cancels via config.abortSignal, so drive an internal
2068
- // AbortController: the caller's signal and a defensive wall-clock timer
2069
- // 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.
2070
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;
2071
2183
  const internalAbort = new AbortController();
2072
2184
  const onCallerAbort = () => internalAbort.abort();
2073
2185
  options.abortSignal?.addEventListener("abort", onCallerAbort);
2074
- const defensiveTimer = setTimeout(() => {
2075
- logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
2076
- internalAbort.abort();
2077
- }, 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
+ });
2078
2200
  const effectiveSignal = internalAbort.signal;
2079
2201
  if (options.abortSignal?.aborted) {
2080
2202
  internalAbort.abort();
2081
2203
  }
2082
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;
2083
2208
  // Step-cap flags declared in the outer scope so the terminal block (also
2084
2209
  // inside the try) and the finishReason mapping (after the finally) can
2085
2210
  // both read them.
@@ -2093,6 +2218,7 @@ export class GoogleVertexProvider extends BaseProvider {
2093
2218
  break;
2094
2219
  }
2095
2220
  step++;
2221
+ turnClock.noteProgress();
2096
2222
  logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
2097
2223
  try {
2098
2224
  // Use generateContentStream and collect all chunks (same as GoogleAIStudio)
@@ -2104,8 +2230,13 @@ export class GoogleVertexProvider extends BaseProvider {
2104
2230
  const stepFunctionCalls = [];
2105
2231
  // Capture raw response parts including thoughtSignature
2106
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;
2107
2237
  // Collect all chunks from stream
2108
2238
  for await (const chunk of stream) {
2239
+ turnClock.noteProgress();
2109
2240
  // Extract raw parts from candidates FIRST
2110
2241
  // This avoids using chunk.text which triggers SDK warning when
2111
2242
  // non-text parts (thoughtSignature, functionCall) are present
@@ -2117,6 +2248,7 @@ export class GoogleVertexProvider extends BaseProvider {
2117
2248
  const chunkFinishReason = firstCandidate?.finishReason;
2118
2249
  if (typeof chunkFinishReason === "string" && chunkFinishReason) {
2119
2250
  lastFinishReason = chunkFinishReason;
2251
+ stepFinishReason = chunkFinishReason;
2120
2252
  }
2121
2253
  const chunkContent = firstCandidate?.content;
2122
2254
  if (chunkContent && Array.isArray(chunkContent.parts)) {
@@ -2147,6 +2279,37 @@ export class GoogleVertexProvider extends BaseProvider {
2147
2279
  .filter((part) => typeof part.text === "string")
2148
2280
  .map((part) => part.text)
2149
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
+ }
2150
2313
  // If no function calls, we're done
2151
2314
  if (stepFunctionCalls.length === 0) {
2152
2315
  finalText = stepText;
@@ -2222,7 +2385,11 @@ export class GoogleVertexProvider extends BaseProvider {
2222
2385
  messages: [],
2223
2386
  abortSignal: effectiveSignal,
2224
2387
  };
2225
- 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();
2226
2393
  // Track execution
2227
2394
  toolExecutions.push({
2228
2395
  name: call.name,
@@ -2247,6 +2414,15 @@ export class GoogleVertexProvider extends BaseProvider {
2247
2414
  wasAborted = true;
2248
2415
  break;
2249
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
+ }
2250
2426
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
2251
2427
  // Track this failure
2252
2428
  const currentFailInfo = failedTools.get(call.name) || {
@@ -2333,6 +2509,15 @@ export class GoogleVertexProvider extends BaseProvider {
2333
2509
  });
2334
2510
  });
2335
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
+ }
2336
2521
  // The @google/genai SDK only accepts "user" and "model" as valid
2337
2522
  // roles in contents — function/tool responses must use role: "user"
2338
2523
  // (matching the SDK's automaticFunctionCalling implementation and
@@ -2389,11 +2574,20 @@ export class GoogleVertexProvider extends BaseProvider {
2389
2574
  finalText = accumulatedText;
2390
2575
  }
2391
2576
  else if (wasAborted) {
2392
- // Aborted turn skip synth entirely so it can never add +300s after
2393
- // a blown budget. Deliver a graceful cap message as ordinary prose.
2394
- logger.warn(`[GoogleVertex] Generate tool call loop aborted mid-turn; ` +
2395
- `returning a graceful cap message.`);
2396
- 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
+ });
2397
2591
  }
2398
2592
  else {
2399
2593
  // Pure functionCall turns leave no text — make one tools-disabled call
@@ -2421,7 +2615,7 @@ export class GoogleVertexProvider extends BaseProvider {
2421
2615
  }
2422
2616
  }
2423
2617
  finally {
2424
- clearTimeout(defensiveTimer);
2618
+ turnClock.dispose();
2425
2619
  options.abortSignal?.removeEventListener("abort", onCallerAbort);
2426
2620
  }
2427
2621
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
@@ -2432,6 +2626,24 @@ export class GoogleVertexProvider extends BaseProvider {
2432
2626
  const resolvedFinishReason = hitStepLimit && !synthesizedFinalAnswer
2433
2627
  ? "tool-calls"
2434
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
+ }
2435
2647
  const responseTime = Date.now() - startTime;
2436
2648
  // Filter out final_result from tool calls and executions as it's an internal pattern
2437
2649
  const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
@@ -2442,6 +2654,9 @@ export class GoogleVertexProvider extends BaseProvider {
2442
2654
  provider: this.providerName,
2443
2655
  model: modelName,
2444
2656
  finishReason: resolvedFinishReason,
2657
+ stopReason,
2658
+ rawFinishReason: lastFinishReason,
2659
+ stepsUsed: step,
2445
2660
  usage: {
2446
2661
  input: totalInputTokens,
2447
2662
  output: totalOutputTokens,
@@ -2879,6 +3094,8 @@ export class GoogleVertexProvider extends BaseProvider {
2879
3094
  // via a getter (consumers read it after draining the stream), mirroring
2880
3095
  // the Gemini stream path's finishReason field.
2881
3096
  const finishReasonRef = {};
3097
+ const stopReasonRef = {};
3098
+ const rawFinishReasonRef = {};
2882
3099
  // Langfuse/OTel: the native SDK bypasses the Vercel AI SDK's
2883
3100
  // experimental_telemetry, so emit spans manually — one turn span, one
2884
3101
  // generation span per API call, one tool span per execution — all carrying
@@ -2927,8 +3144,8 @@ export class GoogleVertexProvider extends BaseProvider {
2927
3144
  creation5m: 0,
2928
3145
  creation1h: 0,
2929
3146
  };
2930
- // Track the active Anthropic stream so options.abortSignal can cancel it
2931
- // 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).
2932
3149
  let activeStream;
2933
3150
  const abortHandler = () => {
2934
3151
  try {
@@ -2938,15 +3155,35 @@ export class GoogleVertexProvider extends BaseProvider {
2938
3155
  /* ignore — stream may already be finalized */
2939
3156
  }
2940
3157
  };
2941
- options.abortSignal?.addEventListener("abort", abortHandler);
2942
- // Defensive upper bound: if neither the caller nor the SDK ever fires,
2943
- // abort the stream after the configured timeout so a stalled
2944
- // Vertex/Anthropic endpoint can't hang forever. options.timeout wins
2945
- // if set; otherwise 5 min — generous for tool-heavy turns.
2946
- const streamTimeoutHandle = setTimeout(() => {
2947
- logger.warn(`[GoogleVertex] Anthropic stream exceeded ${streamTimeoutMs}ms — aborting`);
2948
- abortHandler();
2949
- }, 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
+ });
2950
3187
  const loopPromise = (async () => {
2951
3188
  let step = 0;
2952
3189
  const currentMessages = [...messages];
@@ -2960,15 +3197,17 @@ export class GoogleVertexProvider extends BaseProvider {
2960
3197
  let lastStopReason;
2961
3198
  try {
2962
3199
  while (step < agenticStepBudget) {
2963
- // Honor caller aborts BETWEEN steps: break into terminal handling
2964
- // (one graceful cap chunk, clean close) instead of throwing —
2965
- // channel.error would surface the caller's own abort as a stream
2966
- // failure and route consumers into fallback retries.
2967
- 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) {
2968
3206
  wasAborted = true;
2969
3207
  break;
2970
3208
  }
2971
3209
  step++;
3210
+ turnClock.noteProgress();
2972
3211
  // One generation observation per API call: request in, content + usage out.
2973
3212
  const generationSpan = tracers.generation.startSpan("anthropic.messages.stream", {
2974
3213
  kind: SpanKind.CLIENT,
@@ -3024,6 +3263,7 @@ export class GoogleVertexProvider extends BaseProvider {
3024
3263
  // giving Langfuse the generation's time-to-first-token.
3025
3264
  let firstDeltaSeen = false;
3026
3265
  stream.on("text", (delta) => {
3266
+ turnClock.noteProgress();
3027
3267
  if (delta.length > 0) {
3028
3268
  if (!firstDeltaSeen) {
3029
3269
  firstDeltaSeen = true;
@@ -3050,11 +3290,12 @@ export class GoogleVertexProvider extends BaseProvider {
3050
3290
  generationSpan.recordException(modelCallError);
3051
3291
  }
3052
3292
  generationSpan.end();
3053
- // A mid-flight abort (caller signal or the defensive timer
3054
- // tripping abortHandler) rejects finalMessage() with an
3055
- // abort-shaped error. Break gracefully into the terminal handling
3056
- // instead of routing it through channel.error as a failure.
3057
- 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)) {
3058
3299
  activeStream = undefined;
3059
3300
  wasAborted = true;
3060
3301
  break;
@@ -3189,12 +3430,16 @@ export class GoogleVertexProvider extends BaseProvider {
3189
3430
  const toolOptions = {
3190
3431
  toolCallId: toolUse.id,
3191
3432
  messages: [],
3192
- abortSignal: options.abortSignal,
3433
+ abortSignal: internalAbort.signal,
3193
3434
  };
3435
+ turnClock.noteProgress();
3194
3436
  // Run with toolSpan active so spans inside execute
3195
3437
  // (neurolink.tool.execute) nest under this observation instead
3196
- // of becoming disconnected siblings.
3197
- 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();
3198
3443
  // MCP failures are returned, not thrown — surface them on
3199
3444
  // the span so failed calls show as ERROR in Langfuse.
3200
3445
  endToolSpan(result, extractMcpToolErrorMessage(result));
@@ -3224,7 +3469,7 @@ export class GoogleVertexProvider extends BaseProvider {
3224
3469
  // An aborted tool call is a cancellation, not a tool failure —
3225
3470
  // end the span without recording an error execution/result and
3226
3471
  // break the turn.
3227
- if (options.abortSignal?.aborted || isAbortError(err)) {
3472
+ if (internalAbort.signal.aborted || isAbortError(err)) {
3228
3473
  endToolSpan({ aborted: true });
3229
3474
  // Keep persisted tool history paired: the call row was
3230
3475
  // already pushed above, so record a neutral cancellation
@@ -3238,6 +3483,15 @@ export class GoogleVertexProvider extends BaseProvider {
3238
3483
  wasAborted = true;
3239
3484
  break;
3240
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
+ }
3241
3495
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3242
3496
  const errorPayload = { error: errMsg };
3243
3497
  endToolSpan(errorPayload, errMsg);
@@ -3301,6 +3555,8 @@ export class GoogleVertexProvider extends BaseProvider {
3301
3555
  // to wrap up so the reserved forced-finalization call below stays a
3302
3556
  // fallback, not the norm. Rides as a trailing text block on the
3303
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.
3304
3560
  const stepsRemaining = agenticStepBudget - step;
3305
3561
  if (stepsRemaining > 0 && stepsRemaining <= 3) {
3306
3562
  toolResults.push({
@@ -3311,6 +3567,12 @@ export class GoogleVertexProvider extends BaseProvider {
3311
3567
  : "provide your final answer."),
3312
3568
  });
3313
3569
  }
3570
+ else if (turnClock.shouldNudgeWrapup()) {
3571
+ toolResults.push({
3572
+ type: "text",
3573
+ text: buildWrapupNudgeText(useFinalResultTool),
3574
+ });
3575
+ }
3314
3576
  // Continue the loop: assistant turn + tool_result user turn.
3315
3577
  // Filter server_tool_use blocks (Anthropic API rejects them in
3316
3578
  // subsequent message turns).
@@ -3334,24 +3596,34 @@ export class GoogleVertexProvider extends BaseProvider {
3334
3596
  // Gemini synthesizeFinalAnswerWithoutTools precedent); their tokens
3335
3597
  // still land in `usage` and the turn-span metadata.
3336
3598
  if (!modelFinished) {
3337
- // A caller abort that landed during the FINAL budgeted step's tool
3599
+ // An abort that landed during the FINAL budgeted step's tool
3338
3600
  // execution leaves wasAborted=false (no loop-entry check runs after
3339
3601
  // a budget exit) — re-check before issuing any terminal model call.
3340
- if (options.abortSignal?.aborted) {
3602
+ if (internalAbort.signal.aborted) {
3341
3603
  wasAborted = true;
3342
3604
  }
3343
3605
  const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3344
3606
  if (wasAborted) {
3345
3607
  // Budget already blown — never issue another model call. Deliver
3346
- // exactly one graceful cap chunk if nothing reached the consumer
3347
- // (live deltas already delivered count as "something reached").
3348
- 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.`);
3349
3615
  if (aggregatedTurnText.length === 0 &&
3350
3616
  liveTextPushedLength === 0 &&
3351
3617
  !structuredOutputRef.value) {
3352
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3353
- channel.push(capMessage);
3354
- 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;
3355
3627
  }
3356
3628
  }
3357
3629
  else if (useFinalResultTool) {
@@ -3416,17 +3688,26 @@ export class GoogleVertexProvider extends BaseProvider {
3416
3688
  }
3417
3689
  catch (error) {
3418
3690
  activeStream = undefined;
3419
- // An aborted finalization is a cancellation, not a step-cap
3420
- // turn — keep finishReason mapping from lastStopReason.
3421
- 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)) {
3422
3696
  hitStepLimit = false;
3697
+ wasAborted = true;
3423
3698
  }
3424
- 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", {
3425
3700
  error: error instanceof Error ? error.message : String(error),
3426
3701
  });
3427
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3428
- channel.push(capMessage);
3429
- 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;
3430
3711
  }
3431
3712
  }
3432
3713
  else {
@@ -3496,17 +3777,26 @@ export class GoogleVertexProvider extends BaseProvider {
3496
3777
  }
3497
3778
  catch (error) {
3498
3779
  activeStream = undefined;
3499
- // An aborted backstop is a cancellation, not a step-cap
3500
- // turn keep finishReason mapping from lastStopReason.
3501
- 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)) {
3502
3785
  hitStepLimit = false;
3786
+ wasAborted = true;
3503
3787
  }
3504
- 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", {
3505
3789
  error: error instanceof Error ? error.message : String(error),
3506
3790
  });
3507
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3508
- channel.push(capMessage);
3509
- 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;
3510
3800
  }
3511
3801
  }
3512
3802
  // else: prose already streamed to the consumer across steps —
@@ -3522,9 +3812,15 @@ export class GoogleVertexProvider extends BaseProvider {
3522
3812
  !structuredOutputRef.value;
3523
3813
  const externalToolCallTotal = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3524
3814
  if (deliveredNothing && externalToolCallTotal > 0) {
3525
- const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallTotal);
3526
- channel.push(capMessage);
3527
- 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;
3528
3824
  }
3529
3825
  // Honest finish reason (same mapping as the generate twin): "length"
3530
3826
  // takes precedence, a capped turn without a clean/forced answer is
@@ -3540,6 +3836,30 @@ export class GoogleVertexProvider extends BaseProvider {
3540
3836
  metadata.finishReason = finishReasonRef.value;
3541
3837
  metadata.responseTime = Date.now() - startTime;
3542
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
+ }
3543
3863
  // Surface cache metrics once, after the agentic loop, from the
3544
3864
  // cumulative turnCacheUsage running totals (accumulated via += on every
3545
3865
  // step above). Assigned here — not per-iteration — so the final values
@@ -3594,8 +3914,9 @@ export class GoogleVertexProvider extends BaseProvider {
3594
3914
  }
3595
3915
  finally {
3596
3916
  turnSpan.end();
3597
- options.abortSignal?.removeEventListener("abort", abortHandler);
3598
- clearTimeout(streamTimeoutHandle);
3917
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
3918
+ internalAbort.signal.removeEventListener("abort", abortHandler);
3919
+ turnClock.dispose();
3599
3920
  }
3600
3921
  })();
3601
3922
  // Suppress unhandled-rejection: errors funnel through channel.error()
@@ -3640,6 +3961,16 @@ export class GoogleVertexProvider extends BaseProvider {
3640
3961
  configurable: true,
3641
3962
  get: () => finishReasonRef.value,
3642
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
+ });
3643
3974
  return result;
3644
3975
  }
3645
3976
  /**
@@ -3938,15 +4269,48 @@ export class GoogleVertexProvider extends BaseProvider {
3938
4269
  let synthesizedFinalAnswer = false;
3939
4270
  let modelFinished = false;
3940
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
+ };
3941
4303
  while (step < agenticStepBudget) {
3942
- // Honor caller aborts BETWEEN steps: break into terminal handling
3943
- // instead of throwing (a throw routes consumers into abortSignal-less
3944
- // fallback retries observed in production as a 600s abort no-op).
3945
- 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) {
3946
4309
  wasAborted = true;
3947
4310
  break;
3948
4311
  }
3949
4312
  step++;
4313
+ turnClock.noteProgress();
3950
4314
  try {
3951
4315
  // Bound the SDK wait so a stalled Vertex/Anthropic call can't hang
3952
4316
  // generate forever. options.timeout wins if set, otherwise default
@@ -3975,7 +4339,7 @@ export class GoogleVertexProvider extends BaseProvider {
3975
4339
  tools: cachedGenerate.tools,
3976
4340
  }),
3977
4341
  messages: cachedGenerate.messages,
3978
- }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic generate timed out");
4342
+ }, { signal: internalAbort.signal }), generateTimeoutMs, "Anthropic generate timed out");
3979
4343
  // Update token counts. input_tokens is the uncached remainder; cache
3980
4344
  // reads/writes are reported separately and accumulated here so the
3981
4345
  // result reflects the full picture.
@@ -4035,9 +4399,13 @@ export class GoogleVertexProvider extends BaseProvider {
4035
4399
  const toolOptions = {
4036
4400
  toolCallId: toolUse.id,
4037
4401
  messages: [],
4038
- abortSignal: options.abortSignal,
4402
+ abortSignal: internalAbort.signal,
4039
4403
  };
4040
- 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();
4041
4409
  toolExecutions.push({
4042
4410
  name: toolUse.name,
4043
4411
  input: toolUse.input,
@@ -4063,7 +4431,7 @@ export class GoogleVertexProvider extends BaseProvider {
4063
4431
  catch (err) {
4064
4432
  // An aborted tool call is a cancellation, not a tool failure —
4065
4433
  // break the turn without recording an error execution/result.
4066
- if (options.abortSignal?.aborted || isAbortError(err)) {
4434
+ if (internalAbort.signal.aborted || isAbortError(err)) {
4067
4435
  // Keep persisted tool history paired: the call row was
4068
4436
  // already pushed above, so record a neutral cancellation
4069
4437
  // result (NOT an error) — an unpaired tool_use replayed on
@@ -4076,6 +4444,15 @@ export class GoogleVertexProvider extends BaseProvider {
4076
4444
  wasAborted = true;
4077
4445
  break;
4078
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
+ }
4079
4456
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
4080
4457
  const errorPayload = { error: errMsg };
4081
4458
  toolExecutions.push({
@@ -4138,6 +4515,8 @@ export class GoogleVertexProvider extends BaseProvider {
4138
4515
  // wrap up so the reserved forced-finalization call below stays a
4139
4516
  // fallback, not the norm. Rides as a trailing text block on the
4140
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.
4141
4520
  const stepsRemaining = agenticStepBudget - step;
4142
4521
  if (stepsRemaining > 0 && stepsRemaining <= 3) {
4143
4522
  toolResults.push({
@@ -4148,6 +4527,12 @@ export class GoogleVertexProvider extends BaseProvider {
4148
4527
  : "provide your final answer."),
4149
4528
  });
4150
4529
  }
4530
+ else if (turnClock.shouldNudgeWrapup()) {
4531
+ toolResults.push({
4532
+ type: "text",
4533
+ text: buildWrapupNudgeText(useFinalResultTool),
4534
+ });
4535
+ }
4151
4536
  // Add assistant message and tool results to continue the loop
4152
4537
  // Filter out server_tool_use blocks that the Anthropic API doesn't accept in messages
4153
4538
  const assistantContent = response.content.filter((block) => block.type !== "server_tool_use");
@@ -4165,12 +4550,13 @@ export class GoogleVertexProvider extends BaseProvider {
4165
4550
  }
4166
4551
  catch (error) {
4167
4552
  // A mid-request abort surfaces as an abort-shaped SDK rejection (we
4168
- // 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
4169
4555
  // gracefully into the terminal handling instead of re-throwing — a
4170
4556
  // re-throw routes the caller's abort into abortSignal-less fallback
4171
- // 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
4172
4558
  // an abort-shaped error either way means "stop", not a real failure.
4173
- if (options.abortSignal?.aborted || isAbortError(error)) {
4559
+ if (internalAbort.signal.aborted || isAbortError(error)) {
4174
4560
  wasAborted = true;
4175
4561
  break;
4176
4562
  }
@@ -4190,6 +4576,7 @@ export class GoogleVertexProvider extends BaseProvider {
4190
4576
  catch {
4191
4577
  /* frozen/sealed error — context stays best-effort */
4192
4578
  }
4579
+ releaseTurnResources();
4193
4580
  throw this.handleProviderError(error);
4194
4581
  }
4195
4582
  }
@@ -4199,22 +4586,32 @@ export class GoogleVertexProvider extends BaseProvider {
4199
4586
  // synthesize a plain-text answer on tool turns, or fall back to a
4200
4587
  // graceful cap message. Mirrors the Gemini paths (ed289b7 / PR #1123).
4201
4588
  if (!modelFinished && !finalText) {
4202
- // A caller abort that landed during the FINAL budgeted step's tool
4589
+ // An abort that landed during the FINAL budgeted step's tool
4203
4590
  // execution leaves wasAborted=false (no loop-entry check runs after a
4204
4591
  // budget exit) — re-check before issuing any terminal model call.
4205
- if (options.abortSignal?.aborted) {
4592
+ if (internalAbort.signal.aborted) {
4206
4593
  wasAborted = true;
4207
4594
  }
4208
4595
  const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
4209
4596
  if (wasAborted) {
4210
4597
  // Budget already blown — never issue another model call; prefer the
4211
4598
  // prose the model already produced (mirrors the Gemini abort path),
4212
- // else answer with a graceful cap message. finishReason keeps mapping
4213
- // from lastStopReason (an aborted turn is not a step-cap turn).
4214
- 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.`);
4215
4606
  finalText =
4216
4607
  accumulatedStepText ||
4217
- buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4608
+ this.buildLoopExitMessage({
4609
+ turnClock,
4610
+ wasAborted,
4611
+ stallTimeoutMs: options.stallTimeoutMs,
4612
+ maxSteps,
4613
+ toolCallCount: externalToolCallCount,
4614
+ });
4218
4615
  }
4219
4616
  else if (useFinalResultTool) {
4220
4617
  hitStepLimit = true;
@@ -4244,7 +4641,7 @@ export class GoogleVertexProvider extends BaseProvider {
4244
4641
  tools: cachedFinal.tools,
4245
4642
  }),
4246
4643
  messages: cachedFinal.messages,
4247
- }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic finalization call timed out");
4644
+ }, { signal: internalAbort.signal }), generateTimeoutMs, "Anthropic finalization call timed out");
4248
4645
  totalInputTokens += response.usage?.input_tokens || 0;
4249
4646
  totalOutputTokens += response.usage?.output_tokens || 0;
4250
4647
  totalCacheReadTokens += response.usage?.cache_read_input_tokens || 0;
@@ -4263,13 +4660,22 @@ export class GoogleVertexProvider extends BaseProvider {
4263
4660
  }
4264
4661
  }
4265
4662
  catch (error) {
4266
- // An aborted finalization is a cancellation, not a step-cap turn —
4267
- // keep finishReason mapping from lastStopReason, not "tool-calls".
4268
- 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)) {
4269
4668
  hitStepLimit = false;
4669
+ wasAborted = true;
4270
4670
  }
4271
- logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", { error: error instanceof Error ? error.message : String(error) });
4272
- 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
+ });
4273
4679
  }
4274
4680
  }
4275
4681
  else {
@@ -4307,7 +4713,7 @@ export class GoogleVertexProvider extends BaseProvider {
4307
4713
  tools: cachedBackstop.tools,
4308
4714
  }),
4309
4715
  messages: cachedBackstop.messages,
4310
- }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic backstop call timed out");
4716
+ }, { signal: internalAbort.signal }), generateTimeoutMs, "Anthropic backstop call timed out");
4311
4717
  totalInputTokens += response.usage?.input_tokens || 0;
4312
4718
  totalOutputTokens += response.usage?.output_tokens || 0;
4313
4719
  totalCacheReadTokens +=
@@ -4328,39 +4734,79 @@ export class GoogleVertexProvider extends BaseProvider {
4328
4734
  }
4329
4735
  }
4330
4736
  catch (error) {
4331
- // An aborted backstop is a cancellation, not a step-cap turn —
4332
- // keep finishReason mapping from lastStopReason, not "tool-calls".
4333
- 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)) {
4334
4742
  hitStepLimit = false;
4743
+ wasAborted = true;
4335
4744
  }
4336
- 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", {
4337
4746
  error: error instanceof Error ? error.message : String(error),
4338
4747
  });
4339
- finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4748
+ finalText = this.buildLoopExitMessage({
4749
+ turnClock,
4750
+ wasAborted,
4751
+ stallTimeoutMs: options.stallTimeoutMs,
4752
+ maxSteps,
4753
+ toolCallCount: externalToolCallCount,
4754
+ });
4340
4755
  }
4341
4756
  }
4342
4757
  }
4343
4758
  }
4759
+ releaseTurnResources();
4344
4760
  const responseTime = Date.now() - startTime;
4345
4761
  const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
4346
4762
  const externalToolExecutions = toolExecutions.filter((te) => te.name !== "final_result");
4347
4763
  // Absolute backstop — no code path may surface empty content on a turn
4348
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.
4349
4767
  if (!finalText && externalToolCalls.length > 0) {
4350
- 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
+ });
4351
4803
  }
4352
4804
  const result = {
4353
4805
  content: finalText,
4354
- // Honest finish reason. "length" (token truncation) takes precedence; a
4355
- // step-cap exhaustion without a clean/forced answer surfaces as
4356
- // "tool-calls" (aligned with the Gemini paths, so consumers detect
4357
- // capped turns uniformly); everything else — including a successful
4358
- // forced finalization or backstop synthesis — is a normal "stop".
4359
- finishReason: lastStopReason === "max_tokens"
4360
- ? "length"
4361
- : hitStepLimit && !synthesizedFinalAnswer
4362
- ? "tool-calls"
4363
- : "stop",
4806
+ finishReason: resolvedFinishReason,
4807
+ stopReason,
4808
+ rawFinishReason: lastStopReason ?? undefined,
4809
+ stepsUsed: step,
4364
4810
  provider: this.providerName,
4365
4811
  model: modelName,
4366
4812
  usage: {
@@ -4993,6 +5439,43 @@ export class GoogleVertexProvider extends BaseProvider {
4993
5439
  true;
4994
5440
  }
4995
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
+ }
4996
5479
  formatProviderError(error) {
4997
5480
  const errorRecord = error;
4998
5481
  if (typeof errorRecord?.name === "string" &&