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