@d3ara1n/pi-subagent 0.10.4 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -13
- package/package.json +1 -1
- package/src/history.ts +8 -3
- package/src/index.ts +382 -341
- package/src/output.ts +10 -11
- package/src/render-async.ts +324 -0
- package/src/render.ts +54 -135
- package/src/roles.ts +8 -8
- package/src/run.test.ts +276 -0
- package/src/run.ts +363 -0
- package/src/spawn.ts +26 -26
- package/src/types.ts +55 -7
- package/src/utils.test.ts +321 -49
- package/src/utils.ts +336 -12
package/src/roles.ts
CHANGED
|
@@ -56,10 +56,10 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
56
56
|
role: "default",
|
|
57
57
|
timeout: 2400,
|
|
58
58
|
description:
|
|
59
|
-
"the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep, find,
|
|
59
|
+
"the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep, find, subagent_delegate. Can delegate to explorer/researcher.",
|
|
60
60
|
examples: ["Rename all snake_case fields to camelCase", "Add input validation to POST /login"],
|
|
61
61
|
decisionTrigger: "Task modifies files?",
|
|
62
|
-
tools: ["read", "bash", "edit", "write", "grep", "find", "
|
|
62
|
+
tools: ["read", "bash", "edit", "write", "grep", "find", "subagent_delegate"],
|
|
63
63
|
subagentRoles: ["explorer", "researcher"],
|
|
64
64
|
systemPrompt: [
|
|
65
65
|
"Implementation worker. Work autonomously — all context is in the task description.",
|
|
@@ -67,9 +67,9 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
67
67
|
"After each change, validate: run tests, check syntax, verify behavior.",
|
|
68
68
|
"",
|
|
69
69
|
"## Protecting your context",
|
|
70
|
-
"You have a `
|
|
71
|
-
"-
|
|
72
|
-
"-
|
|
70
|
+
"You have a `subagent_delegate` tool. Use it to offload exploration and research:",
|
|
71
|
+
"- subagent_delegate(role=explorer) when you need to map unfamiliar code before editing",
|
|
72
|
+
"- subagent_delegate(role=researcher) when you need external docs or library references",
|
|
73
73
|
"Don't delegate tasks you can do with a single read or grep.",
|
|
74
74
|
"",
|
|
75
75
|
"Output format (be brief — summarize, don't paste full diffs):",
|
|
@@ -82,10 +82,10 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
82
82
|
fallbackRole: "default",
|
|
83
83
|
timeout: 2400,
|
|
84
84
|
description:
|
|
85
|
-
"the ONLY role with WEB ACCESS — search docs, fetch pages, analyze GitHub repos. Tools: web_search, fetch_content, read, bash,
|
|
85
|
+
"the ONLY role with WEB ACCESS — search docs, fetch pages, analyze GitHub repos. Tools: web_search, fetch_content, read, bash, subagent_delegate. Can clone repos & delegate to explorer.",
|
|
86
86
|
examples: ["Find the React 19 migration guide", "Check GitHub issue #1234 for context"],
|
|
87
87
|
decisionTrigger: "Task searches web or GitHub?",
|
|
88
|
-
tools: ["web_search", "fetch_content", "read", "bash", "
|
|
88
|
+
tools: ["web_search", "fetch_content", "read", "bash", "subagent_delegate"],
|
|
89
89
|
subagentRoles: ["explorer"],
|
|
90
90
|
systemPrompt: [
|
|
91
91
|
"Web researcher. Search with varied angles, prefer official docs over blogs.",
|
|
@@ -94,7 +94,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
94
94
|
"## GitHub repo analysis",
|
|
95
95
|
"When the task requires analyzing a GitHub repo:",
|
|
96
96
|
"1. git clone the repo into PI_SUBAGENT_TMPDIR (must exist)",
|
|
97
|
-
"2. Use `
|
|
97
|
+
"2. Use `subagent_delegate` with role=explorer to investigate the cloned codebase — pass the repo path and the research question",
|
|
98
98
|
"3. Combine explorer findings with any web search results",
|
|
99
99
|
"",
|
|
100
100
|
"bash is for git clone and read-only commands only. Never modify files.",
|
package/src/run.test.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
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 (_m, _t, options) => {
|
|
127
|
+
options.onProgress?.({
|
|
128
|
+
output: "partial",
|
|
129
|
+
activityLog: [{ kind: "toolCall", id: "t1", status: "done", toolName: "read", args: {} }],
|
|
130
|
+
});
|
|
131
|
+
throw new Error("Subagent was aborted");
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const run = startSubagentRun(makeDeps({ spawnImpl }));
|
|
135
|
+
const result = await run.promise; // never rejects
|
|
136
|
+
|
|
137
|
+
assert.strictEqual(run.state, "failed");
|
|
138
|
+
assert.ok(run.thrown instanceof Error);
|
|
139
|
+
assert.strictEqual(run.thrown.message, "Subagent was aborted");
|
|
140
|
+
assert.strictEqual(result.errorMessage, "Subagent was aborted");
|
|
141
|
+
// The partial frame survives — the foreground path renders aborts like any
|
|
142
|
+
// failure (task line + activity + result line) instead of a bare error.
|
|
143
|
+
assert.strictEqual(result.output, "partial");
|
|
144
|
+
assert.strictEqual(result.activityLog.length, 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("spawned runs persist to history on every terminal path; pre-run failures do not", async () => {
|
|
148
|
+
const persisted: SubagentResult[] = [];
|
|
149
|
+
const persistImpl = (
|
|
150
|
+
_sessionId: string | undefined,
|
|
151
|
+
_toolCallId: string,
|
|
152
|
+
_role: string,
|
|
153
|
+
_task: string,
|
|
154
|
+
r: SubagentResult,
|
|
155
|
+
) => {
|
|
156
|
+
persisted.push(r);
|
|
157
|
+
};
|
|
158
|
+
const historyConfig = { ...testConfig, history: { enabled: true } };
|
|
159
|
+
|
|
160
|
+
// Abort mid-run: the run spawned, so it must be audited.
|
|
161
|
+
const aborted = startSubagentRun(
|
|
162
|
+
makeDeps({
|
|
163
|
+
config: historyConfig,
|
|
164
|
+
spawnImpl: async (_m, _t, options) => {
|
|
165
|
+
options.onProgress?.({
|
|
166
|
+
output: "partial",
|
|
167
|
+
activityLog: [{ kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} }],
|
|
168
|
+
});
|
|
169
|
+
throw new Error("Subagent was aborted");
|
|
170
|
+
},
|
|
171
|
+
persistImpl,
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
await aborted.promise;
|
|
175
|
+
assert.equal(persisted.length, 1);
|
|
176
|
+
assert.match(persisted[0].errorMessage!, /aborted/);
|
|
177
|
+
assert.equal(persisted[0].activityLog.length, 1);
|
|
178
|
+
|
|
179
|
+
// Pre-run failure (roles api unavailable): never spawned, not audited.
|
|
180
|
+
const prerun = startSubagentRun(
|
|
181
|
+
makeDeps({
|
|
182
|
+
config: historyConfig,
|
|
183
|
+
getRolesApi: () => {
|
|
184
|
+
throw new Error("not initialized");
|
|
185
|
+
},
|
|
186
|
+
persistImpl,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
await prerun.promise;
|
|
190
|
+
assert.equal(persisted.length, 1);
|
|
191
|
+
|
|
192
|
+
// Normal success is audited too.
|
|
193
|
+
const ok = startSubagentRun(
|
|
194
|
+
makeDeps({
|
|
195
|
+
config: historyConfig,
|
|
196
|
+
spawnImpl: async () => makeResult({ output: "done" }),
|
|
197
|
+
persistImpl,
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
await ok.promise;
|
|
201
|
+
assert.equal(persisted.length, 2);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("provider error on first attempt retries on the fallback role", async () => {
|
|
205
|
+
const calls: string[] = [];
|
|
206
|
+
const spawnImpl: SpawnImpl = async (model) => {
|
|
207
|
+
calls.push(model);
|
|
208
|
+
if (calls.length === 1) {
|
|
209
|
+
return makeResult({ exitCode: 1, errorMessage: "429 quota exceeded", stderr: "HTTP 429" });
|
|
210
|
+
}
|
|
211
|
+
return makeResult({ output: "fallback ok" });
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const run = startSubagentRun(
|
|
215
|
+
makeDeps({
|
|
216
|
+
roleDef: { ...roleDef, fallbackRole: "default" },
|
|
217
|
+
spawnImpl,
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
const result = await run.promise;
|
|
221
|
+
|
|
222
|
+
assert.deepStrictEqual(calls, ["test/model-fast", "test/model-default"]);
|
|
223
|
+
assert.strictEqual(run.state, "finished");
|
|
224
|
+
assert.strictEqual(result.output, "fallback ok");
|
|
225
|
+
assert.ok(result.fallbackFrom, "terminal result records the failed first attempt");
|
|
226
|
+
assert.strictEqual(result.fallbackFrom.model, "test/model-fast");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("prerun failure (roles api unavailable) becomes a failed run, not a throw", async () => {
|
|
230
|
+
const run = startSubagentRun(
|
|
231
|
+
makeDeps({
|
|
232
|
+
getRolesApi: () => {
|
|
233
|
+
throw new Error("not initialized");
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
const result = await run.promise;
|
|
238
|
+
|
|
239
|
+
assert.strictEqual(run.state, "failed");
|
|
240
|
+
assert.strictEqual(run.thrown, undefined);
|
|
241
|
+
assert.match(result.errorMessage!, /pi-model-roles is not initialized/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("abort while queued fails the run and exposes thrown for the foreground path", async () => {
|
|
245
|
+
const gate = new AsyncSemaphore(1);
|
|
246
|
+
await gate.acquire();
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
controller.abort();
|
|
249
|
+
|
|
250
|
+
const run = startSubagentRun(makeDeps({ gate, signal: controller.signal }));
|
|
251
|
+
const result = await run.promise;
|
|
252
|
+
|
|
253
|
+
assert.strictEqual(run.state, "failed");
|
|
254
|
+
assert.ok(run.thrown instanceof Error);
|
|
255
|
+
assert.match(result.errorMessage!, /cancelled while queued/);
|
|
256
|
+
gate.release();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("subscribers are notified on progress and terminal frames", async () => {
|
|
260
|
+
let notifications = 0;
|
|
261
|
+
const spawnImpl: SpawnImpl = async (_m, _t, options) => {
|
|
262
|
+
options.onProgress?.({ output: "step 1" });
|
|
263
|
+
options.onProgress?.({ output: "step 2" });
|
|
264
|
+
return makeResult({ output: "final" });
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const run = startSubagentRun(makeDeps({ spawnImpl }));
|
|
268
|
+
const unsubscribe = run.subscribe(() => notifications++);
|
|
269
|
+
await run.promise;
|
|
270
|
+
unsubscribe();
|
|
271
|
+
const after = notifications;
|
|
272
|
+
// No further notifications after terminal (and after unsubscribing).
|
|
273
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
274
|
+
assert.strictEqual(notifications, after);
|
|
275
|
+
assert.ok(notifications >= 3, `progress x2 + terminal, got ${notifications}`);
|
|
276
|
+
});
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
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). The terminal result still carries the partial frame — callers report it as an ordinary failed result; 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
|
+
/** @internal — injectable history persistence for tests. */
|
|
85
|
+
persistImpl?: typeof persistSubagentHistory;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
89
|
+
const spawn = opts.spawnImpl ?? spawnSubagent;
|
|
90
|
+
const listeners = new Set<() => void>();
|
|
91
|
+
|
|
92
|
+
const inputFrame = (exitCode: number, queued: boolean): SubagentResult => ({
|
|
93
|
+
role: opts.role,
|
|
94
|
+
task: opts.task,
|
|
95
|
+
exitCode,
|
|
96
|
+
queued: queued || undefined,
|
|
97
|
+
output: "",
|
|
98
|
+
stderr: "",
|
|
99
|
+
usage: emptyUsage(),
|
|
100
|
+
activityLog: [],
|
|
101
|
+
files: opts.files,
|
|
102
|
+
context: opts.context,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let currentState: RunState = "queued";
|
|
106
|
+
let snapshot: SubagentResult = inputFrame(-1, true);
|
|
107
|
+
let result: SubagentResult | undefined;
|
|
108
|
+
let thrown: Error | undefined;
|
|
109
|
+
let resolvePromise!: (r: SubagentResult) => void;
|
|
110
|
+
const promise = new Promise<SubagentResult>((resolve) => {
|
|
111
|
+
resolvePromise = resolve;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const notify = () => {
|
|
115
|
+
for (const fn of [...listeners]) {
|
|
116
|
+
try {
|
|
117
|
+
fn();
|
|
118
|
+
} catch {
|
|
119
|
+
/* listener errors never break the run */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const setFrame = (frame: SubagentResult, state: RunState) => {
|
|
124
|
+
snapshot = frame;
|
|
125
|
+
currentState = state;
|
|
126
|
+
notify();
|
|
127
|
+
};
|
|
128
|
+
const finish = (terminal: SubagentResult, error?: Error) => {
|
|
129
|
+
result = terminal;
|
|
130
|
+
snapshot = terminal;
|
|
131
|
+
thrown = error;
|
|
132
|
+
currentState = isFailedResult(terminal) ? "failed" : "finished";
|
|
133
|
+
notify();
|
|
134
|
+
resolvePromise(terminal);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handle: RunHandle = {
|
|
138
|
+
id: opts.id,
|
|
139
|
+
role: opts.role,
|
|
140
|
+
task: opts.task,
|
|
141
|
+
context: opts.context,
|
|
142
|
+
files: opts.files,
|
|
143
|
+
get state() {
|
|
144
|
+
return currentState;
|
|
145
|
+
},
|
|
146
|
+
get snapshot() {
|
|
147
|
+
return snapshot;
|
|
148
|
+
},
|
|
149
|
+
get result() {
|
|
150
|
+
return result;
|
|
151
|
+
},
|
|
152
|
+
get thrown() {
|
|
153
|
+
return thrown;
|
|
154
|
+
},
|
|
155
|
+
subscribe(fn) {
|
|
156
|
+
listeners.add(fn);
|
|
157
|
+
return () => {
|
|
158
|
+
listeners.delete(fn);
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
promise,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
(async () => {
|
|
165
|
+
// ── Concurrency gate (abortable while queued) ──
|
|
166
|
+
try {
|
|
167
|
+
await opts.gate.acquire(opts.signal);
|
|
168
|
+
} catch {
|
|
169
|
+
const msg = "cancelled while queued for a concurrency slot";
|
|
170
|
+
finish({ ...inputFrame(1, false), errorMessage: msg }, new Error(msg));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Audit every spawned run — finished, failed, and aborted alike: an
|
|
175
|
+
// aborted run already consumed tokens, so its cost must stay in the
|
|
176
|
+
// audit log. Pre-run failures (queued-cancel, role/model resolution)
|
|
177
|
+
// never spawned and are not recorded.
|
|
178
|
+
const persist = opts.persistImpl ?? persistSubagentHistory;
|
|
179
|
+
const persistHistory = (terminal: SubagentResult, rawOutput?: string): void => {
|
|
180
|
+
if (!opts.config.history.enabled) return;
|
|
181
|
+
let sessionId: string | undefined;
|
|
182
|
+
try {
|
|
183
|
+
sessionId = opts.getSessionId?.();
|
|
184
|
+
} catch {
|
|
185
|
+
/* ignore */
|
|
186
|
+
}
|
|
187
|
+
persist(sessionId, opts.toolCallId, opts.role, opts.task, terminal, rawOutput);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// Resolve the model AFTER acquiring so the queued period stays zero-cost.
|
|
192
|
+
let rolesApi: ModelRolesAPI;
|
|
193
|
+
try {
|
|
194
|
+
rolesApi = opts.getRolesApi();
|
|
195
|
+
} catch {
|
|
196
|
+
finish({
|
|
197
|
+
...inputFrame(1, false),
|
|
198
|
+
errorMessage: "pi-model-roles is not initialized. Cannot resolve model for subagent.",
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let modelRef: string;
|
|
204
|
+
let thinking: ThinkingLevel | undefined;
|
|
205
|
+
if (opts.modelOverride) {
|
|
206
|
+
modelRef = opts.modelOverride;
|
|
207
|
+
} else {
|
|
208
|
+
const resolved = await rolesApi.resolveRoleAsync(opts.roleDef.role);
|
|
209
|
+
if (!resolved.model) {
|
|
210
|
+
finish({
|
|
211
|
+
...inputFrame(1, false),
|
|
212
|
+
errorMessage: `Role "${opts.roleDef.role}" could not be resolved. Model not available.`,
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
modelRef = `${resolved.model.provider}/${resolved.model.id}`;
|
|
217
|
+
thinking = resolved.config.thinking;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const startTime = Date.now();
|
|
221
|
+
/** Snapshot of a failed first attempt; set before a fallback retry spawns so running frames can show the trace. */
|
|
222
|
+
let activeFallbackFrom: FallbackFrom | undefined;
|
|
223
|
+
// Total active-time budget for this run (ms). The clock pauses while the
|
|
224
|
+
// child delegates, so this caps *active* time, not wall time.
|
|
225
|
+
const timeoutBudgetMs = effectiveTimeout(opts.roleDef) * 1000;
|
|
226
|
+
const maxTurns = opts.roleDef.maxTurns ?? opts.config.maxTurns;
|
|
227
|
+
const maxCost = opts.roleDef.maxCost ?? opts.config.maxCost;
|
|
228
|
+
|
|
229
|
+
// Every progress partial becomes a full TUI-ready frame.
|
|
230
|
+
const liveFrame = (partial: Partial<SubagentResult>): SubagentResult => ({
|
|
231
|
+
role: opts.role,
|
|
232
|
+
task: opts.task,
|
|
233
|
+
exitCode: -1,
|
|
234
|
+
output: partial.output ?? "",
|
|
235
|
+
stderr: "",
|
|
236
|
+
usage: partial.usage ?? emptyUsage(),
|
|
237
|
+
model: partial.model,
|
|
238
|
+
stopReason: partial.stopReason,
|
|
239
|
+
activityLog: partial.activityLog ?? [],
|
|
240
|
+
startTime,
|
|
241
|
+
budgetMs: timeoutBudgetMs,
|
|
242
|
+
graceMs: partial.graceMs,
|
|
243
|
+
pauseStart: partial.pauseStart,
|
|
244
|
+
files: opts.files,
|
|
245
|
+
context: opts.context,
|
|
246
|
+
fallbackFrom: activeFallbackFrom,
|
|
247
|
+
});
|
|
248
|
+
const emitProgress = (partial: Partial<SubagentResult>) => setFrame(liveFrame(partial), "running");
|
|
249
|
+
|
|
250
|
+
// Running placeholder now that we hold a slot.
|
|
251
|
+
setFrame(liveFrame({}), "running");
|
|
252
|
+
|
|
253
|
+
let runResult = await spawn(modelRef, opts.task, {
|
|
254
|
+
cwd: opts.cwd,
|
|
255
|
+
thinking,
|
|
256
|
+
tools: opts.roleDef.tools,
|
|
257
|
+
systemPrompt: opts.roleDef.systemPrompt,
|
|
258
|
+
context: opts.context,
|
|
259
|
+
contextFiles: opts.files,
|
|
260
|
+
subagentRoles: opts.roleDef.subagentRoles,
|
|
261
|
+
timeoutMs: timeoutBudgetMs,
|
|
262
|
+
maxTurns,
|
|
263
|
+
maxCost,
|
|
264
|
+
depth: opts.depth,
|
|
265
|
+
signal: opts.signal,
|
|
266
|
+
onProgress: emitProgress,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Retry with fallback role on provider errors (quota, auth, timeout, etc.)
|
|
270
|
+
if (
|
|
271
|
+
(runResult.exitCode !== 0 || runResult.errorMessage) &&
|
|
272
|
+
opts.roleDef.fallbackRole &&
|
|
273
|
+
isProviderError(runResult)
|
|
274
|
+
) {
|
|
275
|
+
const fallback = await rolesApi.resolveRoleAsync(opts.roleDef.fallbackRole);
|
|
276
|
+
if (fallback.model) {
|
|
277
|
+
const fbRef = `${fallback.model.provider}/${fallback.model.id}`;
|
|
278
|
+
// Snapshot the failed first attempt BEFORE the retry — spawn returns
|
|
279
|
+
// a fresh object, but building the snapshot up front also keeps it
|
|
280
|
+
// if the retry throws (abort). modelRef fills the model field when
|
|
281
|
+
// the child died before any message_end; activeFallbackFrom threads
|
|
282
|
+
// the trace into running frames while the retry is in flight.
|
|
283
|
+
const fallbackFrom = buildFallbackFrom(runResult, modelRef);
|
|
284
|
+
activeFallbackFrom = fallbackFrom;
|
|
285
|
+
runResult = await spawn(fbRef, opts.task, {
|
|
286
|
+
cwd: opts.cwd,
|
|
287
|
+
thinking: fallback.config.thinking,
|
|
288
|
+
tools: opts.roleDef.tools,
|
|
289
|
+
systemPrompt: opts.roleDef.systemPrompt,
|
|
290
|
+
context: opts.context,
|
|
291
|
+
contextFiles: opts.files,
|
|
292
|
+
subagentRoles: opts.roleDef.subagentRoles,
|
|
293
|
+
timeoutMs: timeoutBudgetMs,
|
|
294
|
+
maxTurns,
|
|
295
|
+
maxCost,
|
|
296
|
+
depth: opts.depth,
|
|
297
|
+
signal: opts.signal,
|
|
298
|
+
onProgress: emitProgress,
|
|
299
|
+
});
|
|
300
|
+
runResult.fallbackFrom = fallbackFrom;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Stamp terminal fields once, after any fallback retry: elapsedMs covers
|
|
305
|
+
// the whole delegate span (incl. retry); role/files/context mirror the
|
|
306
|
+
// delegate params (spawn never learns the registry role name).
|
|
307
|
+
runResult.role = opts.role;
|
|
308
|
+
runResult.files = opts.files;
|
|
309
|
+
runResult.context = opts.context;
|
|
310
|
+
runResult.elapsedMs = Date.now() - startTime;
|
|
311
|
+
|
|
312
|
+
// Compress/truncate oversized output before it reaches the main model or TUI.
|
|
313
|
+
// Keep the raw original for the history file (audit), feed the prepared text to LLM + expanded view.
|
|
314
|
+
const rawOutput = runResult.output;
|
|
315
|
+
if (runResult.output.length > MAX_OUTPUT_CHARS) {
|
|
316
|
+
const { text, method } = await compressOutput(
|
|
317
|
+
rolesApi,
|
|
318
|
+
runResult.output,
|
|
319
|
+
opts.task,
|
|
320
|
+
opts.config.summary,
|
|
321
|
+
);
|
|
322
|
+
runResult.output = text;
|
|
323
|
+
runResult.outputMethod = method;
|
|
324
|
+
} else {
|
|
325
|
+
runResult.outputMethod = "raw";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Generate summary for TUI display
|
|
329
|
+
if (opts.config.summary.enabled && runResult.output.trim()) {
|
|
330
|
+
runResult.summary = await generateSummary(rolesApi, runResult.output, opts.config.summary);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Best-effort audit record. The raw original output is kept even when
|
|
334
|
+
// the LLM/TUI saw a compressed/truncated version.
|
|
335
|
+
persistHistory(runResult, rawOutput);
|
|
336
|
+
|
|
337
|
+
finish(runResult);
|
|
338
|
+
} catch (err: any) {
|
|
339
|
+
// Keep whatever the last live frame gathered so aborted/crashed runs
|
|
340
|
+
// still show their partial activity and usage.
|
|
341
|
+
const partial = snapshot;
|
|
342
|
+
const terminal: SubagentResult = {
|
|
343
|
+
...inputFrame(1, false),
|
|
344
|
+
output: partial.output,
|
|
345
|
+
usage: partial.usage,
|
|
346
|
+
model: partial.model,
|
|
347
|
+
stopReason: partial.stopReason,
|
|
348
|
+
activityLog: partial.activityLog,
|
|
349
|
+
budgetMs: partial.budgetMs,
|
|
350
|
+
elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
|
|
351
|
+
errorMessage: err?.message || String(err),
|
|
352
|
+
};
|
|
353
|
+
// The run spawned before throwing — audit it like any terminal state.
|
|
354
|
+
// The partial output is raw (compression never ran on it).
|
|
355
|
+
persistHistory(terminal);
|
|
356
|
+
finish(terminal, err instanceof Error ? err : new Error(String(err)));
|
|
357
|
+
} finally {
|
|
358
|
+
opts.gate.release();
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
|
|
362
|
+
return handle;
|
|
363
|
+
}
|