@juspay/neurolink 9.80.0 → 9.80.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import path from "path";
5
5
  import os from "os";
6
6
  import { 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";
@@ -44,6 +44,144 @@ const hasAnthropicSupport = () => {
44
44
  // Actual availability is checked at runtime when creating the client
45
45
  return true;
46
46
  };
47
+ // Parse stored rows into ordered segments, grouping tool_call/tool_result by (turn, step).
48
+ function collectHistorySegments(conversationMessages) {
49
+ const segments = [];
50
+ const stepMap = new Map();
51
+ let turnCounter = 0;
52
+ const getStep = (stepIndex) => {
53
+ const key = `${turnCounter}:${stepIndex ?? "x"}`;
54
+ let step = stepMap.get(key);
55
+ if (!step) {
56
+ step = { type: "tool_step", callParts: [], resultParts: [] };
57
+ stepMap.set(key, step);
58
+ segments.push(step);
59
+ }
60
+ return step;
61
+ };
62
+ for (const msg of conversationMessages) {
63
+ if (msg.role === "tool_call") {
64
+ getStep(msg.metadata?.stepIndex).callParts.push({
65
+ name: msg.tool || "unknown",
66
+ input: msg.args || {},
67
+ });
68
+ continue;
69
+ }
70
+ if (msg.role === "tool_result") {
71
+ getStep(msg.metadata?.stepIndex).resultParts.push(msg.content && msg.content.length > 0 ? msg.content : "(no output)");
72
+ continue;
73
+ }
74
+ const role = msg.role === "assistant"
75
+ ? "assistant"
76
+ : msg.role === "user"
77
+ ? "user"
78
+ : null;
79
+ if (!role || !msg.content || msg.content.trim().length === 0) {
80
+ continue;
81
+ }
82
+ // New turn → fresh step namespace for any following tool rows.
83
+ turnCounter++;
84
+ segments.push({ type: "regular", role, parts: [msg.content] });
85
+ }
86
+ return segments;
87
+ }
88
+ // Append blocks to the trailing turn if it shares the role, else start a new turn.
89
+ function pushTurn(messages, role, blocks) {
90
+ const last = messages[messages.length - 1];
91
+ if (last && last.role === role && Array.isArray(last.content)) {
92
+ last.content.push(...blocks);
93
+ }
94
+ else {
95
+ messages.push({ role, content: [...blocks] });
96
+ }
97
+ }
98
+ // Pair a tool step's calls/results by position; unbalanced extras are dropped
99
+ // because Anthropic 400s on an orphaned tool_use/tool_result reference.
100
+ function pairToolStep(step, ordinal) {
101
+ const calls = step.callParts;
102
+ const resultStrings = step.resultParts;
103
+ const paired = Math.min(calls.length, resultStrings.length);
104
+ if (paired !== calls.length || paired !== resultStrings.length) {
105
+ logger.debug("[GoogleVertex] Unbalanced tool step in Claude history replay", {
106
+ calls: calls.length,
107
+ results: resultStrings.length,
108
+ paired,
109
+ });
110
+ }
111
+ const uses = [];
112
+ const results = [];
113
+ for (let i = 0; i < paired; i++) {
114
+ const id = `hist_${ordinal}_${i}`;
115
+ uses.push({
116
+ type: "tool_use",
117
+ id,
118
+ name: calls[i].name,
119
+ input: calls[i].input,
120
+ });
121
+ results.push({
122
+ type: "tool_result",
123
+ tool_use_id: id,
124
+ content: resultStrings[i],
125
+ });
126
+ }
127
+ return { uses, results };
128
+ }
129
+ // Drop leading turns until the first is a real user turn — Anthropic requires it
130
+ // (covers a leading assistant/tool step and an orphaned tool_result-only turn).
131
+ function trimLeadingNonUser(messages) {
132
+ const isToolResultOnly = (m) => Array.isArray(m.content) &&
133
+ m.content.length > 0 &&
134
+ m.content.every((block) => block.type === "tool_result");
135
+ while (messages.length > 0 &&
136
+ (messages[0].role !== "user" || isToolResultOnly(messages[0]))) {
137
+ messages.shift();
138
+ }
139
+ }
140
+ // Rebuild Anthropic history with paired tool_use/tool_result turns from stored rows.
141
+ export function buildAnthropicHistoryMessages(conversationMessages) {
142
+ const messages = [];
143
+ let stepOrdinal = 0;
144
+ for (const seg of collectHistorySegments(conversationMessages)) {
145
+ if (seg.type === "regular") {
146
+ pushTurn(messages, seg.role === "assistant" ? "assistant" : "user", [
147
+ { type: "text", text: String(seg.parts[0] ?? "") },
148
+ ]);
149
+ continue;
150
+ }
151
+ const { uses, results } = pairToolStep(seg, stepOrdinal);
152
+ if (uses.length === 0) {
153
+ continue;
154
+ }
155
+ stepOrdinal++;
156
+ pushTurn(messages, "assistant", uses);
157
+ pushTurn(messages, "user", results);
158
+ }
159
+ trimLeadingNonUser(messages);
160
+ if (logger.shouldLog("debug")) {
161
+ const blocks = messages.flatMap((m) => Array.isArray(m.content) ? m.content : []);
162
+ logger.debug("[GoogleVertex] Replayed Claude history with tool turns", {
163
+ historyMessages: messages.length,
164
+ toolUseBlocks: blocks.filter((b) => b.type === "tool_use").length,
165
+ toolResultBlocks: blocks.filter((b) => b.type === "tool_result").length,
166
+ });
167
+ }
168
+ return messages;
169
+ }
170
+ // Append the live user turn, merging into a trailing user turn to avoid back-to-back user messages.
171
+ export function appendUserMessage(messages, content) {
172
+ const last = messages[messages.length - 1];
173
+ if (last && last.role === "user") {
174
+ const head = Array.isArray(last.content)
175
+ ? last.content
176
+ : [{ type: "text", text: last.content }];
177
+ const tail = Array.isArray(content)
178
+ ? content
179
+ : [{ type: "text", text: content }];
180
+ last.content = [...head, ...tail];
181
+ return;
182
+ }
183
+ messages.push({ role: "user", content });
184
+ }
47
185
  /**
48
186
  * Recursively strip JSON-schema fields that Vertex Gemini's function-call AND
49
187
  * `responseSchema` (structured output) validators reject with 400
@@ -1183,177 +1321,238 @@ export class GoogleVertexProvider extends BaseProvider {
1183
1321
  // yielding it once breaks that signal even though the underlying
1184
1322
  // network stream is genuinely incremental.
1185
1323
  const incrementalTextChunks = [];
1186
- // Agentic loop for tool calling
1187
- while (step < maxSteps) {
1188
- step++;
1189
- logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
1190
- try {
1191
- const stream = await client.models.generateContentStream({
1192
- model: modelName,
1193
- contents: currentContents,
1194
- config,
1195
- });
1196
- const stepFunctionCalls = [];
1197
- // Capture raw response parts including thoughtSignature
1198
- const rawResponseParts = [];
1199
- for await (const chunk of stream) {
1200
- // Extract raw parts from candidates FIRST
1201
- // This avoids using chunk.text which triggers SDK warning when
1202
- // non-text parts (thoughtSignature, functionCall) are present
1203
- const chunkRecord = chunk;
1204
- const candidates = chunkRecord.candidates;
1205
- const firstCandidate = candidates?.[0];
1206
- // Capture the SDK finish reason (Bug 2: previously dropped). Last
1207
- // non-empty value across chunks wins.
1208
- const chunkFinishReason = firstCandidate?.finishReason;
1209
- if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1210
- lastFinishReason = chunkFinishReason;
1211
- }
1212
- const chunkContent = firstCandidate?.content;
1213
- if (chunkContent && Array.isArray(chunkContent.parts)) {
1214
- for (const part of chunkContent.parts) {
1215
- rawResponseParts.push(part);
1216
- if (typeof part.text === "string" && part.text.length > 0) {
1217
- incrementalTextChunks.push(part.text);
1324
+ // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
1325
+ // Gemini SDK cancels via config.abortSignal, so drive an internal
1326
+ // AbortController: the caller's signal and a defensive wall-clock timer
1327
+ // both trip it, and every request/tool-exec receives effectiveSignal.
1328
+ const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
1329
+ const internalAbort = new AbortController();
1330
+ const onCallerAbort = () => internalAbort.abort();
1331
+ options.abortSignal?.addEventListener("abort", onCallerAbort);
1332
+ const defensiveTimer = setTimeout(() => {
1333
+ logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
1334
+ internalAbort.abort();
1335
+ }, streamTimeoutMs);
1336
+ const effectiveSignal = internalAbort.signal;
1337
+ if (options.abortSignal?.aborted) {
1338
+ internalAbort.abort();
1339
+ }
1340
+ let wasAborted = false;
1341
+ // Step-cap flags declared in the outer scope so the terminal block (also
1342
+ // inside the try) and the finishReason mapping (after the finally) can
1343
+ // both read them.
1344
+ let hitStepLimit = false;
1345
+ let synthesizedFinalAnswer = false;
1346
+ try {
1347
+ // Agentic loop for tool calling
1348
+ while (step < maxSteps) {
1349
+ if (effectiveSignal.aborted) {
1350
+ wasAborted = true;
1351
+ break;
1352
+ }
1353
+ step++;
1354
+ logger.debug(`[GoogleVertex] Native SDK step ${step}/${maxSteps}`);
1355
+ try {
1356
+ const stream = await client.models.generateContentStream({
1357
+ model: modelName,
1358
+ contents: currentContents,
1359
+ config: { ...config, abortSignal: effectiveSignal },
1360
+ });
1361
+ const stepFunctionCalls = [];
1362
+ // Capture raw response parts including thoughtSignature
1363
+ const rawResponseParts = [];
1364
+ for await (const chunk of stream) {
1365
+ // Extract raw parts from candidates FIRST
1366
+ // This avoids using chunk.text which triggers SDK warning when
1367
+ // non-text parts (thoughtSignature, functionCall) are present
1368
+ const chunkRecord = chunk;
1369
+ const candidates = chunkRecord.candidates;
1370
+ const firstCandidate = candidates?.[0];
1371
+ // Capture the SDK finish reason (Bug 2: previously dropped). Last
1372
+ // non-empty value across chunks wins.
1373
+ const chunkFinishReason = firstCandidate?.finishReason;
1374
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1375
+ lastFinishReason = chunkFinishReason;
1376
+ }
1377
+ const chunkContent = firstCandidate?.content;
1378
+ if (chunkContent && Array.isArray(chunkContent.parts)) {
1379
+ for (const part of chunkContent.parts) {
1380
+ rawResponseParts.push(part);
1381
+ if (typeof part.text === "string" && part.text.length > 0) {
1382
+ incrementalTextChunks.push(part.text);
1383
+ }
1218
1384
  }
1219
1385
  }
1220
- }
1221
- if (chunk.functionCalls) {
1222
- stepFunctionCalls.push(...chunk.functionCalls);
1223
- }
1224
- // Extract usage metadata from chunk
1225
- // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1226
- const usageMetadata = chunkRecord.usageMetadata;
1227
- if (usageMetadata) {
1228
- // Take the latest promptTokenCount (usually only in final chunk)
1229
- if (usageMetadata.promptTokenCount !== undefined &&
1230
- usageMetadata.promptTokenCount > 0) {
1231
- totalInputTokens = usageMetadata.promptTokenCount;
1386
+ if (chunk.functionCalls) {
1387
+ stepFunctionCalls.push(...chunk.functionCalls);
1232
1388
  }
1233
- // Take the latest candidatesTokenCount (accumulates through chunks)
1234
- if (usageMetadata.candidatesTokenCount !== undefined &&
1235
- usageMetadata.candidatesTokenCount > 0) {
1236
- totalOutputTokens = usageMetadata.candidatesTokenCount;
1389
+ // Extract usage metadata from chunk
1390
+ // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1391
+ const usageMetadata = chunkRecord.usageMetadata;
1392
+ if (usageMetadata) {
1393
+ // Take the latest promptTokenCount (usually only in final chunk)
1394
+ if (usageMetadata.promptTokenCount !== undefined &&
1395
+ usageMetadata.promptTokenCount > 0) {
1396
+ totalInputTokens = usageMetadata.promptTokenCount;
1397
+ }
1398
+ // Take the latest candidatesTokenCount (accumulates through chunks)
1399
+ if (usageMetadata.candidatesTokenCount !== undefined &&
1400
+ usageMetadata.candidatesTokenCount > 0) {
1401
+ totalOutputTokens = usageMetadata.candidatesTokenCount;
1402
+ }
1237
1403
  }
1238
1404
  }
1239
- }
1240
- // Extract text from raw parts after stream completes
1241
- // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
1242
- const stepText = rawResponseParts
1243
- .filter((part) => typeof part.text === "string")
1244
- .map((part) => part.text)
1245
- .join("");
1246
- // If no function calls, we're done
1247
- if (stepFunctionCalls.length === 0) {
1248
- finalText = stepText;
1249
- break;
1250
- }
1251
- // Check for final_result tool call - this is our structured output pattern
1252
- if (useFinalResultTool) {
1253
- const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
1254
- if (finalResultCall) {
1255
- // Extract the structured output from final_result arguments
1256
- finalResultStructuredOutput = finalResultCall.args;
1257
- logger.debug("[GoogleVertex] Received final_result tool call with structured output (stream)", {
1258
- outputKeys: Object.keys(finalResultStructuredOutput),
1259
- });
1260
- // Return the structured output as JSON text
1261
- finalText = JSON.stringify(finalResultStructuredOutput);
1405
+ // Extract text from raw parts after stream completes
1406
+ // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
1407
+ const stepText = rawResponseParts
1408
+ .filter((part) => typeof part.text === "string")
1409
+ .map((part) => part.text)
1410
+ .join("");
1411
+ // If no function calls, we're done
1412
+ if (stepFunctionCalls.length === 0) {
1413
+ finalText = stepText;
1262
1414
  break;
1263
1415
  }
1264
- }
1265
- // Execute function calls
1266
- logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
1267
- // Add model response with ALL parts (including thoughtSignature) to history
1268
- // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
1269
- currentContents.push({
1270
- role: "model",
1271
- parts: rawResponseParts.length > 0
1272
- ? rawResponseParts
1273
- : stepFunctionCalls.map((fc) => ({
1274
- functionCall: fc,
1275
- })),
1276
- });
1277
- // Execute each function and collect responses
1278
- const functionResponses = [];
1279
- // Per-step bookkeeping for conversation-memory storage.
1280
- const stepStorageCalls = [];
1281
- const stepStorageResults = [];
1282
- // Note: tool:start / tool:end events are emitted by ToolsManager's
1283
- // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
1284
- for (const call of stepFunctionCalls) {
1285
- allToolCalls.push({ toolName: call.name, args: call.args });
1286
- stepStorageCalls.push({ toolName: call.name, args: call.args });
1287
- // Check if this tool has already exceeded retry limit
1288
- const failedInfo = failedTools.get(call.name);
1289
- if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
1290
- logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
1291
- const errorPayload = {
1292
- 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.`,
1293
- status: "permanently_failed",
1294
- do_not_retry: true,
1295
- };
1296
- functionResponses.push({
1297
- functionResponse: {
1298
- name: call.name,
1299
- response: errorPayload,
1300
- },
1301
- });
1302
- toolExecutions.push({
1303
- name: call.name,
1304
- input: call.args,
1305
- output: errorPayload,
1306
- });
1307
- stepStorageResults.push({
1308
- toolName: call.name,
1309
- output: errorPayload,
1310
- });
1311
- continue;
1416
+ // Check for final_result tool call - this is our structured output pattern
1417
+ if (useFinalResultTool) {
1418
+ const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
1419
+ if (finalResultCall) {
1420
+ // Extract the structured output from final_result arguments
1421
+ finalResultStructuredOutput = finalResultCall.args;
1422
+ logger.debug("[GoogleVertex] Received final_result tool call with structured output (stream)", {
1423
+ outputKeys: Object.keys(finalResultStructuredOutput),
1424
+ });
1425
+ // Return the structured output as JSON text
1426
+ finalText = JSON.stringify(finalResultStructuredOutput);
1427
+ break;
1428
+ }
1312
1429
  }
1313
- const execute = executeMap.get(call.name);
1314
- if (execute) {
1315
- try {
1316
- // AI SDK Tool execute requires (args, options) - provide minimal options
1317
- const toolOptions = {
1318
- toolCallId: `${call.name}-${Date.now()}`,
1319
- messages: [],
1320
- abortSignal: undefined,
1430
+ // Execute function calls
1431
+ logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
1432
+ // Add model response with ALL parts (including thoughtSignature) to history
1433
+ // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
1434
+ currentContents.push({
1435
+ role: "model",
1436
+ parts: rawResponseParts.length > 0
1437
+ ? rawResponseParts
1438
+ : stepFunctionCalls.map((fc) => ({
1439
+ functionCall: fc,
1440
+ })),
1441
+ });
1442
+ // Execute each function and collect responses
1443
+ const functionResponses = [];
1444
+ // Per-step bookkeeping for conversation-memory storage.
1445
+ const stepStorageCalls = [];
1446
+ const stepStorageResults = [];
1447
+ // Note: tool:start / tool:end events are emitted by ToolsManager's
1448
+ // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
1449
+ for (const call of stepFunctionCalls) {
1450
+ allToolCalls.push({ toolName: call.name, args: call.args });
1451
+ stepStorageCalls.push({ toolName: call.name, args: call.args });
1452
+ // Check if this tool has already exceeded retry limit
1453
+ const failedInfo = failedTools.get(call.name);
1454
+ if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
1455
+ logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
1456
+ const errorPayload = {
1457
+ error: `TOOL_PERMANENTLY_FAILED: The tool "${call.name}" has failed ${failedInfo.count} times and will not be retried. Last error: ${failedInfo.lastError}. Please proceed without using this tool or inform the user that this functionality is unavailable.`,
1458
+ status: "permanently_failed",
1459
+ do_not_retry: true,
1321
1460
  };
1322
- const result = await execute(call.args, toolOptions);
1461
+ functionResponses.push({
1462
+ functionResponse: {
1463
+ name: call.name,
1464
+ response: errorPayload,
1465
+ },
1466
+ });
1323
1467
  toolExecutions.push({
1324
1468
  name: call.name,
1325
1469
  input: call.args,
1326
- output: result,
1327
- });
1328
- functionResponses.push({
1329
- functionResponse: { name: call.name, response: { result } },
1470
+ output: errorPayload,
1330
1471
  });
1331
1472
  stepStorageResults.push({
1332
1473
  toolName: call.name,
1333
- output: result,
1474
+ output: errorPayload,
1334
1475
  });
1476
+ continue;
1335
1477
  }
1336
- catch (error) {
1337
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
1338
- // Track this failure
1339
- const currentFailInfo = failedTools.get(call.name) || {
1340
- count: 0,
1341
- lastError: "",
1342
- };
1343
- currentFailInfo.count++;
1344
- currentFailInfo.lastError = errorMessage;
1345
- failedTools.set(call.name, currentFailInfo);
1346
- logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
1347
- // Determine if this is a permanent failure
1348
- const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
1478
+ const execute = executeMap.get(call.name);
1479
+ if (execute) {
1480
+ try {
1481
+ // AI SDK Tool execute requires (args, options) - provide minimal options
1482
+ const toolOptions = {
1483
+ toolCallId: `${call.name}-${Date.now()}`,
1484
+ messages: [],
1485
+ abortSignal: effectiveSignal,
1486
+ };
1487
+ const result = await execute(call.args, toolOptions);
1488
+ toolExecutions.push({
1489
+ name: call.name,
1490
+ input: call.args,
1491
+ output: result,
1492
+ });
1493
+ functionResponses.push({
1494
+ functionResponse: { name: call.name, response: { result } },
1495
+ });
1496
+ stepStorageResults.push({
1497
+ toolName: call.name,
1498
+ output: result,
1499
+ });
1500
+ }
1501
+ catch (error) {
1502
+ // An abort during tool execution ends the turn gracefully — it
1503
+ // must NOT be recorded as a spurious tool failure. Both checks
1504
+ // matter: effectiveSignal.aborted catches an abort WE triggered
1505
+ // (even if the throw isn't abort-shaped); isAbortError(error)
1506
+ // catches an abort-shaped throw (e.g. a tool raising AbortError)
1507
+ // even when the signal itself never fired.
1508
+ if (effectiveSignal.aborted || isAbortError(error)) {
1509
+ wasAborted = true;
1510
+ break;
1511
+ }
1512
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
1513
+ // Track this failure
1514
+ const currentFailInfo = failedTools.get(call.name) || {
1515
+ count: 0,
1516
+ lastError: "",
1517
+ };
1518
+ currentFailInfo.count++;
1519
+ currentFailInfo.lastError = errorMessage;
1520
+ failedTools.set(call.name, currentFailInfo);
1521
+ logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
1522
+ // Determine if this is a permanent failure
1523
+ const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
1524
+ const errorPayload = {
1525
+ error: isPermanentFailure
1526
+ ? `TOOL_PERMANENTLY_FAILED: The tool "${call.name}" has failed ${currentFailInfo.count} times with error: ${errorMessage}. This tool will not be retried. Please proceed without using this tool or inform the user that this functionality is unavailable.`
1527
+ : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
1528
+ status: isPermanentFailure ? "permanently_failed" : "failed",
1529
+ do_not_retry: isPermanentFailure,
1530
+ retry_count: currentFailInfo.count,
1531
+ max_retries: DEFAULT_TOOL_MAX_RETRIES,
1532
+ };
1533
+ functionResponses.push({
1534
+ functionResponse: {
1535
+ name: call.name,
1536
+ response: errorPayload,
1537
+ },
1538
+ });
1539
+ toolExecutions.push({
1540
+ name: call.name,
1541
+ input: call.args,
1542
+ output: errorPayload,
1543
+ });
1544
+ stepStorageResults.push({
1545
+ toolName: call.name,
1546
+ output: errorPayload,
1547
+ });
1548
+ }
1549
+ }
1550
+ else {
1551
+ // Tool not found is a permanent error
1349
1552
  const errorPayload = {
1350
- error: isPermanentFailure
1351
- ? `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.`
1352
- : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
1353
- status: isPermanentFailure ? "permanently_failed" : "failed",
1354
- do_not_retry: isPermanentFailure,
1355
- retry_count: currentFailInfo.count,
1356
- max_retries: DEFAULT_TOOL_MAX_RETRIES,
1553
+ error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
1554
+ status: "permanently_failed",
1555
+ do_not_retry: true,
1357
1556
  };
1358
1557
  functionResponses.push({
1359
1558
  functionResponse: {
@@ -1372,102 +1571,109 @@ export class GoogleVertexProvider extends BaseProvider {
1372
1571
  });
1373
1572
  }
1374
1573
  }
1375
- else {
1376
- // Tool not found is a permanent error
1377
- const errorPayload = {
1378
- error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
1379
- status: "permanently_failed",
1380
- do_not_retry: true,
1381
- };
1382
- functionResponses.push({
1383
- functionResponse: {
1384
- name: call.name,
1385
- response: errorPayload,
1386
- },
1387
- });
1388
- toolExecutions.push({
1389
- name: call.name,
1390
- input: call.args,
1391
- output: errorPayload,
1392
- });
1393
- stepStorageResults.push({
1394
- toolName: call.name,
1395
- output: errorPayload,
1396
- });
1574
+ // An abort inside the tool-exec loop only breaks that inner for-loop.
1575
+ // Break the while too so no further model call is issued and control
1576
+ // reaches the terminal step-cap handling below.
1577
+ if (wasAborted) {
1578
+ break;
1397
1579
  }
1398
- }
1399
- // Persist this step's tool calls/results into conversation memory.
1400
- // Without this, tool_call / tool_result rows never reach Redis and
1401
- // the chat-history UI loses every tool invocation.
1402
- //
1403
- // `thoughtSignature` rides as a sibling on the first call of the
1404
- // step Gemini 3 needs it to match thinking patterns when the
1405
- // conversation is replayed on the next turn.
1406
- if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
1407
- const stepThoughtSig = extractThoughtSignature(rawResponseParts);
1408
- withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c, i) => ({
1409
- ...c,
1410
- ...(i === 0 && stepThoughtSig
1411
- ? { thoughtSignature: stepThoughtSig }
1412
- : {}),
1413
- stepIndex: step,
1414
- })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
1415
- logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
1416
- error: error instanceof Error ? error.message : String(error),
1580
+ // Persist this step's tool calls/results into conversation memory.
1581
+ // Without this, tool_call / tool_result rows never reach Redis and
1582
+ // the chat-history UI loses every tool invocation.
1583
+ //
1584
+ // `thoughtSignature` rides as a sibling on the first call of the
1585
+ // step Gemini 3 needs it to match thinking patterns when the
1586
+ // conversation is replayed on the next turn.
1587
+ if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
1588
+ const stepThoughtSig = extractThoughtSignature(rawResponseParts);
1589
+ withTimeout(this.handleToolExecutionStorage(stepStorageCalls.map((c, i) => ({
1590
+ ...c,
1591
+ ...(i === 0 && stepThoughtSig
1592
+ ? { thoughtSignature: stepThoughtSig }
1593
+ : {}),
1594
+ stepIndex: step,
1595
+ })), stepStorageResults.map((r) => ({ ...r, stepIndex: step })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
1596
+ logger.warn("[GoogleVertex] Failed to store native Gemini stream tool executions", {
1597
+ error: error instanceof Error ? error.message : String(error),
1598
+ });
1417
1599
  });
1600
+ }
1601
+ // The @google/genai SDK only accepts "user" and "model" as valid
1602
+ // roles in contents — function/tool responses must use role: "user"
1603
+ // (matching the SDK's automaticFunctionCalling implementation and
1604
+ // the Google AI Studio path). Sending role: "function" was causing
1605
+ // native Vertex Gemini tool loops to be silently rejected by the
1606
+ // request validator.
1607
+ currentContents.push({
1608
+ role: "user",
1609
+ parts: functionResponses,
1418
1610
  });
1419
1611
  }
1420
- // The @google/genai SDK only accepts "user" and "model" as valid
1421
- // roles in contents function/tool responses must use role: "user"
1422
- // (matching the SDK's automaticFunctionCalling implementation and
1423
- // the Google AI Studio path). Sending role: "function" was causing
1424
- // native Vertex Gemini tool loops to be silently rejected by the
1425
- // request validator.
1426
- currentContents.push({
1427
- role: "user",
1428
- parts: functionResponses,
1429
- });
1430
- }
1431
- catch (error) {
1432
- logger.error("[GoogleVertex] Native SDK error", error);
1433
- throw this.handleProviderError(error);
1434
- }
1435
- }
1436
- // Handle maxSteps termination — the loop exited because the step cap was
1437
- // reached while the model was still calling tools. Surface a real answer
1438
- // instead of the canned placeholder (Bug 1) and a meaningful finishReason
1439
- // (Bug 2).
1440
- let hitStepLimit = false;
1441
- let synthesizedFinalAnswer = false;
1442
- if (step >= maxSteps && !finalText) {
1443
- hitStepLimit = true;
1444
- // The consumer receives text via `incrementalTextChunks`; any text the
1445
- // model emitted across steps is already preserved there. Only synthesize
1446
- // when NOTHING was produced (the pure-functionCall case that otherwise
1447
- // surfaces the placeholder) so we never waste a round-trip whose output
1448
- // the stream would ignore.
1449
- if (incrementalTextChunks.length === 0) {
1450
- logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
1451
- `with no text; synthesizing a final answer with tools disabled.`);
1452
- const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? 300_000);
1453
- if (synth.text) {
1454
- synthesizedFinalAnswer = true;
1455
- finalText = synth.text;
1456
- incrementalTextChunks.push(synth.text);
1457
- totalInputTokens += synth.inputTokens;
1458
- totalOutputTokens += synth.outputTokens;
1459
- if (synth.finishReason) {
1460
- lastFinishReason = synth.finishReason;
1612
+ catch (error) {
1613
+ // A mid-drain abort surfaces as an AbortError from the `for await`.
1614
+ // Break gracefully into the terminal block instead of re-throwing
1615
+ // (a re-throw would route the caller's abort into a second unbounded
1616
+ // fallback stream()). Dual check as with the inner catch:
1617
+ // effectiveSignal.aborted (a signal we tripped) OR isAbortError(error)
1618
+ // (an abort-shaped throw) — either means "stop", not a real failure.
1619
+ if (effectiveSignal.aborted || isAbortError(error)) {
1620
+ wasAborted = true;
1621
+ break;
1461
1622
  }
1623
+ logger.error("[GoogleVertex] Native SDK error", error);
1624
+ throw this.handleProviderError(error);
1625
+ }
1626
+ }
1627
+ // Handle maxSteps termination / abort — the loop exited because the step
1628
+ // cap was reached (or the turn was aborted) while the model was still
1629
+ // calling tools. Surface a real answer instead of the canned placeholder
1630
+ // (Bug 1) and a meaningful finishReason (Bug 2).
1631
+ if (!finalText && (step >= maxSteps || wasAborted)) {
1632
+ hitStepLimit = step >= maxSteps && !wasAborted;
1633
+ const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
1634
+ // The consumer receives text via `incrementalTextChunks`; any text the
1635
+ // model emitted across steps is already preserved there. Only produce a
1636
+ // terminal message when NOTHING was produced (the pure-functionCall /
1637
+ // abort case that otherwise surfaces the placeholder). When chunks
1638
+ // exist, leave `finalText` empty so `textPartsToYield` keeps replaying
1639
+ // the gathered chunks instead of collapsing to a single one.
1640
+ if (incrementalTextChunks.length > 0) {
1641
+ logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1642
+ `returning text already gathered from prior steps.`);
1643
+ }
1644
+ else if (wasAborted) {
1645
+ // Aborted turn — skip synth entirely so it can never add +300s after
1646
+ // a blown budget. Deliver exactly one graceful cap chunk.
1647
+ logger.warn(`[GoogleVertex] Tool call loop aborted mid-turn; ` +
1648
+ `returning a graceful cap message.`);
1649
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
1462
1650
  }
1463
1651
  else {
1464
- finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
1652
+ logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
1653
+ `with no text; synthesizing a final answer with tools disabled.`);
1654
+ // synth self-bounds its connect+drain via withTimeout(timeoutMs) and
1655
+ // never throws (returns empty on timeout/error), so it needs no outer
1656
+ // withTimeout wrapper — see synthesizeFinalAnswerWithoutTools.
1657
+ const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS, effectiveSignal);
1658
+ if (synth.text) {
1659
+ synthesizedFinalAnswer = true;
1660
+ finalText = synth.text;
1661
+ incrementalTextChunks.push(synth.text);
1662
+ totalInputTokens += synth.inputTokens;
1663
+ totalOutputTokens += synth.outputTokens;
1664
+ if (synth.finishReason) {
1665
+ lastFinishReason = synth.finishReason;
1666
+ }
1667
+ }
1668
+ else {
1669
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
1670
+ }
1465
1671
  }
1466
1672
  }
1467
- else {
1468
- logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
1469
- `returning text already gathered from prior steps.`);
1470
- }
1673
+ }
1674
+ finally {
1675
+ clearTimeout(defensiveTimer);
1676
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
1471
1677
  }
1472
1678
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
1473
1679
  // synthesized answer is reported as "tool-calls" (the model still wanted
@@ -1852,173 +2058,230 @@ export class GoogleVertexProvider extends BaseProvider {
1852
2058
  // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1853
2059
  let totalInputTokens = 0;
1854
2060
  let totalOutputTokens = 0;
1855
- // Agentic loop for tool calling
1856
- while (step < maxSteps) {
1857
- step++;
1858
- logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
1859
- try {
1860
- // Use generateContentStream and collect all chunks (same as GoogleAIStudio)
1861
- const stream = await client.models.generateContentStream({
1862
- model: modelName,
1863
- contents: currentContents,
1864
- config,
1865
- });
1866
- const stepFunctionCalls = [];
1867
- // Capture raw response parts including thoughtSignature
1868
- const rawResponseParts = [];
1869
- // Collect all chunks from stream
1870
- for await (const chunk of stream) {
1871
- // Extract raw parts from candidates FIRST
1872
- // This avoids using chunk.text which triggers SDK warning when
1873
- // non-text parts (thoughtSignature, functionCall) are present
1874
- const chunkRecord = chunk;
1875
- const candidates = chunkRecord.candidates;
1876
- const firstCandidate = candidates?.[0];
1877
- // Capture the SDK finish reason (Bug 2: previously dropped). Last
1878
- // non-empty value across chunks wins.
1879
- const chunkFinishReason = firstCandidate?.finishReason;
1880
- if (typeof chunkFinishReason === "string" && chunkFinishReason) {
1881
- lastFinishReason = chunkFinishReason;
1882
- }
1883
- const chunkContent = firstCandidate?.content;
1884
- if (chunkContent && Array.isArray(chunkContent.parts)) {
1885
- rawResponseParts.push(...chunkContent.parts);
1886
- }
1887
- if (chunk.functionCalls) {
1888
- stepFunctionCalls.push(...chunk.functionCalls);
1889
- }
1890
- // Extract usage metadata from chunk
1891
- // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
1892
- const usageMetadata = chunkRecord.usageMetadata;
1893
- if (usageMetadata) {
1894
- // Take the latest promptTokenCount (usually only in final chunk)
1895
- if (usageMetadata.promptTokenCount !== undefined &&
1896
- usageMetadata.promptTokenCount > 0) {
1897
- totalInputTokens = usageMetadata.promptTokenCount;
2061
+ // Abort scaffolding (mirrors executeNativeAnthropicStream). The native
2062
+ // Gemini SDK cancels via config.abortSignal, so drive an internal
2063
+ // AbortController: the caller's signal and a defensive wall-clock timer
2064
+ // both trip it, and every request/tool-exec receives effectiveSignal.
2065
+ const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
2066
+ const internalAbort = new AbortController();
2067
+ const onCallerAbort = () => internalAbort.abort();
2068
+ options.abortSignal?.addEventListener("abort", onCallerAbort);
2069
+ const defensiveTimer = setTimeout(() => {
2070
+ logger.warn(`[GoogleVertex] Native Gemini turn exceeded ${streamTimeoutMs}ms — aborting`);
2071
+ internalAbort.abort();
2072
+ }, streamTimeoutMs);
2073
+ const effectiveSignal = internalAbort.signal;
2074
+ if (options.abortSignal?.aborted) {
2075
+ internalAbort.abort();
2076
+ }
2077
+ let wasAborted = false;
2078
+ // Step-cap flags declared in the outer scope so the terminal block (also
2079
+ // inside the try) and the finishReason mapping (after the finally) can
2080
+ // both read them.
2081
+ let hitStepLimit = false;
2082
+ let synthesizedFinalAnswer = false;
2083
+ try {
2084
+ // Agentic loop for tool calling
2085
+ while (step < maxSteps) {
2086
+ if (effectiveSignal.aborted) {
2087
+ wasAborted = true;
2088
+ break;
2089
+ }
2090
+ step++;
2091
+ logger.debug(`[GoogleVertex] Native SDK generate step ${step}/${maxSteps}`);
2092
+ try {
2093
+ // Use generateContentStream and collect all chunks (same as GoogleAIStudio)
2094
+ const stream = await client.models.generateContentStream({
2095
+ model: modelName,
2096
+ contents: currentContents,
2097
+ config: { ...config, abortSignal: effectiveSignal },
2098
+ });
2099
+ const stepFunctionCalls = [];
2100
+ // Capture raw response parts including thoughtSignature
2101
+ const rawResponseParts = [];
2102
+ // Collect all chunks from stream
2103
+ for await (const chunk of stream) {
2104
+ // Extract raw parts from candidates FIRST
2105
+ // This avoids using chunk.text which triggers SDK warning when
2106
+ // non-text parts (thoughtSignature, functionCall) are present
2107
+ const chunkRecord = chunk;
2108
+ const candidates = chunkRecord.candidates;
2109
+ const firstCandidate = candidates?.[0];
2110
+ // Capture the SDK finish reason (Bug 2: previously dropped). Last
2111
+ // non-empty value across chunks wins.
2112
+ const chunkFinishReason = firstCandidate?.finishReason;
2113
+ if (typeof chunkFinishReason === "string" && chunkFinishReason) {
2114
+ lastFinishReason = chunkFinishReason;
1898
2115
  }
1899
- // Take the latest candidatesTokenCount (accumulates through chunks)
1900
- if (usageMetadata.candidatesTokenCount !== undefined &&
1901
- usageMetadata.candidatesTokenCount > 0) {
1902
- totalOutputTokens = usageMetadata.candidatesTokenCount;
2116
+ const chunkContent = firstCandidate?.content;
2117
+ if (chunkContent && Array.isArray(chunkContent.parts)) {
2118
+ rawResponseParts.push(...chunkContent.parts);
2119
+ }
2120
+ if (chunk.functionCalls) {
2121
+ stepFunctionCalls.push(...chunk.functionCalls);
2122
+ }
2123
+ // Extract usage metadata from chunk
2124
+ // promptTokenCount is typically in the final chunk, candidatesTokenCount accumulates
2125
+ const usageMetadata = chunkRecord.usageMetadata;
2126
+ if (usageMetadata) {
2127
+ // Take the latest promptTokenCount (usually only in final chunk)
2128
+ if (usageMetadata.promptTokenCount !== undefined &&
2129
+ usageMetadata.promptTokenCount > 0) {
2130
+ totalInputTokens = usageMetadata.promptTokenCount;
2131
+ }
2132
+ // Take the latest candidatesTokenCount (accumulates through chunks)
2133
+ if (usageMetadata.candidatesTokenCount !== undefined &&
2134
+ usageMetadata.candidatesTokenCount > 0) {
2135
+ totalOutputTokens = usageMetadata.candidatesTokenCount;
2136
+ }
1903
2137
  }
1904
2138
  }
1905
- }
1906
- // Extract text from raw parts after stream completes
1907
- // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
1908
- const stepText = rawResponseParts
1909
- .filter((part) => typeof part.text === "string")
1910
- .map((part) => part.text)
1911
- .join("");
1912
- // If no function calls, we're done
1913
- if (stepFunctionCalls.length === 0) {
1914
- finalText = stepText;
1915
- break;
1916
- }
1917
- // Check for final_result tool call - this is our structured output pattern
1918
- if (useFinalResultTool) {
1919
- const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
1920
- if (finalResultCall) {
1921
- // Extract the structured output from final_result arguments
1922
- finalResultStructuredOutput = finalResultCall.args;
1923
- logger.debug("[GoogleVertex] Received final_result tool call with structured output (generate)", {
1924
- outputKeys: Object.keys(finalResultStructuredOutput),
1925
- });
1926
- // Return the structured output as JSON text
1927
- finalText = JSON.stringify(finalResultStructuredOutput);
2139
+ // Extract text from raw parts after stream completes
2140
+ // This avoids SDK warning about non-text parts (thoughtSignature, functionCall)
2141
+ const stepText = rawResponseParts
2142
+ .filter((part) => typeof part.text === "string")
2143
+ .map((part) => part.text)
2144
+ .join("");
2145
+ // If no function calls, we're done
2146
+ if (stepFunctionCalls.length === 0) {
2147
+ finalText = stepText;
1928
2148
  break;
1929
2149
  }
1930
- }
1931
- // Accumulate non-empty step text across steps so the
1932
- // maxSteps-exhaustion exit can surface the prose the model produced
1933
- // instead of a canned placeholder (Bug 1). Mirrors the Vertex-Claude
1934
- // loop's text accumulation.
1935
- accumulatedText = appendStepText(accumulatedText, stepText);
1936
- // Execute function calls
1937
- logger.debug(`[GoogleVertex] Generate executing ${stepFunctionCalls.length} function calls`);
1938
- // Add model response with ALL parts (including thoughtSignature) to history
1939
- // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
1940
- currentContents.push({
1941
- role: "model",
1942
- parts: rawResponseParts.length > 0
1943
- ? rawResponseParts
1944
- : stepFunctionCalls.map((fc) => ({
1945
- functionCall: fc,
1946
- })),
1947
- });
1948
- // Execute each function and collect responses
1949
- const functionResponses = [];
1950
- const toolCallsBefore = allToolCalls.length;
1951
- const toolExecsBefore = toolExecutions.length;
1952
- // Note: tool:start / tool:end events are emitted by ToolsManager's
1953
- // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
1954
- for (const call of stepFunctionCalls) {
1955
- allToolCalls.push({ toolName: call.name, args: call.args });
1956
- // Check if this tool has already exceeded retry limit
1957
- const failedInfo = failedTools.get(call.name);
1958
- if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
1959
- logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
1960
- const errorOutput = {
1961
- 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.`,
1962
- status: "permanently_failed",
1963
- do_not_retry: true,
1964
- };
1965
- toolExecutions.push({
1966
- name: call.name,
1967
- input: call.args,
1968
- output: errorOutput,
1969
- });
1970
- functionResponses.push({
1971
- functionResponse: {
1972
- name: call.name,
1973
- response: errorOutput,
1974
- },
1975
- });
1976
- continue;
2150
+ // Check for final_result tool call - this is our structured output pattern
2151
+ if (useFinalResultTool) {
2152
+ const finalResultCall = stepFunctionCalls.find((call) => call.name === "final_result");
2153
+ if (finalResultCall) {
2154
+ // Extract the structured output from final_result arguments
2155
+ finalResultStructuredOutput = finalResultCall.args;
2156
+ logger.debug("[GoogleVertex] Received final_result tool call with structured output (generate)", {
2157
+ outputKeys: Object.keys(finalResultStructuredOutput),
2158
+ });
2159
+ // Return the structured output as JSON text
2160
+ finalText = JSON.stringify(finalResultStructuredOutput);
2161
+ break;
2162
+ }
1977
2163
  }
1978
- const execute = executeMap.get(call.name);
1979
- if (execute) {
1980
- try {
1981
- // AI SDK Tool execute requires (args, options) - provide minimal options
1982
- const toolOptions = {
1983
- toolCallId: `${call.name}-${Date.now()}`,
1984
- messages: [],
1985
- abortSignal: undefined,
2164
+ // Accumulate non-empty step text across steps so the
2165
+ // maxSteps-exhaustion exit can surface the prose the model produced
2166
+ // instead of a canned placeholder (Bug 1). Mirrors the Vertex-Claude
2167
+ // loop's text accumulation.
2168
+ accumulatedText = appendStepText(accumulatedText, stepText);
2169
+ // Execute function calls
2170
+ logger.debug(`[GoogleVertex] Generate executing ${stepFunctionCalls.length} function calls`);
2171
+ // Add model response with ALL parts (including thoughtSignature) to history
2172
+ // This preserves the thought_signature which is required for Gemini 3 multi-turn tool calling
2173
+ currentContents.push({
2174
+ role: "model",
2175
+ parts: rawResponseParts.length > 0
2176
+ ? rawResponseParts
2177
+ : stepFunctionCalls.map((fc) => ({
2178
+ functionCall: fc,
2179
+ })),
2180
+ });
2181
+ // Execute each function and collect responses
2182
+ const functionResponses = [];
2183
+ const toolCallsBefore = allToolCalls.length;
2184
+ const toolExecsBefore = toolExecutions.length;
2185
+ // Note: tool:start / tool:end events are emitted by ToolsManager's
2186
+ // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
2187
+ for (const call of stepFunctionCalls) {
2188
+ allToolCalls.push({ toolName: call.name, args: call.args });
2189
+ // Check if this tool has already exceeded retry limit
2190
+ const failedInfo = failedTools.get(call.name);
2191
+ if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
2192
+ logger.warn(`[GoogleVertex] Tool "${call.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
2193
+ const errorOutput = {
2194
+ error: `TOOL_PERMANENTLY_FAILED: The tool "${call.name}" has failed ${failedInfo.count} times and will not be retried. Last error: ${failedInfo.lastError}. Please proceed without using this tool or inform the user that this functionality is unavailable.`,
2195
+ status: "permanently_failed",
2196
+ do_not_retry: true,
1986
2197
  };
1987
- const execResult = await execute(call.args, toolOptions);
1988
- // Track execution
1989
2198
  toolExecutions.push({
1990
2199
  name: call.name,
1991
2200
  input: call.args,
1992
- output: execResult,
2201
+ output: errorOutput,
1993
2202
  });
1994
2203
  functionResponses.push({
1995
2204
  functionResponse: {
1996
2205
  name: call.name,
1997
- response: { result: execResult },
2206
+ response: errorOutput,
1998
2207
  },
1999
2208
  });
2209
+ continue;
2000
2210
  }
2001
- catch (error) {
2002
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
2003
- // Track this failure
2004
- const currentFailInfo = failedTools.get(call.name) || {
2005
- count: 0,
2006
- lastError: "",
2007
- };
2008
- currentFailInfo.count++;
2009
- currentFailInfo.lastError = errorMessage;
2010
- failedTools.set(call.name, currentFailInfo);
2011
- logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
2012
- // Determine if this is a permanent failure
2013
- const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
2211
+ const execute = executeMap.get(call.name);
2212
+ if (execute) {
2213
+ try {
2214
+ // AI SDK Tool execute requires (args, options) - provide minimal options
2215
+ const toolOptions = {
2216
+ toolCallId: `${call.name}-${Date.now()}`,
2217
+ messages: [],
2218
+ abortSignal: effectiveSignal,
2219
+ };
2220
+ const execResult = await execute(call.args, toolOptions);
2221
+ // Track execution
2222
+ toolExecutions.push({
2223
+ name: call.name,
2224
+ input: call.args,
2225
+ output: execResult,
2226
+ });
2227
+ functionResponses.push({
2228
+ functionResponse: {
2229
+ name: call.name,
2230
+ response: { result: execResult },
2231
+ },
2232
+ });
2233
+ }
2234
+ catch (error) {
2235
+ // An abort during tool execution ends the turn gracefully — it
2236
+ // must NOT be recorded as a spurious tool failure. Both checks
2237
+ // matter: effectiveSignal.aborted catches an abort WE triggered
2238
+ // (even if the throw isn't abort-shaped); isAbortError(error)
2239
+ // catches an abort-shaped throw (e.g. a tool raising AbortError)
2240
+ // even when the signal itself never fired.
2241
+ if (effectiveSignal.aborted || isAbortError(error)) {
2242
+ wasAborted = true;
2243
+ break;
2244
+ }
2245
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
2246
+ // Track this failure
2247
+ const currentFailInfo = failedTools.get(call.name) || {
2248
+ count: 0,
2249
+ lastError: "",
2250
+ };
2251
+ currentFailInfo.count++;
2252
+ currentFailInfo.lastError = errorMessage;
2253
+ failedTools.set(call.name, currentFailInfo);
2254
+ logger.warn(`[GoogleVertex] Tool "${call.name}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
2255
+ // Determine if this is a permanent failure
2256
+ const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
2257
+ const errorOutput = {
2258
+ error: isPermanentFailure
2259
+ ? `TOOL_PERMANENTLY_FAILED: The tool "${call.name}" has failed ${currentFailInfo.count} times with error: ${errorMessage}. This tool will not be retried. Please proceed without using this tool or inform the user that this functionality is unavailable.`
2260
+ : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
2261
+ status: isPermanentFailure ? "permanently_failed" : "failed",
2262
+ do_not_retry: isPermanentFailure,
2263
+ retry_count: currentFailInfo.count,
2264
+ max_retries: DEFAULT_TOOL_MAX_RETRIES,
2265
+ };
2266
+ toolExecutions.push({
2267
+ name: call.name,
2268
+ input: call.args,
2269
+ output: errorOutput,
2270
+ });
2271
+ functionResponses.push({
2272
+ functionResponse: {
2273
+ name: call.name,
2274
+ response: errorOutput,
2275
+ },
2276
+ });
2277
+ }
2278
+ }
2279
+ else {
2280
+ // Tool not found is a permanent error
2014
2281
  const errorOutput = {
2015
- error: isPermanentFailure
2016
- ? `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.`
2017
- : `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
2018
- status: isPermanentFailure ? "permanently_failed" : "failed",
2019
- do_not_retry: isPermanentFailure,
2020
- retry_count: currentFailInfo.count,
2021
- max_retries: DEFAULT_TOOL_MAX_RETRIES,
2282
+ error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
2283
+ status: "permanently_failed",
2284
+ do_not_retry: true,
2022
2285
  };
2023
2286
  toolExecutions.push({
2024
2287
  name: call.name,
@@ -2033,101 +2296,111 @@ export class GoogleVertexProvider extends BaseProvider {
2033
2296
  });
2034
2297
  }
2035
2298
  }
2036
- else {
2037
- // Tool not found is a permanent error
2038
- const errorOutput = {
2039
- error: `TOOL_NOT_FOUND: The tool "${call.name}" does not exist. Do not attempt to call this tool again.`,
2040
- status: "permanently_failed",
2041
- do_not_retry: true,
2042
- };
2043
- toolExecutions.push({
2044
- name: call.name,
2045
- input: call.args,
2046
- output: errorOutput,
2047
- });
2048
- functionResponses.push({
2049
- functionResponse: {
2050
- name: call.name,
2051
- response: errorOutput,
2052
- },
2053
- });
2299
+ // An abort inside the tool-exec loop only breaks that inner for-loop.
2300
+ // Break the while too so no further model call is issued and control
2301
+ // reaches the terminal step-cap handling below.
2302
+ if (wasAborted) {
2303
+ break;
2054
2304
  }
2055
- }
2056
- // Persist this step's tool calls/results into conversation memory.
2057
- // Without this, tool_call / tool_result rows never reach Redis and
2058
- // the chat-history UI loses every tool invocation. The first call
2059
- // of the step carries the step's `thoughtSignature` so Gemini 3 can
2060
- // match thinking patterns on replay.
2061
- const stepToolCalls = allToolCalls.slice(toolCallsBefore);
2062
- const stepToolExecs = toolExecutions.slice(toolExecsBefore);
2063
- if (stepToolCalls.length > 0 || stepToolExecs.length > 0) {
2064
- const stepThoughtSig = extractThoughtSignature(rawResponseParts);
2065
- withTimeout(this.handleToolExecutionStorage(stepToolCalls.map((tc, i) => ({
2066
- toolName: tc.toolName,
2067
- args: tc.args,
2068
- ...(i === 0 && stepThoughtSig
2069
- ? { thoughtSignature: stepThoughtSig }
2070
- : {}),
2071
- stepIndex: step,
2072
- })), stepToolExecs.map((te) => ({
2073
- toolName: te.name,
2074
- output: te.output,
2075
- stepIndex: step,
2076
- })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
2077
- logger.warn("[GoogleVertex] Failed to store native Gemini generate tool executions", {
2078
- error: error instanceof Error ? error.message : String(error),
2305
+ // Persist this step's tool calls/results into conversation memory.
2306
+ // Without this, tool_call / tool_result rows never reach Redis and
2307
+ // the chat-history UI loses every tool invocation. The first call
2308
+ // of the step carries the step's `thoughtSignature` so Gemini 3 can
2309
+ // match thinking patterns on replay.
2310
+ const stepToolCalls = allToolCalls.slice(toolCallsBefore);
2311
+ const stepToolExecs = toolExecutions.slice(toolExecsBefore);
2312
+ if (stepToolCalls.length > 0 || stepToolExecs.length > 0) {
2313
+ const stepThoughtSig = extractThoughtSignature(rawResponseParts);
2314
+ withTimeout(this.handleToolExecutionStorage(stepToolCalls.map((tc, i) => ({
2315
+ toolName: tc.toolName,
2316
+ args: tc.args,
2317
+ ...(i === 0 && stepThoughtSig
2318
+ ? { thoughtSignature: stepThoughtSig }
2319
+ : {}),
2320
+ stepIndex: step,
2321
+ })), stepToolExecs.map((te) => ({
2322
+ toolName: te.name,
2323
+ output: te.output,
2324
+ stepIndex: step,
2325
+ })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
2326
+ logger.warn("[GoogleVertex] Failed to store native Gemini generate tool executions", {
2327
+ error: error instanceof Error ? error.message : String(error),
2328
+ });
2079
2329
  });
2330
+ }
2331
+ // The @google/genai SDK only accepts "user" and "model" as valid
2332
+ // roles in contents — function/tool responses must use role: "user"
2333
+ // (matching the SDK's automaticFunctionCalling implementation and
2334
+ // the Google AI Studio path). See note in executeNativeGemini3Stream.
2335
+ currentContents.push({
2336
+ role: "user",
2337
+ parts: functionResponses,
2080
2338
  });
2081
2339
  }
2082
- // The @google/genai SDK only accepts "user" and "model" as valid
2083
- // roles in contents function/tool responses must use role: "user"
2084
- // (matching the SDK's automaticFunctionCalling implementation and
2085
- // the Google AI Studio path). See note in executeNativeGemini3Stream.
2086
- currentContents.push({
2087
- role: "user",
2088
- parts: functionResponses,
2089
- });
2090
- }
2091
- catch (error) {
2092
- logger.error("[GoogleVertex] Native SDK generate error", error);
2093
- throw this.handleProviderError(error);
2094
- }
2095
- }
2096
- // Handle maxSteps termination — the loop exited because the step cap was
2097
- // reached while the model was still calling tools. Surface a real answer
2098
- // instead of the canned placeholder (Bug 1) and a meaningful finishReason
2099
- // (Bug 2).
2100
- let hitStepLimit = false;
2101
- let synthesizedFinalAnswer = false;
2102
- if (step >= maxSteps && !finalText) {
2103
- hitStepLimit = true;
2104
- if (accumulatedText) {
2105
- // Prefer the prose the model already produced across steps.
2106
- logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
2107
- `returning text already gathered from prior steps.`);
2108
- finalText = accumulatedText;
2109
- }
2110
- else {
2111
- // Pure functionCall turns leave no text — make one tools-disabled call
2112
- // so the model answers from the gathered tool results instead of the
2113
- // canned placeholder.
2114
- logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
2115
- `with no text; synthesizing a final answer with tools disabled.`);
2116
- const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? 300_000);
2117
- if (synth.text) {
2118
- synthesizedFinalAnswer = true;
2119
- finalText = synth.text;
2120
- totalInputTokens += synth.inputTokens;
2121
- totalOutputTokens += synth.outputTokens;
2122
- if (synth.finishReason) {
2123
- lastFinishReason = synth.finishReason;
2340
+ catch (error) {
2341
+ // A mid-drain abort surfaces as an AbortError from the `for await`.
2342
+ // Break gracefully into the terminal block instead of re-throwing
2343
+ // (a re-throw would route the caller's abort into a second unbounded
2344
+ // fallback generate()). Dual check as with the inner catch:
2345
+ // effectiveSignal.aborted (a signal we tripped) OR isAbortError(error)
2346
+ // (an abort-shaped throw) — either means "stop", not a real failure.
2347
+ if (effectiveSignal.aborted || isAbortError(error)) {
2348
+ wasAborted = true;
2349
+ break;
2124
2350
  }
2351
+ logger.error("[GoogleVertex] Native SDK generate error", error);
2352
+ throw this.handleProviderError(error);
2353
+ }
2354
+ }
2355
+ // Handle maxSteps termination / abort — the loop exited because the step
2356
+ // cap was reached (or the turn was aborted) while the model was still
2357
+ // calling tools. Surface a real answer instead of the canned placeholder
2358
+ // (Bug 1) and a meaningful finishReason (Bug 2).
2359
+ if (!finalText && (step >= maxSteps || wasAborted)) {
2360
+ hitStepLimit = step >= maxSteps && !wasAborted;
2361
+ const toolCallCount = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
2362
+ if (accumulatedText) {
2363
+ // Prefer the prose the model already produced across steps.
2364
+ logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
2365
+ `returning text already gathered from prior steps.`);
2366
+ finalText = accumulatedText;
2367
+ }
2368
+ else if (wasAborted) {
2369
+ // Aborted turn — skip synth entirely so it can never add +300s after
2370
+ // a blown budget. Deliver a graceful cap message as ordinary prose.
2371
+ logger.warn(`[GoogleVertex] Generate tool call loop aborted mid-turn; ` +
2372
+ `returning a graceful cap message.`);
2373
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
2125
2374
  }
2126
2375
  else {
2127
- finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
2376
+ // Pure functionCall turns leave no text make one tools-disabled call
2377
+ // so the model answers from the gathered tool results instead of the
2378
+ // canned placeholder.
2379
+ logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
2380
+ `with no text; synthesizing a final answer with tools disabled.`);
2381
+ // synth self-bounds its connect+drain via withTimeout(timeoutMs) and
2382
+ // never throws (returns empty on timeout/error), so it needs no outer
2383
+ // withTimeout wrapper — see synthesizeFinalAnswerWithoutTools.
2384
+ const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS, effectiveSignal);
2385
+ if (synth.text) {
2386
+ synthesizedFinalAnswer = true;
2387
+ finalText = synth.text;
2388
+ totalInputTokens += synth.inputTokens;
2389
+ totalOutputTokens += synth.outputTokens;
2390
+ if (synth.finishReason) {
2391
+ lastFinishReason = synth.finishReason;
2392
+ }
2393
+ }
2394
+ else {
2395
+ finalText = buildToolLoopCapMessage(maxSteps, toolCallCount);
2396
+ }
2128
2397
  }
2129
2398
  }
2130
2399
  }
2400
+ finally {
2401
+ clearTimeout(defensiveTimer);
2402
+ options.abortSignal?.removeEventListener("abort", onCallerAbort);
2403
+ }
2131
2404
  // Unified finish reason: a step-cap exhaustion that did NOT end in a clean
2132
2405
  // synthesized answer is reported as "tool-calls" (the model still wanted
2133
2406
  // tools) — NOT "length", which neurolink.ts treats as token truncation
@@ -2179,14 +2452,25 @@ export class GoogleVertexProvider extends BaseProvider {
2179
2452
  * (`final_result`) pattern was active, a trailing instruction countermands the
2180
2453
  * earlier "you MUST call final_result" directive so the model answers in plain
2181
2454
  * text. Never throws — returns empty text so the caller falls back to the
2182
- * placeholder, guaranteeing no new failure path.
2455
+ * graceful cap message (buildToolLoopCapMessage), guaranteeing no new failure
2456
+ * path.
2183
2457
  */
2184
- async synthesizeFinalAnswerWithoutTools(client, modelName, config, contents, useFinalResultTool, timeoutMs) {
2458
+ // eslint-disable-next-line max-params -- trailing optional abortSignal keeps the positional call sites stable
2459
+ async synthesizeFinalAnswerWithoutTools(client, modelName, config, contents, useFinalResultTool, timeoutMs, abortSignal) {
2460
+ // Already aborted — never issue the synth request (would add +300s after a
2461
+ // blown budget). Return empty so the caller maps to the graceful message.
2462
+ if (abortSignal?.aborted) {
2463
+ return { text: "", inputTokens: 0, outputTokens: 0 };
2464
+ }
2185
2465
  try {
2186
2466
  // Shallow clone so the loop's config is never mutated; dropping the
2187
2467
  // top-level `tools` key is sufficient (nested thinkingConfig /
2188
- // systemInstruction are intentionally preserved).
2189
- const synthConfig = { ...config };
2468
+ // systemInstruction are intentionally preserved). Fold in the abort
2469
+ // signal so the synth request is cancellable too.
2470
+ const synthConfig = {
2471
+ ...config,
2472
+ ...(abortSignal ? { abortSignal } : {}),
2473
+ };
2190
2474
  delete synthConfig.tools;
2191
2475
  if (useFinalResultTool) {
2192
2476
  const baseSystemInstruction = typeof synthConfig.systemInstruction === "string"
@@ -2287,18 +2571,14 @@ export class GoogleVertexProvider extends BaseProvider {
2287
2571
  });
2288
2572
  // Build messages from input
2289
2573
  const messages = [];
2290
- // Add conversation history if present.
2291
- //
2292
- // Intentionally text-only. Anthropic's API rejects messages where a
2293
- // tool_use_id reference appears without its matching tool_use in the
2294
- // same turn — so synthesising tool_use / tool_result blocks from
2295
- // stored ChatMessages risks emitting orphaned references that fail
2296
- // validation. Tool rows are still persisted to Redis (chat-history
2297
- // UI renders them) but they don't re-enter the model's context on
2298
- // subsequent turns.
2574
+ // Replay conversationMessages (with tool turns), else the legacy text-only conversationHistory.
2299
2575
  if (options.conversationMessages &&
2300
2576
  options.conversationMessages.length > 0) {
2301
- for (const msg of options.conversationMessages) {
2577
+ messages.push(...buildAnthropicHistoryMessages(options.conversationMessages));
2578
+ }
2579
+ else if (options.conversationHistory &&
2580
+ options.conversationHistory.length > 0) {
2581
+ for (const msg of options.conversationHistory) {
2302
2582
  if (msg.role === "user" || msg.role === "assistant") {
2303
2583
  messages.push({
2304
2584
  role: msg.role,
@@ -2430,13 +2710,10 @@ export class GoogleVertexProvider extends BaseProvider {
2430
2710
  type: "text",
2431
2711
  text: multimodalInput.text,
2432
2712
  });
2433
- // Add the user message with appropriate content format
2434
- messages.push({
2435
- role: "user",
2436
- content: userContentParts.length === 1 && userContentParts[0].type === "text"
2437
- ? multimodalInput.text
2438
- : userContentParts,
2439
- });
2713
+ // Append the live user turn (merges into a trailing user turn if present).
2714
+ appendUserMessage(messages, userContentParts.length === 1 && userContentParts[0].type === "text"
2715
+ ? multimodalInput.text
2716
+ : userContentParts);
2440
2717
  // Convert tools to Anthropic format if present
2441
2718
  let tools;
2442
2719
  const executeMap = new Map();
@@ -3056,20 +3333,14 @@ export class GoogleVertexProvider extends BaseProvider {
3056
3333
  // Build messages from input
3057
3334
  const messages = [];
3058
3335
  const inputText = options.prompt || options.input?.text || "Please respond.";
3059
- // Add conversation history if present. Prefer `conversationMessages`
3060
- // (what NeuroLink core injects today via MessageBuilder) and fall back
3061
- // to the legacy `conversationHistory` field for callers that still use
3062
- // the older surface. The Vertex Claude STREAM path already follows this
3063
- // priority — keeping the GENERATE path on `conversationHistory` only
3064
- // would silently drop multi-turn context for memory/loop sessions.
3065
- // Intentionally text-only: see the stream sibling for the rationale —
3066
- // synthesising tool_use / tool_result blocks from stored ChatMessages
3067
- // risks emitting orphaned references that Anthropic's API rejects.
3068
- const historyMessages = options.conversationMessages && options.conversationMessages.length > 0
3069
- ? options.conversationMessages
3070
- : options.conversationHistory;
3071
- if (historyMessages && historyMessages.length > 0) {
3072
- for (const msg of historyMessages) {
3336
+ // Replay conversationMessages (with tool turns), else the legacy text-only conversationHistory.
3337
+ if (options.conversationMessages &&
3338
+ options.conversationMessages.length > 0) {
3339
+ messages.push(...buildAnthropicHistoryMessages(options.conversationMessages));
3340
+ }
3341
+ else if (options.conversationHistory &&
3342
+ options.conversationHistory.length > 0) {
3343
+ for (const msg of options.conversationHistory) {
3073
3344
  if (msg.role === "user" || msg.role === "assistant") {
3074
3345
  messages.push({
3075
3346
  role: msg.role,
@@ -3201,13 +3472,10 @@ export class GoogleVertexProvider extends BaseProvider {
3201
3472
  type: "text",
3202
3473
  text: inputText,
3203
3474
  });
3204
- // Add the user message with appropriate content format
3205
- messages.push({
3206
- role: "user",
3207
- content: userContentParts.length === 1 && userContentParts[0].type === "text"
3208
- ? inputText
3209
- : userContentParts,
3210
- });
3475
+ // Append the live user turn (merges into a trailing user turn if present).
3476
+ appendUserMessage(messages, userContentParts.length === 1 && userContentParts[0].type === "text"
3477
+ ? inputText
3478
+ : userContentParts);
3211
3479
  // Convert tools to Anthropic format if present
3212
3480
  let tools;
3213
3481
  const executeMap = new Map();