@mono-agent/agent-runtime 0.15.3 → 0.15.4

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 (36) hide show
  1. package/README.md +43 -6
  2. package/package.json +5 -1
  3. package/src/agent/tools/agent-tool.js +859 -0
  4. package/src/agent/tools/bash.js +241 -123
  5. package/src/agent/tools/exec.js +238 -0
  6. package/src/agent/tools/index.js +10 -3
  7. package/src/agent/tools/node-repl.js +231 -95
  8. package/src/agent/tools/pi-bridge.js +115 -24
  9. package/src/agent/tools/shared/process-runner.js +162 -0
  10. package/src/agent/tools/shared/semaphore.js +73 -0
  11. package/src/agent/tools/web-browser-render.js +221 -0
  12. package/src/agent/tools/web-controller.js +160 -0
  13. package/src/agent/tools/web-fetch.js +653 -68
  14. package/src/agent/tools/web-search.js +568 -16
  15. package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
  16. package/src/ai/providers/pi-native/turn-runner.js +60 -5
  17. package/src/ai/providers/pi-native.js +49 -5
  18. package/src/ai/runtime/router.js +302 -166
  19. package/src/ai/types.js +52 -1
  20. package/src/runtime.js +51 -1
  21. package/types/agent/tools/agent-tool.d.ts +60 -0
  22. package/types/agent/tools/bash.d.ts +55 -7
  23. package/types/agent/tools/exec.d.ts +53 -0
  24. package/types/agent/tools/index.d.ts +5 -3
  25. package/types/agent/tools/node-repl.d.ts +28 -3
  26. package/types/agent/tools/pi-bridge.d.ts +6 -2
  27. package/types/agent/tools/shared/process-runner.d.ts +33 -0
  28. package/types/agent/tools/shared/semaphore.d.ts +29 -0
  29. package/types/agent/tools/web-browser-render.d.ts +16 -0
  30. package/types/agent/tools/web-controller.d.ts +20 -0
  31. package/types/agent/tools/web-fetch.d.ts +74 -5
  32. package/types/agent/tools/web-search.d.ts +81 -5
  33. package/types/ai/providers/pi-native/turn-runner.d.ts +34 -2
  34. package/types/ai/providers/pi-native.d.ts +12 -0
  35. package/types/ai/runtime/router.d.ts +23 -3
  36. package/types/ai/types.d.ts +163 -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,248 @@ 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");
277
+ }
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}`;
235
321
  }
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
- );
322
+ }
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
+ emit(callOptions, {
339
+ type: "provider_failover_started",
340
+ from: modelKey(failoverHistory[failoverHistory.length - 1]?.model),
341
+ to: modelKey(entry.model),
342
+ attemptIndex: i,
343
+ });
344
+ }
345
+
346
+ let result;
347
+ try {
348
+ result = await attemptRuntime.run(attemptSystemPrompt, callOptions);
349
+ } catch (err) {
350
+ // The inner runtime usually surfaces errors as structured result
351
+ // fields, but a bridge can still throw synchronously (e.g. spawn
352
+ // failures). Convert to a result-like shape so the chain logic
353
+ // is uniform.
354
+ result = {
355
+ text: null,
356
+ error: err?.message || String(err),
357
+ failureKind: "provider_unavailable",
358
+ events: [],
359
+ cancelled: false,
360
+ usage: {},
361
+ };
362
+ } finally {
363
+ try { await attemptCleanup?.(); } catch { /* cleanup is additive */ }
364
+ }
365
+
366
+ result = normalizeProviderAuthFailure(result);
367
+
368
+ const retryability = retryableProviderFailureInfo({
369
+ errorText: result.error || "",
370
+ stderrTail: result.stderrTail || "",
371
+ failureKind: result.failureKind,
372
+ });
373
+
374
+ const successful = !result.error && !result.failureKind && !result.cancelled;
375
+ if (successful) {
376
+ // Only a genuine route change is a completed failover: succeeding
377
+ // after a same-model retry must not render as "answered by X
378
+ // (failover)" when X is still the route the operator asked for.
379
+ if (failoverHistory.some((attempt) => modelKey(attempt.model) !== modelKey(entry.model))) {
380
+ emit(callOptions, {
381
+ type: "provider_failover_completed",
382
+ attemptIndex: i,
383
+ model: entry.model,
384
+ });
248
385
  }
249
- } else if (resolution?.runtime !== undefined && resolution.runtime !== inner) {
250
- throw new Error("uniform route safety cannot replace the shared monotonic runtime");
386
+ return { ...result, failoverHistory, routeSafetyHistory };
251
387
  }
252
- } catch (error) {
253
- try { await attemptCleanup?.(); } catch { /* cleanup is additive */ }
254
- const failure = safetyUnavailableResult(error);
255
- lastRouteSkip = failure;
388
+
256
389
  failoverHistory.push({
257
390
  model: entry.model,
258
- failureKind: "safety_unavailable",
391
+ failureKind: result.failureKind || null,
392
+ requestId: retryability.requestId,
393
+ retryableSubkind: retryability.subkind,
394
+ ...(retryIndex > 0 ? { retryIndex } : {}),
259
395
  routeSafety,
260
396
  safetyContract,
261
397
  });
262
- const unavailableRecord = routeSafetyRecord(i, entry, safetyContract, "safety_unavailable");
263
- routeSafetyHistory.push(unavailableRecord);
264
- emit(callOptions, { type: "provider_route_safety", ...unavailableRecord });
265
- continue;
266
- }
398
+ if (result.failureKind === "skipped_capability_mismatch") {
399
+ lastRouteSkip = result;
400
+ // A bridge-level mismatch is about this route, not the logical run.
401
+ // Try the next entry and do not derive a transcript snapshot from it.
402
+ break;
403
+ }
404
+ lastResult = result;
267
405
 
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}`;
406
+ // Provider auth is terminal for one provider, but chain-retryable: a
407
+ // fallback provider may have working credentials. Other non-retryable
408
+ // provider/request errors remain terminal.
409
+ const shouldFallback = (retryability.retryable || result.failureKind === "provider_auth")
410
+ && !result.cancelled
411
+ && !isMidTurnSafetyFailure(result.failureKind);
412
+ if (!shouldFallback) {
413
+ terminalResult = result;
414
+ break;
290
415
  }
291
- }
292
416
 
293
- const safetyRecord = routeSafetyRecord(i, entry, safetyContract, "attempted");
294
- routeSafetyHistory.push(safetyRecord);
295
- emit(callOptions, { type: "provider_route_safety", ...safetyRecord });
417
+ // Build a transcript-tail snapshot from this run's events so the next
418
+ // attempt — same model or next route — can continue. A run that
419
+ // produced no usable events yields a falsy snapshot and merges to a
420
+ // no-op, so the common "died before the first token" retry costs
421
+ // nothing. Keep one bounded snapshot object across the logical run
422
+ // instead of nesting a new <resume_context> block per transition.
423
+ pendingSnapshot = mergeResumeSnapshots(
424
+ pendingSnapshot,
425
+ buildTranscriptTailSnapshot(result.events, { runtimeBrand }),
426
+ );
296
427
 
297
- if (failoverHistory.length > 0) {
428
+ // context_limit is forced retryable so the chain can reach a model with
429
+ // a bigger window, but it is deterministic against the SAME window:
430
+ // another attempt here is a guaranteed second failure. Advance instead.
431
+ const sameModelRetryable = retryability.retryable
432
+ && retryability.subkind !== "context_limit"
433
+ && retryIndex + 1 < entry.attempts;
434
+ if (!sameModelRetryable) break;
435
+
436
+ const backoffMs = Math.min(retryPolicy.maxBackoffMs, retryPolicy.backoffMs * (2 ** retryIndex));
298
437
  emit(callOptions, {
299
- type: "provider_failover_started",
300
- from: failoverHistory[failoverHistory.length - 1]?.model,
301
- to: entry.model,
438
+ type: "provider_retry_started",
439
+ model: modelKey(entry.model),
302
440
  attemptIndex: i,
441
+ retryIndex: retryIndex + 1,
442
+ attempts: entry.attempts,
443
+ delayMs: backoffMs,
444
+ reason: retryability.subkind || result.failureKind || null,
303
445
  });
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
- });
446
+ if (callOptions.abortSignal?.aborted) {
447
+ return { ...result, cancelled: true, failoverHistory, routeSafetyHistory };
448
+ }
449
+ await delay(backoffMs, callOptions.abortSignal);
450
+ if (callOptions.abortSignal?.aborted) {
451
+ return { ...result, cancelled: true, failoverHistory, routeSafetyHistory };
342
452
  }
343
- return { ...result, failoverHistory, routeSafetyHistory };
344
453
  }
345
454
 
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 };
455
+ if (terminalResult !== null) {
456
+ return { ...terminalResult, failoverHistory, routeSafetyHistory };
370
457
  }
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);
458
+ // Every other inner break falls through to the next chain entry.
379
459
  }
380
460
 
381
461
  const exhaustedResult = lastResult || lastRouteSkip || {
@@ -467,7 +547,7 @@ function normaliseChain(chain) {
467
547
  if (!entry) return null;
468
548
  if (entry.sdk && entry.model) {
469
549
  // ModelRef shorthand: { sdk, model, ... }
470
- return { model: entry, executionMode: null, effort: undefined, requires: null };
550
+ return { model: entry, executionMode: null, effort: undefined, requires: null, attempts: 1 };
471
551
  }
472
552
  if (entry.model) {
473
553
  return {
@@ -475,6 +555,7 @@ function normaliseChain(chain) {
475
555
  executionMode: typeof entry.executionMode === "string" ? entry.executionMode : null,
476
556
  effort: normalizeChainEffort(entry.effort),
477
557
  requires: entry.requires && typeof entry.requires === "object" ? entry.requires : null,
558
+ attempts: normalizeChainAttempts(entry.attempts),
478
559
  };
479
560
  }
480
561
  return null;
@@ -482,6 +563,61 @@ function normaliseChain(chain) {
482
563
  .filter(Boolean));
483
564
  }
484
565
 
566
+ /**
567
+ * The kernel default is ONE attempt per entry. Enabling same-model retries is a
568
+ * host policy decision (`@mono-agent/config` supplies the product default), so
569
+ * the router stays mechanism and existing callers keep single-shot behavior.
570
+ * @param {*} attempts
571
+ * @returns {number}
572
+ */
573
+ function normalizeChainAttempts(attempts) {
574
+ if (attempts === undefined || attempts === null) return 1;
575
+ if (!Number.isInteger(attempts) || attempts < 1 || attempts > 10) {
576
+ throw new Error("createRouterRuntime chain attempts must be an integer between 1 and 10");
577
+ }
578
+ return attempts;
579
+ }
580
+
581
+ /**
582
+ * @param {Partial<RouterRetryPolicy>|undefined} retry
583
+ * @returns {RouterRetryPolicy}
584
+ */
585
+ function normalizeRetryPolicy(retry) {
586
+ const backoffMs = normalizeRetryDelay(retry?.backoffMs, 1000, "backoffMs");
587
+ const maxBackoffMs = normalizeRetryDelay(retry?.maxBackoffMs, 15000, "maxBackoffMs");
588
+ return { backoffMs, maxBackoffMs };
589
+ }
590
+
591
+ /** @param {*} value @param {number} fallback @param {string} name @returns {number} */
592
+ function normalizeRetryDelay(value, fallback, name) {
593
+ if (value === undefined || value === null) return fallback;
594
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
595
+ throw new Error(`createRouterRuntime retry.${name} must be a non-negative finite number`);
596
+ }
597
+ return value;
598
+ }
599
+
600
+ /**
601
+ * Abortable sleep. agent-runtime is the kernel and cannot reach the app-layer
602
+ * backoff helpers, so this mirrors the local `delay` in the codex bridge.
603
+ * @param {number} ms
604
+ * @param {AbortSignal} [signal]
605
+ * @returns {Promise<void>}
606
+ */
607
+ function delay(ms, signal) {
608
+ if (!(ms > 0)) return Promise.resolve();
609
+ return new Promise((resolve) => {
610
+ const done = () => {
611
+ clearTimeout(timer);
612
+ signal?.removeEventListener("abort", done);
613
+ resolve();
614
+ };
615
+ const timer = setTimeout(done, ms);
616
+ /** @type {*} */ (timer).unref?.();
617
+ signal?.addEventListener("abort", done, { once: true });
618
+ });
619
+ }
620
+
485
621
  /** @param {*} effort @returns {string|null|undefined} */
486
622
  function normalizeChainEffort(effort) {
487
623
  if (effort === undefined || effort === null) return effort;
package/src/ai/types.js CHANGED
@@ -161,8 +161,13 @@
161
161
  * @property {RuntimeToolLimits} [toolLimits] Typed per-run tool-output limits (supported replacement for the deprecated `settings` tool keys).
162
162
  * @property {RuntimeCompactionPolicy} [compaction] Typed per-run compaction policy (supported replacement for the deprecated `settings` compaction keys).
163
163
  * @property {RuntimePromptOverrides} [prompts] Per-run prompt-fragment overrides (run wins over the host default).
164
+ * @property {{backend?: "auto"|"searxng"|"keyless", endpoint?: string}} [webSearchConfig] Run-scoped WebSearch backend configuration.
165
+ * @property {{render?: "never"|"auto", browserCommand?: string}} [webFetchConfig] Run-scoped WebFetch extraction/render configuration.
166
+ * @property {"sequential"|"safe-parallel"} [piToolExecutionMode] Pi built-in tool scheduling mode. Safe parallelism is the default.
167
+ * @property {"one-at-a-time"|"all"} [piToolParallelismMode] DEPRECATED. Compatibility alias mapped to piToolExecutionMode.
164
168
  * @property {Object} [settings] DEPRECATED. Legacy flat settings bag; consumed only as a per-group FALLBACK when the corresponding typed object (`toolLimits` / `compaction`) is absent. Consuming any key emits one `deprecated_settings_option` runtime_warning per run. Migrate via resolveRuntimePolicies (@mono-agent/runtime-adapter).
165
169
  * @property {Object} [nativeSubagents] Same-runtime teammate helpers exposed through native provider subagent surfaces.
170
+ * @property {RuntimeSubagentsOptions} [subagents] In-process `Agent` built-in: profiles, caps, and the nested-run callback.
166
171
  * @property {Object} [diagnosticsSeed] Set by createRouterRuntime (ai/runtime/router.js) with a `resume_snapshot` when
167
172
  * failing over mid-chain; a host-level coordinator may relay it forward (see agent/transcript.js), not read by any
168
173
  * bridge in this package today.
@@ -185,6 +190,52 @@
185
190
  * hub's emit). `systemPrompt` is passed positionally, not folded into this object.
186
191
  */
187
192
 
193
+ /**
194
+ * @typedef {Object} RuntimeSubagentDefinition
195
+ * One named subagent profile the `Agent` built-in can deploy.
196
+ * @property {string} name Model-visible identifier and the tool's `name` enum value.
197
+ * @property {string} description Model-visible: when to pick this profile.
198
+ * @property {string} systemPrompt Full system prompt for the child run.
199
+ * @property {RuntimeModelRef} [model] Absent inherits the parent's configured route.
200
+ * @property {string} [effort]
201
+ * @property {ReadonlyArray<string>} [allowedTools] Absent uses the safe read-only default set.
202
+ * @property {ReadonlyArray<string>} [disallowedTools]
203
+ * @property {Object<string, Object>} [mcpServers]
204
+ * @property {number} [maxTurns]
205
+ * @property {number} [timeoutMs]
206
+ */
207
+
208
+ /**
209
+ * @callback RuntimeSubagentRun
210
+ * Owning-layer callback that actually executes one child turn. The kernel
211
+ * supplies a self-run fallback so `createRuntime` works without host wiring;
212
+ * agent-app replaces it so subagent runs get the configured fallback chain,
213
+ * same-model retries, and run recording.
214
+ * @param {Object} request
215
+ * @returns {Promise<RuntimeResult>}
216
+ */
217
+
218
+ /**
219
+ * @typedef {Object} RuntimeInlineSubagentsOptions
220
+ * Policy for subagents the model authors at call time rather than picking from
221
+ * `definitions`. Absent suppresses authoring entirely.
222
+ * @property {boolean} [enabled] Only `false` turns authoring off.
223
+ * @property {ReadonlyArray<string>} [allowedTools] Ceiling on what an authored subagent may
224
+ * request. Absent means the safe read-only default set, never every built-in.
225
+ */
226
+
227
+ /**
228
+ * @typedef {Object} RuntimeSubagentsOptions
229
+ * @property {ReadonlyArray<RuntimeSubagentDefinition>} [definitions] Named profiles.
230
+ * @property {RuntimeInlineSubagentsOptions} [inline] Call-time authoring policy.
231
+ * @property {number} [maxConcurrent] In-flight subagents per parent turn. Default 5.
232
+ * @property {number} [maxPerTurn] Total Agent calls per parent turn. Default 20.
233
+ * @property {number} [maxTurns] Default per-subagent turn cap. Default 100.
234
+ * @property {number} [timeoutMs] Default per-subagent wall clock.
235
+ * @property {RuntimeSubagentRun} [run] Nested-run callback; absent uses the kernel self-run.
236
+ * @property {number} [depth] Kernel-owned. Absent/0 is the parent; >=1 suppresses the `Agent` tool.
237
+ */
238
+
188
239
  /**
189
240
  * @typedef {Object} RuntimeResult
190
241
  * @property {string|null} [text]
@@ -206,7 +257,7 @@
206
257
  * @property {Array<Object>} [runtimeWarnings]
207
258
  * @property {Object} [diagnostics]
208
259
  * @property {Object} [capabilitiesUsed]
209
- * @property {Array<{model: RuntimeModelRef, failureKind: (string|null), requestId?: (string|null), retryableSubkind?: (string|null), requirements?: Object, routeSafety?: RuntimeRouteSafetyMode, safetyContract?: RuntimeRouteSafetyContract}>} [failoverHistory] Set by createRouterRuntime (ai/runtime/router.js) on every failed/skipped attempt.
260
+ * @property {Array<{model: RuntimeModelRef, failureKind: (string|null), requestId?: (string|null), retryableSubkind?: (string|null), retryIndex?: number, requirements?: Object, routeSafety?: RuntimeRouteSafetyMode, safetyContract?: RuntimeRouteSafetyContract}>} [failoverHistory] Set by createRouterRuntime (ai/runtime/router.js) on every failed/skipped attempt.
210
261
  * @property {Array<{attemptIndex: number, model: RuntimeModelRef, routeSafety: RuntimeRouteSafetyMode, safetyContract: RuntimeRouteSafetyContract, status: string}>} [routeSafetyHistory] Bounded route-safety audit emitted by createRouterRuntime.
211
262
  */
212
263