@juspay/neurolink 12.12.15 → 12.13.0

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.
@@ -8,7 +8,7 @@ import { BaseProvider } from "../../core/baseProvider.js";
8
8
  import { unwrapImagePayload } from "../../adapters/imageFormatSupport.js";
9
9
  import { appendNativeAudioParts } from "../googleNativeGemini3/utils.js";
10
10
  import { getMimeTypeForExtension } from "../../processors/config/mimeConstants.js";
11
- import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_EXECUTION_TIMEOUT_MS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, TOOL_STORAGE_TIMEOUT_MS, } from "../../core/constants.js";
11
+ import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, resolveToolTimeoutMs, TOOL_STORAGE_TIMEOUT_MS, } from "../../core/constants.js";
12
12
  import { resolveRequestKind } from "../../core/resolveRequestKind.js";
13
13
  import { ModelConfigurationManager } from "../../core/modelConfiguration.js";
14
14
  import { isSchemaComplexityError } from "../../core/modules/structuredOutputPolicy.js";
@@ -1560,7 +1560,7 @@ export class GoogleVertexProvider extends BaseProvider {
1560
1560
  // (whole-turn deadline + optional stall detector) all trip it, and every
1561
1561
  // request/tool-exec receives effectiveSignal.
1562
1562
  const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
1563
- const toolExecTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS;
1563
+ const toolExecTimeoutMs = resolveToolTimeoutMs(options.toolTimeoutMs);
1564
1564
  const effectiveTurnDeadlineMs = options.turnTimeoutMs ?? streamTimeoutMs;
1565
1565
  const internalAbort = new AbortController();
1566
1566
  const onCallerAbort = () => internalAbort.abort();
@@ -1790,6 +1790,10 @@ export class GoogleVertexProvider extends BaseProvider {
1790
1790
  onProgress: () => turnClock.noteProgress(),
1791
1791
  }),
1792
1792
  abortSignal: effectiveSignal,
1793
+ // The engine bounds every tool call itself. Passing the value this
1794
+ // loop already gave `buildDedupedEngineTools` keeps the engine's
1795
+ // backstop from being tighter than what the caller asked for.
1796
+ toolTimeoutMs: toolExecTimeoutMs,
1793
1797
  });
1794
1798
  // Collected, NOT forwarded to the consumer here. This loop replays the
1795
1799
  // gathered text after the turn rather than streaming it live, and a
@@ -2356,7 +2360,7 @@ export class GoogleVertexProvider extends BaseProvider {
2356
2360
  // (whole-turn deadline + optional stall detector) all trip it, and every
2357
2361
  // request/tool-exec receives effectiveSignal.
2358
2362
  const streamTimeoutMs = parseTimeout(options.timeout) ?? DEFAULT_GEMINI_STREAM_TIMEOUT_MS;
2359
- const toolExecTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS;
2363
+ const toolExecTimeoutMs = resolveToolTimeoutMs(options.toolTimeoutMs);
2360
2364
  const effectiveTurnDeadlineMs = options.turnTimeoutMs ?? streamTimeoutMs;
2361
2365
  const internalAbort = new AbortController();
2362
2366
  const onCallerAbort = () => internalAbort.abort();
@@ -2585,6 +2589,10 @@ export class GoogleVertexProvider extends BaseProvider {
2585
2589
  onProgress: () => turnClock.noteProgress(),
2586
2590
  }),
2587
2591
  abortSignal: effectiveSignal,
2592
+ // The engine bounds every tool call itself. Passing the value this
2593
+ // loop already gave `buildDedupedEngineTools` keeps the engine's
2594
+ // backstop from being tighter than what the caller asked for.
2595
+ toolTimeoutMs: toolExecTimeoutMs,
2588
2596
  });
2589
2597
  // generate() returns one result rather than streaming, so the engine's
2590
2598
  // chunks are drained and discarded — the answer comes off the turn's
@@ -3304,7 +3312,7 @@ export class GoogleVertexProvider extends BaseProvider {
3304
3312
  if (options.abortSignal?.aborted) {
3305
3313
  internalAbort.abort();
3306
3314
  }
3307
- const toolExecTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS;
3315
+ const toolExecTimeoutMs = resolveToolTimeoutMs(options.toolTimeoutMs);
3308
3316
  const effectiveTurnDeadlineMs = options.turnTimeoutMs ?? streamTimeoutMs;
3309
3317
  // Whole-turn deadline + optional stall watchdog. When the caller sets no
3310
3318
  // explicit turn budget, keep the pre-existing defensive bound
@@ -3668,6 +3676,9 @@ export class GoogleVertexProvider extends BaseProvider {
3668
3676
  tools: engineTools,
3669
3677
  abortSignal: internalAbort.signal,
3670
3678
  ...(activeSpan ? { span: activeSpan } : {}),
3679
+ // Same value `guardToolExecutor` already received above, so the
3680
+ // engine's own per-tool bound cannot undercut it.
3681
+ toolTimeoutMs: toolExecTimeoutMs,
3671
3682
  });
3672
3683
  const pump = (async () => {
3673
3684
  for await (const chunk of engineStream) {
@@ -4474,7 +4485,7 @@ export class GoogleVertexProvider extends BaseProvider {
4474
4485
  if (options.abortSignal?.aborted) {
4475
4486
  internalAbort.abort();
4476
4487
  }
4477
- const toolExecTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS;
4488
+ const toolExecTimeoutMs = resolveToolTimeoutMs(options.toolTimeoutMs);
4478
4489
  // Whole-turn deadline + optional stall watchdog. NOTE: unlike the stream
4479
4490
  // twin, this path historically had NO whole-turn bound (only the per-call
4480
4491
  // withTimeout) — so no defensive default is introduced here: without an
@@ -4668,6 +4679,9 @@ export class GoogleVertexProvider extends BaseProvider {
4668
4679
  const { stream: engineStream, resultPromise } = runAgenticLoop(adapter, currentMessages.slice(), {
4669
4680
  tools: engineTools,
4670
4681
  abortSignal: internalAbort.signal,
4682
+ // Same value `guardToolExecutor` already received above, so the
4683
+ // engine's own per-tool bound cannot undercut it.
4684
+ toolTimeoutMs: toolExecTimeoutMs,
4671
4685
  });
4672
4686
  // Drained and discarded: generate() returns one result rather than
4673
4687
  // streaming, and the per-step text is accumulated in
@@ -410,11 +410,24 @@ export type GenerateOptions = {
410
410
  */
411
411
  wrapupTimeLeadMs?: number;
412
412
  /**
413
- * Per-tool-execution timeout in milliseconds (default 300_000). A tool
414
- * that exceeds it fails with an error tool_result and costs one step —
415
- * the turn continues instead of hanging on a wedged tool.
416
- */
417
- toolTimeoutMs?: number;
413
+ * Per-tool-execution timeout in milliseconds (default 300_000), or `null`
414
+ * for no bound at all.
415
+ *
416
+ * A tool that exceeds it is told to stop — the AbortSignal it was handed is
417
+ * aborted — and then fails with an error tool_result costing one step, so
418
+ * the turn continues instead of hanging on a wedged tool. A tool that
419
+ * ignores its signal keeps running to completion in the background; nothing
420
+ * here can stop it, and its eventual result is discarded.
421
+ *
422
+ * `null` removes the bound and awaits `execute` unguarded, which is what the
423
+ * native loops did before they had a per-tool timer. It is the way to keep a
424
+ * legitimately long-running tool, since a finite number is always a ceiling
425
+ * and `Infinity` silently becomes `setTimeout`'s ~24.9-day cap. The one
426
+ * combination refused is `null` together with
427
+ * `executionControl.lifetimeTimeoutMs: null`, which would leave the turn
428
+ * with no bound anywhere.
429
+ */
430
+ toolTimeoutMs?: number | null;
418
431
  /** AbortSignal for external cancellation of the AI call */
419
432
  abortSignal?: AbortSignal;
420
433
  /**
@@ -1201,8 +1214,8 @@ export type TextGenerationOptions = {
1201
1214
  stallTimeoutMs?: number;
1202
1215
  /** Remaining-time threshold that triggers the wrap-up nudge (ms). See GenerateOptions.wrapupTimeLeadMs. */
1203
1216
  wrapupTimeLeadMs?: number;
1204
- /** Per-tool-execution timeout (ms, default 300_000). See GenerateOptions.toolTimeoutMs. */
1205
- toolTimeoutMs?: number;
1217
+ /** Per-tool-execution timeout (ms, default 300_000; `null` for no bound). See GenerateOptions.toolTimeoutMs. */
1218
+ toolTimeoutMs?: number | null;
1206
1219
  /** AbortSignal for external cancellation of the AI call */
1207
1220
  abortSignal?: AbortSignal;
1208
1221
  /** Bounds for tool execution capture. See GenerateOptions.toolExecutionCapture. */
@@ -1587,8 +1600,8 @@ export type NativeGenerateLoopArgs = {
1587
1600
  maxOutputTokens?: number;
1588
1601
  temperature?: number;
1589
1602
  abortSignal?: AbortSignal;
1590
- /** Per-tool-execution cap, forwarded into `guardToolExecutor`. */
1591
- toolTimeoutMs?: number;
1603
+ /** Per-tool-execution cap, forwarded into `guardToolExecutor`. `null` for no bound. */
1604
+ toolTimeoutMs?: number | null;
1592
1605
  /** Wraps one step: retry ladder plus provider error classification. */
1593
1606
  runStep: (call: () => Promise<Record<string, unknown>>) => Promise<Record<string, unknown>>;
1594
1607
  };
@@ -2,6 +2,7 @@ import type Anthropic from "@anthropic-ai/sdk";
2
2
  import type { Span } from "@opentelemetry/api";
3
3
  import type { Tool } from "./tools.js";
4
4
  import type { CollectedChunkResult, NativeFunctionCall, NativeToolDeclarationsResult } from "./providers.js";
5
+ import type { ExecutionControlDecision, ExecutionControlStepContext } from "./stream.js";
5
6
  /**
6
7
  * One chunk on the engine's stream.
7
8
  *
@@ -163,6 +164,17 @@ export type AgenticLoopAdapter<TConversation = unknown, TRaw = unknown> = {
163
164
  readonly maxSteps: number;
164
165
  /** Set only for adapter instances whose client has the TOOL_NOT_FOUND strike breaker today: both Gemini adapters (AI Studio, Vertex+Gemini) AND the Vertex+Claude call to createAnthropicLoopAdapter — NOT the native-Anthropic call to that same factory, and not Bedrock. See Verified Fact 4. */
165
166
  readonly toolFailureBreaker?: AgenticLoopToolFailureBreaker;
167
+ /**
168
+ * Append a planning nudge to the conversation, in the provider's own message
169
+ * type. Supplied only by adapters whose caller can pass a step-boundary
170
+ * callback; the engine skips a nudge it has no way to write.
171
+ *
172
+ * Provider-supplied for the same reason `buildToolResultMessages` is: the
173
+ * engine does not know what a valid user turn looks like on this wire, and
174
+ * on Anthropic a nudge appended after a tool-result turn has to merge into
175
+ * that turn rather than open a second consecutive user message.
176
+ */
177
+ readonly appendPlanningNudge?: (conversation: TConversation, text: string) => TConversation;
166
178
  /**
167
179
  * Second lookup path, consulted when a tool call names nothing executable
168
180
  * in the caller's `options.tools` — used by adapters supporting mid-turn
@@ -265,6 +277,30 @@ export type AnthropicLoopAdapterConfig<TMessage = Anthropic.Messages.MessagePara
265
277
  * tell them apart would be guesswork, so the adapter says which happened.
266
278
  */
267
279
  onTerminalResult?: (text: string) => void;
280
+ /**
281
+ * Hard deadline for ONE `messages.create` request (ms). Composed with the
282
+ * engine's signal for the duration of that step only, so it bounds a stalled
283
+ * upstream even when the turn itself has no lifetime ceiling — and is armed
284
+ * fresh per step, which is why a step boundary can never extend a deadline
285
+ * already running.
286
+ *
287
+ * When the deadline fires, the step throws the timer's own TimeoutError
288
+ * rather than returning what it had: the Anthropic SDK's stream iterator
289
+ * exits WITHOUT throwing on an aborted read, so a truncated step would
290
+ * otherwise be reported as a model turn that simply said less.
291
+ */
292
+ requestTimeoutMs?: number;
293
+ /**
294
+ * Require the upstream terminal event (`message_stop`) before a step counts
295
+ * as complete.
296
+ *
297
+ * A response that ends early carries syntactically complete content blocks —
298
+ * including tool_use blocks — so without this the loop dispatches tools the
299
+ * model never finished asking for and reports the turn as a normal stop.
300
+ * Off by default: turning it on unconditionally would change what every
301
+ * existing caller sees from a flaky connection.
302
+ */
303
+ requireTerminalEvent?: boolean;
268
304
  toolFailureBreaker?: AgenticLoopToolFailureBreaker;
269
305
  /**
270
306
  * In-turn context reclaim, run once per step before the request is built.
@@ -305,8 +341,15 @@ export type AnthropicLoopAdapterConfig<TMessage = Anthropic.Messages.MessagePara
305
341
  * never had.
306
342
  */
307
343
  export type ToolExecutionGuards = {
308
- /** Upper bound on a single execute(); omit for no bound. */
309
- toolTimeoutMs?: number;
344
+ /**
345
+ * Upper bound on a single execute(). Omit, or pass `null`, for no bound.
346
+ *
347
+ * `null` is accepted as well as `undefined` because it is what a caller's
348
+ * own `toolTimeoutMs: null` resolves to, and the two must not diverge: a
349
+ * value that means "unbounded" at the public surface cannot arrive here
350
+ * meaning "0ms".
351
+ */
352
+ toolTimeoutMs?: number | null;
310
353
  /**
311
354
  * Turn-level abort, raced against the call so a deadline or caller cancel is
312
355
  * observed immediately instead of after the tool settles.
@@ -491,6 +534,44 @@ export type AgenticLoopOptions = {
491
534
  * because only they threaded a span before moving onto the engine.
492
535
  */
493
536
  span?: Span;
537
+ /**
538
+ * Upper bound on a single `tool.execute()` (ms), or `null` for no bound.
539
+ * Defaults to `DEFAULT_TOOL_EXECUTION_TIMEOUT_MS` (300_000) when omitted.
540
+ *
541
+ * The turn-level timers do not cover this: a per-request deadline bounds one
542
+ * model call and is disposed when the step settles, and the step cap only
543
+ * advances when a step completes. Between two steps, a tool that never
544
+ * returns has nothing watching it, and a turn that opted out of a lifetime
545
+ * ceiling then hangs forever. A tool that exceeds the bound is told to stop
546
+ * — the signal it was handed is aborted — and fails with an error tool
547
+ * result costing one step, exactly as it does on the native generate path.
548
+ *
549
+ * `null` restores the behaviour of a loop with no per-tool timer at all:
550
+ * `execute` is awaited unguarded, with the turn's own signal. Callers that
551
+ * relied on unbounded tool execution say so with it. The one combination
552
+ * refused is `null` together with `executionControl.lifetimeTimeoutMs: null`
553
+ * — that would leave the turn with no bound anywhere.
554
+ *
555
+ * Callers that already guard their executors (Vertex and the Gemini
556
+ * adapters, via `guardToolExecutor`) should pass the SAME value they gave
557
+ * those guards, so this backstop can never be tighter than what the caller
558
+ * asked for.
559
+ */
560
+ toolTimeoutMs?: number | null;
561
+ /**
562
+ * Step-boundary callback. Runs after a step's tool results have settled and
563
+ * been written into the conversation, and BEFORE the loop re-checks the step
564
+ * cap — the one point in a turn where raising the cap changes what happens
565
+ * next without replaying anything that already happened.
566
+ *
567
+ * Already bounded and cancellable by the time it reaches the engine: the
568
+ * caller owns the callback's own budget, because the caller is what the
569
+ * public option was validated against. The engine only calls it, applies a
570
+ * strictly-larger finite cap if one comes back, and asks the adapter to
571
+ * write the nudge. It never restarts a step, retries a request, or replays a
572
+ * tool.
573
+ */
574
+ beforeStep?: (context: ExecutionControlStepContext) => Promise<ExecutionControlDecision | undefined>;
494
575
  };
495
576
  export type AgenticLoopResult<TConversation> = {
496
577
  text: string;
@@ -518,4 +599,18 @@ export type AgenticLoopResult<TConversation> = {
518
599
  finishReason: string;
519
600
  rawStopReason: string | undefined;
520
601
  conversation: TConversation;
602
+ /**
603
+ * True when the turn ended because its abort signal fired rather than
604
+ * because the model finished.
605
+ *
606
+ * Not derivable from anything else on this result, which is why it is here.
607
+ * A turn cut short mid-stream never receives a terminal event, so
608
+ * `rawStopReason` is undefined and `mapFinishReason` lands on exactly the
609
+ * value a model that answered and stopped produces. Without this flag a
610
+ * caller reading the result cannot tell "the model finished" from "we
611
+ * stopped listening", and every consumer that branches on the outcome —
612
+ * fallback gates, retry policy, a UI that says why a turn ended — reads the
613
+ * interrupted turn as a success.
614
+ */
615
+ aborted: boolean;
521
616
  };
@@ -184,6 +184,114 @@ export type ProviderStreamChunk = {
184
184
  base64: string;
185
185
  };
186
186
  };
187
+ /**
188
+ * What a `beforeStep` callback is told at a step boundary.
189
+ *
190
+ * The boundary is reached only after that step's tool results have settled and
191
+ * been written into the conversation, and before the step cap is re-checked —
192
+ * so a decision taken here applies to the NEXT step of the SAME turn, never to
193
+ * a step already in flight.
194
+ */
195
+ export type ExecutionControlStepContext = {
196
+ /** Zero-based index of the step that just settled. */
197
+ stepIndex: number;
198
+ /** Steps that have completed in this turn, including the one that just settled. */
199
+ stepsCompleted: number;
200
+ /** The step cap currently in force — the number a renewal must exceed. */
201
+ maxSteps: number;
202
+ /** Milliseconds since the turn's first request was built. */
203
+ elapsedMs: number;
204
+ /** Names of the tools dispatched on the step that just settled, in order. */
205
+ toolNames: string[];
206
+ /**
207
+ * Fires when the turn is cancelled or the callback outlives its own budget.
208
+ * A callback that does real work (reading a live budget, asking a service)
209
+ * must honour it — the turn does not wait for a callback that ignores it.
210
+ */
211
+ signal: AbortSignal;
212
+ };
213
+ /**
214
+ * What a `beforeStep` callback may change about the rest of the turn.
215
+ *
216
+ * Returning nothing is a decision too: it declines the renewal, so the
217
+ * existing cap stands and a turn that has reached it ends as a step-limit
218
+ * outcome exactly as it would with no callback at all.
219
+ */
220
+ export type ExecutionControlDecision = {
221
+ /**
222
+ * A new absolute step cap. Must be finite and greater than the cap in force;
223
+ * anything else is ignored, so a callback cannot shorten a turn by returning
224
+ * a smaller number or unbound one by returning Infinity.
225
+ */
226
+ maxSteps?: number;
227
+ /**
228
+ * A planning nudge appended to the conversation before the next step, in the
229
+ * same loop and the same history — this is how a caller tells the model that
230
+ * its budget changed without restarting the turn.
231
+ */
232
+ nudge?: string;
233
+ };
234
+ /**
235
+ * Opt-in execution policy for one streamed turn. Supported only on the native
236
+ * Anthropic stream path today; any other provider REJECTS it rather than
237
+ * ignoring it, because silently dropping a policy the caller set is how a turn
238
+ * ends at a limit its owner believed it had removed.
239
+ *
240
+ * Absent, none of the turn-level policy here applies: the turn is bounded by
241
+ * `timeout` / `turnTimeoutMs` / `maxSteps` as it always was.
242
+ *
243
+ * One thing is NOT conditional on this option, and it is worth stating because
244
+ * it changed: the agentic engine bounds every tool call, at
245
+ * `toolTimeoutMs` or the 300s default, whether or not `executionControl` is
246
+ * present. Loops that previously ran tools with no per-tool timer at all — the
247
+ * Bedrock and Google AI Studio paths — therefore acquired one. A caller that
248
+ * relied on unbounded tool execution says so with `toolTimeoutMs: null`, which
249
+ * restores exactly the old behaviour.
250
+ */
251
+ export type ExecutionControlOptions = {
252
+ /**
253
+ * Hard deadline for a single HTTP request (ms). Required, finite, positive.
254
+ *
255
+ * This is the floor that makes the rest of the contract safe: whatever the
256
+ * turn-level policy is — including no lifetime ceiling at all — a stalled
257
+ * upstream is always caught here, with the timer's own identity on the
258
+ * error. A step boundary cannot reset a deadline already running.
259
+ */
260
+ requestTimeoutMs: number;
261
+ /**
262
+ * The turn's wall-clock ceiling (ms).
263
+ *
264
+ * - `null` — no lifetime timer is armed at all. This is the case that cannot
265
+ * be expressed any other way: a very large number is still a ceiling, and
266
+ * it fires eventually, in the middle of work, dressed as a cancel. With no
267
+ * lifetime timer the per-tool deadline is the turn's last bound, so
268
+ * `toolTimeoutMs: null` is refused alongside it.
269
+ * - a finite positive number — an explicit cap, reported as `time-limit`.
270
+ * - `0`, negative, or non-finite — rejected.
271
+ * - absent — the legacy handling (`turnTimeoutMs`, else the provider
272
+ * timeout) is inherited untouched.
273
+ *
274
+ * Set to anything other than absent, this OWNS the turn's lifetime timer, so
275
+ * a `turnTimeoutMs` on the same request would be read by nothing. That
276
+ * combination is rejected rather than silently resolved — set one or the
277
+ * other. Note that "absent" means the property is not there AND that it is
278
+ * present holding `undefined`: both say "no opinion", and both inherit.
279
+ */
280
+ lifetimeTimeoutMs?: number | null;
281
+ /**
282
+ * Runs at each step boundary. May renew the step cap and append a planning
283
+ * nudge. It is itself bounded by `beforeStepTimeoutMs` and cancelled with
284
+ * the turn; a callback that throws, or outlives its budget, is treated as
285
+ * declining to renew.
286
+ */
287
+ beforeStep?: (context: ExecutionControlStepContext) => ExecutionControlDecision | undefined | Promise<ExecutionControlDecision | undefined>;
288
+ /**
289
+ * Bound on `beforeStep` itself (ms, finite and positive; default 30_000).
290
+ * A boundary callback sits between two model calls, so an unbounded one
291
+ * stalls the turn in a place no other timer is watching.
292
+ */
293
+ beforeStepTimeoutMs?: number;
294
+ };
187
295
  export type StreamOptions = {
188
296
  /**
189
297
  * Opt this stream call into the knowledge grounding configured on the
@@ -376,12 +484,39 @@ export type StreamOptions = {
376
484
  timeout?: number | string;
377
485
  /** Wall-clock cap for the whole agentic turn (ms). See GenerateOptions.turnTimeoutMs. */
378
486
  turnTimeoutMs?: number;
487
+ /**
488
+ * Opt-in execution policy for this turn (native Anthropic streaming only).
489
+ * See ExecutionControlOptions. Absent means the legacy bounds apply
490
+ * unchanged; present on any other provider is an error, not a no-op.
491
+ *
492
+ * Two consequences of that "error, not a no-op" stance are worth knowing
493
+ * before you set it:
494
+ *
495
+ * - **It is incompatible with provider fallback.** Only the native Anthropic
496
+ * stream path implements the contract, so a turn that falls back to any
497
+ * other provider — internal fallback, or a configured fallback chain —
498
+ * fails there with a ValidationError instead of being served without the
499
+ * policy. That is deliberate: a fallback that silently dropped the policy
500
+ * would produce exactly the invisible ceiling this option exists to
501
+ * remove. Pair it with `disableInternalFallback: true` when you want the
502
+ * turn to stay on Anthropic, and handle the error if you do not.
503
+ * - **It cannot be combined with `turnTimeoutMs`** when it sets
504
+ * `lifetimeTimeoutMs`, because both name the turn's wall-clock ceiling and
505
+ * only one of them can win. Supplying both is rejected up front rather
506
+ * than resolved in silence.
507
+ * - **`lifetimeTimeoutMs: null` cannot be combined with
508
+ * `toolTimeoutMs: null`.** Removing the turn's ceiling leaves the per-tool
509
+ * deadline as the only thing that will ever end a tool which never
510
+ * returns; removing that as well leaves the turn with no bound anywhere.
511
+ * Also rejected up front.
512
+ */
513
+ executionControl?: ExecutionControlOptions;
379
514
  /** Max time with no progress before the turn ends as "stalled" (ms). Native Vertex loops only — see GenerateOptions.stallTimeoutMs. */
380
515
  stallTimeoutMs?: number;
381
516
  /** Remaining-time threshold that triggers the wrap-up nudge (ms). See GenerateOptions.wrapupTimeLeadMs. */
382
517
  wrapupTimeLeadMs?: number;
383
- /** Per-tool-execution timeout (ms, default 300_000). See GenerateOptions.toolTimeoutMs. */
384
- toolTimeoutMs?: number;
518
+ /** Per-tool-execution timeout (ms, default 300_000; `null` for no bound). See GenerateOptions.toolTimeoutMs. */
519
+ toolTimeoutMs?: number | null;
385
520
  /** AbortSignal for external cancellation of the AI call */
386
521
  abortSignal?: AbortSignal;
387
522
  /** Bounds for tool execution capture. See GenerateOptions.toolExecutionCapture. */
@@ -641,9 +776,10 @@ export type StreamResult = {
641
776
  finishReason?: string;
642
777
  /**
643
778
  * Why the agentic turn ended (see GenerateStopReason). For background-loop
644
- * streams (native Vertex paths) prefer `metadata.stopReason` after draining
645
- * the stream — this top-level field may be a getter that resolves late, and
646
- * wrapper spreads can snapshot it before the loop finishes.
779
+ * streams (the native Vertex paths and the native Anthropic stream path)
780
+ * prefer `metadata.stopReason` after draining the stream — this top-level
781
+ * field may be a getter that resolves late, and wrapper spreads can
782
+ * snapshot it before the loop finishes.
647
783
  */
648
784
  stopReason?: GenerateStopReason;
649
785
  /** Verbatim provider finish/stop reason for the turn's terminal model call. */
@@ -198,3 +198,33 @@ export declare function createValidationSummary(result: EnhancedValidationResult
198
198
  * Check if validation result has only warnings (no errors)
199
199
  */
200
200
  export declare function hasOnlyWarnings(result: EnhancedValidationResult): boolean;
201
+ /**
202
+ * Default bound on a `beforeStep` callback (ms).
203
+ *
204
+ * A boundary callback sits between two model calls, in the one place no other
205
+ * timer in the turn is watching: the request deadline has been disposed and
206
+ * the next one is not armed yet. An unbounded callback therefore stalls the
207
+ * turn silently and indefinitely.
208
+ */
209
+ export declare const DEFAULT_BEFORE_STEP_TIMEOUT_MS = 30000;
210
+ /**
211
+ * Validate an opt-in `executionControl` object and its provider support.
212
+ *
213
+ * Rejecting rather than ignoring is the whole point. A caller that asked for
214
+ * no lifetime ceiling and got one anyway does not find out at call time — it
215
+ * finds out much later, when a long turn dies at a limit its owner believed it
216
+ * had removed, reported as an ordinary cancel. So an unsupported provider is an
217
+ * error here, and so is every shape that could be read two ways.
218
+ *
219
+ * Throws ValidationError; returns void when the control is absent or valid.
220
+ *
221
+ * @param request the sibling options on the SAME call that this control has to
222
+ * be read together with. `turnTimeoutMs`, because a combination in which one
223
+ * of the two ceilings would be silently discarded is rejected instead; and
224
+ * `toolTimeoutMs`, because removing the turn's ceiling makes the per-tool
225
+ * deadline the last thing watching a tool that never returns.
226
+ */
227
+ export declare function validateExecutionControl(control: unknown, providerName: string, supported: boolean, request?: {
228
+ turnTimeoutMs?: unknown;
229
+ toolTimeoutMs?: unknown;
230
+ }): void;
@@ -978,3 +978,104 @@ export function createValidationSummary(result) {
978
978
  export function hasOnlyWarnings(result) {
979
979
  return result.errors.length === 0 && result.warnings.length > 0;
980
980
  }
981
+ // ============================================================================
982
+ // EXECUTION CONTROL
983
+ // ============================================================================
984
+ /**
985
+ * Default bound on a `beforeStep` callback (ms).
986
+ *
987
+ * A boundary callback sits between two model calls, in the one place no other
988
+ * timer in the turn is watching: the request deadline has been disposed and
989
+ * the next one is not armed yet. An unbounded callback therefore stalls the
990
+ * turn silently and indefinitely.
991
+ */
992
+ export const DEFAULT_BEFORE_STEP_TIMEOUT_MS = 30_000;
993
+ function isFinitePositive(value) {
994
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
995
+ }
996
+ /**
997
+ * Validate an opt-in `executionControl` object and its provider support.
998
+ *
999
+ * Rejecting rather than ignoring is the whole point. A caller that asked for
1000
+ * no lifetime ceiling and got one anyway does not find out at call time — it
1001
+ * finds out much later, when a long turn dies at a limit its owner believed it
1002
+ * had removed, reported as an ordinary cancel. So an unsupported provider is an
1003
+ * error here, and so is every shape that could be read two ways.
1004
+ *
1005
+ * Throws ValidationError; returns void when the control is absent or valid.
1006
+ *
1007
+ * @param request the sibling options on the SAME call that this control has to
1008
+ * be read together with. `turnTimeoutMs`, because a combination in which one
1009
+ * of the two ceilings would be silently discarded is rejected instead; and
1010
+ * `toolTimeoutMs`, because removing the turn's ceiling makes the per-tool
1011
+ * deadline the last thing watching a tool that never returns.
1012
+ */
1013
+ export function validateExecutionControl(control, providerName, supported, request = {}) {
1014
+ const { turnTimeoutMs, toolTimeoutMs } = request;
1015
+ if (control === undefined || control === null) {
1016
+ return;
1017
+ }
1018
+ if (!isNonNullObject(control)) {
1019
+ throw new ValidationError("executionControl must be an object", "executionControl", "INVALID_TYPE");
1020
+ }
1021
+ if (!supported) {
1022
+ throw new ValidationError(`executionControl is not supported by provider "${providerName}" — it is implemented only on the native Anthropic stream path. Remove it, or route this turn to anthropic.`, "executionControl", "UNSUPPORTED_PROVIDER", [
1023
+ "Use provider: 'anthropic' for turns that need executionControl",
1024
+ "Use turnTimeoutMs / maxSteps for other providers",
1025
+ ]);
1026
+ }
1027
+ const opts = control;
1028
+ if (!isFinitePositive(opts.requestTimeoutMs)) {
1029
+ throw new ValidationError(`executionControl.requestTimeoutMs is required and must be a finite positive number of milliseconds (received ${String(opts.requestTimeoutMs)}). It is the deadline that bounds a stalled upstream even when the turn has no lifetime ceiling, so there is no valid turn without it.`, "executionControl.requestTimeoutMs", "INVALID_VALUE");
1030
+ }
1031
+ // `null` is a value here, not an omission: it is the only way to say "no
1032
+ // lifetime timer at all", which no number can express — a very large one is
1033
+ // still a ceiling that fires mid-turn.
1034
+ if (opts.lifetimeTimeoutMs !== undefined &&
1035
+ opts.lifetimeTimeoutMs !== null &&
1036
+ !isFinitePositive(opts.lifetimeTimeoutMs)) {
1037
+ throw new ValidationError(`executionControl.lifetimeTimeoutMs must be null (no lifetime ceiling), a finite positive number of milliseconds, or absent (inherit the legacy timeout handling). Received ${String(opts.lifetimeTimeoutMs)}.`, "executionControl.lifetimeTimeoutMs", "INVALID_VALUE");
1038
+ }
1039
+ // Two whole-turn ceilings, one turn. An explicit `lifetimeTimeoutMs` — a
1040
+ // number or `null` — takes over the turn's lifetime timer completely, so a
1041
+ // `turnTimeoutMs` supplied alongside it was read by nothing at all. Dropping
1042
+ // it silently is the same defect this contract exists to remove, one layer
1043
+ // up: the caller's stated ceiling is discarded and it finds out when the
1044
+ // turn ends somewhere it did not expect.
1045
+ //
1046
+ // Scoped deliberately to the case where the drop happens. `lifetimeTimeoutMs`
1047
+ // ABSENT means "no opinion about the turn's lifetime", and that documented
1048
+ // case inherits `turnTimeoutMs` and honours it — there is nothing to reject.
1049
+ if (opts.lifetimeTimeoutMs !== undefined &&
1050
+ typeof turnTimeoutMs === "number" &&
1051
+ Number.isFinite(turnTimeoutMs) &&
1052
+ turnTimeoutMs > 0) {
1053
+ throw new ValidationError(`turnTimeoutMs (${turnTimeoutMs}) cannot be combined with executionControl.lifetimeTimeoutMs (${String(opts.lifetimeTimeoutMs)}): both set the turn's wall-clock ceiling, and executionControl wins, so the turnTimeoutMs would be ignored. Set exactly one of them.`, "executionControl.lifetimeTimeoutMs", "INVALID_VALUE", [
1054
+ "Drop turnTimeoutMs and express the ceiling as executionControl.lifetimeTimeoutMs",
1055
+ "Or drop executionControl.lifetimeTimeoutMs to inherit turnTimeoutMs unchanged",
1056
+ ]);
1057
+ }
1058
+ // No ceiling, and no floor either. `lifetimeTimeoutMs: null` deliberately
1059
+ // arms no turn-level timer, which leaves the per-tool deadline as the only
1060
+ // thing that will ever end a tool that neither returns nor honours its
1061
+ // signal — the step cap does not advance while a tool is in flight, and the
1062
+ // request deadline was disposed when the step settled. `toolTimeoutMs: null`
1063
+ // removes that too, and the turn then has nothing watching it anywhere.
1064
+ //
1065
+ // Refused rather than resolved, because there is no safe way to pick which
1066
+ // of the two the caller meant to keep, and picking one silently is how a
1067
+ // turn ends up bounded by a limit its owner did not choose.
1068
+ if (opts.lifetimeTimeoutMs === null && toolTimeoutMs === null) {
1069
+ throw new ValidationError("executionControl.lifetimeTimeoutMs: null removes the turn's wall-clock ceiling, which leaves toolTimeoutMs as the only bound on a tool that never returns — and toolTimeoutMs: null removes that one too, so the turn would have no bound anywhere. Keep one of them.", "executionControl.lifetimeTimeoutMs", "INVALID_VALUE", [
1070
+ "Give toolTimeoutMs a finite bound, or omit it to take the 300000ms default",
1071
+ "Or give executionControl.lifetimeTimeoutMs a finite ceiling instead of null",
1072
+ ]);
1073
+ }
1074
+ if (opts.beforeStep !== undefined && typeof opts.beforeStep !== "function") {
1075
+ throw new ValidationError("executionControl.beforeStep must be a function", "executionControl.beforeStep", "INVALID_TYPE");
1076
+ }
1077
+ if (opts.beforeStepTimeoutMs !== undefined &&
1078
+ !isFinitePositive(opts.beforeStepTimeoutMs)) {
1079
+ throw new ValidationError(`executionControl.beforeStepTimeoutMs must be a finite positive number of milliseconds when supplied. Received ${String(opts.beforeStepTimeoutMs)}.`, "executionControl.beforeStepTimeoutMs", "INVALID_VALUE");
1080
+ }
1081
+ }
@@ -259,12 +259,35 @@ export async function withTimeout(promise, timeout, provider, operation) {
259
259
  if (!timeoutMs) {
260
260
  return promise;
261
261
  }
262
+ // The handle is captured, unref'd and cleared — the same shape
263
+ // `createTimeoutPromise` above already uses. `Promise.race` settles on the
264
+ // first outcome but cancels nothing, so an uncaptured timer stayed pending
265
+ // for its full duration after the wrapped promise had already resolved: one
266
+ // live timer per call, each holding the event loop open until it fired. The
267
+ // `finally` clears it the moment the race is decided, which is what makes
268
+ // this safe to wrap around something invoked once per tool call.
269
+ let timeoutHandle;
262
270
  const timeoutPromise = new Promise((_, reject) => {
263
- setTimeout(() => {
271
+ const timer = setTimeout(() => {
264
272
  reject(new TimeoutError(`${provider} ${operation} operation timed out after ${timeoutMs}ms`, timeoutMs, provider, operation));
265
273
  }, timeoutMs);
274
+ timeoutHandle = timer;
275
+ // Unref the timer so it doesn't keep the process alive (Node.js only)
276
+ if (typeof timer === "object" &&
277
+ timer &&
278
+ "unref" in timer &&
279
+ typeof timer.unref === "function") {
280
+ timer.unref();
281
+ }
266
282
  });
267
- return Promise.race([promise, timeoutPromise]);
283
+ try {
284
+ return await Promise.race([promise, timeoutPromise]);
285
+ }
286
+ finally {
287
+ if (timeoutHandle !== undefined) {
288
+ clearTimeout(timeoutHandle);
289
+ }
290
+ }
268
291
  }
269
292
  /**
270
293
  * Wrap a streaming async generator with timeout