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