@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +351 -351
- package/dist/cli/loop/optionsSchema.js +16 -0
- package/dist/core/analytics.js +22 -0
- package/dist/core/constants.d.ts +13 -0
- package/dist/core/constants.js +13 -0
- package/dist/lib/core/analytics.js +22 -0
- package/dist/lib/core/constants.d.ts +13 -0
- package/dist/lib/core/constants.js +13 -0
- package/dist/lib/neurolink.js +30 -3
- package/dist/lib/providers/googleNativeGemini3.d.ts +92 -4
- package/dist/lib/providers/googleNativeGemini3.js +186 -4
- package/dist/lib/providers/googleVertex.d.ts +15 -0
- package/dist/lib/providers/googleVertex.js +603 -120
- package/dist/lib/types/analytics.d.ts +10 -0
- package/dist/lib/types/generate.d.ts +75 -0
- package/dist/lib/types/stream.d.ts +21 -1
- package/dist/neurolink.js +30 -3
- package/dist/providers/googleNativeGemini3.d.ts +92 -4
- package/dist/providers/googleNativeGemini3.js +186 -4
- package/dist/providers/googleVertex.d.ts +15 -0
- package/dist/providers/googleVertex.js +603 -120
- package/dist/types/analytics.d.ts +10 -0
- package/dist/types/generate.d.ts +75 -0
- package/dist/types/stream.d.ts +21 -1
- package/package.json +1 -1
|
@@ -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
|
|
1332
|
-
//
|
|
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
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
-
|
|
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
|
|
2069
|
-
//
|
|
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
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2393
|
-
//
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
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
|
-
|
|
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
|
|
2931
|
-
//
|
|
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
|
-
|
|
2942
|
-
//
|
|
2943
|
-
//
|
|
2944
|
-
//
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
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
|
|
2964
|
-
//
|
|
2965
|
-
//
|
|
2966
|
-
//
|
|
2967
|
-
|
|
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
|
|
3054
|
-
// tripping abortHandler) rejects finalMessage()
|
|
3055
|
-
// abort-shaped error. Break gracefully into the terminal
|
|
3056
|
-
// instead of routing it through channel.error as a
|
|
3057
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
3347
|
-
// (
|
|
3348
|
-
|
|
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
|
|
3353
|
-
|
|
3354
|
-
|
|
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
|
|
3420
|
-
// turn — keep finishReason
|
|
3421
|
-
|
|
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
|
|
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
|
|
3428
|
-
|
|
3429
|
-
|
|
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
|
|
3500
|
-
// turn
|
|
3501
|
-
|
|
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
|
|
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
|
|
3508
|
-
|
|
3509
|
-
|
|
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
|
|
3526
|
-
|
|
3527
|
-
|
|
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",
|
|
3598
|
-
|
|
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
|
|
3943
|
-
//
|
|
3944
|
-
//
|
|
3945
|
-
|
|
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:
|
|
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:
|
|
4402
|
+
abortSignal: internalAbort.signal,
|
|
4039
4403
|
};
|
|
4040
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
4213
|
-
//
|
|
4214
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
4267
|
-
//
|
|
4268
|
-
|
|
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
|
|
4272
|
-
finalText =
|
|
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:
|
|
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
|
|
4332
|
-
//
|
|
4333
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
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" &&
|