@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +358 -354
- package/dist/core/constants.d.ts +2 -0
- package/dist/core/constants.js +2 -0
- package/dist/lib/core/constants.d.ts +2 -0
- package/dist/lib/core/constants.js +2 -0
- package/dist/lib/providers/googleNativeGemini3.d.ts +20 -2
- package/dist/lib/providers/googleNativeGemini3.js +37 -4
- package/dist/lib/providers/googleVertex.d.ts +13 -1
- package/dist/lib/providers/googleVertex.js +1216 -499
- package/dist/lib/types/stream.d.ts +1 -0
- package/dist/providers/googleNativeGemini3.d.ts +20 -2
- package/dist/providers/googleNativeGemini3.js +37 -4
- package/dist/providers/googleVertex.d.ts +13 -1
- package/dist/providers/googleVertex.js +1216 -499
- package/dist/types/stream.d.ts +1 -0
- package/package.json +3 -1
|
@@ -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,
|
|
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
|
-
//
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1477
|
+
output: errorPayload,
|
|
1472
1478
|
});
|
|
1479
|
+
continue;
|
|
1473
1480
|
}
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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:
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
:
|
|
1551
|
-
stepIndex: step,
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
-
//
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2038
|
-
if (
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
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
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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:
|
|
2204
|
+
output: errorOutput,
|
|
2131
2205
|
});
|
|
2132
2206
|
functionResponses.push({
|
|
2133
2207
|
functionResponse: {
|
|
2134
2208
|
name: call.name,
|
|
2135
|
-
response:
|
|
2209
|
+
response: errorOutput,
|
|
2136
2210
|
},
|
|
2137
2211
|
});
|
|
2212
|
+
continue;
|
|
2138
2213
|
}
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
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:
|
|
2154
|
-
|
|
2155
|
-
|
|
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
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
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
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
:
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
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
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2458
|
+
* graceful cap message (buildToolLoopCapMessage), guaranteeing no new failure
|
|
2459
|
+
* path.
|
|
2321
2460
|
*/
|
|
2322
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
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 <
|
|
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 ||
|
|
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
|
-
//
|
|
3623
|
-
|
|
3624
|
-
|
|
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
|
-
//
|
|
3638
|
-
//
|
|
3639
|
-
//
|
|
3640
|
-
|
|
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.
|