@juspay/neurolink 11.15.7 → 11.15.9
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 +1 -5
- package/dist/browser/neurolink.min.js +362 -362
- package/dist/core/geminiLoopAdapter.js +35 -12
- package/dist/core/loopEngine.js +180 -96
- package/dist/providers/anthropic/client.js +2 -2
- package/dist/providers/googleAiStudio/client.js +18 -8
- package/dist/providers/googleNativeGemini3/utils.d.ts +3 -3
- package/dist/providers/googleNativeGemini3/utils.js +47 -10
- package/dist/providers/googleVertex/client.js +447 -788
- package/dist/types/loopEngine.d.ts +108 -5
- package/package.json +1 -1
|
@@ -29,7 +29,9 @@ import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } fr
|
|
|
29
29
|
import { createNativeThinkingConfig } from "../../utils/thinkingConfig.js";
|
|
30
30
|
import { TimeoutError, raceWithAbort, withTimeout, } from "../../utils/async/index.js";
|
|
31
31
|
import { parseTimeout } from "../../utils/timeout.js";
|
|
32
|
-
import { appendStepText, buildAbortedTurnMessage, buildContextCapMessage, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createContextGuard, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
|
|
32
|
+
import { appendStepText, buildAbortedTurnMessage, buildContextCapMessage, buildDedupedEngineTools, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createContextGuard, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
|
|
33
|
+
import { createGeminiLoopAdapter } from "../../core/geminiLoopAdapter.js";
|
|
34
|
+
import { runAgenticLoop } from "../../core/loopEngine.js";
|
|
33
35
|
import { createStreamChannel } from "../../core/streamChannel.js";
|
|
34
36
|
import { toNativeToolDeclarations } from "../../core/nativeToolFormat.js";
|
|
35
37
|
import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
|
|
@@ -1338,10 +1340,16 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1338
1340
|
// Convert Vercel AI SDK tools to @google/genai FunctionDeclarations
|
|
1339
1341
|
let tools;
|
|
1340
1342
|
const executeMap = new DedupExecuteMap();
|
|
1343
|
+
let declarations;
|
|
1341
1344
|
if (options.tools &&
|
|
1342
1345
|
Object.keys(options.tools).length > 0 &&
|
|
1343
1346
|
!options.disableTools) {
|
|
1344
1347
|
const declared = toNativeToolDeclarations(options.tools, "functionDeclarations");
|
|
1348
|
+
// Kept, not discarded: the shared adapter needs originalNameMap to
|
|
1349
|
+
// translate sanitized wire names back, and buildDedupedEngineTools
|
|
1350
|
+
// reads executeMap through it — which is the DedupExecuteMap that makes
|
|
1351
|
+
// an identical repeated call answer from cache instead of re-running.
|
|
1352
|
+
declarations = declared;
|
|
1345
1353
|
tools = declared.toolsConfig;
|
|
1346
1354
|
for (const [name, execute] of declared.executeMap) {
|
|
1347
1355
|
executeMap.set(name, execute);
|
|
@@ -1489,12 +1497,8 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1489
1497
|
// hook can persist actual tool outputs rather than the placeholder
|
|
1490
1498
|
// "success" string used by flushPendingToolData's default fallback.
|
|
1491
1499
|
const toolExecutions = [];
|
|
1492
|
-
let step = 0;
|
|
1493
1500
|
// Track structured output from final_result tool (when using final_result pattern)
|
|
1494
1501
|
let finalResultStructuredOutput;
|
|
1495
|
-
// Track failed tools to prevent infinite retry loops
|
|
1496
|
-
// Key: tool name, Value: { count: retry attempts, lastError: error message }
|
|
1497
|
-
const failedTools = new Map();
|
|
1498
1502
|
// In-loop context guard: stop calling tools when the accumulated
|
|
1499
1503
|
// conversation approaches the model's context window instead of stepping
|
|
1500
1504
|
// into a provider "prompt too long" rejection mid-loop.
|
|
@@ -1545,131 +1549,64 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1545
1549
|
internalAbort.abort();
|
|
1546
1550
|
}
|
|
1547
1551
|
let wasAborted = false;
|
|
1548
|
-
// One retry per turn for MALFORMED_FUNCTION_CALL steps (see the retry
|
|
1549
|
-
// block after the step drain).
|
|
1550
|
-
let malformedRetryCount = 0;
|
|
1551
1552
|
// Step-cap flags declared in the outer scope so the terminal block (also
|
|
1552
1553
|
// inside the try) and the finishReason mapping (after the finally) can
|
|
1553
1554
|
// both read them.
|
|
1554
1555
|
let hitStepLimit = false;
|
|
1555
1556
|
let synthesizedFinalAnswer = false;
|
|
1557
|
+
// How many steps the ENGINE actually took, reported back from the per-step
|
|
1558
|
+
// hook. The terminal block compares it against maxSteps, and counting hook
|
|
1559
|
+
// invocations instead would drift by the number of malformed retries.
|
|
1560
|
+
let stepsTaken = 0;
|
|
1556
1561
|
try {
|
|
1557
1562
|
// Agentic loop for tool calling
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
//
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
// this step's request instead of leaving them invisible until the
|
|
1589
|
-
// next turn (TOOL_NOT_FOUND).
|
|
1590
|
-
this.refreshGeminiToolDeclarations(options.tools, tools?.[0]?.functionDeclarations, executeMap, failedTools);
|
|
1591
|
-
logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
|
|
1592
|
-
try {
|
|
1593
|
-
const stream = await client.models.generateContentStream({
|
|
1594
|
-
model: modelName,
|
|
1595
|
-
contents: currentContents,
|
|
1596
|
-
config: { ...config, abortSignal: effectiveSignal },
|
|
1597
|
-
});
|
|
1598
|
-
const stepFunctionCalls = [];
|
|
1599
|
-
// Capture raw response parts including thoughtSignature
|
|
1600
|
-
const rawResponseParts = [];
|
|
1601
|
-
// This step's own finish reason (vs the cross-step
|
|
1602
|
-
// lastFinishReason) — drives the single MALFORMED_FUNCTION_CALL
|
|
1603
|
-
// retry below.
|
|
1604
|
-
let stepFinishReason;
|
|
1605
|
-
// Per-step usage trackers for WRITE-THROUGH accumulation: within a
|
|
1606
|
-
// step's chunk stream the counts are latest-wins (promptTokenCount
|
|
1607
|
-
// arrives once in the final chunk; candidates/thoughts counts are
|
|
1608
|
-
// cumulative across chunks), so each chunk folds only the DELTA
|
|
1609
|
-
// over this step's previous value into the turn totals. The totals
|
|
1610
|
-
// are therefore correct at every point mid-drain — a step killed
|
|
1611
|
-
// mid-stream (abort / turn deadline / stall watchdog) still counts
|
|
1612
|
-
// the billed tokens it already reported.
|
|
1613
|
-
// The drain now lives in collectVertexStreamChunks. The hooks below
|
|
1614
|
-
// are the two couplings it had to this loop, plus the per-chunk
|
|
1615
|
-
// usage deltas — those must stay per-chunk so a step killed
|
|
1616
|
-
// mid-stream still bills what it reported.
|
|
1617
|
-
const collected = await collectVertexStreamChunks(stream, {
|
|
1618
|
-
push: (chunk) => {
|
|
1619
|
-
if (chunk.content) {
|
|
1620
|
-
incrementalTextChunks.push(chunk.content);
|
|
1621
|
-
}
|
|
1622
|
-
},
|
|
1623
|
-
}, {
|
|
1624
|
-
onProgress: () => turnClock.noteProgress(),
|
|
1625
|
-
onUsage: (input, output) => contextGuard.noteUsage(input, output),
|
|
1626
|
-
onUsageDelta: (counter, delta) => {
|
|
1627
|
-
if (counter === "input") {
|
|
1628
|
-
totalInputTokens += delta;
|
|
1629
|
-
}
|
|
1630
|
-
else if (counter === "output") {
|
|
1631
|
-
totalOutputTokens += delta;
|
|
1632
|
-
}
|
|
1633
|
-
else if (counter === "cacheRead") {
|
|
1634
|
-
totalCacheReadTokens += delta;
|
|
1563
|
+
// The turn runs on the shared engine. The step cap, tool dispatch, the
|
|
1564
|
+
// failure breaker, per-step usage accumulation, the single malformed
|
|
1565
|
+
// retry and the pre-first-chunk provider retry all live there now; what
|
|
1566
|
+
// stays here is everything the engine has no opinion about — the turn
|
|
1567
|
+
// clock, the context guard, conversation-memory storage, the wrap-up
|
|
1568
|
+
// nudge, and the terminal block below.
|
|
1569
|
+
const engineAdapter = createGeminiLoopAdapter({
|
|
1570
|
+
providerLabel: "GoogleVertex",
|
|
1571
|
+
maxSteps,
|
|
1572
|
+
// Ported verbatim: the same DEFAULT_TOOL_MAX_RETRIES threshold, plus
|
|
1573
|
+
// the two rules this loop has always had and the engine did not.
|
|
1574
|
+
toolFailureBreaker: {
|
|
1575
|
+
maxRetries: DEFAULT_TOOL_MAX_RETRIES,
|
|
1576
|
+
// Strikes are CONSECUTIVE here: a clean result clears the count, so
|
|
1577
|
+
// an argument-dependent soft error cannot accumulate its way to
|
|
1578
|
+
// disabling a tool that works.
|
|
1579
|
+
consecutive: true,
|
|
1580
|
+
// A result that reports failure without throwing — an MCP isError
|
|
1581
|
+
// payload, a proxy-blocked call resolving with { error } — counts
|
|
1582
|
+
// toward the breaker exactly as a throw does.
|
|
1583
|
+
classifyResultFailure: (output) => extractToolFailureText(output) ?? undefined,
|
|
1584
|
+
},
|
|
1585
|
+
liveTools: options.tools ?? {},
|
|
1586
|
+
...(declarations ? { declarations } : {}),
|
|
1587
|
+
...(useFinalResultTool
|
|
1588
|
+
? {
|
|
1589
|
+
finalResultToolName: "final_result",
|
|
1590
|
+
onTerminalResult: (text) => {
|
|
1591
|
+
try {
|
|
1592
|
+
finalResultStructuredOutput = JSON.parse(text);
|
|
1635
1593
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1594
|
+
catch {
|
|
1595
|
+
/* the caller's coercion layer repairs a partial payload */
|
|
1638
1596
|
}
|
|
1639
1597
|
},
|
|
1640
|
-
});
|
|
1641
|
-
rawResponseParts.push(...collected.rawResponseParts);
|
|
1642
|
-
stepFunctionCalls.push(...collected.stepFunctionCalls);
|
|
1643
|
-
if (collected.finishReason) {
|
|
1644
|
-
stepFinishReason = collected.finishReason;
|
|
1645
|
-
lastFinishReason = collected.finishReason;
|
|
1646
1598
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
if (stepFunctionCalls.length === 0 &&
|
|
1659
|
-
!stepText &&
|
|
1660
|
-
stepFinishReason === "MALFORMED_FUNCTION_CALL" &&
|
|
1661
|
-
malformedRetryCount < 1 &&
|
|
1662
|
-
!effectiveSignal.aborted) {
|
|
1663
|
-
malformedRetryCount++;
|
|
1664
|
-
logger.warn(`[GoogleVertex] Model returned MALFORMED_FUNCTION_CALL at step ${step}/${maxSteps}; retrying once with a corrective note.`);
|
|
1665
|
-
this.emitTurnEvent({ phase: "malformed-retry", step, maxSteps });
|
|
1666
|
-
if (rawResponseParts.length > 0) {
|
|
1667
|
-
currentContents.push({
|
|
1668
|
-
role: "model",
|
|
1669
|
-
parts: rawResponseParts,
|
|
1670
|
-
});
|
|
1671
|
-
}
|
|
1672
|
-
currentContents.push({
|
|
1599
|
+
: {}),
|
|
1600
|
+
enableMalformedRetry: true,
|
|
1601
|
+
buildMalformedRetryNote: (conversation, retriedStep) => {
|
|
1602
|
+
this.emitTurnEvent({
|
|
1603
|
+
phase: "malformed-retry",
|
|
1604
|
+
step: retriedStep + 1,
|
|
1605
|
+
maxSteps,
|
|
1606
|
+
});
|
|
1607
|
+
return [
|
|
1608
|
+
...conversation,
|
|
1609
|
+
{
|
|
1673
1610
|
role: "user",
|
|
1674
1611
|
parts: [
|
|
1675
1612
|
{
|
|
@@ -1678,310 +1615,206 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1678
1615
|
"or answer in plain text.",
|
|
1679
1616
|
},
|
|
1680
1617
|
],
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
// Extract the structured output from final_result arguments
|
|
1694
|
-
finalResultStructuredOutput = finalResultCall.args;
|
|
1695
|
-
logger.debug("[GoogleVertex] Received final_result tool call with structured output (stream)", {
|
|
1696
|
-
outputKeys: Object.keys(finalResultStructuredOutput),
|
|
1697
|
-
});
|
|
1698
|
-
// Return the structured output as JSON text
|
|
1699
|
-
finalText = JSON.stringify(finalResultStructuredOutput);
|
|
1700
|
-
break;
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
// Execute function calls
|
|
1704
|
-
logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
|
|
1705
|
-
// Add model response with ALL parts (including thoughtSignature) to history
|
|
1706
|
-
// This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
|
|
1707
|
-
currentContents.push({
|
|
1708
|
-
role: "model",
|
|
1709
|
-
parts: rawResponseParts.length > 0
|
|
1710
|
-
? rawResponseParts
|
|
1711
|
-
: stepFunctionCalls.map((fc) => ({
|
|
1712
|
-
functionCall: fc,
|
|
1713
|
-
})),
|
|
1714
|
-
});
|
|
1715
|
-
// Execute each function and collect responses (plus an optional
|
|
1716
|
-
// trailing wrap-up nudge text part).
|
|
1717
|
-
const functionResponses = [];
|
|
1718
|
-
// Per-step bookkeeping for conversation-memory storage.
|
|
1719
|
-
const stepStorageCalls = [];
|
|
1720
|
-
const stepStorageResults = [];
|
|
1721
|
-
// Note: tool:start / tool:end events are emitted by ToolsManager's
|
|
1722
|
-
// wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
|
|
1723
|
-
for (const call of stepFunctionCalls) {
|
|
1724
|
-
// Honor a deadline/stall/caller abort BETWEEN tool executions —
|
|
1725
|
-
// without this check a multi-tool step keeps executing its whole
|
|
1726
|
-
// batch (up to N × toolTimeoutMs past the deadline) before the
|
|
1727
|
-
// while-top check finally breaks.
|
|
1728
|
-
if (effectiveSignal.aborted) {
|
|
1729
|
-
wasAborted = true;
|
|
1730
|
-
break;
|
|
1618
|
+
},
|
|
1619
|
+
];
|
|
1620
|
+
},
|
|
1621
|
+
// The turn clock's per-chunk ping and the context guard's per-step
|
|
1622
|
+
// prompt size both ride the drain, which is why this loop keeps its
|
|
1623
|
+
// own collector rather than the adapter's default.
|
|
1624
|
+
collectStep: (stream, channel) => collectVertexStreamChunks(stream, channel, {
|
|
1625
|
+
onProgress: () => turnClock.noteProgress(),
|
|
1626
|
+
onUsage: (input, output) => contextGuard.noteUsage(input, output),
|
|
1627
|
+
onUsageDelta: (counter, delta) => {
|
|
1628
|
+
if (counter === "input") {
|
|
1629
|
+
totalInputTokens += delta;
|
|
1731
1630
|
}
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
// Check if this tool has already exceeded retry limit
|
|
1735
|
-
const failedInfo = failedTools.get(call.name);
|
|
1736
|
-
if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
|
|
1737
|
-
logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
|
|
1738
|
-
const errorPayload = {
|
|
1739
|
-
error: `TOOL_PERMANENTLY_FAILED: The tool "${call.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.`,
|
|
1740
|
-
status: "permanently_failed",
|
|
1741
|
-
do_not_retry: true,
|
|
1742
|
-
};
|
|
1743
|
-
functionResponses.push({
|
|
1744
|
-
functionResponse: {
|
|
1745
|
-
name: call.name,
|
|
1746
|
-
response: errorPayload,
|
|
1747
|
-
},
|
|
1748
|
-
});
|
|
1749
|
-
toolExecutions.push({
|
|
1750
|
-
name: call.name,
|
|
1751
|
-
input: call.args,
|
|
1752
|
-
output: errorPayload,
|
|
1753
|
-
});
|
|
1754
|
-
stepStorageResults.push({
|
|
1755
|
-
toolName: call.name,
|
|
1756
|
-
output: errorPayload,
|
|
1757
|
-
});
|
|
1758
|
-
continue;
|
|
1631
|
+
else if (counter === "output") {
|
|
1632
|
+
totalOutputTokens += delta;
|
|
1759
1633
|
}
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
// Snapshot miss: the tool may have been hydrated into the live
|
|
1763
|
-
// record by search_tools within this very step batch, or the
|
|
1764
|
-
// model called a deferred catalog tool directly by name.
|
|
1765
|
-
execute = this.resolveGeminiToolOnMiss(call.name, options.tools, tools?.[0]?.functionDeclarations, executeMap, failedTools);
|
|
1766
|
-
}
|
|
1767
|
-
if (execute) {
|
|
1768
|
-
try {
|
|
1769
|
-
// AI SDK Tool execute requires (args, options) - provide minimal options
|
|
1770
|
-
const toolOptions = {
|
|
1771
|
-
toolCallId: `${call.name}-${Date.now()}`,
|
|
1772
|
-
messages: [],
|
|
1773
|
-
abortSignal: effectiveSignal,
|
|
1774
|
-
};
|
|
1775
|
-
turnClock.noteProgress();
|
|
1776
|
-
// Bound the execute() await — a wedged tool costs one step
|
|
1777
|
-
// (error tool_result), not the whole turn — and race it
|
|
1778
|
-
// against the turn's abort so a deadline/caller abort is
|
|
1779
|
-
// observed IMMEDIATELY instead of after the tool settles.
|
|
1780
|
-
const result = await withTimeout(raceWithAbort(Promise.resolve(execute(call.args, toolOptions)), effectiveSignal), toolExecTimeoutMs, `Tool "${call.name}" execution timed out after ${toolExecTimeoutMs}ms`);
|
|
1781
|
-
turnClock.noteProgress();
|
|
1782
|
-
// Error-shaped success (MCP isError / { error } payloads —
|
|
1783
|
-
// e.g. proxy-blocked tools) counts toward the breaker too:
|
|
1784
|
-
// these fail without throwing, and only counting throws lets
|
|
1785
|
-
// the model grind on a blocked tool for the whole budget.
|
|
1786
|
-
const resultErrorText = extractToolFailureText(result);
|
|
1787
|
-
if (resultErrorText) {
|
|
1788
|
-
const info = failedTools.get(call.name) || {
|
|
1789
|
-
count: 0,
|
|
1790
|
-
lastError: "",
|
|
1791
|
-
};
|
|
1792
|
-
info.count++;
|
|
1793
|
-
info.lastError = resultErrorText;
|
|
1794
|
-
failedTools.set(call.name, info);
|
|
1795
|
-
}
|
|
1796
|
-
else {
|
|
1797
|
-
// Genuinely consecutive: a success clears the strike count
|
|
1798
|
-
// (argument-dependent soft errors — file-not-found on
|
|
1799
|
-
// different paths — must not disable a working tool).
|
|
1800
|
-
failedTools.delete(call.name);
|
|
1801
|
-
}
|
|
1802
|
-
toolExecutions.push({
|
|
1803
|
-
name: call.name,
|
|
1804
|
-
input: call.args,
|
|
1805
|
-
output: result,
|
|
1806
|
-
});
|
|
1807
|
-
functionResponses.push({
|
|
1808
|
-
functionResponse: { name: call.name, response: { result } },
|
|
1809
|
-
});
|
|
1810
|
-
stepStorageResults.push({
|
|
1811
|
-
toolName: call.name,
|
|
1812
|
-
output: result,
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
catch (error) {
|
|
1816
|
-
// An abort during tool execution ends the turn gracefully — it
|
|
1817
|
-
// must NOT be recorded as a spurious tool failure. Both checks
|
|
1818
|
-
// matter: effectiveSignal.aborted catches an abort WE triggered
|
|
1819
|
-
// (even if the throw isn't abort-shaped); isAbortError(error)
|
|
1820
|
-
// catches an abort-shaped throw (e.g. a tool raising AbortError)
|
|
1821
|
-
// even when the signal itself never fired.
|
|
1822
|
-
if (effectiveSignal.aborted || isAbortError(error)) {
|
|
1823
|
-
wasAborted = true;
|
|
1824
|
-
break;
|
|
1825
|
-
}
|
|
1826
|
-
turnClock.noteProgress();
|
|
1827
|
-
if (error instanceof TimeoutError) {
|
|
1828
|
-
this.emitTurnEvent({
|
|
1829
|
-
phase: "tool-timeout",
|
|
1830
|
-
step,
|
|
1831
|
-
maxSteps,
|
|
1832
|
-
toolName: call.name,
|
|
1833
|
-
});
|
|
1834
|
-
}
|
|
1835
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
1836
|
-
// Track this failure
|
|
1837
|
-
const currentFailInfo = failedTools.get(call.name) || {
|
|
1838
|
-
count: 0,
|
|
1839
|
-
lastError: "",
|
|
1840
|
-
};
|
|
1841
|
-
currentFailInfo.count++;
|
|
1842
|
-
currentFailInfo.lastError = errorMessage;
|
|
1843
|
-
failedTools.set(call.name, currentFailInfo);
|
|
1844
|
-
logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
|
|
1845
|
-
// Determine if this is a permanent failure
|
|
1846
|
-
const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
|
|
1847
|
-
const errorPayload = {
|
|
1848
|
-
error: isPermanentFailure
|
|
1849
|
-
? `TOOL_PERMANENTLY_FAILED: The tool "${call.name}" has failed ${currentFailInfo.count} times with error: ${errorMessage}. This tool will not be retried. Please proceed without using this tool or inform the user that this functionality is unavailable.`
|
|
1850
|
-
: `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
|
|
1851
|
-
status: isPermanentFailure ? "permanently_failed" : "failed",
|
|
1852
|
-
do_not_retry: isPermanentFailure,
|
|
1853
|
-
retry_count: currentFailInfo.count,
|
|
1854
|
-
max_retries: DEFAULT_TOOL_MAX_RETRIES,
|
|
1855
|
-
};
|
|
1856
|
-
functionResponses.push({
|
|
1857
|
-
functionResponse: {
|
|
1858
|
-
name: call.name,
|
|
1859
|
-
response: errorPayload,
|
|
1860
|
-
},
|
|
1861
|
-
});
|
|
1862
|
-
toolExecutions.push({
|
|
1863
|
-
name: call.name,
|
|
1864
|
-
input: call.args,
|
|
1865
|
-
output: errorPayload,
|
|
1866
|
-
});
|
|
1867
|
-
stepStorageResults.push({
|
|
1868
|
-
toolName: call.name,
|
|
1869
|
-
output: errorPayload,
|
|
1870
|
-
});
|
|
1871
|
-
}
|
|
1634
|
+
else if (counter === "cacheRead") {
|
|
1635
|
+
totalCacheReadTokens += delta;
|
|
1872
1636
|
}
|
|
1873
1637
|
else {
|
|
1874
|
-
|
|
1875
|
-
// breaker too (parity with the Anthropic loops) — a model that
|
|
1876
|
-
// ignores the do_not_retry hint and keeps calling a
|
|
1877
|
-
// hallucinated/stale tool name must not burn the whole step
|
|
1878
|
-
// budget on TOOL_NOT_FOUND round-trips.
|
|
1879
|
-
const errorPayload = {
|
|
1880
|
-
error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
|
|
1881
|
-
status: "permanently_failed",
|
|
1882
|
-
do_not_retry: true,
|
|
1883
|
-
};
|
|
1884
|
-
const notFoundInfo = failedTools.get(call.name) || {
|
|
1885
|
-
count: 0,
|
|
1886
|
-
lastError: "",
|
|
1887
|
-
};
|
|
1888
|
-
notFoundInfo.count++;
|
|
1889
|
-
notFoundInfo.lastError = errorPayload.error;
|
|
1890
|
-
failedTools.set(call.name, notFoundInfo);
|
|
1891
|
-
functionResponses.push({
|
|
1892
|
-
functionResponse: {
|
|
1893
|
-
name: call.name,
|
|
1894
|
-
response: errorPayload,
|
|
1895
|
-
},
|
|
1896
|
-
});
|
|
1897
|
-
toolExecutions.push({
|
|
1898
|
-
name: call.name,
|
|
1899
|
-
input: call.args,
|
|
1900
|
-
output: errorPayload,
|
|
1901
|
-
});
|
|
1902
|
-
stepStorageResults.push({
|
|
1903
|
-
toolName: call.name,
|
|
1904
|
-
output: errorPayload,
|
|
1905
|
-
});
|
|
1638
|
+
totalReasoningTokens += delta;
|
|
1906
1639
|
}
|
|
1640
|
+
},
|
|
1641
|
+
}),
|
|
1642
|
+
planReclaim: (conversation) => {
|
|
1643
|
+
if (!contextGuard.shouldStop()) {
|
|
1644
|
+
return undefined;
|
|
1907
1645
|
}
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
//
|
|
1911
|
-
|
|
1912
|
-
|
|
1646
|
+
// Try to RECLAIM budget and keep going before falling back to the
|
|
1647
|
+
// historic stop-only behaviour. Ending the turn early is safe but
|
|
1648
|
+
// throws away work the model was mid-way through; dropping the
|
|
1649
|
+
// oldest complete tool exchanges usually buys enough room to finish.
|
|
1650
|
+
const working = [...conversation];
|
|
1651
|
+
if (reclaimVertexLoopContext(working, modelName, contextGuard.projectedNextPromptTokens)) {
|
|
1652
|
+
contextGuard.resetAfterReclaim();
|
|
1653
|
+
return { conversation: working };
|
|
1913
1654
|
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1655
|
+
hitContextLimit = true;
|
|
1656
|
+
logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
|
|
1657
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
1658
|
+
`>= threshold ${contextGuard.thresholdTokens} — synthesizing a final answer.`);
|
|
1659
|
+
return { stop: true };
|
|
1660
|
+
},
|
|
1661
|
+
buildRequest: (conversation) => ({
|
|
1662
|
+
model: modelName,
|
|
1663
|
+
contents: conversation,
|
|
1664
|
+
config: { ...config, ...(tools ? { tools } : {}) },
|
|
1665
|
+
}),
|
|
1666
|
+
sendStep: async (request, signal) => {
|
|
1667
|
+
turnClock.noteProgress();
|
|
1668
|
+
const built = request;
|
|
1669
|
+
return client.models.generateContentStream({
|
|
1670
|
+
model: built.model,
|
|
1671
|
+
contents: built.contents,
|
|
1672
|
+
config: { ...(built.config ?? {}), abortSignal: signal },
|
|
1673
|
+
});
|
|
1674
|
+
},
|
|
1675
|
+
});
|
|
1676
|
+
// Wrapped rather than configured: these fire once PER STEP, and
|
|
1677
|
+
// buildToolResultMessages is the only hook that runs per step with
|
|
1678
|
+
// exactly that step's results. Reading them off the turn's final result
|
|
1679
|
+
// would batch every step into one late write and lose the per-step
|
|
1680
|
+
// thought signature.
|
|
1681
|
+
const adapter = {
|
|
1682
|
+
...engineAdapter,
|
|
1683
|
+
// Counted HERE, not in buildToolResultMessages: this runs once per
|
|
1684
|
+
// step exactly as the old `step++` at the top of the loop did,
|
|
1685
|
+
// malformed retries included. Counting in the tool-result hook would
|
|
1686
|
+
// skip the final text-only step and quietly report one step fewer in
|
|
1687
|
+
// `stepsUsed`, which is a public field on the result.
|
|
1688
|
+
buildStepRequest: (conversation, step) => {
|
|
1689
|
+
stepsTaken = step + 1;
|
|
1690
|
+
return engineAdapter.buildStepRequest(conversation, step);
|
|
1691
|
+
},
|
|
1692
|
+
buildToolResultMessages: (conversation, stepResult, toolResults, engineStep) => {
|
|
1693
|
+
const next = engineAdapter.buildToolResultMessages(conversation, stepResult, toolResults, engineStep);
|
|
1694
|
+
// Time-budget wrap-up nudge (twin of the Anthropic loops' soft step
|
|
1695
|
+
// nudge): with the turn deadline approaching, tell the model to
|
|
1696
|
+
// consolidate. Rides as a trailing text part on the tool-response
|
|
1697
|
+
// user turn.
|
|
1698
|
+
if (turnClock.shouldNudgeWrapup()) {
|
|
1699
|
+
const last = next[next.length - 1];
|
|
1700
|
+
if (last && Array.isArray(last.parts)) {
|
|
1701
|
+
last.parts.push({
|
|
1702
|
+
text: buildWrapupNudgeText(useFinalResultTool),
|
|
1932
1703
|
});
|
|
1933
|
-
}
|
|
1704
|
+
}
|
|
1934
1705
|
}
|
|
1935
|
-
//
|
|
1936
|
-
//
|
|
1937
|
-
//
|
|
1938
|
-
//
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1706
|
+
// Persist this step's tool calls/results into conversation memory.
|
|
1707
|
+
// Without this, tool_call / tool_result rows never reach Redis and
|
|
1708
|
+
// the chat-history UI loses every tool invocation. `thoughtSignature`
|
|
1709
|
+
// rides as a sibling on the first call of the step — Gemini 3 needs
|
|
1710
|
+
// it to match thinking patterns when the conversation is replayed.
|
|
1711
|
+
const stepThoughtSig = extractThoughtSignature(stepResult.raw.rawResponseParts);
|
|
1712
|
+
withTimeout(this.handleToolExecutionStorage(toolResults.map((result, index) => ({
|
|
1713
|
+
toolName: result.name,
|
|
1714
|
+
args: result.args,
|
|
1715
|
+
...(index === 0 && stepThoughtSig
|
|
1716
|
+
? { thoughtSignature: stepThoughtSig }
|
|
1717
|
+
: {}),
|
|
1718
|
+
stepIndex: engineStep + 1,
|
|
1719
|
+
})), toolResults.map((result) => ({
|
|
1720
|
+
toolName: result.name,
|
|
1721
|
+
output: result.output,
|
|
1722
|
+
stepIndex: engineStep + 1,
|
|
1723
|
+
})), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
|
|
1724
|
+
logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
|
|
1725
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1942
1726
|
});
|
|
1943
|
-
}
|
|
1944
|
-
// The @google/genai SDK only accepts "user" and "model" as valid
|
|
1945
|
-
// roles in contents — function/tool responses must use role: "user"
|
|
1946
|
-
// (matching the SDK's automaticFunctionCalling implementation and
|
|
1947
|
-
// the Google AI Studio path). Sending role: "function" was causing
|
|
1948
|
-
// native Vertex Gemini tool loops to be silently rejected by the
|
|
1949
|
-
// request validator.
|
|
1950
|
-
currentContents.push({
|
|
1951
|
-
role: "user",
|
|
1952
|
-
parts: functionResponses,
|
|
1953
1727
|
});
|
|
1954
1728
|
// Project this step's growth for the context guard: the appended
|
|
1955
1729
|
// tool results ride the next prompt (Gemini reports usage per call,
|
|
1956
1730
|
// but only for content it has already seen).
|
|
1957
1731
|
try {
|
|
1958
|
-
|
|
1732
|
+
const appended = next[next.length - 1];
|
|
1733
|
+
contextGuard.noteAppendedChars(JSON.stringify(appended?.parts ?? []).length);
|
|
1959
1734
|
}
|
|
1960
1735
|
catch {
|
|
1961
1736
|
/* estimation is best-effort — never break the loop */
|
|
1962
1737
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1738
|
+
return next;
|
|
1739
|
+
},
|
|
1740
|
+
};
|
|
1741
|
+
const { stream: engineStream, resultPromise } = runAgenticLoop(adapter,
|
|
1742
|
+
// A concrete parts array widens to the engine's `unknown[]` on its
|
|
1743
|
+
// own; only the direction back needs an assertion.
|
|
1744
|
+
currentContents, {
|
|
1745
|
+
tools: buildDedupedEngineTools(declarations, options.tools, {
|
|
1746
|
+
toolTimeoutMs: toolExecTimeoutMs,
|
|
1747
|
+
abortSignal: effectiveSignal,
|
|
1748
|
+
onProgress: () => turnClock.noteProgress(),
|
|
1749
|
+
}),
|
|
1750
|
+
abortSignal: effectiveSignal,
|
|
1751
|
+
});
|
|
1752
|
+
// Collected, NOT forwarded to the consumer here. This loop replays the
|
|
1753
|
+
// gathered text after the turn rather than streaming it live, and a
|
|
1754
|
+
// characterization case pins exactly that — pushing to the consumer
|
|
1755
|
+
// channel from inside the pump would make the turn stream live and break
|
|
1756
|
+
// it.
|
|
1757
|
+
const pump = (async () => {
|
|
1758
|
+
for await (const chunk of engineStream) {
|
|
1759
|
+
if (chunk.content) {
|
|
1760
|
+
incrementalTextChunks.push(chunk.content);
|
|
1974
1761
|
}
|
|
1975
|
-
logger.error("[GoogleVertex] Native SDK error", error);
|
|
1976
|
-
throw this.handleProviderError(error);
|
|
1977
1762
|
}
|
|
1763
|
+
})();
|
|
1764
|
+
let engineResult;
|
|
1765
|
+
let turnFailure;
|
|
1766
|
+
try {
|
|
1767
|
+
engineResult = await resultPromise;
|
|
1768
|
+
}
|
|
1769
|
+
catch (error) {
|
|
1770
|
+
turnFailure = error;
|
|
1771
|
+
}
|
|
1772
|
+
// Drained unconditionally and tolerantly. When the turn ends by abort the
|
|
1773
|
+
// channel rejects too, and re-awaiting a settled rejection here would
|
|
1774
|
+
// rethrow the very error the branch below has already decided to absorb —
|
|
1775
|
+
// which is what turned both turn-clock cases into failures instead of
|
|
1776
|
+
// clean deadline exits.
|
|
1777
|
+
await pump.catch(() => { });
|
|
1778
|
+
if (turnFailure !== undefined) {
|
|
1779
|
+
// A mid-drain abort surfaces as an AbortError. End gracefully into the
|
|
1780
|
+
// terminal block instead of re-throwing — a re-throw would route the
|
|
1781
|
+
// caller's abort into a second unbounded fallback stream().
|
|
1782
|
+
if (effectiveSignal.aborted || isAbortError(turnFailure)) {
|
|
1783
|
+
wasAborted = true;
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
logger.error("[GoogleVertex] Native SDK error", turnFailure);
|
|
1787
|
+
throw this.handleProviderError(turnFailure);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (engineResult) {
|
|
1791
|
+
finalText = engineResult.text;
|
|
1792
|
+
lastFinishReason = engineResult.rawStopReason ?? lastFinishReason;
|
|
1793
|
+
for (const call of engineResult.toolCalls) {
|
|
1794
|
+
allToolCalls.push({ toolName: call.name, args: call.args });
|
|
1795
|
+
}
|
|
1796
|
+
for (const execution of engineResult.toolExecutions) {
|
|
1797
|
+
toolExecutions.push({
|
|
1798
|
+
name: execution.name,
|
|
1799
|
+
input: execution.input,
|
|
1800
|
+
output: execution.output,
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
// Replace in place: `currentContents` is a const the terminal block
|
|
1804
|
+
// and the synth call both read.
|
|
1805
|
+
currentContents.length = 0;
|
|
1806
|
+
currentContents.push(...engineResult.conversation);
|
|
1807
|
+
}
|
|
1808
|
+
if (effectiveSignal.aborted) {
|
|
1809
|
+
wasAborted = true;
|
|
1978
1810
|
}
|
|
1979
1811
|
// Handle maxSteps termination / abort — the loop exited because the step
|
|
1980
1812
|
// cap was reached (or the turn was aborted) while the model was still
|
|
1981
1813
|
// calling tools. Surface a real answer instead of the canned placeholder
|
|
1982
1814
|
// (Bug 1) and a meaningful finishReason (Bug 2).
|
|
1983
|
-
if (!finalText &&
|
|
1984
|
-
|
|
1815
|
+
if (!finalText &&
|
|
1816
|
+
(stepsTaken >= maxSteps || wasAborted || hitContextLimit)) {
|
|
1817
|
+
hitStepLimit = stepsTaken >= maxSteps && !wasAborted;
|
|
1985
1818
|
const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
|
|
1986
1819
|
// The consumer receives text via `incrementalTextChunks`; any text the
|
|
1987
1820
|
// model emitted across steps is already preserved there. Only produce a
|
|
@@ -2067,7 +1900,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2067
1900
|
if (stopReason !== "completed") {
|
|
2068
1901
|
this.emitTurnEvent({
|
|
2069
1902
|
phase: stopReason,
|
|
2070
|
-
step,
|
|
1903
|
+
step: stepsTaken,
|
|
2071
1904
|
maxSteps,
|
|
2072
1905
|
toolCallCount: allToolCalls.filter((tc) => tc.toolName !== "final_result").length,
|
|
2073
1906
|
elapsedMs: turnClock.elapsedMs(),
|
|
@@ -2146,7 +1979,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2146
1979
|
totalToolExecutions: externalToolCalls.length,
|
|
2147
1980
|
stopReason,
|
|
2148
1981
|
rawFinishReason: lastFinishReason,
|
|
2149
|
-
stepsUsed:
|
|
1982
|
+
stepsUsed: stepsTaken,
|
|
2150
1983
|
},
|
|
2151
1984
|
};
|
|
2152
1985
|
// Add structured output if final_result tool was used
|
|
@@ -2311,8 +2144,12 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2311
2144
|
// Convert Vercel AI SDK tools to @google/genai FunctionDeclarations
|
|
2312
2145
|
let tools;
|
|
2313
2146
|
const executeMap = new DedupExecuteMap();
|
|
2147
|
+
let declarations;
|
|
2314
2148
|
if (Object.keys(combinedTools).length > 0) {
|
|
2315
2149
|
const declared = toNativeToolDeclarations(combinedTools, "functionDeclarations");
|
|
2150
|
+
// Kept for the shared adapter: originalNameMap for name translation,
|
|
2151
|
+
// executeMap for the per-turn dedup wrapper.
|
|
2152
|
+
declarations = declared;
|
|
2316
2153
|
tools = declared.toolsConfig;
|
|
2317
2154
|
for (const [name, execute] of declared.executeMap) {
|
|
2318
2155
|
executeMap.set(name, execute);
|
|
@@ -2456,12 +2293,8 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2456
2293
|
let lastFinishReason;
|
|
2457
2294
|
const allToolCalls = [];
|
|
2458
2295
|
const toolExecutions = [];
|
|
2459
|
-
let step = 0;
|
|
2460
2296
|
// Track structured output from final_result tool (when using final_result pattern)
|
|
2461
2297
|
let finalResultStructuredOutput;
|
|
2462
|
-
// Track failed tools to prevent infinite retry loops
|
|
2463
|
-
// Key: tool name, Value: { count: retry attempts, lastError: error message }
|
|
2464
|
-
const failedTools = new Map();
|
|
2465
2298
|
// In-loop context guard: stop calling tools when the accumulated
|
|
2466
2299
|
// conversation approaches the model's context window instead of stepping
|
|
2467
2300
|
// into a provider "prompt too long" rejection mid-loop.
|
|
@@ -2505,125 +2338,63 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2505
2338
|
internalAbort.abort();
|
|
2506
2339
|
}
|
|
2507
2340
|
let wasAborted = false;
|
|
2508
|
-
// One retry per turn for MALFORMED_FUNCTION_CALL steps (see the retry
|
|
2509
|
-
// block after the step drain).
|
|
2510
|
-
let malformedRetryCount = 0;
|
|
2511
2341
|
// Step-cap flags declared in the outer scope so the terminal block (also
|
|
2512
2342
|
// inside the try) and the finishReason mapping (after the finally) can
|
|
2513
2343
|
// both read them.
|
|
2514
2344
|
let hitStepLimit = false;
|
|
2515
2345
|
let synthesizedFinalAnswer = false;
|
|
2346
|
+
// Steps the ENGINE took, reported back from the per-step hook; counting
|
|
2347
|
+
// hook invocations would drift by the number of malformed retries.
|
|
2348
|
+
let stepsTaken = 0;
|
|
2516
2349
|
try {
|
|
2517
2350
|
// Agentic loop for tool calling
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
//
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
|
|
2549
|
-
try {
|
|
2550
|
-
// Use generateContentStream and collect all chunks (same as GoogleAIStudio)
|
|
2551
|
-
const stream = await client.models.generateContentStream({
|
|
2552
|
-
model: modelName,
|
|
2553
|
-
contents: currentContents,
|
|
2554
|
-
config: { ...config, abortSignal: effectiveSignal },
|
|
2555
|
-
});
|
|
2556
|
-
const stepFunctionCalls = [];
|
|
2557
|
-
// Capture raw response parts including thoughtSignature
|
|
2558
|
-
const rawResponseParts = [];
|
|
2559
|
-
// This step's own finish reason (vs the cross-step
|
|
2560
|
-
// lastFinishReason) — drives the single MALFORMED_FUNCTION_CALL
|
|
2561
|
-
// retry below.
|
|
2562
|
-
let stepFinishReason;
|
|
2563
|
-
// Per-step usage trackers for WRITE-THROUGH accumulation: within a
|
|
2564
|
-
// step's chunk stream the counts are latest-wins (promptTokenCount
|
|
2565
|
-
// arrives once in the final chunk; candidates/thoughts counts are
|
|
2566
|
-
// cumulative across chunks), so each chunk folds only the DELTA
|
|
2567
|
-
// over this step's previous value into the turn totals. The totals
|
|
2568
|
-
// are therefore correct at every point mid-drain — a step killed
|
|
2569
|
-
// mid-stream (abort / turn deadline / stall watchdog) still counts
|
|
2570
|
-
// the billed tokens it already reported.
|
|
2571
|
-
// Same collector as the streaming twin. generate() returns one
|
|
2572
|
-
// result rather than streaming, so the channel is a no-op — the
|
|
2573
|
-
// stream loop uses it to fill incrementalTextChunks for replay, and
|
|
2574
|
-
// there is nothing to replay here. Everything else is identical,
|
|
2575
|
-
// including the per-chunk usage deltas that keep the turn totals
|
|
2576
|
-
// correct for a step killed mid-stream.
|
|
2577
|
-
const collected = await collectVertexStreamChunks(stream, { push: () => { } }, {
|
|
2578
|
-
onProgress: () => turnClock.noteProgress(),
|
|
2579
|
-
onUsage: (input, output) => contextGuard.noteUsage(input, output),
|
|
2580
|
-
onUsageDelta: (counter, delta) => {
|
|
2581
|
-
if (counter === "input") {
|
|
2582
|
-
totalInputTokens += delta;
|
|
2583
|
-
}
|
|
2584
|
-
else if (counter === "output") {
|
|
2585
|
-
totalOutputTokens += delta;
|
|
2586
|
-
}
|
|
2587
|
-
else if (counter === "cacheRead") {
|
|
2588
|
-
totalCacheReadTokens += delta;
|
|
2351
|
+
// The turn runs on the shared engine. The step cap, tool dispatch, the
|
|
2352
|
+
// failure breaker, per-step usage accumulation, the single malformed
|
|
2353
|
+
// retry and the pre-first-chunk provider retry all live there now; what
|
|
2354
|
+
// stays here is everything the engine has no opinion about — the turn
|
|
2355
|
+
// clock, the context guard, conversation-memory storage, the wrap-up
|
|
2356
|
+
// nudge, and the terminal block below.
|
|
2357
|
+
const engineAdapter = createGeminiLoopAdapter({
|
|
2358
|
+
providerLabel: "GoogleVertex",
|
|
2359
|
+
maxSteps,
|
|
2360
|
+
// Ported verbatim: the same DEFAULT_TOOL_MAX_RETRIES threshold, plus
|
|
2361
|
+
// the two rules this loop has always had and the engine did not.
|
|
2362
|
+
toolFailureBreaker: {
|
|
2363
|
+
maxRetries: DEFAULT_TOOL_MAX_RETRIES,
|
|
2364
|
+
// Strikes are CONSECUTIVE here: a clean result clears the count, so
|
|
2365
|
+
// an argument-dependent soft error cannot accumulate its way to
|
|
2366
|
+
// disabling a tool that works.
|
|
2367
|
+
consecutive: true,
|
|
2368
|
+
// A result that reports failure without throwing — an MCP isError
|
|
2369
|
+
// payload, a proxy-blocked call resolving with { error } — counts
|
|
2370
|
+
// toward the breaker exactly as a throw does.
|
|
2371
|
+
classifyResultFailure: (output) => extractToolFailureText(output) ?? undefined,
|
|
2372
|
+
},
|
|
2373
|
+
liveTools: options.tools ?? {},
|
|
2374
|
+
...(declarations ? { declarations } : {}),
|
|
2375
|
+
...(useFinalResultTool
|
|
2376
|
+
? {
|
|
2377
|
+
finalResultToolName: "final_result",
|
|
2378
|
+
onTerminalResult: (text) => {
|
|
2379
|
+
try {
|
|
2380
|
+
finalResultStructuredOutput = JSON.parse(text);
|
|
2589
2381
|
}
|
|
2590
|
-
|
|
2591
|
-
|
|
2382
|
+
catch {
|
|
2383
|
+
/* the caller's coercion layer repairs a partial payload */
|
|
2592
2384
|
}
|
|
2593
2385
|
},
|
|
2594
|
-
});
|
|
2595
|
-
rawResponseParts.push(...collected.rawResponseParts);
|
|
2596
|
-
stepFunctionCalls.push(...collected.stepFunctionCalls);
|
|
2597
|
-
if (collected.finishReason) {
|
|
2598
|
-
stepFinishReason = collected.finishReason;
|
|
2599
|
-
lastFinishReason = collected.finishReason;
|
|
2600
2386
|
}
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
if (stepFunctionCalls.length === 0 &&
|
|
2613
|
-
!stepText &&
|
|
2614
|
-
stepFinishReason === "MALFORMED_FUNCTION_CALL" &&
|
|
2615
|
-
malformedRetryCount < 1 &&
|
|
2616
|
-
!effectiveSignal.aborted) {
|
|
2617
|
-
malformedRetryCount++;
|
|
2618
|
-
logger.warn(`[GoogleVertex] Model returned MALFORMED_FUNCTION_CALL at step ${step}/${maxSteps}; retrying once with a corrective note.`);
|
|
2619
|
-
this.emitTurnEvent({ phase: "malformed-retry", step, maxSteps });
|
|
2620
|
-
if (rawResponseParts.length > 0) {
|
|
2621
|
-
currentContents.push({
|
|
2622
|
-
role: "model",
|
|
2623
|
-
parts: rawResponseParts,
|
|
2624
|
-
});
|
|
2625
|
-
}
|
|
2626
|
-
currentContents.push({
|
|
2387
|
+
: {}),
|
|
2388
|
+
enableMalformedRetry: true,
|
|
2389
|
+
buildMalformedRetryNote: (conversation, retriedStep) => {
|
|
2390
|
+
this.emitTurnEvent({
|
|
2391
|
+
phase: "malformed-retry",
|
|
2392
|
+
step: retriedStep + 1,
|
|
2393
|
+
maxSteps,
|
|
2394
|
+
});
|
|
2395
|
+
return [
|
|
2396
|
+
...conversation,
|
|
2397
|
+
{
|
|
2627
2398
|
role: "user",
|
|
2628
2399
|
parts: [
|
|
2629
2400
|
{
|
|
@@ -2632,307 +2403,195 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2632
2403
|
"or answer in plain text.",
|
|
2633
2404
|
},
|
|
2634
2405
|
],
|
|
2635
|
-
}
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
// Extract the structured output from final_result arguments
|
|
2648
|
-
finalResultStructuredOutput = finalResultCall.args;
|
|
2649
|
-
logger.debug("[GoogleVertex] Received final_result tool call with structured output (generate)", {
|
|
2650
|
-
outputKeys: Object.keys(finalResultStructuredOutput),
|
|
2651
|
-
});
|
|
2652
|
-
// Return the structured output as JSON text
|
|
2653
|
-
finalText = JSON.stringify(finalResultStructuredOutput);
|
|
2654
|
-
break;
|
|
2406
|
+
},
|
|
2407
|
+
];
|
|
2408
|
+
},
|
|
2409
|
+
// The turn clock's per-chunk ping and the context guard's per-step
|
|
2410
|
+
// prompt size both ride the drain, which is why this loop keeps its
|
|
2411
|
+
// own collector rather than the adapter's default.
|
|
2412
|
+
collectStep: (stream, channel) => collectVertexStreamChunks(stream, channel, {
|
|
2413
|
+
onProgress: () => turnClock.noteProgress(),
|
|
2414
|
+
onUsage: (input, output) => contextGuard.noteUsage(input, output),
|
|
2415
|
+
onUsageDelta: (counter, delta) => {
|
|
2416
|
+
if (counter === "input") {
|
|
2417
|
+
totalInputTokens += delta;
|
|
2655
2418
|
}
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
// maxSteps-exhaustion exit can surface the prose the model produced
|
|
2659
|
-
// instead of a canned placeholder (Bug 1). Mirrors the Vertex-Claude
|
|
2660
|
-
// loop's text accumulation.
|
|
2661
|
-
accumulatedText = appendStepText(accumulatedText, stepText);
|
|
2662
|
-
// Execute function calls
|
|
2663
|
-
logger.debug(`[GoogleVertex] Generate executing ${stepFunctionCalls.length} function calls`);
|
|
2664
|
-
// Add model response with ALL parts (including thoughtSignature) to history
|
|
2665
|
-
// This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
|
|
2666
|
-
currentContents.push({
|
|
2667
|
-
role: "model",
|
|
2668
|
-
parts: rawResponseParts.length > 0
|
|
2669
|
-
? rawResponseParts
|
|
2670
|
-
: stepFunctionCalls.map((fc) => ({
|
|
2671
|
-
functionCall: fc,
|
|
2672
|
-
})),
|
|
2673
|
-
});
|
|
2674
|
-
// Execute each function and collect responses (plus an optional
|
|
2675
|
-
// trailing wrap-up nudge text part).
|
|
2676
|
-
const functionResponses = [];
|
|
2677
|
-
const toolCallsBefore = allToolCalls.length;
|
|
2678
|
-
const toolExecsBefore = toolExecutions.length;
|
|
2679
|
-
// Note: tool:start / tool:end events are emitted by ToolsManager's
|
|
2680
|
-
// wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
|
|
2681
|
-
for (const call of stepFunctionCalls) {
|
|
2682
|
-
// Honor a deadline/stall/caller abort BETWEEN tool executions —
|
|
2683
|
-
// without this check a multi-tool step keeps executing its whole
|
|
2684
|
-
// batch (up to N × toolTimeoutMs past the deadline) before the
|
|
2685
|
-
// while-top check finally breaks.
|
|
2686
|
-
if (effectiveSignal.aborted) {
|
|
2687
|
-
wasAborted = true;
|
|
2688
|
-
break;
|
|
2419
|
+
else if (counter === "output") {
|
|
2420
|
+
totalOutputTokens += delta;
|
|
2689
2421
|
}
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
const failedInfo = failedTools.get(call.name);
|
|
2693
|
-
if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
|
|
2694
|
-
logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
|
|
2695
|
-
const errorOutput = {
|
|
2696
|
-
error: `TOOL_PERMANENTLY_FAILED: The tool "${call.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.`,
|
|
2697
|
-
status: "permanently_failed",
|
|
2698
|
-
do_not_retry: true,
|
|
2699
|
-
};
|
|
2700
|
-
toolExecutions.push({
|
|
2701
|
-
name: call.name,
|
|
2702
|
-
input: call.args,
|
|
2703
|
-
output: errorOutput,
|
|
2704
|
-
});
|
|
2705
|
-
functionResponses.push({
|
|
2706
|
-
functionResponse: {
|
|
2707
|
-
name: call.name,
|
|
2708
|
-
response: errorOutput,
|
|
2709
|
-
},
|
|
2710
|
-
});
|
|
2711
|
-
continue;
|
|
2712
|
-
}
|
|
2713
|
-
let execute = executeMap.get(call.name);
|
|
2714
|
-
if (!execute) {
|
|
2715
|
-
// Snapshot miss — see the stream twin.
|
|
2716
|
-
execute = this.resolveGeminiToolOnMiss(call.name, combinedTools, tools?.[0]?.functionDeclarations, executeMap, failedTools);
|
|
2717
|
-
}
|
|
2718
|
-
if (execute) {
|
|
2719
|
-
try {
|
|
2720
|
-
// AI SDK Tool execute requires (args, options) - provide minimal options
|
|
2721
|
-
const toolOptions = {
|
|
2722
|
-
toolCallId: `${call.name}-${Date.now()}`,
|
|
2723
|
-
messages: [],
|
|
2724
|
-
abortSignal: effectiveSignal,
|
|
2725
|
-
};
|
|
2726
|
-
turnClock.noteProgress();
|
|
2727
|
-
// Bound the execute() await — a wedged tool costs one step
|
|
2728
|
-
// (error tool_result), not the whole turn.
|
|
2729
|
-
const execResult = await withTimeout(raceWithAbort(Promise.resolve(execute(call.args, toolOptions)), effectiveSignal), toolExecTimeoutMs, `Tool "${call.name}" execution timed out after ${toolExecTimeoutMs}ms`);
|
|
2730
|
-
turnClock.noteProgress();
|
|
2731
|
-
// Error-shaped success (MCP isError / { error } payloads —
|
|
2732
|
-
// e.g. proxy-blocked tools) counts toward the breaker too:
|
|
2733
|
-
// these fail without throwing, and only counting throws lets
|
|
2734
|
-
// the model grind on a blocked tool for the whole budget.
|
|
2735
|
-
const resultErrorText = extractToolFailureText(execResult);
|
|
2736
|
-
if (resultErrorText) {
|
|
2737
|
-
const info = failedTools.get(call.name) || {
|
|
2738
|
-
count: 0,
|
|
2739
|
-
lastError: "",
|
|
2740
|
-
};
|
|
2741
|
-
info.count++;
|
|
2742
|
-
info.lastError = resultErrorText;
|
|
2743
|
-
failedTools.set(call.name, info);
|
|
2744
|
-
}
|
|
2745
|
-
else {
|
|
2746
|
-
// Genuinely consecutive: a success clears the strike count
|
|
2747
|
-
// (argument-dependent soft errors — file-not-found on
|
|
2748
|
-
// different paths — must not disable a working tool).
|
|
2749
|
-
failedTools.delete(call.name);
|
|
2750
|
-
}
|
|
2751
|
-
// Track execution
|
|
2752
|
-
toolExecutions.push({
|
|
2753
|
-
name: call.name,
|
|
2754
|
-
input: call.args,
|
|
2755
|
-
output: execResult,
|
|
2756
|
-
});
|
|
2757
|
-
functionResponses.push({
|
|
2758
|
-
functionResponse: {
|
|
2759
|
-
name: call.name,
|
|
2760
|
-
response: { result: execResult },
|
|
2761
|
-
},
|
|
2762
|
-
});
|
|
2763
|
-
}
|
|
2764
|
-
catch (error) {
|
|
2765
|
-
// An abort during tool execution ends the turn gracefully — it
|
|
2766
|
-
// must NOT be recorded as a spurious tool failure. Both checks
|
|
2767
|
-
// matter: effectiveSignal.aborted catches an abort WE triggered
|
|
2768
|
-
// (even if the throw isn't abort-shaped); isAbortError(error)
|
|
2769
|
-
// catches an abort-shaped throw (e.g. a tool raising AbortError)
|
|
2770
|
-
// even when the signal itself never fired.
|
|
2771
|
-
if (effectiveSignal.aborted || isAbortError(error)) {
|
|
2772
|
-
wasAborted = true;
|
|
2773
|
-
break;
|
|
2774
|
-
}
|
|
2775
|
-
turnClock.noteProgress();
|
|
2776
|
-
if (error instanceof TimeoutError) {
|
|
2777
|
-
this.emitTurnEvent({
|
|
2778
|
-
phase: "tool-timeout",
|
|
2779
|
-
step,
|
|
2780
|
-
maxSteps,
|
|
2781
|
-
toolName: call.name,
|
|
2782
|
-
});
|
|
2783
|
-
}
|
|
2784
|
-
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
2785
|
-
// Track this failure
|
|
2786
|
-
const currentFailInfo = failedTools.get(call.name) || {
|
|
2787
|
-
count: 0,
|
|
2788
|
-
lastError: "",
|
|
2789
|
-
};
|
|
2790
|
-
currentFailInfo.count++;
|
|
2791
|
-
currentFailInfo.lastError = errorMessage;
|
|
2792
|
-
failedTools.set(call.name, currentFailInfo);
|
|
2793
|
-
logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
|
|
2794
|
-
// Determine if this is a permanent failure
|
|
2795
|
-
const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
|
|
2796
|
-
const errorOutput = {
|
|
2797
|
-
error: isPermanentFailure
|
|
2798
|
-
? `TOOL_PERMANENTLY_FAILED: The tool "${call.name}" has failed ${currentFailInfo.count} times with error: ${errorMessage}. This tool will not be retried. Please proceed without using this tool or inform the user that this functionality is unavailable.`
|
|
2799
|
-
: `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
|
|
2800
|
-
status: isPermanentFailure ? "permanently_failed" : "failed",
|
|
2801
|
-
do_not_retry: isPermanentFailure,
|
|
2802
|
-
retry_count: currentFailInfo.count,
|
|
2803
|
-
max_retries: DEFAULT_TOOL_MAX_RETRIES,
|
|
2804
|
-
};
|
|
2805
|
-
toolExecutions.push({
|
|
2806
|
-
name: call.name,
|
|
2807
|
-
input: call.args,
|
|
2808
|
-
output: errorOutput,
|
|
2809
|
-
});
|
|
2810
|
-
functionResponses.push({
|
|
2811
|
-
functionResponse: {
|
|
2812
|
-
name: call.name,
|
|
2813
|
-
response: errorOutput,
|
|
2814
|
-
},
|
|
2815
|
-
});
|
|
2816
|
-
}
|
|
2422
|
+
else if (counter === "cacheRead") {
|
|
2423
|
+
totalCacheReadTokens += delta;
|
|
2817
2424
|
}
|
|
2818
2425
|
else {
|
|
2819
|
-
|
|
2820
|
-
const errorOutput = {
|
|
2821
|
-
error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
|
|
2822
|
-
status: "permanently_failed",
|
|
2823
|
-
do_not_retry: true,
|
|
2824
|
-
};
|
|
2825
|
-
toolExecutions.push({
|
|
2826
|
-
name: call.name,
|
|
2827
|
-
input: call.args,
|
|
2828
|
-
output: errorOutput,
|
|
2829
|
-
});
|
|
2830
|
-
functionResponses.push({
|
|
2831
|
-
functionResponse: {
|
|
2832
|
-
name: call.name,
|
|
2833
|
-
response: errorOutput,
|
|
2834
|
-
},
|
|
2835
|
-
});
|
|
2426
|
+
totalReasoningTokens += delta;
|
|
2836
2427
|
}
|
|
2428
|
+
},
|
|
2429
|
+
}),
|
|
2430
|
+
planReclaim: (conversation) => {
|
|
2431
|
+
if (!contextGuard.shouldStop()) {
|
|
2432
|
+
return undefined;
|
|
2837
2433
|
}
|
|
2838
|
-
//
|
|
2839
|
-
//
|
|
2840
|
-
//
|
|
2841
|
-
|
|
2842
|
-
|
|
2434
|
+
// Try to RECLAIM budget and keep going before falling back to the
|
|
2435
|
+
// historic stop-only behaviour. Ending the turn early is safe but
|
|
2436
|
+
// throws away work the model was mid-way through; dropping the
|
|
2437
|
+
// oldest complete tool exchanges usually buys enough room to finish.
|
|
2438
|
+
const working = [...conversation];
|
|
2439
|
+
if (reclaimVertexLoopContext(working, modelName, contextGuard.projectedNextPromptTokens)) {
|
|
2440
|
+
contextGuard.resetAfterReclaim();
|
|
2441
|
+
return { conversation: working };
|
|
2843
2442
|
}
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2443
|
+
hitContextLimit = true;
|
|
2444
|
+
logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
|
|
2445
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
2446
|
+
`>= threshold ${contextGuard.thresholdTokens} — synthesizing a final answer.`);
|
|
2447
|
+
return { stop: true };
|
|
2448
|
+
},
|
|
2449
|
+
buildRequest: (conversation) => ({
|
|
2450
|
+
model: modelName,
|
|
2451
|
+
contents: conversation,
|
|
2452
|
+
config: { ...config, ...(tools ? { tools } : {}) },
|
|
2453
|
+
}),
|
|
2454
|
+
sendStep: async (request, signal) => {
|
|
2455
|
+
turnClock.noteProgress();
|
|
2456
|
+
const built = request;
|
|
2457
|
+
return client.models.generateContentStream({
|
|
2458
|
+
model: built.model,
|
|
2459
|
+
contents: built.contents,
|
|
2460
|
+
config: { ...(built.config ?? {}), abortSignal: signal },
|
|
2461
|
+
});
|
|
2462
|
+
},
|
|
2463
|
+
});
|
|
2464
|
+
// Wrapped rather than configured: these fire once PER STEP, and
|
|
2465
|
+
// buildToolResultMessages is the only hook that runs per step with
|
|
2466
|
+
// exactly that step's results. Reading them off the turn's final result
|
|
2467
|
+
// would batch every step into one late write and lose the per-step
|
|
2468
|
+
// thought signature.
|
|
2469
|
+
const adapter = {
|
|
2470
|
+
...engineAdapter,
|
|
2471
|
+
// Counted HERE, not in buildToolResultMessages: this runs once per
|
|
2472
|
+
// step exactly as the old `step++` at the top of the loop did,
|
|
2473
|
+
// malformed retries included. Counting in the tool-result hook would
|
|
2474
|
+
// skip the final text-only step and quietly report one step fewer in
|
|
2475
|
+
// `stepsUsed`, which is a public field on the result.
|
|
2476
|
+
buildStepRequest: (conversation, step) => {
|
|
2477
|
+
stepsTaken = step + 1;
|
|
2478
|
+
return engineAdapter.buildStepRequest(conversation, step);
|
|
2479
|
+
},
|
|
2480
|
+
buildToolResultMessages: (conversation, stepResult, toolResults, engineStep) => {
|
|
2481
|
+
const next = engineAdapter.buildToolResultMessages(conversation, stepResult, toolResults, engineStep);
|
|
2482
|
+
// Time-budget wrap-up nudge (twin of the Anthropic loops' soft step
|
|
2483
|
+
// nudge): with the turn deadline approaching, tell the model to
|
|
2484
|
+
// consolidate. Rides as a trailing text part on the tool-response
|
|
2485
|
+
// user turn.
|
|
2486
|
+
if (turnClock.shouldNudgeWrapup()) {
|
|
2487
|
+
const last = next[next.length - 1];
|
|
2488
|
+
if (last && Array.isArray(last.parts)) {
|
|
2489
|
+
last.parts.push({
|
|
2490
|
+
text: buildWrapupNudgeText(useFinalResultTool),
|
|
2867
2491
|
});
|
|
2868
|
-
}
|
|
2492
|
+
}
|
|
2869
2493
|
}
|
|
2870
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
//
|
|
2873
|
-
//
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2494
|
+
// Persist this step's tool calls/results into conversation memory.
|
|
2495
|
+
// Without this, tool_call / tool_result rows never reach Redis and
|
|
2496
|
+
// the chat-history UI loses every tool invocation. `thoughtSignature`
|
|
2497
|
+
// rides as a sibling on the first call of the step — Gemini 3 needs
|
|
2498
|
+
// it to match thinking patterns when the conversation is replayed.
|
|
2499
|
+
const stepThoughtSig = extractThoughtSignature(stepResult.raw.rawResponseParts);
|
|
2500
|
+
withTimeout(this.handleToolExecutionStorage(toolResults.map((result, index) => ({
|
|
2501
|
+
toolName: result.name,
|
|
2502
|
+
args: result.args,
|
|
2503
|
+
...(index === 0 && stepThoughtSig
|
|
2504
|
+
? { thoughtSignature: stepThoughtSig }
|
|
2505
|
+
: {}),
|
|
2506
|
+
stepIndex: engineStep + 1,
|
|
2507
|
+
})), toolResults.map((result) => ({
|
|
2508
|
+
toolName: result.name,
|
|
2509
|
+
output: result.output,
|
|
2510
|
+
stepIndex: engineStep + 1,
|
|
2511
|
+
})), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
|
|
2512
|
+
logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
|
|
2513
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2877
2514
|
});
|
|
2878
|
-
}
|
|
2879
|
-
// The @google/genai SDK only accepts "user" and "model" as valid
|
|
2880
|
-
// roles in contents — function/tool responses must use role: "user"
|
|
2881
|
-
// (matching the SDK's automaticFunctionCalling implementation and
|
|
2882
|
-
// the Google AI Studio path). See note in executeNativeGemini3Stream.
|
|
2883
|
-
currentContents.push({
|
|
2884
|
-
role: "user",
|
|
2885
|
-
parts: functionResponses,
|
|
2886
2515
|
});
|
|
2887
2516
|
// Project this step's growth for the context guard: the appended
|
|
2888
2517
|
// tool results ride the next prompt (Gemini reports usage per call,
|
|
2889
2518
|
// but only for content it has already seen).
|
|
2890
2519
|
try {
|
|
2891
|
-
|
|
2520
|
+
const appended = next[next.length - 1];
|
|
2521
|
+
contextGuard.noteAppendedChars(JSON.stringify(appended?.parts ?? []).length);
|
|
2892
2522
|
}
|
|
2893
2523
|
catch {
|
|
2894
2524
|
/* estimation is best-effort — never break the loop */
|
|
2895
2525
|
}
|
|
2526
|
+
return next;
|
|
2527
|
+
},
|
|
2528
|
+
};
|
|
2529
|
+
const { stream: engineStream, resultPromise } = runAgenticLoop(adapter,
|
|
2530
|
+
// A concrete parts array widens to the engine's `unknown[]` on its
|
|
2531
|
+
// own; only the direction back needs an assertion.
|
|
2532
|
+
currentContents, {
|
|
2533
|
+
tools: buildDedupedEngineTools(declarations, options.tools, {
|
|
2534
|
+
toolTimeoutMs: toolExecTimeoutMs,
|
|
2535
|
+
abortSignal: effectiveSignal,
|
|
2536
|
+
onProgress: () => turnClock.noteProgress(),
|
|
2537
|
+
}),
|
|
2538
|
+
abortSignal: effectiveSignal,
|
|
2539
|
+
});
|
|
2540
|
+
// generate() returns one result rather than streaming, so the engine's
|
|
2541
|
+
// chunks are drained and discarded — the answer comes off the turn's
|
|
2542
|
+
// result. The drain still has to happen: leaving the channel unread
|
|
2543
|
+
// would stall the engine once its buffer fills.
|
|
2544
|
+
const pump = (async () => {
|
|
2545
|
+
for await (const chunk of engineStream) {
|
|
2546
|
+
void chunk;
|
|
2896
2547
|
}
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
status: error?.status,
|
|
2913
|
-
});
|
|
2914
|
-
// Best-effort request context for formatProviderError —
|
|
2915
|
-
// this.modelName can be stale when options.model overrides the
|
|
2916
|
-
// instance default.
|
|
2917
|
-
try {
|
|
2918
|
-
if (error && typeof error === "object") {
|
|
2919
|
-
const e = error;
|
|
2920
|
-
e.requestModel = modelName;
|
|
2921
|
-
e.requestRegion = effectiveLocation;
|
|
2922
|
-
}
|
|
2923
|
-
}
|
|
2924
|
-
catch {
|
|
2925
|
-
/* frozen/sealed error — context stays best-effort */
|
|
2926
|
-
}
|
|
2548
|
+
})();
|
|
2549
|
+
let engineResult;
|
|
2550
|
+
try {
|
|
2551
|
+
engineResult = await resultPromise;
|
|
2552
|
+
}
|
|
2553
|
+
catch (error) {
|
|
2554
|
+
await pump.catch(() => { });
|
|
2555
|
+
// A mid-drain abort surfaces as an AbortError. End gracefully into the
|
|
2556
|
+
// terminal block instead of re-throwing — a re-throw would route the
|
|
2557
|
+
// caller's abort into a second unbounded fallback stream().
|
|
2558
|
+
if (effectiveSignal.aborted || isAbortError(error)) {
|
|
2559
|
+
wasAborted = true;
|
|
2560
|
+
}
|
|
2561
|
+
else {
|
|
2562
|
+
logger.error("[GoogleVertex] Native SDK error", error);
|
|
2927
2563
|
throw this.handleProviderError(error);
|
|
2928
2564
|
}
|
|
2929
2565
|
}
|
|
2566
|
+
await pump;
|
|
2567
|
+
if (engineResult) {
|
|
2568
|
+
finalText = engineResult.text;
|
|
2569
|
+
lastFinishReason = engineResult.rawStopReason ?? lastFinishReason;
|
|
2570
|
+
for (const call of engineResult.toolCalls) {
|
|
2571
|
+
allToolCalls.push({ toolName: call.name, args: call.args });
|
|
2572
|
+
}
|
|
2573
|
+
for (const execution of engineResult.toolExecutions) {
|
|
2574
|
+
toolExecutions.push({
|
|
2575
|
+
name: execution.name,
|
|
2576
|
+
input: execution.input,
|
|
2577
|
+
output: execution.output,
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
// Replace in place: `currentContents` is a const the terminal block
|
|
2581
|
+
// and the synth call both read.
|
|
2582
|
+
currentContents.length = 0;
|
|
2583
|
+
currentContents.push(...engineResult.conversation);
|
|
2584
|
+
}
|
|
2585
|
+
if (effectiveSignal.aborted) {
|
|
2586
|
+
wasAborted = true;
|
|
2587
|
+
}
|
|
2930
2588
|
// Handle maxSteps termination / abort — the loop exited because the step
|
|
2931
2589
|
// cap was reached (or the turn was aborted) while the model was still
|
|
2932
2590
|
// calling tools. Surface a real answer instead of the canned placeholder
|
|
2933
2591
|
// (Bug 1) and a meaningful finishReason (Bug 2).
|
|
2934
|
-
if (!finalText &&
|
|
2935
|
-
|
|
2592
|
+
if (!finalText &&
|
|
2593
|
+
(stepsTaken >= maxSteps || wasAborted || hitContextLimit)) {
|
|
2594
|
+
hitStepLimit = stepsTaken >= maxSteps && !wasAborted;
|
|
2936
2595
|
const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
|
|
2937
2596
|
if (accumulatedText) {
|
|
2938
2597
|
// Prefer the prose the model already produced across steps.
|
|
@@ -3015,7 +2674,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3015
2674
|
if (stopReason !== "completed") {
|
|
3016
2675
|
this.emitTurnEvent({
|
|
3017
2676
|
phase: stopReason,
|
|
3018
|
-
step,
|
|
2677
|
+
step: stepsTaken,
|
|
3019
2678
|
maxSteps,
|
|
3020
2679
|
toolCallCount: allToolCalls.filter((tc) => tc.toolName !== "final_result").length,
|
|
3021
2680
|
elapsedMs: turnClock.elapsedMs(),
|
|
@@ -3037,7 +2696,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3037
2696
|
finishReason: resolvedFinishReason,
|
|
3038
2697
|
stopReason,
|
|
3039
2698
|
rawFinishReason: lastFinishReason,
|
|
3040
|
-
stepsUsed:
|
|
2699
|
+
stepsUsed: stepsTaken,
|
|
3041
2700
|
usage: {
|
|
3042
2701
|
input: adjustedInputTokens,
|
|
3043
2702
|
// Thinking tokens are billed at the output rate but Gemini does NOT
|
|
@@ -5545,7 +5204,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
5545
5204
|
if (stopReason !== "completed") {
|
|
5546
5205
|
this.emitTurnEvent({
|
|
5547
5206
|
phase: stopReason,
|
|
5548
|
-
step,
|
|
5207
|
+
step: step,
|
|
5549
5208
|
maxSteps,
|
|
5550
5209
|
toolCallCount: externalToolCalls.length,
|
|
5551
5210
|
elapsedMs: turnClock.elapsedMs(),
|