@juspay/neurolink 9.80.2 → 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.
@@ -932,7 +932,10 @@ export class GoogleVertexProvider extends BaseProvider {
932
932
  this.emitStreamEnd(modelName, streamStartTime, false, error);
933
933
  throw error;
934
934
  }
935
- }, (r) => r.stream, (r, wrapped) => ({ ...r, stream: wrapped }));
935
+ }, (r) => r.stream,
936
+ // Preserve live getters (finishReason/structuredOutput/…) that the
937
+ // spread would otherwise snapshot before the background loop resolves.
938
+ (r, wrapped) => this.preserveStreamResultAccessors(r, { ...r, stream: wrapped }));
936
939
  }
937
940
  /**
938
941
  * Emit `stream:end` so the Pipeline B observability listener creates a
@@ -2832,6 +2835,13 @@ export class GoogleVertexProvider extends BaseProvider {
2832
2835
  // while generation is still in progress. Mirrors the executeStream
2833
2836
  // pattern in googleAiStudio.ts.
2834
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;
2835
2845
  const allToolCalls = [];
2836
2846
  const toolExecutions = [];
2837
2847
  const channel = createTextChannel();
@@ -2847,6 +2857,10 @@ export class GoogleVertexProvider extends BaseProvider {
2847
2857
  };
2848
2858
  const toolsUsedRef = [];
2849
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 = {};
2850
2864
  // Langfuse/OTel: the native SDK bypasses the Vercel AI SDK's
2851
2865
  // experimental_telemetry, so emit spans manually — one turn span, one
2852
2866
  // generation span per API call, one tool span per execution — all carrying
@@ -2883,6 +2897,11 @@ export class GoogleVertexProvider extends BaseProvider {
2883
2897
  });
2884
2898
  const turnContext = otelTrace.setSpan(otelContext.active(), turnSpan);
2885
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;
2886
2905
  // Anthropic prompt-cache token accounting, aggregated across loop steps.
2887
2906
  const turnCacheUsage = {
2888
2907
  read: 0,
@@ -2913,10 +2932,23 @@ export class GoogleVertexProvider extends BaseProvider {
2913
2932
  const loopPromise = (async () => {
2914
2933
  let step = 0;
2915
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;
2916
2943
  try {
2917
- while (step < maxSteps) {
2944
+ while (step < agenticStepBudget) {
2945
+ // Honor caller aborts BETWEEN steps: break into terminal handling
2946
+ // (one graceful cap chunk, clean close) instead of throwing —
2947
+ // channel.error would surface the caller's own abort as a stream
2948
+ // failure and route consumers into fallback retries.
2918
2949
  if (options.abortSignal?.aborted) {
2919
- throw new Error("Stream aborted by caller");
2950
+ wasAborted = true;
2951
+ break;
2920
2952
  }
2921
2953
  step++;
2922
2954
  // One generation observation per API call: request in, content + usage out.
@@ -2980,6 +3012,7 @@ export class GoogleVertexProvider extends BaseProvider {
2980
3012
  generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_COMPLETION_START_TIME, new Date().toISOString());
2981
3013
  }
2982
3014
  channel.push(delta);
3015
+ liveTextPushedLength += delta.length;
2983
3016
  }
2984
3017
  });
2985
3018
  // finalMessage() resolves AFTER message_stop. By then the listener
@@ -2999,6 +3032,15 @@ export class GoogleVertexProvider extends BaseProvider {
2999
3032
  generationSpan.recordException(modelCallError);
3000
3033
  }
3001
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
+ }
3002
3044
  throw modelCallError;
3003
3045
  }
3004
3046
  activeStream = undefined;
@@ -3016,6 +3058,7 @@ export class GoogleVertexProvider extends BaseProvider {
3016
3058
  usage.input += response.usage?.input_tokens || 0;
3017
3059
  usage.output += response.usage?.output_tokens || 0;
3018
3060
  usage.total = usage.input + usage.output;
3061
+ lastStopReason = response.stop_reason;
3019
3062
  for (const block of response.content) {
3020
3063
  if (block.type === "text" && typeof block.text === "string") {
3021
3064
  aggregatedTurnText += block.text;
@@ -3057,6 +3100,7 @@ export class GoogleVertexProvider extends BaseProvider {
3057
3100
  if (finalResultCall) {
3058
3101
  structuredOutputRef.value = finalResultCall.input;
3059
3102
  channel.push(JSON.stringify(finalResultCall.input));
3103
+ modelFinished = true;
3060
3104
  logger.debug("[GoogleVertex] Extracted structured output from final_result tool (stream)", { keys: Object.keys(finalResultCall.input) });
3061
3105
  break;
3062
3106
  }
@@ -3064,11 +3108,14 @@ export class GoogleVertexProvider extends BaseProvider {
3064
3108
  // No tools — pure text turn. Listener already pushed all deltas;
3065
3109
  // loop terminates and channel.close() flushes the consumer.
3066
3110
  if (toolUseBlocks.length === 0) {
3111
+ modelFinished = true;
3067
3112
  break;
3068
3113
  }
3069
3114
  // Tool execution loop. tool:start / tool:end events fire from
3070
3115
  // ToolsManager's wrapped execute (ToolsManager.ts:355) — no inline
3071
- // 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).
3072
3119
  const toolResults = [];
3073
3120
  // Per-step bookkeeping for conversation-memory storage.
3074
3121
  const stepStorageCalls = [];
@@ -3156,6 +3203,23 @@ export class GoogleVertexProvider extends BaseProvider {
3156
3203
  });
3157
3204
  }
3158
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
+ }
3159
3223
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3160
3224
  const errorPayload = { error: errMsg };
3161
3225
  endToolSpan(errorPayload, errMsg);
@@ -3199,7 +3263,9 @@ export class GoogleVertexProvider extends BaseProvider {
3199
3263
  }
3200
3264
  // Persist this step's tool calls/results into conversation memory.
3201
3265
  // Without this hook, tool rows never land in Redis and the
3202
- // 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.
3203
3269
  if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
3204
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) => {
3205
3271
  logger.warn("[GoogleVertex] Failed to store native Anthropic stream tool executions", {
@@ -3207,6 +3273,26 @@ export class GoogleVertexProvider extends BaseProvider {
3207
3273
  });
3208
3274
  });
3209
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
+ }
3210
3296
  // Continue the loop: assistant turn + tool_result user turn.
3211
3297
  // Filter server_tool_use blocks (Anthropic API rejects them in
3212
3298
  // subsequent message turns).
@@ -3220,6 +3306,220 @@ export class GoogleVertexProvider extends BaseProvider {
3220
3306
  content: toolResults,
3221
3307
  });
3222
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;
3223
3523
  metadata.responseTime = Date.now() - startTime;
3224
3524
  metadata.totalToolExecutions = allToolCalls.filter((tc) => tc.toolName !== "final_result").length;
3225
3525
  // Surface cache metrics once, after the agentic loop, from the
@@ -3315,6 +3615,13 @@ export class GoogleVertexProvider extends BaseProvider {
3315
3615
  configurable: true,
3316
3616
  get: () => structuredOutputRef.value,
3317
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
+ });
3318
3625
  return result;
3319
3626
  }
3320
3627
  /**
@@ -3578,8 +3885,21 @@ export class GoogleVertexProvider extends BaseProvider {
3578
3885
  };
3579
3886
  // Handle tool calling loop with max steps
3580
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;
3581
3897
  let step = 0;
3582
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 = "";
3583
3903
  let structuredOutput;
3584
3904
  const allToolCalls = [];
3585
3905
  let totalInputTokens = 0;
@@ -3594,8 +3914,22 @@ export class GoogleVertexProvider extends BaseProvider {
3594
3914
  // (notably "length" on token truncation) — the legacy native path always
3595
3915
  // reported "stop", hiding truncation from callers.
3596
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;
3597
3924
  const currentMessages = [...messages];
3598
- while (step < maxSteps) {
3925
+ while (step < agenticStepBudget) {
3926
+ // Honor caller aborts BETWEEN steps: break into terminal handling
3927
+ // instead of throwing (a throw routes consumers into abortSignal-less
3928
+ // fallback retries — observed in production as a 600s abort no-op).
3929
+ if (options.abortSignal?.aborted) {
3930
+ wasAborted = true;
3931
+ break;
3932
+ }
3599
3933
  step++;
3600
3934
  try {
3601
3935
  // Bound the SDK wait so a stalled Vertex/Anthropic call can't hang
@@ -3612,6 +3946,9 @@ export class GoogleVertexProvider extends BaseProvider {
3612
3946
  tools,
3613
3947
  messages: currentMessages,
3614
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).
3615
3952
  const response = await withTimeout(client.messages.create({
3616
3953
  ...requestParams,
3617
3954
  ...(cachedGenerate.system !== undefined && {
@@ -3622,7 +3959,7 @@ export class GoogleVertexProvider extends BaseProvider {
3622
3959
  tools: cachedGenerate.tools,
3623
3960
  }),
3624
3961
  messages: cachedGenerate.messages,
3625
- }), generateTimeoutMs, "Anthropic generate timed out");
3962
+ }, { signal: options.abortSignal }), generateTimeoutMs, "Anthropic generate timed out");
3626
3963
  // Update token counts. input_tokens is the uncached remainder; cache
3627
3964
  // reads/writes are reported separately and accumulated here so the
3628
3965
  // result reflects the full picture.
@@ -3641,6 +3978,7 @@ export class GoogleVertexProvider extends BaseProvider {
3641
3978
  // Extract structured output and convert to JSON string for finalText
3642
3979
  structuredOutput = finalResultCall.input;
3643
3980
  finalText = JSON.stringify(structuredOutput);
3981
+ modelFinished = true;
3644
3982
  logger.debug("[GoogleVertex] Extracted structured output from final_result tool (generate)", { keys: Object.keys(structuredOutput) });
3645
3983
  break; // We have the structured output, we're done
3646
3984
  }
@@ -3650,10 +3988,13 @@ export class GoogleVertexProvider extends BaseProvider {
3650
3988
  const responseText = textBlocks.map((b) => b.text).join("");
3651
3989
  if (toolUseBlocks.length === 0) {
3652
3990
  // No tool calls, we're done
3653
- finalText = responseText || finalText;
3991
+ finalText = responseText || accumulatedStepText;
3992
+ modelFinished = true;
3654
3993
  break;
3655
3994
  }
3656
- // 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).
3657
3998
  const toolResults = [];
3658
3999
  // Per-step bookkeeping for conversation-memory storage. Tracks calls
3659
4000
  // and results for ONLY the tools fired in this step so the storage
@@ -3704,6 +4045,21 @@ export class GoogleVertexProvider extends BaseProvider {
3704
4045
  });
3705
4046
  }
3706
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
+ }
3707
4063
  const errMsg = `Error executing tool "${toolUse.name}": ${err instanceof Error ? err.message : String(err)}`;
3708
4064
  const errorPayload = { error: errMsg };
3709
4065
  toolExecutions.push({
@@ -3747,6 +4103,8 @@ export class GoogleVertexProvider extends BaseProvider {
3747
4103
  // Without this, tool_call / tool_result rows never reach Redis and
3748
4104
  // the chat-history UI loses every tool invocation.
3749
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.
3750
4108
  if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
3751
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) => {
3752
4110
  logger.warn("[GoogleVertex] Failed to store native Anthropic generate tool executions", {
@@ -3754,6 +4112,26 @@ export class GoogleVertexProvider extends BaseProvider {
3754
4112
  });
3755
4113
  });
3756
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
+ }
3757
4135
  // Add assistant message and tool results to continue the loop
3758
4136
  // Filter out server_tool_use blocks that the Anthropic API doesn't accept in messages
3759
4137
  const assistantContent = response.content.filter((block) => block.type !== "server_tool_use");
@@ -3765,25 +4143,193 @@ export class GoogleVertexProvider extends BaseProvider {
3765
4143
  role: "user",
3766
4144
  content: toolResults,
3767
4145
  });
3768
- // Store last text in case we hit max steps
3769
- if (responseText) {
3770
- finalText = responseText;
3771
- }
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);
3772
4149
  }
3773
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
+ }
3774
4161
  logger.error("[GoogleVertex] Native Anthropic SDK generate error", error);
3775
4162
  throw this.handleProviderError(error);
3776
4163
  }
3777
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
+ }
3778
4313
  const responseTime = Date.now() - startTime;
3779
4314
  const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
3780
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
+ }
3781
4321
  const result = {
3782
4322
  content: finalText,
3783
- // Surface truncation: Anthropic "max_tokens" unified "length" so the
3784
- // SDK boundary can flag/observe incomplete structured output. Anything
3785
- // else (end_turn / stop_sequence / tool_use) is a normal stop.
3786
- finishReason: lastStopReason === "max_tokens" ? "length" : "stop",
4323
+ // Honest finish reason. "length" (token truncation) takes precedence; a
4324
+ // step-cap exhaustion without a clean/forced answer surfaces as
4325
+ // "tool-calls" (aligned with the Gemini paths, so consumers detect
4326
+ // capped turns uniformly); everything else including a successful
4327
+ // forced finalization or backstop synthesis — is a normal "stop".
4328
+ finishReason: lastStopReason === "max_tokens"
4329
+ ? "length"
4330
+ : hitStepLimit && !synthesizedFinalAnswer
4331
+ ? "tool-calls"
4332
+ : "stop",
3787
4333
  provider: this.providerName,
3788
4334
  model: modelName,
3789
4335
  usage: {
@@ -4252,10 +4798,35 @@ export class GoogleVertexProvider extends BaseProvider {
4252
4798
  }
4253
4799
  },
4254
4800
  };
4255
- return {
4801
+ return this.preserveStreamResultAccessors(result, {
4256
4802
  ...result,
4257
4803
  stream: wrappedIterable,
4258
- };
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;
4259
4830
  }
4260
4831
  /**
4261
4832
  * Attach `gen_ai.usage.*` and `neurolink.cost` attributes to a span.