@oh-my-pi/pi-coding-agent 17.0.2 → 17.0.3

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 (73) hide show
  1. package/CHANGELOG.md +70 -20
  2. package/dist/cli.js +4087 -4108
  3. package/dist/types/config/settings-schema.d.ts +7 -3
  4. package/dist/types/eval/agent-bridge.d.ts +7 -19
  5. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +15 -1
  6. package/dist/types/lsp/client.d.ts +2 -0
  7. package/dist/types/lsp/types.d.ts +3 -0
  8. package/dist/types/mcp/transports/stdio.d.ts +29 -0
  9. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  10. package/dist/types/registry/persisted-agents.d.ts +3 -0
  11. package/dist/types/sdk.d.ts +14 -3
  12. package/dist/types/session/session-entries.d.ts +6 -1
  13. package/dist/types/session/session-manager.d.ts +5 -0
  14. package/dist/types/task/executor.d.ts +26 -1
  15. package/dist/types/task/index.d.ts +1 -1
  16. package/dist/types/task/parallel.d.ts +14 -0
  17. package/dist/types/task/structured-subagent.d.ts +111 -0
  18. package/dist/types/task/types.d.ts +51 -0
  19. package/dist/types/tools/fetch.d.ts +4 -5
  20. package/dist/types/tools/hub/messaging.d.ts +5 -1
  21. package/dist/types/tools/index.d.ts +16 -1
  22. package/dist/types/tools/todo.d.ts +31 -0
  23. package/dist/types/tui/tree-list.d.ts +7 -0
  24. package/dist/types/utils/markit.d.ts +11 -0
  25. package/package.json +12 -12
  26. package/src/cli/file-processor.ts +1 -2
  27. package/src/config/settings-schema.ts +4 -3
  28. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  29. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  30. package/src/eval/agent-bridge.ts +104 -477
  31. package/src/eval/jl/prelude.jl +7 -6
  32. package/src/eval/js/shared/prelude.txt +5 -4
  33. package/src/eval/py/prelude.py +11 -39
  34. package/src/eval/rb/prelude.rb +5 -6
  35. package/src/extensibility/legacy-pi-coding-agent-shim.ts +53 -7
  36. package/src/lsp/client.ts +57 -0
  37. package/src/lsp/index.ts +62 -6
  38. package/src/lsp/types.ts +3 -0
  39. package/src/mcp/oauth-flow.ts +20 -0
  40. package/src/mcp/transports/stdio.test.ts +269 -1
  41. package/src/mcp/transports/stdio.ts +152 -1
  42. package/src/modes/components/agent-hub.ts +1 -72
  43. package/src/modes/components/bash-execution.ts +7 -3
  44. package/src/modes/components/eval-execution.ts +3 -1
  45. package/src/modes/interactive-mode.ts +30 -8
  46. package/src/prompts/system/system-prompt.md +1 -1
  47. package/src/prompts/tools/eval.md +5 -2
  48. package/src/prompts/tools/read.md +1 -1
  49. package/src/prompts/tools/task.md +12 -8
  50. package/src/prompts/tools/write.md +1 -1
  51. package/src/registry/persisted-agents.ts +74 -0
  52. package/src/sdk.ts +129 -87
  53. package/src/session/agent-session.ts +75 -21
  54. package/src/session/session-entries.ts +6 -1
  55. package/src/session/session-manager.ts +9 -0
  56. package/src/session/streaming-output.ts +41 -1
  57. package/src/system-prompt.ts +7 -2
  58. package/src/task/executor.ts +99 -21
  59. package/src/task/index.ts +258 -429
  60. package/src/task/parallel.ts +43 -0
  61. package/src/task/persisted-revive.ts +19 -7
  62. package/src/task/structured-subagent.ts +642 -0
  63. package/src/task/types.ts +58 -0
  64. package/src/tools/__tests__/eval-description.test.ts +1 -1
  65. package/src/tools/fetch.ts +28 -105
  66. package/src/tools/hub/messaging.ts +16 -3
  67. package/src/tools/index.ts +47 -14
  68. package/src/tools/path-utils.ts +1 -0
  69. package/src/tools/read.ts +14 -26
  70. package/src/tools/todo.ts +126 -13
  71. package/src/tui/tree-list.ts +39 -0
  72. package/src/utils/markit.ts +12 -0
  73. package/src/utils/zip.ts +16 -1
@@ -1,32 +1,15 @@
1
1
  /**
2
2
  * Host-side handler for the eval `agent()` helper.
3
3
  */
4
- import * as fs from "node:fs/promises";
5
- import * as os from "node:os";
6
- import * as path from "node:path";
7
- import { prompt, Snowflake } from "@oh-my-pi/pi-utils";
8
4
  import { type } from "arktype";
9
- import { resolveAgentModelPatterns } from "../config/model-resolver";
10
- import type { LocalProtocolOptions } from "../internal-urls";
11
- import { registerArtifactsDir } from "../internal-urls/registry-helpers";
12
- import { MCPManager } from "../mcp/manager";
13
- import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
14
- import { MAIN_AGENT_ID } from "../registry/agent-registry";
15
- import * as taskDiscovery from "../task/discovery";
16
- import type { ExecutorOptions } from "../task/executor";
17
- import * as taskExecutor from "../task/executor";
18
5
  import {
19
- applyEligibleNestedPatches,
20
- type IsolationContext,
21
- makeIsolationCommitMessage,
22
- mergeIsolatedChanges,
23
- prepareIsolationContext,
24
- runIsolatedSubprocess,
25
- } from "../task/isolation-runner";
26
- import { AgentOutputManager } from "../task/output-manager";
27
- import { resolveSpawnPolicy } from "../task/spawn-policy";
28
- import { type AgentDefinition, type AgentProgress, canSpawnAtDepth, type SingleResult } from "../task/types";
29
- import { type NestedRepoPatch, parseIsolationMode } from "../task/worktree";
6
+ buildStructuredSubagentRecoveryHint,
7
+ runStructuredSubagent,
8
+ StructuredSubagentError,
9
+ type StructuredSubagentSchemaMode,
10
+ } from "../task/structured-subagent";
11
+ import type { AgentProgress, SingleResult } from "../task/types";
12
+ import type { NestedRepoPatch } from "../task/worktree";
30
13
  import type { ToolSession } from "../tools";
31
14
  import { ToolError } from "../tools/tool-errors";
32
15
  import { withBridgeTimeoutPause } from "./bridge-timeout";
@@ -37,21 +20,13 @@ import "../tools/review";
37
20
  /** Synthetic bridge name reserved for the `agent()` helper across both runtimes. */
38
21
  export const EVAL_AGENT_BRIDGE_NAME = "__agent__";
39
22
 
40
- /**
41
- * Hard recursion ceiling for eval-driven subagents. The user setting
42
- * `task.maxRecursionDepth` is honored on top of this — whichever is tighter
43
- * wins, so a maintainer-friendly cap can't get raised by a user setting.
44
- */
45
- export const EVAL_AGENT_MAX_DEPTH = 3;
46
-
47
- const DEFAULT_AGENT_LABEL = "EvalAgent";
48
-
49
23
  const agentArgsSchema = type({
50
24
  prompt: "string>0",
51
25
  "agent?": "string>0",
52
26
  "model?": "string>0|string>0[]",
53
27
  "label?": "string",
54
28
  "schema?": "unknown",
29
+ "schemaMode?": "'permissive' | 'strict'",
55
30
  "isolated?": "boolean",
56
31
  "apply?": "boolean",
57
32
  "merge?": "boolean",
@@ -64,30 +39,10 @@ interface EvalAgentArgs {
64
39
  model?: string | string[];
65
40
  label?: string;
66
41
  schema?: unknown;
67
- /**
68
- * Run this subagent inside an isolation worktree (copy-on-write of the
69
- * parent repo). Strict opt-in: defaults to `false` regardless of the
70
- * session's `task.isolation.mode`, mirroring the `task` tool. Passing
71
- * `true` while `task.isolation.mode === "none"` errors out instead of
72
- * silently downgrading.
73
- */
42
+ schemaMode?: StructuredSubagentSchemaMode;
74
43
  isolated?: boolean;
75
- /**
76
- * When isolated, apply the captured patch / merge the captured branch back
77
- * to the parent repo (default `true`). Pass `false` to keep changes in the
78
- * isolation worktree only — the patch artifact path / branch name lands in
79
- * the result so the caller can inspect or apply manually.
80
- */
81
44
  apply?: boolean;
82
- /**
83
- * When isolated, allow branch-merge mode (cherry-pick onto HEAD). Defaults
84
- * to `true`, in which case the active `task.isolation.merge` setting picks
85
- * patch vs branch. Pass `false` to force patch mode even when the setting
86
- * is `"branch"` — useful when a fan-out cannot tolerate the per-call git
87
- * lock + repo mutation that branch mode performs.
88
- */
89
45
  merge?: boolean;
90
- /** True when a runtime helper will return an `agent://` handle backed by the output artifacts. */
91
46
  handle?: boolean;
92
47
  }
93
48
 
@@ -99,28 +54,21 @@ export interface EvalAgentBridgeOptions {
99
54
 
100
55
  export interface EvalAgentResult {
101
56
  text: string;
57
+ /** Parsed structured data returned by the child executor. */
58
+ data?: unknown;
102
59
  details: {
103
60
  agent: string;
104
61
  id: string;
105
62
  model?: string | string[];
106
63
  structured: boolean;
107
- /** True iff this run executed inside an isolation worktree. */
64
+ schemaSource?: "caller" | "agent" | "session";
65
+ schemaMode?: StructuredSubagentSchemaMode;
66
+ schemaStatus?: "valid" | "invalid";
108
67
  isolated?: boolean;
109
- /** Captured patch artifact (patch mode) — surfaced regardless of `apply`. */
110
68
  patchPath?: string;
111
- /** Captured branch (branch mode) — surfaced regardless of `apply`. */
112
69
  branchName?: string;
113
- /** Captured nested repository patches — surfaced for isolated `apply=false` manual application. */
114
70
  nestedPatches?: NestedRepoPatch[];
115
- /**
116
- * Tri-state apply outcome for isolated runs:
117
- * - `true` — apply ran (or had nothing to do) and left the repo clean.
118
- * - `false` — apply attempted and failed; artifacts preserved.
119
- * - `null` — caller opted out via `apply=false`.
120
- * Omitted for non-isolated runs.
121
- */
122
71
  changesApplied?: boolean | null;
123
- /** Human-readable isolation apply/merge summary; kept out of schema-backed `text`. */
124
72
  isolationSummary?: string;
125
73
  };
126
74
  }
@@ -133,136 +81,11 @@ function parseAgentArgs(args: unknown): EvalAgentArgs {
133
81
  return result;
134
82
  }
135
83
 
136
- function assertDepthAllowed(session: ToolSession): void {
137
- const taskDepth = session.taskDepth ?? 0;
138
- // Honor the user's `task.maxRecursionDepth` (mirroring the task tool's gate
139
- // in tools/index.ts) but never above the hard ceiling. `< 0` means
140
- // "Unlimited" in the same schema `canSpawnAtDepth` reads, so it falls back
141
- // to the hard ceiling instead of going past it.
142
- const settingMax = session.settings.get("task.maxRecursionDepth") ?? 2;
143
- const effectiveMax = settingMax < 0 ? EVAL_AGENT_MAX_DEPTH : Math.min(settingMax, EVAL_AGENT_MAX_DEPTH);
144
- if (!canSpawnAtDepth(effectiveMax, taskDepth)) {
145
- throw new ToolError(
146
- `agent() cannot spawn another agent at task depth ${taskDepth}; maximum depth is ${effectiveMax} (task.maxRecursionDepth=${settingMax}, hard ceiling=${EVAL_AGENT_MAX_DEPTH}).`,
147
- );
148
- }
149
- }
150
-
151
- function assertSpawnAllowed(session: ToolSession, agentName: string): void {
152
- const spawnPolicy = resolveSpawnPolicy(session.getSessionSpawns());
153
- if (!spawnPolicy.enabled) {
154
- throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
155
- }
156
- if (spawnPolicy.allowedAgents !== null && !spawnPolicy.allowedAgents.includes(agentName)) {
157
- throw new ToolError(`Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}`);
158
- }
159
- }
160
-
161
- function assertAgentEnabled(session: ToolSession, agentName: string, agents: AgentDefinition[]): void {
162
- const disabledAgents = session.settings.get("task.disabledAgents") as string[];
163
- if (!disabledAgents.includes(agentName)) return;
164
- const enabled = agents.filter(agent => !disabledAgents.includes(agent.name)).map(agent => agent.name);
165
- throw new ToolError(
166
- `Agent "${agentName}" is disabled in settings. Enable it via /agents, or use a different agent type.${enabled.length > 0 ? ` Available: ${enabled.join(", ")}` : ""}`,
167
- );
168
- }
169
-
170
- function assertNotPlanMode(session: ToolSession): void {
171
- if (session.getPlanModeState?.()?.enabled) {
172
- throw new ToolError("agent() is unavailable in plan mode.");
173
- }
174
- }
175
-
176
- function renderSubagentPrompt(assignment: string): string {
177
- return prompt.render(subagentUserPromptTemplate, { assignment: assignment.trim() });
178
- }
179
-
180
84
  function trimToUndefined(value: string | undefined): string | undefined {
181
85
  const trimmed = value?.trim();
182
86
  return trimmed ? trimmed : undefined;
183
87
  }
184
88
 
185
- function outputIdBase(label: string | undefined, agentName: string): string {
186
- const source = trimToUndefined(label) ?? agentName ?? DEFAULT_AGENT_LABEL;
187
- const sanitized = source.replace(/[^A-Za-z0-9_-]+/g, "").slice(0, 48);
188
- return sanitized || DEFAULT_AGENT_LABEL;
189
- }
190
-
191
- function getOutputManager(session: ToolSession): AgentOutputManager {
192
- if (session.agentOutputManager) return session.agentOutputManager;
193
- const manager = new AgentOutputManager(session.getArtifactsDir ?? (() => null));
194
- session.agentOutputManager = manager;
195
- return manager;
196
- }
197
-
198
- interface ArtifactPaths {
199
- sessionFile: string | null;
200
- artifactsDir: string;
201
- unregisterArtifactsDir?: () => void;
202
- /**
203
- * True when `artifactsDir` was created off the session path (no session
204
- * file). Caller is then free to `rm -rf` it once all isolated patch
205
- * artifacts have been consumed or applied.
206
- */
207
- tempArtifactsDir: boolean;
208
- }
209
-
210
- async function getArtifacts(session: ToolSession): Promise<ArtifactPaths> {
211
- const sessionFile = session.getSessionFile();
212
- const sessionArtifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
213
- const tempArtifactsDir = sessionArtifactsDir === null;
214
- const artifactsDir = sessionArtifactsDir ?? path.join(os.tmpdir(), `omp-eval-agent-${Snowflake.next()}`);
215
- await fs.mkdir(artifactsDir, { recursive: true });
216
- const unregisterArtifactsDir = tempArtifactsDir ? registerArtifactsDir(artifactsDir) : undefined;
217
- return { sessionFile, artifactsDir, unregisterArtifactsDir, tempArtifactsDir };
218
- }
219
-
220
- /**
221
- * Persist nested-repo patches to the per-call artifacts dir so an isolated
222
- * apply failure can surface their paths in the thrown ToolError. The
223
- * isolation worktree is already gone by the time we run, so without this the
224
- * captured nested patches would be unrecoverable.
225
- */
226
- async function persistNestedPatches(
227
- artifactsDir: string,
228
- agentId: string,
229
- nestedPatches: NestedRepoPatch[],
230
- ): Promise<string[]> {
231
- const written: string[] = [];
232
- for (let index = 0; index < nestedPatches.length; index++) {
233
- const patch = nestedPatches[index];
234
- if (!patch) continue;
235
- const slug = patch.relativePath.replace(/[^A-Za-z0-9._-]+/g, "_") || `nested-${index}`;
236
- const out = path.join(artifactsDir, `${agentId}.nested-${index}-${slug}.patch`);
237
- await Bun.write(out, patch.patch);
238
- written.push(out);
239
- }
240
- return written;
241
- }
242
-
243
- /**
244
- * Assemble the "captured X preserved at Y" recovery hint appended to
245
- * isolated-run failure messages. Persists nested-repo patches to
246
- * `artifactsDir` when present so their paths can be surfaced. Returns an
247
- * empty string when the result carries no salvageable artifacts.
248
- */
249
- async function buildIsolationRecoveryHint(result: SingleResult, artifactsDir: string): Promise<string> {
250
- const parts: string[] = [];
251
- if (result.patchPath) parts.push(`Captured patch preserved at ${result.patchPath}.`);
252
- if (result.branchName) parts.push(`Captured branch preserved as ${result.branchName}.`);
253
- if (result.nestedPatches?.length) {
254
- const nestedPaths = await persistNestedPatches(artifactsDir, result.id, result.nestedPatches);
255
- parts.push(
256
- `Captured nested repository patches (${result.nestedPatches.length}) preserved at: ${nestedPaths.join(", ")}.`,
257
- );
258
- }
259
- return parts.length > 0 ? ` ${parts.join(" ")}` : "";
260
- }
261
-
262
- function plainIsolationSummary(summary: string): string {
263
- return summary.replace(/<\/?system-notification>/g, "").trim();
264
- }
265
-
266
89
  function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undefined, progress: AgentProgress): void {
267
90
  if (!emitStatus) return;
268
91
  const preview = (progress.assignment ?? progress.task ?? "").split("\n")[0]?.slice(0, 120);
@@ -285,15 +108,6 @@ function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undef
285
108
  });
286
109
  }
287
110
 
288
- /**
289
- * Coalesce a subagent failure into a non-empty, human-meaningful error message.
290
- *
291
- * When the executor aborts a subagent (runtime limit, parent cancellation, …)
292
- * the actionable explanation lives on `abortReason`, while `error`/`stderr`
293
- * are routinely empty strings. Plain `??` coalescing stops at the empty string
294
- * and ships an empty error through the bridge — Python then surfaces only the
295
- * generic `bridge call '__agent__' failed`. See #2006.
296
- */
297
111
  function buildSubagentFailureMessage(agentName: string, result: SingleResult): string {
298
112
  const abortReason = trimToUndefined(result.abortReason);
299
113
  if (result.aborted && abortReason) return abortReason;
@@ -310,288 +124,101 @@ function buildSubagentFailureMessage(agentName: string, result: SingleResult): s
310
124
  */
311
125
  export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOptions): Promise<EvalAgentResult> {
312
126
  const parsed = parseAgentArgs(args);
313
- const agentName = parsed.agent ?? resolveSpawnPolicy(options.session.getSessionSpawns()).defaultAgent;
314
- const structured = Object.hasOwn(parsed, "schema");
315
-
316
- assertNotPlanMode(options.session);
317
- assertDepthAllowed(options.session);
318
- assertSpawnAllowed(options.session, agentName);
319
-
320
127
  const turnBudget = options.session.getTurnBudget?.();
321
128
  if (turnBudget?.hard && turnBudget.total !== null && turnBudget.spent >= turnBudget.total) {
322
129
  throw new ToolError(
323
130
  `agent() blocked: turn token budget exhausted (${turnBudget.spent}/${turnBudget.total} output tokens). Raise or drop the +Nk! ceiling to continue.`,
324
131
  );
325
132
  }
326
-
327
- const { agents } = await taskDiscovery.discoverAgents(options.session.cwd);
328
- const agent = taskDiscovery.getAgent(agents, agentName);
329
- if (!agent) {
330
- const available = agents.map(candidate => candidate.name).join(", ") || "none";
331
- throw new ToolError(`Unknown agent "${agentName}". Available: ${available}`);
332
- }
333
- assertAgentEnabled(options.session, agentName, agents);
334
-
335
- const effectiveAgent = agent;
336
- const parentActiveModelPattern = options.session.getActiveModelString?.();
337
- const agentModelOverrides = options.session.settings.get("task.agentModelOverrides");
338
- const modelOverride = resolveAgentModelPatterns({
339
- settingsOverride: parsed.model ?? agentModelOverrides[agentName],
340
- agentModel: effectiveAgent.model,
341
- settings: options.session.settings,
342
- activeModelPattern: parentActiveModelPattern,
343
- fallbackModelPattern: options.session.getModelString?.(),
344
- });
345
- const availableSkills = [...(options.session.skills ?? [])];
346
- const resolvedAutoloadSkills =
347
- effectiveAgent.autoloadSkills?.length && availableSkills.length > 0
348
- ? effectiveAgent.autoloadSkills
349
- .map(name => availableSkills.find(skill => skill.name === name))
350
- .filter((skill): skill is NonNullable<typeof skill> => skill !== undefined)
351
- : [];
352
- const contextFiles = options.session.contextFiles?.filter(
353
- file => path.basename(file.path).toLowerCase() !== "agents.md",
354
- );
355
- const localProtocolOptions: LocalProtocolOptions = options.session.localProtocolOptions ?? {
356
- getArtifactsDir: options.session.getArtifactsDir ?? (() => null),
357
- getSessionId: options.session.getSessionId ?? (() => null),
358
- };
359
- const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined;
360
- const mcpManager = options.session.mcpManager ?? MCPManager.instance();
361
- const { sessionFile, artifactsDir, unregisterArtifactsDir, tempArtifactsDir } = await getArtifacts(options.session);
362
- const outputManager = getOutputManager(options.session);
363
- const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
364
- const assignment = parsed.prompt.trim();
365
-
366
- // Isolation gating. Strict opt-in: only the explicit `isolated=true`
367
- // argument turns it on; `task.isolation.mode` no longer drives the
368
- // default. Mirrors the `task` tool so eval `agent()` and `task` callers
369
- // see the same semantic. `isolated=true` while the mode is `"none"`
370
- // surfaces a clear error instead of silently downgrading.
371
- const isolationMode = options.session.settings.get("task.isolation.mode");
372
- const isolationEnabledInSettings = isolationMode !== "none";
373
- if (parsed.isolated === true && !isolationEnabledInSettings) {
374
- throw new ToolError(`agent(isolated=True) requires task.isolation.mode to be set; current mode is "none".`);
375
- }
376
- const isIsolated = parsed.isolated === true;
377
- const settingsMergeMode = options.session.settings.get("task.isolation.merge");
378
- const mergeMode: "patch" | "branch" = parsed.merge === false ? "patch" : settingsMergeMode;
379
- const applyChanges = parsed.apply !== false;
380
-
381
- // Isolation context capture (prepareIsolationContext → captureBaseline)
382
- // happens inside the timeout-pause closure below; on dirty/large repos the
383
- // baseline walk can run long and must stay covered by the eval idle
384
- // suspension.
385
-
386
- const buildCommitMessage = makeIsolationCommitMessage(options.session);
387
-
388
- const baseRunOptions: ExecutorOptions = {
389
- cwd: options.session.cwd,
390
- agent: effectiveAgent,
391
- task: renderSubagentPrompt(assignment),
392
- assignment,
393
- description: trimToUndefined(parsed.label),
394
- index: 0,
395
- id,
396
- taskDepth: options.session.taskDepth ?? 0,
397
- modelOverride,
398
- parentActiveModelPattern,
399
- thinkingLevel: effectiveAgent.thinkingLevel,
400
- ...(structured ? { outputSchema: parsed.schema, outputSchemaOverridesAgent: true } : {}),
401
- sessionFile,
402
- persistArtifacts: Boolean(sessionFile),
403
- artifactsDir,
404
- // Eval `agent()` subagents are short-lived programmatic helpers (data
405
- // collection, structured output, parallel() fan-out). LSP server
406
- // cold-start costs tens of seconds and is pure overhead here, so it is
407
- // forced off regardless of the `task.enableLsp` setting — that knob only
408
- // governs LSP-aware delegation through the `task` tool.
409
- enableLsp: false,
410
- signal: options.signal,
411
- eventBus: options.session.eventBus,
412
- onProgress: progress => emitProgressStatus(options.emitStatus, progress),
413
- authStorage: options.session.authStorage,
414
- modelRegistry: options.session.modelRegistry,
415
- settings: options.session.settings,
416
- // Eval `agent()` subagents are never wall-clock capped: the parent
417
- // cell's idle watchdog is suspended for the whole bridge call
418
- // (withBridgeTimeoutPause), so a long-running phase/recovery workflow
419
- // must not be killed by `task.maxRuntimeMs`. Force the limit off
420
- // regardless of the inherited session setting.
421
- maxRuntimeMs: 0,
422
- keepAlive: false,
423
- mcpManager,
424
- contextFiles,
425
- skills: availableSkills,
426
- autoloadSkills: resolvedAutoloadSkills,
427
- workspaceTree: options.session.workspaceTree,
428
- promptTemplates: options.session.promptTemplates,
429
- localProtocolOptions,
430
- parentArtifactManager,
431
- parentHindsightSessionState: options.session.getHindsightSessionState?.(),
432
- parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
433
- parentTelemetry: options.session.getTelemetry?.(),
434
- parentAgentId: options.session.getAgentId?.() ?? MAIN_AGENT_ID,
435
- // Live source of truth for `tier.subagent: inherit` (null = explicit none).
436
- parentServiceTier: options.session.getServiceTierByFamily
437
- ? (options.session.getServiceTierByFamily() ?? null)
438
- : undefined,
439
- // Deliberately omit parentEvalSessionId: the parent's Python kernel is
440
- // blocked on this bridge call, so sharing the eval session would deadlock
441
- // (subagent queues behind the parent's in-flight execution, parent waits
442
- // for subagent → circular). Each bridge-spawned subagent gets its own
443
- // eval session with an independent kernel.
444
- };
445
-
446
- // Suspend eval timeout accounting through the WHOLE bridge call: the
447
- // subagent subprocess plus any isolation post-processing (merge,
448
- // nested-patch apply, cleanup). All of that is host-side work while the
449
- // runtime is parked waiting for the result, and the cell timeout must
450
- // not abort us mid-cherry-pick or mid-nested-commit. The clock restarts
451
- // only after we hand control back to the runtime.
452
- const { result, mergeSummary, changesApplied } = await withBridgeTimeoutPause(
453
- options.emitStatus,
454
- async () => {
455
- let isolationContext: IsolationContext | null = null;
456
- if (isIsolated) {
457
- try {
458
- isolationContext = await prepareIsolationContext(options.session.cwd);
459
- } catch (err) {
460
- const message = err instanceof Error ? err.message : String(err);
461
- throw new ToolError(`Isolated agent() execution requires a git repository. ${message}`);
462
- }
463
- }
464
- const preferredBackend = isIsolated ? parseIsolationMode(isolationMode) : undefined;
465
-
466
- const result = await (async () => {
467
- if (!isolationContext) {
468
- return taskExecutor.runSubprocess(baseRunOptions);
469
- }
470
- const taskStart = Date.now();
471
- return runIsolatedSubprocess({
472
- baseOptions: baseRunOptions,
473
- context: isolationContext,
474
- preferredBackend,
475
- agentId: id,
476
- mergeMode,
477
- artifactsDir,
478
- description: trimToUndefined(parsed.label),
479
- buildCommitMessage,
480
- buildFailureResult: err => {
481
- const message = err instanceof Error ? err.message : String(err);
482
- return {
483
- index: 0,
484
- id,
485
- agent: effectiveAgent.name,
486
- agentSource: effectiveAgent.source,
487
- task: renderSubagentPrompt(assignment),
488
- assignment,
489
- description: trimToUndefined(parsed.label),
490
- exitCode: 1,
491
- output: "",
492
- stderr: message,
493
- truncated: false,
494
- durationMs: Date.now() - taskStart,
495
- tokens: 0,
496
- requests: 0,
497
- modelOverride,
498
- error: message,
499
- };
500
- },
501
- });
502
- })();
503
-
504
- if (result.exitCode !== 0 || result.error || result.aborted) {
505
- const failureMessage = buildSubagentFailureMessage(agentName, result);
506
- const recoveryHint = isIsolated ? await buildIsolationRecoveryHint(result, artifactsDir) : "";
507
- throw new ToolError(`${failureMessage}${recoveryHint}`);
508
- }
509
-
510
- let mergeSummary = "";
511
- let changesApplied: boolean | null = null;
512
- if (isIsolated && isolationContext) {
513
- if (applyChanges) {
514
- const outcome = await mergeIsolatedChanges({
515
- result,
516
- repoRoot: isolationContext.repoRoot,
517
- mergeMode,
518
- });
519
- mergeSummary = outcome.summary;
520
- changesApplied = outcome.changesApplied;
521
- if (outcome.changesApplied === false) {
522
- const summaryText = outcome.summary.trim();
523
- const recoveryHint = await buildIsolationRecoveryHint(result, artifactsDir);
524
- throw new ToolError(
525
- `agent() isolated apply failed for ${result.id}${summaryText ? `: ${summaryText}` : ""}${recoveryHint}`,
526
- );
527
- }
528
-
529
- const nestedSummary = await applyEligibleNestedPatches({
530
- result,
531
- repoRoot: isolationContext.repoRoot,
532
- mergeMode,
533
- changesApplied: outcome.changesApplied,
534
- mergedBranchForNestedPatches: outcome.mergedBranchForNestedPatches,
535
- commitMessage: buildCommitMessage(),
536
- });
537
- mergeSummary += nestedSummary;
538
- if (structured && nestedSummary.trim()) {
539
- const recoveryHint = await buildIsolationRecoveryHint(
540
- { ...result, patchPath: undefined, branchName: undefined },
541
- artifactsDir,
542
- );
543
- throw new ToolError(
544
- `agent() isolated nested patch apply failed for ${result.id}: ${plainIsolationSummary(nestedSummary)}${recoveryHint}`,
545
- );
546
- }
547
- } else if (result.branchName) {
548
- mergeSummary = `\n\nIsolation: changes captured on branch \`${result.branchName}\` (apply=false). Not merged.`;
549
- } else if (result.patchPath) {
550
- mergeSummary = `\n\nIsolation: changes captured at \`${result.patchPath}\` (apply=false). Not applied.`;
551
- } else {
552
- const nestedPatches = result.nestedPatches ?? [];
553
- if (nestedPatches.length > 0) {
554
- mergeSummary = `\n\nIsolation: changes captured for ${nestedPatches.length} nested repositor${nestedPatches.length === 1 ? "y" : "ies"} (apply=false). Not applied.`;
555
- } else {
556
- mergeSummary = "\n\nIsolation: no changes captured.";
557
- }
133
+ const isolation =
134
+ Object.hasOwn(parsed, "isolated") || Object.hasOwn(parsed, "apply") || Object.hasOwn(parsed, "merge")
135
+ ? {
136
+ ...(parsed.isolated !== undefined ? { requested: parsed.isolated } : {}),
137
+ ...(parsed.merge === false ? { merge: "patch" as const } : {}),
138
+ ...(parsed.apply !== undefined ? { apply: parsed.apply } : {}),
558
139
  }
559
- }
560
-
561
- // Clean up the temp artifacts dir we created for this call only when the
562
- // caller will not need files from it later. Keep it when the runtime helper
563
- // will return an `agent://` handle (the `.md`/`.jsonl` backing files live
564
- // here) and on `apply=false` (`changesApplied === null`) where the caller
565
- // consumes `details.patchPath` / `details.branchName` /
566
- // `details.nestedPatches` out of band. Failed isolated applies throw
567
- // earlier with a recovery hint, so they never reach this gate.
568
- const shouldCleanupTempArtifacts =
569
- tempArtifactsDir && !parsed.handle && (!isIsolated || changesApplied === true);
570
- if (shouldCleanupTempArtifacts) {
571
- await fs.rm(artifactsDir, { recursive: true, force: true });
572
- unregisterArtifactsDir?.();
573
- }
574
-
575
- options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0);
576
-
577
- return { result, mergeSummary, changesApplied };
578
- },
579
- { deferExternalAbort: true },
580
- );
581
-
582
- return {
583
- text: structured ? result.output : result.output + mergeSummary,
584
- details: {
585
- agent: result.agent,
586
- id: result.id,
587
- model: result.resolvedModel ?? modelOverride,
588
- structured,
589
- isolated: isIsolated || undefined,
590
- patchPath: result.patchPath,
591
- branchName: result.branchName,
592
- nestedPatches: result.nestedPatches?.length ? result.nestedPatches : undefined,
593
- changesApplied: isIsolated ? changesApplied : undefined,
594
- isolationSummary: mergeSummary ? mergeSummary.trim() : undefined,
595
- },
596
- };
140
+ : undefined;
141
+
142
+ try {
143
+ const execution = await withBridgeTimeoutPause(
144
+ options.emitStatus,
145
+ () =>
146
+ runStructuredSubagent({
147
+ session: options.session,
148
+ invocationKind: "eval",
149
+ assignment: parsed.prompt,
150
+ ...(parsed.agent !== undefined ? { agent: parsed.agent } : {}),
151
+ ...(parsed.model !== undefined ? { model: parsed.model } : {}),
152
+ ...(Object.hasOwn(parsed, "schema") ? { outputSchema: parsed.schema } : {}),
153
+ ...(parsed.schemaMode !== undefined ? { schemaMode: parsed.schemaMode } : {}),
154
+ ...(parsed.label !== undefined ? { identity: { label: parsed.label } } : {}),
155
+ ...(isolation ? { isolation } : {}),
156
+ ...(parsed.handle ? { retainArtifacts: true } : {}),
157
+ keepAlive: false,
158
+ maxRuntimeMs: 0,
159
+ shareEvalSession: false,
160
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
161
+ ...(options.emitStatus
162
+ ? { onProgress: (progress: AgentProgress) => emitProgressStatus(options.emitStatus, progress) }
163
+ : {}),
164
+ }),
165
+ { deferExternalAbort: true },
166
+ );
167
+ const { result, policy, mergeSummary, changesApplied, artifactsDir } = execution;
168
+ if (result.exitCode !== 0 || result.error || result.aborted) {
169
+ const failureMessage = buildSubagentFailureMessage(policy.agentName, result)
170
+ .replace(/<\/?system-notification>/g, "")
171
+ .trim();
172
+ const recoveryHint = policy.isIsolated ? await buildStructuredSubagentRecoveryHint(result, artifactsDir) : "";
173
+ throw new ToolError(`${failureMessage}${recoveryHint}`);
174
+ }
175
+ if (policy.isIsolated && changesApplied === false) {
176
+ const summary = mergeSummary.replace(/<\/?system-notification>/g, "").trim();
177
+ const recoveryHint = await buildStructuredSubagentRecoveryHint(result, artifactsDir);
178
+ throw new ToolError(
179
+ `agent() isolated apply failed for ${result.id}${summary ? `: ${summary}` : ""}${recoveryHint}`,
180
+ );
181
+ }
182
+
183
+ const structuredOutput = result.structuredOutput;
184
+ const structured = structuredOutput?.source !== undefined && structuredOutput.source !== "none";
185
+ if (structured && mergeSummary.includes("<system-notification>")) {
186
+ const recoveryHint = await buildStructuredSubagentRecoveryHint(result, artifactsDir);
187
+ throw new ToolError(
188
+ `agent() isolated nested patch apply failed for ${result.id}: ${mergeSummary.replace(/<\/?system-notification>/g, "").trim()}${recoveryHint}`,
189
+ );
190
+ }
191
+
192
+ const hasData = structured && structuredOutput !== undefined && Object.hasOwn(structuredOutput, "data");
193
+ const data = structuredOutput?.data;
194
+ const text = structured ? result.output : result.output + mergeSummary;
195
+ const schemaSource = structuredOutput?.source === "none" ? undefined : structuredOutput?.source;
196
+ const schemaMode = structured ? structuredOutput?.mode : undefined;
197
+ const schemaStatus = structuredOutput?.status === "unavailable" ? undefined : structuredOutput?.status;
198
+
199
+ const model = result.resolvedModel ?? policy.modelOverride;
200
+ const nestedPatches = result.nestedPatches?.length ? result.nestedPatches : undefined;
201
+ const isolationSummary = mergeSummary ? mergeSummary.trim() : undefined;
202
+ return {
203
+ text,
204
+ ...(hasData ? { data } : {}),
205
+ details: {
206
+ agent: result.agent,
207
+ id: result.id,
208
+ ...(model !== undefined ? { model } : {}),
209
+ structured,
210
+ ...(schemaSource !== undefined ? { schemaSource } : {}),
211
+ ...(schemaMode !== undefined ? { schemaMode } : {}),
212
+ ...(schemaStatus !== undefined ? { schemaStatus } : {}),
213
+ ...(policy.isIsolated ? { isolated: true, changesApplied } : {}),
214
+ ...(result.patchPath !== undefined ? { patchPath: result.patchPath } : {}),
215
+ ...(result.branchName !== undefined ? { branchName: result.branchName } : {}),
216
+ ...(nestedPatches !== undefined ? { nestedPatches } : {}),
217
+ ...(isolationSummary !== undefined ? { isolationSummary } : {}),
218
+ },
219
+ };
220
+ } catch (error) {
221
+ if (error instanceof StructuredSubagentError) throw new ToolError(error.message);
222
+ throw error;
223
+ }
597
224
  }