@juspay/neurolink 11.16.3 → 11.17.1

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.
@@ -27,11 +27,14 @@ import { resolveClaudeMaxTokens } from "../../utils/tokenLimits.js";
27
27
  import { validateApiKey, createVertexProjectConfig, createGoogleAuthConfig, } from "../../utils/providerConfig.js";
28
28
  import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } from "../../utils/schemaConversion.js";
29
29
  import { createNativeThinkingConfig } from "../../utils/thinkingConfig.js";
30
- import { TimeoutError, raceWithAbort, withTimeout, } from "../../utils/async/index.js";
30
+ import { TimeoutError, withTimeout } from "../../utils/async/index.js";
31
31
  import { parseTimeout } from "../../utils/timeout.js";
32
32
  import { appendStepText, buildAbortedTurnMessage, buildContextCapMessage, buildDedupedEngineTools, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createContextGuard, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
33
33
  import { createGeminiLoopAdapter } from "../../core/geminiLoopAdapter.js";
34
34
  import { runAgenticLoop } from "../../core/loopEngine.js";
35
+ import { createAnthropicLoopAdapter } from "../anthropic/loopAdapter.js";
36
+ import { guardToolExecutor } from "../googleNativeGemini3/utils.js";
37
+ import { extractMcpToolErrorMessage } from "../../utils/mcpErrorText.js";
35
38
  import { createStreamChannel } from "../../core/streamChannel.js";
36
39
  import { toNativeToolDeclarations } from "../../core/nativeToolFormat.js";
37
40
  import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
@@ -43,7 +46,7 @@ import { transformToolExecutions } from "../../utils/transformationUtils.js";
43
46
  import { resolveToolExecutionRecords } from "../../core/toolExecutionRecorder.js";
44
47
  import { resolveSamplingParams } from "../../models/modelRegistry.js";
45
48
  import { sanitizeAnthropicMessagesForTrace } from "../../utils/anthropicTraceSanitizer.js";
46
- import { extractMcpToolErrorMessage, extractToolFailureText, } from "../../utils/mcpErrorText.js";
49
+ import { extractToolFailureText } from "../../utils/mcpErrorText.js";
47
50
  // Import proper types for multimodal message handling
48
51
  // Dynamic import helper for native Anthropic Vertex SDK
49
52
  let anthropicVertexModule = null;
@@ -3334,514 +3337,379 @@ export class GoogleVertexProvider extends BaseProvider {
3334
3337
  const contextGuard = createContextGuard(getContextWindowSize("vertex", modelName));
3335
3338
  const failedTools = new Map();
3336
3339
  try {
3337
- while (step < agenticStepBudget) {
3338
- // Honor aborts BETWEEN steps (caller signal OR the turn clock's
3339
- // watchdogs all fan into internalAbort): break into terminal
3340
- // handling (one honest terminal chunk, clean close) instead of
3341
- // throwing channel.error would surface the caller's own abort as
3342
- // a stream failure and route consumers into fallback retries.
3343
- if (internalAbort.signal.aborted) {
3344
- wasAborted = true;
3345
- break;
3346
- }
3347
- // Context guard: stop the tool loop before the accumulated
3348
- // conversation crosses the window threshold (see generate twin).
3349
- if (contextGuard.shouldStop()) {
3350
- // Parity upgrade: reclaim and continue where possible; the
3351
- // historic stop-only behaviour remains the fallback.
3352
- const reclaimed = reclaimVertexAnthropicContext(currentMessages, modelName, contextGuard.projectedNextPromptTokens);
3353
- if (reclaimed) {
3354
- contextGuard.resetAfterReclaim();
3355
- }
3356
- else {
3357
- hitContextLimit = true;
3358
- logger.warn(`[GoogleVertex] Anthropic stream turn stopped by the context guard: ` +
3359
- `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
3360
- `>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
3361
- break;
3362
- }
3340
+ // Executors handed to the engine, taken through the turn's
3341
+ // DedupExecuteMap so an identical repeated call is answered from the
3342
+ // per-turn cache rather than run again (BZ-3327). `.get()` returns the
3343
+ // wrapper; iterating the map yields the raw functions, which is why the
3344
+ // record is built by name rather than from entries.
3345
+ const engineTools = {};
3346
+ for (const toolName of executeMap.keys()) {
3347
+ const wrapped = executeMap.get(toolName);
3348
+ if (!wrapped) {
3349
+ continue;
3363
3350
  }
3364
- step++;
3365
- turnClock.noteProgress();
3366
- // Mid-turn discovery sync: Claude only calls tools declared in the
3367
- // request, so tools hydrated by search_tools last step must be
3368
- // advertised now (requestParams.tools holds this array by
3369
- // reference).
3370
- this.refreshAnthropicToolDeclarations(options.tools, tools, executeMap, failedTools);
3371
- // One generation observation per API call: request in, content + usage out.
3372
- const generationSpan = tracers.generation.startSpan("anthropic.messages.stream", {
3373
- kind: SpanKind.CLIENT,
3374
- attributes: {
3375
- [LANGFUSE_ATTR.OBSERVATION_TYPE]: "generation",
3376
- [LANGFUSE_ATTR.OBSERVATION_MODEL_NAME]: modelName,
3377
- [LANGFUSE_ATTR.OBSERVATION_MODEL_PARAMETERS]: spanJsonAttribute({
3378
- max_tokens: requestParams.max_tokens,
3379
- temperature: requestParams.temperature,
3380
- top_p: requestParams.top_p,
3381
- }),
3382
- [LANGFUSE_ATTR.OBSERVATION_INPUT]: spanJsonAttribute({
3383
- system: systemPromptWithSchema,
3384
- messages: sanitizeAnthropicMessagesForTrace(currentMessages),
3385
- }),
3386
- [LANGFUSE_ATTR.OBSERVATION_METADATA]: spanJsonAttribute({
3387
- step,
3388
- toolsOffered: offeredToolNames.length,
3389
- }),
3390
- [ATTR.GEN_AI_SYSTEM]: "anthropic",
3391
- [ATTR.GEN_AI_MODEL]: modelName,
3392
- [ATTR.GEN_AI_OPERATION]: "chat",
3393
- },
3394
- }, turnContext);
3395
- let response;
3396
- try {
3397
- // Vertex has no automatic prompt caching — place explicit
3398
- // cache_control breakpoints (system, tools, rolling history) so the
3399
- // conversation prefix is cached across turns instead of re-billed as
3400
- // fresh input every call. Re-applied per step: the stable prefix
3401
- // stays byte-identical (consistent cache key) while the rolling
3402
- // breakpoint follows the growing tail.
3403
- const cachedStream = applyVertexAnthropicCacheBreakpoints({
3351
+ engineTools[toolName] = {
3352
+ // Guarded exactly as the hand-rolled loop guarded it: a per-tool
3353
+ // bound so a wedged tool costs ONE STEP rather than the whole turn,
3354
+ // raced against the turn's abort so a deadline is observed at once
3355
+ // instead of after the tool settles, and a stall-clock ping either
3356
+ // side so a slow-but-healthy tool is not read as a stalled turn.
3357
+ execute: guardToolExecutor(toolName, wrapped, {
3358
+ toolTimeoutMs: toolExecTimeoutMs,
3359
+ abortSignal: internalAbort.signal,
3360
+ onProgress: () => turnClock.noteProgress(),
3361
+ // Restores the per-tool observation the hand-rolled dispatch had.
3362
+ // The execution runs INSIDE the span's context so spans the tool opens
3363
+ // itself nest under this call rather than dangling beside the turn, and
3364
+ // the settled RESULT is inspected — an MCP tool reports failure in its
3365
+ // payload, so an observation that only watches for throws records a
3366
+ // failed call as successful.
3367
+ //
3368
+ // Stream path only: the generate twin never had per-tool spans, and
3369
+ // giving it them here would be behaviour GAINED under cover of a
3370
+ // migration.
3371
+ withToolSpan: (toolCallName, run) => {
3372
+ const toolSpan = tracers.mcp.startSpan("ai.toolCall", {
3373
+ kind: SpanKind.INTERNAL,
3374
+ attributes: {
3375
+ [LANGFUSE_ATTR.OBSERVATION_TYPE]: "tool",
3376
+ [ATTR.GEN_AI_TOOL_NAME]: toolCallName,
3377
+ "ai.toolCall.name": toolCallName,
3378
+ },
3379
+ }, turnContext);
3380
+ const finish = (output, errorMessage) => {
3381
+ toolSpan.setAttribute("ai.toolCall.result", spanJsonAttribute(output));
3382
+ toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(output));
3383
+ if (errorMessage) {
3384
+ toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_LEVEL, "ERROR");
3385
+ toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_STATUS_MESSAGE, errorMessage);
3386
+ toolSpan.setStatus({
3387
+ code: SpanStatusCode.ERROR,
3388
+ message: errorMessage,
3389
+ });
3390
+ }
3391
+ else {
3392
+ toolSpan.setStatus({ code: SpanStatusCode.OK });
3393
+ }
3394
+ toolSpan.end();
3395
+ };
3396
+ return otelContext.with(otelTrace.setSpan(turnContext, toolSpan), async () => {
3397
+ try {
3398
+ const result = await run();
3399
+ finish(result, extractMcpToolErrorMessage(result));
3400
+ return result;
3401
+ }
3402
+ catch (error) {
3403
+ finish({ error: true }, error instanceof Error ? error.message : String(error));
3404
+ throw error;
3405
+ }
3406
+ });
3407
+ },
3408
+ }),
3409
+ };
3410
+ }
3411
+ // The turn runs on the shared engine. The step cap, tool dispatch, the
3412
+ // failure breaker, per-step usage accumulation and the pre-first-chunk
3413
+ // provider retry all live there now. What stays here is everything the
3414
+ // engine has no opinion about: the turn clock, the context guard, the
3415
+ // per-step Langfuse generation span, conversation-memory storage, the
3416
+ // wrap-up nudge, and the reserved finalization in the terminal block.
3417
+ //
3418
+ // maxSteps is agenticStepBudget, NOT maxSteps: when structured output is
3419
+ // active the last slot is reserved for the forced final_result call, and
3420
+ // the engine must never spend it.
3421
+ // The type argument is explicit: without it TMessage infers as the SDK's
3422
+ // MessageParam and every hook here is typed against the wrong shape.
3423
+ const baseAdapter = createAnthropicLoopAdapter({
3424
+ client,
3425
+ maxSteps: agenticStepBudget,
3426
+ toolsRecord: options.tools ?? {},
3427
+ // Set here and NOT for native Anthropic: these loops have always had
3428
+ // the consecutive-failure strike breaker, and native Anthropic has
3429
+ // never had one. Giving it one under cover of a shared refactor would
3430
+ // be a behaviour change, not a migration.
3431
+ toolFailureBreaker: {
3432
+ maxRetries: DEFAULT_TOOL_MAX_RETRIES,
3433
+ // MCP failures are RETURNED, not thrown. Counting only throws lets
3434
+ // the model grind on a blocked tool for the whole step budget.
3435
+ classifyResultFailure: (output) => extractToolFailureText(output) ?? undefined,
3436
+ },
3437
+ buildParams: (conversation) => {
3438
+ // Mid-turn discovery sync: Claude only calls tools declared in the
3439
+ // request, so tools hydrated by search_tools last step have to be
3440
+ // advertised now. `tools` is held by reference in requestParams.
3441
+ this.refreshAnthropicToolDeclarations(options.tools, tools, executeMap, failedTools);
3442
+ // Vertex has no automatic prompt caching — explicit cache_control
3443
+ // breakpoints (system, tools, rolling history) keep the conversation
3444
+ // prefix cached across turns instead of re-billed as fresh input.
3445
+ // Re-applied per step: the stable prefix stays byte-identical for a
3446
+ // consistent cache key while the rolling breakpoint follows the tail.
3447
+ const cached = applyVertexAnthropicCacheBreakpoints({
3404
3448
  system: systemPromptWithSchema,
3405
3449
  tools,
3406
- messages: currentMessages,
3450
+ messages: conversation,
3407
3451
  });
3408
- const stream = await client.messages.stream({
3409
- ...requestParams,
3410
- ...(cachedStream.system !== undefined && {
3411
- system: cachedStream.system,
3452
+ // `stream` is dropped from the spread: requestParams is typed for
3453
+ // messages.stream so it carries an optional stream flag, and
3454
+ // executeStep sets that itself. Passing it through would type this
3455
+ // return as the streaming variant for a field the adapter owns.
3456
+ const { stream: _ignoredStreamFlag, output_config: _ignoredOutputConfig, ...baseParams } = requestParams;
3457
+ void _ignoredStreamFlag;
3458
+ // output_config differs between the two param types as well — the
3459
+ // streaming variant allows null where the non-streaming one does
3460
+ // not. Nothing here sets it, so it is dropped rather than widened.
3461
+ void _ignoredOutputConfig;
3462
+ return {
3463
+ ...baseParams,
3464
+ ...(cached.system !== undefined && {
3465
+ system: cached.system,
3412
3466
  }),
3413
- ...(cachedStream.tools &&
3414
- cachedStream.tools.length > 0 && {
3415
- tools: cachedStream.tools,
3467
+ ...(cached.tools &&
3468
+ cached.tools.length > 0 && {
3469
+ tools: cached.tools,
3416
3470
  }),
3417
- messages: cachedStream.messages,
3418
- });
3419
- activeStream = stream;
3420
- // Forward each text delta as it arrives — the Anthropic SDK fires
3421
- // this synchronously per content_block_delta, so the channel streams
3422
- // at wire cadence. The first delta stamps completion_start_time,
3423
- // giving Langfuse the generation's time-to-first-token.
3424
- let firstDeltaSeen = false;
3425
- stream.on("text", (delta) => {
3426
- turnClock.noteProgress();
3427
- if (delta.length > 0) {
3428
- if (!firstDeltaSeen) {
3429
- firstDeltaSeen = true;
3430
- generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_COMPLETION_START_TIME, new Date().toISOString());
3431
- }
3432
- channel.push({ content: delta });
3433
- liveTextPushedLength += delta.length;
3434
- }
3435
- });
3436
- // finalMessage() resolves AFTER message_stop. By then the listener
3437
- // has already fired for every delta — awaiting here doesn't block
3438
- // visible streaming, it just gives us the structured response
3439
- // shape needed for tool_use block extraction.
3440
- response = await stream.finalMessage();
3441
- }
3442
- catch (modelCallError) {
3443
- generationSpan.setStatus({
3444
- code: SpanStatusCode.ERROR,
3445
- message: modelCallError instanceof Error
3446
- ? modelCallError.message
3447
- : String(modelCallError),
3448
- });
3449
- if (modelCallError instanceof Error) {
3450
- generationSpan.recordException(modelCallError);
3451
- }
3452
- generationSpan.end();
3453
- // A mid-flight abort (caller signal or a turn-clock watchdog
3454
- // tripping internalAbort/abortHandler) rejects finalMessage()
3455
- // with an abort-shaped error. Break gracefully into the terminal
3456
- // handling instead of routing it through channel.error as a
3457
- // failure.
3458
- if (internalAbort.signal.aborted || isAbortError(modelCallError)) {
3459
- activeStream = undefined;
3460
- wasAborted = true;
3461
- break;
3462
- }
3463
- throw modelCallError;
3464
- }
3465
- activeStream = undefined;
3466
- // End the generation span even if the bookkeeping below throws (else
3467
- // it leaks). The model-call error path already ended it — no double-end.
3468
- try {
3469
- const stepCacheRead = response.usage?.cache_read_input_tokens ?? 0;
3470
- const stepCacheCreation = response.usage?.cache_creation_input_tokens ?? 0;
3471
- const stepCacheCreation5m = response.usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
3472
- const stepCacheCreation1h = response.usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
3473
- turnCacheUsage.read += stepCacheRead;
3474
- turnCacheUsage.creation += stepCacheCreation;
3475
- turnCacheUsage.creation5m += stepCacheCreation5m;
3476
- turnCacheUsage.creation1h += stepCacheCreation1h;
3477
- usage.input += response.usage?.input_tokens || 0;
3478
- usage.output += response.usage?.output_tokens || 0;
3479
- // Anthropic's input_tokens is only the UNCACHED remainder; cache
3480
- // reads/writes are billed tokens reported separately, so the
3481
- // total must include them (matches proxyTracer + anthropic.ts).
3482
- usage.total =
3483
- usage.input +
3484
- usage.output +
3485
- turnCacheUsage.read +
3486
- turnCacheUsage.creation;
3487
- lastStopReason = response.stop_reason;
3488
- // Feed the context guard the FULL prompt size of this call
3489
- // (uncached input + cache reads/writes).
3490
- contextGuard.noteUsage((response.usage?.input_tokens || 0) +
3491
- stepCacheRead +
3492
- stepCacheCreation, response.usage?.output_tokens || 0);
3493
- for (const block of response.content) {
3494
- if (block.type === "text" && typeof block.text === "string") {
3495
- aggregatedTurnText += block.text;
3496
- }
3497
- }
3498
- generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(response.content));
3499
- // 5m and 1h cache-creation are priced differently, so keep both;
3500
- // drop the aggregate input_cache_creation (= 5m + 1h) that would
3501
- // double-count. total sums the per-TTL keys shown here to match them.
3502
- generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_USAGE_DETAILS, spanJsonAttribute({
3503
- input: response.usage?.input_tokens ?? 0,
3504
- output: response.usage?.output_tokens ?? 0,
3505
- input_cached_tokens: stepCacheRead,
3506
- input_cache_creation_5m: stepCacheCreation5m,
3507
- input_cache_creation_1h: stepCacheCreation1h,
3508
- total: (response.usage?.input_tokens ?? 0) +
3509
- (response.usage?.output_tokens ?? 0) +
3510
- stepCacheRead +
3511
- stepCacheCreation5m +
3512
- stepCacheCreation1h,
3513
- }));
3514
- generationSpan.setAttribute(ATTR.GEN_AI_INPUT_TOKENS, response.usage?.input_tokens ?? 0);
3515
- generationSpan.setAttribute(ATTR.GEN_AI_OUTPUT_TOKENS, response.usage?.output_tokens ?? 0);
3516
- if (response.stop_reason) {
3517
- generationSpan.setAttribute(ATTR.GEN_AI_FINISH_REASON, response.stop_reason);
3471
+ messages: cached.messages,
3472
+ };
3473
+ },
3474
+ planReclaim: (conversation) => {
3475
+ if (!contextGuard.shouldStop()) {
3476
+ return undefined;
3518
3477
  }
3519
- generationSpan.setStatus({ code: SpanStatusCode.OK });
3520
- }
3521
- finally {
3522
- generationSpan.end();
3523
- }
3524
- const toolUseBlocks = response.content.filter((block) => block.type === "tool_use");
3525
- // Structured-output pattern: when the model returns the
3526
- // final_result tool call, push its arguments as JSON and stop.
3527
- // Single-shot yield so callers consuming the stream still see
3528
- // the structured value.
3529
- if (useFinalResultTool) {
3530
- const finalResultCall = toolUseBlocks.find((block) => block.name === "final_result");
3531
- if (finalResultCall) {
3532
- structuredOutputRef.value = finalResultCall.input;
3533
- channel.push({ content: JSON.stringify(finalResultCall.input) });
3534
- modelFinished = true;
3535
- logger.debug("[GoogleVertex] Extracted structured output from final_result tool (stream)", { keys: Object.keys(finalResultCall.input) });
3536
- break;
3478
+ // Reclaim and continue where possible: ending the turn early is safe
3479
+ // but throws away work the model was mid-way through.
3480
+ const working = [...conversation];
3481
+ if (reclaimVertexAnthropicContext(working, modelName, contextGuard.projectedNextPromptTokens)) {
3482
+ contextGuard.resetAfterReclaim();
3483
+ return { conversation: working };
3537
3484
  }
3538
- }
3539
- // No tools pure text turn. Listener already pushed all deltas;
3540
- // loop terminates and channel.close() flushes the consumer.
3541
- if (toolUseBlocks.length === 0) {
3542
- modelFinished = true;
3543
- break;
3544
- }
3545
- // Tool execution loop. tool:start / tool:end events fire from
3546
- // ToolsManager's wrapped execute (ToolsManager.ts:355) — no inline
3547
- // emit needed. The array also carries the trailing soft-budget
3548
- // nudge text block appended below (tool_result blocks stay first,
3549
- // as the Anthropic API requires).
3550
- const toolResults = [];
3551
- // Per-step bookkeeping for conversation-memory storage.
3552
- const stepStorageCalls = [];
3553
- const stepStorageResults = [];
3554
- // Note: tool:start / tool:end events are emitted by ToolsManager's
3555
- // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
3556
- for (const toolUse of toolUseBlocks) {
3557
- // Honor a deadline/stall/caller abort BETWEEN tool executions —
3558
- // without this check a multi-tool step keeps executing its whole
3559
- // batch (up to N × toolTimeoutMs past the deadline) before the
3560
- // while-top check finally breaks. Skipped tools get neither a
3561
- // call row nor a result row, so persisted history stays paired.
3562
- if (internalAbort.signal.aborted) {
3563
- wasAborted = true;
3564
- break;
3485
+ hitContextLimit = true;
3486
+ logger.warn(`[GoogleVertex] Native Anthropic turn stopped by the context guard: ` +
3487
+ `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
3488
+ `>= threshold ${contextGuard.thresholdTokens} forcing finalization.`);
3489
+ return undefined;
3490
+ },
3491
+ noteObservedPromptTokens: (tokens) => {
3492
+ contextGuard.noteUsage(tokens, 0);
3493
+ },
3494
+ ...(useFinalResultTool
3495
+ ? {
3496
+ finalResultToolName: "final_result",
3497
+ onTerminalResult: (text) => {
3498
+ // The engine ends the turn on a terminal call and hands back
3499
+ // the payload as text; this loop also streams it, so the push
3500
+ // stays here rather than in the adapter.
3501
+ try {
3502
+ structuredOutputRef.value = JSON.parse(text);
3503
+ }
3504
+ catch {
3505
+ /* the caller's coercion layer repairs a partial payload */
3506
+ }
3507
+ channel.push({ content: text });
3508
+ liveTextPushedLength += text.length;
3509
+ logger.debug("[GoogleVertex] Extracted structured output from final_result tool (stream)", { chars: text.length });
3510
+ },
3565
3511
  }
3566
- allToolCalls.push({
3567
- toolName: toolUse.name,
3568
- args: toolUse.input,
3569
- });
3570
- toolsUsedRef.push(toolUse.name);
3571
- stepStorageCalls.push({
3572
- toolCallId: toolUse.id,
3573
- toolName: toolUse.name,
3574
- args: toolUse.input,
3575
- });
3576
- // Consecutive-failure breaker (ports the Gemini loops' failedTools
3577
- // map): a tool that has already failed DEFAULT_TOOL_MAX_RETRIES
3578
- // times this turn is short-circuited instead of re-executed.
3579
- const failedInfo = failedTools.get(toolUse.name);
3580
- if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
3581
- logger.warn(`[GoogleVertex] Tool "${toolUse.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
3582
- const errMsg = `TOOL_PERMANENTLY_FAILED: The tool "${toolUse.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.`;
3583
- const errorPayload = { error: errMsg };
3584
- toolExecutions.push({
3585
- name: toolUse.name,
3586
- input: toolUse.input,
3587
- output: errorPayload,
3588
- });
3589
- toolResults.push({
3590
- type: "tool_result",
3591
- tool_use_id: toolUse.id,
3592
- content: errMsg,
3593
- });
3594
- stepStorageResults.push({
3595
- toolCallId: toolUse.id,
3596
- toolName: toolUse.name,
3597
- output: errorPayload,
3598
- });
3599
- continue;
3512
+ : {}),
3513
+ });
3514
+ // Wrapped rather than configured: both of these fire once PER STEP, and
3515
+ // these are the only hooks that see a single step's request and results.
3516
+ // Reading them off the turn's final result would batch every step into
3517
+ // one late write and lose the per-step generation span entirely.
3518
+ const adapter = {
3519
+ ...baseAdapter,
3520
+ buildStepRequest: (conversation, engineStep) => {
3521
+ step = engineStep + 1;
3522
+ turnClock.noteProgress();
3523
+ return baseAdapter.buildStepRequest(conversation, engineStep);
3524
+ },
3525
+ // The provider's own miss handler, not the adapter's. The adapter
3526
+ // resolves a deferred tool and hands back its RAW executor; this one
3527
+ // also DECLARES the tool so Claude can call it on later steps, and
3528
+ // registers it in the turn's DedupExecuteMap so a repeat with
3529
+ // identical arguments is served from cache. Without the declaration a
3530
+ // hydrated tool works exactly once and is then invisible again.
3531
+ resolveToolOnMiss: (name) => {
3532
+ const hydrated = this.resolveAnthropicToolOnMiss(name, options.tools, tools, executeMap, failedTools);
3533
+ if (!hydrated) {
3534
+ return undefined;
3600
3535
  }
3601
- // One tool observation per execution. ai.toolCall.* names follow the
3602
- // Vercel AI SDK convention so existing tooling keeps working.
3603
- const toolSpan = tracers.mcp.startSpan("ai.toolCall", {
3604
- kind: SpanKind.INTERNAL,
3536
+ return {
3537
+ execute: guardToolExecutor(name, hydrated, {
3538
+ toolTimeoutMs: toolExecTimeoutMs,
3539
+ abortSignal: internalAbort.signal,
3540
+ onProgress: () => turnClock.noteProgress(),
3541
+ }),
3542
+ };
3543
+ },
3544
+ executeStep: async (request, stepChannel, signal) => {
3545
+ // One generation observation per API call: request in, content and
3546
+ // usage out. Started here rather than inside the adapter because the
3547
+ // attributes are this provider's, not the engine's.
3548
+ const generationSpan = tracers.generation.startSpan("anthropic.messages.stream", {
3549
+ kind: SpanKind.CLIENT,
3605
3550
  attributes: {
3606
- [LANGFUSE_ATTR.OBSERVATION_TYPE]: "tool",
3607
- [ATTR.GEN_AI_TOOL_NAME]: toolUse.name,
3608
- "ai.toolCall.name": toolUse.name,
3609
- "ai.toolCall.id": toolUse.id,
3610
- "ai.toolCall.args": spanJsonAttribute(toolUse.input, 20_000),
3611
- [LANGFUSE_ATTR.OBSERVATION_INPUT]: spanJsonAttribute(toolUse.input, 20_000),
3551
+ [LANGFUSE_ATTR.OBSERVATION_TYPE]: "generation",
3552
+ [LANGFUSE_ATTR.OBSERVATION_MODEL_NAME]: modelName,
3553
+ [LANGFUSE_ATTR.OBSERVATION_MODEL_PARAMETERS]: spanJsonAttribute({
3554
+ max_tokens: requestParams.max_tokens,
3555
+ temperature: requestParams.temperature,
3556
+ top_p: requestParams.top_p,
3557
+ }),
3558
+ [LANGFUSE_ATTR.OBSERVATION_INPUT]: spanJsonAttribute({
3559
+ system: systemPromptWithSchema,
3560
+ messages: sanitizeAnthropicMessagesForTrace(currentMessages),
3561
+ }),
3612
3562
  [LANGFUSE_ATTR.OBSERVATION_METADATA]: spanJsonAttribute({
3613
3563
  step,
3564
+ toolsOffered: offeredToolNames.length,
3614
3565
  }),
3566
+ [ATTR.GEN_AI_SYSTEM]: "anthropic",
3567
+ [ATTR.GEN_AI_MODEL]: modelName,
3568
+ [ATTR.GEN_AI_OPERATION]: "chat",
3615
3569
  },
3616
3570
  }, turnContext);
3617
- const endToolSpan = (output, errorMessage) => {
3618
- toolSpan.setAttribute("ai.toolCall.result", spanJsonAttribute(output));
3619
- toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute(output));
3620
- if (errorMessage) {
3621
- toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_LEVEL, "ERROR");
3622
- toolSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_STATUS_MESSAGE, errorMessage);
3623
- toolSpan.setStatus({
3624
- code: SpanStatusCode.ERROR,
3625
- message: errorMessage,
3626
- });
3627
- }
3628
- else {
3629
- toolSpan.setStatus({ code: SpanStatusCode.OK });
3630
- }
3631
- toolSpan.end();
3632
- };
3633
- let execute = executeMap.get(toolUse.name);
3634
- if (!execute) {
3635
- // Snapshot miss: hydrated by search_tools this step batch, or
3636
- // a deferred catalog tool called directly by name.
3637
- execute = this.resolveAnthropicToolOnMiss(toolUse.name, options.tools, tools, executeMap, failedTools);
3571
+ let firstDeltaSeen = false;
3572
+ try {
3573
+ const result = await baseAdapter.executeStep(request, {
3574
+ push: (chunk) => {
3575
+ turnClock.noteProgress();
3576
+ if (chunk.content && !firstDeltaSeen) {
3577
+ firstDeltaSeen = true;
3578
+ // Time-to-first-token for this generation.
3579
+ generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_COMPLETION_START_TIME, new Date().toISOString());
3580
+ }
3581
+ stepChannel.push(chunk);
3582
+ },
3583
+ }, signal);
3584
+ turnCacheUsage.read += result.usage.cacheReadTokens ?? 0;
3585
+ turnCacheUsage.creation += result.usage.cacheWriteTokens ?? 0;
3586
+ turnCacheUsage.creation5m += result.usage.cacheWrite5mTokens ?? 0;
3587
+ turnCacheUsage.creation1h += result.usage.cacheWrite1hTokens ?? 0;
3588
+ generationSpan.setAttribute(LANGFUSE_ATTR.OBSERVATION_OUTPUT, spanJsonAttribute({ text: result.text }));
3589
+ return result;
3638
3590
  }
3639
- if (execute) {
3640
- try {
3641
- const toolOptions = {
3642
- toolCallId: toolUse.id,
3643
- messages: [],
3644
- abortSignal: internalAbort.signal,
3645
- };
3646
- turnClock.noteProgress();
3647
- // Run with toolSpan active so spans inside execute
3648
- // (neurolink.tool.execute) nest under this observation instead
3649
- // of becoming disconnected siblings. Bound the await — a
3650
- // wedged tool costs one step (error tool_result), not the
3651
- // whole turn — and race it against the turn's abort so a
3652
- // deadline/caller abort is observed IMMEDIATELY instead of
3653
- // after the tool settles.
3654
- const result = await withTimeout(raceWithAbort(otelContext.with(otelTrace.setSpan(turnContext, toolSpan), () => Promise.resolve(execute(toolUse.input, toolOptions))), internalAbort.signal), toolExecTimeoutMs, `Tool "${toolUse.name}" execution timed out after ${toolExecTimeoutMs}ms`);
3655
- turnClock.noteProgress();
3656
- // MCP failures are returned, not thrown — surface them on
3657
- // the span so failed calls show as ERROR in Langfuse.
3658
- endToolSpan(result, extractMcpToolErrorMessage(result));
3659
- // Error-shaped success (MCP isError / { error } payloads)
3660
- // counts toward the breaker too — see the generate twin.
3661
- const resultErrorText = extractToolFailureText(result);
3662
- if (resultErrorText) {
3663
- const info = failedTools.get(toolUse.name) || {
3664
- count: 0,
3665
- lastError: "",
3666
- };
3667
- info.count++;
3668
- info.lastError = resultErrorText;
3669
- failedTools.set(toolUse.name, info);
3670
- }
3671
- else {
3672
- // Genuinely consecutive: a success clears the strike count
3673
- // (argument-dependent soft errors — file-not-found on
3674
- // different paths — must not disable a working tool).
3675
- failedTools.delete(toolUse.name);
3676
- }
3677
- toolExecutions.push({
3678
- name: toolUse.name,
3679
- input: toolUse.input,
3680
- output: result,
3681
- });
3682
- // Anthropic requires tool_result.content to be a string.
3683
- // JSON.stringify returns undefined for undefined/function/symbol,
3684
- // so coerce defensively to keep the follow-up turn valid.
3685
- const resultContent = typeof result === "string"
3686
- ? result
3687
- : stringifyContentSafe(result ?? null);
3688
- toolResults.push({
3689
- type: "tool_result",
3690
- tool_use_id: toolUse.id,
3691
- content: resultContent,
3692
- });
3693
- stepStorageResults.push({
3694
- toolCallId: toolUse.id,
3695
- toolName: toolUse.name,
3696
- output: result,
3697
- });
3698
- }
3699
- catch (err) {
3700
- // An aborted tool call is a cancellation, not a tool failure —
3701
- // end the span without recording an error execution/result and
3702
- // break the turn.
3703
- if (internalAbort.signal.aborted || isAbortError(err)) {
3704
- endToolSpan({ aborted: true });
3705
- // Keep persisted tool history paired: the call row was
3706
- // already pushed above, so record a neutral cancellation
3707
- // result (NOT an error) — an unpaired tool_use replayed on
3708
- // the next turn would be rejected by the Anthropic API.
3709
- stepStorageResults.push({
3710
- toolCallId: toolUse.id,
3711
- toolName: toolUse.name,
3712
- output: { aborted: true },
3713
- });
3714
- wasAborted = true;
3715
- break;
3716
- }
3717
- turnClock.noteProgress();
3718
- if (err instanceof TimeoutError) {
3719
- this.emitTurnEvent({
3720
- phase: "tool-timeout",
3721
- step,
3722
- maxSteps,
3723
- toolName: toolUse.name,
3724
- });
3725
- }
3726
- // Count the failure toward the consecutive-failure breaker.
3727
- const thrownErrorText = err instanceof Error ? err.message : String(err);
3728
- const info = failedTools.get(toolUse.name) || {
3729
- count: 0,
3730
- lastError: "",
3731
- };
3732
- info.count++;
3733
- info.lastError = thrownErrorText;
3734
- failedTools.set(toolUse.name, info);
3735
- logger.warn(`[GoogleVertex] Tool "${toolUse.name}" failed (attempt ${info.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${thrownErrorText}`);
3736
- const errMsg = `Error executing tool "${toolUse.name}": ${thrownErrorText}`;
3737
- const errorPayload = { error: errMsg };
3738
- endToolSpan(errorPayload, errMsg);
3739
- toolExecutions.push({
3740
- name: toolUse.name,
3741
- input: toolUse.input,
3742
- output: errorPayload,
3743
- });
3744
- toolResults.push({
3745
- type: "tool_result",
3746
- tool_use_id: toolUse.id,
3747
- content: errMsg,
3748
- });
3749
- stepStorageResults.push({
3750
- toolCallId: toolUse.id,
3751
- toolName: toolUse.name,
3752
- output: errorPayload,
3753
- });
3591
+ catch (error) {
3592
+ generationSpan.setStatus({
3593
+ code: SpanStatusCode.ERROR,
3594
+ message: error instanceof Error ? error.message : String(error),
3595
+ });
3596
+ if (error instanceof Error) {
3597
+ generationSpan.recordException(error);
3754
3598
  }
3599
+ throw error;
3755
3600
  }
3756
- else {
3757
- const errMsg = `TOOL_NOT_FOUND: The tool "${toolUse.name}" does not exist.`;
3758
- const errorPayload = { error: errMsg };
3759
- // A missing tool counts toward the breaker too — a model
3760
- // grinding on a hallucinated/stale tool name must not burn the
3761
- // whole step budget on TOOL_NOT_FOUND round-trips.
3762
- const notFoundInfo = failedTools.get(toolUse.name) || {
3763
- count: 0,
3764
- lastError: "",
3765
- };
3766
- notFoundInfo.count++;
3767
- notFoundInfo.lastError = errMsg;
3768
- failedTools.set(toolUse.name, notFoundInfo);
3769
- endToolSpan(errorPayload, errMsg);
3601
+ finally {
3602
+ generationSpan.end();
3603
+ }
3604
+ },
3605
+ buildToolResultMessages: (conversation, stepResult, toolResults, engineStep) => {
3606
+ for (const result of toolResults) {
3607
+ allToolCalls.push({ toolName: result.name, args: result.args });
3608
+ toolsUsedRef.push(result.name);
3770
3609
  toolExecutions.push({
3771
- name: toolUse.name,
3772
- input: toolUse.input,
3773
- output: errorPayload,
3774
- });
3775
- toolResults.push({
3776
- type: "tool_result",
3777
- tool_use_id: toolUse.id,
3778
- content: errMsg,
3779
- });
3780
- stepStorageResults.push({
3781
- toolCallId: toolUse.id,
3782
- toolName: toolUse.name,
3783
- output: errorPayload,
3610
+ name: result.name,
3611
+ input: result.args,
3612
+ output: result.output,
3784
3613
  });
3785
3614
  }
3786
- }
3787
- // Persist this step's tool calls/results into conversation memory.
3788
- // Without this hook, tool rows never land in Redis and the
3789
- // chat-history UI loses every tool invocation. Runs BEFORE the
3790
- // abort break below so tools that DID complete in an aborted step
3791
- // (real side effects) still reach the chat history.
3792
- if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
3793
- 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) => {
3615
+ metadata.totalToolExecutions += toolResults.length;
3616
+ const next = baseAdapter.buildToolResultMessages(conversation, stepResult, toolResults, engineStep);
3617
+ // Time-budget wrap-up nudge: with the turn deadline approaching,
3618
+ // tell the model to consolidate. Rides as a trailing text block on
3619
+ // the tool_result user turn.
3620
+ if (turnClock.shouldNudgeWrapup()) {
3621
+ const last = next[next.length - 1];
3622
+ if (last && Array.isArray(last.content)) {
3623
+ last.content.push({
3624
+ type: "text",
3625
+ text: buildWrapupNudgeText(useFinalResultTool),
3626
+ });
3627
+ }
3628
+ }
3629
+ // Tool activity reaches conversation memory per step, not batched at
3630
+ // the end: tools that DID complete in a step later aborted are real
3631
+ // side effects and belong in the chat history.
3632
+ withTimeout(this.handleToolExecutionStorage(toolResults.map((result) => ({
3633
+ toolName: result.name,
3634
+ args: result.args,
3635
+ stepIndex: engineStep + 1,
3636
+ })), toolResults.map((result) => ({
3637
+ toolName: result.name,
3638
+ output: result.output,
3639
+ stepIndex: engineStep + 1,
3640
+ })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
3794
3641
  logger.warn("[GoogleVertex] Failed to store native Anthropic stream tool executions", {
3795
3642
  error: error instanceof Error ? error.message : String(error),
3796
3643
  });
3797
3644
  });
3645
+ // Project this step's growth for the context guard: everything just
3646
+ // appended rides the next prompt.
3647
+ try {
3648
+ const appended = next[next.length - 1];
3649
+ contextGuard.noteAppendedChars(JSON.stringify(appended?.content ?? []).length);
3650
+ }
3651
+ catch {
3652
+ /* estimation is best-effort — never break the loop */
3653
+ }
3654
+ return next;
3655
+ },
3656
+ };
3657
+ const activeSpan = otelTrace.getSpan(turnContext);
3658
+ const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentMessages.slice(), {
3659
+ tools: engineTools,
3660
+ abortSignal: internalAbort.signal,
3661
+ ...(activeSpan ? { span: activeSpan } : {}),
3662
+ });
3663
+ const pump = (async () => {
3664
+ for await (const chunk of engineStream) {
3665
+ if (chunk.content) {
3666
+ channel.push({ content: chunk.content });
3667
+ liveTextPushedLength += chunk.content.length;
3668
+ aggregatedTurnText += chunk.content;
3669
+ }
3798
3670
  }
3799
- // An abort inside the tool-exec loop only breaks that inner
3800
- // for-loop. Break the while too so no further model call is issued
3801
- // and control reaches the terminal step-cap handling below.
3802
- if (wasAborted) {
3803
- break;
3804
- }
3805
- // Soft budget nudge: with the step cap approaching, tell the model
3806
- // to wrap up so the reserved forced-finalization call below stays a
3807
- // fallback, not the norm. Rides as a trailing text block on the
3808
- // tool_result user turn (cache-safe: it lives in the growing tail).
3809
- // The time-budget twin fires when the turn deadline is inside the
3810
- // wrap-up lead window instead.
3811
- const stepsRemaining = agenticStepBudget - step;
3812
- if (stepsRemaining > 0 && stepsRemaining <= 3) {
3813
- toolResults.push({
3814
- type: "text",
3815
- text: `NOTE: Only ${stepsRemaining} tool step(s) remain. Consolidate what you have and ` +
3816
- (useFinalResultTool
3817
- ? "call final_result with your best answer."
3818
- : "provide your final answer."),
3819
- });
3671
+ })();
3672
+ let engineResult;
3673
+ let turnFailure;
3674
+ try {
3675
+ engineResult = await resultPromise;
3676
+ }
3677
+ catch (error) {
3678
+ turnFailure = error;
3679
+ }
3680
+ // Drained tolerantly and exactly once: when a turn ends by abort the
3681
+ // channel rejects too, and re-awaiting a settled rejection would rethrow
3682
+ // the error the branch below has already decided to absorb.
3683
+ await pump.catch(() => { });
3684
+ if (turnFailure !== undefined) {
3685
+ if (internalAbort.signal.aborted || isAbortError(turnFailure)) {
3686
+ wasAborted = true;
3820
3687
  }
3821
- else if (turnClock.shouldNudgeWrapup()) {
3822
- toolResults.push({
3823
- type: "text",
3824
- text: buildWrapupNudgeText(useFinalResultTool),
3825
- });
3688
+ else {
3689
+ throw turnFailure;
3826
3690
  }
3827
- // Continue the loop: assistant turn + tool_result user turn.
3828
- // Filter server_tool_use blocks (Anthropic API rejects them in
3829
- // subsequent message turns).
3830
- const assistantContent = response.content.filter((block) => block.type !== "server_tool_use");
3831
- currentMessages.push({
3832
- role: "assistant",
3833
- content: assistantContent,
3834
- });
3835
- currentMessages.push({
3836
- role: "user",
3837
- content: toolResults,
3838
- });
3839
- // Project this step's growth for the context guard: everything
3840
- // just appended (tool results + nudge text) rides the next prompt.
3841
- contextGuard.noteAppendedChars(toolResults.reduce((sum, block) => sum +
3842
- ("content" in block
3843
- ? block.content.length
3844
- : block.text.length), 0));
3691
+ }
3692
+ if (engineResult) {
3693
+ usage.input += engineResult.usage.inputTokens;
3694
+ usage.output += engineResult.usage.outputTokens;
3695
+ finishReasonRef.value =
3696
+ engineResult.rawStopReason ?? finishReasonRef.value;
3697
+ // NOT `toolCalls.length === 0`: that array accumulates across the
3698
+ // WHOLE turn, so a turn that called a tool in step 1 and answered
3699
+ // with text in step 2 would look unfinished and fall into terminal
3700
+ // handling. The finish reason is the per-turn signal — the engine
3701
+ // reports "tool-calls" only when the cap was hit with tools still
3702
+ // pending.
3703
+ modelFinished =
3704
+ engineResult.toolCalls.length === 0 ||
3705
+ engineResult.finishReason !== "tool-calls";
3706
+ // Replace in place: the terminal block and the finalization call both
3707
+ // read `currentMessages`.
3708
+ currentMessages.length = 0;
3709
+ currentMessages.push(...engineResult.conversation);
3710
+ }
3711
+ if (internalAbort.signal.aborted) {
3712
+ wasAborted = true;
3845
3713
  }
3846
3714
  // Terminal handling — the loop exited without a model-initiated
3847
3715
  // finish (step budget exhausted, or the turn was aborted). Never end
@@ -4620,398 +4488,225 @@ export class GoogleVertexProvider extends BaseProvider {
4620
4488
  turnClock.dispose();
4621
4489
  options.abortSignal?.removeEventListener("abort", onCallerAbort);
4622
4490
  };
4623
- while (step < agenticStepBudget) {
4624
- // Honor aborts BETWEEN steps (caller signal OR turn-clock watchdogs
4625
- // all fan into internalAbort): break into terminal handling instead of
4626
- // throwing (a throw routes consumers into abortSignal-less fallback
4627
- // retries observed in production as a 600s abort no-op).
4628
- if (internalAbort.signal.aborted) {
4629
- wasAborted = true;
4630
- break;
4631
- }
4632
- // Context guard: the projected next prompt (last call's REAL usage +
4633
- // this step's appended tool results/output) would cross the window
4634
- // threshold — stop the tool loop and synthesize from what we have.
4635
- if (contextGuard.shouldStop()) {
4636
- const reclaimed = reclaimVertexAnthropicContext(currentMessages, modelName, contextGuard.projectedNextPromptTokens);
4637
- if (reclaimed) {
4638
- contextGuard.resetAfterReclaim();
4639
- }
4640
- else {
4641
- hitContextLimit = true;
4642
- logger.warn(`[GoogleVertex] Anthropic generate turn stopped by the context guard: ` +
4643
- `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
4644
- `>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
4645
- break;
4646
- }
4491
+ // Executors handed to the engine, taken through the turn's
4492
+ // DedupExecuteMap so an identical repeated call is answered from the
4493
+ // per-turn cache rather than run again (BZ-3327), and guarded exactly as
4494
+ // the hand-rolled loop guarded them: a per-tool bound so a wedged tool
4495
+ // costs ONE STEP rather than the whole turn, raced against the turn's
4496
+ // abort, and a stall-clock ping either side.
4497
+ const engineTools = {};
4498
+ for (const toolName of executeMap.keys()) {
4499
+ const wrapped = executeMap.get(toolName);
4500
+ if (!wrapped) {
4501
+ continue;
4647
4502
  }
4648
- step++;
4649
- turnClock.noteProgress();
4650
- // Mid-turn discovery sync — see the stream twin.
4651
- this.refreshAnthropicToolDeclarations(options.tools, tools, executeMap, failedTools);
4652
- try {
4653
- // Bound the SDK wait so a stalled Vertex/Anthropic call can't hang
4654
- // generate forever. options.timeout wins if set, otherwise default
4655
- // to 5 min — generous for tool-heavy turns.
4656
- // Vertex has no automatic prompt caching place explicit cache_control
4657
- // breakpoints (system, tools, rolling history) so the conversation
4658
- // prefix is cached across turns instead of re-billed as fresh input
4659
- // every call. Re-applied per step: the stable prefix stays
4660
- // byte-identical (consistent cache key) while the rolling breakpoint
4661
- // follows the growing tail.
4662
- const cachedGenerate = applyVertexAnthropicCacheBreakpoints({
4503
+ engineTools[toolName] = {
4504
+ execute: guardToolExecutor(toolName, wrapped, {
4505
+ toolTimeoutMs: toolExecTimeoutMs,
4506
+ abortSignal: internalAbort.signal,
4507
+ onProgress: () => turnClock.noteProgress(),
4508
+ }),
4509
+ };
4510
+ }
4511
+ // The turn runs on the shared engine, exactly as the streaming twin does.
4512
+ // maxSteps is agenticStepBudget, not maxSteps: the last slot is reserved
4513
+ // for the forced final_result call in the terminal block below, and the
4514
+ // engine must never spend it.
4515
+ const baseAdapter = createAnthropicLoopAdapter({
4516
+ client,
4517
+ maxSteps: agenticStepBudget,
4518
+ toolsRecord: options.tools ?? {},
4519
+ toolFailureBreaker: {
4520
+ maxRetries: DEFAULT_TOOL_MAX_RETRIES,
4521
+ // MCP failures are RETURNED, not thrown. Counting only throws lets
4522
+ // the model grind on a blocked tool for the whole step budget.
4523
+ classifyResultFailure: (output) => extractToolFailureText(output) ?? undefined,
4524
+ },
4525
+ buildParams: (conversation) => {
4526
+ // Mid-turn discovery sync — see the stream twin.
4527
+ this.refreshAnthropicToolDeclarations(options.tools, tools, executeMap, failedTools);
4528
+ const cached = applyVertexAnthropicCacheBreakpoints({
4663
4529
  system: systemPromptWithSchema,
4664
4530
  tools,
4665
- messages: currentMessages,
4531
+ messages: conversation,
4666
4532
  });
4667
- // The caller's abortSignal rides as an SDK request option so a
4668
- // mid-flight abort cancels the HTTP call itself (the SDK rejects with
4669
- // an abort-shaped error the per-step catch below turns into a break).
4670
- const response = await withTimeout(client.messages.create({
4533
+ return {
4671
4534
  ...requestParams,
4672
- ...(cachedGenerate.system !== undefined && {
4673
- system: cachedGenerate.system,
4535
+ ...(cached.system !== undefined && {
4536
+ system: cached.system,
4674
4537
  }),
4675
- ...(cachedGenerate.tools &&
4676
- cachedGenerate.tools.length > 0 && {
4677
- tools: cachedGenerate.tools,
4538
+ ...(cached.tools &&
4539
+ cached.tools.length > 0 && {
4540
+ tools: cached.tools,
4678
4541
  }),
4679
- messages: cachedGenerate.messages,
4680
- }, { signal: internalAbort.signal }), generateTimeoutMs, "Anthropic generate timed out");
4681
- // Update token counts. input_tokens is the uncached remainder; cache
4682
- // reads/writes are reported separately and accumulated here so the
4683
- // result reflects the full picture.
4684
- totalInputTokens += response.usage?.input_tokens || 0;
4685
- totalOutputTokens += response.usage?.output_tokens || 0;
4686
- totalCacheReadTokens += response.usage?.cache_read_input_tokens || 0;
4687
- totalCacheCreationTokens +=
4688
- response.usage?.cache_creation_input_tokens || 0;
4689
- lastStopReason = response.stop_reason;
4690
- // Feed the context guard the FULL prompt size of this call (uncached
4691
- // input + cache reads/writes) — the API reports it every step.
4692
- contextGuard.noteUsage((response.usage?.input_tokens || 0) +
4693
- (response.usage?.cache_read_input_tokens || 0) +
4694
- (response.usage?.cache_creation_input_tokens || 0), response.usage?.output_tokens || 0);
4695
- // Check if we need to handle tool use
4696
- const toolUseBlocks = response.content.filter((block) => block.type === "tool_use");
4697
- // Check for final_result tool call (for structured output)
4698
- if (useFinalResultTool) {
4699
- const finalResultCall = toolUseBlocks.find((block) => block.name === "final_result");
4700
- if (finalResultCall) {
4701
- // Extract structured output and convert to JSON string for finalText
4702
- structuredOutput = finalResultCall.input;
4703
- finalText = JSON.stringify(structuredOutput);
4704
- modelFinished = true;
4705
- logger.debug("[GoogleVertex] Extracted structured output from final_result tool (generate)", { keys: Object.keys(structuredOutput) });
4706
- break; // We have the structured output, we're done
4707
- }
4542
+ messages: cached.messages,
4543
+ };
4544
+ },
4545
+ planReclaim: (conversation) => {
4546
+ if (!contextGuard.shouldStop()) {
4547
+ return undefined;
4708
4548
  }
4709
- // Extract text from response
4710
- const textBlocks = response.content.filter((block) => block.type === "text");
4711
- const responseText = textBlocks.map((b) => b.text).join("");
4712
- if (toolUseBlocks.length === 0) {
4713
- // No tool calls, we're done
4714
- finalText = responseText || accumulatedStepText;
4715
- modelFinished = true;
4716
- break;
4549
+ const working = [...conversation];
4550
+ if (reclaimVertexAnthropicContext(working, modelName, contextGuard.projectedNextPromptTokens)) {
4551
+ contextGuard.resetAfterReclaim();
4552
+ return { conversation: working };
4717
4553
  }
4718
- // Handle tool calls. The array also carries the trailing soft-budget
4719
- // nudge text block appended below (tool_result blocks stay first, as
4720
- // the Anthropic API requires).
4721
- const toolResults = [];
4722
- // Per-step bookkeeping for conversation-memory storage. Tracks calls
4723
- // and results for ONLY the tools fired in this step so the storage
4724
- // hook can tag them with the current stepIndex.
4725
- const stepStorageCalls = [];
4726
- const stepStorageResults = [];
4727
- // Note: tool:start / tool:end events are emitted by ToolsManager's
4728
- // wrapped `execute` (see ToolsManager.ts:355) — no inline emit needed.
4729
- for (const toolUse of toolUseBlocks) {
4730
- // Honor a deadline/stall/caller abort BETWEEN tool executions —
4731
- // without this check a multi-tool step keeps executing its whole
4732
- // batch (up to N × toolTimeoutMs past the deadline) before the
4733
- // while-top check finally breaks. Skipped tools get neither a call
4734
- // row nor a result row, so persisted history stays paired.
4735
- if (internalAbort.signal.aborted) {
4736
- wasAborted = true;
4737
- break;
4738
- }
4739
- allToolCalls.push({
4740
- toolName: toolUse.name,
4741
- args: toolUse.input,
4742
- });
4743
- stepStorageCalls.push({
4744
- toolCallId: toolUse.id,
4745
- toolName: toolUse.name,
4746
- args: toolUse.input,
4747
- });
4748
- // Consecutive-failure breaker: stop re-executing a tool that has
4749
- // already failed DEFAULT_TOOL_MAX_RETRIES times this turn (ports
4750
- // the Gemini loops' failedTools map — the Anthropic loops let a
4751
- // blocked tool be retried for the entire remaining step budget).
4752
- const failedInfo = failedTools.get(toolUse.name);
4753
- if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
4754
- logger.warn(`[GoogleVertex] Tool "${toolUse.name}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
4755
- const errMsg = `TOOL_PERMANENTLY_FAILED: The tool "${toolUse.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.`;
4756
- const errorPayload = { error: errMsg };
4757
- toolExecutions.push({
4758
- name: toolUse.name,
4759
- input: toolUse.input,
4760
- output: errorPayload,
4761
- });
4762
- toolResults.push({
4763
- type: "tool_result",
4764
- tool_use_id: toolUse.id,
4765
- content: errMsg,
4766
- });
4767
- stepStorageResults.push({
4768
- toolCallId: toolUse.id,
4769
- toolName: toolUse.name,
4770
- output: errorPayload,
4771
- });
4772
- continue;
4773
- }
4774
- let execute = executeMap.get(toolUse.name);
4775
- if (!execute) {
4776
- // Snapshot miss — see the stream twin.
4777
- execute = this.resolveAnthropicToolOnMiss(toolUse.name, options.tools, tools, executeMap, failedTools);
4778
- }
4779
- if (execute) {
4554
+ hitContextLimit = true;
4555
+ logger.warn(`[GoogleVertex] Anthropic generate turn stopped by the context guard: ` +
4556
+ `projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
4557
+ `>= threshold ${contextGuard.thresholdTokens} — forcing finalization.`);
4558
+ // STOP, not undefined: undefined means "nothing to reclaim, carry
4559
+ // on", which is the opposite of what the guard just decided.
4560
+ return { stop: true };
4561
+ },
4562
+ noteObservedPromptTokens: (tokens) => {
4563
+ contextGuard.noteUsage(tokens, 0);
4564
+ },
4565
+ ...(useFinalResultTool
4566
+ ? {
4567
+ finalResultToolName: "final_result",
4568
+ onTerminalResult: (text) => {
4780
4569
  try {
4781
- const toolOptions = {
4782
- toolCallId: toolUse.id,
4783
- messages: [],
4784
- abortSignal: internalAbort.signal,
4785
- };
4786
- turnClock.noteProgress();
4787
- // Bound the execute() await — a wedged tool costs one step
4788
- // (error tool_result), not the whole turn — and race it against
4789
- // the turn's abort so a deadline/caller abort is observed
4790
- // IMMEDIATELY instead of after the tool settles (live-verified:
4791
- // an 8s deadline previously waited out a 60s tool).
4792
- const result = await withTimeout(raceWithAbort(Promise.resolve(execute(toolUse.input, toolOptions)), internalAbort.signal), toolExecTimeoutMs, `Tool "${toolUse.name}" execution timed out after ${toolExecTimeoutMs}ms`);
4793
- turnClock.noteProgress();
4794
- // Error-shaped success (MCP isError / { error } payloads —
4795
- // e.g. proxy-blocked tools) counts toward the breaker too:
4796
- // these fail without throwing, and only counting throws lets
4797
- // the model grind on a blocked tool for the whole budget.
4798
- const resultErrorText = extractToolFailureText(result);
4799
- if (resultErrorText) {
4800
- const info = failedTools.get(toolUse.name) || {
4801
- count: 0,
4802
- lastError: "",
4803
- };
4804
- info.count++;
4805
- info.lastError = resultErrorText;
4806
- failedTools.set(toolUse.name, info);
4807
- }
4808
- else {
4809
- // Genuinely consecutive: a success clears the strike count
4810
- // (argument-dependent soft errors — file-not-found on
4811
- // different paths — must not disable a working tool).
4812
- failedTools.delete(toolUse.name);
4813
- }
4814
- toolExecutions.push({
4815
- name: toolUse.name,
4816
- input: toolUse.input,
4817
- output: result,
4818
- });
4819
- // Anthropic requires tool_result.content to be a string.
4820
- // JSON.stringify returns undefined for undefined/function/symbol,
4821
- // so coerce defensively to keep the follow-up turn valid.
4822
- const resultContent = typeof result === "string"
4823
- ? result
4824
- : stringifyContentSafe(result ?? null);
4825
- toolResults.push({
4826
- type: "tool_result",
4827
- tool_use_id: toolUse.id,
4828
- content: resultContent,
4829
- });
4830
- stepStorageResults.push({
4831
- toolCallId: toolUse.id,
4832
- toolName: toolUse.name,
4833
- output: result,
4834
- });
4570
+ structuredOutput = JSON.parse(text);
4835
4571
  }
4836
- catch (err) {
4837
- // An aborted tool call is a cancellation, not a tool failure —
4838
- // break the turn without recording an error execution/result.
4839
- if (internalAbort.signal.aborted || isAbortError(err)) {
4840
- // Keep persisted tool history paired: the call row was
4841
- // already pushed above, so record a neutral cancellation
4842
- // result (NOT an error) — an unpaired tool_use replayed on
4843
- // the next turn would be rejected by the Anthropic API.
4844
- stepStorageResults.push({
4845
- toolCallId: toolUse.id,
4846
- toolName: toolUse.name,
4847
- output: { aborted: true },
4848
- });
4849
- wasAborted = true;
4850
- break;
4851
- }
4852
- turnClock.noteProgress();
4853
- if (err instanceof TimeoutError) {
4854
- this.emitTurnEvent({
4855
- phase: "tool-timeout",
4856
- step,
4857
- maxSteps,
4858
- toolName: toolUse.name,
4859
- });
4860
- }
4861
- // Count the failure toward the consecutive-failure breaker.
4862
- const thrownErrorText = err instanceof Error ? err.message : String(err);
4863
- const info = failedTools.get(toolUse.name) || {
4864
- count: 0,
4865
- lastError: "",
4866
- };
4867
- info.count++;
4868
- info.lastError = thrownErrorText;
4869
- failedTools.set(toolUse.name, info);
4870
- logger.warn(`[GoogleVertex] Tool "${toolUse.name}" failed (attempt ${info.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${thrownErrorText}`);
4871
- const errMsg = `Error executing tool "${toolUse.name}": ${thrownErrorText}`;
4872
- const errorPayload = { error: errMsg };
4873
- toolExecutions.push({
4874
- name: toolUse.name,
4875
- input: toolUse.input,
4876
- output: errorPayload,
4877
- });
4878
- toolResults.push({
4879
- type: "tool_result",
4880
- tool_use_id: toolUse.id,
4881
- content: errMsg,
4882
- });
4883
- stepStorageResults.push({
4884
- toolCallId: toolUse.id,
4885
- toolName: toolUse.name,
4886
- output: errorPayload,
4887
- });
4572
+ catch {
4573
+ /* the caller's coercion layer repairs a partial payload */
4888
4574
  }
4889
- }
4890
- else {
4891
- const errMsg = `TOOL_NOT_FOUND: The tool "${toolUse.name}" does not exist.`;
4892
- const errorPayload = { error: errMsg };
4893
- // A missing tool counts toward the breaker too — a model
4894
- // grinding on a hallucinated/stale tool name must not burn the
4895
- // whole step budget on TOOL_NOT_FOUND round-trips.
4896
- const notFoundInfo = failedTools.get(toolUse.name) || {
4897
- count: 0,
4898
- lastError: "",
4899
- };
4900
- notFoundInfo.count++;
4901
- notFoundInfo.lastError = errMsg;
4902
- failedTools.set(toolUse.name, notFoundInfo);
4903
- toolExecutions.push({
4904
- name: toolUse.name,
4905
- input: toolUse.input,
4906
- output: errorPayload,
4907
- });
4908
- toolResults.push({
4909
- type: "tool_result",
4910
- tool_use_id: toolUse.id,
4911
- content: errMsg,
4912
- });
4913
- stepStorageResults.push({
4914
- toolCallId: toolUse.id,
4915
- toolName: toolUse.name,
4916
- output: errorPayload,
4917
- });
4918
- }
4575
+ logger.debug("[GoogleVertex] Extracted structured output from final_result tool (generate)", { chars: text.length });
4576
+ },
4919
4577
  }
4920
- // Persist this step's tool calls/results into conversation memory.
4921
- // Without this, tool_call / tool_result rows never reach Redis and
4922
- // the chat-history UI loses every tool invocation.
4923
- // Fire-and-forget — storage failures must not break generation.
4924
- // Runs BEFORE the abort break below so tools that DID complete in an
4925
- // aborted step (real side effects) still reach the chat history.
4926
- if (stepStorageCalls.length > 0 || stepStorageResults.length > 0) {
4927
- 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) => {
4928
- logger.warn("[GoogleVertex] Failed to store native Anthropic generate tool executions", {
4929
- error: error instanceof Error ? error.message : String(error),
4930
- });
4578
+ : {}),
4579
+ });
4580
+ const adapter = {
4581
+ ...baseAdapter,
4582
+ buildStepRequest: (conversation, engineStep) => {
4583
+ step = engineStep + 1;
4584
+ turnClock.noteProgress();
4585
+ return baseAdapter.buildStepRequest(conversation, engineStep);
4586
+ },
4587
+ // The provider's own miss handler, not the adapter's. The adapter
4588
+ // resolves a deferred tool and hands back its RAW executor; this one
4589
+ // also DECLARES the tool so Claude can call it on later steps, and
4590
+ // registers it in the turn's DedupExecuteMap so a repeat with
4591
+ // identical arguments is served from cache. Without the declaration a
4592
+ // hydrated tool works exactly once and is then invisible again.
4593
+ resolveToolOnMiss: (name) => {
4594
+ const hydrated = this.resolveAnthropicToolOnMiss(name, options.tools, tools, executeMap, failedTools);
4595
+ if (!hydrated) {
4596
+ return undefined;
4597
+ }
4598
+ return {
4599
+ execute: guardToolExecutor(name, hydrated, {
4600
+ toolTimeoutMs: toolExecTimeoutMs,
4601
+ abortSignal: internalAbort.signal,
4602
+ onProgress: () => turnClock.noteProgress(),
4603
+ }),
4604
+ };
4605
+ },
4606
+ buildToolResultMessages: (conversation, stepResult, toolResults, engineStep) => {
4607
+ for (const result of toolResults) {
4608
+ allToolCalls.push({ toolName: result.name, args: result.args });
4609
+ // Recorded here as well: `toolExecutions` feeds
4610
+ // resolveToolExecutionRecords and the result's own
4611
+ // toolExecutions, and pushing only to allToolCalls left it empty
4612
+ // for every generate turn.
4613
+ toolExecutions.push({
4614
+ name: result.name,
4615
+ input: result.args,
4616
+ output: result.output,
4931
4617
  });
4932
4618
  }
4933
- // An abort inside the tool-exec loop only breaks that inner for-loop.
4934
- // Break the while too so no further model call is issued and control
4935
- // reaches the terminal step-cap handling below.
4936
- if (wasAborted) {
4937
- break;
4619
+ // Per STEP, with appendStepText's newline join this hook runs once
4620
+ // per step and `stepResult.text` is that step's whole text.
4621
+ accumulatedStepText = appendStepText(accumulatedStepText, stepResult.text);
4622
+ const next = baseAdapter.buildToolResultMessages(conversation, stepResult, toolResults, engineStep);
4623
+ // Time-budget wrap-up nudge, as a trailing text block on the
4624
+ // tool_result user turn.
4625
+ if (turnClock.shouldNudgeWrapup()) {
4626
+ const last = next[next.length - 1];
4627
+ if (last && Array.isArray(last.content)) {
4628
+ last.content.push({
4629
+ type: "text",
4630
+ text: buildWrapupNudgeText(useFinalResultTool),
4631
+ });
4632
+ }
4938
4633
  }
4939
- // Soft budget nudge: with the step cap approaching, tell the model to
4940
- // wrap up so the reserved forced-finalization call below stays a
4941
- // fallback, not the norm. Rides as a trailing text block on the
4942
- // tool_result user turn (cache-safe: it lives in the growing tail).
4943
- // The time-budget twin fires when the turn deadline is inside the
4944
- // wrap-up lead window instead.
4945
- const stepsRemaining = agenticStepBudget - step;
4946
- if (stepsRemaining > 0 && stepsRemaining <= 3) {
4947
- toolResults.push({
4948
- type: "text",
4949
- text: `NOTE: Only ${stepsRemaining} tool step(s) remain. Consolidate what you have and ` +
4950
- (useFinalResultTool
4951
- ? "call final_result with your best answer."
4952
- : "provide your final answer."),
4634
+ // Per step, not batched at the end: tools that completed in a step
4635
+ // later aborted are real side effects and belong in the history.
4636
+ withTimeout(this.handleToolExecutionStorage(toolResults.map((result) => ({
4637
+ toolName: result.name,
4638
+ args: result.args,
4639
+ stepIndex: engineStep + 1,
4640
+ })), toolResults.map((result) => ({
4641
+ toolName: result.name,
4642
+ output: result.output,
4643
+ stepIndex: engineStep + 1,
4644
+ })), options, new Date()), TOOL_STORAGE_TIMEOUT_MS, "tool storage write timed out").catch((error) => {
4645
+ logger.warn("[GoogleVertex] Failed to store native Anthropic generate tool executions", {
4646
+ error: error instanceof Error ? error.message : String(error),
4953
4647
  });
4648
+ });
4649
+ try {
4650
+ const appended = next[next.length - 1];
4651
+ contextGuard.noteAppendedChars(JSON.stringify(appended?.content ?? []).length);
4954
4652
  }
4955
- else if (turnClock.shouldNudgeWrapup()) {
4956
- toolResults.push({
4957
- type: "text",
4958
- text: buildWrapupNudgeText(useFinalResultTool),
4959
- });
4653
+ catch {
4654
+ /* estimation is best-effort — never break the loop */
4960
4655
  }
4961
- // Add assistant message and tool results to continue the loop
4962
- // Filter out server_tool_use blocks that the Anthropic API doesn't accept in messages
4963
- const assistantContent = response.content.filter((block) => block.type !== "server_tool_use");
4964
- currentMessages.push({
4965
- role: "assistant",
4966
- content: assistantContent,
4967
- });
4968
- currentMessages.push({
4969
- role: "user",
4970
- content: toolResults,
4971
- });
4972
- // Project this step's growth for the context guard: everything just
4973
- // appended (tool results + nudge text) rides the next prompt. The
4974
- // assistant output was already counted via noteUsage.
4975
- contextGuard.noteAppendedChars(toolResults.reduce((sum, block) => sum +
4976
- ("content" in block
4977
- ? block.content.length
4978
- : block.text.length), 0));
4979
- // Accumulate the step's prose so a capped turn can still surface it —
4980
- // finalText is reserved for model-initiated finishes.
4981
- accumulatedStepText = appendStepText(accumulatedStepText, responseText);
4656
+ return next;
4657
+ },
4658
+ };
4659
+ const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentMessages.slice(), {
4660
+ tools: engineTools,
4661
+ abortSignal: internalAbort.signal,
4662
+ });
4663
+ // Drained and discarded: generate() returns one result rather than
4664
+ // streaming, and the per-step text is accumulated in
4665
+ // buildToolResultMessages instead — appendStepText joins steps with a
4666
+ // NEWLINE, which a chunk-by-chunk `+=` here would silently drop, running
4667
+ // consecutive steps' text together. The drain still has to happen:
4668
+ // leaving the channel unread stalls the engine once its buffer fills.
4669
+ const pump = (async () => {
4670
+ for await (const chunk of engineStream) {
4671
+ void chunk;
4982
4672
  }
4983
- catch (error) {
4984
- // A mid-request abort surfaces as an abort-shaped SDK rejection (we
4985
- // pass internalAbort.signal as the request signal above, and the
4986
- // caller's signal + turn-clock watchdogs all fan into it). Break
4987
- // gracefully into the terminal handling instead of re-throwing — a
4988
- // re-throw routes the caller's abort into abortSignal-less fallback
4989
- // retries. Dual check as in the Gemini loops: the internal signal OR
4990
- // an abort-shaped error either way means "stop", not a real failure.
4991
- if (internalAbort.signal.aborted || isAbortError(error)) {
4992
- wasAborted = true;
4993
- break;
4994
- }
4673
+ })();
4674
+ let engineResult;
4675
+ let turnFailure;
4676
+ try {
4677
+ engineResult = await resultPromise;
4678
+ }
4679
+ catch (error) {
4680
+ turnFailure = error;
4681
+ }
4682
+ await pump.catch(() => { });
4683
+ if (turnFailure !== undefined) {
4684
+ if (internalAbort.signal.aborted || isAbortError(turnFailure)) {
4685
+ wasAborted = true;
4686
+ }
4687
+ else {
4995
4688
  logger.error("[GoogleVertex] Native Anthropic SDK generate error", {
4996
- error,
4689
+ error: turnFailure,
4997
4690
  model: modelName,
4998
- step,
4999
- status: error?.status,
5000
4691
  });
5001
- // Best-effort request context for formatProviderError — see the
5002
- // native Gemini catch for rationale.
5003
- try {
5004
- if (error && typeof error === "object") {
5005
- error.requestModel = modelName;
5006
- }
5007
- }
5008
- catch {
5009
- /* frozen/sealed error — context stays best-effort */
5010
- }
5011
- releaseTurnResources();
5012
- throw this.handleProviderError(error);
4692
+ throw this.handleProviderError(turnFailure);
5013
4693
  }
5014
4694
  }
4695
+ if (engineResult) {
4696
+ totalInputTokens += engineResult.usage.inputTokens;
4697
+ totalOutputTokens += engineResult.usage.outputTokens;
4698
+ totalCacheReadTokens += engineResult.usage.cacheReadTokens ?? 0;
4699
+ totalCacheCreationTokens += engineResult.usage.cacheWriteTokens ?? 0;
4700
+ lastStopReason = engineResult.rawStopReason ?? lastStopReason;
4701
+ finalText = engineResult.text || finalText;
4702
+ // Replace in place: the terminal block and the finalization call both
4703
+ // read `currentMessages`.
4704
+ currentMessages.length = 0;
4705
+ currentMessages.push(...engineResult.conversation);
4706
+ }
4707
+ if (internalAbort.signal.aborted) {
4708
+ wasAborted = true;
4709
+ }
5015
4710
  // Terminal handling — the loop exited without a model-initiated finish
5016
4711
  // (step budget exhausted, or the turn was aborted). Never return "":
5017
4712
  // force the reserved final_result step on structured-output turns,