@d3ara1n/pi-subagent 0.10.4 → 1.0.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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Tests for the delegation run engine — state machine transitions, snapshot
3
+ * frames, fallback retry, and error paths, using an injected fake spawn.
4
+ */
5
+
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
9
+ import type { SubagentConfig, SubagentRole, SubagentResult } from "./types.ts";
10
+ import { DEFAULT_CONFIG } from "./types.ts";
11
+ import { AsyncSemaphore, emptyUsage } from "./utils.ts";
12
+ import { startSubagentRun, type StartRunOptions } from "./run.ts";
13
+
14
+ const testConfig: SubagentConfig = {
15
+ ...DEFAULT_CONFIG,
16
+ history: { enabled: false },
17
+ summary: { role: "utility", enabled: false },
18
+ };
19
+
20
+ const roleDef: SubagentRole = {
21
+ role: "fast",
22
+ description: "",
23
+ examples: [],
24
+ decisionTrigger: "",
25
+ tools: ["read"],
26
+ systemPrompt: "",
27
+ };
28
+
29
+ const fakeRolesApi = {
30
+ resolveRoleAsync: async (role: string) => ({
31
+ model: { provider: "test", id: `model-${role}` },
32
+ config: {},
33
+ }),
34
+ } as unknown as ModelRolesAPI;
35
+
36
+ function makeResult(overrides: Partial<SubagentResult>): SubagentResult {
37
+ return {
38
+ role: "explorer",
39
+ task: "test task",
40
+ exitCode: 0,
41
+ output: "",
42
+ stderr: "",
43
+ usage: emptyUsage(),
44
+ activityLog: [],
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ type SpawnImpl = NonNullable<StartRunOptions["spawnImpl"]>;
50
+
51
+ function makeDeps(overrides: Partial<StartRunOptions> = {}): StartRunOptions {
52
+ return {
53
+ id: "sub-1",
54
+ toolCallId: "call-1",
55
+ role: "explorer",
56
+ roleDef,
57
+ task: "test task",
58
+ cwd: "/tmp",
59
+ depth: 1,
60
+ config: testConfig,
61
+ gate: new AsyncSemaphore(4),
62
+ getRolesApi: () => fakeRolesApi,
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ test("run starts queued, transitions to running, then succeeds", async () => {
68
+ const states: string[] = [];
69
+ const spawnImpl: SpawnImpl = async (_model, _task, options) => {
70
+ options.onProgress?.({
71
+ activityLog: [{ kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} }],
72
+ usage: { ...emptyUsage(), turns: 1 },
73
+ });
74
+ return makeResult({ output: "done", usage: { ...emptyUsage(), turns: 1 } });
75
+ };
76
+
77
+ const run = startSubagentRun(makeDeps({ spawnImpl }));
78
+ assert.strictEqual(run.state, "queued");
79
+ assert.ok(run.snapshot.queued, "initial snapshot is a queued frame");
80
+
81
+ const unsubscribe = run.subscribe(() => states.push(run.state));
82
+ const result = await run.promise;
83
+ unsubscribe();
84
+
85
+ assert.strictEqual(run.state, "finished");
86
+ assert.strictEqual(run.result, result);
87
+ assert.strictEqual(result.output, "done");
88
+ // Terminal frames carry the registry role name (spawn itself never learns it).
89
+ assert.strictEqual(result.role, "explorer");
90
+ assert.ok(states.includes("running"), `saw running in ${JSON.stringify(states)}`);
91
+ assert.strictEqual(run.thrown, undefined);
92
+ // Terminal frame carries elapsed time and stops looking live.
93
+ assert.strictEqual(result.exitCode, 0);
94
+ assert.ok(typeof result.elapsedMs === "number");
95
+ assert.strictEqual(run.snapshot.startTime, undefined);
96
+ });
97
+
98
+ test("run stays queued while the concurrency gate is full", async () => {
99
+ const gate = new AsyncSemaphore(1);
100
+ await gate.acquire(); // exhaust the single slot
101
+ const spawnImpl: SpawnImpl = async () => makeResult({ output: "late" });
102
+
103
+ const run = startSubagentRun(makeDeps({ gate, spawnImpl }));
104
+ await new Promise((r) => setTimeout(r, 10));
105
+ assert.strictEqual(run.state, "queued");
106
+
107
+ gate.release();
108
+ const result = await run.promise;
109
+ assert.strictEqual(run.state, "finished");
110
+ assert.strictEqual(result.output, "late");
111
+ });
112
+
113
+ test("non-zero exit yields state failed without thrown", async () => {
114
+ const spawnImpl: SpawnImpl = async () =>
115
+ makeResult({ exitCode: 1, errorMessage: "boom", output: "partial" });
116
+
117
+ const run = startSubagentRun(makeDeps({ spawnImpl }));
118
+ const result = await run.promise;
119
+
120
+ assert.strictEqual(run.state, "failed");
121
+ assert.strictEqual(run.thrown, undefined);
122
+ assert.strictEqual(result.errorMessage, "boom");
123
+ });
124
+
125
+ test("a throwing spawn resolves the promise with a failed result carrying the error", async () => {
126
+ const spawnImpl: SpawnImpl = async () => {
127
+ throw new Error("Subagent was aborted");
128
+ };
129
+
130
+ const run = startSubagentRun(makeDeps({ spawnImpl }));
131
+ const result = await run.promise; // never rejects
132
+
133
+ assert.strictEqual(run.state, "failed");
134
+ assert.ok(run.thrown instanceof Error);
135
+ assert.strictEqual(run.thrown.message, "Subagent was aborted");
136
+ assert.strictEqual(result.errorMessage, "Subagent was aborted");
137
+ });
138
+
139
+ test("provider error on first attempt retries on the fallback role", async () => {
140
+ const calls: string[] = [];
141
+ const spawnImpl: SpawnImpl = async (model) => {
142
+ calls.push(model);
143
+ if (calls.length === 1) {
144
+ return makeResult({ exitCode: 1, errorMessage: "429 quota exceeded", stderr: "HTTP 429" });
145
+ }
146
+ return makeResult({ output: "fallback ok" });
147
+ };
148
+
149
+ const run = startSubagentRun(
150
+ makeDeps({
151
+ roleDef: { ...roleDef, fallbackRole: "default" },
152
+ spawnImpl,
153
+ }),
154
+ );
155
+ const result = await run.promise;
156
+
157
+ assert.deepStrictEqual(calls, ["test/model-fast", "test/model-default"]);
158
+ assert.strictEqual(run.state, "finished");
159
+ assert.strictEqual(result.output, "fallback ok");
160
+ assert.ok(result.fallbackFrom, "terminal result records the failed first attempt");
161
+ assert.strictEqual(result.fallbackFrom.model, "test/model-fast");
162
+ });
163
+
164
+ test("prerun failure (roles api unavailable) becomes a failed run, not a throw", async () => {
165
+ const run = startSubagentRun(
166
+ makeDeps({
167
+ getRolesApi: () => {
168
+ throw new Error("not initialized");
169
+ },
170
+ }),
171
+ );
172
+ const result = await run.promise;
173
+
174
+ assert.strictEqual(run.state, "failed");
175
+ assert.strictEqual(run.thrown, undefined);
176
+ assert.match(result.errorMessage!, /pi-model-roles is not initialized/);
177
+ });
178
+
179
+ test("abort while queued fails the run and exposes thrown for the foreground path", async () => {
180
+ const gate = new AsyncSemaphore(1);
181
+ await gate.acquire();
182
+ const controller = new AbortController();
183
+ controller.abort();
184
+
185
+ const run = startSubagentRun(makeDeps({ gate, signal: controller.signal }));
186
+ const result = await run.promise;
187
+
188
+ assert.strictEqual(run.state, "failed");
189
+ assert.ok(run.thrown instanceof Error);
190
+ assert.match(result.errorMessage!, /cancelled while queued/);
191
+ gate.release();
192
+ });
193
+
194
+ test("subscribers are notified on progress and terminal frames", async () => {
195
+ let notifications = 0;
196
+ const spawnImpl: SpawnImpl = async (_m, _t, options) => {
197
+ options.onProgress?.({ output: "step 1" });
198
+ options.onProgress?.({ output: "step 2" });
199
+ return makeResult({ output: "final" });
200
+ };
201
+
202
+ const run = startSubagentRun(makeDeps({ spawnImpl }));
203
+ const unsubscribe = run.subscribe(() => notifications++);
204
+ await run.promise;
205
+ unsubscribe();
206
+ const after = notifications;
207
+ // No further notifications after terminal (and after unsubscribing).
208
+ await new Promise((r) => setTimeout(r, 5));
209
+ assert.strictEqual(notifications, after);
210
+ assert.ok(notifications >= 3, `progress x2 + terminal, got ${notifications}`);
211
+ });
package/src/run.ts ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The delegation run engine — one async pipeline per delegate call, shared by
3
+ * the foreground (blocking) and background tool paths. Foreground delegation
4
+ * is background delegation that the tool call blocks on.
5
+ *
6
+ * startSubagentRun() returns a live RunHandle immediately: a small state
7
+ * machine exposing the latest TUI-ready snapshot frame, a promise that always
8
+ * resolves with the terminal result (never rejects — pipeline throws are
9
+ * exposed via `thrown`), and a subscriber list the `wait` tool uses to mirror
10
+ * live progress into its own tool row.
11
+ *
12
+ * All post-processing (fallback retry, output compression, summary
13
+ * generation, history persistence) runs inside the pipeline, so background
14
+ * runs finish exactly like foreground ones.
15
+ */
16
+
17
+ import type { ModelRolesAPI, ThinkingLevel } from "@d3ara1n/pi-model-roles";
18
+ import type {
19
+ FallbackFrom,
20
+ RunState,
21
+ SubagentConfig,
22
+ SubagentResult,
23
+ SubagentRole,
24
+ } from "./types.ts";
25
+ import { spawnSubagent } from "./spawn.ts";
26
+ import {
27
+ MAX_OUTPUT_CHARS,
28
+ AsyncSemaphore,
29
+ buildFallbackFrom,
30
+ effectiveTimeout,
31
+ emptyUsage,
32
+ isFailedResult,
33
+ isProviderError,
34
+ } from "./utils.ts";
35
+ import { compressOutput, generateSummary } from "./output.ts";
36
+ import { persistSubagentHistory } from "./history.ts";
37
+
38
+ export interface RunHandle {
39
+ /** Registry id (sub-N). */
40
+ readonly id: string;
41
+ readonly role: string;
42
+ readonly task: string;
43
+ readonly context?: string;
44
+ readonly files?: string[];
45
+ /** Lifecycle state, kept in sync with the latest snapshot frame. */
46
+ readonly state: RunState;
47
+ /** Latest frame: queued placeholder, live progress, or terminal result. */
48
+ readonly snapshot: SubagentResult;
49
+ /** Terminal result; undefined while queued/running. */
50
+ readonly result: SubagentResult | undefined;
51
+ /** Set when the pipeline threw (abort, spawn crash). Foreground callers rethrow; wait/check only see state "failed". */
52
+ readonly thrown: Error | undefined;
53
+ /** Resolves with the terminal result once the run finishes (always succeeds). */
54
+ readonly promise: Promise<SubagentResult>;
55
+ /** Get notified on every frame change. Returns an unsubscribe function. */
56
+ subscribe(fn: () => void): () => void;
57
+ }
58
+
59
+ export interface StartRunOptions {
60
+ id: string;
61
+ /** delegate toolCallId — names the history record. */
62
+ toolCallId: string;
63
+ /** Role name key (params.role). */
64
+ role: string;
65
+ roleDef: SubagentRole;
66
+ task: string;
67
+ context?: string;
68
+ files?: string[];
69
+ cwd: string;
70
+ /** Nesting depth for the child (CURRENT_DEPTH + 1). */
71
+ depth: number;
72
+ /** Foreground callers pass the tool's AbortSignal; background runs pass none and outlive the turn. */
73
+ signal?: AbortSignal;
74
+ /** Per-call model override ('provider/model-id'), bypassing the role's configured model. */
75
+ modelOverride?: string;
76
+ config: SubagentConfig;
77
+ gate: AsyncSemaphore;
78
+ /** May throw when pi-model-roles is not initialized — becomes a failed run. */
79
+ getRolesApi: () => ModelRolesAPI;
80
+ /** History sessionId lookup (best-effort, wrapped in try/catch). */
81
+ getSessionId?: () => string | undefined;
82
+ /** @internal — injectable spawn for tests. */
83
+ spawnImpl?: typeof spawnSubagent;
84
+ }
85
+
86
+ export function startSubagentRun(opts: StartRunOptions): RunHandle {
87
+ const spawn = opts.spawnImpl ?? spawnSubagent;
88
+ const listeners = new Set<() => void>();
89
+
90
+ const inputFrame = (exitCode: number, queued: boolean): SubagentResult => ({
91
+ role: opts.role,
92
+ task: opts.task,
93
+ exitCode,
94
+ queued: queued || undefined,
95
+ output: "",
96
+ stderr: "",
97
+ usage: emptyUsage(),
98
+ activityLog: [],
99
+ files: opts.files,
100
+ context: opts.context,
101
+ });
102
+
103
+ let currentState: RunState = "queued";
104
+ let snapshot: SubagentResult = inputFrame(-1, true);
105
+ let result: SubagentResult | undefined;
106
+ let thrown: Error | undefined;
107
+ let resolvePromise!: (r: SubagentResult) => void;
108
+ const promise = new Promise<SubagentResult>((resolve) => {
109
+ resolvePromise = resolve;
110
+ });
111
+
112
+ const notify = () => {
113
+ for (const fn of [...listeners]) {
114
+ try {
115
+ fn();
116
+ } catch {
117
+ /* listener errors never break the run */
118
+ }
119
+ }
120
+ };
121
+ const setFrame = (frame: SubagentResult, state: RunState) => {
122
+ snapshot = frame;
123
+ currentState = state;
124
+ notify();
125
+ };
126
+ const finish = (terminal: SubagentResult, error?: Error) => {
127
+ result = terminal;
128
+ snapshot = terminal;
129
+ thrown = error;
130
+ currentState = isFailedResult(terminal) ? "failed" : "finished";
131
+ notify();
132
+ resolvePromise(terminal);
133
+ };
134
+
135
+ const handle: RunHandle = {
136
+ id: opts.id,
137
+ role: opts.role,
138
+ task: opts.task,
139
+ context: opts.context,
140
+ files: opts.files,
141
+ get state() {
142
+ return currentState;
143
+ },
144
+ get snapshot() {
145
+ return snapshot;
146
+ },
147
+ get result() {
148
+ return result;
149
+ },
150
+ get thrown() {
151
+ return thrown;
152
+ },
153
+ subscribe(fn) {
154
+ listeners.add(fn);
155
+ return () => {
156
+ listeners.delete(fn);
157
+ };
158
+ },
159
+ promise,
160
+ };
161
+
162
+ (async () => {
163
+ // ── Concurrency gate (abortable while queued) ──
164
+ try {
165
+ await opts.gate.acquire(opts.signal);
166
+ } catch {
167
+ const msg = `Subagent (${opts.role}) was cancelled while queued.`;
168
+ finish({ ...inputFrame(1, false), errorMessage: msg }, new Error("cancelled while queued"));
169
+ return;
170
+ }
171
+
172
+ try {
173
+ // Resolve the model AFTER acquiring so the queued period stays zero-cost.
174
+ let rolesApi: ModelRolesAPI;
175
+ try {
176
+ rolesApi = opts.getRolesApi();
177
+ } catch {
178
+ finish({
179
+ ...inputFrame(1, false),
180
+ errorMessage: "pi-model-roles is not initialized. Cannot resolve model for subagent.",
181
+ });
182
+ return;
183
+ }
184
+
185
+ let modelRef: string;
186
+ let thinking: ThinkingLevel | undefined;
187
+ if (opts.modelOverride) {
188
+ modelRef = opts.modelOverride;
189
+ } else {
190
+ const resolved = await rolesApi.resolveRoleAsync(opts.roleDef.role);
191
+ if (!resolved.model) {
192
+ finish({
193
+ ...inputFrame(1, false),
194
+ errorMessage: `Role "${opts.roleDef.role}" could not be resolved. Model not available.`,
195
+ });
196
+ return;
197
+ }
198
+ modelRef = `${resolved.model.provider}/${resolved.model.id}`;
199
+ thinking = resolved.config.thinking;
200
+ }
201
+
202
+ const startTime = Date.now();
203
+ /** Snapshot of a failed first attempt; set before a fallback retry spawns so running frames can show the trace. */
204
+ let activeFallbackFrom: FallbackFrom | undefined;
205
+ // Total active-time budget for this run (ms). The clock pauses while the
206
+ // child delegates, so this caps *active* time, not wall time.
207
+ const timeoutBudgetMs = effectiveTimeout(opts.roleDef) * 1000;
208
+ const maxTurns = opts.roleDef.maxTurns ?? opts.config.maxTurns;
209
+ const maxCost = opts.roleDef.maxCost ?? opts.config.maxCost;
210
+
211
+ // Every progress partial becomes a full TUI-ready frame.
212
+ const liveFrame = (partial: Partial<SubagentResult>): SubagentResult => ({
213
+ role: opts.role,
214
+ task: opts.task,
215
+ exitCode: -1,
216
+ output: partial.output ?? "",
217
+ stderr: "",
218
+ usage: partial.usage ?? emptyUsage(),
219
+ model: partial.model,
220
+ stopReason: partial.stopReason,
221
+ activityLog: partial.activityLog ?? [],
222
+ startTime,
223
+ budgetMs: timeoutBudgetMs,
224
+ graceMs: partial.graceMs,
225
+ pauseStart: partial.pauseStart,
226
+ files: opts.files,
227
+ context: opts.context,
228
+ fallbackFrom: activeFallbackFrom,
229
+ });
230
+ const emitProgress = (partial: Partial<SubagentResult>) => setFrame(liveFrame(partial), "running");
231
+
232
+ // Running placeholder now that we hold a slot.
233
+ setFrame(liveFrame({}), "running");
234
+
235
+ let runResult = await spawn(modelRef, opts.task, {
236
+ cwd: opts.cwd,
237
+ thinking,
238
+ tools: opts.roleDef.tools,
239
+ systemPrompt: opts.roleDef.systemPrompt,
240
+ context: opts.context,
241
+ contextFiles: opts.files,
242
+ subagentRoles: opts.roleDef.subagentRoles,
243
+ timeoutMs: timeoutBudgetMs,
244
+ maxTurns,
245
+ maxCost,
246
+ depth: opts.depth,
247
+ signal: opts.signal,
248
+ onProgress: emitProgress,
249
+ });
250
+
251
+ // Retry with fallback role on provider errors (quota, auth, timeout, etc.)
252
+ if (
253
+ (runResult.exitCode !== 0 || runResult.errorMessage) &&
254
+ opts.roleDef.fallbackRole &&
255
+ isProviderError(runResult)
256
+ ) {
257
+ const fallback = await rolesApi.resolveRoleAsync(opts.roleDef.fallbackRole);
258
+ if (fallback.model) {
259
+ const fbRef = `${fallback.model.provider}/${fallback.model.id}`;
260
+ // Snapshot the failed first attempt BEFORE the retry — spawn returns
261
+ // a fresh object, but building the snapshot up front also keeps it
262
+ // if the retry throws (abort). modelRef fills the model field when
263
+ // the child died before any message_end; activeFallbackFrom threads
264
+ // the trace into running frames while the retry is in flight.
265
+ const fallbackFrom = buildFallbackFrom(runResult, modelRef);
266
+ activeFallbackFrom = fallbackFrom;
267
+ runResult = await spawn(fbRef, opts.task, {
268
+ cwd: opts.cwd,
269
+ thinking: fallback.config.thinking,
270
+ tools: opts.roleDef.tools,
271
+ systemPrompt: opts.roleDef.systemPrompt,
272
+ context: opts.context,
273
+ contextFiles: opts.files,
274
+ subagentRoles: opts.roleDef.subagentRoles,
275
+ timeoutMs: timeoutBudgetMs,
276
+ maxTurns,
277
+ maxCost,
278
+ depth: opts.depth,
279
+ signal: opts.signal,
280
+ onProgress: emitProgress,
281
+ });
282
+ runResult.fallbackFrom = fallbackFrom;
283
+ }
284
+ }
285
+
286
+ // Stamp terminal fields once, after any fallback retry: elapsedMs covers
287
+ // the whole delegate span (incl. retry); role/files/context mirror the
288
+ // delegate params (spawn never learns the registry role name).
289
+ runResult.role = opts.role;
290
+ runResult.files = opts.files;
291
+ runResult.context = opts.context;
292
+ runResult.elapsedMs = Date.now() - startTime;
293
+
294
+ // Compress/truncate oversized output before it reaches the main model or TUI.
295
+ // Keep the raw original for the history file (audit), feed the prepared text to LLM + expanded view.
296
+ const rawOutput = runResult.output;
297
+ if (runResult.output.length > MAX_OUTPUT_CHARS) {
298
+ const { text, method } = await compressOutput(
299
+ rolesApi,
300
+ runResult.output,
301
+ opts.task,
302
+ opts.config.summary,
303
+ );
304
+ runResult.output = text;
305
+ runResult.outputMethod = method;
306
+ } else {
307
+ runResult.outputMethod = "raw";
308
+ }
309
+
310
+ // Generate summary for TUI display
311
+ if (opts.config.summary.enabled && runResult.output.trim()) {
312
+ runResult.summary = await generateSummary(rolesApi, runResult.output, opts.config.summary);
313
+ }
314
+
315
+ // Persist audit record (best-effort; covers both success and failure).
316
+ // History keeps the raw original output even when LLM/TUI saw a compressed/truncated version.
317
+ if (opts.config.history.enabled) {
318
+ let sessionId: string | undefined;
319
+ try {
320
+ sessionId = opts.getSessionId?.();
321
+ } catch {
322
+ /* ignore */
323
+ }
324
+ persistSubagentHistory(sessionId, opts.toolCallId, opts.role, opts.task, runResult, rawOutput);
325
+ }
326
+
327
+ finish(runResult);
328
+ } catch (err: any) {
329
+ // Keep whatever the last live frame gathered so aborted/crashed runs
330
+ // still show their partial activity and usage.
331
+ const partial = snapshot;
332
+ finish(
333
+ {
334
+ ...inputFrame(1, false),
335
+ output: partial.output,
336
+ usage: partial.usage,
337
+ model: partial.model,
338
+ stopReason: partial.stopReason,
339
+ activityLog: partial.activityLog,
340
+ budgetMs: partial.budgetMs,
341
+ elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
342
+ errorMessage: err?.message || String(err),
343
+ },
344
+ err instanceof Error ? err : new Error(String(err)),
345
+ );
346
+ } finally {
347
+ opts.gate.release();
348
+ }
349
+ })();
350
+
351
+ return handle;
352
+ }