@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.
Files changed (43) hide show
  1. package/MIGRATION.md +41 -13
  2. package/README.md +43 -6
  3. package/package.json +7 -3
  4. package/src/agent/tools/agent-tool.js +894 -0
  5. package/src/agent/tools/bash.js +241 -123
  6. package/src/agent/tools/exec.js +238 -0
  7. package/src/agent/tools/index.js +10 -3
  8. package/src/agent/tools/node-repl.js +231 -95
  9. package/src/agent/tools/pi-bridge.js +115 -24
  10. package/src/agent/tools/shared/process-runner.js +162 -0
  11. package/src/agent/tools/shared/semaphore.js +73 -0
  12. package/src/agent/tools/web-browser-render.js +221 -0
  13. package/src/agent/tools/web-controller.js +160 -0
  14. package/src/agent/tools/web-fetch.js +653 -68
  15. package/src/agent/tools/web-search.js +568 -16
  16. package/src/ai/pi-interop.js +7 -5
  17. package/src/ai/pi-oauth-compat.js +193 -0
  18. package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
  19. package/src/ai/providers/pi-native/turn-runner.js +73 -8
  20. package/src/ai/providers/pi-native.js +67 -7
  21. package/src/ai/runtime/router.js +310 -166
  22. package/src/ai/types.js +54 -2
  23. package/src/pi-auth.js +2 -2
  24. package/src/runtime.js +58 -1
  25. package/types/agent/tools/agent-tool.d.ts +80 -0
  26. package/types/agent/tools/bash.d.ts +55 -7
  27. package/types/agent/tools/exec.d.ts +53 -0
  28. package/types/agent/tools/index.d.ts +5 -3
  29. package/types/agent/tools/node-repl.d.ts +28 -3
  30. package/types/agent/tools/pi-bridge.d.ts +6 -2
  31. package/types/agent/tools/shared/process-runner.d.ts +33 -0
  32. package/types/agent/tools/shared/semaphore.d.ts +29 -0
  33. package/types/agent/tools/web-browser-render.d.ts +16 -0
  34. package/types/agent/tools/web-controller.d.ts +20 -0
  35. package/types/agent/tools/web-fetch.d.ts +74 -5
  36. package/types/agent/tools/web-search.d.ts +81 -5
  37. package/types/ai/pi-oauth-compat.d.ts +57 -0
  38. package/types/ai/providers/pi-native/turn-runner.d.ts +33 -2
  39. package/types/ai/providers/pi-native.d.ts +12 -0
  40. package/types/ai/runtime/router.d.ts +23 -3
  41. package/types/ai/types.d.ts +174 -4
  42. package/types/ai/backend.d.ts +0 -57
  43. package/types/ai/registry.d.ts +0 -1
@@ -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?}` form.
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
- let safetyContract = routeSafetyContract(
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, safetyContract, "skipped_capability_mismatch");
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
- let callOptions = {
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
- if (resolveAttempt === undefined && i > 0) {
201
- callOptions = withoutAttemptScopedOptions(callOptions);
202
- }
203
- /** @type {AgentRuntimeInstance} */
204
- let attemptRuntime = inner;
205
- /** @type {(() => (void|Promise<void>))|undefined} */
206
- let attemptCleanup;
207
- try {
208
- const resolved = resolveAttempt === undefined
209
- ? undefined
210
- : await resolveAttempt({
211
- model: entry.model,
212
- executionMode: entry.executionMode,
213
- attemptIndex: i,
214
- routeSafety,
215
- });
216
- const resolution = normalizeAttemptResolution(resolved);
217
- attemptCleanup = resolution?.cleanup;
218
- if (resolveAttempt !== undefined) {
219
- callOptions = mergeAttemptOptions(callOptions, resolution?.options);
220
- }
221
- if (routeSafety === "per-route-native") {
222
- callOptions = projectPerRouteNativeOptions(entry, callOptions);
223
- const key = routeRuntimeKey(entry, i);
224
- const resolvedRuntime = resolution?.runtime;
225
- if (resolvedRuntime !== undefined) {
226
- assertRuntimeLike(resolvedRuntime);
227
- const previousRuntime = routeRuntimes.get(key);
228
- if (previousRuntime !== undefined && previousRuntime !== resolvedRuntime) {
229
- try { await previousRuntime.disposeAllSessions?.(); } catch { /* best-effort replacement */ }
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.set(key, resolvedRuntime);
232
- if (entry.model.sdk !== "pi") {
233
- resolvedRuntime.configureTools?.(projectPerRouteNativeToolOptions(entry, configuredTools));
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
- attemptRuntime = routeRuntimes.get(key) ?? createRouteRuntime(key, entry, host, routeRuntimes, configuredTools);
237
- if (entry.model.sdk === "pi") {
238
- projectPiRuntimeToolContext(attemptRuntime, effectiveToolOptions);
239
- // Derive the attestation from the same complete base context and
240
- // request-scoped inputs that the supplied/runtime-owned Pi
241
- // bridge will actually receive. Resolver options cannot alter
242
- // these protected fields.
243
- safetyContract = routeSafetyContract(
244
- routeSafety,
245
- entry,
246
- effectivePiSandboxPolicy(effectiveToolOptions, callOptions),
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
- } catch (error) {
253
- try { await attemptCleanup?.(); } catch { /* cleanup is additive */ }
254
- const failure = safetyUnavailableResult(error);
255
- lastRouteSkip = failure;
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: "safety_unavailable",
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
- const unavailableRecord = routeSafetyRecord(i, entry, safetyContract, "safety_unavailable");
263
- routeSafetyHistory.push(unavailableRecord);
264
- emit(callOptions, { type: "provider_route_safety", ...unavailableRecord });
265
- continue;
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
- applyEntryEffort(callOptions, entry.effort);
269
- // A provider session belongs to the route that created it. The entire
270
- // chain is stateless whenever a fallback exists, keeping the full
271
- // logical run replayable regardless of which route is attempted.
272
- if (entries.length > 1 || i > 0 || !entrySupportsSessionResume(entry)) {
273
- delete callOptions.sessionId;
274
- delete callOptions.providerSessionId;
275
- delete callOptions.sessionKeepAlive;
276
- delete callOptions.sessionIdleTimeoutMs;
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
- const safetyRecord = routeSafetyRecord(i, entry, safetyContract, "attempted");
294
- routeSafetyHistory.push(safetyRecord);
295
- emit(callOptions, { type: "provider_route_safety", ...safetyRecord });
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
- if (failoverHistory.length > 0) {
444
+ const backoffMs = Math.min(retryPolicy.maxBackoffMs, retryPolicy.backoffMs * (2 ** retryIndex));
298
445
  emit(callOptions, {
299
- type: "provider_failover_started",
300
- from: failoverHistory[failoverHistory.length - 1]?.model,
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
- let result;
307
- try {
308
- result = await attemptRuntime.run(attemptSystemPrompt, callOptions);
309
- } catch (err) {
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
- failoverHistory.push({
347
- model: entry.model,
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;