@juspay/neurolink 12.12.16 → 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.
@@ -41,6 +41,16 @@ export declare abstract class BaseProvider implements AIProvider {
41
41
  * @returns the current model's registered capability, or true when unknown
42
42
  */
43
43
  supportsTools(): boolean;
44
+ /**
45
+ * Whether this provider implements the opt-in `executionControl` contract.
46
+ *
47
+ * Default false, and that default is load-bearing: a provider that has not
48
+ * implemented the contract must REJECT it, not ignore it. Silently dropping
49
+ * a caller-set execution policy is invisible until a long turn dies at a
50
+ * ceiling its owner believed had been removed. Overridden only where the
51
+ * control is genuinely honoured end to end.
52
+ */
53
+ supportsExecutionControl(): boolean;
44
54
  /**
45
55
  * Apply the shared tool gate and optionally report registry-backed
46
56
  * suppression at the request entry point.
@@ -46,6 +46,7 @@ import { TelemetryHandler } from "./modules/TelemetryHandler.js";
46
46
  import { ToolsManager } from "./modules/ToolsManager.js";
47
47
  import { Utilities } from "./modules/Utilities.js";
48
48
  import { generateOnceNative } from "../utils/nativeSingleShot.js";
49
+ import { validateExecutionControl } from "../utils/parameterValidation.js";
49
50
  import { extractTokenUsage } from "../utils/tokenUtils.js";
50
51
  /**
51
52
  * Read the consumer-facing lifecycle callbacks buried inside a request's
@@ -182,6 +183,18 @@ export class BaseProvider {
182
183
  supportsTools() {
183
184
  return modelSupports("functionCalling", this.providerName, this.modelName);
184
185
  }
186
+ /**
187
+ * Whether this provider implements the opt-in `executionControl` contract.
188
+ *
189
+ * Default false, and that default is load-bearing: a provider that has not
190
+ * implemented the contract must REJECT it, not ignore it. Silently dropping
191
+ * a caller-set execution policy is invisible until a long turn dies at a
192
+ * ceiling its owner believed had been removed. Overridden only where the
193
+ * control is genuinely honoured end to end.
194
+ */
195
+ supportsExecutionControl() {
196
+ return false;
197
+ }
185
198
  /**
186
199
  * Apply the shared tool gate and optionally report registry-backed
187
200
  * suppression at the request entry point.
@@ -211,6 +224,14 @@ export class BaseProvider {
211
224
  // maxTokens (getSafeMaxTokens consults the discovered output ceiling).
212
225
  await this.ensureModelLimits();
213
226
  let options = this.normalizeStreamOptions(optionsOrPrompt);
227
+ // Before anything else, and before a single byte leaves the process: an
228
+ // execution policy this provider cannot honour is an error, and a policy
229
+ // whose shape could be read two ways is an error. Both are silent bugs at
230
+ // the point they would otherwise matter.
231
+ validateExecutionControl(options.executionControl, this.providerName, this.supportsExecutionControl(), {
232
+ turnTimeoutMs: options.turnTimeoutMs,
233
+ toolTimeoutMs: options.toolTimeoutMs,
234
+ });
214
235
  logger.info(`Starting stream`, {
215
236
  provider: this.providerName,
216
237
  hasTools: !options.disableTools && this.supportsTools(),
@@ -27,9 +27,22 @@ export declare const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300000;
27
27
  * Default per-tool-execution timeout for native agentic loops. A tool that
28
28
  * exceeds it fails with an error tool_result and costs one step — the turn
29
29
  * continues instead of hanging on a wedged tool. Override per call with
30
- * `toolTimeoutMs`.
30
+ * `toolTimeoutMs`, or remove the bound entirely with `toolTimeoutMs: null`.
31
31
  */
32
32
  export declare const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300000;
33
+ /**
34
+ * Resolve a caller's `toolTimeoutMs` into the bound a loop should actually
35
+ * apply: a number of milliseconds, or `null` for no bound at all.
36
+ *
37
+ * The three-way distinction is the whole point, and `??` cannot express it:
38
+ * `undefined` means "no opinion, take the default", while `null` is a stated
39
+ * choice to run tools unbounded — the pre-existing behaviour of the loops that
40
+ * never had a per-tool timer, and the only way to say it, since a finite
41
+ * number is always a ceiling and `Infinity` silently desugars to `setTimeout`'s
42
+ * ~24.9-day cap. Every loop resolves it through here so `null` cannot come to
43
+ * mean "the default" on one provider and "unbounded" on another.
44
+ */
45
+ export declare function resolveToolTimeoutMs(toolTimeoutMs: number | null | undefined): number | null;
33
46
  /**
34
47
  * Default wrap-up lead applied when `turnTimeoutMs` is set but
35
48
  * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
@@ -122,9 +122,26 @@ export const DEFAULT_GEMINI_STREAM_TIMEOUT_MS = 300_000;
122
122
  * Default per-tool-execution timeout for native agentic loops. A tool that
123
123
  * exceeds it fails with an error tool_result and costs one step — the turn
124
124
  * continues instead of hanging on a wedged tool. Override per call with
125
- * `toolTimeoutMs`.
125
+ * `toolTimeoutMs`, or remove the bound entirely with `toolTimeoutMs: null`.
126
126
  */
127
127
  export const DEFAULT_TOOL_EXECUTION_TIMEOUT_MS = 300_000;
128
+ /**
129
+ * Resolve a caller's `toolTimeoutMs` into the bound a loop should actually
130
+ * apply: a number of milliseconds, or `null` for no bound at all.
131
+ *
132
+ * The three-way distinction is the whole point, and `??` cannot express it:
133
+ * `undefined` means "no opinion, take the default", while `null` is a stated
134
+ * choice to run tools unbounded — the pre-existing behaviour of the loops that
135
+ * never had a per-tool timer, and the only way to say it, since a finite
136
+ * number is always a ceiling and `Infinity` silently desugars to `setTimeout`'s
137
+ * ~24.9-day cap. Every loop resolves it through here so `null` cannot come to
138
+ * mean "the default" on one provider and "unbounded" on another.
139
+ */
140
+ export function resolveToolTimeoutMs(toolTimeoutMs) {
141
+ return toolTimeoutMs === null
142
+ ? null
143
+ : (toolTimeoutMs ?? DEFAULT_TOOL_EXECUTION_TIMEOUT_MS);
144
+ }
128
145
  /**
129
146
  * Default wrap-up lead applied when `turnTimeoutMs` is set but
130
147
  * `wrapupTimeLeadMs` is not: with less than this much turn time remaining,
@@ -1,6 +1,7 @@
1
1
  import { createStreamChannel } from "./streamChannel.js";
2
2
  import { logger } from "../utils/logger.js";
3
3
  import { withProviderRetry } from "../utils/providerRetry.js";
4
+ import { resolveToolTimeoutMs } from "./constants.js";
4
5
  /**
5
6
  * Marks a step error that occurred AFTER at least one chunk had already
6
7
  * been streamed to the consumer for this step. Retrying at that point
@@ -32,6 +33,71 @@ function sumUsage(a, b) {
32
33
  cacheWrite1hTokens: (a.cacheWrite1hTokens ?? 0) + (b.cacheWrite1hTokens ?? 0) || undefined,
33
34
  };
34
35
  }
36
+ /**
37
+ * Run one tool call under its deadline, cancelling it rather than abandoning it.
38
+ *
39
+ * The signal handed to `execute` is a controller owned by THIS call, not the
40
+ * turn's — the deadline aborts it. A tool that honours its signal is therefore
41
+ * told to stop when its time is up, instead of being left running while the
42
+ * loop records that it failed: a terminal outcome reported for something that
43
+ * has not terminated, still holding its resources and, for a side-effecting
44
+ * tool, still applying its effect after the model was told it did not. A retry
45
+ * would then start a second copy alongside the first.
46
+ *
47
+ * What this cannot do is stop a tool that ignores its signal. Nothing in this
48
+ * process can; the loop stops waiting and the call runs on. That limit is
49
+ * stated in `toolTimeoutMs`'s own documentation rather than left implied.
50
+ *
51
+ * The turn's abort is forwarded into the same controller, so cancelling a turn
52
+ * reaches an in-flight tool exactly as it did before. Only the deadline is
53
+ * raced — a turn-level abort still lets the call settle on its own terms.
54
+ *
55
+ * `null` means the caller opted out of the bound: the call is awaited
56
+ * unguarded, with the turn's own signal, which is what the loops that never
57
+ * had a per-tool timer did.
58
+ */
59
+ async function executeToolCall(params) {
60
+ const { name, execute, args, toolCallId, turnSignal, toolTimeoutMs } = params;
61
+ if (toolTimeoutMs === null) {
62
+ return execute(args, { toolCallId, abortSignal: turnSignal });
63
+ }
64
+ const toolAbort = new AbortController();
65
+ const onTurnAbort = () => toolAbort.abort(turnSignal.reason);
66
+ if (turnSignal.aborted) {
67
+ toolAbort.abort(turnSignal.reason);
68
+ }
69
+ else {
70
+ turnSignal.addEventListener("abort", onTurnAbort, { once: true });
71
+ }
72
+ // One timer does both jobs — abort the tool, then stop waiting for it — so
73
+ // the two can never drift apart. That is why this is not `withTimeout`,
74
+ // which would need a second timer to reach the controller. It is
75
+ // deliberately NOT unref'd: while a tool is in flight this timer is the only
76
+ // thing that will ever end a wedged one, so it must be able to hold the
77
+ // event loop open long enough to fire. `finally` clears it the instant the
78
+ // call settles, so it never outlives its tool.
79
+ let timer;
80
+ const deadline = new Promise((_, reject) => {
81
+ timer = setTimeout(() => {
82
+ const error = new Error(`Tool "${name}" execution timed out after ${toolTimeoutMs}ms`);
83
+ toolAbort.abort(error);
84
+ reject(error);
85
+ }, toolTimeoutMs);
86
+ });
87
+ try {
88
+ // `Promise.race` subscribes to both, so a tool that eventually rejects
89
+ // after its deadline has already been reported cannot resurface as an
90
+ // unhandled rejection and kill the consumer's process.
91
+ return await Promise.race([
92
+ Promise.resolve(execute(args, { toolCallId, abortSignal: toolAbort.signal })),
93
+ deadline,
94
+ ]);
95
+ }
96
+ finally {
97
+ clearTimeout(timer);
98
+ turnSignal.removeEventListener("abort", onTurnAbort);
99
+ }
100
+ }
35
101
  /**
36
102
  * Dispatch one step's tool calls.
37
103
  *
@@ -42,7 +108,7 @@ function sumUsage(a, b) {
42
108
  * what happened by returning it, so the turn's accumulators stay in one place.
43
109
  */
44
110
  async function dispatchStepTools(params) {
45
- const { calls, adapter, tools, failedTools, abortSignal } = params;
111
+ const { calls, adapter, tools, failedTools, abortSignal, toolTimeoutMs } = params;
46
112
  const toolResults = [];
47
113
  const executions = [];
48
114
  const dispatched = [];
@@ -126,9 +192,24 @@ async function dispatchStepTools(params) {
126
192
  continue;
127
193
  }
128
194
  try {
129
- const output = await tool.execute(call.args, {
195
+ // Bounded unless the caller opted out. `abortSignal` alone only ends a
196
+ // tool that honours it, and between two steps there is no other timer
197
+ // running: the step's request deadline was disposed when the step
198
+ // settled and the next one is not armed yet. A tool that neither returns
199
+ // nor watches its signal would otherwise hang the whole turn with
200
+ // nothing to end it — the case a turn that removed its lifetime ceiling
201
+ // has no defence against.
202
+ //
203
+ // A breach lands in the catch below and is recorded as an ordinary tool
204
+ // failure, so the turn spends one step and carries on, matching what
205
+ // `toolTimeoutMs` already means on the native generate path.
206
+ const output = await executeToolCall({
207
+ name: call.name,
208
+ execute: tool.execute,
209
+ args: call.args,
130
210
  toolCallId: call.id,
131
- abortSignal: abortSignal,
211
+ turnSignal: abortSignal,
212
+ toolTimeoutMs,
132
213
  });
133
214
  // A result can report failure without throwing — an MCP isError
134
215
  // payload, a proxy-blocked call resolving with `{ error }`. When
@@ -221,8 +302,13 @@ export function runAgenticLoop(adapter, initialConversation, options) {
221
302
  const allToolCalls = [];
222
303
  const allToolExecutions = [];
223
304
  let hadToolCallsAtCap = false;
305
+ // The cap is a variable, not `adapter.maxSteps` read in place, because a
306
+ // step-boundary callback may renew it mid-turn. With no callback nothing
307
+ // ever writes to it, so the loop is the same loop it was.
308
+ let stepCap = adapter.maxSteps;
309
+ const turnStartedAt = Date.now();
224
310
  try {
225
- for (let step = 0; step < adapter.maxSteps; step++) {
311
+ for (let step = 0; step < stepCap; step++) {
226
312
  if (internalAbort.signal.aborted) {
227
313
  break;
228
314
  }
@@ -292,7 +378,7 @@ export function runAgenticLoop(adapter, initialConversation, options) {
292
378
  !malformedRetryUsed &&
293
379
  !internalAbort.signal.aborted) {
294
380
  malformedRetryUsed = true;
295
- logger.warn(`[${adapter.providerLabel}] Malformed function call at step ${step + 1}/${adapter.maxSteps}; retrying once.`);
381
+ logger.warn(`[${adapter.providerLabel}] Malformed function call at step ${step + 1}/${stepCap}; retrying once.`);
296
382
  conversation =
297
383
  adapter.buildMalformedRetryNote?.(conversation, step) ??
298
384
  conversation;
@@ -302,7 +388,7 @@ export function runAgenticLoop(adapter, initialConversation, options) {
302
388
  finalText = stepResult.text || finalText;
303
389
  break;
304
390
  }
305
- if (step === adapter.maxSteps - 1) {
391
+ if (step === stepCap - 1) {
306
392
  hadToolCallsAtCap = true;
307
393
  }
308
394
  const dispatch = await dispatchStepTools({
@@ -311,6 +397,7 @@ export function runAgenticLoop(adapter, initialConversation, options) {
311
397
  tools: options.tools,
312
398
  failedTools,
313
399
  abortSignal: internalAbort.signal,
400
+ toolTimeoutMs: resolveToolTimeoutMs(options.toolTimeoutMs),
314
401
  });
315
402
  const toolResults = dispatch.toolResults;
316
403
  allToolCalls.push(...dispatch.dispatched);
@@ -326,7 +413,55 @@ export function runAgenticLoop(adapter, initialConversation, options) {
326
413
  break;
327
414
  }
328
415
  conversation = adapter.buildToolResultMessages(conversation, stepResult, toolResults, step);
416
+ // THE step boundary. Everything this step did has settled — tools ran,
417
+ // their results are in the conversation — and the cap has not yet been
418
+ // re-checked, so this is the only point where raising it changes what
419
+ // happens next without replaying anything. Deliberately not reached
420
+ // when the step asked for no tools (the turn ended on its own; renewing
421
+ // there would be a restart) or when the turn is already cancelled.
422
+ if (options.beforeStep && !internalAbort.signal.aborted) {
423
+ const decision = await options.beforeStep({
424
+ stepIndex: step,
425
+ stepsCompleted: step + 1,
426
+ maxSteps: stepCap,
427
+ elapsedMs: Date.now() - turnStartedAt,
428
+ toolNames: dispatch.dispatched.map((call) => call.name),
429
+ signal: internalAbort.signal,
430
+ });
431
+ // Strictly larger and finite. A smaller number would end a turn the
432
+ // engine has already committed steps to, and Infinity would remove
433
+ // the bound entirely — the callback's job is to extend a budget, not
434
+ // to delete it.
435
+ //
436
+ // Floored BEFORE the comparison, not after. A cap is a whole number
437
+ // of steps, so `stepCap + 0.5` is not a renewal at all — it floors
438
+ // back to the cap already in force. Comparing the raw value first
439
+ // let it pass the guard, leave the cap where it was, and still clear
440
+ // `hadToolCallsAtCap` below, which drops the turn's last-step-text
441
+ // fallback and reports a capped turn as an uncapped one.
442
+ const renewed = typeof decision?.maxSteps === "number" &&
443
+ Number.isFinite(decision.maxSteps)
444
+ ? Math.floor(decision.maxSteps)
445
+ : undefined;
446
+ if (renewed !== undefined && renewed > stepCap) {
447
+ stepCap = renewed;
448
+ // The step that just ran is no longer the last one, so the turn is
449
+ // no longer capped. Left set, a renewed turn would report itself
450
+ // as having run out of steps.
451
+ hadToolCallsAtCap = false;
452
+ }
453
+ if (decision?.nudge && adapter.appendPlanningNudge) {
454
+ conversation = adapter.appendPlanningNudge(conversation, decision.nudge);
455
+ }
456
+ }
329
457
  }
458
+ // Read AFTER the loop, so it covers every way an abort can end a turn:
459
+ // the step-top check, a tool batch cut short, and an adapter whose
460
+ // provider SDK swallows the cancellation and returns what it had. The
461
+ // Anthropic SDK does exactly that last one — its stream iterator treats
462
+ // an aborted read as a clean end — so an interrupted turn arrives here
463
+ // indistinguishable from a completed one unless the signal is consulted.
464
+ const aborted = internalAbort.signal.aborted;
330
465
  const finishReason = adapter.mapFinishReason(rawStopReason, hadToolCallsAtCap);
331
466
  return {
332
467
  // `finalText` is only set by a step that asked for no tools, so a turn
@@ -340,9 +475,17 @@ export function runAgenticLoop(adapter, initialConversation, options) {
340
475
  toolCalls: allToolCalls,
341
476
  toolExecutions: allToolExecutions,
342
477
  usage,
343
- finishReason,
478
+ // A turn that was cut short before any terminal event reached it has
479
+ // no provider stop reason to map, and `mapFinishReason`'s default
480
+ // branch reports "stop" for that absence — the same value a model
481
+ // that finished normally produces. "other" is the AI-SDK-shaped value
482
+ // for "ended for a reason outside this enum", which is the truth.
483
+ // When the provider DID report a stop reason before the abort, that
484
+ // reason is real and is kept.
485
+ finishReason: aborted && rawStopReason === undefined ? "other" : finishReason,
344
486
  rawStopReason,
345
487
  conversation,
488
+ aborted,
346
489
  };
347
490
  }
348
491
  catch (err) {
@@ -47,7 +47,12 @@ export function guardToolExecutor(name, execute, guards) {
47
47
  const raced = guards.abortSignal
48
48
  ? raceWithAbort(call(), guards.abortSignal)
49
49
  : call();
50
- return await (guards.toolTimeoutMs === undefined
50
+ // `== null` catches both an omitted bound and an explicit `null` — a
51
+ // caller that asked for unbounded tool execution. Testing only for
52
+ // `undefined` would send `null` into `withTimeout`, where it parses as
53
+ // no delay and fires the deadline immediately.
54
+ return await (guards.toolTimeoutMs === undefined ||
55
+ guards.toolTimeoutMs === null
51
56
  ? raced
52
57
  : withTimeout(raced, guards.toolTimeoutMs, `Tool "${name}" execution timed out after ${guards.toolTimeoutMs}ms`));
53
58
  }
@@ -287,6 +287,11 @@ export class AmazonBedrockProvider extends BaseProvider {
287
287
  const { resultPromise } = runAgenticLoop(adapter, this.conversationHistory, {
288
288
  tools: this.toEngineTools(tools),
289
289
  abortSignal: options.abortSignal,
290
+ // `toEngineTools` adds no deadline of its own, so this is the only
291
+ // per-tool bound on the turn. The engine defaults when it is absent.
292
+ ...(options.toolTimeoutMs !== undefined
293
+ ? { toolTimeoutMs: options.toolTimeoutMs }
294
+ : {}),
290
295
  });
291
296
  const result = await resultPromise;
292
297
  this.conversationHistory = result.conversation;
@@ -893,6 +898,11 @@ export class AmazonBedrockProvider extends BaseProvider {
893
898
  const { stream, resultPromise } = runAgenticLoop(adapter, this.conversationHistory, {
894
899
  tools: this.toEngineTools(tools),
895
900
  abortSignal: options.abortSignal,
901
+ // `toEngineTools` adds no deadline of its own, so this is the only
902
+ // per-tool bound on the turn. The engine defaults when it is absent.
903
+ ...(options.toolTimeoutMs !== undefined
904
+ ? { toolTimeoutMs: options.toolTimeoutMs }
905
+ : {}),
896
906
  });
897
907
  // The stream surfaces the same failure, so this settled view exists only
898
908
  // so the turn's outcome can be read without a second unhandled rejection.
@@ -152,6 +152,14 @@ export declare class AnthropicProvider extends BaseProvider {
152
152
  * that read it, were never populated by anything.
153
153
  */
154
154
  private recordLimitSnapshot;
155
+ /**
156
+ * The native stream loop below is the one implementation of the
157
+ * `executionControl` contract: it arms the lifetime policy, hands the
158
+ * per-request deadline and the terminal-event requirement to the loop
159
+ * adapter, and runs the step-boundary callback through the engine. Every
160
+ * other provider rejects the option rather than ignoring it.
161
+ */
162
+ supportsExecutionControl(): boolean;
155
163
  protected executeStream(options: StreamOptions, analysisSchema?: ValidationSchema): Promise<StreamResult>;
156
164
  private executeStreamInCaptureScope;
157
165
  isAvailable(): Promise<boolean>;
@@ -24,6 +24,7 @@ import { ANTHROPIC_MAX_CACHE_BREAKPOINTS, applyAnthropicHistoryCacheBreakpoints,
24
24
  import { calculateCost } from "../../utils/pricing.js";
25
25
  import { stringifyAnthropicToolOutput } from "./toolOutput.js";
26
26
  import { createAnthropicLoopAdapter } from "./loopAdapter.js";
27
+ import { DEFAULT_BEFORE_STEP_TIMEOUT_MS } from "../../utils/parameterValidation.js";
27
28
  import { runAgenticLoop } from "../../core/loopEngine.js";
28
29
  import { hasNativeDoGenerate, runNativeGenerateLoop, } from "../../core/nativeGenerateLoop.js";
29
30
  import { withProviderRetry } from "../../utils/providerRetry.js";
@@ -31,7 +32,8 @@ import { resolveRequestKind } from "../../core/resolveRequestKind.js";
31
32
  import { resolveToolExecutionRecords, toolCallsFromSummaries, } from "../../core/toolExecutionRecorder.js";
32
33
  import { transformToolExecutions } from "../../utils/transformationUtils.js";
33
34
  import { createAnthropicConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js";
34
- import { composeAbortSignals, createTimeoutController, mergeAbortSignals, TimeoutError, } from "../../utils/timeout.js";
35
+ import { composeAbortSignals, composeAbortSignalsScoped, createTimeoutController, mergeAbortSignals, TimeoutError, } from "../../utils/timeout.js";
36
+ import { raceWithAbort } from "../../utils/async/index.js";
35
37
  import { resolveToolChoice } from "../../utils/toolChoice.js";
36
38
  import { emitToolEndFromStepFinish } from "../../utils/toolEndEmitter.js";
37
39
  import { NoOutputGeneratedError } from "../../utils/generationErrors.js";
@@ -1504,6 +1506,16 @@ export class AnthropicProvider extends BaseProvider {
1504
1506
  setLimitSpanAttributes(snapshot);
1505
1507
  logClaudeLimitSnapshot(snapshot, this.modelName);
1506
1508
  }
1509
+ /**
1510
+ * The native stream loop below is the one implementation of the
1511
+ * `executionControl` contract: it arms the lifetime policy, hands the
1512
+ * per-request deadline and the terminal-event requirement to the loop
1513
+ * adapter, and runs the step-boundary callback through the engine. Every
1514
+ * other provider rejects the option rather than ignoring it.
1515
+ */
1516
+ supportsExecutionControl() {
1517
+ return true;
1518
+ }
1507
1519
  async executeStream(options, analysisSchema) {
1508
1520
  // The capture scope must outlive this call: the SSE loop keeps running in
1509
1521
  // the background after executeStream returns, and its per-step HTTP
@@ -1516,8 +1528,38 @@ export class AnthropicProvider extends BaseProvider {
1516
1528
  // Refresh OAuth token if needed before making any API request.
1517
1529
  await this.refreshAuthIfNeeded();
1518
1530
  this.validateStreamOptions(options);
1519
- const timeout = this.getTimeout(options);
1520
- const timeoutController = createTimeoutController(timeout, this.providerName, "stream");
1531
+ // Validated in BaseProvider.stream() before this point, so the shape here
1532
+ // is known good: requestTimeoutMs finite positive, lifetimeTimeoutMs null
1533
+ // or finite positive or absent.
1534
+ const control = options.executionControl;
1535
+ // Same split the generate path already enforces in
1536
+ // `BaseProvider.withTurnTimeout`: an explicit, valid `turnTimeoutMs` is
1537
+ // the caller's whole-turn contract and owns this timer. Without it the
1538
+ // native stream path armed only the provider's own `timeout`, so a caller
1539
+ // asking for a 40-minute turn of 5-minute calls was killed at the shorter
1540
+ // value — and a caller asking for a 200ms turn was not bounded at all.
1541
+ const hasValidTurnTimeout = typeof options.turnTimeoutMs === "number" &&
1542
+ Number.isFinite(options.turnTimeoutMs) &&
1543
+ options.turnTimeoutMs > 0;
1544
+ // `lifetimeTimeoutMs: null` means NO lifetime timer, which is why this
1545
+ // resolves to undefined rather than to a large number:
1546
+ // createTimeoutController arms nothing for a falsy duration, so the turn
1547
+ // ends up bounded by its per-request deadline and its step cap alone.
1548
+ //
1549
+ // Tested with `!== undefined` rather than `"lifetimeTimeoutMs" in control`:
1550
+ // `in` is true for `{ lifetimeTimeoutMs: undefined }`, which is the shape
1551
+ // any programmatic construction produces — an optional field spread, a
1552
+ // JSON round trip, a config object assembled field by field. Under `in`
1553
+ // that shape removed the turn's ceiling entirely while the caller had said
1554
+ // nothing at all about it, and TypeScript could not warn because the field
1555
+ // is `?: number | null`. An absent value means "no opinion", and no
1556
+ // opinion inherits the legacy handling below.
1557
+ const lifetimeTimeoutMs = control && control.lifetimeTimeoutMs !== undefined
1558
+ ? (control.lifetimeTimeoutMs ?? undefined)
1559
+ : hasValidTurnTimeout
1560
+ ? options.turnTimeoutMs
1561
+ : this.getTimeout(options);
1562
+ const timeoutController = createTimeoutController(lifetimeTimeoutMs, this.providerName, "stream");
1521
1563
  // Consumer-driven abort: fires when the async iterator is closed early
1522
1564
  // (caller breaks out of `for await`) so the background loop stops
1523
1565
  // reading SSE and running tools.
@@ -1807,6 +1849,14 @@ export class AnthropicProvider extends BaseProvider {
1807
1849
  toolsRecord,
1808
1850
  buildParams,
1809
1851
  planReclaim,
1852
+ // Both are opt-in with the control and absent without it, so a caller
1853
+ // that never passed executionControl sees the turn it saw before.
1854
+ ...(control
1855
+ ? {
1856
+ requestTimeoutMs: control.requestTimeoutMs,
1857
+ requireTerminalEvent: true,
1858
+ }
1859
+ : {}),
1810
1860
  noteObservedPromptTokens: (tokens) => {
1811
1861
  lastObservedPromptTokens = tokens;
1812
1862
  },
@@ -1894,10 +1944,63 @@ export class AnthropicProvider extends BaseProvider {
1894
1944
  // The engine passed `undefined` in its place, so the attribute silently
1895
1945
  // stopped being emitted for every native Anthropic turn.
1896
1946
  const activeSpan = trace.getActiveSpan();
1947
+ // The caller's step-boundary callback, made finite and cancellable
1948
+ // before the engine ever sees it. The engine's contract is "already
1949
+ // bounded", and this is the layer that knows the budget, because the
1950
+ // budget is a field on the public option this layer validated.
1951
+ //
1952
+ // A callback that throws or outlives its budget declines the renewal
1953
+ // rather than failing the turn: the cap it did not raise still stands,
1954
+ // so the turn ends at the step limit the caller originally set. Failing
1955
+ // instead would let a flaky budget service kill work already done.
1956
+ const callerBeforeStep = control?.beforeStep;
1957
+ const beforeStep = callerBeforeStep
1958
+ ? async (context) => {
1959
+ const budgetMs = control?.beforeStepTimeoutMs ?? DEFAULT_BEFORE_STEP_TIMEOUT_MS;
1960
+ const callbackTimeout = createTimeoutController(budgetMs, this.providerName, "stream");
1961
+ const composed = composeAbortSignalsScoped(context.signal, callbackTimeout?.controller.signal);
1962
+ try {
1963
+ // One timer, not two. `callbackTimeout` already aborts the
1964
+ // composed signal at `budgetMs`, so the second `withTimeout`
1965
+ // that used to sit here armed a duplicate timer for the same
1966
+ // deadline. Racing the composed signal instead keeps the budget
1967
+ // COMPULSORY — a callback that ignores its signal must not be
1968
+ // able to park the turn at a step boundary, which is the one
1969
+ // place no other timer is watching — while arming nothing new.
1970
+ // It also ends the wait the moment the TURN is cancelled, which
1971
+ // the old duplicate timer did not do.
1972
+ return await raceWithAbort(Promise.resolve(callerBeforeStep({
1973
+ ...context,
1974
+ signal: composed.signal ?? context.signal,
1975
+ })), composed.signal ?? context.signal);
1976
+ }
1977
+ catch (callbackError) {
1978
+ logger.warn("[Anthropic] executionControl.beforeStep failed or timed out; the step cap stands", {
1979
+ error: callbackError instanceof Error
1980
+ ? callbackError.message
1981
+ : String(callbackError),
1982
+ stepsCompleted: context.stepsCompleted,
1983
+ });
1984
+ return undefined;
1985
+ }
1986
+ finally {
1987
+ composed.dispose();
1988
+ callbackTimeout?.cleanup();
1989
+ }
1990
+ }
1991
+ : undefined;
1897
1992
  const { stream, resultPromise } = runAgenticLoop(adapter, payload.messages.slice(), {
1898
1993
  tools: engineTools,
1899
1994
  ...(abortSignal ? { abortSignal } : {}),
1900
1995
  ...(activeSpan ? { span: activeSpan } : {}),
1996
+ ...(beforeStep ? { beforeStep } : {}),
1997
+ // `engineTools` above only fixes the context object; it adds no
1998
+ // deadline. The engine's per-tool bound is therefore the only thing
1999
+ // watching a wedged tool on this path — which matters most when the
2000
+ // caller asked for no lifetime ceiling at all.
2001
+ ...(options.toolTimeoutMs !== undefined
2002
+ ? { toolTimeoutMs: options.toolTimeoutMs }
2003
+ : {}),
1901
2004
  });
1902
2005
  // Structured turns buffer their text rather than streaming it: a caller
1903
2006
  // that passed a schema needs parseable JSON, and deltas emitted before
@@ -1946,6 +2049,32 @@ export class AnthropicProvider extends BaseProvider {
1946
2049
  totalCacheRead += result.usage.cacheReadTokens ?? 0;
1947
2050
  totalCacheWrite += result.usage.cacheWriteTokens ?? 0;
1948
2051
  lastStop = result.rawStopReason ?? lastStop;
2052
+ // An interrupted turn is not a stop. The Anthropic SDK's stream
2053
+ // iterator exits WITHOUT throwing when its request is aborted, so a
2054
+ // turn killed by the caller's signal or by the turn deadline drains
2055
+ // through here carrying no terminal event — and the ordinary path below
2056
+ // would report it with the same `finishReason` and the same resolved
2057
+ // stop reason as a model that answered and stopped.
2058
+ //
2059
+ // The merged signal's reason is what separates the two causes: NeuroLink's
2060
+ // own timers abort with a TimeoutError, and nothing else does.
2061
+ // Everything the completed steps produced is still reported — the text
2062
+ // was already pushed to the consumer, and the tokens were billed.
2063
+ if (result.aborted) {
2064
+ const reason = abortSignal?.aborted ? abortSignal.reason : undefined;
2065
+ turnMetadata.stopReason =
2066
+ reason instanceof TimeoutError ? "time-limit" : "aborted";
2067
+ turnMetadata.finishReason = "other";
2068
+ if (result.rawStopReason) {
2069
+ turnMetadata.rawFinishReason = result.rawStopReason;
2070
+ }
2071
+ resolveUsage(buildDeferredUsage());
2072
+ // Returns before the step-cap branch below: a turn aborted while the
2073
+ // model still wanted tools carries stop_reason "tool_use", which that
2074
+ // branch would read as the caller's own maxSteps bound.
2075
+ resolveFinish("other");
2076
+ return;
2077
+ }
1949
2078
  turnMetadata.finishReason = result.finishReason;
1950
2079
  if (result.rawStopReason) {
1951
2080
  turnMetadata.rawFinishReason = result.rawStopReason;