@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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +358 -358
- package/dist/core/geminiLoopAdapter.js +35 -12
- package/dist/core/loopEngine.js +172 -109
- package/dist/providers/anthropic/client.js +2 -2
- package/dist/providers/googleAiStudio/client.js +18 -8
- package/dist/providers/googleNativeGemini3/utils.d.ts +3 -3
- package/dist/providers/googleNativeGemini3/utils.js +47 -10
- package/dist/providers/googleVertex/client.js +493 -791
- package/dist/types/loopEngine.d.ts +108 -5
- package/dist/types/providers.d.ts +28 -0
- package/package.json +3 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
//
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
// this step's request instead of leaving them invisible until the
|
|
1589
|
-
// next turn (TOOL_NOT_FOUND).
|
|
1590
|
-
this.refreshGeminiToolDeclarations(options.tools, tools?.[0]?.functionDeclarations, executeMap, failedTools);
|
|
1591
|
-
logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
|
|
1592
|
-
try {
|
|
1593
|
-
const stream = await client.models.generateContentStream({
|
|
1594
|
-
model: modelName,
|
|
1595
|
-
contents: currentContents,
|
|
1596
|
-
config: { ...config, abortSignal: effectiveSignal },
|
|
1597
|
-
});
|
|
1598
|
-
const stepFunctionCalls = [];
|
|
1599
|
-
// Capture raw response parts including thoughtSignature
|
|
1600
|
-
const rawResponseParts = [];
|
|
1601
|
-
// This step's own finish reason (vs the cross-step
|
|
1602
|
-
// lastFinishReason) — drives the single MALFORMED_FUNCTION_CALL
|
|
1603
|
-
// retry below.
|
|
1604
|
-
let stepFinishReason;
|
|
1605
|
-
// Per-step usage trackers for WRITE-THROUGH accumulation: within a
|
|
1606
|
-
// step's chunk stream the counts are latest-wins (promptTokenCount
|
|
1607
|
-
// arrives once in the final chunk; candidates/thoughts counts are
|
|
1608
|
-
// cumulative across chunks), so each chunk folds only the DELTA
|
|
1609
|
-
// over this step's previous value into the turn totals. The totals
|
|
1610
|
-
// are therefore correct at every point mid-drain — a step killed
|
|
1611
|
-
// mid-stream (abort / turn deadline / stall watchdog) still counts
|
|
1612
|
-
// the billed tokens it already reported.
|
|
1613
|
-
// The drain now lives in collectVertexStreamChunks. The hooks below
|
|
1614
|
-
// are the two couplings it had to this loop, plus the per-chunk
|
|
1615
|
-
// usage deltas — those must stay per-chunk so a step killed
|
|
1616
|
-
// mid-stream still bills what it reported.
|
|
1617
|
-
const collected = await collectVertexStreamChunks(stream, {
|
|
1618
|
-
push: (chunk) => {
|
|
1619
|
-
if (chunk.content) {
|
|
1620
|
-
incrementalTextChunks.push(chunk.content);
|
|
1621
|
-
}
|
|
1622
|
-
},
|
|
1623
|
-
}, {
|
|
1624
|
-
onProgress: () => turnClock.noteProgress(),
|
|
1625
|
-
onUsage: (input, output) => contextGuard.noteUsage(input, output),
|
|
1626
|
-
onUsageDelta: (counter, delta) => {
|
|
1627
|
-
if (counter === "input") {
|
|
1628
|
-
totalInputTokens += delta;
|
|
1629
|
-
}
|
|
1630
|
-
else if (counter === "output") {
|
|
1631
|
-
totalOutputTokens += delta;
|
|
1632
|
-
}
|
|
1633
|
-
else if (counter === "cacheRead") {
|
|
1634
|
-
totalCacheReadTokens += delta;
|
|
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
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
if (stepFunctionCalls.length === 0 &&
|
|
1659
|
-
!stepText &&
|
|
1660
|
-
stepFinishReason === "MALFORMED_FUNCTION_CALL" &&
|
|
1661
|
-
malformedRetryCount < 1 &&
|
|
1662
|
-
!effectiveSignal.aborted) {
|
|
1663
|
-
malformedRetryCount++;
|
|
1664
|
-
logger.warn(`[GoogleVertex] Model returned MALFORMED_FUNCTION_CALL at step ${step}/${maxSteps}; retrying once with a corrective note.`);
|
|
1665
|
-
this.emitTurnEvent({ phase: "malformed-retry", step, maxSteps });
|
|
1666
|
-
if (rawResponseParts.length > 0) {
|
|
1667
|
-
currentContents.push({
|
|
1668
|
-
role: "model",
|
|
1669
|
-
parts: rawResponseParts,
|
|
1670
|
-
});
|
|
1671
|
-
}
|
|
1672
|
-
currentContents.push({
|
|
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
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
// Extract the structured output from final_result arguments
|
|
1694
|
-
finalResultStructuredOutput = finalResultCall.args;
|
|
1695
|
-
logger.debug("[GoogleVertex] Received final_result tool call with structured output (stream)", {
|
|
1696
|
-
outputKeys: Object.keys(finalResultStructuredOutput),
|
|
1697
|
-
});
|
|
1698
|
-
// Return the structured output as JSON text
|
|
1699
|
-
finalText = JSON.stringify(finalResultStructuredOutput);
|
|
1700
|
-
break;
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
// Execute function calls
|
|
1704
|
-
logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
|
|
1705
|
-
// Add model response with ALL parts (including thoughtSignature) to history
|
|
1706
|
-
// This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
|
|
1707
|
-
currentContents.push({
|
|
1708
|
-
role: "model",
|
|
1709
|
-
parts: rawResponseParts.length > 0
|
|
1710
|
-
? rawResponseParts
|
|
1711
|
-
: stepFunctionCalls.map((fc) => ({
|
|
1712
|
-
functionCall: fc,
|
|
1713
|
-
})),
|
|
1714
|
-
});
|
|
1715
|
-
// Execute each function and collect responses (plus an optional
|
|
1716
|
-
// trailing wrap-up nudge text part).
|
|
1717
|
-
const functionResponses = [];
|
|
1718
|
-
// Per-step bookkeeping for conversation-memory storage.
|
|
1719
|
-
const stepStorageCalls = [];
|
|
1720
|
-
const stepStorageResults = [];
|
|
1721
|
-
// Note: tool:start / tool:end events are emitted by ToolsManager's
|
|
1722
|
-
// wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
|
|
1723
|
-
for (const call of stepFunctionCalls) {
|
|
1724
|
-
// Honor a deadline/stall/caller abort BETWEEN tool executions —
|
|
1725
|
-
// without this check a multi-tool step keeps executing its whole
|
|
1726
|
-
// batch (up to N × toolTimeoutMs past the deadline) before the
|
|
1727
|
-
// while-top check finally breaks.
|
|
1728
|
-
if (effectiveSignal.aborted) {
|
|
1729
|
-
wasAborted = true;
|
|
1730
|
-
break;
|
|
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
|
-
|
|
1761
|
-
|
|
1762
|
-
// Snapshot miss: the tool may have been hydrated into the live
|
|
1763
|
-
// record by search_tools within this very step batch, or the
|
|
1764
|
-
// model called a deferred catalog tool directly by name.
|
|
1765
|
-
execute = this.resolveGeminiToolOnMiss(call.name, options.tools, tools?.[0]?.functionDeclarations, executeMap, failedTools);
|
|
1661
|
+
else if (counter === "output") {
|
|
1662
|
+
totalOutputTokens += delta;
|
|
1766
1663
|
}
|
|
1767
|
-
if (
|
|
1768
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
//
|
|
1911
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
-
//
|
|
1936
|
-
//
|
|
1937
|
-
//
|
|
1938
|
-
//
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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 &&
|
|
1984
|
-
|
|
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:
|
|
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
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
//
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
|
|
2549
|
-
try {
|
|
2550
|
-
// Use generateContentStream and collect all chunks (same as GoogleAIStudio)
|
|
2551
|
-
const stream = await client.models.generateContentStream({
|
|
2552
|
-
model: modelName,
|
|
2553
|
-
contents: currentContents,
|
|
2554
|
-
config: { ...config, abortSignal: effectiveSignal },
|
|
2555
|
-
});
|
|
2556
|
-
const stepFunctionCalls = [];
|
|
2557
|
-
// Capture raw response parts including thoughtSignature
|
|
2558
|
-
const rawResponseParts = [];
|
|
2559
|
-
// This step's own finish reason (vs the cross-step
|
|
2560
|
-
// lastFinishReason) — drives the single MALFORMED_FUNCTION_CALL
|
|
2561
|
-
// retry below.
|
|
2562
|
-
let stepFinishReason;
|
|
2563
|
-
// Per-step usage trackers for WRITE-THROUGH accumulation: within a
|
|
2564
|
-
// step's chunk stream the counts are latest-wins (promptTokenCount
|
|
2565
|
-
// arrives once in the final chunk; candidates/thoughts counts are
|
|
2566
|
-
// cumulative across chunks), so each chunk folds only the DELTA
|
|
2567
|
-
// over this step's previous value into the turn totals. The totals
|
|
2568
|
-
// are therefore correct at every point mid-drain — a step killed
|
|
2569
|
-
// mid-stream (abort / turn deadline / stall watchdog) still counts
|
|
2570
|
-
// the billed tokens it already reported.
|
|
2571
|
-
// Same collector as the streaming twin. generate() returns one
|
|
2572
|
-
// result rather than streaming, so the channel is a no-op — the
|
|
2573
|
-
// stream loop uses it to fill incrementalTextChunks for replay, and
|
|
2574
|
-
// there is nothing to replay here. Everything else is identical,
|
|
2575
|
-
// including the per-chunk usage deltas that keep the turn totals
|
|
2576
|
-
// correct for a step killed mid-stream.
|
|
2577
|
-
const collected = await collectVertexStreamChunks(stream, { push: () => { } }, {
|
|
2578
|
-
onProgress: () => turnClock.noteProgress(),
|
|
2579
|
-
onUsage: (input, output) => contextGuard.noteUsage(input, output),
|
|
2580
|
-
onUsageDelta: (counter, delta) => {
|
|
2581
|
-
if (counter === "input") {
|
|
2582
|
-
totalInputTokens += delta;
|
|
2583
|
-
}
|
|
2584
|
-
else if (counter === "output") {
|
|
2585
|
-
totalOutputTokens += delta;
|
|
2586
|
-
}
|
|
2587
|
-
else if (counter === "cacheRead") {
|
|
2588
|
-
totalCacheReadTokens += delta;
|
|
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
|
-
|
|
2591
|
-
|
|
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
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
if (stepFunctionCalls.length === 0 &&
|
|
2613
|
-
!stepText &&
|
|
2614
|
-
stepFinishReason === "MALFORMED_FUNCTION_CALL" &&
|
|
2615
|
-
malformedRetryCount < 1 &&
|
|
2616
|
-
!effectiveSignal.aborted) {
|
|
2617
|
-
malformedRetryCount++;
|
|
2618
|
-
logger.warn(`[GoogleVertex] Model returned MALFORMED_FUNCTION_CALL at step ${step}/${maxSteps}; retrying once with a corrective note.`);
|
|
2619
|
-
this.emitTurnEvent({ phase: "malformed-retry", step, maxSteps });
|
|
2620
|
-
if (rawResponseParts.length > 0) {
|
|
2621
|
-
currentContents.push({
|
|
2622
|
-
role: "model",
|
|
2623
|
-
parts: rawResponseParts,
|
|
2624
|
-
});
|
|
2625
|
-
}
|
|
2626
|
-
currentContents.push({
|
|
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
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
// Extract the structured output from final_result arguments
|
|
2648
|
-
finalResultStructuredOutput = finalResultCall.args;
|
|
2649
|
-
logger.debug("[GoogleVertex] Received final_result tool call with structured output (generate)", {
|
|
2650
|
-
outputKeys: Object.keys(finalResultStructuredOutput),
|
|
2651
|
-
});
|
|
2652
|
-
// Return the structured output as JSON text
|
|
2653
|
-
finalText = JSON.stringify(finalResultStructuredOutput);
|
|
2654
|
-
break;
|
|
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
|
-
|
|
2714
|
-
|
|
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 (
|
|
2719
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2839
|
-
//
|
|
2840
|
-
//
|
|
2841
|
-
|
|
2842
|
-
|
|
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
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
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
|
-
//
|
|
2871
|
-
//
|
|
2872
|
-
//
|
|
2873
|
-
//
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
status: error?.status,
|
|
2913
|
-
});
|
|
2914
|
-
// Best-effort request context for formatProviderError —
|
|
2915
|
-
// this.modelName can be stale when options.model overrides the
|
|
2916
|
-
// instance default.
|
|
2917
|
-
try {
|
|
2918
|
-
if (error && typeof error === "object") {
|
|
2919
|
-
const e = error;
|
|
2920
|
-
e.requestModel = modelName;
|
|
2921
|
-
e.requestRegion = effectiveLocation;
|
|
2922
|
-
}
|
|
2923
|
-
}
|
|
2924
|
-
catch {
|
|
2925
|
-
/* frozen/sealed error — context stays best-effort */
|
|
2926
|
-
}
|
|
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 &&
|
|
2935
|
-
|
|
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:
|
|
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
|
|
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(),
|