@mono-agent/agent-runtime 0.6.2 → 0.8.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 (39) hide show
  1. package/README.md +36 -16
  2. package/package.json +14 -7
  3. package/src/agent/approval.js +52 -17
  4. package/src/agent/sandbox-seam.js +1 -0
  5. package/src/agent/tools/pi-bridge.js +2 -0
  6. package/src/agent/tools/shared/ripgrep.js +12 -8
  7. package/src/ai/index.js +8 -0
  8. package/src/ai/providers/claude-cli.js +109 -5
  9. package/src/ai/providers/claude-sandbox.js +71 -0
  10. package/src/ai/providers/claude-sdk-discovery-worker.js +53 -0
  11. package/src/ai/providers/claude-sdk-discovery.js +352 -0
  12. package/src/ai/providers/claude-sdk.js +313 -35
  13. package/src/ai/providers/codex-app.js +823 -78
  14. package/src/ai/providers/opencode-app.js +682 -96
  15. package/src/ai/providers/opencode-server.js +508 -0
  16. package/src/ai/runtime/capabilities.js +12 -0
  17. package/src/ai/runtime/context-windows.js +8 -0
  18. package/src/ai/runtime/registry.js +8 -2
  19. package/src/ai/runtime/router.js +627 -29
  20. package/src/ai/types.js +29 -2
  21. package/src/index.js +6 -0
  22. package/src/runtime.js +17 -1
  23. package/types/agent/approval.d.ts +4 -7
  24. package/types/agent/sandbox-seam.d.ts +5 -0
  25. package/types/ai/backend.d.ts +16 -0
  26. package/types/ai/index.d.ts +1 -0
  27. package/types/ai/providers/claude-cli.d.ts +116 -0
  28. package/types/ai/providers/claude-sandbox.d.ts +79 -0
  29. package/types/ai/providers/claude-sdk-discovery-worker.d.ts +1 -0
  30. package/types/ai/providers/claude-sdk-discovery.d.ts +97 -0
  31. package/types/ai/providers/claude-sdk.d.ts +81 -5
  32. package/types/ai/providers/codex-app.d.ts +11 -7
  33. package/types/ai/providers/opencode-app.d.ts +15 -16
  34. package/types/ai/providers/opencode-server.d.ts +20 -0
  35. package/types/ai/runtime/capabilities.d.ts +19 -0
  36. package/types/ai/runtime/context-windows.d.ts +1 -0
  37. package/types/ai/runtime/router.d.ts +24 -23
  38. package/types/ai/types.d.ts +75 -2
  39. package/types/index.d.ts +1 -0
@@ -18,8 +18,11 @@
18
18
  // so the router is a drop-in replacement for createRuntime(host).
19
19
  //
20
20
  // chain entries:
21
- // { model: ModelRef, executionMode?: "sdk" | "cli", requires?: Capabilities }
21
+ // { model: ModelRef, executionMode?: "sdk" | "cli", effort?: string|null,
22
+ // requires?: Capabilities }
22
23
  // shorthand: a bare ModelRef is also accepted (no requirements).
24
+ // effort string = fixed for that route, undefined = inherit the legacy run
25
+ // effort, null = omit effort so the provider chooses its default.
23
26
  //
24
27
  // Result:
25
28
  // The success run's result, with `failoverHistory` appended describing every
@@ -34,6 +37,7 @@ import { createRuntime } from "../../runtime.js";
34
37
  import { isProviderAuthFailureText, retryableProviderFailureInfo } from "../failure.js";
35
38
  import { runtimeCapabilities } from "./capabilities.js";
36
39
  import { buildTranscriptTailSnapshot, renderResumeSnapshot } from "../../agent/transcript.js";
40
+ import { passthroughSandbox } from "../../agent/sandbox-seam.js";
37
41
  import { resolveRuntimeBrand } from "../../runtime-brand.js";
38
42
 
39
43
  /**
@@ -47,9 +51,10 @@ import { resolveRuntimeBrand } from "../../runtime-brand.js";
47
51
  /**
48
52
  * @typedef {Object} RouterChainEntryInput
49
53
  * A chain entry as accepted by createRouterRuntime: either the shorthand bare
50
- * RuntimeModelRef, or the full `{model, executionMode?, requires?}` form.
54
+ * RuntimeModelRef, or the full `{model, executionMode?, effort?, requires?}` form.
51
55
  * @property {RuntimeModelRef} model
52
56
  * @property {string} [executionMode]
57
+ * @property {string|null} [effort]
53
58
  * @property {Object<string, *>} [requires]
54
59
  */
55
60
 
@@ -57,21 +62,54 @@ import { resolveRuntimeBrand } from "../../runtime-brand.js";
57
62
  * @typedef {Object} RouterChainEntry
58
63
  * @property {RuntimeModelRef} model
59
64
  * @property {string|null} executionMode
65
+ * @property {string|null|undefined} effort
60
66
  * @property {Object<string, *>|null} requires
61
67
  */
62
68
 
69
+ /**
70
+ * @typedef {Object} RouterAttemptResolution
71
+ * Private host seam for route-specific provider options/runtime ownership.
72
+ * Returned options are never copied into router telemetry.
73
+ * @property {AgentRuntimeInstance} [runtime]
74
+ * @property {Object<string, *>} [options]
75
+ * @property {() => (void|Promise<void>)} [cleanup]
76
+ */
77
+
78
+ const ROUTE_SAFETY_MODES = new Set(["uniform", "per-route-native"]);
79
+ const ATTEMPT_SCOPED_OPTION_KEYS = ["customProvider", "customModel", "modelCapabilities", "isPrivateProvider"];
80
+ const ROUTER_TOOL_CONTEXT_KEYS = [
81
+ "workspace", "repoRoot", "ripgrepPath", "qaOutputDir", "sandboxPolicy", "sandboxEngine",
82
+ ];
83
+ const RESOLVER_PROTECTED_OPTION_KEYS = new Set([
84
+ "model", "executionMode", "effort", "messages", "abortSignal", "onEvent",
85
+ "sessionId", "providerSessionId", "sessionKeepAlive", "sessionIdleTimeoutMs",
86
+ "diagnosticsSeed", "systemPromptPrefix", "sandboxPolicy", "sandboxEngine", "sandbox",
87
+ "allowedTools", "disallowedTools", "permissionMode", "mcpServers", "skills",
88
+ "outputSchema", "nativeSubagents", "liveInput", "fastMode",
89
+ ]);
90
+
63
91
  /**
64
92
  * @param {Object} [options]
65
93
  * @param {AgentRuntimeHostOptions} [options.host]
66
94
  * @param {ReadonlyArray<RuntimeModelRef|RouterChainEntryInput>} [options.chain]
95
+ * @param {"uniform"|"per-route-native"} [options.routeSafety]
96
+ * @param {(input: {model: RuntimeModelRef, executionMode: string|null, attemptIndex: number, routeSafety: "uniform"|"per-route-native"}) => (RouterAttemptResolution|Promise<RouterAttemptResolution>)} [options.resolveAttempt]
67
97
  * @returns {AgentRuntimeInstance & {chain: () => Array<RouterChainEntry>}}
68
98
  */
69
- export function createRouterRuntime({ host = {}, chain = [] } = {}) {
99
+ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "uniform", resolveAttempt } = {}) {
100
+ if (!ROUTE_SAFETY_MODES.has(routeSafety)) {
101
+ throw new Error("createRouterRuntime routeSafety must be uniform or per-route-native");
102
+ }
70
103
  const entries = normaliseChain(chain);
71
104
  if (entries.length === 0) {
72
105
  throw new Error("createRouterRuntime requires a non-empty chain");
73
106
  }
107
+ assertUniqueEntries(entries);
74
108
  const inner = createRuntime(host);
109
+ /** @type {Map<string, AgentRuntimeInstance>} */
110
+ const routeRuntimes = new Map();
111
+ /** @type {import('../types.js').AgentRuntimeToolOptions|undefined} */
112
+ let configuredTools;
75
113
  // The router builds transcript-tail snapshots outside the inner runtime's
76
114
  // bridge call (which is where the per-instance toolContext lives), so resolve
77
115
  // the host brand here to stamp the snapshot schema id with the same brand the
@@ -87,44 +125,155 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
87
125
  * @returns {Promise<RuntimeResult>}
88
126
  */
89
127
  async run(systemPrompt, options = {}) {
90
- /** @type {Array<{model: RuntimeModelRef, failureKind: (string|null), requestId?: (string|null|undefined), retryableSubkind?: (string|null|undefined), requirements?: (Object<string,*>|null)}>} */
128
+ /** @type {Array<{model: RuntimeModelRef, failureKind: (string|null), requestId?: (string|null|undefined), retryableSubkind?: (string|null|undefined), requirements?: (Object<string,*>|null), routeSafety?: import('../types.js').RuntimeRouteSafetyMode, safetyContract?: import('../types.js').RuntimeRouteSafetyContract}>} */
91
129
  const failoverHistory = [];
92
130
  /** @type {RuntimeResult|null} */
93
131
  let lastResult = null;
132
+ /** @type {RuntimeResult|null} */
133
+ let lastRouteSkip = null;
134
+ const promptBase = systemPrompt;
94
135
  /** @type {*} */
95
- let resumeSnapshot = null;
136
+ let pendingSnapshot = null;
137
+ /** @type {Array<{attemptIndex: number, model: RuntimeModelRef, routeSafety: import('../types.js').RuntimeRouteSafetyMode, safetyContract: import('../types.js').RuntimeRouteSafetyContract, status: string}>} */
138
+ const routeSafetyHistory = [];
96
139
 
97
140
  for (let i = 0; i < entries.length; i += 1) {
98
141
  const entry = entries[i];
142
+ const effectiveToolOptions = effectiveRouterToolOptions(host, configuredTools);
143
+ let safetyContract = routeSafetyContract(
144
+ routeSafety,
145
+ entry,
146
+ effectivePiSandboxPolicy(effectiveToolOptions, options),
147
+ );
99
148
  if (!entrySatisfiesRequirements(entry, options)) {
149
+ lastRouteSkip = {
150
+ text: null,
151
+ error: `Route ${modelKey(entry.model)} does not satisfy the logical run's required capabilities.`,
152
+ failureKind: "skipped_capability_mismatch",
153
+ events: [],
154
+ cancelled: false,
155
+ usage: {},
156
+ };
100
157
  failoverHistory.push({
101
158
  model: entry.model,
102
159
  failureKind: "skipped_capability_mismatch",
103
160
  requirements: entry.requires,
161
+ routeSafety,
162
+ safetyContract,
104
163
  });
164
+ const skippedRecord = routeSafetyRecord(i, entry, safetyContract, "skipped_capability_mismatch");
165
+ routeSafetyHistory.push(skippedRecord);
166
+ emit(options, { type: "provider_route_safety", ...skippedRecord });
105
167
  continue;
106
168
  }
107
169
 
108
- const callOptions = {
170
+ /** @type {*} */
171
+ let callOptions = {
109
172
  ...options,
110
173
  model: entry.model,
111
174
  executionMode: entry.executionMode || options.executionMode,
112
175
  };
113
- if (resumeSnapshot) {
176
+ // The legacy run-level custom-provider bag describes the primary
177
+ // route. Without a route resolver there is no authoritative metadata
178
+ // for a different fallback, so never let the primary's credentials or
179
+ // model capabilities contaminate later attempts.
180
+ if (resolveAttempt === undefined && i > 0) {
181
+ callOptions = withoutAttemptScopedOptions(callOptions);
182
+ }
183
+ /** @type {AgentRuntimeInstance} */
184
+ let attemptRuntime = inner;
185
+ /** @type {(() => (void|Promise<void>))|undefined} */
186
+ let attemptCleanup;
187
+ try {
188
+ const resolved = resolveAttempt === undefined
189
+ ? undefined
190
+ : await resolveAttempt({
191
+ model: entry.model,
192
+ executionMode: entry.executionMode,
193
+ attemptIndex: i,
194
+ routeSafety,
195
+ });
196
+ const resolution = normalizeAttemptResolution(resolved);
197
+ attemptCleanup = resolution?.cleanup;
198
+ if (resolveAttempt !== undefined) {
199
+ callOptions = mergeAttemptOptions(callOptions, resolution?.options);
200
+ }
201
+ if (routeSafety === "per-route-native") {
202
+ callOptions = projectPerRouteNativeOptions(entry, callOptions);
203
+ const key = routeRuntimeKey(entry, i);
204
+ const resolvedRuntime = resolution?.runtime;
205
+ if (resolvedRuntime !== undefined) {
206
+ assertRuntimeLike(resolvedRuntime);
207
+ const previousRuntime = routeRuntimes.get(key);
208
+ if (previousRuntime !== undefined && previousRuntime !== resolvedRuntime) {
209
+ try { await previousRuntime.disposeAllSessions?.(); } catch { /* best-effort replacement */ }
210
+ }
211
+ routeRuntimes.set(key, resolvedRuntime);
212
+ if (entry.model.sdk !== "pi") {
213
+ resolvedRuntime.configureTools?.(projectPerRouteNativeToolOptions(entry, configuredTools));
214
+ }
215
+ }
216
+ attemptRuntime = routeRuntimes.get(key) ?? createRouteRuntime(key, entry, host, routeRuntimes, configuredTools);
217
+ if (entry.model.sdk === "pi") {
218
+ projectPiRuntimeToolContext(attemptRuntime, effectiveToolOptions);
219
+ // Derive the attestation from the same complete base context and
220
+ // request-scoped inputs that the supplied/runtime-owned Pi
221
+ // bridge will actually receive. Resolver options cannot alter
222
+ // these protected fields.
223
+ safetyContract = routeSafetyContract(
224
+ routeSafety,
225
+ entry,
226
+ effectivePiSandboxPolicy(effectiveToolOptions, callOptions),
227
+ );
228
+ }
229
+ } else if (resolution?.runtime !== undefined && resolution.runtime !== inner) {
230
+ throw new Error("uniform route safety cannot replace the shared monotonic runtime");
231
+ }
232
+ } catch (error) {
233
+ try { await attemptCleanup?.(); } catch { /* cleanup is additive */ }
234
+ const failure = safetyUnavailableResult(error);
235
+ lastRouteSkip = failure;
236
+ failoverHistory.push({
237
+ model: entry.model,
238
+ failureKind: "safety_unavailable",
239
+ routeSafety,
240
+ safetyContract,
241
+ });
242
+ const unavailableRecord = routeSafetyRecord(i, entry, safetyContract, "safety_unavailable");
243
+ routeSafetyHistory.push(unavailableRecord);
244
+ emit(callOptions, { type: "provider_route_safety", ...unavailableRecord });
245
+ continue;
246
+ }
247
+
248
+ applyEntryEffort(callOptions, entry.effort);
249
+ // A provider session belongs to the route that created it. The entire
250
+ // chain is stateless whenever a fallback exists, keeping the full
251
+ // logical run replayable regardless of which route is attempted.
252
+ if (entries.length > 1 || i > 0 || !entrySupportsSessionResume(entry)) {
253
+ delete callOptions.sessionId;
254
+ delete callOptions.providerSessionId;
255
+ delete callOptions.sessionKeepAlive;
256
+ delete callOptions.sessionIdleTimeoutMs;
257
+ }
258
+ let attemptSystemPrompt = promptBase;
259
+ if (pendingSnapshot) {
114
260
  callOptions.diagnosticsSeed = {
115
261
  ...(callOptions.diagnosticsSeed || {}),
116
- resume_snapshot: resumeSnapshot,
262
+ resume_snapshot: pendingSnapshot,
117
263
  };
118
264
  // Also prepend the rendered snapshot to the system prompt so SDK
119
265
  // backends that don't read diagnosticsSeed still continue from the
120
- // previous attempt.
121
- const rendered = renderResumeSnapshot(resumeSnapshot);
266
+ const rendered = renderResumeSnapshot(pendingSnapshot);
122
267
  if (rendered) {
123
268
  callOptions.systemPromptPrefix = rendered;
124
- systemPrompt = `${rendered}\n\n${systemPrompt}`;
269
+ attemptSystemPrompt = `${rendered}\n\n${promptBase}`;
125
270
  }
126
271
  }
127
272
 
273
+ const safetyRecord = routeSafetyRecord(i, entry, safetyContract, "attempted");
274
+ routeSafetyHistory.push(safetyRecord);
275
+ emit(callOptions, { type: "provider_route_safety", ...safetyRecord });
276
+
128
277
  if (failoverHistory.length > 0) {
129
278
  emit(callOptions, {
130
279
  type: "provider_failover_started",
@@ -136,7 +285,7 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
136
285
 
137
286
  let result;
138
287
  try {
139
- result = await inner.run(systemPrompt, callOptions);
288
+ result = await attemptRuntime.run(attemptSystemPrompt, callOptions);
140
289
  } catch (err) {
141
290
  // The inner runtime usually surfaces errors as structured result
142
291
  // fields, but a bridge can still throw synchronously (e.g. spawn
@@ -150,6 +299,8 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
150
299
  cancelled: false,
151
300
  usage: {},
152
301
  };
302
+ } finally {
303
+ try { await attemptCleanup?.(); } catch { /* cleanup is additive */ }
153
304
  }
154
305
 
155
306
  result = normalizeProviderAuthFailure(result);
@@ -169,7 +320,7 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
169
320
  model: entry.model,
170
321
  });
171
322
  }
172
- return { ...result, failoverHistory };
323
+ return { ...result, failoverHistory, routeSafetyHistory };
173
324
  }
174
325
 
175
326
  failoverHistory.push({
@@ -177,26 +328,37 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
177
328
  failureKind: result.failureKind || null,
178
329
  requestId: retryability.requestId,
179
330
  retryableSubkind: retryability.subkind,
331
+ routeSafety,
332
+ safetyContract,
180
333
  });
334
+ if (result.failureKind === "skipped_capability_mismatch") {
335
+ lastRouteSkip = result;
336
+ // A bridge-level mismatch is about this route, not the logical run.
337
+ // Try the next entry and do not derive a transcript snapshot from it.
338
+ continue;
339
+ }
181
340
  lastResult = result;
182
341
 
183
342
  // Provider auth is terminal for one provider, but chain-retryable: a
184
343
  // fallback provider may have working credentials. Other non-retryable
185
344
  // provider/request errors remain terminal.
186
345
  const shouldFallback = (retryability.retryable || result.failureKind === "provider_auth")
187
- && !result.cancelled;
346
+ && !result.cancelled
347
+ && !isMidTurnSafetyFailure(result.failureKind);
188
348
  if (!shouldFallback) {
189
- return { ...result, failoverHistory };
349
+ return { ...result, failoverHistory, routeSafetyHistory };
190
350
  }
191
351
 
192
352
  // Build a transcript-tail snapshot from this run's events so the
193
353
  // next provider can continue. If the run produced no usable events,
194
354
  // skip the snapshot (the next attempt starts fresh).
195
355
  const snapshot = buildTranscriptTailSnapshot(result.events, { runtimeBrand });
196
- if (snapshot) resumeSnapshot = snapshot;
356
+ // Keep one bounded snapshot object across the logical run instead of
357
+ // nesting a new <resume_context> block on every provider transition.
358
+ pendingSnapshot = mergeResumeSnapshots(pendingSnapshot, snapshot);
197
359
  }
198
360
 
199
- const exhaustedResult = lastResult || {
361
+ const exhaustedResult = lastResult || lastRouteSkip || {
200
362
  text: null,
201
363
  events: [],
202
364
  error: "router chain exhausted with no executions",
@@ -208,17 +370,33 @@ export function createRouterRuntime({ host = {}, chain = [] } = {}) {
208
370
  ...exhaustedResult,
209
371
  failureKind: lastResult ? "provider_unavailable_exhausted" : exhaustedResult.failureKind,
210
372
  failoverHistory,
373
+ routeSafetyHistory,
211
374
  };
212
375
  },
213
376
  chain: () => entries.slice(),
214
- configureTools(next) {
215
- inner.configureTools?.(next);
377
+ configureTools(next = {}) {
378
+ configuredTools = { ...(configuredTools || {}), ...next };
379
+ if (routeSafety === "uniform") {
380
+ inner.configureTools?.(next);
381
+ return;
382
+ }
383
+ entries.forEach((entry, index) => {
384
+ const runtime = routeRuntimes.get(routeRuntimeKey(entry, index));
385
+ runtime?.configureTools?.(projectPerRouteNativeToolOptions(entry, next));
386
+ });
387
+ // `inner` is deliberately not configured in per-route-native mode: all
388
+ // attempts use their isolated route runtime, and applying one route's
389
+ // policy to this shared standby would defeat that isolation.
216
390
  },
217
391
  async disposeSession(providerSessionId) {
218
- return inner.disposeSession?.(providerSessionId);
392
+ let disposed = false;
393
+ for (const runtime of allRuntimes(inner, routeRuntimes)) {
394
+ disposed = Boolean(await runtime.disposeSession?.(providerSessionId)) || disposed;
395
+ }
396
+ return disposed;
219
397
  },
220
398
  async disposeAllSessions() {
221
- await inner.disposeAllSessions?.();
399
+ await Promise.all(allRuntimes(inner, routeRuntimes).map(async (runtime) => runtime.disposeAllSessions?.()));
222
400
  },
223
401
  };
224
402
  }
@@ -236,12 +414,13 @@ function normaliseChain(chain) {
236
414
  if (!entry) return null;
237
415
  if (entry.sdk && entry.model) {
238
416
  // ModelRef shorthand: { sdk, model, ... }
239
- return { model: entry, executionMode: null, requires: null };
417
+ return { model: entry, executionMode: null, effort: undefined, requires: null };
240
418
  }
241
419
  if (entry.model) {
242
420
  return {
243
421
  model: entry.model,
244
422
  executionMode: typeof entry.executionMode === "string" ? entry.executionMode : null,
423
+ effort: normalizeChainEffort(entry.effort),
245
424
  requires: entry.requires && typeof entry.requires === "object" ? entry.requires : null,
246
425
  };
247
426
  }
@@ -250,6 +429,388 @@ function normaliseChain(chain) {
250
429
  .filter(Boolean));
251
430
  }
252
431
 
432
+ /** @param {*} effort @returns {string|null|undefined} */
433
+ function normalizeChainEffort(effort) {
434
+ if (effort === undefined || effort === null) return effort;
435
+ if (typeof effort !== "string" || effort.length === 0 || effort.trim() !== effort) {
436
+ throw new Error("createRouterRuntime chain effort must be a non-empty trimmed string, null, or omitted");
437
+ }
438
+ return effort;
439
+ }
440
+
441
+ /** @param {Array<RouterChainEntry>} entries */
442
+ function assertUniqueEntries(entries) {
443
+ const seen = new Map();
444
+ entries.forEach((entry, index) => {
445
+ const key = modelKey(entry.model);
446
+ const first = seen.get(key);
447
+ if (first !== undefined) {
448
+ throw new Error(`createRouterRuntime duplicate model ${key} at chain entries ${first} and ${index}`);
449
+ }
450
+ seen.set(key, index);
451
+ });
452
+ }
453
+
454
+ /** @param {RuntimeModelRef} model */
455
+ function modelKey(model) {
456
+ const provider = typeof model.provider === "string" && model.provider.length > 0 ? `${model.provider}:` : "";
457
+ return `${model.sdk}:${provider}${model.model}`;
458
+ }
459
+
460
+ /**
461
+ * A small fixed vocabulary keeps safety telemetry bounded and prevents route
462
+ * credentials/options from accidentally entering events or persisted results.
463
+ * @param {"uniform"|"per-route-native"} mode
464
+ * @param {RouterChainEntry} entry
465
+ * @param {Object<string, *>|undefined} piSandboxPolicy
466
+ * @returns {import('../types.js').RuntimeRouteSafetyContract}
467
+ */
468
+ function routeSafetyContract(mode, entry, piSandboxPolicy) {
469
+ if (mode === "uniform") {
470
+ return {
471
+ mode,
472
+ sandbox: "mono-agent-monotonic",
473
+ tools: "mono-agent-monotonic",
474
+ };
475
+ }
476
+ switch (entry.model.sdk) {
477
+ case "pi":
478
+ return {
479
+ mode,
480
+ sandbox: piSandboxContract(piSandboxPolicy),
481
+ tools: "mono-agent-policy",
482
+ };
483
+ case "claude":
484
+ return { mode, sandbox: "provider-native", tools: "provider-representable" };
485
+ case "codex":
486
+ return { mode, sandbox: "codex-native", tools: "exact-allow-all" };
487
+ case "opencode":
488
+ return { mode, sandbox: "provider-native", tools: "exact-allow-all" };
489
+ default:
490
+ return { mode, sandbox: "unsupported", tools: "unsupported" };
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Describe the Pi sandbox posture without claiming that SRT is enforced when
496
+ * the effective policy explicitly permits an unavailable engine to fall back
497
+ * to an unsandboxed host process. Both fields are required because the runtime
498
+ * adapter treats an unsafe fallback without its explicit opt-in as fail-closed.
499
+ *
500
+ * @param {Object<string, *>|undefined} policy
501
+ * @returns {import('../types.js').RuntimeRouteSandboxContract}
502
+ */
503
+ function piSandboxContract(policy) {
504
+ if (policy === undefined) return "disabled";
505
+ if (policy.fallback === "unsafe-host-process" && policy.unsafeAllowHostProcess === true) {
506
+ return "mono-agent-srt-unsafe-host-fallback";
507
+ }
508
+ return "mono-agent-srt";
509
+ }
510
+
511
+ /**
512
+ * Describe the policy Pi tools actually receive after host/configure/run
513
+ * precedence. A request-scoped `off` policy cannot weaken an active host
514
+ * policy, while an active request policy can tighten an absent/off host.
515
+ * `configureTools({ sandboxPolicy: undefined })` explicitly clears the host
516
+ * tool-context policy and must therefore be distinguished from an omitted key.
517
+ *
518
+ * @param {import('../types.js').AgentRuntimeToolOptions} toolOptions
519
+ * @param {Object<string, *>} runOptions
520
+ * @returns {Object<string, *>|undefined}
521
+ */
522
+ function effectivePiSandboxPolicy(toolOptions, runOptions) {
523
+ // Match the Pi turn runner's implementation precedence: a run-scoped
524
+ // RuntimeSandbox wins, followed by configureTools/host, then the kernel's
525
+ // fail-closed passthrough. Delegating the merge is essential here: the real
526
+ // adapter makes fail-closed dominate unsafe-host-process, so inspecting only
527
+ // the first non-off policy would produce false safety telemetry.
528
+ const sandbox = runOptions.sandbox
529
+ ?? toolOptions.sandbox
530
+ ?? passthroughSandbox;
531
+ const policy = sandbox.mergePolicies(toolOptions.sandboxPolicy, runOptions.sandboxPolicy);
532
+ return policy && typeof policy === "object" && policy.mode !== "off"
533
+ ? policy
534
+ : undefined;
535
+ }
536
+
537
+ /**
538
+ * Resolve the router-owned base ToolContext exactly as createRuntime(host)
539
+ * followed by the router's configureTools calls would. Every data key is
540
+ * present so projecting into a resolver-supplied runtime also clears hidden
541
+ * state. RuntimeSandbox is special: configureTools intentionally ignores an
542
+ * undefined implementation, so the configured value only replaces the host
543
+ * seam when it is concrete.
544
+ *
545
+ * @param {AgentRuntimeHostOptions} host
546
+ * @param {import('../types.js').AgentRuntimeToolOptions|undefined} configuredTools
547
+ * @returns {import('../types.js').AgentRuntimeToolOptions}
548
+ */
549
+ function effectiveRouterToolOptions(host, configuredTools) {
550
+ /** @type {Object<string, *>} */
551
+ const effective = {};
552
+ for (const key of ROUTER_TOOL_CONTEXT_KEYS) {
553
+ effective[key] = configuredTools !== undefined && Object.hasOwn(configuredTools, key)
554
+ ? configuredTools[key]
555
+ : host[key];
556
+ }
557
+ effective.sandbox = configuredTools?.sandbox
558
+ ?? host.sandbox
559
+ ?? passthroughSandbox;
560
+ return effective;
561
+ }
562
+
563
+ /**
564
+ * A resolver-supplied Pi runtime is allowed to own credentials/provider
565
+ * lifecycle, never the route's safety posture. Replace its complete mutable
566
+ * ToolContext before every execution so a blank, stale, or weaker runtime
567
+ * cannot diverge from the router's host/configured/run policy or telemetry.
568
+ *
569
+ * @param {AgentRuntimeInstance} runtime
570
+ * @param {import('../types.js').AgentRuntimeToolOptions} toolOptions
571
+ */
572
+ function projectPiRuntimeToolContext(runtime, toolOptions) {
573
+ if (typeof runtime.configureTools !== "function") {
574
+ throw new Error("per-route-native Pi runtime must expose configureTools() for safety projection");
575
+ }
576
+ runtime.configureTools(toolOptions);
577
+ }
578
+
579
+ /**
580
+ * @param {number} attemptIndex
581
+ * @param {RouterChainEntry} entry
582
+ * @param {import('../types.js').RuntimeRouteSafetyContract} contract
583
+ * @param {string} status
584
+ * @returns {{attemptIndex: number, model: RuntimeModelRef, routeSafety: import('../types.js').RuntimeRouteSafetyMode, safetyContract: import('../types.js').RuntimeRouteSafetyContract, status: string}}
585
+ */
586
+ function routeSafetyRecord(attemptIndex, entry, contract, status) {
587
+ return {
588
+ attemptIndex,
589
+ model: entry.model,
590
+ routeSafety: contract.mode,
591
+ safetyContract: contract,
592
+ status,
593
+ };
594
+ }
595
+
596
+ /** @param {RouterAttemptResolution|undefined} value */
597
+ function normalizeAttemptResolution(value) {
598
+ if (value === undefined) return undefined;
599
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
600
+ throw new Error("route attempt resolver must return an object or undefined");
601
+ }
602
+ if (value.options !== undefined && (value.options === null || typeof value.options !== "object" || Array.isArray(value.options))) {
603
+ throw new Error("route attempt resolver options must be an object");
604
+ }
605
+ if (value.cleanup !== undefined && typeof value.cleanup !== "function") {
606
+ throw new Error("route attempt resolver cleanup must be a function");
607
+ }
608
+ return value;
609
+ }
610
+
611
+ /**
612
+ * Removes credentials and model metadata belonging to the previous route,
613
+ * then applies the current route's private options. Logical request, safety,
614
+ * effort, and session fields remain router-owned.
615
+ * @param {Object<string, *>} base
616
+ * @param {Object<string, *>|undefined} resolved
617
+ */
618
+ function mergeAttemptOptions(base, resolved) {
619
+ const merged = withoutAttemptScopedOptions(base);
620
+ if (resolved === undefined) return merged;
621
+ for (const [key, value] of Object.entries(resolved)) {
622
+ if (RESOLVER_PROTECTED_OPTION_KEYS.has(key)) {
623
+ throw new Error(`route attempt resolver cannot override ${key}`);
624
+ }
625
+ if (value !== undefined) merged[key] = value;
626
+ }
627
+ return merged;
628
+ }
629
+
630
+ /**
631
+ * @param {Object<string, *>} options
632
+ * @returns {Object<string, *>}
633
+ */
634
+ function withoutAttemptScopedOptions(options) {
635
+ const projected = { ...options };
636
+ for (const key of ATTEMPT_SCOPED_OPTION_KEYS) delete projected[key];
637
+ return projected;
638
+ }
639
+
640
+ /**
641
+ * Explicit mixed-route projection. Capability-bearing inputs (MCP, skills,
642
+ * schema, live input, native subagents) are never removed here; the capability
643
+ * gate either proves support or skips the route before execution.
644
+ * @param {RouterChainEntry} entry
645
+ * @param {Object<string, *>} options
646
+ */
647
+ function projectPerRouteNativeOptions(entry, options) {
648
+ const projected = { ...options };
649
+ switch (entry.model.sdk) {
650
+ case "pi":
651
+ return projected;
652
+ case "claude":
653
+ delete projected.sandboxPolicy;
654
+ delete projected.sandboxEngine;
655
+ return projected;
656
+ case "codex":
657
+ case "opencode":
658
+ delete projected.sandboxPolicy;
659
+ delete projected.sandboxEngine;
660
+ projected.allowedTools = ["*"];
661
+ projected.disallowedTools = [];
662
+ return projected;
663
+ default:
664
+ throw new Error(`per-route-native safety has no contract for sdk ${entry.model.sdk}`);
665
+ }
666
+ }
667
+
668
+ /**
669
+ * @param {string} key
670
+ * @param {RouterChainEntry} entry
671
+ * @param {AgentRuntimeHostOptions} host
672
+ * @param {Map<string, AgentRuntimeInstance>} runtimes
673
+ * @param {import('../types.js').AgentRuntimeToolOptions|undefined} configuredTools
674
+ */
675
+ function createRouteRuntime(key, entry, host, runtimes, configuredTools) {
676
+ const runtime = createRuntime(projectPerRouteNativeHost(entry, host));
677
+ if (configuredTools !== undefined) {
678
+ runtime.configureTools?.(projectPerRouteNativeToolOptions(entry, configuredTools));
679
+ }
680
+ runtimes.set(key, runtime);
681
+ return runtime;
682
+ }
683
+
684
+ /**
685
+ * Provider-native routes retain the injected sandbox implementation seam, but
686
+ * must never inherit mono-agent policy data or a concrete srt engine. Assigning
687
+ * explicit `undefined` values (rather than deleting the keys) also clears a
688
+ * previously configured runtime when configureTools is called again.
689
+ * @param {RouterChainEntry} entry
690
+ * @param {import('../types.js').AgentRuntimeToolOptions|undefined} configuredTools
691
+ * @returns {import('../types.js').AgentRuntimeToolOptions}
692
+ */
693
+ function projectPerRouteNativeToolOptions(entry, configuredTools = {}) {
694
+ const projected = { ...configuredTools };
695
+ if (entry.model.sdk !== "pi") {
696
+ projected.sandboxPolicy = undefined;
697
+ projected.sandboxEngine = undefined;
698
+ }
699
+ return projected;
700
+ }
701
+
702
+ /**
703
+ * Host-level policy must be isolated alongside request-level policy. The
704
+ * sandbox implementation itself remains available; only enforcing policy data
705
+ * and its route-specific engine are removed for provider-native bridges.
706
+ * @param {RouterChainEntry} entry
707
+ * @param {AgentRuntimeHostOptions} host
708
+ */
709
+ function projectPerRouteNativeHost(entry, host) {
710
+ if (entry.model.sdk === "pi") return host;
711
+ const projected = { ...host };
712
+ delete projected.sandboxPolicy;
713
+ delete projected.sandboxEngine;
714
+ return projected;
715
+ }
716
+
717
+ /** @param {RouterChainEntry} entry @param {number} index */
718
+ function routeRuntimeKey(entry, index) {
719
+ return `${index}:${modelKey(entry.model)}:${entry.executionMode ?? "default"}`;
720
+ }
721
+
722
+ /** @param {AgentRuntimeInstance} runtime */
723
+ function assertRuntimeLike(runtime) {
724
+ if (runtime === null || typeof runtime !== "object" || typeof runtime.run !== "function") {
725
+ throw new Error("route attempt resolver runtime must expose run()");
726
+ }
727
+ }
728
+
729
+ /** @param {Object<string, *>} options @param {string|null|undefined} effort */
730
+ function applyEntryEffort(options, effort) {
731
+ if (effort === null) {
732
+ delete options.effort;
733
+ } else if (typeof effort === "string") {
734
+ options.effort = effort;
735
+ }
736
+ }
737
+
738
+ /** @param {unknown} error @returns {RuntimeResult} */
739
+ function safetyUnavailableResult(error) {
740
+ // Host resolvers may handle credentials. Never echo their exception text
741
+ // into persisted results or route telemetry.
742
+ void error;
743
+ return {
744
+ text: null,
745
+ error: "The route safety contract could not be established before execution.",
746
+ failureKind: "safety_unavailable",
747
+ events: [],
748
+ cancelled: false,
749
+ usage: {},
750
+ };
751
+ }
752
+
753
+ /** @param {string|null|undefined} failureKind */
754
+ function isMidTurnSafetyFailure(failureKind) {
755
+ return typeof failureKind === "string"
756
+ && (failureKind.startsWith("sandbox_") || failureKind.startsWith("safety_"));
757
+ }
758
+
759
+ /**
760
+ * @param {AgentRuntimeInstance} inner
761
+ * @param {Map<string, AgentRuntimeInstance>} routeRuntimes
762
+ */
763
+ function allRuntimes(inner, routeRuntimes) {
764
+ return [...new Set([inner, ...routeRuntimes.values()])];
765
+ }
766
+
767
+ /**
768
+ * Merge progress into one bounded snapshot so prompts never accumulate nested
769
+ * resume blocks across a long provider chain.
770
+ * @param {*} previous
771
+ * @param {*} next
772
+ */
773
+ function mergeResumeSnapshots(previous, next) {
774
+ if (!next) return previous || null;
775
+ if (!previous) return next;
776
+ const previousTurns = Array.isArray(previous.turns) ? previous.turns : [];
777
+ const nextTurns = Array.isArray(next.turns) ? next.turns : [];
778
+ const allTurns = [...previousTurns, ...nextTurns];
779
+ const turns = allTurns.slice(-3);
780
+ const dropped = allTurns.slice(0, Math.max(0, allTurns.length - turns.length));
781
+ const existingSummaries = [
782
+ ...(Array.isArray(previous.earlier_turn_summaries) ? previous.earlier_turn_summaries : []),
783
+ ...(Array.isArray(next.earlier_turn_summaries) ? next.earlier_turn_summaries : []),
784
+ ].map((entry) => String(entry?.summary ?? "").slice(0, 320)).filter(Boolean);
785
+ const droppedSummaries = dropped.map(summarizeSnapshotTurn);
786
+ const summaries = [...existingSummaries, ...droppedSummaries].slice(-9);
787
+ const turnCount = Math.max(
788
+ turns.length + summaries.length,
789
+ Number(previous.turn_count || 0) + Number(next.turn_count || 0),
790
+ );
791
+ return {
792
+ ...next,
793
+ turn_count: turnCount,
794
+ earlier_turn_summaries: summaries.map((summary, index) => ({
795
+ turn_index: Math.max(1, turnCount - turns.length - summaries.length + index + 1),
796
+ summary,
797
+ })),
798
+ turns,
799
+ };
800
+ }
801
+
802
+ /** @param {*} turn */
803
+ function summarizeSnapshotTurn(turn) {
804
+ const assistant = typeof turn?.assistant_text === "string" ? turn.assistant_text.trim() : "";
805
+ const tools = Array.isArray(turn?.tool_uses)
806
+ ? turn.tool_uses.map((tool) => tool?.name).filter(Boolean).slice(0, 5)
807
+ : [];
808
+ const pieces = [];
809
+ if (assistant) pieces.push(assistant.split(/\r?\n/u)[0].slice(0, 220));
810
+ if (tools.length > 0) pieces.push(`tools: ${tools.join(", ")}`);
811
+ return (pieces.join("; ") || "provider attempt made progress").slice(0, 320);
812
+ }
813
+
253
814
  /**
254
815
  * @param {RouterChainEntry} entry
255
816
  * @param {Partial<RuntimeRunOptions>} options
@@ -263,18 +824,39 @@ function entrySatisfiesRequirements(entry, options) {
263
824
  // one today) still respects option-implied capability needs. Each option
264
825
  // inference defers to an explicit entry pin, never overriding it.
265
826
  const effectiveRequires = { ...(requires || null) };
266
- // Honour request-time outputSchema require structured_output unless the
267
- // entry already pins it.
268
- if (options.outputSchema && effectiveRequires.structured_output === undefined) {
827
+ // Infer required capabilities from request-time options. These requirements
828
+ // override a contradictory entry pin (`requires: false`): the caller's actual
829
+ // request cannot be silently weakened. Empty JSON Schema `{}` still counts.
830
+ if (
831
+ options.outputSchema !== undefined
832
+ && options.outputSchema !== null
833
+ ) {
269
834
  effectiveRequires.structured_output = true;
270
835
  }
271
- // Honour native-subagent teammates → require supports_native_subagents unless
272
- // the entry already pins it. A pi entry (supports_native_subagents:false) then
273
- // fails here rather than silently dropping the teammates.
836
+ if (
837
+ options.mcpServers !== undefined
838
+ && options.mcpServers !== null
839
+ && Object.keys(options.mcpServers).length > 0
840
+ ) {
841
+ effectiveRequires.supports_mcp = true;
842
+ }
843
+ if (
844
+ Array.isArray(options.skills)
845
+ && options.skills.length > 0
846
+ ) {
847
+ effectiveRequires.supports_skills = true;
848
+ }
849
+ if (options.liveInput) {
850
+ effectiveRequires.supports_live_input = true;
851
+ }
852
+ if (options.fastMode === true) {
853
+ effectiveRequires.supports_fast_mode = true;
854
+ }
855
+ // Native teammates likewise require a capable route; Pi/OpenCode must skip
856
+ // rather than silently dropping them.
274
857
  if (
275
858
  Array.isArray(options.nativeSubagents?.teammates)
276
859
  && options.nativeSubagents.teammates.length > 0
277
- && effectiveRequires.supports_native_subagents === undefined
278
860
  ) {
279
861
  effectiveRequires.supports_native_subagents = true;
280
862
  }
@@ -291,6 +873,22 @@ function entrySatisfiesRequirements(entry, options) {
291
873
  return true;
292
874
  }
293
875
 
876
+ /**
877
+ * Session identifiers belong to the bridge that created them. Never forward
878
+ * one into a bridge that declares no resume support (notably isolated
879
+ * per-run OpenCode), including when that bridge is reached through fallback.
880
+ * Unknown SDKs retain the existing fail-later behavior.
881
+ * @param {RouterChainEntry} entry
882
+ * @returns {boolean}
883
+ */
884
+ function entrySupportsSessionResume(entry) {
885
+ try {
886
+ return runtimeCapabilities(entry.model).supports_session_resume === true;
887
+ } catch {
888
+ return true;
889
+ }
890
+ }
891
+
294
892
  /**
295
893
  * @param {Partial<RuntimeRunOptions>} callOptions
296
894
  * @param {import('../types.js').RuntimeEvent} event