@juspay/neurolink 9.80.1 → 9.80.2

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.
@@ -5,7 +5,7 @@ import path from "path";
5
5
  import os from "os";
6
6
  import { ErrorCategory, ErrorSeverity, } from "../constants/enums.js";
7
7
  import { BaseProvider } from "../core/baseProvider.js";
8
- import { DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, IMAGE_GENERATION_MODELS, TOOL_STORAGE_TIMEOUT_MS, } from "../core/constants.js";
8
+ import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, IMAGE_GENERATION_MODELS, TOOL_STORAGE_TIMEOUT_MS, } from "../core/constants.js";
9
9
  import { ModelConfigurationManager } from "../core/modelConfiguration.js";
10
10
  import { createProxyFetch } from "../proxy/proxyFetch.js";
11
11
  import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
@@ -22,7 +22,7 @@ import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } fr
22
22
  import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
23
23
  import { TimeoutError, withTimeout } from "../utils/async/index.js";
24
24
  import { parseTimeout } from "../utils/timeout.js";
25
- import { appendStepText, createTextChannel, extractThoughtSignature, mapGeminiFinishReason, prependConversationMessages, } from "./googleNativeGemini3.js";
25
+ import { appendStepText, buildToolLoopCapMessage, createTextChannel, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, } from "./googleNativeGemini3.js";
26
26
  import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../telemetry/index.js";
27
27
  import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
28
28
  import { calculateCost } from "../utils/pricing.js";
@@ -1321,177 +1321,238 @@ export class GoogleVertexProvider extends BaseProvider {
1321
1321
  // yielding it once breaks that signal even though the underlying
1322
1322
  // network stream is genuinely incremental.
1323
1323
  const incrementalTextChunks = [];
1324
- // Agentic loop for tool calling
1325
- while (step < maxSteps) {
1326
- step++;
1327
- logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
1328
- try {
1329
- const stream = await client.models.generateContentStream({
1330
- model: modelName,
1331
- contents: currentContents,
1332
- config,
1333
- });
1334
- const stepFunctionCalls = [];
1335
- // Capture raw response parts including thoughtSignature
1336
- const rawResponseParts = [];
1337
- for await (const chunk of stream) {
1338
- // Extract raw parts from candidates FIRST
1339
- // This avoids using chunk.text which triggers SDK warning when
1340
- // non-text parts (thoughtSignature, functionCall) are present
1341
- const chunkRecord = chunk;
1342
- const candidates = chunkRecord.candidates;
1343
- const firstCandidate = candidates?.[0];
1344
- // Capture the SDK finish reason (Bug 2: previously dropped). Last
1345
- // non-empty value across chunks wins.
1346
- const chunkFinishReason = firstCandidate?.finishReason;
1347
- if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1348
- lastFinishReason = chunkFinishReason;
1349
- }
1350
- const chunkContent = firstCandidate?.content;
1351
- if (chunkContent && Array.isArray(chunkContent.parts)) {
1352
- for (const part of chunkContent.parts) {
1353
- rawResponseParts.push(part);
1354
- if (typeof part.text === "string" && part.text.length > 0) {
1355
- incrementalTextChunks.push(part.text);
1324
+ // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
1325
+ // Gemini SDK cancels via config.abortSignal, so drive an internal
1326
+ // AbortController: the caller's signal and a defensive wall-clock timer
1327
+ // both trip it, and every request/tool-exec receives effectiveSignal.
1328
+ const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
1329
+ const internalAbort = new AbortController();
1330
+ const onCallerAbort = () => internalAbort.abort();
1331
+ options.abortSignal?.addEventListener("abort", onCallerAbort);
1332
+ const defensiveTimer = setTimeout(() => {
1333
+ logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
1334
+ internalAbort.abort();
1335
+ }, streamTimeoutMs);
1336
+ const effectiveSignal = internalAbort.signal;
1337
+ if (options.abortSignal?.aborted) {
1338
+ internalAbort.abort();
1339
+ }
1340
+ let wasAborted = false;
1341
+ // Step-cap flags declared in the outer scope so the terminal block (also
1342
+ // inside the try) and the finishReason mapping (after the finally) can
1343
+ // both read them.
1344
+ let hitStepLimit = false;
1345
+ let synthesizedFinalAnswer = false;
1346
+ try {
1347
+ // Agentic loop for tool calling
1348
+ while (step < maxSteps) {
1349
+ if (effectiveSignal.aborted) {
1350
+ wasAborted = true;
1351
+ break;
1352
+ }
1353
+ step++;
1354
+ logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
1355
+ try {
1356
+ const stream = await client.models.generateContentStream({
1357
+ model: modelName,
1358
+ contents: currentContents,
1359
+ config: { ...config, abortSignal: effectiveSignal },
1360
+ });
1361
+ const stepFunctionCalls = [];
1362
+ // Capture raw response parts including thoughtSignature
1363
+ const rawResponseParts = [];
1364
+ for await (const chunk of stream) {
1365
+ // Extract raw parts from candidates FIRST
1366
+ // This avoids using chunk.text which triggers SDK warning when
1367
+ // non-text parts (thoughtSignature, functionCall) are present
1368
+ const chunkRecord = chunk;
1369
+ const candidates = chunkRecord.candidates;
1370
+ const firstCandidate = candidates?.[0];
1371
+ // Capture the SDK finish reason (Bug 2: previously dropped). Last
1372
+ // non-empty value across chunks wins.
1373
+ const chunkFinishReason = firstCandidate?.finishReason;
1374
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1375
+ lastFinishReason = chunkFinishReason;
1376
+ }
1377
+ const chunkContent = firstCandidate?.content;
1378
+ if (chunkContent && Array.isArray(chunkContent.parts)) {
1379
+ for (const part of chunkContent.parts) {
1380
+ rawResponseParts.push(part);
1381
+ if (typeof part.text === "string" && part.text.length > 0) {
1382
+ incrementalTextChunks.push(part.text);
1383
+ }
1356
1384
  }
1357
1385
  }
1358
- }
1359
- if (chunk.functionCalls) {
1360
- stepFunctionCalls.push(...chunk.functionCalls);
1361
- }
1362
- // Extract usage metadata from chunk
1363
- // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1364
- const usageMetadata = chunkRecord.usageMetadata;
1365
- if (usageMetadata) {
1366
- // Take the latest promptTokenCount (usually only in final chunk)
1367
- if (usageMetadata.promptTokenCount !== undefined &&
1368
- usageMetadata.promptTokenCount > 0) {
1369
- totalInputTokens = usageMetadata.promptTokenCount;
1386
+ if (chunk.functionCalls) {
1387
+ stepFunctionCalls.push(...chunk.functionCalls);
1370
1388
  }
1371
- // Take the latest candidatesTokenCount (accumulates through chunks)
1372
- if (usageMetadata.candidatesTokenCount !== undefined &&
1373
- usageMetadata.candidatesTokenCount > 0) {
1374
- totalOutputTokens = usageMetadata.candidatesTokenCount;
1389
+ // Extract usage metadata from chunk
1390
+ // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1391
+ const usageMetadata = chunkRecord.usageMetadata;
1392
+ if (usageMetadata) {
1393
+ // Take the latest promptTokenCount (usually only in final chunk)
1394
+ if (usageMetadata.promptTokenCount !== undefined &&
1395
+ usageMetadata.promptTokenCount > 0) {
1396
+ totalInputTokens = usageMetadata.promptTokenCount;
1397
+ }
1398
+ // Take the latest candidatesTokenCount (accumulates through chunks)
1399
+ if (usageMetadata.candidatesTokenCount !== undefined &&
1400
+ usageMetadata.candidatesTokenCount > 0) {
1401
+ totalOutputTokens = usageMetadata.candidatesTokenCount;
1402
+ }
1375
1403
  }
1376
1404
  }
1377
- }
1378
- // Extract text from raw parts after stream completes
1379
- // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
1380
- const stepText = rawResponseParts
1381
- .filter((part) => typeof part.text === "string")
1382
- .map((part) => part.text)
1383
- .join("");
1384
- // If no function calls, we're done
1385
- if (stepFunctionCalls.length === 0) {
1386
- finalText = stepText;
1387
- break;
1388
- }
1389
- // Check for final_result tool call - this is our structured output pattern
1390
- if (useFinalResultTool) {
1391
- const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
1392
- if (finalResultCall) {
1393
- // Extract the structured output from final_result arguments
1394
- finalResultStructuredOutput = finalResultCall.args;
1395
- logger.debug("[GoogleVertex] Received final_result tool call with structured output (stream)", {
1396
- outputKeys: Object.keys(finalResultStructuredOutput),
1397
- });
1398
- // Return the structured output as JSON text
1399
- finalText = JSON.stringify(finalResultStructuredOutput);
1405
+ // Extract text from raw parts after stream completes
1406
+ // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
1407
+ const stepText = rawResponseParts
1408
+ .filter((part) => typeof part.text === "string")
1409
+ .map((part) => part.text)
1410
+ .join("");
1411
+ // If no function calls, we're done
1412
+ if (stepFunctionCalls.length === 0) {
1413
+ finalText = stepText;
1400
1414
  break;
1401
1415
  }
1402
- }
1403
- // Execute function calls
1404
- logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
1405
- // Add model response with ALL parts (including thoughtSignature) to history
1406
- // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
1407
- currentContents.push({
1408
- role: "model",
1409
- parts: rawResponseParts.length > 0
1410
- ? rawResponseParts
1411
- : stepFunctionCalls.map((fc) => ({
1412
- functionCall: fc,
1413
- })),
1414
- });
1415
- // Execute each function and collect responses
1416
- const functionResponses = [];
1417
- // Per-step bookkeeping for conversation-memory storage.
1418
- const stepStorageCalls = [];
1419
- const stepStorageResults = [];
1420
- // Note: tool:start / tool:end events are emitted by ToolsManager's
1421
- // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
1422
- for (const call of stepFunctionCalls) {
1423
- allToolCalls.push({ toolName: call.name, args: call.args });
1424
- stepStorageCalls.push({ toolName: call.name, args: call.args });
1425
- // Check if this tool has already exceeded retry limit
1426
- const failedInfo = failedTools.get(call.name);
1427
- if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
1428
- logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
1429
- const errorPayload = {
1430
- 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.`,
1431
- status: "permanently_failed",
1432
- do_not_retry: true,
1433
- };
1434
- functionResponses.push({
1435
- functionResponse: {
1436
- name: call.name,
1437
- response: errorPayload,
1438
- },
1439
- });
1440
- toolExecutions.push({
1441
- name: call.name,
1442
- input: call.args,
1443
- output: errorPayload,
1444
- });
1445
- stepStorageResults.push({
1446
- toolName: call.name,
1447
- output: errorPayload,
1448
- });
1449
- continue;
1416
+ // Check for final_result tool call - this is our structured output pattern
1417
+ if (useFinalResultTool) {
1418
+ const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
1419
+ if (finalResultCall) {
1420
+ // Extract the structured output from final_result arguments
1421
+ finalResultStructuredOutput = finalResultCall.args;
1422
+ logger.debug("[GoogleVertex] Received final_result tool call with structured output (stream)", {
1423
+ outputKeys: Object.keys(finalResultStructuredOutput),
1424
+ });
1425
+ // Return the structured output as JSON text
1426
+ finalText = JSON.stringify(finalResultStructuredOutput);
1427
+ break;
1428
+ }
1450
1429
  }
1451
- const execute = executeMap.get(call.name);
1452
- if (execute) {
1453
- try {
1454
- // AI SDK Tool execute requires (args, options) - provide minimal options
1455
- const toolOptions = {
1456
- toolCallId: `${call.name}-${Date.now()}`,
1457
- messages: [],
1458
- abortSignal: undefined,
1430
+ // Execute function calls
1431
+ logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
1432
+ // Add model response with ALL parts (including thoughtSignature) to history
1433
+ // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
1434
+ currentContents.push({
1435
+ role: "model",
1436
+ parts: rawResponseParts.length > 0
1437
+ ? rawResponseParts
1438
+ : stepFunctionCalls.map((fc) => ({
1439
+ functionCall: fc,
1440
+ })),
1441
+ });
1442
+ // Execute each function and collect responses
1443
+ const functionResponses = [];
1444
+ // Per-step bookkeeping for conversation-memory storage.
1445
+ const stepStorageCalls = [];
1446
+ const stepStorageResults = [];
1447
+ // Note: tool:start / tool:end events are emitted by ToolsManager's
1448
+ // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
1449
+ for (const call of stepFunctionCalls) {
1450
+ allToolCalls.push({ toolName: call.name, args: call.args });
1451
+ stepStorageCalls.push({ toolName: call.name, args: call.args });
1452
+ // Check if this tool has already exceeded retry limit
1453
+ const failedInfo = failedTools.get(call.name);
1454
+ if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
1455
+ logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
1456
+ const errorPayload = {
1457
+ 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.`,
1458
+ status: "permanently_failed",
1459
+ do_not_retry: true,
1459
1460
  };
1460
- const result = await execute(call.args, toolOptions);
1461
+ functionResponses.push({
1462
+ functionResponse: {
1463
+ name: call.name,
1464
+ response: errorPayload,
1465
+ },
1466
+ });
1461
1467
  toolExecutions.push({
1462
1468
  name: call.name,
1463
1469
  input: call.args,
1464
- output: result,
1465
- });
1466
- functionResponses.push({
1467
- functionResponse: { name: call.name, response: { result } },
1470
+ output: errorPayload,
1468
1471
  });
1469
1472
  stepStorageResults.push({
1470
1473
  toolName: call.name,
1471
- output: result,
1474
+ output: errorPayload,
1472
1475
  });
1476
+ continue;
1473
1477
  }
1474
- catch (error) {
1475
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
1476
- // Track this failure
1477
- const currentFailInfo = failedTools.get(call.name) || {
1478
- count: 0,
1479
- lastError: "",
1480
- };
1481
- currentFailInfo.count++;
1482
- currentFailInfo.lastError = errorMessage;
1483
- failedTools.set(call.name, currentFailInfo);
1484
- logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
1485
- // Determine if this is a permanent failure
1486
- const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
1478
+ const execute = executeMap.get(call.name);
1479
+ if (execute) {
1480
+ try {
1481
+ // AI SDK Tool execute requires (args, options) - provide minimal options
1482
+ const toolOptions = {
1483
+ toolCallId: `${call.name}-${Date.now()}`,
1484
+ messages: [],
1485
+ abortSignal: effectiveSignal,
1486
+ };
1487
+ const result = await execute(call.args, toolOptions);
1488
+ toolExecutions.push({
1489
+ name: call.name,
1490
+ input: call.args,
1491
+ output: result,
1492
+ });
1493
+ functionResponses.push({
1494
+ functionResponse: { name: call.name, response: { result } },
1495
+ });
1496
+ stepStorageResults.push({
1497
+ toolName: call.name,
1498
+ output: result,
1499
+ });
1500
+ }
1501
+ catch (error) {
1502
+ // An abort during tool execution ends the turn gracefully — it
1503
+ // must NOT be recorded as a spurious tool failure. Both checks
1504
+ // matter: effectiveSignal.aborted catches an abort WE triggered
1505
+ // (even if the throw isn't abort-shaped); isAbortError(error)
1506
+ // catches an abort-shaped throw (e.g. a tool raising AbortError)
1507
+ // even when the signal itself never fired.
1508
+ if (effectiveSignal.aborted || isAbortError(error)) {
1509
+ wasAborted = true;
1510
+ break;
1511
+ }
1512
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1513
+ // Track this failure
1514
+ const currentFailInfo = failedTools.get(call.name) || {
1515
+ count: 0,
1516
+ lastError: "",
1517
+ };
1518
+ currentFailInfo.count++;
1519
+ currentFailInfo.lastError = errorMessage;
1520
+ failedTools.set(call.name, currentFailInfo);
1521
+ logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
1522
+ // Determine if this is a permanent failure
1523
+ const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
1524
+ const errorPayload = {
1525
+ error: isPermanentFailure
1526
+ ? `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.`
1527
+ : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
1528
+ status: isPermanentFailure ? "permanently_failed" : "failed",
1529
+ do_not_retry: isPermanentFailure,
1530
+ retry_count: currentFailInfo.count,
1531
+ max_retries: DEFAULT_TOOL_MAX_RETRIES,
1532
+ };
1533
+ functionResponses.push({
1534
+ functionResponse: {
1535
+ name: call.name,
1536
+ response: errorPayload,
1537
+ },
1538
+ });
1539
+ toolExecutions.push({
1540
+ name: call.name,
1541
+ input: call.args,
1542
+ output: errorPayload,
1543
+ });
1544
+ stepStorageResults.push({
1545
+ toolName: call.name,
1546
+ output: errorPayload,
1547
+ });
1548
+ }
1549
+ }
1550
+ else {
1551
+ // Tool not found is a permanent error
1487
1552
  const errorPayload = {
1488
- error: isPermanentFailure
1489
- ? `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.`
1490
- : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
1491
- status: isPermanentFailure ? "permanently_failed" : "failed",
1492
- do_not_retry: isPermanentFailure,
1493
- retry_count: currentFailInfo.count,
1494
- max_retries: DEFAULT_TOOL_MAX_RETRIES,
1553
+ error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
1554
+ status: "permanently_failed",
1555
+ do_not_retry: true,
1495
1556
  };
1496
1557
  functionResponses.push({
1497
1558
  functionResponse: {
@@ -1510,102 +1571,109 @@ export class GoogleVertexProvider extends BaseProvider {
1510
1571
  });
1511
1572
  }
1512
1573
  }
1513
- else {
1514
- // Tool not found is a permanent error
1515
- const errorPayload = {
1516
- error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
1517
- status: "permanently_failed",
1518
- do_not_retry: true,
1519
- };
1520
- functionResponses.push({
1521
- functionResponse: {
1522
- name: call.name,
1523
- response: errorPayload,
1524
- },
1525
- });
1526
- toolExecutions.push({
1527
- name: call.name,
1528
- input: call.args,
1529
- output: errorPayload,
1530
- });
1531
- stepStorageResults.push({
1532
- toolName: call.name,
1533
- output: errorPayload,
1534
- });
1574
+ // An abort inside the tool-exec loop only breaks that inner for-loop.
1575
+ // Break the while too so no further model call is issued and control
1576
+ // reaches the terminal step-cap handling below.
1577
+ if (wasAborted) {
1578
+ break;
1535
1579
  }
1536
- }
1537
- // Persist this step's tool calls/results into conversation memory.
1538
- // Without this, tool_call / tool_result rows never reach Redis and
1539
- // the chat-history UI loses every tool invocation.
1540
- //
1541
- // `thoughtSignature` rides as a sibling on the first call of the
1542
- // step Gemini 3 needs it to match thinking patterns when the
1543
- // conversation is replayed on the next turn.
1544
- if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
1545
- const stepThoughtSig = extractThoughtSignature(rawResponseParts);
1546
- withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c, i) => ({
1547
- ...c,
1548
- ...(i === 0 && stepThoughtSig
1549
- ? { thoughtSignature: stepThoughtSig }
1550
- : {}),
1551
- stepIndex: step,
1552
- })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
1553
- logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
1554
- error: error instanceof Error ? error.message : String(error),
1580
+ // Persist this step's tool calls/results into conversation memory.
1581
+ // Without this, tool_call / tool_result rows never reach Redis and
1582
+ // the chat-history UI loses every tool invocation.
1583
+ //
1584
+ // `thoughtSignature` rides as a sibling on the first call of the
1585
+ // step Gemini 3 needs it to match thinking patterns when the
1586
+ // conversation is replayed on the next turn.
1587
+ if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
1588
+ const stepThoughtSig = extractThoughtSignature(rawResponseParts);
1589
+ withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c, i) => ({
1590
+ ...c,
1591
+ ...(i === 0 && stepThoughtSig
1592
+ ? { thoughtSignature: stepThoughtSig }
1593
+ : {}),
1594
+ stepIndex: step,
1595
+ })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
1596
+ logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
1597
+ error: error instanceof Error ? error.message : String(error),
1598
+ });
1555
1599
  });
1600
+ }
1601
+ // The @google/genai SDK only accepts "user" and "model" as valid
1602
+ // roles in contents — function/tool responses must use role: "user"
1603
+ // (matching the SDK's automaticFunctionCalling implementation and
1604
+ // the Google AI Studio path). Sending role: "function" was causing
1605
+ // native Vertex Gemini tool loops to be silently rejected by the
1606
+ // request validator.
1607
+ currentContents.push({
1608
+ role: "user",
1609
+ parts: functionResponses,
1556
1610
  });
1557
1611
  }
1558
- // The @google/genai SDK only accepts "user" and "model" as valid
1559
- // roles in contents function/tool responses must use role: "user"
1560
- // (matching the SDK's automaticFunctionCalling implementation and
1561
- // the Google AI Studio path). Sending role: "function" was causing
1562
- // native Vertex Gemini tool loops to be silently rejected by the
1563
- // request validator.
1564
- currentContents.push({
1565
- role: "user",
1566
- parts: functionResponses,
1567
- });
1568
- }
1569
- catch (error) {
1570
- logger.error("[GoogleVertex] Native SDK error", error);
1571
- throw this.handleProviderError(error);
1572
- }
1573
- }
1574
- // Handle maxSteps termination — the loop exited because the step cap was
1575
- // reached while the model was still calling tools. Surface a real answer
1576
- // instead of the canned placeholder (Bug 1) and a meaningful finishReason
1577
- // (Bug 2).
1578
- let hitStepLimit = false;
1579
- let synthesizedFinalAnswer = false;
1580
- if (step >= maxSteps && !finalText) {
1581
- hitStepLimit = true;
1582
- // The consumer receives text via `incrementalTextChunks`; any text the
1583
- // model emitted across steps is already preserved there. Only synthesize
1584
- // when NOTHING was produced (the pure-functionCall case that otherwise
1585
- // surfaces the placeholder) so we never waste a round-trip whose output
1586
- // the stream would ignore.
1587
- if (incrementalTextChunks.length === 0) {
1588
- logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
1589
- `with no text; synthesizing a final answer with tools disabled.`);
1590
- const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? 300_000);
1591
- if (synth.text) {
1592
- synthesizedFinalAnswer = true;
1593
- finalText = synth.text;
1594
- incrementalTextChunks.push(synth.text);
1595
- totalInputTokens += synth.inputTokens;
1596
- totalOutputTokens += synth.outputTokens;
1597
- if (synth.finishReason) {
1598
- lastFinishReason = synth.finishReason;
1612
+ catch (error) {
1613
+ // A mid-drain abort surfaces as an AbortError from the `for await`.
1614
+ // Break gracefully into the terminal block instead of re-throwing
1615
+ // (a re-throw would route the caller's abort into a second unbounded
1616
+ // fallback stream()). Dual check as with the inner catch:
1617
+ // effectiveSignal.aborted (a signal we tripped) OR isAbortError(error)
1618
+ // (an abort-shaped throw) — either means "stop", not a real failure.
1619
+ if (effectiveSignal.aborted || isAbortError(error)) {
1620
+ wasAborted = true;
1621
+ break;
1599
1622
  }
1623
+ logger.error("[GoogleVertex] Native SDK error", error);
1624
+ throw this.handleProviderError(error);
1625
+ }
1626
+ }
1627
+ // Handle maxSteps termination / abort — the loop exited because the step
1628
+ // cap was reached (or the turn was aborted) while the model was still
1629
+ // calling tools. Surface a real answer instead of the canned placeholder
1630
+ // (Bug 1) and a meaningful finishReason (Bug 2).
1631
+ if (!finalText && (step >= maxSteps || wasAborted)) {
1632
+ hitStepLimit = step >= maxSteps && !wasAborted;
1633
+ const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
1634
+ // The consumer receives text via `incrementalTextChunks`; any text the
1635
+ // model emitted across steps is already preserved there. Only produce a
1636
+ // terminal message when NOTHING was produced (the pure-functionCall /
1637
+ // abort case that otherwise surfaces the placeholder). When chunks
1638
+ // exist, leave `finalText` empty so `textPartsToYield` keeps replaying
1639
+ // the gathered chunks instead of collapsing to a single one.
1640
+ if (incrementalTextChunks.length > 0) {
1641
+ logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1642
+ `returning text already gathered from prior steps.`);
1643
+ }
1644
+ else if (wasAborted) {
1645
+ // Aborted turn — skip synth entirely so it can never add +300s after
1646
+ // a blown budget. Deliver exactly one graceful cap chunk.
1647
+ logger.warn(`[GoogleVertex] Tool call loop aborted mid-turn; ` +
1648
+ `returning a graceful cap message.`);
1649
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
1600
1650
  }
1601
1651
  else {
1602
- finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
1652
+ logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
1653
+ `with no text; synthesizing a final answer with tools disabled.`);
1654
+ // synth self-bounds its connect+drain via withTimeout(timeoutMs) and
1655
+ // never throws (returns empty on timeout/error), so it needs no outer
1656
+ // withTimeout wrapper — see synthesizeFinalAnswerWithoutTools.
1657
+ const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS, effectiveSignal);
1658
+ if (synth.text) {
1659
+ synthesizedFinalAnswer = true;
1660
+ finalText = synth.text;
1661
+ incrementalTextChunks.push(synth.text);
1662
+ totalInputTokens += synth.inputTokens;
1663
+ totalOutputTokens += synth.outputTokens;
1664
+ if (synth.finishReason) {
1665
+ lastFinishReason = synth.finishReason;
1666
+ }
1667
+ }
1668
+ else {
1669
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
1670
+ }
1603
1671
  }
1604
1672
  }
1605
- else {
1606
- logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1607
- `returning text already gathered from prior steps.`);
1608
- }
1673
+ }
1674
+ finally {
1675
+ clearTimeout(defensiveTimer);
1676
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
1609
1677
  }
1610
1678
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
1611
1679
  // synthesized answer is reported as "tool-calls" (the model still wanted
@@ -1990,173 +2058,230 @@ export class GoogleVertexProvider extends BaseProvider {
1990
2058
  // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1991
2059
  let totalInputTokens = 0;
1992
2060
  let totalOutputTokens = 0;
1993
- // Agentic loop for tool calling
1994
- while (step < maxSteps) {
1995
- step++;
1996
- logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
1997
- try {
1998
- // Use generateContentStream and collect all chunks (same as GoogleAIStudio)
1999
- const stream = await client.models.generateContentStream({
2000
- model: modelName,
2001
- contents: currentContents,
2002
- config,
2003
- });
2004
- const stepFunctionCalls = [];
2005
- // Capture raw response parts including thoughtSignature
2006
- const rawResponseParts = [];
2007
- // Collect all chunks from stream
2008
- for await (const chunk of stream) {
2009
- // Extract raw parts from candidates FIRST
2010
- // This avoids using chunk.text which triggers SDK warning when
2011
- // non-text parts (thoughtSignature, functionCall) are present
2012
- const chunkRecord = chunk;
2013
- const candidates = chunkRecord.candidates;
2014
- const firstCandidate = candidates?.[0];
2015
- // Capture the SDK finish reason (Bug 2: previously dropped). Last
2016
- // non-empty value across chunks wins.
2017
- const chunkFinishReason = firstCandidate?.finishReason;
2018
- if (typeof chunkFinishReason === "string" && chunkFinishReason) {
2019
- lastFinishReason = chunkFinishReason;
2020
- }
2021
- const chunkContent = firstCandidate?.content;
2022
- if (chunkContent && Array.isArray(chunkContent.parts)) {
2023
- rawResponseParts.push(...chunkContent.parts);
2024
- }
2025
- if (chunk.functionCalls) {
2026
- stepFunctionCalls.push(...chunk.functionCalls);
2027
- }
2028
- // Extract usage metadata from chunk
2029
- // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
2030
- const usageMetadata = chunkRecord.usageMetadata;
2031
- if (usageMetadata) {
2032
- // Take the latest promptTokenCount (usually only in final chunk)
2033
- if (usageMetadata.promptTokenCount !== undefined &&
2034
- usageMetadata.promptTokenCount > 0) {
2035
- totalInputTokens = usageMetadata.promptTokenCount;
2061
+ // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
2062
+ // Gemini SDK cancels via config.abortSignal, so drive an internal
2063
+ // AbortController: the caller's signal and a defensive wall-clock timer
2064
+ // both trip it, and every request/tool-exec receives effectiveSignal.
2065
+ const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
2066
+ const internalAbort = new AbortController();
2067
+ const onCallerAbort = () => internalAbort.abort();
2068
+ options.abortSignal?.addEventListener("abort", onCallerAbort);
2069
+ const defensiveTimer = setTimeout(() => {
2070
+ logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
2071
+ internalAbort.abort();
2072
+ }, streamTimeoutMs);
2073
+ const effectiveSignal = internalAbort.signal;
2074
+ if (options.abortSignal?.aborted) {
2075
+ internalAbort.abort();
2076
+ }
2077
+ let wasAborted = false;
2078
+ // Step-cap flags declared in the outer scope so the terminal block (also
2079
+ // inside the try) and the finishReason mapping (after the finally) can
2080
+ // both read them.
2081
+ let hitStepLimit = false;
2082
+ let synthesizedFinalAnswer = false;
2083
+ try {
2084
+ // Agentic loop for tool calling
2085
+ while (step < maxSteps) {
2086
+ if (effectiveSignal.aborted) {
2087
+ wasAborted = true;
2088
+ break;
2089
+ }
2090
+ step++;
2091
+ logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
2092
+ try {
2093
+ // Use generateContentStream and collect all chunks (same as GoogleAIStudio)
2094
+ const stream = await client.models.generateContentStream({
2095
+ model: modelName,
2096
+ contents: currentContents,
2097
+ config: { ...config, abortSignal: effectiveSignal },
2098
+ });
2099
+ const stepFunctionCalls = [];
2100
+ // Capture raw response parts including thoughtSignature
2101
+ const rawResponseParts = [];
2102
+ // Collect all chunks from stream
2103
+ for await (const chunk of stream) {
2104
+ // Extract raw parts from candidates FIRST
2105
+ // This avoids using chunk.text which triggers SDK warning when
2106
+ // non-text parts (thoughtSignature, functionCall) are present
2107
+ const chunkRecord = chunk;
2108
+ const candidates = chunkRecord.candidates;
2109
+ const firstCandidate = candidates?.[0];
2110
+ // Capture the SDK finish reason (Bug 2: previously dropped). Last
2111
+ // non-empty value across chunks wins.
2112
+ const chunkFinishReason = firstCandidate?.finishReason;
2113
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
2114
+ lastFinishReason = chunkFinishReason;
2036
2115
  }
2037
- // Take the latest candidatesTokenCount (accumulates through chunks)
2038
- if (usageMetadata.candidatesTokenCount !== undefined &&
2039
- usageMetadata.candidatesTokenCount > 0) {
2040
- totalOutputTokens = usageMetadata.candidatesTokenCount;
2116
+ const chunkContent = firstCandidate?.content;
2117
+ if (chunkContent && Array.isArray(chunkContent.parts)) {
2118
+ rawResponseParts.push(...chunkContent.parts);
2119
+ }
2120
+ if (chunk.functionCalls) {
2121
+ stepFunctionCalls.push(...chunk.functionCalls);
2122
+ }
2123
+ // Extract usage metadata from chunk
2124
+ // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
2125
+ const usageMetadata = chunkRecord.usageMetadata;
2126
+ if (usageMetadata) {
2127
+ // Take the latest promptTokenCount (usually only in final chunk)
2128
+ if (usageMetadata.promptTokenCount !== undefined &&
2129
+ usageMetadata.promptTokenCount > 0) {
2130
+ totalInputTokens = usageMetadata.promptTokenCount;
2131
+ }
2132
+ // Take the latest candidatesTokenCount (accumulates through chunks)
2133
+ if (usageMetadata.candidatesTokenCount !== undefined &&
2134
+ usageMetadata.candidatesTokenCount > 0) {
2135
+ totalOutputTokens = usageMetadata.candidatesTokenCount;
2136
+ }
2041
2137
  }
2042
2138
  }
2043
- }
2044
- // Extract text from raw parts after stream completes
2045
- // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
2046
- const stepText = rawResponseParts
2047
- .filter((part) => typeof part.text === "string")
2048
- .map((part) => part.text)
2049
- .join("");
2050
- // If no function calls, we're done
2051
- if (stepFunctionCalls.length === 0) {
2052
- finalText = stepText;
2053
- break;
2054
- }
2055
- // Check for final_result tool call - this is our structured output pattern
2056
- if (useFinalResultTool) {
2057
- const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
2058
- if (finalResultCall) {
2059
- // Extract the structured output from final_result arguments
2060
- finalResultStructuredOutput = finalResultCall.args;
2061
- logger.debug("[GoogleVertex] Received final_result tool call with structured output (generate)", {
2062
- outputKeys: Object.keys(finalResultStructuredOutput),
2063
- });
2064
- // Return the structured output as JSON text
2065
- finalText = JSON.stringify(finalResultStructuredOutput);
2139
+ // Extract text from raw parts after stream completes
2140
+ // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
2141
+ const stepText = rawResponseParts
2142
+ .filter((part) => typeof part.text === "string")
2143
+ .map((part) => part.text)
2144
+ .join("");
2145
+ // If no function calls, we're done
2146
+ if (stepFunctionCalls.length === 0) {
2147
+ finalText = stepText;
2066
2148
  break;
2067
2149
  }
2068
- }
2069
- // Accumulate non-empty step text across steps so the
2070
- // maxSteps-exhaustion exit can surface the prose the model produced
2071
- // instead of a canned placeholder (Bug 1). Mirrors the Vertex-Claude
2072
- // loop's text accumulation.
2073
- accumulatedText = appendStepText(accumulatedText, stepText);
2074
- // Execute function calls
2075
- logger.debug(`[GoogleVertex] Generate executing ${stepFunctionCalls.length} function calls`);
2076
- // Add model response with ALL parts (including thoughtSignature) to history
2077
- // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
2078
- currentContents.push({
2079
- role: "model",
2080
- parts: rawResponseParts.length > 0
2081
- ? rawResponseParts
2082
- : stepFunctionCalls.map((fc) => ({
2083
- functionCall: fc,
2084
- })),
2085
- });
2086
- // Execute each function and collect responses
2087
- const functionResponses = [];
2088
- const toolCallsBefore = allToolCalls.length;
2089
- const toolExecsBefore = toolExecutions.length;
2090
- // Note: tool:start / tool:end events are emitted by ToolsManager's
2091
- // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
2092
- for (const call of stepFunctionCalls) {
2093
- allToolCalls.push({ toolName: call.name, args: call.args });
2094
- // Check if this tool has already exceeded retry limit
2095
- const failedInfo = failedTools.get(call.name);
2096
- if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
2097
- logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
2098
- const errorOutput = {
2099
- 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.`,
2100
- status: "permanently_failed",
2101
- do_not_retry: true,
2102
- };
2103
- toolExecutions.push({
2104
- name: call.name,
2105
- input: call.args,
2106
- output: errorOutput,
2107
- });
2108
- functionResponses.push({
2109
- functionResponse: {
2110
- name: call.name,
2111
- response: errorOutput,
2112
- },
2113
- });
2114
- continue;
2150
+ // Check for final_result tool call - this is our structured output pattern
2151
+ if (useFinalResultTool) {
2152
+ const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
2153
+ if (finalResultCall) {
2154
+ // Extract the structured output from final_result arguments
2155
+ finalResultStructuredOutput = finalResultCall.args;
2156
+ logger.debug("[GoogleVertex] Received final_result tool call with structured output (generate)", {
2157
+ outputKeys: Object.keys(finalResultStructuredOutput),
2158
+ });
2159
+ // Return the structured output as JSON text
2160
+ finalText = JSON.stringify(finalResultStructuredOutput);
2161
+ break;
2162
+ }
2115
2163
  }
2116
- const execute = executeMap.get(call.name);
2117
- if (execute) {
2118
- try {
2119
- // AI SDK Tool execute requires (args, options) - provide minimal options
2120
- const toolOptions = {
2121
- toolCallId: `${call.name}-${Date.now()}`,
2122
- messages: [],
2123
- abortSignal: undefined,
2164
+ // Accumulate non-empty step text across steps so the
2165
+ // maxSteps-exhaustion exit can surface the prose the model produced
2166
+ // instead of a canned placeholder (Bug 1). Mirrors the Vertex-Claude
2167
+ // loop's text accumulation.
2168
+ accumulatedText = appendStepText(accumulatedText, stepText);
2169
+ // Execute function calls
2170
+ logger.debug(`[GoogleVertex] Generate executing ${stepFunctionCalls.length} function calls`);
2171
+ // Add model response with ALL parts (including thoughtSignature) to history
2172
+ // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
2173
+ currentContents.push({
2174
+ role: "model",
2175
+ parts: rawResponseParts.length > 0
2176
+ ? rawResponseParts
2177
+ : stepFunctionCalls.map((fc) => ({
2178
+ functionCall: fc,
2179
+ })),
2180
+ });
2181
+ // Execute each function and collect responses
2182
+ const functionResponses = [];
2183
+ const toolCallsBefore = allToolCalls.length;
2184
+ const toolExecsBefore = toolExecutions.length;
2185
+ // Note: tool:start / tool:end events are emitted by ToolsManager's
2186
+ // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
2187
+ for (const call of stepFunctionCalls) {
2188
+ allToolCalls.push({ toolName: call.name, args: call.args });
2189
+ // Check if this tool has already exceeded retry limit
2190
+ const failedInfo = failedTools.get(call.name);
2191
+ if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
2192
+ logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
2193
+ const errorOutput = {
2194
+ 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.`,
2195
+ status: "permanently_failed",
2196
+ do_not_retry: true,
2124
2197
  };
2125
- const execResult = await execute(call.args, toolOptions);
2126
- // Track execution
2127
2198
  toolExecutions.push({
2128
2199
  name: call.name,
2129
2200
  input: call.args,
2130
- output: execResult,
2201
+ output: errorOutput,
2131
2202
  });
2132
2203
  functionResponses.push({
2133
2204
  functionResponse: {
2134
2205
  name: call.name,
2135
- response: { result: execResult },
2206
+ response: errorOutput,
2136
2207
  },
2137
2208
  });
2209
+ continue;
2138
2210
  }
2139
- catch (error) {
2140
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
2141
- // Track this failure
2142
- const currentFailInfo = failedTools.get(call.name) || {
2143
- count: 0,
2144
- lastError: "",
2145
- };
2146
- currentFailInfo.count++;
2147
- currentFailInfo.lastError = errorMessage;
2148
- failedTools.set(call.name, currentFailInfo);
2149
- logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
2150
- // Determine if this is a permanent failure
2151
- const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
2211
+ const execute = executeMap.get(call.name);
2212
+ if (execute) {
2213
+ try {
2214
+ // AI SDK Tool execute requires (args, options) - provide minimal options
2215
+ const toolOptions = {
2216
+ toolCallId: `${call.name}-${Date.now()}`,
2217
+ messages: [],
2218
+ abortSignal: effectiveSignal,
2219
+ };
2220
+ const execResult = await execute(call.args, toolOptions);
2221
+ // Track execution
2222
+ toolExecutions.push({
2223
+ name: call.name,
2224
+ input: call.args,
2225
+ output: execResult,
2226
+ });
2227
+ functionResponses.push({
2228
+ functionResponse: {
2229
+ name: call.name,
2230
+ response: { result: execResult },
2231
+ },
2232
+ });
2233
+ }
2234
+ catch (error) {
2235
+ // An abort during tool execution ends the turn gracefully — it
2236
+ // must NOT be recorded as a spurious tool failure. Both checks
2237
+ // matter: effectiveSignal.aborted catches an abort WE triggered
2238
+ // (even if the throw isn't abort-shaped); isAbortError(error)
2239
+ // catches an abort-shaped throw (e.g. a tool raising AbortError)
2240
+ // even when the signal itself never fired.
2241
+ if (effectiveSignal.aborted || isAbortError(error)) {
2242
+ wasAborted = true;
2243
+ break;
2244
+ }
2245
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
2246
+ // Track this failure
2247
+ const currentFailInfo = failedTools.get(call.name) || {
2248
+ count: 0,
2249
+ lastError: "",
2250
+ };
2251
+ currentFailInfo.count++;
2252
+ currentFailInfo.lastError = errorMessage;
2253
+ failedTools.set(call.name, currentFailInfo);
2254
+ logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
2255
+ // Determine if this is a permanent failure
2256
+ const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
2257
+ const errorOutput = {
2258
+ error: isPermanentFailure
2259
+ ? `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.`
2260
+ : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
2261
+ status: isPermanentFailure ? "permanently_failed" : "failed",
2262
+ do_not_retry: isPermanentFailure,
2263
+ retry_count: currentFailInfo.count,
2264
+ max_retries: DEFAULT_TOOL_MAX_RETRIES,
2265
+ };
2266
+ toolExecutions.push({
2267
+ name: call.name,
2268
+ input: call.args,
2269
+ output: errorOutput,
2270
+ });
2271
+ functionResponses.push({
2272
+ functionResponse: {
2273
+ name: call.name,
2274
+ response: errorOutput,
2275
+ },
2276
+ });
2277
+ }
2278
+ }
2279
+ else {
2280
+ // Tool not found is a permanent error
2152
2281
  const errorOutput = {
2153
- error: isPermanentFailure
2154
- ? `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.`
2155
- : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
2156
- status: isPermanentFailure ? "permanently_failed" : "failed",
2157
- do_not_retry: isPermanentFailure,
2158
- retry_count: currentFailInfo.count,
2159
- max_retries: DEFAULT_TOOL_MAX_RETRIES,
2282
+ error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
2283
+ status: "permanently_failed",
2284
+ do_not_retry: true,
2160
2285
  };
2161
2286
  toolExecutions.push({
2162
2287
  name: call.name,
@@ -2171,101 +2296,111 @@ export class GoogleVertexProvider extends BaseProvider {
2171
2296
  });
2172
2297
  }
2173
2298
  }
2174
- else {
2175
- // Tool not found is a permanent error
2176
- const errorOutput = {
2177
- error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
2178
- status: "permanently_failed",
2179
- do_not_retry: true,
2180
- };
2181
- toolExecutions.push({
2182
- name: call.name,
2183
- input: call.args,
2184
- output: errorOutput,
2185
- });
2186
- functionResponses.push({
2187
- functionResponse: {
2188
- name: call.name,
2189
- response: errorOutput,
2190
- },
2191
- });
2299
+ // An abort inside the tool-exec loop only breaks that inner for-loop.
2300
+ // Break the while too so no further model call is issued and control
2301
+ // reaches the terminal step-cap handling below.
2302
+ if (wasAborted) {
2303
+ break;
2192
2304
  }
2193
- }
2194
- // Persist this step's tool calls/results into conversation memory.
2195
- // Without this, tool_call / tool_result rows never reach Redis and
2196
- // the chat-history UI loses every tool invocation. The first call
2197
- // of the step carries the step's `thoughtSignature` so Gemini 3 can
2198
- // match thinking patterns on replay.
2199
- const stepToolCalls = allToolCalls.slice(toolCallsBefore);
2200
- const stepToolExecs = toolExecutions.slice(toolExecsBefore);
2201
- if (stepToolCalls.length > 0 || stepToolExecs.length > 0) {
2202
- const stepThoughtSig = extractThoughtSignature(rawResponseParts);
2203
- withTimeout(this.handleToolExecutionStorage(stepToolCalls.map((tc, i) => ({
2204
- toolName: tc.toolName,
2205
- args: tc.args,
2206
- ...(i === 0 && stepThoughtSig
2207
- ? { thoughtSignature: stepThoughtSig }
2208
- : {}),
2209
- stepIndex: step,
2210
- })), stepToolExecs.map((te) => ({
2211
- toolName: te.name,
2212
- output: te.output,
2213
- stepIndex: step,
2214
- })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
2215
- logger.warn("[GoogleVertex] Failed to store native Gemini generate tool executions", {
2216
- error: error instanceof Error ? error.message : String(error),
2305
+ // Persist this step's tool calls/results into conversation memory.
2306
+ // Without this, tool_call / tool_result rows never reach Redis and
2307
+ // the chat-history UI loses every tool invocation. The first call
2308
+ // of the step carries the step's `thoughtSignature` so Gemini 3 can
2309
+ // match thinking patterns on replay.
2310
+ const stepToolCalls = allToolCalls.slice(toolCallsBefore);
2311
+ const stepToolExecs = toolExecutions.slice(toolExecsBefore);
2312
+ if (stepToolCalls.length > 0 || stepToolExecs.length > 0) {
2313
+ const stepThoughtSig = extractThoughtSignature(rawResponseParts);
2314
+ withTimeout(this.handleToolExecutionStorage(stepToolCalls.map((tc, i) => ({
2315
+ toolName: tc.toolName,
2316
+ args: tc.args,
2317
+ ...(i === 0 && stepThoughtSig
2318
+ ? { thoughtSignature: stepThoughtSig }
2319
+ : {}),
2320
+ stepIndex: step,
2321
+ })), stepToolExecs.map((te) => ({
2322
+ toolName: te.name,
2323
+ output: te.output,
2324
+ stepIndex: step,
2325
+ })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
2326
+ logger.warn("[GoogleVertex] Failed to store native Gemini generate tool executions", {
2327
+ error: error instanceof Error ? error.message : String(error),
2328
+ });
2217
2329
  });
2330
+ }
2331
+ // The @google/genai SDK only accepts "user" and "model" as valid
2332
+ // roles in contents — function/tool responses must use role: "user"
2333
+ // (matching the SDK's automaticFunctionCalling implementation and
2334
+ // the Google AI Studio path). See note in executeNativeGemini3Stream.
2335
+ currentContents.push({
2336
+ role: "user",
2337
+ parts: functionResponses,
2218
2338
  });
2219
2339
  }
2220
- // The @google/genai SDK only accepts "user" and "model" as valid
2221
- // roles in contents function/tool responses must use role: "user"
2222
- // (matching the SDK's automaticFunctionCalling implementation and
2223
- // the Google AI Studio path). See note in executeNativeGemini3Stream.
2224
- currentContents.push({
2225
- role: "user",
2226
- parts: functionResponses,
2227
- });
2228
- }
2229
- catch (error) {
2230
- logger.error("[GoogleVertex] Native SDK generate error", error);
2231
- throw this.handleProviderError(error);
2232
- }
2233
- }
2234
- // Handle maxSteps termination — the loop exited because the step cap was
2235
- // reached while the model was still calling tools. Surface a real answer
2236
- // instead of the canned placeholder (Bug 1) and a meaningful finishReason
2237
- // (Bug 2).
2238
- let hitStepLimit = false;
2239
- let synthesizedFinalAnswer = false;
2240
- if (step >= maxSteps && !finalText) {
2241
- hitStepLimit = true;
2242
- if (accumulatedText) {
2243
- // Prefer the prose the model already produced across steps.
2244
- logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
2245
- `returning text already gathered from prior steps.`);
2246
- finalText = accumulatedText;
2247
- }
2248
- else {
2249
- // Pure functionCall turns leave no text — make one tools-disabled call
2250
- // so the model answers from the gathered tool results instead of the
2251
- // canned placeholder.
2252
- logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
2253
- `with no text; synthesizing a final answer with tools disabled.`);
2254
- const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? 300_000);
2255
- if (synth.text) {
2256
- synthesizedFinalAnswer = true;
2257
- finalText = synth.text;
2258
- totalInputTokens += synth.inputTokens;
2259
- totalOutputTokens += synth.outputTokens;
2260
- if (synth.finishReason) {
2261
- lastFinishReason = synth.finishReason;
2340
+ catch (error) {
2341
+ // A mid-drain abort surfaces as an AbortError from the `for await`.
2342
+ // Break gracefully into the terminal block instead of re-throwing
2343
+ // (a re-throw would route the caller's abort into a second unbounded
2344
+ // fallback generate()). Dual check as with the inner catch:
2345
+ // effectiveSignal.aborted (a signal we tripped) OR isAbortError(error)
2346
+ // (an abort-shaped throw) — either means "stop", not a real failure.
2347
+ if (effectiveSignal.aborted || isAbortError(error)) {
2348
+ wasAborted = true;
2349
+ break;
2262
2350
  }
2351
+ logger.error("[GoogleVertex] Native SDK generate error", error);
2352
+ throw this.handleProviderError(error);
2353
+ }
2354
+ }
2355
+ // Handle maxSteps termination / abort — the loop exited because the step
2356
+ // cap was reached (or the turn was aborted) while the model was still
2357
+ // calling tools. Surface a real answer instead of the canned placeholder
2358
+ // (Bug 1) and a meaningful finishReason (Bug 2).
2359
+ if (!finalText && (step >= maxSteps || wasAborted)) {
2360
+ hitStepLimit = step >= maxSteps && !wasAborted;
2361
+ const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
2362
+ if (accumulatedText) {
2363
+ // Prefer the prose the model already produced across steps.
2364
+ logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
2365
+ `returning text already gathered from prior steps.`);
2366
+ finalText = accumulatedText;
2367
+ }
2368
+ else if (wasAborted) {
2369
+ // Aborted turn — skip synth entirely so it can never add +300s after
2370
+ // a blown budget. Deliver a graceful cap message as ordinary prose.
2371
+ logger.warn(`[GoogleVertex] Generate tool call loop aborted mid-turn; ` +
2372
+ `returning a graceful cap message.`);
2373
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
2263
2374
  }
2264
2375
  else {
2265
- finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
2376
+ // Pure functionCall turns leave no text make one tools-disabled call
2377
+ // so the model answers from the gathered tool results instead of the
2378
+ // canned placeholder.
2379
+ logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
2380
+ `with no text; synthesizing a final answer with tools disabled.`);
2381
+ // synth self-bounds its connect+drain via withTimeout(timeoutMs) and
2382
+ // never throws (returns empty on timeout/error), so it needs no outer
2383
+ // withTimeout wrapper — see synthesizeFinalAnswerWithoutTools.
2384
+ const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS, effectiveSignal);
2385
+ if (synth.text) {
2386
+ synthesizedFinalAnswer = true;
2387
+ finalText = synth.text;
2388
+ totalInputTokens += synth.inputTokens;
2389
+ totalOutputTokens += synth.outputTokens;
2390
+ if (synth.finishReason) {
2391
+ lastFinishReason = synth.finishReason;
2392
+ }
2393
+ }
2394
+ else {
2395
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
2396
+ }
2266
2397
  }
2267
2398
  }
2268
2399
  }
2400
+ finally {
2401
+ clearTimeout(defensiveTimer);
2402
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
2403
+ }
2269
2404
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
2270
2405
  // synthesized answer is reported as "tool-calls" (the model still wanted
2271
2406
  // tools) — NOT "length", which neurolink.ts treats as token truncation
@@ -2317,14 +2452,25 @@ export class GoogleVertexProvider extends BaseProvider {
2317
2452
  * (`final_result`) pattern was active, a trailing instruction countermands the
2318
2453
  * earlier "you MUST call final_result" directive so the model answers in plain
2319
2454
  * text. Never throws — returns empty text so the caller falls back to the
2320
- * placeholder, guaranteeing no new failure path.
2455
+ * graceful cap message (buildToolLoopCapMessage), guaranteeing no new failure
2456
+ * path.
2321
2457
  */
2322
- async synthesizeFinalAnswerWithoutTools(client, modelName, config, contents, useFinalResultTool, timeoutMs) {
2458
+ // eslint-disable-next-line max-params -- trailing optional abortSignal keeps the positional call sites stable
2459
+ async synthesizeFinalAnswerWithoutTools(client, modelName, config, contents, useFinalResultTool, timeoutMs, abortSignal) {
2460
+ // Already aborted — never issue the synth request (would add +300s after a
2461
+ // blown budget). Return empty so the caller maps to the graceful message.
2462
+ if (abortSignal?.aborted) {
2463
+ return { text: "", inputTokens: 0, outputTokens: 0 };
2464
+ }
2323
2465
  try {
2324
2466
  // Shallow clone so the loop's config is never mutated; dropping the
2325
2467
  // top-level `tools` key is sufficient (nested thinkingConfig /
2326
- // systemInstruction are intentionally preserved).
2327
- const synthConfig = { ...config };
2468
+ // systemInstruction are intentionally preserved). Fold in the abort
2469
+ // signal so the synth request is cancellable too.
2470
+ const synthConfig = {
2471
+ ...config,
2472
+ ...(abortSignal ? { abortSignal } : {}),
2473
+ };
2328
2474
  delete synthConfig.tools;
2329
2475
  if (useFinalResultTool) {
2330
2476
  const baseSystemInstruction = typeof synthConfig.systemInstruction === "string"