@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.
@@ -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
- while (step < maxSteps) {
1559
- if (effectiveSignal.aborted) {
1560
- wasAborted = true;
1561
- break;
1562
- }
1563
- // Context guard: stop the tool loop before the accumulated
1564
- // conversation crosses the window threshold — synthesize from what
1565
- // we have instead of stepping into a provider rejection.
1566
- if (contextGuard.shouldStop()) {
1567
- // Parity upgrade: try to RECLAIM budget and keep going before
1568
- // falling back to the historic stop-only behaviour. Ending the turn
1569
- // early is safe but throws away work the model was mid-way through;
1570
- // dropping the oldest complete tool exchanges usually buys enough
1571
- // room to finish. Only when reclaiming changes nothing do we stop.
1572
- const reclaimed = reclaimVertexLoopContext(currentContents, modelName, contextGuard.projectedNextPromptTokens);
1573
- if (reclaimed) {
1574
- contextGuard.resetAfterReclaim();
1575
- }
1576
- else {
1577
- hitContextLimit = true;
1578
- logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
1579
- `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
1580
- `>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
1581
- break;
1582
- }
1583
- }
1584
- step++;
1585
- turnClock.noteProgress();
1586
- // Mid-turn discovery sync: search_tools may have hydrated new tools
1587
- // into the live record during the previous step — advertise them in
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
- else {
1637
- totalReasoningTokens += delta;
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
- // Extract text from raw parts after stream completes
1648
- // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
1649
- const stepText = rawResponseParts
1650
- .filter((part) => typeof part.text === "string")
1651
- .map((part) => part.text)
1652
- .join("");
1653
- // MALFORMED_FUNCTION_CALL is usually a transient formatting failure
1654
- // (the model emitted an unparseable call): retry the step ONCE with
1655
- // a corrective note instead of hard-ending the turn with empty
1656
- // content — automated alert-RCA turns were dying at step 2-4 on
1657
- // this, mislabeled as step-cap exits.
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
- continue;
1683
- }
1684
- // If no function calls, we're done
1685
- if (stepFunctionCalls.length === 0) {
1686
- finalText = stepText;
1687
- break;
1688
- }
1689
- // Check for final_result tool call - this is our structured output pattern
1690
- if (useFinalResultTool) {
1691
- const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
1692
- if (finalResultCall) {
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
- allToolCalls.push({ toolName: call.name, args: call.args });
1733
- stepStorageCalls.push({ toolName: call.name, args: call.args });
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
- let execute = executeMap.get(call.name);
1761
- if (!execute) {
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
- // Tool not found is a permanent error. Count it toward the
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
- // An abort inside the tool-exec loop only breaks that inner for-loop.
1909
- // Break the while too so no further model call is issued and control
1910
- // reaches the terminal step-cap handling below.
1911
- if (wasAborted) {
1912
- break;
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
- // Persist this step's tool calls/results into conversation memory.
1915
- // Without this, tool_call / tool_result rows never reach Redis and
1916
- // the chat-history UI loses every tool invocation.
1917
- //
1918
- // `thoughtSignature` rides as a sibling on the first call of the
1919
- // step — Gemini 3 needs it to match thinking patterns when the
1920
- // conversation is replayed on the next turn.
1921
- if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
1922
- const stepThoughtSig = extractThoughtSignature(rawResponseParts);
1923
- withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c, i) => ({
1924
- ...c,
1925
- ...(i === 0 && stepThoughtSig
1926
- ? { thoughtSignature: stepThoughtSig }
1927
- : {}),
1928
- stepIndex: step,
1929
- })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
1930
- logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
1931
- error: error instanceof Error ? error.message : String(error),
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
- // Time-budget wrap-up nudge (twin of the Anthropic loops' soft
1936
- // step nudge): with the turn deadline approaching, tell the model
1937
- // to consolidate. Rides as a trailing text part on the
1938
- // tool-response user turn.
1939
- if (turnClock.shouldNudgeWrapup()) {
1940
- functionResponses.push({
1941
- text: buildWrapupNudgeText(useFinalResultTool),
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
- contextGuard.noteAppendedChars(JSON.stringify(functionResponses).length);
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
- catch (error) {
1965
- // A mid-drain abort surfaces as an AbortError from the `for await`.
1966
- // Break gracefully into the terminal block instead of re-throwing
1967
- // (a re-throw would route the caller's abort into a second unbounded
1968
- // fallback stream()). Dual check as with the inner catch:
1969
- // effectiveSignal.aborted (a signal we tripped) OR isAbortError(error)
1970
- // (an abort-shaped throw) — either means "stop", not a real failure.
1971
- if (effectiveSignal.aborted || isAbortError(error)) {
1972
- wasAborted = true;
1973
- break;
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 && (step >= maxSteps || wasAborted || hitContextLimit)) {
1984
- hitStepLimit = step >= maxSteps && !wasAborted;
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: step,
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
- while (step < maxSteps) {
2519
- if (effectiveSignal.aborted) {
2520
- wasAborted = true;
2521
- break;
2522
- }
2523
- // Context guard: stop the tool loop before the accumulated
2524
- // conversation crosses the window threshold — synthesize from what
2525
- // we have instead of stepping into a provider rejection.
2526
- if (contextGuard.shouldStop()) {
2527
- // Parity upgrade: try to RECLAIM budget and keep going before
2528
- // falling back to the historic stop-only behaviour. Ending the turn
2529
- // early is safe but throws away work the model was mid-way through;
2530
- // dropping the oldest complete tool exchanges usually buys enough
2531
- // room to finish. Only when reclaiming changes nothing do we stop.
2532
- const reclaimed = reclaimVertexLoopContext(currentContents, modelName, contextGuard.projectedNextPromptTokens);
2533
- if (reclaimed) {
2534
- contextGuard.resetAfterReclaim();
2535
- }
2536
- else {
2537
- hitContextLimit = true;
2538
- logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
2539
- `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
2540
- `>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
2541
- break;
2542
- }
2543
- }
2544
- step++;
2545
- turnClock.noteProgress();
2546
- // Mid-turn discovery sync — see the stream twin.
2547
- this.refreshGeminiToolDeclarations(combinedTools, tools?.[0]?.functionDeclarations, executeMap, failedTools);
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
- else {
2591
- totalReasoningTokens += delta;
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
- // Extract text from raw parts after stream completes
2602
- // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
2603
- const stepText = rawResponseParts
2604
- .filter((part) => typeof part.text === "string")
2605
- .map((part) => part.text)
2606
- .join("");
2607
- // MALFORMED_FUNCTION_CALL is usually a transient formatting failure
2608
- // (the model emitted an unparseable call): retry the step ONCE with
2609
- // a corrective note instead of hard-ending the turn with empty
2610
- // content — automated alert-RCA turns were dying at step 2-4 on
2611
- // this, mislabeled as step-cap exits.
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
- continue;
2637
- }
2638
- // If no function calls, we're done
2639
- if (stepFunctionCalls.length === 0) {
2640
- finalText = stepText;
2641
- break;
2642
- }
2643
- // Check for final_result tool call - this is our structured output pattern
2644
- if (useFinalResultTool) {
2645
- const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
2646
- if (finalResultCall) {
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
- // Accumulate non-empty step text across steps so the
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
- allToolCalls.push({ toolName: call.name, args: call.args });
2691
- // Check if this tool has already exceeded retry limit
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
- // Tool not found is a permanent error
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
- // An abort inside the tool-exec loop only breaks that inner for-loop.
2839
- // Break the while too so no further model call is issued and control
2840
- // reaches the terminal step-cap handling below.
2841
- if (wasAborted) {
2842
- break;
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
- // Persist this step's tool calls/results into conversation memory.
2845
- // Without this, tool_call / tool_result rows never reach Redis and
2846
- // the chat-history UI loses every tool invocation. The first call
2847
- // of the step carries the step's `thoughtSignature` so Gemini 3 can
2848
- // match thinking patterns on replay.
2849
- const stepToolCalls = allToolCalls.slice(toolCallsBefore);
2850
- const stepToolExecs = toolExecutions.slice(toolExecsBefore);
2851
- if (stepToolCalls.length > 0 || stepToolExecs.length > 0) {
2852
- const stepThoughtSig = extractThoughtSignature(rawResponseParts);
2853
- withTimeout(this.handleToolExecutionStorage(stepToolCalls.map((tc, i) => ({
2854
- toolName: tc.toolName,
2855
- args: tc.args,
2856
- ...(i === 0 && stepThoughtSig
2857
- ? { thoughtSignature: stepThoughtSig }
2858
- : {}),
2859
- stepIndex: step,
2860
- })), stepToolExecs.map((te) => ({
2861
- toolName: te.name,
2862
- output: te.output,
2863
- stepIndex: step,
2864
- })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
2865
- logger.warn("[GoogleVertex] Failed to store native Gemini generate tool executions", {
2866
- error: error instanceof Error ? error.message : String(error),
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
- // Time-budget wrap-up nudge (twin of the Anthropic loops' soft
2871
- // step nudge): with the turn deadline approaching, tell the model
2872
- // to consolidate. Rides as a trailing text part on the
2873
- // tool-response user turn.
2874
- if (turnClock.shouldNudgeWrapup()) {
2875
- functionResponses.push({
2876
- text: buildWrapupNudgeText(useFinalResultTool),
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
- contextGuard.noteAppendedChars(JSON.stringify(functionResponses).length);
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
- catch (error) {
2898
- // A mid-drain abort surfaces as an AbortError from the `for await`.
2899
- // Break gracefully into the terminal block instead of re-throwing
2900
- // (a re-throw would route the caller's abort into a second unbounded
2901
- // fallback generate()). Dual check as with the inner catch:
2902
- // effectiveSignal.aborted (a signal we tripped) OR isAbortError(error)
2903
- // (an abort-shaped throw) either means "stop", not a real failure.
2904
- if (effectiveSignal.aborted || isAbortError(error)) {
2905
- wasAborted = true;
2906
- break;
2907
- }
2908
- logger.error("[GoogleVertex] Native SDK generate error", {
2909
- error,
2910
- model: modelName,
2911
- location: effectiveLocation,
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 && (step >= maxSteps || wasAborted || hitContextLimit)) {
2935
- hitStepLimit = step >= maxSteps && !wasAborted;
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: step,
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(),