@mono-agent/agent-runtime 0.15.3 → 0.16.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/MIGRATION.md +41 -13
- package/README.md +43 -6
- package/package.json +7 -3
- package/src/agent/tools/agent-tool.js +894 -0
- package/src/agent/tools/bash.js +241 -123
- package/src/agent/tools/exec.js +238 -0
- package/src/agent/tools/index.js +10 -3
- package/src/agent/tools/node-repl.js +231 -95
- package/src/agent/tools/pi-bridge.js +115 -24
- package/src/agent/tools/shared/process-runner.js +162 -0
- package/src/agent/tools/shared/semaphore.js +73 -0
- package/src/agent/tools/web-browser-render.js +221 -0
- package/src/agent/tools/web-controller.js +160 -0
- package/src/agent/tools/web-fetch.js +653 -68
- package/src/agent/tools/web-search.js +568 -16
- package/src/ai/pi-interop.js +7 -5
- package/src/ai/pi-oauth-compat.js +193 -0
- package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
- package/src/ai/providers/pi-native/turn-runner.js +73 -8
- package/src/ai/providers/pi-native.js +67 -7
- package/src/ai/runtime/router.js +310 -166
- package/src/ai/types.js +54 -2
- package/src/pi-auth.js +2 -2
- package/src/runtime.js +58 -1
- package/types/agent/tools/agent-tool.d.ts +80 -0
- package/types/agent/tools/bash.d.ts +55 -7
- package/types/agent/tools/exec.d.ts +53 -0
- package/types/agent/tools/index.d.ts +5 -3
- package/types/agent/tools/node-repl.d.ts +28 -3
- package/types/agent/tools/pi-bridge.d.ts +6 -2
- package/types/agent/tools/shared/process-runner.d.ts +33 -0
- package/types/agent/tools/shared/semaphore.d.ts +29 -0
- package/types/agent/tools/web-browser-render.d.ts +16 -0
- package/types/agent/tools/web-controller.d.ts +20 -0
- package/types/agent/tools/web-fetch.d.ts +74 -5
- package/types/agent/tools/web-search.d.ts +81 -5
- package/types/ai/pi-oauth-compat.d.ts +57 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +33 -2
- package/types/ai/providers/pi-native.d.ts +12 -0
- package/types/ai/runtime/router.d.ts +23 -3
- package/types/ai/types.d.ts +174 -4
- package/types/ai/backend.d.ts +0 -57
- package/types/ai/registry.d.ts +0 -1
package/src/ai/runtime/router.js
CHANGED
|
@@ -55,11 +55,13 @@ import { instrumentLiveInputAppliedEvents } from "./live-input-events.js";
|
|
|
55
55
|
/**
|
|
56
56
|
* @typedef {Object} RouterChainEntryInput
|
|
57
57
|
* A chain entry as accepted by createRouterRuntime: either the shorthand bare
|
|
58
|
-
* RuntimeModelRef, or the full `{model, executionMode?, effort?, requires
|
|
58
|
+
* RuntimeModelRef, or the full `{model, executionMode?, effort?, requires?,
|
|
59
|
+
* attempts?}` form.
|
|
59
60
|
* @property {RuntimeModelRef} model
|
|
60
61
|
* @property {string} [executionMode]
|
|
61
62
|
* @property {string|null} [effort]
|
|
62
63
|
* @property {Object<string, *>} [requires]
|
|
64
|
+
* @property {number} [attempts]
|
|
63
65
|
*/
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -68,6 +70,13 @@ import { instrumentLiveInputAppliedEvents } from "./live-input-events.js";
|
|
|
68
70
|
* @property {string|null} executionMode
|
|
69
71
|
* @property {string|null|undefined} effort
|
|
70
72
|
* @property {Object<string, *>|null} requires
|
|
73
|
+
* @property {number} attempts Total attempts on this route including the first.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {Object} RouterRetryPolicy
|
|
78
|
+
* @property {number} backoffMs Delay before the first retry; doubles per retry.
|
|
79
|
+
* @property {number} maxBackoffMs Ceiling for the doubled delay.
|
|
71
80
|
*/
|
|
72
81
|
|
|
73
82
|
/**
|
|
@@ -97,13 +106,16 @@ const RESOLVER_PROTECTED_OPTION_KEYS = new Set([
|
|
|
97
106
|
* @param {AgentRuntimeHostOptions} [options.host]
|
|
98
107
|
* @param {ReadonlyArray<RuntimeModelRef|RouterChainEntryInput>} [options.chain]
|
|
99
108
|
* @param {"uniform"|"per-route-native"} [options.routeSafety]
|
|
100
|
-
* @param {(input: {model: RuntimeModelRef, executionMode: string|null, attemptIndex: number, routeSafety: "uniform"|"per-route-native"}) => (RouterAttemptResolution|Promise<RouterAttemptResolution>)} [options.resolveAttempt]
|
|
109
|
+
* @param {(input: {model: RuntimeModelRef, executionMode: string|null, attemptIndex: number, retryIndex: number, routeSafety: "uniform"|"per-route-native"}) => (RouterAttemptResolution|Promise<RouterAttemptResolution>)} [options.resolveAttempt]
|
|
110
|
+
* @param {Partial<RouterRetryPolicy>} [options.retry] Backoff shape for same-model
|
|
111
|
+
* retries. Per-route retry counts live on each chain entry's `attempts`.
|
|
101
112
|
* @returns {AgentRuntimeInstance & {chain: () => Array<RouterChainEntry>}}
|
|
102
113
|
*/
|
|
103
|
-
export function createRouterRuntime({ host = {}, chain = [], routeSafety = "uniform", resolveAttempt } = {}) {
|
|
114
|
+
export function createRouterRuntime({ host = {}, chain = [], routeSafety = "uniform", resolveAttempt, retry } = {}) {
|
|
104
115
|
if (!ROUTE_SAFETY_MODES.has(routeSafety)) {
|
|
105
116
|
throw new Error("createRouterRuntime routeSafety must be uniform or per-route-native");
|
|
106
117
|
}
|
|
118
|
+
const retryPolicy = normalizeRetryPolicy(retry);
|
|
107
119
|
const entries = normaliseChain(chain);
|
|
108
120
|
if (entries.length === 0) {
|
|
109
121
|
throw new Error("createRouterRuntime requires a non-empty chain");
|
|
@@ -160,7 +172,7 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
|
|
|
160
172
|
for (let i = 0; i < entries.length; i += 1) {
|
|
161
173
|
const entry = entries[i];
|
|
162
174
|
const effectiveToolOptions = effectiveRouterToolOptions(host, configuredTools);
|
|
163
|
-
|
|
175
|
+
const entrySafetyContract = routeSafetyContract(
|
|
164
176
|
routeSafety,
|
|
165
177
|
entry,
|
|
166
178
|
effectivePiSandboxPolicy(effectiveToolOptions, options),
|
|
@@ -179,16 +191,21 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
|
|
|
179
191
|
failureKind: "skipped_capability_mismatch",
|
|
180
192
|
requirements: entry.requires,
|
|
181
193
|
routeSafety,
|
|
182
|
-
safetyContract,
|
|
194
|
+
safetyContract: entrySafetyContract,
|
|
183
195
|
});
|
|
184
|
-
const skippedRecord = routeSafetyRecord(i, entry,
|
|
196
|
+
const skippedRecord = routeSafetyRecord(i, entry, entrySafetyContract, "skipped_capability_mismatch");
|
|
185
197
|
routeSafetyHistory.push(skippedRecord);
|
|
186
198
|
emit(options, { type: "provider_route_safety", ...skippedRecord });
|
|
187
199
|
continue;
|
|
188
200
|
}
|
|
189
201
|
|
|
202
|
+
// Attempt-scoped stripping is a property of the ROUTE (chain index), not
|
|
203
|
+
// of one attempt, so it is decided once here. Every same-model retry
|
|
204
|
+
// derives a fresh mutable callOptions from this immutable base, because
|
|
205
|
+
// the per-attempt bag is mutated in place (effort, session deletes,
|
|
206
|
+
// snapshot seed) and reassigned by mergeAttemptOptions.
|
|
190
207
|
/** @type {*} */
|
|
191
|
-
|
|
208
|
+
const entryOptionsBase = {
|
|
192
209
|
...options,
|
|
193
210
|
model: entry.model,
|
|
194
211
|
executionMode: entry.executionMode || options.executionMode,
|
|
@@ -197,185 +214,256 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
|
|
|
197
214
|
// route. Without a route resolver there is no authoritative metadata
|
|
198
215
|
// for a different fallback, so never let the primary's credentials or
|
|
199
216
|
// model capabilities contaminate later attempts.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
217
|
+
const entryCallBase = resolveAttempt === undefined && i > 0
|
|
218
|
+
? withoutAttemptScopedOptions(entryOptionsBase)
|
|
219
|
+
: entryOptionsBase;
|
|
220
|
+
|
|
221
|
+
/** @type {RuntimeResult|null} A failure that ends the whole logical run. */
|
|
222
|
+
let terminalResult = null;
|
|
223
|
+
|
|
224
|
+
for (let retryIndex = 0; retryIndex < entry.attempts; retryIndex += 1) {
|
|
225
|
+
let safetyContract = entrySafetyContract;
|
|
226
|
+
/** @type {*} */
|
|
227
|
+
let callOptions = { ...entryCallBase };
|
|
228
|
+
/** @type {AgentRuntimeInstance} */
|
|
229
|
+
let attemptRuntime = inner;
|
|
230
|
+
/** @type {(() => (void|Promise<void>))|undefined} */
|
|
231
|
+
let attemptCleanup;
|
|
232
|
+
try {
|
|
233
|
+
const resolved = resolveAttempt === undefined
|
|
234
|
+
? undefined
|
|
235
|
+
: await resolveAttempt({
|
|
236
|
+
model: entry.model,
|
|
237
|
+
executionMode: entry.executionMode,
|
|
238
|
+
attemptIndex: i,
|
|
239
|
+
retryIndex,
|
|
240
|
+
routeSafety,
|
|
241
|
+
});
|
|
242
|
+
const resolution = normalizeAttemptResolution(resolved);
|
|
243
|
+
attemptCleanup = resolution?.cleanup;
|
|
244
|
+
if (resolveAttempt !== undefined) {
|
|
245
|
+
callOptions = mergeAttemptOptions(callOptions, resolution?.options);
|
|
246
|
+
}
|
|
247
|
+
if (routeSafety === "per-route-native") {
|
|
248
|
+
callOptions = projectPerRouteNativeOptions(entry, callOptions);
|
|
249
|
+
const key = routeRuntimeKey(entry, i);
|
|
250
|
+
const resolvedRuntime = resolution?.runtime;
|
|
251
|
+
if (resolvedRuntime !== undefined) {
|
|
252
|
+
assertRuntimeLike(resolvedRuntime);
|
|
253
|
+
const previousRuntime = routeRuntimes.get(key);
|
|
254
|
+
if (previousRuntime !== undefined && previousRuntime !== resolvedRuntime) {
|
|
255
|
+
try { await previousRuntime.disposeAllSessions?.(); } catch { /* best-effort replacement */ }
|
|
256
|
+
}
|
|
257
|
+
routeRuntimes.set(key, resolvedRuntime);
|
|
258
|
+
if (entry.model.sdk !== "pi") {
|
|
259
|
+
resolvedRuntime.configureTools?.(projectPerRouteNativeToolOptions(entry, configuredTools));
|
|
260
|
+
}
|
|
230
261
|
}
|
|
231
|
-
routeRuntimes.
|
|
232
|
-
if (entry.model.sdk
|
|
233
|
-
|
|
262
|
+
attemptRuntime = routeRuntimes.get(key) ?? createRouteRuntime(key, entry, host, routeRuntimes, configuredTools);
|
|
263
|
+
if (entry.model.sdk === "pi") {
|
|
264
|
+
projectPiRuntimeToolContext(attemptRuntime, effectiveToolOptions);
|
|
265
|
+
// Derive the attestation from the same complete base context and
|
|
266
|
+
// request-scoped inputs that the supplied/runtime-owned Pi
|
|
267
|
+
// bridge will actually receive. Resolver options cannot alter
|
|
268
|
+
// these protected fields.
|
|
269
|
+
safetyContract = routeSafetyContract(
|
|
270
|
+
routeSafety,
|
|
271
|
+
entry,
|
|
272
|
+
effectivePiSandboxPolicy(effectiveToolOptions, callOptions),
|
|
273
|
+
);
|
|
234
274
|
}
|
|
275
|
+
} else if (resolution?.runtime !== undefined && resolution.runtime !== inner) {
|
|
276
|
+
throw new Error("uniform route safety cannot replace the shared monotonic runtime");
|
|
235
277
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
278
|
+
} catch (error) {
|
|
279
|
+
try { await attemptCleanup?.(); } catch { /* cleanup is additive */ }
|
|
280
|
+
const failure = safetyUnavailableResult(error);
|
|
281
|
+
lastRouteSkip = failure;
|
|
282
|
+
failoverHistory.push({
|
|
283
|
+
model: entry.model,
|
|
284
|
+
failureKind: "safety_unavailable",
|
|
285
|
+
routeSafety,
|
|
286
|
+
safetyContract,
|
|
287
|
+
});
|
|
288
|
+
const unavailableRecord = routeSafetyRecord(i, entry, safetyContract, "safety_unavailable");
|
|
289
|
+
routeSafetyHistory.push(unavailableRecord);
|
|
290
|
+
emit(callOptions, { type: "provider_route_safety", ...unavailableRecord });
|
|
291
|
+
// A resolver fault is a config/credential problem, not a transient
|
|
292
|
+
// provider blip: retrying the same route cannot fix it. Advance.
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
applyEntryEffort(callOptions, entry.effort);
|
|
297
|
+
// A provider session belongs to the route AND to the attempt that
|
|
298
|
+
// created it. The entire chain is stateless whenever a fallback exists,
|
|
299
|
+
// keeping the full logical run replayable regardless of which route is
|
|
300
|
+
// attempted. A same-model retry re-sends the whole logical turn, so
|
|
301
|
+
// resuming the session the failed attempt already appended into would
|
|
302
|
+
// duplicate the turn or hit session_busy.
|
|
303
|
+
if (entries.length > 1 || i > 0 || retryIndex > 0 || !entrySupportsSessionResume(entry)) {
|
|
304
|
+
delete callOptions.sessionId;
|
|
305
|
+
delete callOptions.providerSessionId;
|
|
306
|
+
delete callOptions.sessionKeepAlive;
|
|
307
|
+
delete callOptions.sessionIdleTimeoutMs;
|
|
308
|
+
}
|
|
309
|
+
let attemptSystemPrompt = promptBase;
|
|
310
|
+
if (pendingSnapshot) {
|
|
311
|
+
callOptions.diagnosticsSeed = {
|
|
312
|
+
...(callOptions.diagnosticsSeed || {}),
|
|
313
|
+
resume_snapshot: pendingSnapshot,
|
|
314
|
+
};
|
|
315
|
+
// Also prepend the rendered snapshot to the system prompt so SDK
|
|
316
|
+
// backends that don't read diagnosticsSeed still continue from the
|
|
317
|
+
const rendered = renderResumeSnapshot(pendingSnapshot);
|
|
318
|
+
if (rendered) {
|
|
319
|
+
callOptions.systemPromptPrefix = rendered;
|
|
320
|
+
attemptSystemPrompt = `${rendered}\n\n${promptBase}`;
|
|
248
321
|
}
|
|
249
|
-
} else if (resolution?.runtime !== undefined && resolution.runtime !== inner) {
|
|
250
|
-
throw new Error("uniform route safety cannot replace the shared monotonic runtime");
|
|
251
322
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
323
|
+
|
|
324
|
+
// The safety contract is a property of the route: resolver options can
|
|
325
|
+
// never reach the sandbox/tool policy (RESOLVER_PROTECTED_OPTION_KEYS)
|
|
326
|
+
// and effectivePiSandboxPolicy reads only protected fields, so every
|
|
327
|
+
// retry of one entry derives an identical contract. Record it once so
|
|
328
|
+
// the bounded safety telemetry stays one record per chain entry.
|
|
329
|
+
if (retryIndex === 0) {
|
|
330
|
+
const safetyRecord = routeSafetyRecord(i, entry, safetyContract, "attempted");
|
|
331
|
+
routeSafetyHistory.push(safetyRecord);
|
|
332
|
+
emit(callOptions, { type: "provider_route_safety", ...safetyRecord });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A same-model retry is not a failover: only the first attempt of a new
|
|
336
|
+
// route announces a transition.
|
|
337
|
+
if (retryIndex === 0 && failoverHistory.length > 0) {
|
|
338
|
+
const previous = failoverHistory[failoverHistory.length - 1];
|
|
339
|
+
emit(callOptions, {
|
|
340
|
+
type: "provider_failover_started",
|
|
341
|
+
from: modelKey(previous?.model),
|
|
342
|
+
to: modelKey(entry.model),
|
|
343
|
+
attemptIndex: i,
|
|
344
|
+
// Why the route changed, in the same vocabulary provider_retry_started
|
|
345
|
+
// uses. Operators reading a transcript need the cause next to the
|
|
346
|
+
// transition, not only in the run artifact's failoverHistory.
|
|
347
|
+
reason: previous?.retryableSubkind || previous?.failureKind || null,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let result;
|
|
352
|
+
try {
|
|
353
|
+
result = await attemptRuntime.run(attemptSystemPrompt, callOptions);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
// The inner runtime usually surfaces errors as structured result
|
|
356
|
+
// fields, but a bridge can still throw synchronously (e.g. spawn
|
|
357
|
+
// failures). Convert to a result-like shape so the chain logic
|
|
358
|
+
// is uniform.
|
|
359
|
+
result = {
|
|
360
|
+
text: null,
|
|
361
|
+
error: err?.message || String(err),
|
|
362
|
+
failureKind: "provider_unavailable",
|
|
363
|
+
events: [],
|
|
364
|
+
cancelled: false,
|
|
365
|
+
usage: {},
|
|
366
|
+
};
|
|
367
|
+
} finally {
|
|
368
|
+
try { await attemptCleanup?.(); } catch { /* cleanup is additive */ }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
result = normalizeProviderAuthFailure(result);
|
|
372
|
+
|
|
373
|
+
const retryability = retryableProviderFailureInfo({
|
|
374
|
+
errorText: result.error || "",
|
|
375
|
+
stderrTail: result.stderrTail || "",
|
|
376
|
+
failureKind: result.failureKind,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const successful = !result.error && !result.failureKind && !result.cancelled;
|
|
380
|
+
if (successful) {
|
|
381
|
+
// Only a genuine route change is a completed failover: succeeding
|
|
382
|
+
// after a same-model retry must not render as "answered by X
|
|
383
|
+
// (failover)" when X is still the route the operator asked for.
|
|
384
|
+
if (failoverHistory.some((attempt) => modelKey(attempt.model) !== modelKey(entry.model))) {
|
|
385
|
+
emit(callOptions, {
|
|
386
|
+
// modelKey, not the ModelRef: every consumer of this event reads
|
|
387
|
+
// `model` as a string (responder.ts's stringField is string-only),
|
|
388
|
+
// so an object here is dropped silently rather than rendered.
|
|
389
|
+
type: "provider_failover_completed",
|
|
390
|
+
attemptIndex: i,
|
|
391
|
+
model: modelKey(entry.model),
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return { ...result, failoverHistory, routeSafetyHistory };
|
|
395
|
+
}
|
|
396
|
+
|
|
256
397
|
failoverHistory.push({
|
|
257
398
|
model: entry.model,
|
|
258
|
-
failureKind:
|
|
399
|
+
failureKind: result.failureKind || null,
|
|
400
|
+
requestId: retryability.requestId,
|
|
401
|
+
retryableSubkind: retryability.subkind,
|
|
402
|
+
...(retryIndex > 0 ? { retryIndex } : {}),
|
|
259
403
|
routeSafety,
|
|
260
404
|
safetyContract,
|
|
261
405
|
});
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
406
|
+
if (result.failureKind === "skipped_capability_mismatch") {
|
|
407
|
+
lastRouteSkip = result;
|
|
408
|
+
// A bridge-level mismatch is about this route, not the logical run.
|
|
409
|
+
// Try the next entry and do not derive a transcript snapshot from it.
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
lastResult = result;
|
|
267
413
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
let attemptSystemPrompt = promptBase;
|
|
279
|
-
if (pendingSnapshot) {
|
|
280
|
-
callOptions.diagnosticsSeed = {
|
|
281
|
-
...(callOptions.diagnosticsSeed || {}),
|
|
282
|
-
resume_snapshot: pendingSnapshot,
|
|
283
|
-
};
|
|
284
|
-
// Also prepend the rendered snapshot to the system prompt so SDK
|
|
285
|
-
// backends that don't read diagnosticsSeed still continue from the
|
|
286
|
-
const rendered = renderResumeSnapshot(pendingSnapshot);
|
|
287
|
-
if (rendered) {
|
|
288
|
-
callOptions.systemPromptPrefix = rendered;
|
|
289
|
-
attemptSystemPrompt = `${rendered}\n\n${promptBase}`;
|
|
414
|
+
// Provider auth is terminal for one provider, but chain-retryable: a
|
|
415
|
+
// fallback provider may have working credentials. Other non-retryable
|
|
416
|
+
// provider/request errors remain terminal.
|
|
417
|
+
const shouldFallback = (retryability.retryable || result.failureKind === "provider_auth")
|
|
418
|
+
&& !result.cancelled
|
|
419
|
+
&& !isMidTurnSafetyFailure(result.failureKind);
|
|
420
|
+
if (!shouldFallback) {
|
|
421
|
+
terminalResult = result;
|
|
422
|
+
break;
|
|
290
423
|
}
|
|
291
|
-
}
|
|
292
424
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
425
|
+
// Build a transcript-tail snapshot from this run's events so the next
|
|
426
|
+
// attempt — same model or next route — can continue. A run that
|
|
427
|
+
// produced no usable events yields a falsy snapshot and merges to a
|
|
428
|
+
// no-op, so the common "died before the first token" retry costs
|
|
429
|
+
// nothing. Keep one bounded snapshot object across the logical run
|
|
430
|
+
// instead of nesting a new <resume_context> block per transition.
|
|
431
|
+
pendingSnapshot = mergeResumeSnapshots(
|
|
432
|
+
pendingSnapshot,
|
|
433
|
+
buildTranscriptTailSnapshot(result.events, { runtimeBrand }),
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// context_limit is forced retryable so the chain can reach a model with
|
|
437
|
+
// a bigger window, but it is deterministic against the SAME window:
|
|
438
|
+
// another attempt here is a guaranteed second failure. Advance instead.
|
|
439
|
+
const sameModelRetryable = retryability.retryable
|
|
440
|
+
&& retryability.subkind !== "context_limit"
|
|
441
|
+
&& retryIndex + 1 < entry.attempts;
|
|
442
|
+
if (!sameModelRetryable) break;
|
|
296
443
|
|
|
297
|
-
|
|
444
|
+
const backoffMs = Math.min(retryPolicy.maxBackoffMs, retryPolicy.backoffMs * (2 ** retryIndex));
|
|
298
445
|
emit(callOptions, {
|
|
299
|
-
type: "
|
|
300
|
-
|
|
301
|
-
to: entry.model,
|
|
446
|
+
type: "provider_retry_started",
|
|
447
|
+
model: modelKey(entry.model),
|
|
302
448
|
attemptIndex: i,
|
|
449
|
+
retryIndex: retryIndex + 1,
|
|
450
|
+
attempts: entry.attempts,
|
|
451
|
+
delayMs: backoffMs,
|
|
452
|
+
reason: retryability.subkind || result.failureKind || null,
|
|
303
453
|
});
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
// The inner runtime usually surfaces errors as structured result
|
|
311
|
-
// fields, but a bridge can still throw synchronously (e.g. spawn
|
|
312
|
-
// failures). Convert to a result-like shape so the chain logic
|
|
313
|
-
// is uniform.
|
|
314
|
-
result = {
|
|
315
|
-
text: null,
|
|
316
|
-
error: err?.message || String(err),
|
|
317
|
-
failureKind: "provider_unavailable",
|
|
318
|
-
events: [],
|
|
319
|
-
cancelled: false,
|
|
320
|
-
usage: {},
|
|
321
|
-
};
|
|
322
|
-
} finally {
|
|
323
|
-
try { await attemptCleanup?.(); } catch { /* cleanup is additive */ }
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
result = normalizeProviderAuthFailure(result);
|
|
327
|
-
|
|
328
|
-
const retryability = retryableProviderFailureInfo({
|
|
329
|
-
errorText: result.error || "",
|
|
330
|
-
stderrTail: result.stderrTail || "",
|
|
331
|
-
failureKind: result.failureKind,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
const successful = !result.error && !result.failureKind && !result.cancelled;
|
|
335
|
-
if (successful) {
|
|
336
|
-
if (failoverHistory.length > 0) {
|
|
337
|
-
emit(callOptions, {
|
|
338
|
-
type: "provider_failover_completed",
|
|
339
|
-
attemptIndex: i,
|
|
340
|
-
model: entry.model,
|
|
341
|
-
});
|
|
454
|
+
if (callOptions.abortSignal?.aborted) {
|
|
455
|
+
return { ...result, cancelled: true, failoverHistory, routeSafetyHistory };
|
|
456
|
+
}
|
|
457
|
+
await delay(backoffMs, callOptions.abortSignal);
|
|
458
|
+
if (callOptions.abortSignal?.aborted) {
|
|
459
|
+
return { ...result, cancelled: true, failoverHistory, routeSafetyHistory };
|
|
342
460
|
}
|
|
343
|
-
return { ...result, failoverHistory, routeSafetyHistory };
|
|
344
461
|
}
|
|
345
462
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
failureKind: result.failureKind || null,
|
|
349
|
-
requestId: retryability.requestId,
|
|
350
|
-
retryableSubkind: retryability.subkind,
|
|
351
|
-
routeSafety,
|
|
352
|
-
safetyContract,
|
|
353
|
-
});
|
|
354
|
-
if (result.failureKind === "skipped_capability_mismatch") {
|
|
355
|
-
lastRouteSkip = result;
|
|
356
|
-
// A bridge-level mismatch is about this route, not the logical run.
|
|
357
|
-
// Try the next entry and do not derive a transcript snapshot from it.
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
lastResult = result;
|
|
361
|
-
|
|
362
|
-
// Provider auth is terminal for one provider, but chain-retryable: a
|
|
363
|
-
// fallback provider may have working credentials. Other non-retryable
|
|
364
|
-
// provider/request errors remain terminal.
|
|
365
|
-
const shouldFallback = (retryability.retryable || result.failureKind === "provider_auth")
|
|
366
|
-
&& !result.cancelled
|
|
367
|
-
&& !isMidTurnSafetyFailure(result.failureKind);
|
|
368
|
-
if (!shouldFallback) {
|
|
369
|
-
return { ...result, failoverHistory, routeSafetyHistory };
|
|
463
|
+
if (terminalResult !== null) {
|
|
464
|
+
return { ...terminalResult, failoverHistory, routeSafetyHistory };
|
|
370
465
|
}
|
|
371
|
-
|
|
372
|
-
// Build a transcript-tail snapshot from this run's events so the
|
|
373
|
-
// next provider can continue. If the run produced no usable events,
|
|
374
|
-
// skip the snapshot (the next attempt starts fresh).
|
|
375
|
-
const snapshot = buildTranscriptTailSnapshot(result.events, { runtimeBrand });
|
|
376
|
-
// Keep one bounded snapshot object across the logical run instead of
|
|
377
|
-
// nesting a new <resume_context> block on every provider transition.
|
|
378
|
-
pendingSnapshot = mergeResumeSnapshots(pendingSnapshot, snapshot);
|
|
466
|
+
// Every other inner break falls through to the next chain entry.
|
|
379
467
|
}
|
|
380
468
|
|
|
381
469
|
const exhaustedResult = lastResult || lastRouteSkip || {
|
|
@@ -467,7 +555,7 @@ function normaliseChain(chain) {
|
|
|
467
555
|
if (!entry) return null;
|
|
468
556
|
if (entry.sdk && entry.model) {
|
|
469
557
|
// ModelRef shorthand: { sdk, model, ... }
|
|
470
|
-
return { model: entry, executionMode: null, effort: undefined, requires: null };
|
|
558
|
+
return { model: entry, executionMode: null, effort: undefined, requires: null, attempts: 1 };
|
|
471
559
|
}
|
|
472
560
|
if (entry.model) {
|
|
473
561
|
return {
|
|
@@ -475,6 +563,7 @@ function normaliseChain(chain) {
|
|
|
475
563
|
executionMode: typeof entry.executionMode === "string" ? entry.executionMode : null,
|
|
476
564
|
effort: normalizeChainEffort(entry.effort),
|
|
477
565
|
requires: entry.requires && typeof entry.requires === "object" ? entry.requires : null,
|
|
566
|
+
attempts: normalizeChainAttempts(entry.attempts),
|
|
478
567
|
};
|
|
479
568
|
}
|
|
480
569
|
return null;
|
|
@@ -482,6 +571,61 @@ function normaliseChain(chain) {
|
|
|
482
571
|
.filter(Boolean));
|
|
483
572
|
}
|
|
484
573
|
|
|
574
|
+
/**
|
|
575
|
+
* The kernel default is ONE attempt per entry. Enabling same-model retries is a
|
|
576
|
+
* host policy decision (`@mono-agent/config` supplies the product default), so
|
|
577
|
+
* the router stays mechanism and existing callers keep single-shot behavior.
|
|
578
|
+
* @param {*} attempts
|
|
579
|
+
* @returns {number}
|
|
580
|
+
*/
|
|
581
|
+
function normalizeChainAttempts(attempts) {
|
|
582
|
+
if (attempts === undefined || attempts === null) return 1;
|
|
583
|
+
if (!Number.isInteger(attempts) || attempts < 1 || attempts > 10) {
|
|
584
|
+
throw new Error("createRouterRuntime chain attempts must be an integer between 1 and 10");
|
|
585
|
+
}
|
|
586
|
+
return attempts;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* @param {Partial<RouterRetryPolicy>|undefined} retry
|
|
591
|
+
* @returns {RouterRetryPolicy}
|
|
592
|
+
*/
|
|
593
|
+
function normalizeRetryPolicy(retry) {
|
|
594
|
+
const backoffMs = normalizeRetryDelay(retry?.backoffMs, 1000, "backoffMs");
|
|
595
|
+
const maxBackoffMs = normalizeRetryDelay(retry?.maxBackoffMs, 15000, "maxBackoffMs");
|
|
596
|
+
return { backoffMs, maxBackoffMs };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** @param {*} value @param {number} fallback @param {string} name @returns {number} */
|
|
600
|
+
function normalizeRetryDelay(value, fallback, name) {
|
|
601
|
+
if (value === undefined || value === null) return fallback;
|
|
602
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
603
|
+
throw new Error(`createRouterRuntime retry.${name} must be a non-negative finite number`);
|
|
604
|
+
}
|
|
605
|
+
return value;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Abortable sleep. agent-runtime is the kernel and cannot reach the app-layer
|
|
610
|
+
* backoff helpers, so this mirrors the local `delay` in the codex bridge.
|
|
611
|
+
* @param {number} ms
|
|
612
|
+
* @param {AbortSignal} [signal]
|
|
613
|
+
* @returns {Promise<void>}
|
|
614
|
+
*/
|
|
615
|
+
function delay(ms, signal) {
|
|
616
|
+
if (!(ms > 0)) return Promise.resolve();
|
|
617
|
+
return new Promise((resolve) => {
|
|
618
|
+
const done = () => {
|
|
619
|
+
clearTimeout(timer);
|
|
620
|
+
signal?.removeEventListener("abort", done);
|
|
621
|
+
resolve();
|
|
622
|
+
};
|
|
623
|
+
const timer = setTimeout(done, ms);
|
|
624
|
+
/** @type {*} */ (timer).unref?.();
|
|
625
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
485
629
|
/** @param {*} effort @returns {string|null|undefined} */
|
|
486
630
|
function normalizeChainEffort(effort) {
|
|
487
631
|
if (effort === undefined || effort === null) return effort;
|