@juspay/neurolink 11.15.8 → 11.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -371,9 +373,19 @@ const hasGoogleCredentials = () => {
371
373
  process.env.GOOGLE_AUTH_PRIVATE_KEY));
372
374
  };
373
375
  // Create Anthropic-specific Vertex settings for native @anthropic-ai/vertex-sdk
374
- const createVertexAnthropicSettings = async (region, timeoutMs) => {
376
+ const createVertexAnthropicSettings = async (region, timeoutMs, direct, baseURL) => {
375
377
  const location = region || getVertexLocation();
376
- const project = getVertexProjectId();
378
+ // Express-style auth carries its own credentials, so the ADC-derived project
379
+ // is neither available nor needed; asking for it would throw before the
380
+ // request is ever built. It cannot be EMPTY either — the SDK rejects a
381
+ // falsy projectId outright ("No projectId was given and it could not be
382
+ // resolved from credentials") — so a configured project is used when there
383
+ // is one and a placeholder stands in otherwise. The value only ever appears
384
+ // in the request path, which an endpoint reached this way is expected to
385
+ // route on its own.
386
+ const project = direct
387
+ ? direct.projectId?.trim() || "express"
388
+ : getVertexProjectId();
377
389
  return {
378
390
  projectId: project,
379
391
  region: location,
@@ -382,6 +394,26 @@ const createVertexAnthropicSettings = async (region, timeoutMs) => {
382
394
  // bound (a 429 with retry-after: 8549 sleeps 2.4h per retry, invisible
383
395
  // to fallback orchestration). Retries are the orchestrator's job.
384
396
  maxRetries: 0,
397
+ // Outside the express branch too: an endpoint override is about WHERE the
398
+ // request goes, not how it is authenticated, so a caller using ADC against
399
+ // a gateway needs it just as much.
400
+ ...(baseURL ? { baseURL } : {}),
401
+ ...(direct
402
+ ? {
403
+ // The token goes on the request directly. `accessToken` on the SDK's
404
+ // own options looks like it should do this and does not — the client
405
+ // stores it and never reads it for auth, so prepareOptions() still
406
+ // awaits Application Default Credentials and the call fails with a
407
+ // credentials error that names nothing useful. `authClient` is the
408
+ // option the SDK actually consults.
409
+ authClient: {
410
+ getRequestHeaders: async () => ({
411
+ Authorization: `Bearer ${direct.apiKey}`,
412
+ }),
413
+ projectId: null,
414
+ },
415
+ }
416
+ : {}),
385
417
  };
386
418
  };
387
419
  // Helper function to determine if a model is an Anthropic model
@@ -1338,10 +1370,16 @@ export class GoogleVertexProvider extends BaseProvider {
1338
1370
  // Convert Vercel AI SDK tools to @google/genai FunctionDeclarations
1339
1371
  let tools;
1340
1372
  const executeMap = new DedupExecuteMap();
1373
+ let declarations;
1341
1374
  if (options.tools &&
1342
1375
  Object.keys(options.tools).length > 0 &&
1343
1376
  !options.disableTools) {
1344
1377
  const declared = toNativeToolDeclarations(options.tools, "functionDeclarations");
1378
+ // Kept, not discarded: the shared adapter needs originalNameMap to
1379
+ // translate sanitized wire names back, and buildDedupedEngineTools
1380
+ // reads executeMap through it — which is the DedupExecuteMap that makes
1381
+ // an identical repeated call answer from cache instead of re-running.
1382
+ declarations = declared;
1345
1383
  tools = declared.toolsConfig;
1346
1384
  for (const [name, execute] of declared.executeMap) {
1347
1385
  executeMap.set(name, execute);
@@ -1489,12 +1527,8 @@ export class GoogleVertexProvider extends BaseProvider {
1489
1527
  // hook can persist actual tool outputs rather than the placeholder
1490
1528
  // "success" string used by flushPendingToolData's default fallback.
1491
1529
  const toolExecutions = [];
1492
- let step = 0;
1493
1530
  // Track structured output from final_result tool (when using final_result pattern)
1494
1531
  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
1532
  // In-loop context guard: stop calling tools when the accumulated
1499
1533
  // conversation approaches the model's context window instead of stepping
1500
1534
  // into a provider "prompt too long" rejection mid-loop.
@@ -1545,131 +1579,64 @@ export class GoogleVertexProvider extends BaseProvider {
1545
1579
  internalAbort.abort();
1546
1580
  }
1547
1581
  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
1582
  // Step-cap flags declared in the outer scope so the terminal block (also
1552
1583
  // inside the try) and the finishReason mapping (after the finally) can
1553
1584
  // both read them.
1554
1585
  let hitStepLimit = false;
1555
1586
  let synthesizedFinalAnswer = false;
1587
+ // How many steps the ENGINE actually took, reported back from the per-step
1588
+ // hook. The terminal block compares it against maxSteps, and counting hook
1589
+ // invocations instead would drift by the number of malformed retries.
1590
+ let stepsTaken = 0;
1556
1591
  try {
1557
1592
  // 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;
1593
+ // The turn runs on the shared engine. The step cap, tool dispatch, the
1594
+ // failure breaker, per-step usage accumulation, the single malformed
1595
+ // retry and the pre-first-chunk provider retry all live there now; what
1596
+ // stays here is everything the engine has no opinion about — the turn
1597
+ // clock, the context guard, conversation-memory storage, the wrap-up
1598
+ // nudge, and the terminal block below.
1599
+ const engineAdapter = createGeminiLoopAdapter({
1600
+ providerLabel: "GoogleVertex",
1601
+ maxSteps,
1602
+ // Ported verbatim: the same DEFAULT_TOOL_MAX_RETRIES threshold, plus
1603
+ // the two rules this loop has always had and the engine did not.
1604
+ toolFailureBreaker: {
1605
+ maxRetries: DEFAULT_TOOL_MAX_RETRIES,
1606
+ // Strikes are CONSECUTIVE here: a clean result clears the count, so
1607
+ // an argument-dependent soft error cannot accumulate its way to
1608
+ // disabling a tool that works.
1609
+ consecutive: true,
1610
+ // A result that reports failure without throwing — an MCP isError
1611
+ // payload, a proxy-blocked call resolving with { error } — counts
1612
+ // toward the breaker exactly as a throw does.
1613
+ classifyResultFailure: (output) => extractToolFailureText(output) ?? undefined,
1614
+ },
1615
+ liveTools: options.tools ?? {},
1616
+ ...(declarations ? { declarations } : {}),
1617
+ ...(useFinalResultTool
1618
+ ? {
1619
+ finalResultToolName: "final_result",
1620
+ onTerminalResult: (text) => {
1621
+ try {
1622
+ finalResultStructuredOutput = JSON.parse(text);
1635
1623
  }
1636
- else {
1637
- totalReasoningTokens += delta;
1624
+ catch {
1625
+ /* the caller's coercion layer repairs a partial payload */
1638
1626
  }
1639
1627
  },
1640
- });
1641
- rawResponseParts.push(...collected.rawResponseParts);
1642
- stepFunctionCalls.push(...collected.stepFunctionCalls);
1643
- if (collected.finishReason) {
1644
- stepFinishReason = collected.finishReason;
1645
- lastFinishReason = collected.finishReason;
1646
1628
  }
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({
1629
+ : {}),
1630
+ enableMalformedRetry: true,
1631
+ buildMalformedRetryNote: (conversation, retriedStep) => {
1632
+ this.emitTurnEvent({
1633
+ phase: "malformed-retry",
1634
+ step: retriedStep + 1,
1635
+ maxSteps,
1636
+ });
1637
+ return [
1638
+ ...conversation,
1639
+ {
1673
1640
  role: "user",
1674
1641
  parts: [
1675
1642
  {
@@ -1678,310 +1645,206 @@ export class GoogleVertexProvider extends BaseProvider {
1678
1645
  "or answer in plain text.",
1679
1646
  },
1680
1647
  ],
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;
1731
- }
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;
1648
+ },
1649
+ ];
1650
+ },
1651
+ // The turn clock's per-chunk ping and the context guard's per-step
1652
+ // prompt size both ride the drain, which is why this loop keeps its
1653
+ // own collector rather than the adapter's default.
1654
+ collectStep: (stream, channel) => collectVertexStreamChunks(stream, channel, {
1655
+ onProgress: () => turnClock.noteProgress(),
1656
+ onUsage: (input, output) => contextGuard.noteUsage(input, output),
1657
+ onUsageDelta: (counter, delta) => {
1658
+ if (counter === "input") {
1659
+ totalInputTokens += delta;
1759
1660
  }
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);
1661
+ else if (counter === "output") {
1662
+ totalOutputTokens += delta;
1766
1663
  }
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
- }
1664
+ else if (counter === "cacheRead") {
1665
+ totalCacheReadTokens += delta;
1872
1666
  }
1873
1667
  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
- });
1668
+ totalReasoningTokens += delta;
1906
1669
  }
1670
+ },
1671
+ }),
1672
+ planReclaim: (conversation) => {
1673
+ if (!contextGuard.shouldStop()) {
1674
+ return undefined;
1907
1675
  }
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;
1676
+ // Try to RECLAIM budget and keep going before falling back to the
1677
+ // historic stop-only behaviour. Ending the turn early is safe but
1678
+ // throws away work the model was mid-way through; dropping the
1679
+ // oldest complete tool exchanges usually buys enough room to finish.
1680
+ const working = [...conversation];
1681
+ if (reclaimVertexLoopContext(working, modelName, contextGuard.projectedNextPromptTokens)) {
1682
+ contextGuard.resetAfterReclaim();
1683
+ return { conversation: working };
1913
1684
  }
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),
1685
+ hitContextLimit = true;
1686
+ logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
1687
+ `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
1688
+ `>= threshold ${contextGuard.thresholdTokens} — synthesizing a final answer.`);
1689
+ return { stop: true };
1690
+ },
1691
+ buildRequest: (conversation) => ({
1692
+ model: modelName,
1693
+ contents: conversation,
1694
+ config: { ...config, ...(tools ? { tools } : {}) },
1695
+ }),
1696
+ sendStep: async (request, signal) => {
1697
+ turnClock.noteProgress();
1698
+ const built = request;
1699
+ return client.models.generateContentStream({
1700
+ model: built.model,
1701
+ contents: built.contents,
1702
+ config: { ...(built.config ?? {}), abortSignal: signal },
1703
+ });
1704
+ },
1705
+ });
1706
+ // Wrapped rather than configured: these fire once PER STEP, and
1707
+ // buildToolResultMessages is the only hook that runs per step with
1708
+ // exactly that step's results. Reading them off the turn's final result
1709
+ // would batch every step into one late write and lose the per-step
1710
+ // thought signature.
1711
+ const adapter = {
1712
+ ...engineAdapter,
1713
+ // Counted HERE, not in buildToolResultMessages: this runs once per
1714
+ // step exactly as the old `step++` at the top of the loop did,
1715
+ // malformed retries included. Counting in the tool-result hook would
1716
+ // skip the final text-only step and quietly report one step fewer in
1717
+ // `stepsUsed`, which is a public field on the result.
1718
+ buildStepRequest: (conversation, step) => {
1719
+ stepsTaken = step + 1;
1720
+ return engineAdapter.buildStepRequest(conversation, step);
1721
+ },
1722
+ buildToolResultMessages: (conversation, stepResult, toolResults, engineStep) => {
1723
+ const next = engineAdapter.buildToolResultMessages(conversation, stepResult, toolResults, engineStep);
1724
+ // Time-budget wrap-up nudge (twin of the Anthropic loops' soft step
1725
+ // nudge): with the turn deadline approaching, tell the model to
1726
+ // consolidate. Rides as a trailing text part on the tool-response
1727
+ // user turn.
1728
+ if (turnClock.shouldNudgeWrapup()) {
1729
+ const last = next[next.length - 1];
1730
+ if (last && Array.isArray(last.parts)) {
1731
+ last.parts.push({
1732
+ text: buildWrapupNudgeText(useFinalResultTool),
1932
1733
  });
1933
- });
1734
+ }
1934
1735
  }
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),
1736
+ // Persist this step's tool calls/results into conversation memory.
1737
+ // Without this, tool_call / tool_result rows never reach Redis and
1738
+ // the chat-history UI loses every tool invocation. `thoughtSignature`
1739
+ // rides as a sibling on the first call of the step — Gemini 3 needs
1740
+ // it to match thinking patterns when the conversation is replayed.
1741
+ const stepThoughtSig = extractThoughtSignature(stepResult.raw.rawResponseParts);
1742
+ withTimeout(this.handleToolExecutionStorage(toolResults.map((result, index) => ({
1743
+ toolName: result.name,
1744
+ args: result.args,
1745
+ ...(index === 0 && stepThoughtSig
1746
+ ? { thoughtSignature: stepThoughtSig }
1747
+ : {}),
1748
+ stepIndex: engineStep + 1,
1749
+ })), toolResults.map((result) => ({
1750
+ toolName: result.name,
1751
+ output: result.output,
1752
+ stepIndex: engineStep + 1,
1753
+ })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
1754
+ logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
1755
+ error: error instanceof Error ? error.message : String(error),
1942
1756
  });
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
1757
  });
1954
1758
  // Project this step's growth for the context guard: the appended
1955
1759
  // tool results ride the next prompt (Gemini reports usage per call,
1956
1760
  // but only for content it has already seen).
1957
1761
  try {
1958
- contextGuard.noteAppendedChars(JSON.stringify(functionResponses).length);
1762
+ const appended = next[next.length - 1];
1763
+ contextGuard.noteAppendedChars(JSON.stringify(appended?.parts ?? []).length);
1959
1764
  }
1960
1765
  catch {
1961
1766
  /* estimation is best-effort — never break the loop */
1962
1767
  }
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;
1768
+ return next;
1769
+ },
1770
+ };
1771
+ const { stream: engineStream, resultPromise } = runAgenticLoop(adapter,
1772
+ // A concrete parts array widens to the engine's `unknown[]` on its
1773
+ // own; only the direction back needs an assertion.
1774
+ currentContents, {
1775
+ tools: buildDedupedEngineTools(declarations, options.tools, {
1776
+ toolTimeoutMs: toolExecTimeoutMs,
1777
+ abortSignal: effectiveSignal,
1778
+ onProgress: () => turnClock.noteProgress(),
1779
+ }),
1780
+ abortSignal: effectiveSignal,
1781
+ });
1782
+ // Collected, NOT forwarded to the consumer here. This loop replays the
1783
+ // gathered text after the turn rather than streaming it live, and a
1784
+ // characterization case pins exactly that — pushing to the consumer
1785
+ // channel from inside the pump would make the turn stream live and break
1786
+ // it.
1787
+ const pump = (async () => {
1788
+ for await (const chunk of engineStream) {
1789
+ if (chunk.content) {
1790
+ incrementalTextChunks.push(chunk.content);
1974
1791
  }
1975
- logger.error("[GoogleVertex] Native SDK error", error);
1976
- throw this.handleProviderError(error);
1977
1792
  }
1793
+ })();
1794
+ let engineResult;
1795
+ let turnFailure;
1796
+ try {
1797
+ engineResult = await resultPromise;
1798
+ }
1799
+ catch (error) {
1800
+ turnFailure = error;
1801
+ }
1802
+ // Drained unconditionally and tolerantly. When the turn ends by abort the
1803
+ // channel rejects too, and re-awaiting a settled rejection here would
1804
+ // rethrow the very error the branch below has already decided to absorb —
1805
+ // which is what turned both turn-clock cases into failures instead of
1806
+ // clean deadline exits.
1807
+ await pump.catch(() => { });
1808
+ if (turnFailure !== undefined) {
1809
+ // A mid-drain abort surfaces as an AbortError. End gracefully into the
1810
+ // terminal block instead of re-throwing — a re-throw would route the
1811
+ // caller's abort into a second unbounded fallback stream().
1812
+ if (effectiveSignal.aborted || isAbortError(turnFailure)) {
1813
+ wasAborted = true;
1814
+ }
1815
+ else {
1816
+ logger.error("[GoogleVertex] Native SDK error", turnFailure);
1817
+ throw this.handleProviderError(turnFailure);
1818
+ }
1819
+ }
1820
+ if (engineResult) {
1821
+ finalText = engineResult.text;
1822
+ lastFinishReason = engineResult.rawStopReason ?? lastFinishReason;
1823
+ for (const call of engineResult.toolCalls) {
1824
+ allToolCalls.push({ toolName: call.name, args: call.args });
1825
+ }
1826
+ for (const execution of engineResult.toolExecutions) {
1827
+ toolExecutions.push({
1828
+ name: execution.name,
1829
+ input: execution.input,
1830
+ output: execution.output,
1831
+ });
1832
+ }
1833
+ // Replace in place: `currentContents` is a const the terminal block
1834
+ // and the synth call both read.
1835
+ currentContents.length = 0;
1836
+ currentContents.push(...engineResult.conversation);
1837
+ }
1838
+ if (effectiveSignal.aborted) {
1839
+ wasAborted = true;
1978
1840
  }
1979
1841
  // Handle maxSteps termination / abort — the loop exited because the step
1980
1842
  // cap was reached (or the turn was aborted) while the model was still
1981
1843
  // calling tools. Surface a real answer instead of the canned placeholder
1982
1844
  // (Bug 1) and a meaningful finishReason (Bug 2).
1983
- if (!finalText && (step >= maxSteps || wasAborted || hitContextLimit)) {
1984
- hitStepLimit = step >= maxSteps && !wasAborted;
1845
+ if (!finalText &&
1846
+ (stepsTaken >= maxSteps || wasAborted || hitContextLimit)) {
1847
+ hitStepLimit = stepsTaken >= maxSteps && !wasAborted;
1985
1848
  const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
1986
1849
  // The consumer receives text via `incrementalTextChunks`; any text the
1987
1850
  // model emitted across steps is already preserved there. Only produce a
@@ -2067,7 +1930,7 @@ export class GoogleVertexProvider extends BaseProvider {
2067
1930
  if (stopReason !== "completed") {
2068
1931
  this.emitTurnEvent({
2069
1932
  phase: stopReason,
2070
- step,
1933
+ step: stepsTaken,
2071
1934
  maxSteps,
2072
1935
  toolCallCount: allToolCalls.filter((tc) => tc.toolName !== "final_result").length,
2073
1936
  elapsedMs: turnClock.elapsedMs(),
@@ -2146,7 +2009,7 @@ export class GoogleVertexProvider extends BaseProvider {
2146
2009
  totalToolExecutions: externalToolCalls.length,
2147
2010
  stopReason,
2148
2011
  rawFinishReason: lastFinishReason,
2149
- stepsUsed: step,
2012
+ stepsUsed: stepsTaken,
2150
2013
  },
2151
2014
  };
2152
2015
  // Add structured output if final_result tool was used
@@ -2311,8 +2174,12 @@ export class GoogleVertexProvider extends BaseProvider {
2311
2174
  // Convert Vercel AI SDK tools to @google/genai FunctionDeclarations
2312
2175
  let tools;
2313
2176
  const executeMap = new DedupExecuteMap();
2177
+ let declarations;
2314
2178
  if (Object.keys(combinedTools).length > 0) {
2315
2179
  const declared = toNativeToolDeclarations(combinedTools, "functionDeclarations");
2180
+ // Kept for the shared adapter: originalNameMap for name translation,
2181
+ // executeMap for the per-turn dedup wrapper.
2182
+ declarations = declared;
2316
2183
  tools = declared.toolsConfig;
2317
2184
  for (const [name, execute] of declared.executeMap) {
2318
2185
  executeMap.set(name, execute);
@@ -2456,12 +2323,8 @@ export class GoogleVertexProvider extends BaseProvider {
2456
2323
  let lastFinishReason;
2457
2324
  const allToolCalls = [];
2458
2325
  const toolExecutions = [];
2459
- let step = 0;
2460
2326
  // Track structured output from final_result tool (when using final_result pattern)
2461
2327
  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
2328
  // In-loop context guard: stop calling tools when the accumulated
2466
2329
  // conversation approaches the model's context window instead of stepping
2467
2330
  // into a provider "prompt too long" rejection mid-loop.
@@ -2505,125 +2368,63 @@ export class GoogleVertexProvider extends BaseProvider {
2505
2368
  internalAbort.abort();
2506
2369
  }
2507
2370
  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
2371
  // Step-cap flags declared in the outer scope so the terminal block (also
2512
2372
  // inside the try) and the finishReason mapping (after the finally) can
2513
2373
  // both read them.
2514
2374
  let hitStepLimit = false;
2515
2375
  let synthesizedFinalAnswer = false;
2376
+ // Steps the ENGINE took, reported back from the per-step hook; counting
2377
+ // hook invocations would drift by the number of malformed retries.
2378
+ let stepsTaken = 0;
2516
2379
  try {
2517
2380
  // 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;
2381
+ // The turn runs on the shared engine. The step cap, tool dispatch, the
2382
+ // failure breaker, per-step usage accumulation, the single malformed
2383
+ // retry and the pre-first-chunk provider retry all live there now; what
2384
+ // stays here is everything the engine has no opinion about — the turn
2385
+ // clock, the context guard, conversation-memory storage, the wrap-up
2386
+ // nudge, and the terminal block below.
2387
+ const engineAdapter = createGeminiLoopAdapter({
2388
+ providerLabel: "GoogleVertex",
2389
+ maxSteps,
2390
+ // Ported verbatim: the same DEFAULT_TOOL_MAX_RETRIES threshold, plus
2391
+ // the two rules this loop has always had and the engine did not.
2392
+ toolFailureBreaker: {
2393
+ maxRetries: DEFAULT_TOOL_MAX_RETRIES,
2394
+ // Strikes are CONSECUTIVE here: a clean result clears the count, so
2395
+ // an argument-dependent soft error cannot accumulate its way to
2396
+ // disabling a tool that works.
2397
+ consecutive: true,
2398
+ // A result that reports failure without throwing — an MCP isError
2399
+ // payload, a proxy-blocked call resolving with { error } — counts
2400
+ // toward the breaker exactly as a throw does.
2401
+ classifyResultFailure: (output) => extractToolFailureText(output) ?? undefined,
2402
+ },
2403
+ liveTools: options.tools ?? {},
2404
+ ...(declarations ? { declarations } : {}),
2405
+ ...(useFinalResultTool
2406
+ ? {
2407
+ finalResultToolName: "final_result",
2408
+ onTerminalResult: (text) => {
2409
+ try {
2410
+ finalResultStructuredOutput = JSON.parse(text);
2589
2411
  }
2590
- else {
2591
- totalReasoningTokens += delta;
2412
+ catch {
2413
+ /* the caller's coercion layer repairs a partial payload */
2592
2414
  }
2593
2415
  },
2594
- });
2595
- rawResponseParts.push(...collected.rawResponseParts);
2596
- stepFunctionCalls.push(...collected.stepFunctionCalls);
2597
- if (collected.finishReason) {
2598
- stepFinishReason = collected.finishReason;
2599
- lastFinishReason = collected.finishReason;
2600
2416
  }
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({
2417
+ : {}),
2418
+ enableMalformedRetry: true,
2419
+ buildMalformedRetryNote: (conversation, retriedStep) => {
2420
+ this.emitTurnEvent({
2421
+ phase: "malformed-retry",
2422
+ step: retriedStep + 1,
2423
+ maxSteps,
2424
+ });
2425
+ return [
2426
+ ...conversation,
2427
+ {
2627
2428
  role: "user",
2628
2429
  parts: [
2629
2430
  {
@@ -2632,307 +2433,195 @@ export class GoogleVertexProvider extends BaseProvider {
2632
2433
  "or answer in plain text.",
2633
2434
  },
2634
2435
  ],
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;
2655
- }
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;
2689
- }
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;
2436
+ },
2437
+ ];
2438
+ },
2439
+ // The turn clock's per-chunk ping and the context guard's per-step
2440
+ // prompt size both ride the drain, which is why this loop keeps its
2441
+ // own collector rather than the adapter's default.
2442
+ collectStep: (stream, channel) => collectVertexStreamChunks(stream, channel, {
2443
+ onProgress: () => turnClock.noteProgress(),
2444
+ onUsage: (input, output) => contextGuard.noteUsage(input, output),
2445
+ onUsageDelta: (counter, delta) => {
2446
+ if (counter === "input") {
2447
+ totalInputTokens += delta;
2712
2448
  }
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);
2449
+ else if (counter === "output") {
2450
+ totalOutputTokens += delta;
2717
2451
  }
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
- }
2452
+ else if (counter === "cacheRead") {
2453
+ totalCacheReadTokens += delta;
2817
2454
  }
2818
2455
  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
- });
2456
+ totalReasoningTokens += delta;
2836
2457
  }
2458
+ },
2459
+ }),
2460
+ planReclaim: (conversation) => {
2461
+ if (!contextGuard.shouldStop()) {
2462
+ return undefined;
2837
2463
  }
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;
2464
+ // Try to RECLAIM budget and keep going before falling back to the
2465
+ // historic stop-only behaviour. Ending the turn early is safe but
2466
+ // throws away work the model was mid-way through; dropping the
2467
+ // oldest complete tool exchanges usually buys enough room to finish.
2468
+ const working = [...conversation];
2469
+ if (reclaimVertexLoopContext(working, modelName, contextGuard.projectedNextPromptTokens)) {
2470
+ contextGuard.resetAfterReclaim();
2471
+ return { conversation: working };
2843
2472
  }
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),
2473
+ hitContextLimit = true;
2474
+ logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
2475
+ `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
2476
+ `>= threshold ${contextGuard.thresholdTokens} synthesizing a final answer.`);
2477
+ return { stop: true };
2478
+ },
2479
+ buildRequest: (conversation) => ({
2480
+ model: modelName,
2481
+ contents: conversation,
2482
+ config: { ...config, ...(tools ? { tools } : {}) },
2483
+ }),
2484
+ sendStep: async (request, signal) => {
2485
+ turnClock.noteProgress();
2486
+ const built = request;
2487
+ return client.models.generateContentStream({
2488
+ model: built.model,
2489
+ contents: built.contents,
2490
+ config: { ...(built.config ?? {}), abortSignal: signal },
2491
+ });
2492
+ },
2493
+ });
2494
+ // Wrapped rather than configured: these fire once PER STEP, and
2495
+ // buildToolResultMessages is the only hook that runs per step with
2496
+ // exactly that step's results. Reading them off the turn's final result
2497
+ // would batch every step into one late write and lose the per-step
2498
+ // thought signature.
2499
+ const adapter = {
2500
+ ...engineAdapter,
2501
+ // Counted HERE, not in buildToolResultMessages: this runs once per
2502
+ // step exactly as the old `step++` at the top of the loop did,
2503
+ // malformed retries included. Counting in the tool-result hook would
2504
+ // skip the final text-only step and quietly report one step fewer in
2505
+ // `stepsUsed`, which is a public field on the result.
2506
+ buildStepRequest: (conversation, step) => {
2507
+ stepsTaken = step + 1;
2508
+ return engineAdapter.buildStepRequest(conversation, step);
2509
+ },
2510
+ buildToolResultMessages: (conversation, stepResult, toolResults, engineStep) => {
2511
+ const next = engineAdapter.buildToolResultMessages(conversation, stepResult, toolResults, engineStep);
2512
+ // Time-budget wrap-up nudge (twin of the Anthropic loops' soft step
2513
+ // nudge): with the turn deadline approaching, tell the model to
2514
+ // consolidate. Rides as a trailing text part on the tool-response
2515
+ // user turn.
2516
+ if (turnClock.shouldNudgeWrapup()) {
2517
+ const last = next[next.length - 1];
2518
+ if (last && Array.isArray(last.parts)) {
2519
+ last.parts.push({
2520
+ text: buildWrapupNudgeText(useFinalResultTool),
2867
2521
  });
2868
- });
2522
+ }
2869
2523
  }
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),
2524
+ // Persist this step's tool calls/results into conversation memory.
2525
+ // Without this, tool_call / tool_result rows never reach Redis and
2526
+ // the chat-history UI loses every tool invocation. `thoughtSignature`
2527
+ // rides as a sibling on the first call of the step — Gemini 3 needs
2528
+ // it to match thinking patterns when the conversation is replayed.
2529
+ const stepThoughtSig = extractThoughtSignature(stepResult.raw.rawResponseParts);
2530
+ withTimeout(this.handleToolExecutionStorage(toolResults.map((result, index) => ({
2531
+ toolName: result.name,
2532
+ args: result.args,
2533
+ ...(index === 0 && stepThoughtSig
2534
+ ? { thoughtSignature: stepThoughtSig }
2535
+ : {}),
2536
+ stepIndex: engineStep + 1,
2537
+ })), toolResults.map((result) => ({
2538
+ toolName: result.name,
2539
+ output: result.output,
2540
+ stepIndex: engineStep + 1,
2541
+ })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
2542
+ logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
2543
+ error: error instanceof Error ? error.message : String(error),
2877
2544
  });
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
2545
  });
2887
2546
  // Project this step's growth for the context guard: the appended
2888
2547
  // tool results ride the next prompt (Gemini reports usage per call,
2889
2548
  // but only for content it has already seen).
2890
2549
  try {
2891
- contextGuard.noteAppendedChars(JSON.stringify(functionResponses).length);
2550
+ const appended = next[next.length - 1];
2551
+ contextGuard.noteAppendedChars(JSON.stringify(appended?.parts ?? []).length);
2892
2552
  }
2893
2553
  catch {
2894
2554
  /* estimation is best-effort — never break the loop */
2895
2555
  }
2556
+ return next;
2557
+ },
2558
+ };
2559
+ const { stream: engineStream, resultPromise } = runAgenticLoop(adapter,
2560
+ // A concrete parts array widens to the engine's `unknown[]` on its
2561
+ // own; only the direction back needs an assertion.
2562
+ currentContents, {
2563
+ tools: buildDedupedEngineTools(declarations, options.tools, {
2564
+ toolTimeoutMs: toolExecTimeoutMs,
2565
+ abortSignal: effectiveSignal,
2566
+ onProgress: () => turnClock.noteProgress(),
2567
+ }),
2568
+ abortSignal: effectiveSignal,
2569
+ });
2570
+ // generate() returns one result rather than streaming, so the engine's
2571
+ // chunks are drained and discarded — the answer comes off the turn's
2572
+ // result. The drain still has to happen: leaving the channel unread
2573
+ // would stall the engine once its buffer fills.
2574
+ const pump = (async () => {
2575
+ for await (const chunk of engineStream) {
2576
+ void chunk;
2896
2577
  }
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
- }
2578
+ })();
2579
+ let engineResult;
2580
+ try {
2581
+ engineResult = await resultPromise;
2582
+ }
2583
+ catch (error) {
2584
+ await pump.catch(() => { });
2585
+ // A mid-drain abort surfaces as an AbortError. End gracefully into the
2586
+ // terminal block instead of re-throwing — a re-throw would route the
2587
+ // caller's abort into a second unbounded fallback stream().
2588
+ if (effectiveSignal.aborted || isAbortError(error)) {
2589
+ wasAborted = true;
2590
+ }
2591
+ else {
2592
+ logger.error("[GoogleVertex] Native SDK error", error);
2927
2593
  throw this.handleProviderError(error);
2928
2594
  }
2929
2595
  }
2596
+ await pump;
2597
+ if (engineResult) {
2598
+ finalText = engineResult.text;
2599
+ lastFinishReason = engineResult.rawStopReason ?? lastFinishReason;
2600
+ for (const call of engineResult.toolCalls) {
2601
+ allToolCalls.push({ toolName: call.name, args: call.args });
2602
+ }
2603
+ for (const execution of engineResult.toolExecutions) {
2604
+ toolExecutions.push({
2605
+ name: execution.name,
2606
+ input: execution.input,
2607
+ output: execution.output,
2608
+ });
2609
+ }
2610
+ // Replace in place: `currentContents` is a const the terminal block
2611
+ // and the synth call both read.
2612
+ currentContents.length = 0;
2613
+ currentContents.push(...engineResult.conversation);
2614
+ }
2615
+ if (effectiveSignal.aborted) {
2616
+ wasAborted = true;
2617
+ }
2930
2618
  // Handle maxSteps termination / abort — the loop exited because the step
2931
2619
  // cap was reached (or the turn was aborted) while the model was still
2932
2620
  // calling tools. Surface a real answer instead of the canned placeholder
2933
2621
  // (Bug 1) and a meaningful finishReason (Bug 2).
2934
- if (!finalText && (step >= maxSteps || wasAborted || hitContextLimit)) {
2935
- hitStepLimit = step >= maxSteps && !wasAborted;
2622
+ if (!finalText &&
2623
+ (stepsTaken >= maxSteps || wasAborted || hitContextLimit)) {
2624
+ hitStepLimit = stepsTaken >= maxSteps && !wasAborted;
2936
2625
  const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
2937
2626
  if (accumulatedText) {
2938
2627
  // Prefer the prose the model already produced across steps.
@@ -3015,7 +2704,7 @@ export class GoogleVertexProvider extends BaseProvider {
3015
2704
  if (stopReason !== "completed") {
3016
2705
  this.emitTurnEvent({
3017
2706
  phase: stopReason,
3018
- step,
2707
+ step: stepsTaken,
3019
2708
  maxSteps,
3020
2709
  toolCallCount: allToolCalls.filter((tc) => tc.toolName !== "final_result").length,
3021
2710
  elapsedMs: turnClock.elapsedMs(),
@@ -3037,7 +2726,7 @@ export class GoogleVertexProvider extends BaseProvider {
3037
2726
  finishReason: resolvedFinishReason,
3038
2727
  stopReason,
3039
2728
  rawFinishReason: lastFinishReason,
3040
- stepsUsed: step,
2729
+ stepsUsed: stepsTaken,
3041
2730
  usage: {
3042
2731
  input: adjustedInputTokens,
3043
2732
  // Thinking tokens are billed at the output rate but Gemini does NOT
@@ -3185,7 +2874,20 @@ export class GoogleVertexProvider extends BaseProvider {
3185
2874
  */
3186
2875
  async createAnthropicVertexClient(timeoutMs) {
3187
2876
  const mod = await getAnthropicVertexModule();
3188
- const settings = await createVertexAnthropicSettings(this.location, timeoutMs);
2877
+ const expressApiKey = this.resolveExpressApiKey();
2878
+ const directBaseURL = this.baseURL?.trim() || process.env.GOOGLE_VERTEX_BASE_URL?.trim();
2879
+ const settings = await createVertexAnthropicSettings(this.location, timeoutMs, expressApiKey
2880
+ ? {
2881
+ apiKey: expressApiKey,
2882
+ ...(this.projectId ? { projectId: this.projectId } : {}),
2883
+ }
2884
+ : undefined, directBaseURL);
2885
+ // One assertion, at the one place the shapes genuinely differ. The SDK
2886
+ // declares `authClient` as its full `AuthClient` (24-plus members) while
2887
+ // `prepareOptions()` only ever calls `getRequestHeaders()` and reads
2888
+ // `projectId`. Naming the narrow surface in our own type and widening it
2889
+ // here is the honest version of that gap; the alternative is standing up a
2890
+ // real AuthClient to satisfy a contract the SDK does not exercise.
3189
2891
  const client = new mod.AnthropicVertex(settings);
3190
2892
  // The vertex SDK eagerly starts Google ADC resolution in its constructor
3191
2893
  // (`this._authClientPromise = this._auth.getClient()`) and only awaits it
@@ -5545,7 +5247,7 @@ export class GoogleVertexProvider extends BaseProvider {
5545
5247
  if (stopReason !== "completed") {
5546
5248
  this.emitTurnEvent({
5547
5249
  phase: stopReason,
5548
- step,
5250
+ step: step,
5549
5251
  maxSteps,
5550
5252
  toolCallCount: externalToolCalls.length,
5551
5253
  elapsedMs: turnClock.elapsedMs(),