@d3ara1n/pi-subagent 1.0.0 → 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 +1 -1
- package/package.json +1 -1
- package/src/history.ts +7 -3
- package/src/index.ts +4 -8
- package/src/render.ts +5 -5
- package/src/run.test.ts +66 -1
- package/src/run.ts +39 -28
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -260,7 +260,7 @@ When a provider error (429, quota, timeout, ...) kills a run and the whole task
|
|
|
260
260
|
|
|
261
261
|
### Run history
|
|
262
262
|
|
|
263
|
-
Every
|
|
263
|
+
Every **spawned** delegate run is written (best-effort) to `~/.pi/subagent/history/{sessionId}/{toolCallId}.json` — finished, failed, and aborted alike (an aborted run already consumed tokens, so its partial activity and cost stay auditable). Records cover role, task, usage, activity log, the **full raw output** (even when the main model saw a compressed/truncated version), and the `fallbackFrom` snapshot when the run was retried on the fallback role. Runs that never spawned (cancelled while queued, role/model resolution failures) are not recorded. Useful for auditing what subagents did and how much they cost. Disable with `history.enabled: false`.
|
|
264
264
|
|
|
265
265
|
## License
|
|
266
266
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/history.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* History persistence for pi-subagent delegate runs.
|
|
3
3
|
*
|
|
4
|
-
* Best-effort audit log: writes one JSON record per delegate run
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Best-effort audit log: writes one JSON record per *spawned* delegate run —
|
|
5
|
+
* finished, failed, and aborted alike (an aborted run already consumed
|
|
6
|
+
* tokens, so its partial activity and cost stay auditable). Pre-run failures
|
|
7
|
+
* that never spawned (queued-cancel, role/model resolution) are not recorded.
|
|
8
|
+
* Records land under ~/.pi/subagent/history/{sessionId}/{toolCallId}.json.
|
|
9
|
+
* Never throws — persistence must not fail the delegation. Privacy parity
|
|
10
|
+
* with pi's own session files.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import * as os from "node:os";
|
package/src/index.ts
CHANGED
|
@@ -345,14 +345,6 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
345
345
|
try {
|
|
346
346
|
const result = await run.promise;
|
|
347
347
|
|
|
348
|
-
// Pipeline throws (abort, spawn crash) surface as tool errors. The
|
|
349
|
-
// empty-results frame keeps the TUI on the plain-content fallback.
|
|
350
|
-
if (run.thrown) {
|
|
351
|
-
const errorText = `Subagent (${params.role}) error: ${run.thrown.message || run.thrown}`;
|
|
352
|
-
emit([], errorText);
|
|
353
|
-
throw new Error(errorText);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
348
|
// Fallback note: the main model must know the answer came from the
|
|
357
349
|
// fallback model, not the role's primary — on success AND failure.
|
|
358
350
|
// Budget note: budget stops are intentional successes, but the model
|
|
@@ -360,6 +352,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
360
352
|
const fallbackNote = formatFallbackNote(result);
|
|
361
353
|
const budgetNote = formatBudgetNote(result);
|
|
362
354
|
|
|
355
|
+
// Aborts and spawn crashes arrive here too: the engine resolves them
|
|
356
|
+
// into failed results that keep the partial frame (task, activity,
|
|
357
|
+
// output, usage), so the TUI renders them like any failure instead
|
|
358
|
+
// of collapsing to a bare error line.
|
|
363
359
|
if (isFailedResult(result)) {
|
|
364
360
|
const failedText =
|
|
365
361
|
`Subagent (${params.role}) failed: ${result.errorMessage || result.stderr || "unknown error"}\n\nPartial output:\n${result.output}` +
|
package/src/render.ts
CHANGED
|
@@ -45,11 +45,11 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
|
|
|
45
45
|
const isRunning = !!details?.results[0] && details.results[0].exitCode === -1;
|
|
46
46
|
|
|
47
47
|
// Tick elapsed time every second while running; stop once terminal.
|
|
48
|
-
// Placed BEFORE the
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
48
|
+
// Placed BEFORE the missing-details early return so every terminal path
|
|
49
|
+
// still clears the timer — otherwise the interval leaks a permanent
|
|
50
|
+
// 1 Hz re-render per row. The timer calls context.invalidate() so the
|
|
51
|
+
// render recomputes elapsed time fresh from Date.now() without dirtying
|
|
52
|
+
// the data layer.
|
|
53
53
|
if (isRunning) {
|
|
54
54
|
ensureElapsedTimer(context);
|
|
55
55
|
} else {
|
package/src/run.test.ts
CHANGED
|
@@ -123,7 +123,11 @@ test("non-zero exit yields state failed without thrown", async () => {
|
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
test("a throwing spawn resolves the promise with a failed result carrying the error", async () => {
|
|
126
|
-
const spawnImpl: SpawnImpl = 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
|
+
});
|
|
127
131
|
throw new Error("Subagent was aborted");
|
|
128
132
|
};
|
|
129
133
|
|
|
@@ -134,6 +138,67 @@ test("a throwing spawn resolves the promise with a failed result carrying the er
|
|
|
134
138
|
assert.ok(run.thrown instanceof Error);
|
|
135
139
|
assert.strictEqual(run.thrown.message, "Subagent was aborted");
|
|
136
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);
|
|
137
202
|
});
|
|
138
203
|
|
|
139
204
|
test("provider error on first attempt retries on the fallback role", async () => {
|
package/src/run.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface RunHandle {
|
|
|
48
48
|
readonly snapshot: SubagentResult;
|
|
49
49
|
/** Terminal result; undefined while queued/running. */
|
|
50
50
|
readonly result: SubagentResult | undefined;
|
|
51
|
-
/** Set when the pipeline threw (abort, spawn crash).
|
|
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
52
|
readonly thrown: Error | undefined;
|
|
53
53
|
/** Resolves with the terminal result once the run finishes (always succeeds). */
|
|
54
54
|
readonly promise: Promise<SubagentResult>;
|
|
@@ -81,6 +81,8 @@ export interface StartRunOptions {
|
|
|
81
81
|
getSessionId?: () => string | undefined;
|
|
82
82
|
/** @internal — injectable spawn for tests. */
|
|
83
83
|
spawnImpl?: typeof spawnSubagent;
|
|
84
|
+
/** @internal — injectable history persistence for tests. */
|
|
85
|
+
persistImpl?: typeof persistSubagentHistory;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
@@ -164,11 +166,27 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
164
166
|
try {
|
|
165
167
|
await opts.gate.acquire(opts.signal);
|
|
166
168
|
} catch {
|
|
167
|
-
const msg =
|
|
168
|
-
finish({ ...inputFrame(1, false), errorMessage: msg }, new Error(
|
|
169
|
+
const msg = "cancelled while queued for a concurrency slot";
|
|
170
|
+
finish({ ...inputFrame(1, false), errorMessage: msg }, new Error(msg));
|
|
169
171
|
return;
|
|
170
172
|
}
|
|
171
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
|
+
|
|
172
190
|
try {
|
|
173
191
|
// Resolve the model AFTER acquiring so the queued period stays zero-cost.
|
|
174
192
|
let rolesApi: ModelRolesAPI;
|
|
@@ -312,37 +330,30 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
312
330
|
runResult.summary = await generateSummary(rolesApi, runResult.output, opts.config.summary);
|
|
313
331
|
}
|
|
314
332
|
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
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
|
-
}
|
|
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);
|
|
326
336
|
|
|
327
337
|
finish(runResult);
|
|
328
338
|
} catch (err: any) {
|
|
329
339
|
// Keep whatever the last live frame gathered so aborted/crashed runs
|
|
330
340
|
// still show their partial activity and usage.
|
|
331
341
|
const partial = snapshot;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
);
|
|
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)));
|
|
346
357
|
} finally {
|
|
347
358
|
opts.gate.release();
|
|
348
359
|
}
|
package/src/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface SubagentConfig {
|
|
|
12
12
|
maxTurns: number;
|
|
13
13
|
/** Default cumulative cost budget in USD. `0` means unlimited; negative values are normalized to `0`. Per-role maxCost overrides this. */
|
|
14
14
|
maxCost: number;
|
|
15
|
-
/** Persist
|
|
15
|
+
/** Persist every spawned delegate run (finished/failed/aborted alike) to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. Pre-run failures that never spawned are not recorded. */
|
|
16
16
|
history: SubagentHistoryConfig;
|
|
17
17
|
summary: SubagentSummaryConfig;
|
|
18
18
|
/**
|