@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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +412 -412
- package/dist/core/baseProvider.d.ts +10 -0
- package/dist/core/baseProvider.js +21 -0
- package/dist/core/constants.d.ts +14 -1
- package/dist/core/constants.js +18 -1
- package/dist/core/loopEngine.js +150 -7
- package/dist/core/toolExecutionGuards.js +6 -1
- package/dist/providers/amazonBedrock/client.js +10 -0
- package/dist/providers/anthropic/client.d.ts +8 -0
- package/dist/providers/anthropic/client.js +132 -3
- package/dist/providers/anthropic/loopAdapter.js +352 -244
- package/dist/providers/googleAiStudio/client.js +12 -0
- package/dist/providers/googleVertex/client.js +19 -5
- package/dist/types/generate.d.ts +22 -9
- package/dist/types/loopEngine.d.ts +97 -2
- package/dist/types/stream.d.ts +141 -5
- package/dist/utils/parameterValidation.d.ts +30 -0
- package/dist/utils/parameterValidation.js +101 -0
- package/dist/utils/timeout.js +25 -2
- package/package.json +6 -5
|
@@ -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(),
|
package/dist/core/constants.d.ts
CHANGED
|
@@ -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,
|
package/dist/core/constants.js
CHANGED
|
@@ -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,
|
package/dist/core/loopEngine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 <
|
|
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}/${
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1520
|
-
|
|
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;
|