@mono-agent/agent-runtime 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MIGRATION.md CHANGED
@@ -42,8 +42,8 @@ the configuration schema.
42
42
  switch to `listPiBuiltinModels`, `getPiBuiltinModel`,
43
43
  `reasoningLevelsForPiModel`, `resolvePiOAuthApiKey`, and `loginPiOAuth` from
44
44
  `@mono-agent/agent-runtime/ai`. The runtime keeps Pi AI and Pi Agent Core
45
- exact-pinned at `0.80.6`; the façade returns cloned model and credential
46
- snapshots rather than exposing mutable upstream registries.
45
+ exact-pinned at `0.83.0`; the façade returns cloned model and credential
46
+ snapshots rather than exposing upstream provider objects.
47
47
  - **Claude test seam:** downstream tests should pass
48
48
  `RuntimeRunOptions.claudeAgentQuery` instead of mocking
49
49
  `@anthropic-ai/claude-agent-sdk` by package name. Normal runs omit this option
@@ -63,12 +63,27 @@ the configuration schema.
63
63
  authoritative and the runtime emits a bounded
64
64
  `live_input_callback_failed` warning.
65
65
 
66
- ## 0.15.x baseline
66
+ ## 0.16.x baseline
67
67
 
68
68
  This is the current published baseline for the detailed pre-1.0 reference
69
- below. It includes the explicit exports map, the five-bridge lazy registry,
70
- typed runtime policies, runtime-owned provider dependencies, and the
71
- public-surface cleanup described in this guide.
69
+ below. It carries the whole 0.15.x contract forward and adds:
70
+
71
+ - `skills` and `skillsRoot` on the run options. `skills` is the disclosed
72
+ `{name, description}` set for a run; a non-empty value makes `supports_skills`
73
+ a routing requirement, so a chain entry lacking it is skipped. `skillsRoot`
74
+ names the directory holding `<name>/SKILL.md` and is required alongside
75
+ `skills` for `ReadSkill` to exist. A subagent run now inherits both from its
76
+ parent unless a host-supplied `run` withholds them, so a child no longer has
77
+ to rediscover by trial and error what its parent could look up.
78
+ - Provider failover detail reaches whoever is watching the run rather than being
79
+ flattened at the boundary.
80
+ - Pi SDK 0.83.0.
81
+
82
+ ## 0.15.x
83
+
84
+ - The explicit exports map, the five-bridge lazy registry, typed runtime
85
+ policies, runtime-owned provider dependencies, and the public-surface cleanup
86
+ described in this guide.
72
87
 
73
88
  ## 0.12.x
74
89
 
@@ -313,9 +328,22 @@ falls back to its own env vars, exactly as returning `undefined` from the old ho
313
328
  did). **No host action needed** — `resolvePiApiKey` behaves as before.
314
329
 
315
330
  Dependency bump: **`@earendil-works/pi-ai` and `@earendil-works/pi-agent-core` are
316
- now `0.80.6`** (the initial Pi 0.80 migration landed at `0.80.5`, from
317
- `^0.79.1`). Compaction is driven natively (section 3). The `0.80.6` refresh also
318
- preserves model-native `max` reasoning and Pi's request-wide pricing tiers.
331
+ now `0.83.0`** (the initial Pi 0.80 migration landed at `0.80.5`, from
332
+ `^0.79.1`, and ran at `0.80.6` until the 0.83 upgrade). Compaction is driven
333
+ natively (section 3), and model-native `max` reasoning plus Pi's request-wide
334
+ pricing tiers are preserved.
335
+
336
+ The 0.83 upgrade carries two upstream removals, both absorbed inside the runtime
337
+ so hosts need no action:
338
+
339
+ - `@earendil-works/pi-ai/oauth` became a type-only entry point. The generic
340
+ registry (`getOAuthApiKey`, `getOAuthProvider`, `getOAuthProviders`) is gone,
341
+ and the per-provider flows are not importable. `src/ai/pi-oauth-compat.js`
342
+ rebuilds the same contracts over `provider.auth.oauth`, so `resolvePiApiKey`,
343
+ `resolvePiOAuthApiKey`, and `loginPiOAuth` keep their existing signatures and
344
+ behaviour, including the refresh-on-expiry trigger.
345
+ - `AgentHarnessOptions.env` was removed in favour of a per-turn `toolContext`.
346
+ The runtime passes neither: it uses none of Pi's built-in file/shell tools.
319
347
 
320
348
  ### 11. Exports map: wildcards removed (explicit deep-path map)
321
349
 
@@ -366,7 +394,7 @@ a compatibility subpath.
366
394
 
367
395
  ## Version
368
396
 
369
- This guide describes the published `0.15.x` package contract. Keep
397
+ This guide describes the published `0.16.x` package contract. Keep
370
398
  `@mono-agent/agent-runtime`, `@mono-agent/runtime-adapter`, and other
371
399
  `@mono-agent/*` packages on the same lockstep version when upgrading. The paired
372
400
  runtime adapter no longer exposes `piReasoningSummary` in its run-options type.
@@ -387,14 +415,14 @@ Worklab's runtime fork:
387
415
  `@earendil-works/pi-ai`, its separate Pi version constraint, and local copies
388
416
  of provider bridge code. Move tests off Pi's faux-provider helpers too; until
389
417
  that is complete, isolate the fixture or pin its development-only Pi
390
- dependency to exact `0.80.6` rather than a floating range. Do not restore the
418
+ dependency to exact `0.83.0` rather than a floating range. Do not restore the
391
419
  removed `pi-sdk.js` subpath.
392
420
  3. **Use the public Pi surfaces.** Run models through
393
421
  `generatePiNativeResponse` or the runtime registry. Use
394
422
  `listPiBuiltinModels`, `getPiBuiltinModel`,
395
423
  `reasoningLevelsForPiModel`, `resolvePiOAuthApiKey`, and `loginPiOAuth` for
396
- catalog and OAuth integration. Those façades keep Pi mutable state and the
397
- exact `0.80.6` compatibility pin inside the runtime. OAuth login adapters
424
+ catalog and OAuth integration. Those façades keep Pi provider objects and the
425
+ exact `0.83.0` compatibility pin inside the runtime. OAuth login adapters
398
426
  must supply `onAuth`, `onDeviceCode`, `onPrompt`, and `onSelect`; the façade
399
427
  rejects an incomplete callback contract before starting provider login.
400
428
  4. **Inject Claude tests.** Replace package-level mocks of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "description": "Agent runtime supporting Claude SDK/CLI, Codex, OpenCode, and Pi SDK bridges out of the box",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
@@ -133,8 +133,8 @@
133
133
  "dependencies": {
134
134
  "@anthropic-ai/claude-agent-sdk": "0.3.206",
135
135
  "@anthropic-ai/sdk": "^0.110.0",
136
- "@earendil-works/pi-agent-core": "0.80.6",
137
- "@earendil-works/pi-ai": "0.80.6",
136
+ "@earendil-works/pi-agent-core": "0.83.0",
137
+ "@earendil-works/pi-ai": "0.83.0",
138
138
  "@modelcontextprotocol/sdk": "^1.29.0",
139
139
  "@mozilla/readability": "0.6.0",
140
140
  "@opencode-ai/sdk": "^1.15.13",
@@ -93,12 +93,21 @@ function toolDescription(subagents, definitions, ceiling) {
93
93
  const named = definitions.length === 0
94
94
  ? ""
95
95
  : `\n\nAvailable subagents:\n${definitions.map((d) => `- ${d.name}: ${d.description}`).join("\n")}\n- ${GENERAL_PURPOSE_SUBAGENT}: read-only researcher inheriting the main model. Used when \`name\` is omitted.`;
96
+ // With authoring on, `name` is a free string rather than an enum, so it is
97
+ // the model's only signal for which of the two shapes it is writing. Left
98
+ // implicit, a caller that wants a configured profile AND a descriptive label
99
+ // splits those across two fields — label into `name`, profile into an
100
+ // invented one — and the closed schema rejects the whole call before any of
101
+ // the handler's precise errors can run.
102
+ const shapes = ceiling === null
103
+ ? ""
104
+ : `\n\nExactly two ways to call this, and \`name\` carries the agent's identity in both:\n- Use a configured one: set \`name\` to a name from the list above. Nothing else.\n- Build one for this task: set \`name\` to a NEW kebab-case name AND \`systemPrompt\` to its full instructions (optionally \`tools\`, \`effort\`). Do that when no configured one fits — a dedicated prompt beats stuffing constraints into \`prompt\`.\n\n\`description\` is the short label shown in the activity log, never the agent's name. There is no separate field for choosing a configured agent.`;
96
105
  // The ceiling is listed because the model has no other way to discover it: a
97
106
  // tool it cannot see is indistinguishable from one it forgot to ask for.
98
107
  const inline = ceiling === null
99
108
  ? ""
100
- : `\n\nYou can also build a specialist on the spot instead of picking a profile: pass \`systemPrompt\` with its full instructions, a kebab-case \`name\`, and the \`tools\` it needs. Do that when no profile fits the task — a dedicated prompt beats stuffing constraints into \`prompt\`. Tools you may grant: ${ceiling.join(", ")}. Anything else is dropped. Omit \`tools\` for a read-only helper.`;
101
- return `${DESCRIPTION_BASE}${parallel}${named}${inline}`;
109
+ : `\n\nTools you may grant a subagent you build: ${ceiling.join(", ")}. Anything else is dropped. Omit \`tools\` for a read-only helper.`;
110
+ return `${DESCRIPTION_BASE}${parallel}${named}${shapes}${inline}`;
102
111
  }
103
112
 
104
113
  /**
@@ -125,6 +134,27 @@ function budgetForRun(subagents, parentRunId) {
125
134
  return fresh;
126
135
  }
127
136
 
137
+ /**
138
+ * How many subagents this logical run actually spawned.
139
+ *
140
+ * A read-only accessor so a provider can report `subagent_invoked` truthfully
141
+ * without reaching into `__budgets`, which is a deliberately private,
142
+ * non-enumerable implementation detail. Returns 0 when nothing was ever
143
+ * registered — a run with no `Agent` tool never creates a budget entry, and that
144
+ * is indistinguishable from one that had the tool and never used it, which is
145
+ * exactly what "no subagent was invoked" means for this signal.
146
+ *
147
+ * @param {*} subagents The run-scoped options object, or undefined.
148
+ * @param {string|undefined} parentRunId
149
+ * @returns {number}
150
+ */
151
+ export function subagentInvocationCount(subagents, parentRunId) {
152
+ const store = subagents?.__budgets;
153
+ if (!(store instanceof Map)) return 0;
154
+ const entry = store.get(parentRunId ?? "unkeyed");
155
+ return Number.isInteger(entry?.total) ? entry.total : 0;
156
+ }
157
+
128
158
  /** @param {*} value @param {number} fallback @returns {number} */
129
159
  function positiveInt(value, fallback) {
130
160
  return Number.isInteger(value) && value > 0 ? value : fallback;
@@ -134,7 +164,7 @@ function positiveInt(value, fallback) {
134
164
  * Build the `Agent` tool, or null when subagents are unavailable for this run.
135
165
  *
136
166
  * @param {RuntimeSubagentsOptions|null|undefined} subagents
137
- * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, onEvent?: (event: *) => void}} [context]
167
+ * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, onEvent?: (event: *) => void}} [context]
138
168
  * @returns {*|null}
139
169
  */
140
170
  export function createAgentTool(subagents, context = {}) {
@@ -326,6 +356,11 @@ export function createAgentTool(subagents, context = {}) {
326
356
  // Inherited, never widened: a profile cannot loosen confinement.
327
357
  ...(context.sandboxPolicy === undefined ? {} : { sandboxPolicy: context.sandboxPolicy }),
328
358
  ...(context.sandboxEngine === undefined ? {} : { sandboxEngine: context.sandboxEngine }),
359
+ // The parent's disclosed skills. Offered, not imposed — the host's
360
+ // `run` decides whether this child may have them, since only it knows
361
+ // the child's resolved route and deny lists.
362
+ ...(context.skills === undefined ? {} : { skills: context.skills }),
363
+ ...(context.skillsRoot === undefined ? {} : { skillsRoot: context.skillsRoot }),
329
364
  abortSignal: controller.signal,
330
365
  maxTurns,
331
366
  callId: toolCallId,
@@ -3,7 +3,7 @@
3
3
  // directly so the runtime's known-good Pi version remains authoritative.
4
4
 
5
5
  import { getBuiltinModel, getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
6
- import { getOAuthApiKey, getOAuthProvider } from "@earendil-works/pi-ai/oauth";
6
+ import { getPiOAuthAuth, resolveOAuthApiKey, toAuthInteraction } from "./pi-oauth-compat.js";
7
7
  import { reasoningLevelsForPiModel as resolveReasoningLevels } from "./providers/pi-models.js";
8
8
 
9
9
  /**
@@ -122,7 +122,7 @@ export function reasoningLevelsForPiModel(model) {
122
122
  * @returns {Promise<{apiKey: string, newCredentials: PiOAuthCredentialsSnapshot}|null>}
123
123
  */
124
124
  export async function resolvePiOAuthApiKey(providerId, credentials) {
125
- const result = await getOAuthApiKey(
125
+ const result = await resolveOAuthApiKey(
126
126
  providerId,
127
127
  /** @type {any} */ (cloneInteropValue(credentials)),
128
128
  );
@@ -142,8 +142,8 @@ export async function resolvePiOAuthApiKey(providerId, credentials) {
142
142
  * @returns {Promise<PiOAuthCredentialsSnapshot>}
143
143
  */
144
144
  export async function loginPiOAuth(providerId, callbacks) {
145
- const provider = getOAuthProvider(providerId);
146
- if (!provider || typeof provider.login !== "function") {
145
+ const oauth = getPiOAuthAuth(providerId);
146
+ if (!oauth || typeof oauth.login !== "function") {
147
147
  throw new Error(`Pi OAuth provider is unavailable: ${providerId}`);
148
148
  }
149
149
  for (const callbackName of ["onAuth", "onDeviceCode", "onPrompt", "onSelect"]) {
@@ -151,6 +151,8 @@ export async function loginPiOAuth(providerId, callbacks) {
151
151
  throw new TypeError(`loginPiOAuth requires callbacks.${callbackName}()`);
152
152
  }
153
153
  }
154
- const credentials = await provider.login(/** @type {any} */ ({ ...callbacks }));
154
+ const credentials = await oauth.login(
155
+ toAuthInteraction(/** @type {any} */ ({ ...callbacks })),
156
+ );
155
157
  return cloneInteropValue(credentials);
156
158
  }
@@ -0,0 +1,193 @@
1
+ // @ts-check
2
+
3
+ // Adapter over pi-ai's provider-owned OAuth surface.
4
+ //
5
+ // pi-ai 0.83.0 removed the generic OAuth registry — `getOAuthApiKey`,
6
+ // `getOAuthProvider` and `getOAuthProviders` are gone, and the
7
+ // `@earendil-works/pi-ai/oauth` entry point is now type-only (`export {}` at
8
+ // runtime). The per-provider implementations (`anthropicOAuth`, …) live under
9
+ // `dist/auth/oauth/*`, which has no entry in the package's `exports` map, so
10
+ // they cannot be imported. The supported surface is `provider.auth.oauth`,
11
+ // reached through the provider factories.
12
+ //
13
+ // mono-agent resolves providers dynamically from `pi:<provider>:<model>`, so it
14
+ // needs a lookup by id — this module rebuilds that over `builtinProviders()` and
15
+ // preserves the old call contracts exactly, keeping the migration confined here.
16
+ //
17
+ // Deliberately NOT using `createModels({credentials})`: that hands credential
18
+ // locking and persistence to pi, while mono-agent already owns `auth.json`
19
+ // through `pi-auth.js` (serialized writes, atomic 0600 rename). `refresh()` and
20
+ // `toAuth()` are callable directly, so the pure caller-persists contract stays.
21
+
22
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
23
+
24
+ /**
25
+ * @typedef {import("@earendil-works/pi-ai").OAuthAuth} OAuthAuth
26
+ * @typedef {import("@earendil-works/pi-ai").OAuthCredential} OAuthCredential
27
+ * @typedef {import("@earendil-works/pi-ai").AuthInteraction} AuthInteraction
28
+ * @typedef {import("@earendil-works/pi-ai").AuthPrompt} AuthPrompt
29
+ * @typedef {import("@earendil-works/pi-ai").AuthEvent} AuthEvent
30
+ * @typedef {import("@earendil-works/pi-ai/oauth").OAuthLoginCallbacks} OAuthLoginCallbacks
31
+ */
32
+
33
+ /** @type {Map<string, import("@earendil-works/pi-ai").Provider>|undefined} */
34
+ let providerIndexCache;
35
+
36
+ /**
37
+ * `builtinProviders()` freshly constructs every provider (~37 objects) on each
38
+ * call, and `pi-auth.js` sits on the per-request credential path. The catalog is
39
+ * static for the process lifetime, so index it once.
40
+ *
41
+ * @returns {Map<string, import("@earendil-works/pi-ai").Provider>}
42
+ */
43
+ function providerIndex() {
44
+ if (providerIndexCache === undefined) {
45
+ providerIndexCache = new Map(builtinProviders().map((provider) => [provider.id, provider]));
46
+ }
47
+ return providerIndexCache;
48
+ }
49
+
50
+ /** @internal Exported only so tests can force a rebuild of the memoized index. */
51
+ export function resetPiProviderIndexForTests() {
52
+ providerIndexCache = undefined;
53
+ }
54
+
55
+ /**
56
+ * The OAuth implementation for a Pi provider id, or undefined when the provider
57
+ * is unknown or supports only API-key auth (e.g. `opencode-go`).
58
+ *
59
+ * @param {string} providerId
60
+ * @returns {OAuthAuth|undefined}
61
+ */
62
+ export function getPiOAuthAuth(providerId) {
63
+ if (typeof providerId !== "string" || providerId.length === 0) return undefined;
64
+ return providerIndex().get(providerId)?.auth?.oauth;
65
+ }
66
+
67
+ /**
68
+ * Every Pi provider id that supports OAuth. Replaces
69
+ * `getOAuthProviders().map((provider) => provider.id)`.
70
+ *
71
+ * @returns {string[]}
72
+ */
73
+ export function getPiOAuthProviderIds() {
74
+ const ids = [];
75
+ for (const [id, provider] of providerIndex()) {
76
+ if (provider.auth?.oauth !== undefined) ids.push(id);
77
+ }
78
+ return ids;
79
+ }
80
+
81
+ /**
82
+ * Resolve an API key from stored OAuth credentials, refreshing first when the
83
+ * token has expired.
84
+ *
85
+ * Reproduces pi-ai 0.80.6's `getOAuthApiKey(providerId, credentials)` contract
86
+ * so its call sites keep their shape: takes the whole provider-keyed credential
87
+ * map, returns `null` when this provider has no stored credential, and is
88
+ * *pure* — the refreshed credential comes back as `newCredentials` for the
89
+ * caller to persist rather than being written here.
90
+ *
91
+ * The refresh trigger is deliberately the old exact-expiry check. pi's own
92
+ * `Models.getAuth()` refreshes five minutes ahead of expiry; matching that would
93
+ * change live token rotation timing, which this migration does not intend.
94
+ *
95
+ * @param {string} providerId
96
+ * @param {Record<string, *>|undefined} credentials Provider-keyed credential map.
97
+ * @returns {Promise<{newCredentials: OAuthCredential, apiKey: string|undefined}|null>}
98
+ */
99
+ export async function resolveOAuthApiKey(providerId, credentials) {
100
+ const oauth = getPiOAuthAuth(providerId);
101
+ if (oauth === undefined) {
102
+ throw new Error(`Unknown OAuth provider: ${providerId}`);
103
+ }
104
+
105
+ const stored = credentials?.[providerId];
106
+ if (stored === undefined || stored === null) return null;
107
+
108
+ // 0.83.0's OAuthCredential carries a `type: "oauth"` discriminant that the
109
+ // 0.80.6 shape did not. Stored entries written by mono-agent already have it;
110
+ // tag defensively so hand-edited or externally written files still work.
111
+ let credential = /** @type {OAuthCredential} */ ({ ...stored, type: "oauth" });
112
+
113
+ if (Date.now() >= credential.expires) {
114
+ try {
115
+ credential = await oauth.refresh(credential);
116
+ } catch {
117
+ throw new Error(`Failed to refresh OAuth token for ${providerId}`);
118
+ }
119
+ }
120
+
121
+ // `toAuth()` also derives a per-credential baseUrl (GitHub Copilot's
122
+ // per-account proxy). 0.80.6 discarded it and callers here have no field for
123
+ // it, so it stays dropped — behaviour preserved, worth revisiting separately.
124
+ const auth = await oauth.toAuth(credential);
125
+ return { newCredentials: credential, apiKey: auth?.apiKey };
126
+ }
127
+
128
+ /**
129
+ * Bridge the legacy six-callback OAuth surface onto 0.83.0's single
130
+ * `prompt`/`notify` pair.
131
+ *
132
+ * `manual_code` must stay wired to `onManualCodeInput`: Anthropic races its
133
+ * localhost callback against a pasted redirect URL, and that path is the reason
134
+ * `agent-app`'s `runPiOAuthLogin` exists at all.
135
+ *
136
+ * @param {OAuthLoginCallbacks} callbacks
137
+ * @returns {AuthInteraction}
138
+ */
139
+ export function toAuthInteraction(callbacks) {
140
+ if (callbacks === null || typeof callbacks !== "object") {
141
+ throw new TypeError("toAuthInteraction requires an OAuthLoginCallbacks object");
142
+ }
143
+
144
+ return {
145
+ ...(callbacks.signal === undefined ? {} : { signal: callbacks.signal }),
146
+
147
+ /** @param {AuthPrompt} prompt */
148
+ async prompt(prompt) {
149
+ if (prompt.type === "select") {
150
+ const selected = await callbacks.onSelect({
151
+ message: prompt.message,
152
+ options: prompt.options.map((option) => ({ id: option.id, label: option.label })),
153
+ });
154
+ // The legacy callback resolves undefined when the user cancels; the new
155
+ // contract requires a string and rejects on cancel.
156
+ if (typeof selected !== "string") {
157
+ throw new Error("OAuth provider selection was cancelled.");
158
+ }
159
+ return selected;
160
+ }
161
+
162
+ if (prompt.type === "manual_code" && typeof callbacks.onManualCodeInput === "function") {
163
+ return await callbacks.onManualCodeInput();
164
+ }
165
+
166
+ return await callbacks.onPrompt({
167
+ message: prompt.message,
168
+ ...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
169
+ });
170
+ },
171
+
172
+ /** @param {AuthEvent} event */
173
+ notify(event) {
174
+ if (event.type === "auth_url") {
175
+ callbacks.onAuth({
176
+ url: event.url,
177
+ ...(event.instructions === undefined ? {} : { instructions: event.instructions }),
178
+ });
179
+ return;
180
+ }
181
+ if (event.type === "device_code") {
182
+ callbacks.onDeviceCode({
183
+ userCode: event.userCode,
184
+ verificationUri: event.verificationUri,
185
+ ...(event.intervalSeconds === undefined ? {} : { intervalSeconds: event.intervalSeconds }),
186
+ ...(event.expiresInSeconds === undefined ? {} : { expiresInSeconds: event.expiresInSeconds }),
187
+ });
188
+ return;
189
+ }
190
+ callbacks.onProgress?.(event.message);
191
+ },
192
+ };
193
+ }
@@ -8,7 +8,6 @@
8
8
  // caller-owned runState.
9
9
 
10
10
  import { AgentHarness } from "@earendil-works/pi-agent-core";
11
- import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
12
11
  import {
13
12
  createStructuredOutputTool,
14
13
  getPiBuiltinTools,
@@ -134,6 +133,14 @@ export async function buildTurnTools(runState, {
134
133
  // child is never less sandboxed than the parent that spawned it.
135
134
  sandboxPolicy: options.sandboxPolicy,
136
135
  sandboxEngine,
136
+ // The same skills this turn was disclosed. A child runs OUTSIDE the
137
+ // harness, so without this it gets no index and — because ReadSkill is
138
+ // only built when `skills` is non-empty — no way to read one either. It
139
+ // then rediscovers by trial and error whatever its parent could simply
140
+ // have looked up. Pass-through only: whether the child actually receives
141
+ // them is the host's decision, not this layer's.
142
+ skills: options.skills,
143
+ skillsRoot: options.skillsRoot,
137
144
  },
138
145
  ctx: runCtx,
139
146
  }));
@@ -241,7 +248,6 @@ export function toolResultErrorOverride(details) {
241
248
  }
242
249
 
243
250
  export function buildTurnHarness(runState, {
244
- cwd,
245
251
  session,
246
252
  piModels,
247
253
  model,
@@ -259,8 +265,12 @@ export function buildTurnHarness(runState, {
259
265
  sdk,
260
266
  reference,
261
267
  }) {
268
+ // pi-agent-core 0.83.0 removed `env` from AgentHarnessOptions: an
269
+ // ExecutionEnv now reaches tools through the generic per-turn `toolContext`
270
+ // instead. mono-agent needs neither — it uses none of pi's built-in
271
+ // file/shell tools, and its own tools close over what they need — so the
272
+ // option is dropped rather than migrated.
262
273
  const harness = new AgentHarness({
263
- env: new NodeExecutionEnv({ cwd: cwd || process.cwd() }),
264
274
  session,
265
275
  models: piModels,
266
276
  model,
@@ -31,7 +31,9 @@ import {
31
31
  resolveAgentCompactionPolicy,
32
32
  resolveRuntimePolicyInputs,
33
33
  } from "../../agent/compaction.js";
34
+ import { subagentInvocationCount } from "../../agent/tools/agent-tool.js";
34
35
  import { closePiMcpClients } from "../../agent/tools/pi-bridge.js";
36
+ import { readToolRuntime } from "../../agent/tools/shared/runtime-context.js";
35
37
  import { createApprovalManager } from "../../agent/approval.js";
36
38
  import { buildCapabilitiesUsed, toolCompactionAppliedFromWarnings } from "../runtime/capabilities-used.js";
37
39
  import { reasoningLevelsForPiModel, resolvePiRuntimeModel } from "./pi-models.js";
@@ -481,7 +483,6 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
481
483
  // external abort handler (which sets runState.externalAbort and aborts the
482
484
  // harness). Sets runState.harness + runState.removeAbortHandler.
483
485
  harness = buildTurnHarness(runState, {
484
- cwd: options.cwd,
485
486
  session: runState.session,
486
487
  piModels,
487
488
  model: runtime.model,
@@ -709,8 +710,23 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
709
710
  promptCacheActive: usage.cacheRead > 0 || usage.cacheWrite > 0,
710
711
  thinkingEnabled: effectiveThinkingLevel !== "off" && effectiveThinkingLevel !== "low",
711
712
  structuredOutputEnforced: !!options.outputSchema,
712
- subagentInvoked: false,
713
+ // What actually happened, not what was configured. This used to be a
714
+ // hardcoded `false`, which reported "no subagent ran" for runs that had
715
+ // just spawned several — the one signal that would have shown delegation
716
+ // working said it never happened.
717
+ //
718
+ // The count is keyed by the same parentRunId the Agent tool stamps its
719
+ // budget under (turn-runner threads `runCtx?.runId`); `runId` survives the
720
+ // sandbox-branch spread there, so reading it off the tool context directly
721
+ // yields the same key.
722
+ subagentInvoked: subagentInvocationCount(
723
+ options.subagents,
724
+ (options.toolContext ?? readToolRuntime())?.runId,
725
+ ) > 0,
713
726
  mcpServersUsed: mcpClients.map((entry) => entry?.name).filter(Boolean),
727
+ // Empty by contract, not by omission: "native" means provider-native
728
+ // subagents (Claude's Task tool). mono-agent's `Agent` is its own, so pi
729
+ // has none — `subagent_invoked` above is where pi delegation is reported.
714
730
  nativeSubagentsUsed: [],
715
731
  toolCompactionApplied: toolCompactionAppliedFromWarnings(runtimeWarnings),
716
732
  // Tristate: true = a compaction fired this run (proactive or reactive),
@@ -335,11 +335,16 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
335
335
  // A same-model retry is not a failover: only the first attempt of a new
336
336
  // route announces a transition.
337
337
  if (retryIndex === 0 && failoverHistory.length > 0) {
338
+ const previous = failoverHistory[failoverHistory.length - 1];
338
339
  emit(callOptions, {
339
340
  type: "provider_failover_started",
340
- from: modelKey(failoverHistory[failoverHistory.length - 1]?.model),
341
+ from: modelKey(previous?.model),
341
342
  to: modelKey(entry.model),
342
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,
343
348
  });
344
349
  }
345
350
 
@@ -378,9 +383,12 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
378
383
  // (failover)" when X is still the route the operator asked for.
379
384
  if (failoverHistory.some((attempt) => modelKey(attempt.model) !== modelKey(entry.model))) {
380
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.
381
389
  type: "provider_failover_completed",
382
390
  attemptIndex: i,
383
- model: entry.model,
391
+ model: modelKey(entry.model),
384
392
  });
385
393
  }
386
394
  return { ...result, failoverHistory, routeSafetyHistory };
package/src/ai/types.js CHANGED
@@ -147,7 +147,8 @@
147
147
  * @property {boolean} [fastMode]
148
148
  * @property {string} [cwd]
149
149
  * @property {Object<string, Object>} [mcpServers]
150
- * @property {ReadonlyArray<Object>} [skills] Runtime skill metadata for progressive disclosure.
150
+ * @property {ReadonlyArray<{name: string, description?: string}>} [skills] Skills disclosed to this run, as `{name, description}`. Non-empty makes `supports_skills` a routing requirement (see router.js), so a chain entry that lacks it is skipped.
151
+ * @property {string} [skillsRoot] Directory holding `<name>/SKILL.md`. Required alongside `skills` for `ReadSkill` to be built.
151
152
  * @property {ReadonlyArray<string>} [allowedTools]
152
153
  * @property {ReadonlyArray<string>} [disallowedTools]
153
154
  * @property {string} [permissionMode]
package/src/pi-auth.js CHANGED
@@ -4,7 +4,7 @@ import { realpathSync } from "node:fs";
4
4
  import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
6
 
7
- import { getOAuthApiKey } from "@earendil-works/pi-ai/oauth";
7
+ import { resolveOAuthApiKey } from "./ai/pi-oauth-compat.js";
8
8
 
9
9
  /**
10
10
  * @typedef {{type: "api_key", key?: string, env?: Object<string, *>}} PiApiKeyCredential
@@ -41,7 +41,7 @@ export function createPiOAuthApiKeyResolver(options = {}) {
41
41
  return undefined;
42
42
  }
43
43
 
44
- const result = await getOAuthApiKey(provider, cloneAuth(auth));
44
+ const result = await resolveOAuthApiKey(provider, cloneAuth(auth));
45
45
  if (result === null || result === undefined || typeof result.apiKey !== "string" || result.apiKey.length === 0) {
46
46
  return undefined;
47
47
  }
package/src/runtime.js CHANGED
@@ -165,6 +165,13 @@ export function createRuntime(host = {}) {
165
165
  // WebSearch, so even a read-only profile could bypass network policy.
166
166
  ...(request.sandboxPolicy === undefined ? {} : { sandboxPolicy: request.sandboxPolicy }),
167
167
  ...(request.sandboxEngine === undefined ? {} : { sandboxEngine: request.sandboxEngine }),
168
+ // The parent's disclosed skills, for the same reason: they are a per-run
169
+ // option, so a child that does not receive them has no ReadSkill tool and no
170
+ // index, and must rediscover by trial and error what its parent could look
171
+ // up. A host-supplied `run` may gate this; the default has no route or deny
172
+ // list of its own to consult, so it forwards what it was given.
173
+ ...(request.skills === undefined ? {} : { skills: request.skills }),
174
+ ...(request.skillsRoot === undefined ? {} : { skillsRoot: request.skillsRoot }),
168
175
  ...(request.executionMode === undefined ? {} : { executionMode: request.executionMode }),
169
176
  ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
170
177
  // A profile that pins effort — declared or authored at call time — means it
@@ -1,8 +1,23 @@
1
+ /**
2
+ * How many subagents this logical run actually spawned.
3
+ *
4
+ * A read-only accessor so a provider can report `subagent_invoked` truthfully
5
+ * without reaching into `__budgets`, which is a deliberately private,
6
+ * non-enumerable implementation detail. Returns 0 when nothing was ever
7
+ * registered — a run with no `Agent` tool never creates a budget entry, and that
8
+ * is indistinguishable from one that had the tool and never used it, which is
9
+ * exactly what "no subagent was invoked" means for this signal.
10
+ *
11
+ * @param {*} subagents The run-scoped options object, or undefined.
12
+ * @param {string|undefined} parentRunId
13
+ * @returns {number}
14
+ */
15
+ export function subagentInvocationCount(subagents: any, parentRunId: string | undefined): number;
1
16
  /**
2
17
  * Build the `Agent` tool, or null when subagents are unavailable for this run.
3
18
  *
4
19
  * @param {RuntimeSubagentsOptions|null|undefined} subagents
5
- * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, onEvent?: (event: *) => void}} [context]
20
+ * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, onEvent?: (event: *) => void}} [context]
6
21
  * @returns {*|null}
7
22
  */
8
23
  export function createAgentTool(subagents: RuntimeSubagentsOptions | null | undefined, context?: {
@@ -12,6 +27,11 @@ export function createAgentTool(subagents: RuntimeSubagentsOptions | null | unde
12
27
  parentRunId?: string;
13
28
  sandboxPolicy?: any;
14
29
  sandboxEngine?: any;
30
+ skills?: {
31
+ name: string;
32
+ description?: string;
33
+ }[];
34
+ skillsRoot?: string;
15
35
  onEvent?: (event: any) => void;
16
36
  }): any | null;
17
37
  /**
@@ -0,0 +1,57 @@
1
+ /** @internal Exported only so tests can force a rebuild of the memoized index. */
2
+ export function resetPiProviderIndexForTests(): void;
3
+ /**
4
+ * The OAuth implementation for a Pi provider id, or undefined when the provider
5
+ * is unknown or supports only API-key auth (e.g. `opencode-go`).
6
+ *
7
+ * @param {string} providerId
8
+ * @returns {OAuthAuth|undefined}
9
+ */
10
+ export function getPiOAuthAuth(providerId: string): OAuthAuth | undefined;
11
+ /**
12
+ * Every Pi provider id that supports OAuth. Replaces
13
+ * `getOAuthProviders().map((provider) => provider.id)`.
14
+ *
15
+ * @returns {string[]}
16
+ */
17
+ export function getPiOAuthProviderIds(): string[];
18
+ /**
19
+ * Resolve an API key from stored OAuth credentials, refreshing first when the
20
+ * token has expired.
21
+ *
22
+ * Reproduces pi-ai 0.80.6's `getOAuthApiKey(providerId, credentials)` contract
23
+ * so its call sites keep their shape: takes the whole provider-keyed credential
24
+ * map, returns `null` when this provider has no stored credential, and is
25
+ * *pure* — the refreshed credential comes back as `newCredentials` for the
26
+ * caller to persist rather than being written here.
27
+ *
28
+ * The refresh trigger is deliberately the old exact-expiry check. pi's own
29
+ * `Models.getAuth()` refreshes five minutes ahead of expiry; matching that would
30
+ * change live token rotation timing, which this migration does not intend.
31
+ *
32
+ * @param {string} providerId
33
+ * @param {Record<string, *>|undefined} credentials Provider-keyed credential map.
34
+ * @returns {Promise<{newCredentials: OAuthCredential, apiKey: string|undefined}|null>}
35
+ */
36
+ export function resolveOAuthApiKey(providerId: string, credentials: Record<string, any> | undefined): Promise<{
37
+ newCredentials: OAuthCredential;
38
+ apiKey: string | undefined;
39
+ } | null>;
40
+ /**
41
+ * Bridge the legacy six-callback OAuth surface onto 0.83.0's single
42
+ * `prompt`/`notify` pair.
43
+ *
44
+ * `manual_code` must stay wired to `onManualCodeInput`: Anthropic races its
45
+ * localhost callback against a pasted redirect URL, and that path is the reason
46
+ * `agent-app`'s `runPiOAuthLogin` exists at all.
47
+ *
48
+ * @param {OAuthLoginCallbacks} callbacks
49
+ * @returns {AuthInteraction}
50
+ */
51
+ export function toAuthInteraction(callbacks: OAuthLoginCallbacks): AuthInteraction;
52
+ export type OAuthAuth = import("@earendil-works/pi-ai").OAuthAuth;
53
+ export type OAuthCredential = import("@earendil-works/pi-ai").OAuthCredential;
54
+ export type AuthInteraction = import("@earendil-works/pi-ai").AuthInteraction;
55
+ export type AuthPrompt = import("@earendil-works/pi-ai").AuthPrompt;
56
+ export type AuthEvent = import("@earendil-works/pi-ai").AuthEvent;
57
+ export type OAuthLoginCallbacks = import("@earendil-works/pi-ai/oauth").OAuthLoginCallbacks;
@@ -45,8 +45,7 @@ export function thinkingLevelForEffort(effort: string, capabilities: any): strin
45
45
  export function toolResultErrorOverride(details: any): {
46
46
  isError: true;
47
47
  } | undefined;
48
- export function buildTurnHarness(runState: any, { cwd, session, piModels, model, thinkingLevel, systemPrompt, outputSchema, tools, transport, maxRetries, maxRetryDelayMs, steeringMode, onEvent, options, toolLimits, sdk, reference, }: {
49
- cwd: any;
48
+ export function buildTurnHarness(runState: any, { session, piModels, model, thinkingLevel, systemPrompt, outputSchema, tools, transport, maxRetries, maxRetryDelayMs, steeringMode, onEvent, options, toolLimits, sdk, reference, }: {
50
49
  session: any;
51
50
  piModels: any;
52
51
  model: any;
@@ -63,7 +62,7 @@ export function buildTurnHarness(runState: any, { cwd, session, piModels, model,
63
62
  toolLimits: any;
64
63
  sdk: any;
65
64
  reference: any;
66
- }): AgentHarness<import("@earendil-works/pi-agent-core").Skill, import("@earendil-works/pi-agent-core").PromptTemplate, import("@earendil-works/pi-agent-core").AgentTool<import("@earendil-works/pi-ai").TSchema, any>>;
65
+ }): AgentHarness<undefined, import("@earendil-works/pi-agent-core").Skill, import("@earendil-works/pi-agent-core").PromptTemplate, import("@earendil-works/pi-agent-core").AgentHarnessTool<undefined>>;
67
66
  /**
68
67
  * Start the live-input steering consumer. Consumes follow-up messages and steers
69
68
  * the harness mid-run; the consumer is tied to run completion (an internal
@@ -118,7 +118,8 @@
118
118
  * @property {boolean} [fastMode]
119
119
  * @property {string} [cwd]
120
120
  * @property {Object<string, Object>} [mcpServers]
121
- * @property {ReadonlyArray<Object>} [skills] Runtime skill metadata for progressive disclosure.
121
+ * @property {ReadonlyArray<{name: string, description?: string}>} [skills] Skills disclosed to this run, as `{name, description}`. Non-empty makes `supports_skills` a routing requirement (see router.js), so a chain entry that lacks it is skipped.
122
+ * @property {string} [skillsRoot] Directory holding `<name>/SKILL.md`. Required alongside `skills` for `ReadSkill` to be built.
122
123
  * @property {ReadonlyArray<string>} [allowedTools]
123
124
  * @property {ReadonlyArray<string>} [disallowedTools]
124
125
  * @property {string} [permissionMode]
@@ -578,9 +579,16 @@ export type RuntimeRunOptions = {
578
579
  [x: string]: any;
579
580
  };
580
581
  /**
581
- * Runtime skill metadata for progressive disclosure.
582
+ * Skills disclosed to this run, as `{name, description}`. Non-empty makes `supports_skills` a routing requirement (see router.js), so a chain entry that lacks it is skipped.
582
583
  */
583
- skills?: ReadonlyArray<any>;
584
+ skills?: ReadonlyArray<{
585
+ name: string;
586
+ description?: string;
587
+ }>;
588
+ /**
589
+ * Directory holding `<name>/SKILL.md`. Required alongside `skills` for `ReadSkill` to be built.
590
+ */
591
+ skillsRoot?: string;
584
592
  allowedTools?: ReadonlyArray<string>;
585
593
  disallowedTools?: ReadonlyArray<string>;
586
594
  permissionMode?: string;
@@ -1,57 +0,0 @@
1
- export function backendCapabilities(sdkOrModel: any): any;
2
- export function backendUsesExecenvConfig(sdk: any): boolean;
3
- export function backendSupportsSessionResume(sdk: any): boolean;
4
- export const BACKEND_CAPABILITIES: {
5
- claude: {
6
- supports_session_resume: boolean;
7
- streaming: boolean;
8
- structured_output: boolean;
9
- native_runtime_config: any;
10
- supports_mcp: boolean;
11
- supports_skills: boolean;
12
- supports_builtin_tools: boolean;
13
- supports_live_input: boolean;
14
- supports_native_subagents: boolean;
15
- supports_fast_mode: boolean;
16
- runtime: string;
17
- };
18
- pi: {
19
- supports_session_resume: boolean;
20
- supports_native_subagents: boolean;
21
- streaming: boolean;
22
- structured_output: boolean;
23
- native_runtime_config: any;
24
- supports_mcp: boolean;
25
- supports_skills: boolean;
26
- supports_builtin_tools: boolean;
27
- supports_live_input: boolean;
28
- supports_fast_mode: boolean;
29
- runtime: string;
30
- };
31
- codex: {
32
- supports_session_resume: boolean;
33
- supports_fast_mode: boolean;
34
- streaming: boolean;
35
- structured_output: boolean;
36
- native_runtime_config: any;
37
- supports_mcp: boolean;
38
- supports_skills: boolean;
39
- supports_builtin_tools: boolean;
40
- supports_live_input: boolean;
41
- supports_native_subagents: boolean;
42
- runtime: string;
43
- };
44
- opencode: {
45
- structured_output: boolean;
46
- supports_session_resume: boolean;
47
- supports_mcp: boolean;
48
- supports_skills: boolean;
49
- supports_live_input: boolean;
50
- supports_native_subagents: boolean;
51
- streaming: boolean;
52
- native_runtime_config: any;
53
- supports_builtin_tools: boolean;
54
- supports_fast_mode: boolean;
55
- runtime: string;
56
- };
57
- };
@@ -1 +0,0 @@
1
- export { listRuntimeBridges as listProviders, resolveRuntimeBridge as findProviderForModel, runtimeCapabilities } from "./runtime/registry.js";