@juspay/neurolink 9.80.1 → 9.80.3

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 { AIProviderName, 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";
@@ -932,7 +932,10 @@ export class GoogleVertexProvider extends BaseProvider {
932
932
  this.emitStreamEnd(modelName, streamStartTime, false, error);
933
933
  throw error;
934
934
  }
935
- }, (r) => r.stream, (r, wrapped) => ({ ...r, stream: wrapped }));
935
+ }, (r) => r.stream,
936
+ // Preserve live getters (finishReason/structuredOutput/…) that the
937
+ // spread would otherwise snapshot before the background loop resolves.
938
+ (r, wrapped) => this.preserveStreamResultAccessors(r, { ...r, stream: wrapped }));
936
939
  }
937
940
  /**
938
941
  * Emit `stream:end` so the Pipeline B observability listener creates a
@@ -1321,177 +1324,238 @@ export class GoogleVertexProvider extends BaseProvider {
1321
1324
  // yielding it once breaks that signal even though the underlying
1322
1325
  // network stream is genuinely incremental.
1323
1326
  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);
1327
+ // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
1328
+ // Gemini SDK cancels via config.abortSignal, so drive an internal
1329
+ // AbortController: the caller's signal and a defensive wall-clock timer
1330
+ // both trip it, and every request/tool-exec receives effectiveSignal.
1331
+ const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
1332
+ const internalAbort = new AbortController();
1333
+ const onCallerAbort = () => internalAbort.abort();
1334
+ options.abortSignal?.addEventListener("abort", onCallerAbort);
1335
+ const defensiveTimer = setTimeout(() => {
1336
+ logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
1337
+ internalAbort.abort();
1338
+ }, streamTimeoutMs);
1339
+ const effectiveSignal = internalAbort.signal;
1340
+ if (options.abortSignal?.aborted) {
1341
+ internalAbort.abort();
1342
+ }
1343
+ let wasAborted = false;
1344
+ // Step-cap flags declared in the outer scope so the terminal block (also
1345
+ // inside the try) and the finishReason mapping (after the finally) can
1346
+ // both read them.
1347
+ let hitStepLimit = false;
1348
+ let synthesizedFinalAnswer = false;
1349
+ try {
1350
+ // Agentic loop for tool calling
1351
+ while (step < maxSteps) {
1352
+ if (effectiveSignal.aborted) {
1353
+ wasAborted = true;
1354
+ break;
1355
+ }
1356
+ step++;
1357
+ logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
1358
+ try {
1359
+ const stream = await client.models.generateContentStream({
1360
+ model: modelName,
1361
+ contents: currentContents,
1362
+ config: { ...config, abortSignal: effectiveSignal },
1363
+ });
1364
+ const stepFunctionCalls = [];
1365
+ // Capture raw response parts including thoughtSignature
1366
+ const rawResponseParts = [];
1367
+ for await (const chunk of stream) {
1368
+ // Extract raw parts from candidates FIRST
1369
+ // This avoids using chunk.text which triggers SDK warning when
1370
+ // non-text parts (thoughtSignature, functionCall) are present
1371
+ const chunkRecord = chunk;
1372
+ const candidates = chunkRecord.candidates;
1373
+ const firstCandidate = candidates?.[0];
1374
+ // Capture the SDK finish reason (Bug 2: previously dropped). Last
1375
+ // non-empty value across chunks wins.
1376
+ const chunkFinishReason = firstCandidate?.finishReason;
1377
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1378
+ lastFinishReason = chunkFinishReason;
1379
+ }
1380
+ const chunkContent = firstCandidate?.content;
1381
+ if (chunkContent && Array.isArray(chunkContent.parts)) {
1382
+ for (const part of chunkContent.parts) {
1383
+ rawResponseParts.push(part);
1384
+ if (typeof part.text === "string" && part.text.length > 0) {
1385
+ incrementalTextChunks.push(part.text);
1386
+ }
1356
1387
  }
1357
1388
  }
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;
1389
+ if (chunk.functionCalls) {
1390
+ stepFunctionCalls.push(...chunk.functionCalls);
1370
1391
  }
1371
- // Take the latest candidatesTokenCount (accumulates through chunks)
1372
- if (usageMetadata.candidatesTokenCount !== undefined &&
1373
- usageMetadata.candidatesTokenCount > 0) {
1374
- totalOutputTokens = usageMetadata.candidatesTokenCount;
1392
+ // Extract usage metadata from chunk
1393
+ // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1394
+ const usageMetadata = chunkRecord.usageMetadata;
1395
+ if (usageMetadata) {
1396
+ // Take the latest promptTokenCount (usually only in final chunk)
1397
+ if (usageMetadata.promptTokenCount !== undefined &&
1398
+ usageMetadata.promptTokenCount > 0) {
1399
+ totalInputTokens = usageMetadata.promptTokenCount;
1400
+ }
1401
+ // Take the latest candidatesTokenCount (accumulates through chunks)
1402
+ if (usageMetadata.candidatesTokenCount !== undefined &&
1403
+ usageMetadata.candidatesTokenCount > 0) {
1404
+ totalOutputTokens = usageMetadata.candidatesTokenCount;
1405
+ }
1375
1406
  }
1376
1407
  }
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);
1408
+ // Extract text from raw parts after stream completes
1409
+ // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
1410
+ const stepText = rawResponseParts
1411
+ .filter((part) => typeof part.text === "string")
1412
+ .map((part) => part.text)
1413
+ .join("");
1414
+ // If no function calls, we're done
1415
+ if (stepFunctionCalls.length === 0) {
1416
+ finalText = stepText;
1400
1417
  break;
1401
1418
  }
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;
1419
+ // Check for final_result tool call - this is our structured output pattern
1420
+ if (useFinalResultTool) {
1421
+ const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
1422
+ if (finalResultCall) {
1423
+ // Extract the structured output from final_result arguments
1424
+ finalResultStructuredOutput = finalResultCall.args;
1425
+ logger.debug("[GoogleVertex] Received final_result tool call with structured output (stream)", {
1426
+ outputKeys: Object.keys(finalResultStructuredOutput),
1427
+ });
1428
+ // Return the structured output as JSON text
1429
+ finalText = JSON.stringify(finalResultStructuredOutput);
1430
+ break;
1431
+ }
1450
1432
  }
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,
1433
+ // Execute function calls
1434
+ logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
1435
+ // Add model response with ALL parts (including thoughtSignature) to history
1436
+ // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
1437
+ currentContents.push({
1438
+ role: "model",
1439
+ parts: rawResponseParts.length > 0
1440
+ ? rawResponseParts
1441
+ : stepFunctionCalls.map((fc) => ({
1442
+ functionCall: fc,
1443
+ })),
1444
+ });
1445
+ // Execute each function and collect responses
1446
+ const functionResponses = [];
1447
+ // Per-step bookkeeping for conversation-memory storage.
1448
+ const stepStorageCalls = [];
1449
+ const stepStorageResults = [];
1450
+ // Note: tool:start / tool:end events are emitted by ToolsManager's
1451
+ // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
1452
+ for (const call of stepFunctionCalls) {
1453
+ allToolCalls.push({ toolName: call.name, args: call.args });
1454
+ stepStorageCalls.push({ toolName: call.name, args: call.args });
1455
+ // Check if this tool has already exceeded retry limit
1456
+ const failedInfo = failedTools.get(call.name);
1457
+ if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
1458
+ logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
1459
+ const errorPayload = {
1460
+ 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.`,
1461
+ status: "permanently_failed",
1462
+ do_not_retry: true,
1459
1463
  };
1460
- const result = await execute(call.args, toolOptions);
1464
+ functionResponses.push({
1465
+ functionResponse: {
1466
+ name: call.name,
1467
+ response: errorPayload,
1468
+ },
1469
+ });
1461
1470
  toolExecutions.push({
1462
1471
  name: call.name,
1463
1472
  input: call.args,
1464
- output: result,
1465
- });
1466
- functionResponses.push({
1467
- functionResponse: { name: call.name, response: { result } },
1473
+ output: errorPayload,
1468
1474
  });
1469
1475
  stepStorageResults.push({
1470
1476
  toolName: call.name,
1471
- output: result,
1477
+ output: errorPayload,
1472
1478
  });
1479
+ continue;
1473
1480
  }
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;
1481
+ const execute = executeMap.get(call.name);
1482
+ if (execute) {
1483
+ try {
1484
+ // AI SDK Tool execute requires (args, options) - provide minimal options
1485
+ const toolOptions = {
1486
+ toolCallId: `${call.name}-${Date.now()}`,
1487
+ messages: [],
1488
+ abortSignal: effectiveSignal,
1489
+ };
1490
+ const result = await execute(call.args, toolOptions);
1491
+ toolExecutions.push({
1492
+ name: call.name,
1493
+ input: call.args,
1494
+ output: result,
1495
+ });
1496
+ functionResponses.push({
1497
+ functionResponse: { name: call.name, response: { result } },
1498
+ });
1499
+ stepStorageResults.push({
1500
+ toolName: call.name,
1501
+ output: result,
1502
+ });
1503
+ }
1504
+ catch (error) {
1505
+ // An abort during tool execution ends the turn gracefully — it
1506
+ // must NOT be recorded as a spurious tool failure. Both checks
1507
+ // matter: effectiveSignal.aborted catches an abort WE triggered
1508
+ // (even if the throw isn't abort-shaped); isAbortError(error)
1509
+ // catches an abort-shaped throw (e.g. a tool raising AbortError)
1510
+ // even when the signal itself never fired.
1511
+ if (effectiveSignal.aborted || isAbortError(error)) {
1512
+ wasAborted = true;
1513
+ break;
1514
+ }
1515
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1516
+ // Track this failure
1517
+ const currentFailInfo = failedTools.get(call.name) || {
1518
+ count: 0,
1519
+ lastError: "",
1520
+ };
1521
+ currentFailInfo.count++;
1522
+ currentFailInfo.lastError = errorMessage;
1523
+ failedTools.set(call.name, currentFailInfo);
1524
+ logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
1525
+ // Determine if this is a permanent failure
1526
+ const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
1527
+ const errorPayload = {
1528
+ error: isPermanentFailure
1529
+ ? `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.`
1530
+ : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
1531
+ status: isPermanentFailure ? "permanently_failed" : "failed",
1532
+ do_not_retry: isPermanentFailure,
1533
+ retry_count: currentFailInfo.count,
1534
+ max_retries: DEFAULT_TOOL_MAX_RETRIES,
1535
+ };
1536
+ functionResponses.push({
1537
+ functionResponse: {
1538
+ name: call.name,
1539
+ response: errorPayload,
1540
+ },
1541
+ });
1542
+ toolExecutions.push({
1543
+ name: call.name,
1544
+ input: call.args,
1545
+ output: errorPayload,
1546
+ });
1547
+ stepStorageResults.push({
1548
+ toolName: call.name,
1549
+ output: errorPayload,
1550
+ });
1551
+ }
1552
+ }
1553
+ else {
1554
+ // Tool not found is a permanent error
1487
1555
  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,
1556
+ error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
1557
+ status: "permanently_failed",
1558
+ do_not_retry: true,
1495
1559
  };
1496
1560
  functionResponses.push({
1497
1561
  functionResponse: {
@@ -1510,102 +1574,109 @@ export class GoogleVertexProvider extends BaseProvider {
1510
1574
  });
1511
1575
  }
1512
1576
  }
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
- });
1577
+ // An abort inside the tool-exec loop only breaks that inner for-loop.
1578
+ // Break the while too so no further model call is issued and control
1579
+ // reaches the terminal step-cap handling below.
1580
+ if (wasAborted) {
1581
+ break;
1535
1582
  }
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),
1583
+ // Persist this step's tool calls/results into conversation memory.
1584
+ // Without this, tool_call / tool_result rows never reach Redis and
1585
+ // the chat-history UI loses every tool invocation.
1586
+ //
1587
+ // `thoughtSignature` rides as a sibling on the first call of the
1588
+ // step Gemini 3 needs it to match thinking patterns when the
1589
+ // conversation is replayed on the next turn.
1590
+ if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
1591
+ const stepThoughtSig = extractThoughtSignature(rawResponseParts);
1592
+ withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c, i) => ({
1593
+ ...c,
1594
+ ...(i === 0 && stepThoughtSig
1595
+ ? { thoughtSignature: stepThoughtSig }
1596
+ : {}),
1597
+ stepIndex: step,
1598
+ })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
1599
+ logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
1600
+ error: error instanceof Error ? error.message : String(error),
1601
+ });
1555
1602
  });
1603
+ }
1604
+ // The @google/genai SDK only accepts "user" and "model" as valid
1605
+ // roles in contents — function/tool responses must use role: "user"
1606
+ // (matching the SDK's automaticFunctionCalling implementation and
1607
+ // the Google AI Studio path). Sending role: "function" was causing
1608
+ // native Vertex Gemini tool loops to be silently rejected by the
1609
+ // request validator.
1610
+ currentContents.push({
1611
+ role: "user",
1612
+ parts: functionResponses,
1556
1613
  });
1557
1614
  }
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;
1615
+ catch (error) {
1616
+ // A mid-drain abort surfaces as an AbortError from the `for await`.
1617
+ // Break gracefully into the terminal block instead of re-throwing
1618
+ // (a re-throw would route the caller's abort into a second unbounded
1619
+ // fallback stream()). Dual check as with the inner catch:
1620
+ // effectiveSignal.aborted (a signal we tripped) OR isAbortError(error)
1621
+ // (an abort-shaped throw) — either means "stop", not a real failure.
1622
+ if (effectiveSignal.aborted || isAbortError(error)) {
1623
+ wasAborted = true;
1624
+ break;
1599
1625
  }
1626
+ logger.error("[GoogleVertex] Native SDK error", error);
1627
+ throw this.handleProviderError(error);
1628
+ }
1629
+ }
1630
+ // Handle maxSteps termination / abort — the loop exited because the step
1631
+ // cap was reached (or the turn was aborted) while the model was still
1632
+ // calling tools. Surface a real answer instead of the canned placeholder
1633
+ // (Bug 1) and a meaningful finishReason (Bug 2).
1634
+ if (!finalText && (step >= maxSteps || wasAborted)) {
1635
+ hitStepLimit = step >= maxSteps && !wasAborted;
1636
+ const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
1637
+ // The consumer receives text via `incrementalTextChunks`; any text the
1638
+ // model emitted across steps is already preserved there. Only produce a
1639
+ // terminal message when NOTHING was produced (the pure-functionCall /
1640
+ // abort case that otherwise surfaces the placeholder). When chunks
1641
+ // exist, leave `finalText` empty so `textPartsToYield` keeps replaying
1642
+ // the gathered chunks instead of collapsing to a single one.
1643
+ if (incrementalTextChunks.length > 0) {
1644
+ logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1645
+ `returning text already gathered from prior steps.`);
1646
+ }
1647
+ else if (wasAborted) {
1648
+ // Aborted turn — skip synth entirely so it can never add +300s after
1649
+ // a blown budget. Deliver exactly one graceful cap chunk.
1650
+ logger.warn(`[GoogleVertex] Tool call loop aborted mid-turn; ` +
1651
+ `returning a graceful cap message.`);
1652
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
1600
1653
  }
1601
1654
  else {
1602
- finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
1655
+ logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
1656
+ `with no text; synthesizing a final answer with tools disabled.`);
1657
+ // synth self-bounds its connect+drain via withTimeout(timeoutMs) and
1658
+ // never throws (returns empty on timeout/error), so it needs no outer
1659
+ // withTimeout wrapper — see synthesizeFinalAnswerWithoutTools.
1660
+ const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS, effectiveSignal);
1661
+ if (synth.text) {
1662
+ synthesizedFinalAnswer = true;
1663
+ finalText = synth.text;
1664
+ incrementalTextChunks.push(synth.text);
1665
+ totalInputTokens += synth.inputTokens;
1666
+ totalOutputTokens += synth.outputTokens;
1667
+ if (synth.finishReason) {
1668
+ lastFinishReason = synth.finishReason;
1669
+ }
1670
+ }
1671
+ else {
1672
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
1673
+ }
1603
1674
  }
1604
1675
  }
1605
- else {
1606
- logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1607
- `returning text already gathered from prior steps.`);
1608
- }
1676
+ }
1677
+ finally {
1678
+ clearTimeout(defensiveTimer);
1679
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
1609
1680
  }
1610
1681
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
1611
1682
  // synthesized answer is reported as "tool-calls" (the model still wanted
@@ -1990,173 +2061,230 @@ export class GoogleVertexProvider extends BaseProvider {
1990
2061
  // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1991
2062
  let totalInputTokens = 0;
1992
2063
  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;
2064
+ // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
2065
+ // Gemini SDK cancels via config.abortSignal, so drive an internal
2066
+ // AbortController: the caller's signal and a defensive wall-clock timer
2067
+ // both trip it, and every request/tool-exec receives effectiveSignal.
2068
+ const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
2069
+ const internalAbort = new AbortController();
2070
+ const onCallerAbort = () => internalAbort.abort();
2071
+ options.abortSignal?.addEventListener("abort", onCallerAbort);
2072
+ const defensiveTimer = setTimeout(() => {
2073
+ logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
2074
+ internalAbort.abort();
2075
+ }, streamTimeoutMs);
2076
+ const effectiveSignal = internalAbort.signal;
2077
+ if (options.abortSignal?.aborted) {
2078
+ internalAbort.abort();
2079
+ }
2080
+ let wasAborted = false;
2081
+ // Step-cap flags declared in the outer scope so the terminal block (also
2082
+ // inside the try) and the finishReason mapping (after the finally) can
2083
+ // both read them.
2084
+ let hitStepLimit = false;
2085
+ let synthesizedFinalAnswer = false;
2086
+ try {
2087
+ // Agentic loop for tool calling
2088
+ while (step < maxSteps) {
2089
+ if (effectiveSignal.aborted) {
2090
+ wasAborted = true;
2091
+ break;
2092
+ }
2093
+ step++;
2094
+ logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
2095
+ try {
2096
+ // Use generateContentStream and collect all chunks (same as GoogleAIStudio)
2097
+ const stream = await client.models.generateContentStream({
2098
+ model: modelName,
2099
+ contents: currentContents,
2100
+ config: { ...config, abortSignal: effectiveSignal },
2101
+ });
2102
+ const stepFunctionCalls = [];
2103
+ // Capture raw response parts including thoughtSignature
2104
+ const rawResponseParts = [];
2105
+ // Collect all chunks from stream
2106
+ for await (const chunk of stream) {
2107
+ // Extract raw parts from candidates FIRST
2108
+ // This avoids using chunk.text which triggers SDK warning when
2109
+ // non-text parts (thoughtSignature, functionCall) are present
2110
+ const chunkRecord = chunk;
2111
+ const candidates = chunkRecord.candidates;
2112
+ const firstCandidate = candidates?.[0];
2113
+ // Capture the SDK finish reason (Bug 2: previously dropped). Last
2114
+ // non-empty value across chunks wins.
2115
+ const chunkFinishReason = firstCandidate?.finishReason;
2116
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
2117
+ lastFinishReason = chunkFinishReason;
2036
2118
  }
2037
- // Take the latest candidatesTokenCount (accumulates through chunks)
2038
- if (usageMetadata.candidatesTokenCount !== undefined &&
2039
- usageMetadata.candidatesTokenCount > 0) {
2040
- totalOutputTokens = usageMetadata.candidatesTokenCount;
2119
+ const chunkContent = firstCandidate?.content;
2120
+ if (chunkContent && Array.isArray(chunkContent.parts)) {
2121
+ rawResponseParts.push(...chunkContent.parts);
2122
+ }
2123
+ if (chunk.functionCalls) {
2124
+ stepFunctionCalls.push(...chunk.functionCalls);
2125
+ }
2126
+ // Extract usage metadata from chunk
2127
+ // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
2128
+ const usageMetadata = chunkRecord.usageMetadata;
2129
+ if (usageMetadata) {
2130
+ // Take the latest promptTokenCount (usually only in final chunk)
2131
+ if (usageMetadata.promptTokenCount !== undefined &&
2132
+ usageMetadata.promptTokenCount > 0) {
2133
+ totalInputTokens = usageMetadata.promptTokenCount;
2134
+ }
2135
+ // Take the latest candidatesTokenCount (accumulates through chunks)
2136
+ if (usageMetadata.candidatesTokenCount !== undefined &&
2137
+ usageMetadata.candidatesTokenCount > 0) {
2138
+ totalOutputTokens = usageMetadata.candidatesTokenCount;
2139
+ }
2041
2140
  }
2042
2141
  }
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);
2142
+ // Extract text from raw parts after stream completes
2143
+ // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
2144
+ const stepText = rawResponseParts
2145
+ .filter((part) => typeof part.text === "string")
2146
+ .map((part) => part.text)
2147
+ .join("");
2148
+ // If no function calls, we're done
2149
+ if (stepFunctionCalls.length === 0) {
2150
+ finalText = stepText;
2066
2151
  break;
2067
2152
  }
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;
2153
+ // Check for final_result tool call - this is our structured output pattern
2154
+ if (useFinalResultTool) {
2155
+ const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
2156
+ if (finalResultCall) {
2157
+ // Extract the structured output from final_result arguments
2158
+ finalResultStructuredOutput = finalResultCall.args;
2159
+ logger.debug("[GoogleVertex] Received final_result tool call with structured output (generate)", {
2160
+ outputKeys: Object.keys(finalResultStructuredOutput),
2161
+ });
2162
+ // Return the structured output as JSON text
2163
+ finalText = JSON.stringify(finalResultStructuredOutput);
2164
+ break;
2165
+ }
2115
2166
  }
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,
2167
+ // Accumulate non-empty step text across steps so the
2168
+ // maxSteps-exhaustion exit can surface the prose the model produced
2169
+ // instead of a canned placeholder (Bug 1). Mirrors the Vertex-Claude
2170
+ // loop's text accumulation.
2171
+ accumulatedText = appendStepText(accumulatedText, stepText);
2172
+ // Execute function calls
2173
+ logger.debug(`[GoogleVertex] Generate executing ${stepFunctionCalls.length} function calls`);
2174
+ // Add model response with ALL parts (including thoughtSignature) to history
2175
+ // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
2176
+ currentContents.push({
2177
+ role: "model",
2178
+ parts: rawResponseParts.length > 0
2179
+ ? rawResponseParts
2180
+ : stepFunctionCalls.map((fc) => ({
2181
+ functionCall: fc,
2182
+ })),
2183
+ });
2184
+ // Execute each function and collect responses
2185
+ const functionResponses = [];
2186
+ const toolCallsBefore = allToolCalls.length;
2187
+ const toolExecsBefore = toolExecutions.length;
2188
+ // Note: tool:start / tool:end events are emitted by ToolsManager's
2189
+ // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
2190
+ for (const call of stepFunctionCalls) {
2191
+ allToolCalls.push({ toolName: call.name, args: call.args });
2192
+ // Check if this tool has already exceeded retry limit
2193
+ const failedInfo = failedTools.get(call.name);
2194
+ if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
2195
+ logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
2196
+ const errorOutput = {
2197
+ 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.`,
2198
+ status: "permanently_failed",
2199
+ do_not_retry: true,
2124
2200
  };
2125
- const execResult = await execute(call.args, toolOptions);
2126
- // Track execution
2127
2201
  toolExecutions.push({
2128
2202
  name: call.name,
2129
2203
  input: call.args,
2130
- output: execResult,
2204
+ output: errorOutput,
2131
2205
  });
2132
2206
  functionResponses.push({
2133
2207
  functionResponse: {
2134
2208
  name: call.name,
2135
- response: { result: execResult },
2209
+ response: errorOutput,
2136
2210
  },
2137
2211
  });
2212
+ continue;
2138
2213
  }
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;
2214
+ const execute = executeMap.get(call.name);
2215
+ if (execute) {
2216
+ try {
2217
+ // AI SDK Tool execute requires (args, options) - provide minimal options
2218
+ const toolOptions = {
2219
+ toolCallId: `${call.name}-${Date.now()}`,
2220
+ messages: [],
2221
+ abortSignal: effectiveSignal,
2222
+ };
2223
+ const execResult = await execute(call.args, toolOptions);
2224
+ // Track execution
2225
+ toolExecutions.push({
2226
+ name: call.name,
2227
+ input: call.args,
2228
+ output: execResult,
2229
+ });
2230
+ functionResponses.push({
2231
+ functionResponse: {
2232
+ name: call.name,
2233
+ response: { result: execResult },
2234
+ },
2235
+ });
2236
+ }
2237
+ catch (error) {
2238
+ // An abort during tool execution ends the turn gracefully — it
2239
+ // must NOT be recorded as a spurious tool failure. Both checks
2240
+ // matter: effectiveSignal.aborted catches an abort WE triggered
2241
+ // (even if the throw isn't abort-shaped); isAbortError(error)
2242
+ // catches an abort-shaped throw (e.g. a tool raising AbortError)
2243
+ // even when the signal itself never fired.
2244
+ if (effectiveSignal.aborted || isAbortError(error)) {
2245
+ wasAborted = true;
2246
+ break;
2247
+ }
2248
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
2249
+ // Track this failure
2250
+ const currentFailInfo = failedTools.get(call.name) || {
2251
+ count: 0,
2252
+ lastError: "",
2253
+ };
2254
+ currentFailInfo.count++;
2255
+ currentFailInfo.lastError = errorMessage;
2256
+ failedTools.set(call.name, currentFailInfo);
2257
+ logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
2258
+ // Determine if this is a permanent failure
2259
+ const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
2260
+ const errorOutput = {
2261
+ error: isPermanentFailure
2262
+ ? `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.`
2263
+ : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
2264
+ status: isPermanentFailure ? "permanently_failed" : "failed",
2265
+ do_not_retry: isPermanentFailure,
2266
+ retry_count: currentFailInfo.count,
2267
+ max_retries: DEFAULT_TOOL_MAX_RETRIES,
2268
+ };
2269
+ toolExecutions.push({
2270
+ name: call.name,
2271
+ input: call.args,
2272
+ output: errorOutput,
2273
+ });
2274
+ functionResponses.push({
2275
+ functionResponse: {
2276
+ name: call.name,
2277
+ response: errorOutput,
2278
+ },
2279
+ });
2280
+ }
2281
+ }
2282
+ else {
2283
+ // Tool not found is a permanent error
2152
2284
  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,
2285
+ error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
2286
+ status: "permanently_failed",
2287
+ do_not_retry: true,
2160
2288
  };
2161
2289
  toolExecutions.push({
2162
2290
  name: call.name,
@@ -2171,101 +2299,111 @@ export class GoogleVertexProvider extends BaseProvider {
2171
2299
  });
2172
2300
  }
2173
2301
  }
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
- });
2302
+ // An abort inside the tool-exec loop only breaks that inner for-loop.
2303
+ // Break the while too so no further model call is issued and control
2304
+ // reaches the terminal step-cap handling below.
2305
+ if (wasAborted) {
2306
+ break;
2192
2307
  }
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),
2308
+ // Persist this step's tool calls/results into conversation memory.
2309
+ // Without this, tool_call / tool_result rows never reach Redis and
2310
+ // the chat-history UI loses every tool invocation. The first call
2311
+ // of the step carries the step's `thoughtSignature` so Gemini 3 can
2312
+ // match thinking patterns on replay.
2313
+ const stepToolCalls = allToolCalls.slice(toolCallsBefore);
2314
+ const stepToolExecs = toolExecutions.slice(toolExecsBefore);
2315
+ if (stepToolCalls.length > 0 || stepToolExecs.length > 0) {
2316
+ const stepThoughtSig = extractThoughtSignature(rawResponseParts);
2317
+ withTimeout(this.handleToolExecutionStorage(stepToolCalls.map((tc, i) => ({
2318
+ toolName: tc.toolName,
2319
+ args: tc.args,
2320
+ ...(i === 0 && stepThoughtSig
2321
+ ? { thoughtSignature: stepThoughtSig }
2322
+ : {}),
2323
+ stepIndex: step,
2324
+ })), stepToolExecs.map((te) => ({
2325
+ toolName: te.name,
2326
+ output: te.output,
2327
+ stepIndex: step,
2328
+ })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
2329
+ logger.warn("[GoogleVertex] Failed to store native Gemini generate tool executions", {
2330
+ error: error instanceof Error ? error.message : String(error),
2331
+ });
2217
2332
  });
2333
+ }
2334
+ // The @google/genai SDK only accepts "user" and "model" as valid
2335
+ // roles in contents — function/tool responses must use role: "user"
2336
+ // (matching the SDK's automaticFunctionCalling implementation and
2337
+ // the Google AI Studio path). See note in executeNativeGemini3Stream.
2338
+ currentContents.push({
2339
+ role: "user",
2340
+ parts: functionResponses,
2218
2341
  });
2219
2342
  }
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;
2343
+ catch (error) {
2344
+ // A mid-drain abort surfaces as an AbortError from the `for await`.
2345
+ // Break gracefully into the terminal block instead of re-throwing
2346
+ // (a re-throw would route the caller's abort into a second unbounded
2347
+ // fallback generate()). Dual check as with the inner catch:
2348
+ // effectiveSignal.aborted (a signal we tripped) OR isAbortError(error)
2349
+ // (an abort-shaped throw) — either means "stop", not a real failure.
2350
+ if (effectiveSignal.aborted || isAbortError(error)) {
2351
+ wasAborted = true;
2352
+ break;
2262
2353
  }
2354
+ logger.error("[GoogleVertex] Native SDK generate error", error);
2355
+ throw this.handleProviderError(error);
2356
+ }
2357
+ }
2358
+ // Handle maxSteps termination / abort — the loop exited because the step
2359
+ // cap was reached (or the turn was aborted) while the model was still
2360
+ // calling tools. Surface a real answer instead of the canned placeholder
2361
+ // (Bug 1) and a meaningful finishReason (Bug 2).
2362
+ if (!finalText && (step >= maxSteps || wasAborted)) {
2363
+ hitStepLimit = step >= maxSteps && !wasAborted;
2364
+ const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
2365
+ if (accumulatedText) {
2366
+ // Prefer the prose the model already produced across steps.
2367
+ logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
2368
+ `returning text already gathered from prior steps.`);
2369
+ finalText = accumulatedText;
2370
+ }
2371
+ else if (wasAborted) {
2372
+ // Aborted turn — skip synth entirely so it can never add +300s after
2373
+ // a blown budget. Deliver a graceful cap message as ordinary prose.
2374
+ logger.warn(`[GoogleVertex] Generate tool call loop aborted mid-turn; ` +
2375
+ `returning a graceful cap message.`);
2376
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
2263
2377
  }
2264
2378
  else {
2265
- finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
2379
+ // Pure functionCall turns leave no text make one tools-disabled call
2380
+ // so the model answers from the gathered tool results instead of the
2381
+ // canned placeholder.
2382
+ logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
2383
+ `with no text; synthesizing a final answer with tools disabled.`);
2384
+ // synth self-bounds its connect+drain via withTimeout(timeoutMs) and
2385
+ // never throws (returns empty on timeout/error), so it needs no outer
2386
+ // withTimeout wrapper — see synthesizeFinalAnswerWithoutTools.
2387
+ const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS, effectiveSignal);
2388
+ if (synth.text) {
2389
+ synthesizedFinalAnswer = true;
2390
+ finalText = synth.text;
2391
+ totalInputTokens += synth.inputTokens;
2392
+ totalOutputTokens += synth.outputTokens;
2393
+ if (synth.finishReason) {
2394
+ lastFinishReason = synth.finishReason;
2395
+ }
2396
+ }
2397
+ else {
2398
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
2399
+ }
2266
2400
  }
2267
2401
  }
2268
2402
  }
2403
+ finally {
2404
+ clearTimeout(defensiveTimer);
2405
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
2406
+ }
2269
2407
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
2270
2408
  // synthesized answer is reported as "tool-calls" (the model still wanted
2271
2409
  // tools) — NOT "length", which neurolink.ts treats as token truncation
@@ -2317,14 +2455,25 @@ export class GoogleVertexProvider extends BaseProvider {
2317
2455
  * (`final_result`) pattern was active, a trailing instruction countermands the
2318
2456
  * earlier "you MUST call final_result" directive so the model answers in plain
2319
2457
  * text. Never throws — returns empty text so the caller falls back to the
2320
- * placeholder, guaranteeing no new failure path.
2458
+ * graceful cap message (buildToolLoopCapMessage), guaranteeing no new failure
2459
+ * path.
2321
2460
  */
2322
- async synthesizeFinalAnswerWithoutTools(client, modelName, config, contents, useFinalResultTool, timeoutMs) {
2461
+ // eslint-disable-next-line max-params -- trailing optional abortSignal keeps the positional call sites stable
2462
+ async synthesizeFinalAnswerWithoutTools(client, modelName, config, contents, useFinalResultTool, timeoutMs, abortSignal) {
2463
+ // Already aborted — never issue the synth request (would add +300s after a
2464
+ // blown budget). Return empty so the caller maps to the graceful message.
2465
+ if (abortSignal?.aborted) {
2466
+ return { text: "", inputTokens: 0, outputTokens: 0 };
2467
+ }
2323
2468
  try {
2324
2469
  // Shallow clone so the loop's config is never mutated; dropping the
2325
2470
  // top-level `tools` key is sufficient (nested thinkingConfig /
2326
- // systemInstruction are intentionally preserved).
2327
- const synthConfig = { ...config };
2471
+ // systemInstruction are intentionally preserved). Fold in the abort
2472
+ // signal so the synth request is cancellable too.
2473
+ const synthConfig = {
2474
+ ...config,
2475
+ ...(abortSignal ? { abortSignal } : {}),
2476
+ };
2328
2477
  delete synthConfig.tools;
2329
2478
  if (useFinalResultTool) {
2330
2479
  const baseSystemInstruction = typeof synthConfig.systemInstruction === "string"
@@ -2686,6 +2835,13 @@ export class GoogleVertexProvider extends BaseProvider {
2686
2835
  // while generation is still in progress. Mirrors the executeStream
2687
2836
  // pattern in googleAiStudio.ts.
2688
2837
  const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
2838
+ // Reserve the LAST step of the budget for a forced final_result call when
2839
+ // structured output is active — see the generate twin for the full
2840
+ // rationale (tool_choice:"any" makes the text exit unreachable, so an
2841
+ // un-reserved budget deadlocks into an empty stream).
2842
+ const agenticStepBudget = useFinalResultTool
2843
+ ? Math.max(maxSteps - 1, 0)
2844
+ : maxSteps;
2689
2845
  const allToolCalls = [];
2690
2846
  const toolExecutions = [];
2691
2847
  const channel = createTextChannel();
@@ -2701,6 +2857,10 @@ export class GoogleVertexProvider extends BaseProvider {
2701
2857
  };
2702
2858
  const toolsUsedRef = [];
2703
2859
  const structuredOutputRef = {};
2860
+ // Resolved after the background loop finishes; the StreamResult exposes it
2861
+ // via a getter (consumers read it after draining the stream), mirroring
2862
+ // the Gemini stream path's finishReason field.
2863
+ const finishReasonRef = {};
2704
2864
  // Langfuse/OTel: the native SDK bypasses the Vercel AI SDK's
2705
2865
  // experimental_telemetry, so emit spans manually — one turn span, one
2706
2866
  // generation span per API call, one tool span per execution — all carrying
@@ -2737,6 +2897,11 @@ export class GoogleVertexProvider extends BaseProvider {
2737
2897
  });
2738
2898
  const turnContext = otelTrace.setSpan(otelContext.active(), turnSpan);
2739
2899
  let aggregatedTurnText = "";
2900
+ // Count of characters ALREADY delivered to the consumer via live text
2901
+ // deltas. aggregatedTurnText only reflects completed finalMessage()
2902
+ // responses, so an aborted mid-answer call would otherwise look "empty"
2903
+ // to the terminal handling even though the consumer saw partial prose.
2904
+ let liveTextPushedLength = 0;
2740
2905
  // Anthropic prompt-cache token accounting, aggregated across loop steps.
2741
2906
  const turnCacheUsage = {
2742
2907
  read: 0,
@@ -2767,10 +2932,23 @@ export class GoogleVertexProvider extends BaseProvider {
2767
2932
  const loopPromise = (async () => {
2768
2933
  let step = 0;
2769
2934
  const currentMessages = [...messages];
2935
+ // Step-cap / abort bookkeeping (mirrors the generate twin): read by the
2936
+ // terminal recovery block after the loop and by the finishReason
2937
+ // mapping written into finishReasonRef.
2938
+ let wasAborted = false;
2939
+ let hitStepLimit = false;
2940
+ let synthesizedFinalAnswer = false;
2941
+ let modelFinished = false;
2942
+ let lastStopReason;
2770
2943
  try {
2771
- while (step < maxSteps) {
2944
+ while (step < agenticStepBudget) {
2945
+ // Honor caller aborts BETWEEN steps: break into terminal handling
2946
+ // (one graceful cap chunk, clean close) instead of throwing —
2947
+ // channel.error would surface the caller's own abort as a stream
2948
+ // failure and route consumers into fallback retries.
2772
2949
  if (options.abortSignal?.aborted) {
2773
- throw new Error("Stream aborted by caller");
2950
+ wasAborted = true;
2951
+ break;
2774
2952
  }
2775
2953
  step++;
2776
2954
  // One generation observation per API call: request in, content + usage out.
@@ -2834,6 +3012,7 @@ export class GoogleVertexProvider extends BaseProvider {
2834
3012
  generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_COMPLETION_START_TIME, new Date().toISOString());
2835
3013
  }
2836
3014
  channel.push(delta);
3015
+ liveTextPushedLength += delta.length;
2837
3016
  }
2838
3017
  });
2839
3018
  // finalMessage() resolves AFTER message_stop. By then the listener
@@ -2853,6 +3032,15 @@ export class GoogleVertexProvider extends BaseProvider {
2853
3032
  generationSpan.recordException(modelCallError);
2854
3033
  }
2855
3034
  generationSpan.end();
3035
+ // A mid-flight abort (caller signal or the defensive timer
3036
+ // tripping abortHandler) rejects finalMessage() with an
3037
+ // abort-shaped error. Break gracefully into the terminal handling
3038
+ // instead of routing it through channel.error as a failure.
3039
+ if (options.abortSignal?.aborted || isAbortError(modelCallError)) {
3040
+ activeStream = undefined;
3041
+ wasAborted = true;
3042
+ break;
3043
+ }
2856
3044
  throw modelCallError;
2857
3045
  }
2858
3046
  activeStream = undefined;
@@ -2870,6 +3058,7 @@ export class GoogleVertexProvider extends BaseProvider {
2870
3058
  usage.input += response.usage?.input_tokens || 0;
2871
3059
  usage.output += response.usage?.output_tokens || 0;
2872
3060
  usage.total = usage.input + usage.output;
3061
+ lastStopReason = response.stop_reason;
2873
3062
  for (const block of response.content) {
2874
3063
  if (block.type === "text" && typeof block.text === "string") {
2875
3064
  aggregatedTurnText += block.text;
@@ -2911,6 +3100,7 @@ export class GoogleVertexProvider extends BaseProvider {
2911
3100
  if (finalResultCall) {
2912
3101
  structuredOutputRef.value = finalResultCall.input;
2913
3102
  channel.push(JSON.stringify(finalResultCall.input));
3103
+ modelFinished = true;
2914
3104
  logger.debug("[GoogleVertex] Extracted structured output from final_result tool (stream)", { keys: Object.keys(finalResultCall.input) });
2915
3105
  break;
2916
3106
  }
@@ -2918,11 +3108,14 @@ export class GoogleVertexProvider extends BaseProvider {
2918
3108
  // No tools — pure text turn. Listener already pushed all deltas;
2919
3109
  // loop terminates and channel.close() flushes the consumer.
2920
3110
  if (toolUseBlocks.length === 0) {
3111
+ modelFinished = true;
2921
3112
  break;
2922
3113
  }
2923
3114
  // Tool execution loop. tool:start / tool:end events fire from
2924
3115
  // ToolsManager's wrapped execute (ToolsManager.ts:355) — no inline
2925
- // emit needed.
3116
+ // emit needed. The array also carries the trailing soft-budget
3117
+ // nudge text block appended below (tool_result blocks stay first,
3118
+ // as the Anthropic API requires).
2926
3119
  const toolResults = [];
2927
3120
  // Per-step bookkeeping for conversation-memory storage.
2928
3121
  const stepStorageCalls = [];
@@ -3010,6 +3203,23 @@ export class GoogleVertexProvider extends BaseProvider {
3010
3203
  });
3011
3204
  }
3012
3205
  catch (err) {
3206
+ // An aborted tool call is a cancellation, not a tool failure —
3207
+ // end the span without recording an error execution/result and
3208
+ // break the turn.
3209
+ if (options.abortSignal?.aborted || isAbortError(err)) {
3210
+ endToolSpan({ aborted: true });
3211
+ // Keep persisted tool history paired: the call row was
3212
+ // already pushed above, so record a neutral cancellation
3213
+ // result (NOT an error) — an unpaired tool_use replayed on
3214
+ // the next turn would be rejected by the Anthropic API.
3215
+ stepStorageResults.push({
3216
+ toolCallId: toolUse.id,
3217
+ toolName: toolUse.name,
3218
+ output: { aborted: true },
3219
+ });
3220
+ wasAborted = true;
3221
+ break;
3222
+ }
3013
3223
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3014
3224
  const errorPayload = { error: errMsg };
3015
3225
  endToolSpan(errorPayload, errMsg);
@@ -3053,7 +3263,9 @@ export class GoogleVertexProvider extends BaseProvider {
3053
3263
  }
3054
3264
  // Persist this step's tool calls/results into conversation memory.
3055
3265
  // Without this hook, tool rows never land in Redis and the
3056
- // chat-history UI loses every tool invocation.
3266
+ // chat-history UI loses every tool invocation. Runs BEFORE the
3267
+ // abort break below so tools that DID complete in an aborted step
3268
+ // (real side effects) still reach the chat history.
3057
3269
  if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
3058
3270
  withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c) => ({ ...c, stepIndex: step })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
3059
3271
  logger.warn("[GoogleVertex] Failed to store native Anthropic stream tool executions", {
@@ -3061,6 +3273,26 @@ export class GoogleVertexProvider extends BaseProvider {
3061
3273
  });
3062
3274
  });
3063
3275
  }
3276
+ // An abort inside the tool-exec loop only breaks that inner
3277
+ // for-loop. Break the while too so no further model call is issued
3278
+ // and control reaches the terminal step-cap handling below.
3279
+ if (wasAborted) {
3280
+ break;
3281
+ }
3282
+ // Soft budget nudge: with the step cap approaching, tell the model
3283
+ // to wrap up so the reserved forced-finalization call below stays a
3284
+ // fallback, not the norm. Rides as a trailing text block on the
3285
+ // tool_result user turn (cache-safe: it lives in the growing tail).
3286
+ const stepsRemaining = agenticStepBudget - step;
3287
+ if (stepsRemaining > 0 && stepsRemaining <= 3) {
3288
+ toolResults.push({
3289
+ type: "text",
3290
+ text: `NOTE: Only ${stepsRemaining} tool step(s) remain. Consolidate what you have and ` +
3291
+ (useFinalResultTool
3292
+ ? "call final_result with your best answer."
3293
+ : "provide your final answer."),
3294
+ });
3295
+ }
3064
3296
  // Continue the loop: assistant turn + tool_result user turn.
3065
3297
  // Filter server_tool_use blocks (Anthropic API rejects them in
3066
3298
  // subsequent message turns).
@@ -3074,6 +3306,220 @@ export class GoogleVertexProvider extends BaseProvider {
3074
3306
  content: toolResults,
3075
3307
  });
3076
3308
  }
3309
+ // Terminal handling — the loop exited without a model-initiated
3310
+ // finish (step budget exhausted, or the turn was aborted). Never end
3311
+ // the stream empty: force the reserved final_result step on
3312
+ // structured-output turns, synthesize a plain-text answer on tool
3313
+ // turns, or push one graceful cap chunk. Mirrors the generate twin
3314
+ // and the Gemini paths (ed289b7 / PR #1123). The finalization and
3315
+ // backstop calls emit no per-call generation span (matching the
3316
+ // Gemini synthesizeFinalAnswerWithoutTools precedent); their tokens
3317
+ // still land in `usage` and the turn-span metadata.
3318
+ if (!modelFinished) {
3319
+ // A caller abort that landed during the FINAL budgeted step's tool
3320
+ // execution leaves wasAborted=false (no loop-entry check runs after
3321
+ // a budget exit) — re-check before issuing any terminal model call.
3322
+ if (options.abortSignal?.aborted) {
3323
+ wasAborted = true;
3324
+ }
3325
+ const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3326
+ if (wasAborted) {
3327
+ // Budget already blown — never issue another model call. Deliver
3328
+ // exactly one graceful cap chunk if nothing reached the consumer
3329
+ // (live deltas already delivered count as "something reached").
3330
+ logger.warn("[GoogleVertex] Native Anthropic stream loop aborted mid-turn; returning a graceful cap message.");
3331
+ if (aggregatedTurnText.length === 0 &&
3332
+ liveTextPushedLength === 0 &&
3333
+ !structuredOutputRef.value) {
3334
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3335
+ channel.push(capMessage);
3336
+ aggregatedTurnText = capMessage;
3337
+ }
3338
+ }
3339
+ else if (useFinalResultTool) {
3340
+ hitStepLimit = true;
3341
+ logger.warn(`[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
3342
+ try {
3343
+ // Reserved finalization step: identical request except
3344
+ // tool_choice pins final_result — Anthropic guarantees a
3345
+ // final_result tool_use. Cache treatment is byte-identical to
3346
+ // loop steps. No text listener: forced tool calls stream no
3347
+ // text deltas, and the structured JSON is pushed once below.
3348
+ const cachedFinal = applyVertexAnthropicCacheBreakpoints({
3349
+ system: systemPromptWithSchema,
3350
+ tools,
3351
+ messages: currentMessages,
3352
+ });
3353
+ const finalizationStream = await client.messages.stream({
3354
+ ...requestParams,
3355
+ tool_choice: {
3356
+ type: "tool",
3357
+ name: "final_result",
3358
+ },
3359
+ ...(cachedFinal.system !== undefined && {
3360
+ system: cachedFinal.system,
3361
+ }),
3362
+ ...(cachedFinal.tools &&
3363
+ cachedFinal.tools.length > 0 && {
3364
+ tools: cachedFinal.tools,
3365
+ }),
3366
+ messages: cachedFinal.messages,
3367
+ });
3368
+ // Mid-flight aborts are covered by the shared abortHandler via
3369
+ // activeStream; already-fired aborts were handled by the
3370
+ // terminal-entry re-check above.
3371
+ activeStream = finalizationStream;
3372
+ const response = await finalizationStream.finalMessage();
3373
+ activeStream = undefined;
3374
+ usage.input += response.usage?.input_tokens || 0;
3375
+ usage.output += response.usage?.output_tokens || 0;
3376
+ usage.total = usage.input + usage.output;
3377
+ turnCacheUsage.read +=
3378
+ response.usage?.cache_read_input_tokens ?? 0;
3379
+ turnCacheUsage.creation +=
3380
+ response.usage?.cache_creation_input_tokens ?? 0;
3381
+ turnCacheUsage.creation5m +=
3382
+ response.usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
3383
+ turnCacheUsage.creation1h +=
3384
+ response.usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
3385
+ lastStopReason = response.stop_reason;
3386
+ const forcedFinalResult = response.content.find((block) => block.type === "tool_use" && block.name === "final_result");
3387
+ if (forcedFinalResult) {
3388
+ structuredOutputRef.value = forcedFinalResult.input;
3389
+ channel.push(JSON.stringify(forcedFinalResult.input));
3390
+ synthesizedFinalAnswer = true;
3391
+ logger.debug("[GoogleVertex] Forced finalization returned structured output (stream)", { keys: Object.keys(forcedFinalResult.input) });
3392
+ }
3393
+ else {
3394
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3395
+ channel.push(capMessage);
3396
+ aggregatedTurnText += capMessage;
3397
+ }
3398
+ }
3399
+ catch (error) {
3400
+ activeStream = undefined;
3401
+ // An aborted finalization is a cancellation, not a step-cap
3402
+ // turn — keep finishReason mapping from lastStopReason.
3403
+ if (options.abortSignal?.aborted || isAbortError(error)) {
3404
+ hitStepLimit = false;
3405
+ }
3406
+ logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", {
3407
+ error: error instanceof Error ? error.message : String(error),
3408
+ });
3409
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3410
+ channel.push(capMessage);
3411
+ aggregatedTurnText += capMessage;
3412
+ }
3413
+ }
3414
+ else {
3415
+ hitStepLimit = true;
3416
+ if (aggregatedTurnText.length === 0) {
3417
+ // Pure tool-loop with no streamed text: one tools-disabled call
3418
+ // so the model answers from the gathered tool results, pushed
3419
+ // as a single chunk (matching the Gemini stream synth). NOTE:
3420
+ // the tools array must stay in the request — Anthropic rejects
3421
+ // histories containing tool_use/tool_result blocks without a
3422
+ // tools param — so "tools disabled" is tool_choice:"none",
3423
+ // which also keeps the cached tools prefix byte-identical.
3424
+ logger.warn(`[GoogleVertex] Native Anthropic stream loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
3425
+ try {
3426
+ const backstopSystem = (systemPromptWithSchema
3427
+ ? systemPromptWithSchema + "\n\n"
3428
+ : "") +
3429
+ "Tool calling is no longer available for this turn. " +
3430
+ "Provide your final answer directly as plain text now, " +
3431
+ "using the information gathered so far.";
3432
+ const cachedBackstop = applyVertexAnthropicCacheBreakpoints({
3433
+ system: backstopSystem,
3434
+ tools,
3435
+ messages: currentMessages,
3436
+ });
3437
+ const backstopStream = await client.messages.stream({
3438
+ ...requestParams,
3439
+ tool_choice: { type: "none" },
3440
+ system: cachedBackstop.system,
3441
+ ...(cachedBackstop.tools &&
3442
+ cachedBackstop.tools.length > 0 && {
3443
+ tools: cachedBackstop.tools,
3444
+ }),
3445
+ messages: cachedBackstop.messages,
3446
+ });
3447
+ activeStream = backstopStream;
3448
+ const response = await backstopStream.finalMessage();
3449
+ activeStream = undefined;
3450
+ usage.input += response.usage?.input_tokens || 0;
3451
+ usage.output += response.usage?.output_tokens || 0;
3452
+ usage.total = usage.input + usage.output;
3453
+ turnCacheUsage.read +=
3454
+ response.usage?.cache_read_input_tokens ?? 0;
3455
+ turnCacheUsage.creation +=
3456
+ response.usage?.cache_creation_input_tokens ?? 0;
3457
+ turnCacheUsage.creation5m +=
3458
+ response.usage?.cache_creation?.ephemeral_5m_input_tokens ??
3459
+ 0;
3460
+ turnCacheUsage.creation1h +=
3461
+ response.usage?.cache_creation?.ephemeral_1h_input_tokens ??
3462
+ 0;
3463
+ lastStopReason = response.stop_reason;
3464
+ const backstopText = response.content
3465
+ .filter((block) => block.type === "text")
3466
+ .map((b) => b.text)
3467
+ .join("");
3468
+ if (backstopText) {
3469
+ synthesizedFinalAnswer = true;
3470
+ channel.push(backstopText);
3471
+ aggregatedTurnText = backstopText;
3472
+ }
3473
+ else {
3474
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3475
+ channel.push(capMessage);
3476
+ aggregatedTurnText = capMessage;
3477
+ }
3478
+ }
3479
+ catch (error) {
3480
+ activeStream = undefined;
3481
+ // An aborted backstop is a cancellation, not a step-cap
3482
+ // turn — keep finishReason mapping from lastStopReason.
3483
+ if (options.abortSignal?.aborted || isAbortError(error)) {
3484
+ hitStepLimit = false;
3485
+ }
3486
+ logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to cap message", {
3487
+ error: error instanceof Error ? error.message : String(error),
3488
+ });
3489
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
3490
+ channel.push(capMessage);
3491
+ aggregatedTurnText = capMessage;
3492
+ }
3493
+ }
3494
+ // else: prose already streamed to the consumer across steps —
3495
+ // add nothing; finishReason "tool-calls" flags the capped turn.
3496
+ }
3497
+ }
3498
+ // Absolute backstop — never close the stream empty on a turn that
3499
+ // ran tools (mirrors the generate twin's guard). Catches the
3500
+ // model-finished-with-empty-content exit, which skips the terminal
3501
+ // recovery above.
3502
+ const deliveredNothing = aggregatedTurnText.length === 0 &&
3503
+ liveTextPushedLength === 0 &&
3504
+ !structuredOutputRef.value;
3505
+ const externalToolCallTotal = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3506
+ if (deliveredNothing && externalToolCallTotal > 0) {
3507
+ const capMessage = buildToolLoopCapMessage(maxSteps, externalToolCallTotal);
3508
+ channel.push(capMessage);
3509
+ aggregatedTurnText = capMessage;
3510
+ }
3511
+ // Honest finish reason (same mapping as the generate twin): "length"
3512
+ // takes precedence, a capped turn without a clean/forced answer is
3513
+ // "tool-calls", everything else is "stop". Mirrored onto metadata
3514
+ // (a mutable reference) because wrapper spreads snapshot the
3515
+ // top-level getter before this line runs.
3516
+ finishReasonRef.value =
3517
+ lastStopReason === "max_tokens"
3518
+ ? "length"
3519
+ : hitStepLimit && !synthesizedFinalAnswer
3520
+ ? "tool-calls"
3521
+ : "stop";
3522
+ metadata.finishReason = finishReasonRef.value;
3077
3523
  metadata.responseTime = Date.now() - startTime;
3078
3524
  metadata.totalToolExecutions = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3079
3525
  // Surface cache metrics once, after the agentic loop, from the
@@ -3169,6 +3615,13 @@ export class GoogleVertexProvider extends BaseProvider {
3169
3615
  configurable: true,
3170
3616
  get: () => structuredOutputRef.value,
3171
3617
  });
3618
+ // Resolved by the background loop right before channel.close(); consumers
3619
+ // read it after draining the stream (same contract as usage/metadata).
3620
+ Object.defineProperty(result, "finishReason", {
3621
+ enumerable: true,
3622
+ configurable: true,
3623
+ get: () => finishReasonRef.value,
3624
+ });
3172
3625
  return result;
3173
3626
  }
3174
3627
  /**
@@ -3432,8 +3885,21 @@ export class GoogleVertexProvider extends BaseProvider {
3432
3885
  };
3433
3886
  // Handle tool calling loop with max steps
3434
3887
  const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
3888
+ // Reserve the LAST step of the budget for a forced final_result call when
3889
+ // structured output is active. tool_choice:"any" (set above) forces a tool
3890
+ // call on every assistant turn, so the toolUseBlocks.length===0 text exit
3891
+ // is structurally unreachable — without a reserved finalization step, a
3892
+ // turn whose budget is consumed by other tool calls (runaway loop, or all
3893
+ // tools failing) ends with content:"" and a masking finishReason "stop".
3894
+ const agenticStepBudget = useFinalResultTool
3895
+ ? Math.max(maxSteps - 1, 0)
3896
+ : maxSteps;
3435
3897
  let step = 0;
3436
3898
  let finalText = "";
3899
+ // Prose produced mid-loop alongside tool calls, accumulated across steps
3900
+ // via appendStepText — surfaced if the step budget runs out (mirrors the
3901
+ // Gemini paths' accumulatedText; last-step-only would drop earlier prose).
3902
+ let accumulatedStepText = "";
3437
3903
  let structuredOutput;
3438
3904
  const allToolCalls = [];
3439
3905
  let totalInputTokens = 0;
@@ -3448,8 +3914,22 @@ export class GoogleVertexProvider extends BaseProvider {
3448
3914
  // (notably "length" on token truncation) — the legacy native path always
3449
3915
  // reported "stop", hiding truncation from callers.
3450
3916
  let lastStopReason;
3917
+ // Step-cap / abort bookkeeping (mirrors the native Gemini loops): the
3918
+ // terminal recovery block below and the finishReason mapping both read
3919
+ // these to distinguish clean finishes, capped turns, and aborted turns.
3920
+ let wasAborted = false;
3921
+ let hitStepLimit = false;
3922
+ let synthesizedFinalAnswer = false;
3923
+ let modelFinished = false;
3451
3924
  const currentMessages = [...messages];
3452
- while (step < maxSteps) {
3925
+ while (step < agenticStepBudget) {
3926
+ // Honor caller aborts BETWEEN steps: break into terminal handling
3927
+ // instead of throwing (a throw routes consumers into abortSignal-less
3928
+ // fallback retries — observed in production as a 600s abort no-op).
3929
+ if (options.abortSignal?.aborted) {
3930
+ wasAborted = true;
3931
+ break;
3932
+ }
3453
3933
  step++;
3454
3934
  try {
3455
3935
  // Bound the SDK wait so a stalled Vertex/Anthropic call can't hang
@@ -3466,6 +3946,9 @@ export class GoogleVertexProvider extends BaseProvider {
3466
3946
  tools,
3467
3947
  messages: currentMessages,
3468
3948
  });
3949
+ // The caller's abortSignal rides as an SDK request option so a
3950
+ // mid-flight abort cancels the HTTP call itself (the SDK rejects with
3951
+ // an abort-shaped error the per-step catch below turns into a break).
3469
3952
  const response = await withTimeout(client.messages.create({
3470
3953
  ...requestParams,
3471
3954
  ...(cachedGenerate.system !== undefined && {
@@ -3476,7 +3959,7 @@ export class GoogleVertexProvider extends BaseProvider {
3476
3959
  tools: cachedGenerate.tools,
3477
3960
  }),
3478
3961
  messages: cachedGenerate.messages,
3479
- }), generateTimeoutMs, "Anthropic generate timed out");
3962
+ }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic generate timed out");
3480
3963
  // Update token counts. input_tokens is the uncached remainder; cache
3481
3964
  // reads/writes are reported separately and accumulated here so the
3482
3965
  // result reflects the full picture.
@@ -3495,6 +3978,7 @@ export class GoogleVertexProvider extends BaseProvider {
3495
3978
  // Extract structured output and convert to JSON string for finalText
3496
3979
  structuredOutput = finalResultCall.input;
3497
3980
  finalText = JSON.stringify(structuredOutput);
3981
+ modelFinished = true;
3498
3982
  logger.debug("[GoogleVertex] Extracted structured output from final_result tool (generate)", { keys: Object.keys(structuredOutput) });
3499
3983
  break; // We have the structured output, we're done
3500
3984
  }
@@ -3504,10 +3988,13 @@ export class GoogleVertexProvider extends BaseProvider {
3504
3988
  const responseText = textBlocks.map((b) => b.text).join("");
3505
3989
  if (toolUseBlocks.length === 0) {
3506
3990
  // No tool calls, we're done
3507
- finalText = responseText || finalText;
3991
+ finalText = responseText || accumulatedStepText;
3992
+ modelFinished = true;
3508
3993
  break;
3509
3994
  }
3510
- // Handle tool calls
3995
+ // Handle tool calls. The array also carries the trailing soft-budget
3996
+ // nudge text block appended below (tool_result blocks stay first, as
3997
+ // the Anthropic API requires).
3511
3998
  const toolResults = [];
3512
3999
  // Per-step bookkeeping for conversation-memory storage. Tracks calls
3513
4000
  // and results for ONLY the tools fired in this step so the storage
@@ -3558,6 +4045,21 @@ export class GoogleVertexProvider extends BaseProvider {
3558
4045
  });
3559
4046
  }
3560
4047
  catch (err) {
4048
+ // An aborted tool call is a cancellation, not a tool failure —
4049
+ // break the turn without recording an error execution/result.
4050
+ if (options.abortSignal?.aborted || isAbortError(err)) {
4051
+ // Keep persisted tool history paired: the call row was
4052
+ // already pushed above, so record a neutral cancellation
4053
+ // result (NOT an error) — an unpaired tool_use replayed on
4054
+ // the next turn would be rejected by the Anthropic API.
4055
+ stepStorageResults.push({
4056
+ toolCallId: toolUse.id,
4057
+ toolName: toolUse.name,
4058
+ output: { aborted: true },
4059
+ });
4060
+ wasAborted = true;
4061
+ break;
4062
+ }
3561
4063
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3562
4064
  const errorPayload = { error: errMsg };
3563
4065
  toolExecutions.push({
@@ -3601,6 +4103,8 @@ export class GoogleVertexProvider extends BaseProvider {
3601
4103
  // Without this, tool_call / tool_result rows never reach Redis and
3602
4104
  // the chat-history UI loses every tool invocation.
3603
4105
  // Fire-and-forget — storage failures must not break generation.
4106
+ // Runs BEFORE the abort break below so tools that DID complete in an
4107
+ // aborted step (real side effects) still reach the chat history.
3604
4108
  if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
3605
4109
  withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c) => ({ ...c, stepIndex: step })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
3606
4110
  logger.warn("[GoogleVertex] Failed to store native Anthropic generate tool executions", {
@@ -3608,6 +4112,26 @@ export class GoogleVertexProvider extends BaseProvider {
3608
4112
  });
3609
4113
  });
3610
4114
  }
4115
+ // An abort inside the tool-exec loop only breaks that inner for-loop.
4116
+ // Break the while too so no further model call is issued and control
4117
+ // reaches the terminal step-cap handling below.
4118
+ if (wasAborted) {
4119
+ break;
4120
+ }
4121
+ // Soft budget nudge: with the step cap approaching, tell the model to
4122
+ // wrap up so the reserved forced-finalization call below stays a
4123
+ // fallback, not the norm. Rides as a trailing text block on the
4124
+ // tool_result user turn (cache-safe: it lives in the growing tail).
4125
+ const stepsRemaining = agenticStepBudget - step;
4126
+ if (stepsRemaining > 0 && stepsRemaining <= 3) {
4127
+ toolResults.push({
4128
+ type: "text",
4129
+ text: `NOTE: Only ${stepsRemaining} tool step(s) remain. Consolidate what you have and ` +
4130
+ (useFinalResultTool
4131
+ ? "call final_result with your best answer."
4132
+ : "provide your final answer."),
4133
+ });
4134
+ }
3611
4135
  // Add assistant message and tool results to continue the loop
3612
4136
  // Filter out server_tool_use blocks that the Anthropic API doesn't accept in messages
3613
4137
  const assistantContent = response.content.filter((block) => block.type !== "server_tool_use");
@@ -3619,25 +4143,193 @@ export class GoogleVertexProvider extends BaseProvider {
3619
4143
  role: "user",
3620
4144
  content: toolResults,
3621
4145
  });
3622
- // Store last text in case we hit max steps
3623
- if (responseText) {
3624
- finalText = responseText;
3625
- }
4146
+ // Accumulate the step's prose so a capped turn can still surface it —
4147
+ // finalText is reserved for model-initiated finishes.
4148
+ accumulatedStepText = appendStepText(accumulatedStepText, responseText);
3626
4149
  }
3627
4150
  catch (error) {
4151
+ // A mid-request abort surfaces as an abort-shaped SDK rejection (we
4152
+ // pass options.abortSignal as the request signal above). Break
4153
+ // gracefully into the terminal handling instead of re-throwing — a
4154
+ // re-throw routes the caller's abort into abortSignal-less fallback
4155
+ // retries. Dual check as in the Gemini loops: the caller's signal OR
4156
+ // an abort-shaped error either way means "stop", not a real failure.
4157
+ if (options.abortSignal?.aborted || isAbortError(error)) {
4158
+ wasAborted = true;
4159
+ break;
4160
+ }
3628
4161
  logger.error("[GoogleVertex] Native Anthropic SDK generate error", error);
3629
4162
  throw this.handleProviderError(error);
3630
4163
  }
3631
4164
  }
4165
+ // Terminal handling — the loop exited without a model-initiated finish
4166
+ // (step budget exhausted, or the turn was aborted). Never return "":
4167
+ // force the reserved final_result step on structured-output turns,
4168
+ // synthesize a plain-text answer on tool turns, or fall back to a
4169
+ // graceful cap message. Mirrors the Gemini paths (ed289b7 / PR #1123).
4170
+ if (!modelFinished && !finalText) {
4171
+ // A caller abort that landed during the FINAL budgeted step's tool
4172
+ // execution leaves wasAborted=false (no loop-entry check runs after a
4173
+ // budget exit) — re-check before issuing any terminal model call.
4174
+ if (options.abortSignal?.aborted) {
4175
+ wasAborted = true;
4176
+ }
4177
+ const externalToolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
4178
+ if (wasAborted) {
4179
+ // Budget already blown — never issue another model call; prefer the
4180
+ // prose the model already produced (mirrors the Gemini abort path),
4181
+ // else answer with a graceful cap message. finishReason keeps mapping
4182
+ // from lastStopReason (an aborted turn is not a step-cap turn).
4183
+ logger.warn("[GoogleVertex] Native Anthropic generate loop aborted mid-turn; returning gathered text or a graceful cap message.");
4184
+ finalText =
4185
+ accumulatedStepText ||
4186
+ buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4187
+ }
4188
+ else if (useFinalResultTool) {
4189
+ hitStepLimit = true;
4190
+ logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) without final_result; forcing a finalization call.`);
4191
+ try {
4192
+ // Reserved finalization step: identical request except tool_choice
4193
+ // pins final_result, so Anthropic guarantees the response is a
4194
+ // final_result tool_use. Cache treatment is byte-identical to loop
4195
+ // steps (same applyVertexAnthropicCacheBreakpoints on the same
4196
+ // stable system/tools prefix), so caching is unaffected.
4197
+ const cachedFinal = applyVertexAnthropicCacheBreakpoints({
4198
+ system: systemPromptWithSchema,
4199
+ tools,
4200
+ messages: currentMessages,
4201
+ });
4202
+ const response = await withTimeout(client.messages.create({
4203
+ ...requestParams,
4204
+ tool_choice: {
4205
+ type: "tool",
4206
+ name: "final_result",
4207
+ },
4208
+ ...(cachedFinal.system !== undefined && {
4209
+ system: cachedFinal.system,
4210
+ }),
4211
+ ...(cachedFinal.tools &&
4212
+ cachedFinal.tools.length > 0 && {
4213
+ tools: cachedFinal.tools,
4214
+ }),
4215
+ messages: cachedFinal.messages,
4216
+ }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic finalization call timed out");
4217
+ totalInputTokens += response.usage?.input_tokens || 0;
4218
+ totalOutputTokens += response.usage?.output_tokens || 0;
4219
+ totalCacheReadTokens += response.usage?.cache_read_input_tokens || 0;
4220
+ totalCacheCreationTokens +=
4221
+ response.usage?.cache_creation_input_tokens || 0;
4222
+ lastStopReason = response.stop_reason;
4223
+ const forcedFinalResult = response.content.find((block) => block.type === "tool_use" && block.name === "final_result");
4224
+ if (forcedFinalResult) {
4225
+ structuredOutput = forcedFinalResult.input;
4226
+ finalText = JSON.stringify(structuredOutput);
4227
+ synthesizedFinalAnswer = true;
4228
+ logger.debug("[GoogleVertex] Forced finalization returned structured output (generate)", { keys: Object.keys(structuredOutput) });
4229
+ }
4230
+ else {
4231
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4232
+ }
4233
+ }
4234
+ catch (error) {
4235
+ // An aborted finalization is a cancellation, not a step-cap turn —
4236
+ // keep finishReason mapping from lastStopReason, not "tool-calls".
4237
+ if (options.abortSignal?.aborted || isAbortError(error)) {
4238
+ hitStepLimit = false;
4239
+ }
4240
+ logger.warn("[GoogleVertex] Forced finalization call failed; falling back to cap message", { error: error instanceof Error ? error.message : String(error) });
4241
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4242
+ }
4243
+ }
4244
+ else {
4245
+ hitStepLimit = true;
4246
+ if (accumulatedStepText) {
4247
+ // Prefer the prose the model already produced across steps.
4248
+ logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}); returning text already gathered from prior steps.`);
4249
+ finalText = accumulatedStepText;
4250
+ }
4251
+ else {
4252
+ // Pure tool-loop with no text: one tools-disabled call so the model
4253
+ // answers from the gathered tool results (Anthropic twin of the
4254
+ // Gemini synthesizeFinalAnswerWithoutTools). NOTE: the tools array
4255
+ // must stay in the request — Anthropic rejects requests whose
4256
+ // history contains tool_use/tool_result blocks without a tools
4257
+ // param — so "tools disabled" is expressed as tool_choice:"none",
4258
+ // which also keeps the cached tools prefix byte-identical.
4259
+ logger.warn(`[GoogleVertex] Native Anthropic generate loop reached maxSteps (${maxSteps}) with no text; synthesizing a final answer with tools disabled.`);
4260
+ try {
4261
+ const backstopSystem = (systemPromptWithSchema ? systemPromptWithSchema + "\n\n" : "") +
4262
+ "Tool calling is no longer available for this turn. Provide " +
4263
+ "your final answer directly as plain text now, using the " +
4264
+ "information gathered so far.";
4265
+ const cachedBackstop = applyVertexAnthropicCacheBreakpoints({
4266
+ system: backstopSystem,
4267
+ tools,
4268
+ messages: currentMessages,
4269
+ });
4270
+ const response = await withTimeout(client.messages.create({
4271
+ ...requestParams,
4272
+ tool_choice: { type: "none" },
4273
+ system: cachedBackstop.system,
4274
+ ...(cachedBackstop.tools &&
4275
+ cachedBackstop.tools.length > 0 && {
4276
+ tools: cachedBackstop.tools,
4277
+ }),
4278
+ messages: cachedBackstop.messages,
4279
+ }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic backstop call timed out");
4280
+ totalInputTokens += response.usage?.input_tokens || 0;
4281
+ totalOutputTokens += response.usage?.output_tokens || 0;
4282
+ totalCacheReadTokens +=
4283
+ response.usage?.cache_read_input_tokens || 0;
4284
+ totalCacheCreationTokens +=
4285
+ response.usage?.cache_creation_input_tokens || 0;
4286
+ lastStopReason = response.stop_reason;
4287
+ const backstopText = response.content
4288
+ .filter((block) => block.type === "text")
4289
+ .map((b) => b.text)
4290
+ .join("");
4291
+ if (backstopText) {
4292
+ synthesizedFinalAnswer = true;
4293
+ finalText = backstopText;
4294
+ }
4295
+ else {
4296
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4297
+ }
4298
+ }
4299
+ catch (error) {
4300
+ // An aborted backstop is a cancellation, not a step-cap turn —
4301
+ // keep finishReason mapping from lastStopReason, not "tool-calls".
4302
+ if (options.abortSignal?.aborted || isAbortError(error)) {
4303
+ hitStepLimit = false;
4304
+ }
4305
+ logger.warn("[GoogleVertex] Tools-disabled backstop call failed; falling back to cap message", {
4306
+ error: error instanceof Error ? error.message : String(error),
4307
+ });
4308
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCallCount);
4309
+ }
4310
+ }
4311
+ }
4312
+ }
3632
4313
  const responseTime = Date.now() - startTime;
3633
4314
  const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
3634
4315
  const externalToolExecutions = toolExecutions.filter((te) => te.name !== "final_result");
4316
+ // Absolute backstop — no code path may surface empty content on a turn
4317
+ // that ran tools (the consumer-facing "empty response" incident shape).
4318
+ if (!finalText && externalToolCalls.length > 0) {
4319
+ finalText = buildToolLoopCapMessage(maxSteps, externalToolCalls.length);
4320
+ }
3635
4321
  const result = {
3636
4322
  content: finalText,
3637
- // Surface truncation: Anthropic "max_tokens" unified "length" so the
3638
- // SDK boundary can flag/observe incomplete structured output. Anything
3639
- // else (end_turn / stop_sequence / tool_use) is a normal stop.
3640
- finishReason: lastStopReason === "max_tokens" ? "length" : "stop",
4323
+ // Honest finish reason. "length" (token truncation) takes precedence; a
4324
+ // step-cap exhaustion without a clean/forced answer surfaces as
4325
+ // "tool-calls" (aligned with the Gemini paths, so consumers detect
4326
+ // capped turns uniformly); everything else including a successful
4327
+ // forced finalization or backstop synthesis — is a normal "stop".
4328
+ finishReason: lastStopReason === "max_tokens"
4329
+ ? "length"
4330
+ : hitStepLimit && !synthesizedFinalAnswer
4331
+ ? "tool-calls"
4332
+ : "stop",
3641
4333
  provider: this.providerName,
3642
4334
  model: modelName,
3643
4335
  usage: {
@@ -4106,10 +4798,35 @@ export class GoogleVertexProvider extends BaseProvider {
4106
4798
  }
4107
4799
  },
4108
4800
  };
4109
- return {
4801
+ return this.preserveStreamResultAccessors(result, {
4110
4802
  ...result,
4111
4803
  stream: wrappedIterable,
4112
- };
4804
+ });
4805
+ }
4806
+ /**
4807
+ * Re-apply getter-based accessor properties from a source StreamResult onto
4808
+ * a wrapper copy. Wrapper spreads (`{ ...result }`) invoke and SNAPSHOT
4809
+ * enumerable getters at wrap time — for background-loop streams (the native
4810
+ * Anthropic path) that resolve finishReason / structuredOutput / toolCalls
4811
+ * only as the consumer drains, the snapshot is permanently undefined/empty.
4812
+ * Copying the accessor descriptors keeps the wrapped result live. Results
4813
+ * built from plain data properties (the buffered Gemini paths) have no
4814
+ * getters and pass through untouched.
4815
+ */
4816
+ preserveStreamResultAccessors(source, wrapped) {
4817
+ for (const key of [
4818
+ "finishReason",
4819
+ "structuredOutput",
4820
+ "toolCalls",
4821
+ "toolsUsed",
4822
+ "toolExecutions",
4823
+ ]) {
4824
+ const descriptor = Object.getOwnPropertyDescriptor(source, key);
4825
+ if (descriptor?.get) {
4826
+ Object.defineProperty(wrapped, key, descriptor);
4827
+ }
4828
+ }
4829
+ return wrapped;
4113
4830
  }
4114
4831
  /**
4115
4832
  * Attach `gen_ai.usage.*` and `neurolink.cost` attributes to a span.