pi-subagents 0.32.0 → 0.33.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 (52) hide show
  1. package/CHANGELOG.md +30 -3
  2. package/README.md +147 -58
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +70 -14
  6. package/src/agents/agent-management.ts +177 -5
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +142 -12
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/doctor.ts +1 -9
  12. package/src/extension/fanout-child.ts +2 -2
  13. package/src/extension/index.ts +65 -90
  14. package/src/extension/rpc.ts +369 -0
  15. package/src/extension/schemas.ts +52 -8
  16. package/src/extension/tool-description.ts +200 -0
  17. package/src/intercom/intercom-bridge.ts +21 -253
  18. package/src/intercom/native-supervisor-channel.ts +510 -0
  19. package/src/runs/background/async-execution.ts +51 -7
  20. package/src/runs/background/async-job-tracker.ts +12 -2
  21. package/src/runs/background/async-status.ts +27 -2
  22. package/src/runs/background/completion-batcher.ts +166 -0
  23. package/src/runs/background/control-channel.ts +106 -1
  24. package/src/runs/background/fleet-view.ts +515 -0
  25. package/src/runs/background/notify.ts +161 -44
  26. package/src/runs/background/result-watcher.ts +1 -2
  27. package/src/runs/background/run-id-resolver.ts +3 -2
  28. package/src/runs/background/run-status.ts +166 -6
  29. package/src/runs/background/scheduled-runs.ts +514 -0
  30. package/src/runs/background/subagent-runner.ts +409 -35
  31. package/src/runs/background/wait.ts +353 -0
  32. package/src/runs/foreground/chain-execution.ts +95 -21
  33. package/src/runs/foreground/execution.ts +150 -21
  34. package/src/runs/foreground/subagent-executor.ts +378 -64
  35. package/src/runs/shared/dynamic-fanout.ts +1 -1
  36. package/src/runs/shared/model-fallback.ts +167 -20
  37. package/src/runs/shared/model-scope.ts +128 -0
  38. package/src/runs/shared/nested-events.ts +31 -0
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/pi-args.ts +30 -1
  41. package/src/runs/shared/subagent-prompt-runtime.ts +108 -2
  42. package/src/runs/shared/tool-budget.ts +74 -0
  43. package/src/runs/shared/turn-budget.ts +52 -0
  44. package/src/shared/artifacts.ts +1 -0
  45. package/src/shared/atomic-json.ts +15 -2
  46. package/src/shared/child-transcript.ts +212 -0
  47. package/src/shared/settings.ts +3 -1
  48. package/src/shared/types.ts +134 -19
  49. package/src/slash/prompt-workflows.ts +330 -0
  50. package/src/slash/slash-commands.ts +16 -2
  51. package/src/tui/render.ts +16 -8
  52. package/src/extension/companion-suggestions.ts +0 -359
@@ -0,0 +1,353 @@
1
+ /**
2
+ * `wait` tool: block the current turn until outstanding async subagent runs
3
+ * finish (or another completion notification arrives).
4
+ *
5
+ * Background subagent runs are detached. In an interactive session the parent
6
+ * can end its turn and Pi will wake it with a completion notification. That
7
+ * does not work when the parent is a skill that must run to completion, and it
8
+ * cannot work at all non-interactively (`pi -p ...`), where the run is a single
9
+ * turn: once the turn ends there is nothing left to receive the notification.
10
+ *
11
+ * `wait` closes that gap. It keeps the turn alive until a tracked async run for
12
+ * this session reaches a terminal state (complete / failed / paused), the
13
+ * caller-supplied timeout elapses, or the turn is aborted. Because it awaits
14
+ * inside the turn, the completion the model was told to wait for is actually
15
+ * observed before the tool returns.
16
+ *
17
+ * By default `wait` returns as soon as ONE run finishes, so a fleet manager can
18
+ * use it in a rolling-replacement loop: launch N workers, wait for the next one
19
+ * to finish, spawn its replacement, wait again — keeping N in flight instead of
20
+ * draining to zero between batches. Pass `all: true` to block until every
21
+ * tracked run is terminal, or `id` to block on one specific run.
22
+ *
23
+ * `wait` also returns when a run needs attention — not just on completion. A
24
+ * child that goes idle or blocks for a decision surfaces `needs_attention`
25
+ * (the same signal Pi shows as a control notice and, interactively, wakes the
26
+ * parent with). Since `wait` is used exactly where there is no next turn to
27
+ * receive that notice, it must break on it too, or a stuck child would stall
28
+ * the loop until the timeout. Attention runs are reported so the caller can
29
+ * inspect / nudge / resume / interrupt them.
30
+ *
31
+ * Wake mechanism: when given Pi's event bus (`deps.events`), `wait` subscribes
32
+ * to the subagent completion/control channels and wakes the instant any fires,
33
+ * rather than waiting out a fixed poll interval. A poll still runs on the
34
+ * interval as a reconciliation fallback (crashed runners, missed events), and
35
+ * the poll is the source of truth for what actually changed — the event only
36
+ * ends the sleep early. With no bus, `wait` degrades to pure polling.
37
+ */
38
+
39
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
40
+ import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
41
+ import {
42
+ ASYNC_DIR,
43
+ RESULTS_DIR,
44
+ SUBAGENT_ASYNC_COMPLETE_EVENT,
45
+ SUBAGENT_CONTROL_EVENT,
46
+ SUBAGENT_CONTROL_INTERCOM_EVENT,
47
+ SUBAGENT_RESULT_INTERCOM_EVENT,
48
+ type Details,
49
+ type SubagentState,
50
+ } from "../../shared/types.ts";
51
+ import { formatDuration } from "../../shared/formatters.ts";
52
+
53
+ /** States that mean a run is still in flight (not yet resolved). */
54
+ const ACTIVE_STATES: ReadonlyArray<AsyncRunSummary["state"]> = ["queued", "running"];
55
+
56
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
57
+ const MIN_POLL_INTERVAL_MS = 250;
58
+ const DEFAULT_POLL_INTERVAL_MS = 1000;
59
+
60
+ export interface WaitParams {
61
+ /** Optional run id/prefix to wait for. When omitted, waits across every active run in this session. */
62
+ id?: string;
63
+ /**
64
+ * When true, block until EVERY active run in this session (or matching `id`)
65
+ * is terminal. Default false: return as soon as the first run finishes, so a
66
+ * fleet manager can spawn a replacement and wait again. Ignored when `id`
67
+ * targets a single run.
68
+ */
69
+ all?: boolean;
70
+ /** Give up after this many milliseconds. Defaults to 30 minutes. */
71
+ timeoutMs?: number;
72
+ }
73
+
74
+ /** Minimal event-bus surface wait subscribes to (matches pi.events). */
75
+ export interface WaitEventBus {
76
+ on(channel: string, handler: (data: unknown) => void): () => void;
77
+ }
78
+
79
+ export interface WaitDeps {
80
+ state: SubagentState;
81
+ asyncDirRoot?: string;
82
+ resultsDir?: string;
83
+ kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
84
+ now?: () => number;
85
+ pollIntervalMs?: number;
86
+ /** Injectable sleep for tests. */
87
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
88
+ /**
89
+ * Optional event bus (pi.events). When provided, wait wakes immediately on a
90
+ * subagent completion/control event instead of waiting out the poll interval;
91
+ * the poll then remains as a reconciliation fallback (crashed runners, missed
92
+ * events). Omit in tests that want pure poll behavior.
93
+ */
94
+ events?: WaitEventBus;
95
+ }
96
+
97
+ /** Bus channels that indicate a run changed state or needs attention. */
98
+ const WAKE_CHANNELS = [
99
+ SUBAGENT_ASYNC_COMPLETE_EVENT,
100
+ SUBAGENT_CONTROL_EVENT,
101
+ SUBAGENT_CONTROL_INTERCOM_EVENT,
102
+ SUBAGENT_RESULT_INTERCOM_EVENT,
103
+ ];
104
+
105
+ function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
106
+ return new Promise((resolve) => {
107
+ if (signal?.aborted) {
108
+ resolve();
109
+ return;
110
+ }
111
+ const timer = setTimeout(() => {
112
+ signal?.removeEventListener("abort", onAbort);
113
+ resolve();
114
+ }, ms);
115
+ const onAbort = () => {
116
+ clearTimeout(timer);
117
+ resolve();
118
+ };
119
+ signal?.addEventListener("abort", onAbort, { once: true });
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Sleep up to `ms`, but wake early if a subagent event fires on the bus (or the
125
+ * turn aborts). Returns when the first of those happens. With no bus this is a
126
+ * plain sleep, so the poll interval alone drives progress.
127
+ */
128
+ function waitForWake(ms: number, signal: AbortSignal | undefined, deps: WaitDeps): Promise<void> {
129
+ const sleep = deps.sleep ?? defaultSleep;
130
+ const events = deps.events;
131
+ if (!events) return sleep(ms, signal);
132
+ return new Promise((resolve) => {
133
+ let settled = false;
134
+ const unsubs: Array<() => void> = [];
135
+ const wakeController = new AbortController();
136
+ const done = () => {
137
+ if (settled) return;
138
+ settled = true;
139
+ wakeController.abort();
140
+ signal?.removeEventListener("abort", done);
141
+ for (const u of unsubs) {
142
+ try { u(); } catch { /* best effort */ }
143
+ }
144
+ resolve();
145
+ };
146
+ if (signal?.aborted) {
147
+ done();
148
+ return;
149
+ }
150
+ signal?.addEventListener("abort", done, { once: true });
151
+ for (const channel of WAKE_CHANNELS) {
152
+ try { unsubs.push(events.on(channel, done)); } catch { /* ignore bad channel */ }
153
+ }
154
+ // Poll-interval fallback so we still reconcile even if no event arrives.
155
+ // The local signal cancels that fallback timer when an event wakes us first.
156
+ void sleep(ms, wakeController.signal).then(done);
157
+ });
158
+ }
159
+
160
+ function matchesId(run: AsyncRunSummary, id: string): boolean {
161
+ return run.id === id || run.id.startsWith(id);
162
+ }
163
+
164
+ /** A running run that has flagged it needs the parent's attention. */
165
+ function needsAttention(run: AsyncRunSummary): boolean {
166
+ return run.activityState === "needs_attention";
167
+ }
168
+
169
+ /** Queued/running runs from this session, including runs that need attention. */
170
+ function activeRunsForSession(params: WaitParams, deps: WaitDeps): AsyncRunSummary[] {
171
+ const asyncDirRoot = deps.asyncDirRoot ?? ASYNC_DIR;
172
+ const resultsDir = deps.resultsDir ?? RESULTS_DIR;
173
+ const runs = listAsyncRuns(asyncDirRoot, {
174
+ states: [...ACTIVE_STATES],
175
+ sessionId: deps.state.currentSessionId ?? undefined,
176
+ resultsDir,
177
+ kill: deps.kill,
178
+ now: deps.now,
179
+ });
180
+ return params.id ? runs.filter((run) => matchesId(run, params.id!)) : runs;
181
+ }
182
+
183
+ /** Runs (from the initial set) currently flagged needs_attention, for reporting. */
184
+ function attentionRunsForSession(params: WaitParams, deps: WaitDeps, initialIds: Set<string>): AsyncRunSummary[] {
185
+ return activeRunsForSession(params, deps).filter((run) => needsAttention(run) && initialIds.has(run.id));
186
+ }
187
+
188
+ /** All runs (any state) for this session, for the final summary. */
189
+ function allRunsForSession(params: WaitParams, deps: WaitDeps): AsyncRunSummary[] {
190
+ const asyncDirRoot = deps.asyncDirRoot ?? ASYNC_DIR;
191
+ const resultsDir = deps.resultsDir ?? RESULTS_DIR;
192
+ const runs = listAsyncRuns(asyncDirRoot, {
193
+ sessionId: deps.state.currentSessionId ?? undefined,
194
+ resultsDir,
195
+ kill: deps.kill,
196
+ now: deps.now,
197
+ });
198
+ return params.id ? runs.filter((run) => matchesId(run, params.id!)) : runs;
199
+ }
200
+
201
+ function summarizeTerminalRuns(runs: AsyncRunSummary[]): string {
202
+ if (runs.length === 0) return "";
203
+ const counts = { complete: 0, failed: 0, paused: 0 } as Record<string, number>;
204
+ for (const run of runs) {
205
+ if (run.state in counts) counts[run.state] += 1;
206
+ }
207
+ const parts: string[] = [];
208
+ if (counts.complete) parts.push(`${counts.complete} complete`);
209
+ if (counts.failed) parts.push(`${counts.failed} failed`);
210
+ if (counts.paused) parts.push(`${counts.paused} paused`);
211
+ return parts.join(", ");
212
+ }
213
+
214
+ function result(text: string, isError = false): AgentToolResult<Details> {
215
+ return {
216
+ content: [{ type: "text", text }],
217
+ ...(isError ? { isError: true } : {}),
218
+ details: { mode: "management", results: [] },
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Block until the targeted async runs finish, the timeout elapses, or the turn
224
+ * is aborted. Resolves with a short human-readable summary either way.
225
+ */
226
+ export async function waitForSubagents(
227
+ params: WaitParams,
228
+ signal: AbortSignal | undefined,
229
+ deps: WaitDeps,
230
+ ): Promise<AgentToolResult<Details>> {
231
+ const now = deps.now ?? Date.now;
232
+ const pollIntervalMs = Math.max(MIN_POLL_INTERVAL_MS, deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
233
+ const timeoutMs = params.timeoutMs !== undefined && params.timeoutMs > 0 ? params.timeoutMs : DEFAULT_TIMEOUT_MS;
234
+ const startedAt = now();
235
+
236
+ // A single named run always means "wait until that one is done", regardless
237
+ // of `all`. Otherwise `all` decides: true → every run terminal; false → the
238
+ // first run to finish.
239
+ const waitForAll = params.id ? true : params.all === true;
240
+
241
+ let active: AsyncRunSummary[];
242
+ try {
243
+ active = activeRunsForSession(params, deps);
244
+ } catch (error) {
245
+ return result(error instanceof Error ? error.message : String(error), true);
246
+ }
247
+
248
+ if (active.length === 0) {
249
+ const finished = params.id
250
+ ? `No active run matched "${params.id}". Nothing to wait for.`
251
+ : "No active async runs in this session. Nothing to wait for.";
252
+ return result(finished);
253
+ }
254
+ if (params.id) {
255
+ const exact = active.filter((run) => run.id === params.id);
256
+ if (exact.length === 1) active = exact;
257
+ else if (active.length > 1) {
258
+ return result(`Ambiguous async run id prefix "${params.id}" matched ${active.length} active runs: ${active.map((run) => run.id).join(", ")}. Pass a longer id.`, true);
259
+ }
260
+ }
261
+ const waitParams = params.id ? { ...params, id: active[0]!.id } : params;
262
+
263
+ // The set of runs in flight when the wait began. In first-completion mode we
264
+ // return as soon as any of THESE leaves the active set — a run spawned by a
265
+ // concurrent turn shouldn't satisfy this wait.
266
+ const initialIds = new Set(active.map((run) => run.id));
267
+ const initialCount = initialIds.size;
268
+ let pending = active.filter((run) => !needsAttention(run));
269
+
270
+ const done = (active: AsyncRunSummary[], attention: AsyncRunSummary[]): boolean => {
271
+ // A run needing attention always breaks the wait, in either mode: the
272
+ // caller has to act on it (nudge/resume/interrupt) and blocking longer
273
+ // helps nothing.
274
+ if (attention.length > 0) return true;
275
+ if (waitForAll) return active.every((run) => !initialIds.has(run.id));
276
+ // First-completion: satisfied once any initially-pending run is gone.
277
+ const stillActiveInitial = active.filter((run) => initialIds.has(run.id));
278
+ return stillActiveInitial.length < initialCount;
279
+ };
280
+
281
+ let attention = active.filter((run) => needsAttention(run));
282
+
283
+ while (!done(pending, attention)) {
284
+ if (signal?.aborted) {
285
+ const stillActive = pending.map((run) => `${run.id} (${run.state})`).join(", ");
286
+ return result(`Wait aborted after ${formatDuration(now() - startedAt)}. Still active: ${stillActive}.`, true);
287
+ }
288
+ if (now() - startedAt >= timeoutMs) {
289
+ const stillActive = pending.map((run) => `${run.id} (${run.state})`).join(", ");
290
+ return result(
291
+ `Wait timed out after ${formatDuration(timeoutMs)} with ${pending.length} run(s) still active: ${stillActive}. `
292
+ + `The runs are detached and keep going; call wait again or inspect with subagent({ action: "status" }).`,
293
+ true,
294
+ );
295
+ }
296
+ await waitForWake(pollIntervalMs, signal, deps);
297
+ try {
298
+ active = activeRunsForSession(waitParams, deps);
299
+ pending = active.filter((run) => !needsAttention(run));
300
+ attention = attentionRunsForSession(waitParams, deps, initialIds);
301
+ } catch (error) {
302
+ return result(error instanceof Error ? error.message : String(error), true);
303
+ }
304
+ }
305
+
306
+ // Report how the finished run(s) came out. In first-completion mode, name the
307
+ // runs from the initial set that are now terminal.
308
+ let terminalSummary = "";
309
+ let finishedCount = 0;
310
+ try {
311
+ const allNow = allRunsForSession(waitParams, deps);
312
+ const terminal = allNow.filter((run) => !ACTIVE_STATES.includes(run.state) && initialIds.has(run.id));
313
+ finishedCount = terminal.length;
314
+ terminalSummary = summarizeTerminalRuns(terminal);
315
+ } catch {
316
+ // Summary is best-effort; the important part is that the wait resolved.
317
+ }
318
+
319
+ const attentionNote = attention.length > 0
320
+ ? ` ${attention.length} run(s) need attention: ${attention.map((r) => r.id).join(", ")} — inspect with subagent({ action: "status" }) then nudge/resume/interrupt.`
321
+ : "";
322
+
323
+ const stillRunning = pending.filter((run) => initialIds.has(run.id)).length;
324
+ const elapsed = formatDuration(now() - startedAt);
325
+ const outcome = terminalSummary ? ` Outcome: ${terminalSummary}.` : "";
326
+
327
+ if (waitForAll) {
328
+ const scope = params.id ? `run "${params.id}"` : `${initialCount} async run(s)`;
329
+ const status = attention.length > 0 ? "attention required" : "done";
330
+ const notificationText = attention.length > 0
331
+ ? "Relevant completion/control events have been observed; inspect status if the notification is not visible yet."
332
+ : "Completion events have been observed; inspect status if the notification is not visible yet.";
333
+ return result(
334
+ `Waited ${elapsed} for ${scope}; ${status}.${outcome}${attentionNote} ${notificationText}`,
335
+ );
336
+ }
337
+
338
+ // First-completion mode.
339
+ const remainder = stillRunning > 0
340
+ ? ` ${stillRunning} run(s) still in flight — call wait again to catch the next one.`
341
+ : attention.length > 0
342
+ ? " No other runs are waitable until attention is handled."
343
+ : " No runs remain in flight.";
344
+ const progress = attention.length > 0 && finishedCount === 0
345
+ ? `${attention.length} of ${initialCount} run(s) need attention`
346
+ : `${finishedCount} of ${initialCount} run(s) finished`;
347
+ const notificationText = finishedCount > 0
348
+ ? " Completion events for the finished run(s) have been observed; inspect status if the notification is not visible yet."
349
+ : " Relevant control events have been observed; inspect status if the notification is not visible yet.";
350
+ return result(
351
+ `Waited ${elapsed}; ${progress}.${outcome}${attentionNote}${remainder}${notificationText}`,
352
+ );
353
+ }
@@ -56,11 +56,15 @@ import {
56
56
  type IntercomEventBus,
57
57
  type NestedRouteInfo,
58
58
  type ResolvedControlConfig,
59
+ type ResolvedTurnBudget,
60
+ type ResolvedToolBudget,
59
61
  type SingleResult,
62
+ type ToolBudgetConfig,
60
63
  MAX_CONCURRENCY,
61
64
  resolveChildMaxSubagentDepth,
62
65
  } from "../../shared/types.ts";
63
66
  import { resolveSubagentModelOverride } from "../shared/model-fallback.ts";
67
+ import type { ModelScopeConfig } from "../shared/model-scope.ts";
64
68
  import { validateFileOnlyOutputMode } from "../shared/single-output.ts";
65
69
  import { buildWorkflowGraphSnapshot } from "../shared/workflow-graph.ts";
66
70
  import { ChainOutputValidationError, outputEntryFromResult, resolveOutputReferences, validateChainOutputBindings } from "../shared/chain-outputs.ts";
@@ -68,6 +72,7 @@ import { createStructuredOutputRuntime } from "../shared/structured-output.ts";
68
72
  import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection, type DynamicCollectedResult } from "../shared/dynamic-fanout.ts";
69
73
  import { acceptanceFailureMessage, aggregateAcceptanceReport, evaluateAcceptance, resolveEffectiveAcceptance } from "../shared/acceptance.ts";
70
74
  import type { ChainOutputMap } from "../../shared/types.ts";
75
+ import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
71
76
 
72
77
  interface ChainExecutionDetailsInput {
73
78
  results: SingleResult[];
@@ -93,6 +98,7 @@ interface ParallelChainRunInput {
93
98
  agents: AgentConfig[];
94
99
  stepIndex: number;
95
100
  availableModels: ModelInfo[];
101
+ modelScope?: ModelScopeConfig;
96
102
  chainDir: string;
97
103
  prev: string;
98
104
  originalTask: string;
@@ -141,6 +147,10 @@ interface ParallelChainRunInput {
141
147
  nestedRoute?: NestedRouteInfo;
142
148
  timeoutMs?: number;
143
149
  deadlineAt?: number;
150
+ turnBudget?: ResolvedTurnBudget;
151
+ onDetachedExit?: (index: number, result: SingleResult) => void;
152
+ toolBudget?: ResolvedToolBudget;
153
+ configToolBudget?: ToolBudgetConfig;
144
154
  globalSemaphore?: Semaphore;
145
155
  }
146
156
 
@@ -202,6 +212,20 @@ function appendParallelWorktreeSummary(
202
212
  return `${output}\n\n${diffSummary}`;
203
213
  }
204
214
 
215
+ function resolveChainToolBudget(input: { stepBudget?: ToolBudgetConfig; runBudget?: ResolvedToolBudget; agentBudget?: ToolBudgetConfig; configBudget?: ToolBudgetConfig }): { toolBudget?: ResolvedToolBudget; error?: string } {
216
+ if (input.stepBudget !== undefined) {
217
+ const resolved = validateToolBudgetConfig(input.stepBudget, "toolBudget");
218
+ return { toolBudget: resolved.budget, error: resolved.error };
219
+ }
220
+ if (input.runBudget !== undefined) return { toolBudget: input.runBudget };
221
+ if (input.agentBudget !== undefined) {
222
+ const resolved = validateToolBudgetConfig(input.agentBudget, "agent.toolBudget");
223
+ return { toolBudget: resolved.budget, error: resolved.error };
224
+ }
225
+ const resolved = validateToolBudgetConfig(input.configBudget, "config.toolBudget");
226
+ return { toolBudget: resolved.budget, error: resolved.error };
227
+ }
228
+
205
229
  async function runParallelChainTasks(input: ParallelChainRunInput): Promise<SingleResult[]> {
206
230
  const concurrency = input.step.concurrency ?? MAX_CONCURRENCY;
207
231
  const failFast = input.step.failFast ?? false;
@@ -245,8 +269,11 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
245
269
  input.ctx.model,
246
270
  input.availableModels,
247
271
  input.ctx.model?.provider,
272
+ { scope: input.modelScope, source: task.model ? "explicit" : "inherited" },
248
273
  );
249
274
  const maxSubagentDepth = resolveChildMaxSubagentDepth(input.maxSubagentDepth, taskAgentConfig?.maxSubagentDepth);
275
+ const toolBudget = resolveChainToolBudget({ stepBudget: task.toolBudget, runBudget: input.toolBudget, agentBudget: taskAgentConfig?.toolBudget, configBudget: input.configToolBudget });
276
+ if (toolBudget.error) throw new Error(toolBudget.error);
250
277
 
251
278
  const taskCwd = input.worktreeSetup
252
279
  ? input.worktreeSetup.worktrees[taskIndex]!.agentCwd
@@ -300,12 +327,18 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
300
327
  modelOverride: effectiveModel,
301
328
  availableModels: input.availableModels,
302
329
  preferredModelProvider: input.ctx.model?.provider,
330
+ modelScope: input.modelScope,
303
331
  skills: behavior.skills === false ? [] : behavior.skills,
304
332
  structuredOutput: structuredRuntime,
305
333
  acceptance: task.acceptance,
306
334
  acceptanceContext: { mode: "chain" },
307
335
  timeoutMs: input.timeoutMs,
308
336
  deadlineAt: input.deadlineAt,
337
+ turnBudget: input.turnBudget,
338
+ onDetachedExit: input.onDetachedExit
339
+ ? (result) => input.onDetachedExit?.(input.globalTaskIndex + taskIndex, result)
340
+ : undefined,
341
+ toolBudget: toolBudget.toolBudget,
309
342
  onUpdate: input.onUpdate
310
343
  ? (progressUpdate) => {
311
344
  const stepResults = progressUpdate.details?.results || [];
@@ -414,6 +447,10 @@ interface ChainExecutionParams {
414
447
  worktreeBaseDir?: string;
415
448
  timeoutMs?: number;
416
449
  deadlineAt?: number;
450
+ turnBudget?: ResolvedTurnBudget;
451
+ onDetachedExit?: (index: number, result: SingleResult) => void;
452
+ toolBudget?: ResolvedToolBudget;
453
+ configToolBudget?: ToolBudgetConfig;
417
454
  /** Global cap on simultaneously-running tasks within this chain. Defaults to DEFAULT_GLOBAL_CONCURRENCY_LIMIT. */
418
455
  globalConcurrencyLimit?: number;
419
456
  }
@@ -452,12 +489,14 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
452
489
  onUpdate,
453
490
  onControlEvent,
454
491
  controlConfig,
492
+ onDetachedExit,
455
493
  childIntercomTarget,
456
494
  orchestratorIntercomTarget,
457
495
  foregroundControl,
458
496
  intercomEvents,
459
497
  chainSkills: chainSkillsParam,
460
498
  chainDir: chainDirBase,
499
+ modelScope,
461
500
  } = params;
462
501
  const chainSkills = chainSkillsParam ?? [];
463
502
 
@@ -516,7 +555,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
516
555
  const chainDir = createChainDir(runId, chainDirBase);
517
556
  const hasParallelSteps = chainSteps.some((step) => isParallelStep(step) || isDynamicParallelStep(step));
518
557
  let templates: ResolvedTemplates = resolveChainTemplates(chainSteps);
519
- const shouldClarify = clarify !== false && ctx.hasUI && !hasParallelSteps;
558
+ const shouldClarify = clarify === true && ctx.hasUI && !hasParallelSteps;
520
559
  let tuiBehaviorOverrides: (BehaviorOverride | undefined)[] | undefined;
521
560
  const availableModels: ModelInfo[] = ctx.modelRegistry.getAvailable().map(toModelInfo);
522
561
  const availableSkills = discoverAvailableSkills(cwd ?? ctx.cwd);
@@ -665,6 +704,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
665
704
  agents,
666
705
  stepIndex,
667
706
  availableModels,
707
+ modelScope,
668
708
  chainDir,
669
709
  prev,
670
710
  originalTask,
@@ -700,6 +740,10 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
700
740
  maxSubagentDepth: params.maxSubagentDepth,
701
741
  timeoutMs: params.timeoutMs,
702
742
  deadlineAt,
743
+ turnBudget: params.turnBudget,
744
+ onDetachedExit,
745
+ toolBudget: params.toolBudget,
746
+ configToolBudget: params.configToolBudget,
703
747
  globalSemaphore,
704
748
  });
705
749
  globalTaskIndex += step.parallel.length;
@@ -880,6 +924,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
880
924
  agents,
881
925
  stepIndex,
882
926
  availableModels,
927
+ modelScope,
883
928
  chainDir,
884
929
  prev,
885
930
  originalTask,
@@ -914,6 +959,10 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
914
959
  maxSubagentDepth: params.maxSubagentDepth,
915
960
  timeoutMs: params.timeoutMs,
916
961
  deadlineAt,
962
+ turnBudget: params.turnBudget,
963
+ onDetachedExit,
964
+ toolBudget: params.toolBudget,
965
+ configToolBudget: params.configToolBudget,
917
966
  globalSemaphore,
918
967
  });
919
968
  globalTaskIndex = dynamicStartIndex + reservedDynamicItems;
@@ -1060,13 +1109,14 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1060
1109
  const cleanTask = stepTask;
1061
1110
  stepTask = prefix + stepTask + suffix;
1062
1111
 
1063
- const effectiveModel = tuiOverride?.model
1064
- ?? resolveSubagentModelOverride(
1065
- seqStep.model ?? agentConfig.model,
1066
- ctx.model,
1067
- availableModels,
1068
- ctx.model?.provider,
1069
- );
1112
+ const explicitStepModel = tuiOverride?.model ?? seqStep.model;
1113
+ const effectiveModel = resolveSubagentModelOverride(
1114
+ explicitStepModel ?? agentConfig.model,
1115
+ ctx.model,
1116
+ availableModels,
1117
+ ctx.model?.provider,
1118
+ { scope: modelScope, source: explicitStepModel ? "explicit" : "inherited" },
1119
+ );
1070
1120
 
1071
1121
  const outputPath = typeof behavior.output === "string"
1072
1122
  ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
@@ -1076,10 +1126,11 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1076
1126
  return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
1077
1127
  }
1078
1128
  const maxSubagentDepth = resolveChildMaxSubagentDepth(params.maxSubagentDepth, agentConfig.maxSubagentDepth);
1129
+ const childIndex = globalTaskIndex;
1079
1130
  const interruptController = new AbortController();
1080
1131
  if (foregroundControl) {
1081
1132
  foregroundControl.currentAgent = seqStep.agent;
1082
- foregroundControl.currentIndex = globalTaskIndex;
1133
+ foregroundControl.currentIndex = childIndex;
1083
1134
  foregroundControl.currentActivityState = undefined;
1084
1135
  foregroundControl.updatedAt = Date.now();
1085
1136
  foregroundControl.interrupt = () => {
@@ -1094,6 +1145,23 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1094
1145
  const structuredRuntime = seqStep.outputSchema
1095
1146
  ? createStructuredOutputRuntime(seqStep.outputSchema, path.join(chainDir, "structured-output"))
1096
1147
  : undefined;
1148
+ const toolBudget = resolveChainToolBudget({ stepBudget: seqStep.toolBudget, runBudget: params.toolBudget, agentBudget: agentConfig?.toolBudget, configBudget: params.configToolBudget });
1149
+ if (toolBudget.error) return buildChainExecutionErrorResult(toolBudget.error, {
1150
+ results,
1151
+ includeProgress,
1152
+ allProgress,
1153
+ allArtifactPaths,
1154
+ artifactsDir: params.artifactsDir,
1155
+ chainAgents,
1156
+ chainSteps,
1157
+ totalSteps,
1158
+ currentStepIndex: stepIndex,
1159
+ runId: params.runId,
1160
+ outputs,
1161
+ currentFlatIndex: globalTaskIndex,
1162
+ dynamicChildren,
1163
+ dynamicGroupStatuses,
1164
+ });
1097
1165
  const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
1098
1166
  parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
1099
1167
  cwd: resolveChildCwd(cwd ?? ctx.cwd, seqStep.cwd),
@@ -1102,11 +1170,11 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1102
1170
  allowIntercomDetach: agentConfig.systemPrompt?.includes(INTERCOM_BRIDGE_MARKER) === true,
1103
1171
  intercomEvents,
1104
1172
  runId,
1105
- index: globalTaskIndex,
1106
- sessionDir: sessionDirForIndex(globalTaskIndex),
1107
- sessionFile: sessionFileForTask?.(seqStep.agent, globalTaskIndex)
1108
- ?? sessionFileForIndex?.(globalTaskIndex),
1109
- thinkingOverride: thinkingOverrideForTask?.(seqStep.agent, globalTaskIndex),
1173
+ index: childIndex,
1174
+ sessionDir: sessionDirForIndex(childIndex),
1175
+ sessionFile: sessionFileForTask?.(seqStep.agent, childIndex)
1176
+ ?? sessionFileForIndex?.(childIndex),
1177
+ thinkingOverride: thinkingOverrideForTask?.(seqStep.agent, childIndex),
1110
1178
  share: shareEnabled,
1111
1179
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1112
1180
  artifactConfig,
@@ -1115,18 +1183,24 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1115
1183
  maxSubagentDepth,
1116
1184
  controlConfig,
1117
1185
  onControlEvent,
1118
- intercomSessionName: childIntercomTarget?.(seqStep.agent, globalTaskIndex),
1186
+ intercomSessionName: childIntercomTarget?.(seqStep.agent, childIndex),
1119
1187
  orchestratorIntercomTarget,
1120
1188
  nestedRoute: params.nestedRoute,
1121
1189
  modelOverride: effectiveModel,
1122
1190
  availableModels,
1123
1191
  preferredModelProvider: ctx.model?.provider,
1192
+ modelScope,
1124
1193
  skills: behavior.skills === false ? [] : behavior.skills,
1125
1194
  structuredOutput: structuredRuntime,
1126
1195
  acceptance: seqStep.acceptance,
1127
1196
  acceptanceContext: { mode: "chain" },
1128
1197
  timeoutMs: params.timeoutMs,
1129
1198
  deadlineAt,
1199
+ turnBudget: params.turnBudget,
1200
+ onDetachedExit: onDetachedExit
1201
+ ? (result) => onDetachedExit(childIndex, result)
1202
+ : undefined,
1203
+ toolBudget: toolBudget.toolBudget,
1130
1204
  onUpdate: onUpdate
1131
1205
  ? (p) => {
1132
1206
  const stepResults = p.details?.results || [];
@@ -1134,7 +1208,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1134
1208
  if (foregroundControl && stepProgress.length > 0) {
1135
1209
  const current = stepProgress[0];
1136
1210
  foregroundControl.currentAgent = seqStep.agent;
1137
- foregroundControl.currentIndex = globalTaskIndex;
1211
+ foregroundControl.currentIndex = childIndex;
1138
1212
  foregroundControl.currentActivityState = current?.activityState;
1139
1213
  foregroundControl.lastActivityAt = current?.lastActivityAt;
1140
1214
  foregroundControl.currentTool = current?.currentTool;
@@ -1162,7 +1236,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1162
1236
  steps: chainSteps,
1163
1237
  results: results.concat(stepResults),
1164
1238
  currentStepIndex: stepIndex,
1165
- currentFlatIndex: globalTaskIndex,
1239
+ currentFlatIndex: childIndex,
1166
1240
  dynamicChildren,
1167
1241
  dynamicGroupStatuses,
1168
1242
  }),
@@ -1171,7 +1245,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1171
1245
  }
1172
1246
  : undefined,
1173
1247
  });
1174
- if (foregroundControl?.currentIndex === globalTaskIndex) {
1248
+ if (foregroundControl?.currentIndex === childIndex) {
1175
1249
  foregroundControl.interrupt = undefined;
1176
1250
  foregroundControl.updatedAt = Date.now();
1177
1251
  }
@@ -1185,13 +1259,13 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1185
1259
  if (r.interrupted) {
1186
1260
  return {
1187
1261
  content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${r.agent}). Waiting for explicit next action.` }],
1188
- details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
1262
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: childIndex })),
1189
1263
  };
1190
1264
  }
1191
1265
  if (r.detached) {
1192
1266
  return {
1193
1267
  content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${r.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
1194
- details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
1268
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: childIndex })),
1195
1269
  };
1196
1270
  }
1197
1271
 
@@ -1202,7 +1276,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1202
1276
  });
1203
1277
  return {
1204
1278
  content: [{ type: "text", text: summary }],
1205
- details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
1279
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: childIndex })),
1206
1280
  isError: true,
1207
1281
  };
1208
1282
  }