@mono-agent/agent-runtime 0.15.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/MIGRATION.md +41 -13
  2. package/README.md +43 -6
  3. package/package.json +7 -3
  4. package/src/agent/tools/agent-tool.js +894 -0
  5. package/src/agent/tools/bash.js +241 -123
  6. package/src/agent/tools/exec.js +238 -0
  7. package/src/agent/tools/index.js +10 -3
  8. package/src/agent/tools/node-repl.js +231 -95
  9. package/src/agent/tools/pi-bridge.js +115 -24
  10. package/src/agent/tools/shared/process-runner.js +162 -0
  11. package/src/agent/tools/shared/semaphore.js +73 -0
  12. package/src/agent/tools/web-browser-render.js +221 -0
  13. package/src/agent/tools/web-controller.js +160 -0
  14. package/src/agent/tools/web-fetch.js +653 -68
  15. package/src/agent/tools/web-search.js +568 -16
  16. package/src/ai/pi-interop.js +7 -5
  17. package/src/ai/pi-oauth-compat.js +193 -0
  18. package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
  19. package/src/ai/providers/pi-native/turn-runner.js +73 -8
  20. package/src/ai/providers/pi-native.js +67 -7
  21. package/src/ai/runtime/router.js +310 -166
  22. package/src/ai/types.js +54 -2
  23. package/src/pi-auth.js +2 -2
  24. package/src/runtime.js +58 -1
  25. package/types/agent/tools/agent-tool.d.ts +80 -0
  26. package/types/agent/tools/bash.d.ts +55 -7
  27. package/types/agent/tools/exec.d.ts +53 -0
  28. package/types/agent/tools/index.d.ts +5 -3
  29. package/types/agent/tools/node-repl.d.ts +28 -3
  30. package/types/agent/tools/pi-bridge.d.ts +6 -2
  31. package/types/agent/tools/shared/process-runner.d.ts +33 -0
  32. package/types/agent/tools/shared/semaphore.d.ts +29 -0
  33. package/types/agent/tools/web-browser-render.d.ts +16 -0
  34. package/types/agent/tools/web-controller.d.ts +20 -0
  35. package/types/agent/tools/web-fetch.d.ts +74 -5
  36. package/types/agent/tools/web-search.d.ts +81 -5
  37. package/types/ai/pi-oauth-compat.d.ts +57 -0
  38. package/types/ai/providers/pi-native/turn-runner.d.ts +33 -2
  39. package/types/ai/providers/pi-native.d.ts +12 -0
  40. package/types/ai/runtime/router.d.ts +23 -3
  41. package/types/ai/types.d.ts +174 -4
  42. package/types/ai/backend.d.ts +0 -57
  43. package/types/ai/registry.d.ts +0 -1
@@ -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
+ }
@@ -22,6 +22,41 @@ function toolResultFileChange(result) {
22
22
  : null;
23
23
  }
24
24
 
25
+ function toolResultOutcome(result) {
26
+ const source = result?.details?.outcome;
27
+ if (!source || typeof source !== "object" || Array.isArray(source)) return null;
28
+ const bounded = {};
29
+ const strings = {
30
+ status: "status",
31
+ code: "code",
32
+ backend: "backend",
33
+ signal: "signal",
34
+ };
35
+ const numbers = {
36
+ attempts: "attempts",
37
+ bytes: "bytes",
38
+ exitCode: "exit_code",
39
+ statusCode: "status_code",
40
+ };
41
+ const booleans = {
42
+ retryable: "retryable",
43
+ cacheHit: "cache_hit",
44
+ truncated: "truncated",
45
+ timedOut: "timed_out",
46
+ rendered: "rendered",
47
+ };
48
+ for (const [input, output] of Object.entries(strings)) {
49
+ if (typeof source[input] === "string") bounded[output] = source[input].slice(0, 120);
50
+ }
51
+ for (const [input, output] of Object.entries(numbers)) {
52
+ if (Number.isFinite(Number(source[input]))) bounded[output] = Number(source[input]);
53
+ }
54
+ for (const [input, output] of Object.entries(booleans)) {
55
+ if (typeof source[input] === "boolean") bounded[output] = source[input];
56
+ }
57
+ return bounded;
58
+ }
59
+
25
60
  /**
26
61
  * The slice of run state the stream subscriber reads and mutates. A structural
27
62
  * subset of the orchestrator's runState.
@@ -107,6 +142,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
107
142
  } else if (event.type === "tool_execution_end") {
108
143
  const resultContent = toolResultContent(event.result);
109
144
  const fileChange = toolResultFileChange(event.result);
145
+ const outcome = toolResultOutcome(event.result);
110
146
  if (!event.isError) runState.toolResultsSeen += 1;
111
147
  const startedAt = runState.toolStartTimes.get(event.toolCallId);
112
148
  if (startedAt !== undefined) {
@@ -117,6 +153,7 @@ export function createStreamSubscriber(runState, { onEvent, options, toolLimits,
117
153
  name: event.toolName,
118
154
  execution_ms: Date.now() - startedAt,
119
155
  is_error: !!event.isError,
156
+ ...(outcome || {}),
120
157
  });
121
158
  }
122
159
  onEvent({
@@ -8,13 +8,13 @@
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,
15
14
  initPiMcpTools,
16
15
  } from "../../../agent/tools/pi-bridge.js";
17
16
  import { createNodeReplController } from "../../../agent/tools/node-repl.js";
17
+ import { createWebToolController } from "../../../agent/tools/web-controller.js";
18
18
  import { readToolRuntime } from "../../../agent/tools/shared/runtime-context.js";
19
19
  import { formatLiveInputGuidance } from "../../live-input-prompt.js";
20
20
  import { appendStructuredOutputInstruction } from "./structured-output.js";
@@ -40,6 +40,7 @@ export async function buildTurnTools(runState, {
40
40
  resolved,
41
41
  onEvent,
42
42
  runtimeWarnings,
43
+ toolExecutionMode = "safe-parallel",
43
44
  }) {
44
45
  const onTruncate = (info) => {
45
46
  try {
@@ -73,6 +74,15 @@ export async function buildTurnTools(runState, {
73
74
  sandboxEngine,
74
75
  ctx: runCtx,
75
76
  });
77
+ const webController = capabilities.tool_use === false
78
+ ? null
79
+ : createWebToolController({
80
+ searchConfig: options.webSearchConfig,
81
+ fetchConfig: options.webFetchConfig,
82
+ sandboxPolicy: options.sandboxPolicy,
83
+ sandboxEngine,
84
+ ctx: runCtx,
85
+ });
76
86
 
77
87
  // REUSED custom pieces: built-in tool sandboxing + allowlist/bloat filter +
78
88
  // approval gates. These are identical to the legacy bridge.
@@ -109,6 +119,29 @@ export async function buildTurnTools(runState, {
109
119
  approvalManager,
110
120
  approvalModel: runtime.model?.id || runtime.model?.name || resolved.model,
111
121
  nodeReplController,
122
+ webController,
123
+ toolExecutionMode,
124
+ subagents: options.subagents,
125
+ // The child inherits the parent's route and workspace unless its profile
126
+ // pins a model; the tool closure reads these to build each child request.
127
+ subagentContext: {
128
+ model: options.model,
129
+ executionMode: options.executionMode,
130
+ cwd: options.cwd,
131
+ parentRunId: runCtx?.runId,
132
+ // Same policy + engine this turn's own tools are confined by, so a
133
+ // child is never less sandboxed than the parent that spawned it.
134
+ sandboxPolicy: options.sandboxPolicy,
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,
144
+ },
112
145
  ctx: runCtx,
113
146
  }));
114
147
 
@@ -143,14 +176,19 @@ export async function buildTurnTools(runState, {
143
176
 
144
177
  const tools = [
145
178
  ...builtIns,
146
- ...mcpInit.tools,
179
+ ...mcpInit.tools.map((tool) => ({ ...tool, executionMode: "sequential" })),
147
180
  ...(structuredTool ? [structuredTool] : []),
148
181
  ];
149
182
  return {
150
183
  tools,
151
184
  structuredTool,
152
185
  mcpClients: mcpInit.clients,
153
- closeRunTools: async () => { await nodeReplController?.close(); },
186
+ closeRunTools: async () => {
187
+ await Promise.allSettled([
188
+ nodeReplController?.close(),
189
+ webController?.close(),
190
+ ].filter(Boolean));
191
+ },
154
192
  };
155
193
  }
156
194
 
@@ -189,8 +227,27 @@ export function thinkingLevelForEffort(effort, capabilities) {
189
227
  * @param {any} params
190
228
  * @returns {any}
191
229
  */
230
+ /**
231
+ * Restore the error flag on a tool result pi resolved successfully.
232
+ *
233
+ * pi hardcodes `isError: false` for every `execute()` that returns rather than
234
+ * throws, so any tool that reports failure in its payload needs this hook or
235
+ * the model is told the call succeeded.
236
+ *
237
+ * @param {*} details Tool-result details recorded by the bridge.
238
+ * @returns {{isError: true}|undefined}
239
+ */
240
+ export function toolResultErrorOverride(details) {
241
+ const subagentStatus = details?.subagent?.status;
242
+ const failed = details?.mcp_result_is_error === true
243
+ || details?.outcome?.status === "error"
244
+ // A failed subagent returns its answer-plus-log instead of throwing,
245
+ // precisely so a failed delegation keeps its activity log.
246
+ || (typeof subagentStatus === "string" && subagentStatus !== "ok");
247
+ return failed ? { isError: true } : undefined;
248
+ }
249
+
192
250
  export function buildTurnHarness(runState, {
193
- cwd,
194
251
  session,
195
252
  piModels,
196
253
  model,
@@ -208,8 +265,12 @@ export function buildTurnHarness(runState, {
208
265
  sdk,
209
266
  reference,
210
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.
211
273
  const harness = new AgentHarness({
212
- env: new NodeExecutionEnv({ cwd: cwd || process.cwd() }),
213
274
  session,
214
275
  models: piModels,
215
276
  model,
@@ -225,9 +286,13 @@ export function buildTurnHarness(runState, {
225
286
  // this after-tool hook restores the error flag while preserving the already
226
287
  // bounded content/details verbatim. Downstream tool_execution_end and timing
227
288
  // events therefore report the failure accurately.
228
- harness.on("tool_result", (event) => /** @type {any} */ (event?.details)?.mcp_result_is_error === true
229
- ? { isError: true }
230
- : undefined);
289
+ //
290
+ // A failed subagent is the same shape of problem: the `Agent` tool returns
291
+ // its formatted answer-plus-activity-log rather than throwing, precisely so a
292
+ // failed delegation keeps its log — but pi hardcodes `isError: false` for
293
+ // every resolved execute(), so without this the model would be told a failed,
294
+ // timed-out, or empty delegation succeeded.
295
+ harness.on("tool_result", (event) => toolResultErrorOverride(event?.details));
231
296
  runState.harness = harness;
232
297
 
233
298
  harness.subscribe(createStreamSubscriber(runState, {
@@ -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";
@@ -78,6 +80,37 @@ import {
78
80
  } from "./pi-native/turn-runner.js";
79
81
  import { resolvePiTransport } from "./pi-native/transport.js";
80
82
 
83
+ /**
84
+ * Pi 0.80.6 exposes per-tool execution markers but not AgentHarness's global
85
+ * toolExecution option. Resolve mono-agent's programmatic mode once per run;
86
+ * individual tool builders then mark stateful/mutating tools sequential.
87
+ */
88
+ export function resolvePiToolExecutionMode(options = {}) {
89
+ const warnings = [];
90
+ const requested = options.piToolExecutionMode;
91
+ let mode = requested === "sequential" || requested === "safe-parallel"
92
+ ? requested
93
+ : null;
94
+ if (requested !== undefined && mode === null) {
95
+ warnings.push({
96
+ warning_kind: "invalid_pi_tool_execution_mode",
97
+ message: `Unknown piToolExecutionMode ${JSON.stringify(requested)}; using safe-parallel.`,
98
+ });
99
+ }
100
+ if (options.piToolParallelismMode !== undefined) {
101
+ warnings.push({
102
+ warning_kind: "deprecated_pi_tool_parallelism_mode",
103
+ message: "piToolParallelismMode is deprecated; use piToolExecutionMode (sequential or safe-parallel).",
104
+ });
105
+ if (mode === null) {
106
+ mode = options.piToolParallelismMode === "one-at-a-time"
107
+ ? "sequential"
108
+ : "safe-parallel";
109
+ }
110
+ }
111
+ return { mode: mode || "safe-parallel", warnings };
112
+ }
113
+
81
114
  async function resolveApiKey(provider, { apiKeys, resolvePiApiKey, runtimeWarnings }) {
82
115
  if (apiKeys?.has(provider)) return apiKeys.get(provider);
83
116
  if (typeof resolvePiApiKey !== "function") return undefined;
@@ -307,13 +340,21 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
307
340
  const piTransport = resolvePiTransport(options.piTransport);
308
341
 
309
342
  const onEvent = (event) => emitCaptured(events, options.onEvent, event);
343
+ const approvalRiskTiers = {
344
+ ...(options.toolRiskTiers || {}),
345
+ ...(
346
+ options.toolRiskTiers?.Exec === undefined && options.toolRiskTiers?.Bash !== undefined
347
+ ? { Exec: options.toolRiskTiers.Bash }
348
+ : {}
349
+ ),
350
+ };
310
351
  const approvalManager = options.onToolApprovalRequest
311
352
  ? createApprovalManager({
312
353
  onToolApprovalRequest: options.onToolApprovalRequest,
313
354
  defaultRiskTier: options.approvalDefaultRiskTier,
314
355
  timeoutMs: options.approvalTimeoutMs,
315
356
  onEvent,
316
- riskTiersByTool: options.toolRiskTiers,
357
+ riskTiersByTool: approvalRiskTiers,
317
358
  alwaysAllowTools: options.approvalAlwaysAllowTools,
318
359
  })
319
360
  : null;
@@ -396,6 +437,11 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
396
437
  // normalization. Restores configurable clamping (toolTextLimitChars,
397
438
  // searchResultLimit, ...) on top of the 256KB hard ceiling.
398
439
  const toolLimits = resolveAgentCompactionPolicy(settingsLike, runtime.model);
440
+ const toolExecution = resolvePiToolExecutionMode(options);
441
+ for (const warning of toolExecution.warnings) {
442
+ runtimeWarnings.push(warning);
443
+ onEvent({ type: "runtime_warning", ...warning });
444
+ }
399
445
 
400
446
  // Build the turn's tools (builtins + MCP bridge + StructuredOutput). The
401
447
  // StructuredOutput callback writes runState.structuredResult; the MCP clients
@@ -414,6 +460,7 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
414
460
  resolved,
415
461
  onEvent,
416
462
  runtimeWarnings,
463
+ toolExecutionMode: toolExecution.mode,
417
464
  });
418
465
  mcpClients = builtMcpClients;
419
466
  closeRunTools = builtCloseRunTools;
@@ -426,10 +473,9 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
426
473
  const maxRetryDelayMs = Number.isFinite(Number(options.maxRetryDelayMs))
427
474
  ? Number(options.maxRetryDelayMs)
428
475
  : 60_000;
429
- // Tool steering: default "one-at-a-time" (safe, deterministic ordering).
430
- // Opt-in "all" lets pi-agent-core run a model step's tool calls concurrently
431
- // (QueueMode). Only enable when tools in a step are independent.
432
- const toolSteeringMode = options.piToolParallelismMode === "all" ? "all" : "one-at-a-time";
476
+ // Steering controls user follow-up delivery, not tool scheduling. Keep it
477
+ // independent from piToolExecutionMode; tools carry their own executionMode.
478
+ const toolSteeringMode = "one-at-a-time";
433
479
 
434
480
  const piModels = buildRunModels(runtime, options, runtimeWarnings);
435
481
 
@@ -437,7 +483,6 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
437
483
  // external abort handler (which sets runState.externalAbort and aborts the
438
484
  // harness). Sets runState.harness + runState.removeAbortHandler.
439
485
  harness = buildTurnHarness(runState, {
440
- cwd: options.cwd,
441
486
  session: runState.session,
442
487
  piModels,
443
488
  model: runtime.model,
@@ -665,8 +710,23 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
665
710
  promptCacheActive: usage.cacheRead > 0 || usage.cacheWrite > 0,
666
711
  thinkingEnabled: effectiveThinkingLevel !== "off" && effectiveThinkingLevel !== "low",
667
712
  structuredOutputEnforced: !!options.outputSchema,
668
- 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,
669
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.
670
730
  nativeSubagentsUsed: [],
671
731
  toolCompactionApplied: toolCompactionAppliedFromWarnings(runtimeWarnings),
672
732
  // Tristate: true = a compaction fired this run (proactive or reactive),