@ferris1225/pi-subagents 0.29.0 → 0.31.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.
- package/README.md +24 -19
- package/package.json +1 -1
- package/src/completion.ts +28 -0
- package/src/dispatch.ts +123 -7
- package/src/format.ts +11 -4
- package/src/monitor.ts +27 -0
- package/src/prompt.ts +6 -0
- package/src/runtime.ts +36 -1
- package/src/spawn.ts +189 -68
- package/src/tools.ts +4 -1
- package/src/widget.ts +5 -1
package/README.md
CHANGED
|
@@ -28,6 +28,10 @@ on its own — no prompt engineering, no babysitting.
|
|
|
28
28
|
"go check" step. `subagent_wait` is a **non-blocking** in-turn lookup by default
|
|
29
29
|
(pass `timeoutMs` to block); `subagent_status` inspects runs; `subagent_stop`
|
|
30
30
|
cancels one and delivers its partial output.
|
|
31
|
+
- **Results are not re-narrated** — a sub-agent's completion is shown to you
|
|
32
|
+
verbatim, and the main agent is told not to paraphrase it back. It replies with
|
|
33
|
+
only its own conclusion or next step, so the same findings are never paid for
|
|
34
|
+
twice in tokens.
|
|
31
35
|
- **A quality gate that closes the loop** — when a reviewer returns `REVIEW_FAIL`,
|
|
32
36
|
the extension dispatches a worker briefed with the concrete findings, then a
|
|
33
37
|
re-review, up to `maxFixRounds` times — and only then wakes the main agent.
|
|
@@ -36,6 +40,10 @@ on its own — no prompt engineering, no babysitting.
|
|
|
36
40
|
with backoff, then falls back once to the main window's model; terminal errors
|
|
37
41
|
(quota/auth) short-circuit straight to the main agent; an idle watchdog kills
|
|
38
42
|
runs that go silent; startup races are retried with backoff.
|
|
43
|
+
- **Resumes, not restarts, on a model switch** — every run is session-backed, so
|
|
44
|
+
a model quota/auth failure resumes on another model with its earlier searches,
|
|
45
|
+
reads, and edits intact (no re-scanning). When every model is out, the run is
|
|
46
|
+
handed back with its session preserved for a one-call `subagent({ resume })`.
|
|
39
47
|
- **Honest completions** — a run that ended with failed tool calls (e.g. a broken
|
|
40
48
|
build) is reported as `completed with N failed tool call(s)` with the errors
|
|
41
49
|
attached — a cheerful final text can never hide a failure.
|
|
@@ -195,6 +203,22 @@ errors, then once with the main window's model — per-run only, never persisted
|
|
|
195
203
|
if everything fails, the task is handed back to the main window with
|
|
196
204
|
instructions to execute it directly.
|
|
197
205
|
|
|
206
|
+
### Resuming after a model quota/auth failure
|
|
207
|
+
|
|
208
|
+
Every sub-agent run is **session-backed**: its pi session is persisted to a
|
|
209
|
+
temp dir for the run. When a model fails at the provider level, the retry and
|
|
210
|
+
the fallback **resume that session** instead of starting over — so a model
|
|
211
|
+
switch inherits the sub-agent's earlier searches, reads, and edits and never
|
|
212
|
+
re-scans. If every available model is exhausted (e.g. the account is out of
|
|
213
|
+
quota), the run is handed back with its session preserved; once you have a
|
|
214
|
+
working model again, resume it in-context:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
subagent({ resume: 7 }); // continue run #7 from where its model stopped
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The session is reclaimed once the resume succeeds (or when the session ends).
|
|
221
|
+
|
|
198
222
|
### Configuration migration
|
|
199
223
|
|
|
200
224
|
The config file migrates itself on load — no manual steps after an upgrade:
|
|
@@ -241,25 +265,6 @@ model), `tools.ts` (wait/status/stop), `widget.ts` (widget + announcements),
|
|
|
241
265
|
`monitor.ts` (run tracking), `setup.ts` (wizard), `prompt.ts` (delegation
|
|
242
266
|
directive). No runtime dependencies beyond pi peer dependencies.
|
|
243
267
|
|
|
244
|
-
## Acknowledgments
|
|
245
|
-
|
|
246
|
-
- The official [pi subagent example](https://github.com/earendil-works/pi)
|
|
247
|
-
(`examples/extensions/subagent`) — the child-process dispatch and
|
|
248
|
-
event-stream handling build on it.
|
|
249
|
-
- [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) — the
|
|
250
|
-
live widget and parallel fan-out follow its design.
|
|
251
|
-
- [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) — the
|
|
252
|
-
result-delivery design is learned from it: prompt **steer** delivery, a
|
|
253
|
-
non-blocking `subagent_wait`, status inspection, and stop/interrupt
|
|
254
|
-
management. Its status-file and workflow-script orchestration are deliberately
|
|
255
|
-
out of scope: this extension stays a focused 3-agent delegation tool with a
|
|
256
|
-
configuration wizard.
|
|
257
|
-
- The sub-agent pattern itself, popularized by
|
|
258
|
-
[Claude Code](https://github.com/anthropics/claude-code).
|
|
259
|
-
|
|
260
|
-
The agent prompts and extension code are written independently for this
|
|
261
|
-
project; the projects above served as design references.
|
|
262
|
-
|
|
263
268
|
## License
|
|
264
269
|
|
|
265
270
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/completion.ts
CHANGED
|
@@ -151,3 +151,31 @@ export function completionTriggersTurn(result: SingleResult, notifyOnReviewPass:
|
|
|
151
151
|
reviewVerdict(getResultOutput(result)) === "pass"
|
|
152
152
|
);
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
/** Minimal shape of an active run, for the "others still running" footer. Kept
|
|
156
|
+
* decoupled from the monitor's RunView so this stays a pure, easily tested
|
|
157
|
+
* formatter; the caller maps its live runs into this shape. */
|
|
158
|
+
export interface ActiveRunFoot {
|
|
159
|
+
id: number;
|
|
160
|
+
agent: string;
|
|
161
|
+
/** Optional content label (task-derived) shown next to the agent name. */
|
|
162
|
+
label?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Footer appended to a completion message when OTHER runs are still active, so
|
|
167
|
+
* the main agent does not declare the overall task done prematurely. A result
|
|
168
|
+
* arriving for one run does not mean sibling runs are finished; naming them
|
|
169
|
+
* gives the main agent concrete, in-context awareness to keep waiting.
|
|
170
|
+
*
|
|
171
|
+
* Returns "" when nothing is active (the common, single-run case stays quiet).
|
|
172
|
+
*/
|
|
173
|
+
export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed = 4): string {
|
|
174
|
+
if (runs.length === 0) return "";
|
|
175
|
+
const listed = runs.slice(0, maxListed);
|
|
176
|
+
const items = listed
|
|
177
|
+
.map((run) => `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}`)
|
|
178
|
+
.join(", ");
|
|
179
|
+
const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : "";
|
|
180
|
+
return `\n\n⚠ ${runs.length} other run${runs.length === 1 ? "" : "s"} still active: ${items}${more}. Do not conclude the overall task yet — wait for their results (they wake you automatically) or check subagent_status.`;
|
|
181
|
+
}
|
package/src/dispatch.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { rm } from "node:fs/promises";
|
|
16
17
|
import { Text } from "@earendil-works/pi-tui";
|
|
17
18
|
import { Type } from "typebox";
|
|
18
19
|
import { discoverAgents, type AgentConfig } from "./agents.ts";
|
|
@@ -47,6 +48,8 @@ import {
|
|
|
47
48
|
} from "./monitor.ts";
|
|
48
49
|
import type { SubagentRuntime } from "./runtime.ts";
|
|
49
50
|
import {
|
|
51
|
+
buildFallbackResumeReason,
|
|
52
|
+
buildResumePrompt,
|
|
50
53
|
getResultOutput,
|
|
51
54
|
isFailedResult,
|
|
52
55
|
isModelLevelFailure,
|
|
@@ -81,6 +84,12 @@ const SubagentParams = Type.Object({
|
|
|
81
84
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
|
82
85
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
83
86
|
vision: Type.Optional(Type.Boolean({ description: VISION_DESCRIPTION })),
|
|
87
|
+
resume: Type.Optional(
|
|
88
|
+
Type.Number({
|
|
89
|
+
description:
|
|
90
|
+
"Resume a handed-back run by its id: continue a sub-agent whose model hit a quota/auth limit, picking up its preserved context without re-scanning. Use the run id from a model-level handback message.",
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
84
93
|
});
|
|
85
94
|
|
|
86
95
|
/** True when any dispatched task carries the vision flag. */
|
|
@@ -142,6 +151,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
142
151
|
"Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
|
|
143
152
|
"Agents: explore (read-only codebase recon), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
|
|
144
153
|
"Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
|
|
154
|
+
"Resume: pass { resume: <runId> } to continue a run that was handed back after its model hit a quota/auth limit — it picks up the preserved context without re-scanning.",
|
|
145
155
|
"It starts agents in the background and immediately returns control to the main window; completion messages automatically wake the main agent to continue.",
|
|
146
156
|
"Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
|
|
147
157
|
"Results arrive as wake-up messages automatically — you do NOT need to wait. If you must get a result in-turn, subagent_wait is a non-blocking lookup by default (pass timeoutMs to block).",
|
|
@@ -159,6 +169,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
159
169
|
"NEVER sleep or poll, and do NOT call subagent_wait to hold the turn — subagent ends the turn immediately and the result arrives as a message that wakes you automatically (even mid-turn). Ending your turn is the default and the only correct way to wait.",
|
|
160
170
|
"If you must keep the turn for a result, call subagent_wait with an explicit timeoutMs (non-blocking by default) — never bash sleep/timeout to wait for a sub-agent.",
|
|
161
171
|
"When a delegated task may require viewing images (frontend screenshots, mockups, design comparisons), pass vision: true and give the sub-agent the exact image paths — it reads them with its read tool. The sub-agent then runs on the configured vision-capable model, or the main session's current model when none is configured.",
|
|
172
|
+
"When a sub-agent result arrives it is already shown to the user — do NOT restate, paraphrase, or summarize it; reply with only your own conclusion or next action (often just one line), since duplicating the result wastes tokens for nothing.",
|
|
162
173
|
],
|
|
163
174
|
parameters: SubagentParams,
|
|
164
175
|
|
|
@@ -251,6 +262,9 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
251
262
|
|
|
252
263
|
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
253
264
|
const hasSingle = Boolean(params.agent) && params.task !== undefined;
|
|
265
|
+
// `resume` is its own exclusive mode (it re-dispatches a handed-back run
|
|
266
|
+
// from its preserved session), so it bypasses the single/parallel check.
|
|
267
|
+
const hasResume = typeof params.resume === "number";
|
|
254
268
|
|
|
255
269
|
const makeDetails =
|
|
256
270
|
(mode: "single" | "parallel", background = false) =>
|
|
@@ -258,7 +272,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
258
272
|
|
|
259
273
|
const catalog = agents.map((a) => a.name).join(", ") || "none";
|
|
260
274
|
|
|
261
|
-
if (Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
275
|
+
if (!hasResume && Number(hasTasks) + Number(hasSingle) !== 1) {
|
|
262
276
|
return {
|
|
263
277
|
content: [
|
|
264
278
|
{
|
|
@@ -436,7 +450,16 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
436
450
|
const last = chain[chain.length - 1];
|
|
437
451
|
let block = formatChainSummary(chain);
|
|
438
452
|
if (isFailedResult(last.result) && isModelLevelFailure(last.result)) {
|
|
439
|
-
|
|
453
|
+
if (last.result.sessionDir && last.result.sessionId) {
|
|
454
|
+
runtime.preservedSessions.set(parentRunId, {
|
|
455
|
+
sessionId: last.result.sessionId,
|
|
456
|
+
sessionDir: last.result.sessionDir,
|
|
457
|
+
agentName: last.result.agent,
|
|
458
|
+
task: last.result.task,
|
|
459
|
+
vision,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(last.result, { runId: parentRunId })}`;
|
|
440
463
|
} else if (isFailedResult(last.result) || reviewVerdict(getResultOutput(last.result)) === "fail") {
|
|
441
464
|
block = `${block}\n\n${formatCompletionBlock(last.result, config.maxResultLines, ctx.cwd)}`;
|
|
442
465
|
}
|
|
@@ -487,7 +510,13 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
487
510
|
));
|
|
488
511
|
};
|
|
489
512
|
|
|
490
|
-
const startBackground = (
|
|
513
|
+
const startBackground = (
|
|
514
|
+
agentName: string,
|
|
515
|
+
task: string,
|
|
516
|
+
cwd?: string,
|
|
517
|
+
vision = false,
|
|
518
|
+
resumeSession?: { sessionId: string; sessionDir: string; preservedRunId: number },
|
|
519
|
+
): SingleResult => {
|
|
491
520
|
const agent = agents.find((candidate) => candidate.name === agentName);
|
|
492
521
|
if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
493
522
|
// A vision-flagged task runs on the configured vision model (or the main
|
|
@@ -519,6 +548,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
519
548
|
onLive,
|
|
520
549
|
makeDetails: makeDetails("single", true),
|
|
521
550
|
idleTimeoutMs: config.idleTimeoutSec * 1000,
|
|
551
|
+
// A resume reuses a preserved session (handed back after a
|
|
552
|
+
// model-level failure) so it continues in-context instead of
|
|
553
|
+
// re-scanning. The wrapper detects the existing session file and
|
|
554
|
+
// resumes it; the continuation prompt steers the model to pick up.
|
|
555
|
+
...(resumeSession
|
|
556
|
+
? {
|
|
557
|
+
sessionId: resumeSession.sessionId,
|
|
558
|
+
sessionDir: resumeSession.sessionDir,
|
|
559
|
+
stdinText: buildResumePrompt(task, buildFallbackResumeReason()),
|
|
560
|
+
}
|
|
561
|
+
: {}),
|
|
522
562
|
},
|
|
523
563
|
sessionRef,
|
|
524
564
|
);
|
|
@@ -568,15 +608,36 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
568
608
|
// the result even though the run row is already gone from the monitor.
|
|
569
609
|
runtime.registerRunResult(runId, result);
|
|
570
610
|
runtime.runControllers.delete(runId);
|
|
611
|
+
// A successful resume consumed the preserved session: reclaim its temp
|
|
612
|
+
// dir and drop the id so it cannot be re-resumed. A failed resume keeps
|
|
613
|
+
// it (still filed under the original preserved run id) for another try.
|
|
614
|
+
if (resumeSession && !failed) {
|
|
615
|
+
runtime.preservedSessions.delete(resumeSession.preservedRunId);
|
|
616
|
+
void rm(resumeSession.sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
617
|
+
}
|
|
571
618
|
if (!runtime.sessionActive) return;
|
|
619
|
+
// A model-level failure that preserved a session (the run did real work
|
|
620
|
+
// before the model quota/auth broke) files it under this run id so a
|
|
621
|
+
// later `subagent({ resume: <runId> })` can continue in-context. Skipped
|
|
622
|
+
// for a resume run — its session is already filed under the original id.
|
|
623
|
+
if (modelLevel && !resumeSession && result.sessionDir && result.sessionId) {
|
|
624
|
+
runtime.preservedSessions.set(runId, {
|
|
625
|
+
sessionId: result.sessionId,
|
|
626
|
+
sessionDir: result.sessionDir,
|
|
627
|
+
agentName: agent.name,
|
|
628
|
+
task,
|
|
629
|
+
vision,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
572
632
|
// Model-level failure: the configured model is unavailable or broke
|
|
573
|
-
// and the
|
|
574
|
-
//
|
|
575
|
-
//
|
|
633
|
+
// and the resume on the main-window model (when distinct) also failed.
|
|
634
|
+
// Hand the task back; when a session was preserved, steer the main agent
|
|
635
|
+
// to resume it in-context instead of executing it fresh.
|
|
636
|
+
const handbackRunId = resumeSession ? resumeSession.preservedRunId : runId;
|
|
576
637
|
const completion: CompletionMessageItem = {
|
|
577
638
|
agent: result.agent,
|
|
578
639
|
block: modelLevel
|
|
579
|
-
? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result)}`
|
|
640
|
+
? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result, { runId: handbackRunId })}`
|
|
580
641
|
: formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
|
|
581
642
|
triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
|
|
582
643
|
};
|
|
@@ -629,6 +690,61 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
629
690
|
return pending;
|
|
630
691
|
};
|
|
631
692
|
|
|
693
|
+
// Resume mode (exclusive): continue a handed-back run in its preserved
|
|
694
|
+
// session on the agent's configured model, picking up the prior context
|
|
695
|
+
// instead of re-scanning. Triggered by a model-level handback that named
|
|
696
|
+
// the run id, after the user has a working model again.
|
|
697
|
+
if (typeof params.resume === "number") {
|
|
698
|
+
const preserved = runtime.preservedSessions.get(params.resume);
|
|
699
|
+
if (!preserved) {
|
|
700
|
+
return {
|
|
701
|
+
content: [
|
|
702
|
+
{
|
|
703
|
+
type: "text",
|
|
704
|
+
text: `No preservable session for run #${params.resume}. It completed normally, was not a model-level handback, or the session has ended.`,
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
details: makeDetails("single")([]),
|
|
708
|
+
isError: true,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
const resumeAgent = agents.find((a) => a.name === preserved.agentName);
|
|
712
|
+
if (!resumeAgent) {
|
|
713
|
+
return {
|
|
714
|
+
content: [
|
|
715
|
+
{
|
|
716
|
+
type: "text",
|
|
717
|
+
text: `Cannot resume run #${params.resume}: agent "${preserved.agentName}" is not enabled. Re-enable it (or run /subagents-setup) and resume again.`,
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
details: makeDetails("single")([]),
|
|
721
|
+
isError: true,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
const pending = startBackground(preserved.agentName, preserved.task, undefined, preserved.vision, {
|
|
725
|
+
sessionId: preserved.sessionId,
|
|
726
|
+
sessionDir: preserved.sessionDir,
|
|
727
|
+
preservedRunId: params.resume,
|
|
728
|
+
});
|
|
729
|
+
if (pending.exitCode !== -1) {
|
|
730
|
+
return {
|
|
731
|
+
content: [{ type: "text", text: getResultOutput(pending) }],
|
|
732
|
+
details: makeDetails("single")([pending]),
|
|
733
|
+
isError: true,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
content: [
|
|
738
|
+
{
|
|
739
|
+
type: "text",
|
|
740
|
+
text: `Resuming ${preserved.agentName} (run #${params.resume}) in the background on its configured model, picking up its preserved context. Its result will automatically resume the main agent when ready.`,
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
details: makeDetails("single", true)([pending]),
|
|
744
|
+
terminate: true,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
632
748
|
// Sub-agents intentionally detach from the foreground turn. This makes the
|
|
633
749
|
// editor available immediately; completion messages later wake the main agent.
|
|
634
750
|
if (params.tasks && params.tasks.length > 0) {
|
package/src/format.ts
CHANGED
|
@@ -122,13 +122,20 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
|
|
|
122
122
|
|
|
123
123
|
/** Instruction appended to a model-level failure: the sub-agent's provider never
|
|
124
124
|
* produced usable output (or the run stalled), so the task is handed back to the
|
|
125
|
-
* main window instead of being left as a dead failure.
|
|
126
|
-
|
|
125
|
+
* main window instead of being left as a dead failure. When the run preserved a
|
|
126
|
+
* session with earlier work (and the run id is known), steer the main agent to
|
|
127
|
+
* RESUME it in-context once a model is available, instead of re-dispatching
|
|
128
|
+
* fresh (which would re-scan everything). */
|
|
129
|
+
export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
|
|
127
130
|
const sameModel = result.modelRetries
|
|
128
131
|
? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
|
|
129
132
|
: "";
|
|
130
|
-
const retry = result.modelFallbackFrom ? ", and the
|
|
131
|
-
|
|
133
|
+
const retry = result.modelFallbackFrom ? ", and the resume on the main-window model also failed" : "";
|
|
134
|
+
const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
|
|
135
|
+
const recovery = sessionPreserved
|
|
136
|
+
? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent with { resume: ${opts!.runId} } to CONTINUE it in-context (it picks up where it stopped — no re-scan), or execute the task in the main window with your own tools.`
|
|
137
|
+
: ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
|
|
138
|
+
return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}.${recovery}`;
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
/** Resolve a run-id request to actual ids: an exact numeric match always wins
|
package/src/monitor.ts
CHANGED
|
@@ -39,6 +39,10 @@ export interface RunView {
|
|
|
39
39
|
id: number;
|
|
40
40
|
agent: string;
|
|
41
41
|
task: string;
|
|
42
|
+
/** Short content label derived from the task (paths/symbols), shown next to
|
|
43
|
+
* the agent name so concurrent same-agent runs are told apart by what they
|
|
44
|
+
* are doing, not just their run id. */
|
|
45
|
+
label?: string;
|
|
42
46
|
model?: string;
|
|
43
47
|
/** Effective thinking strength this run was launched with (frontmatter/config/global). */
|
|
44
48
|
thinking?: string;
|
|
@@ -228,6 +232,28 @@ export function formatTaskSummary(task: string, maxWidth: number = TASK_SUMMARY_
|
|
|
228
232
|
return `${takeGraphemes(segments, headMax)}${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, tailMax)}`;
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
/** Max display width of a run's content label. */
|
|
236
|
+
export const RUN_LABEL_MAX = 32;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Short content label for a run, derived from its task: the single most
|
|
240
|
+
* distinguishing fragment (path, quoted phrase, symbol) so concurrent same-agent
|
|
241
|
+
* runs are told apart by WHAT they do, not just their run id. A long path keeps
|
|
242
|
+
* its tail (the filename is the recognisable part); a task with no recognizable
|
|
243
|
+
* fragment falls back to a head slice of its prose. Grapheme-safe.
|
|
244
|
+
*/
|
|
245
|
+
export function runLabel(task: string): string {
|
|
246
|
+
const fragment = extractKeyFragments(task)[0];
|
|
247
|
+
const src = fragment ?? stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
|
|
248
|
+
if (visibleWidth(src) <= RUN_LABEL_MAX) return src;
|
|
249
|
+
const chars = [...graphemeSegmenter.segment(src)].map((s) => s.segment);
|
|
250
|
+
// A path/symbol fragment keeps its tail (filename/symbol is recognisable);
|
|
251
|
+
// a prose fallback keeps its head.
|
|
252
|
+
return fragment
|
|
253
|
+
? `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, RUN_LABEL_MAX - 1)}`
|
|
254
|
+
: `${takeGraphemes(chars, RUN_LABEL_MAX - 1)}${TASK_SUMMARY_ELLIPSIS}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
231
257
|
function formatTokens(count: number): string {
|
|
232
258
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
233
259
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
@@ -401,6 +427,7 @@ export class MonitorStore {
|
|
|
401
427
|
id,
|
|
402
428
|
agent,
|
|
403
429
|
task,
|
|
430
|
+
label: runLabel(task),
|
|
404
431
|
model,
|
|
405
432
|
thinking,
|
|
406
433
|
status: "queued",
|
package/src/prompt.ts
CHANGED
|
@@ -66,6 +66,12 @@ Vision tasks:
|
|
|
66
66
|
- Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
|
|
67
67
|
- \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast/cheap — a non-vision model cannot see the images.
|
|
68
68
|
|
|
69
|
+
Result handoff (do not re-state):
|
|
70
|
+
- A sub-agent's result arrives as a message that is already shown to the user. Do NOT restate, paraphrase, or re-summarize its findings in your reply — that just burns tokens duplicating what is already visible. The user can read the result above.
|
|
71
|
+
- Reply only with what you ADD: your own conclusion, the next action you are taking, or a one-line acknowledgement. When the result already answers the user, a single sentence is enough — then end your turn or proceed.
|
|
72
|
+
- Read the result and act on it (verify, continue, commit). Keep your own output short.
|
|
73
|
+
- A result arriving does NOT mean all work is finished: sub-agents run in the background and siblings may still be active (a delivery names any still-running runs). Do not report the overall task complete until no runs are active — call subagent_status to confirm before saying Done.
|
|
74
|
+
|
|
69
75
|
Review & verification:
|
|
70
76
|
- Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
|
|
71
77
|
${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
|
package/src/runtime.ts
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
12
13
|
import { BackgroundTaskQueue } from "./background.ts";
|
|
13
14
|
import {
|
|
14
15
|
completionGroupTriggersTurn,
|
|
15
16
|
createCompletionBatcher,
|
|
17
|
+
formatActiveRunsFooter,
|
|
16
18
|
formatCompletionMessage,
|
|
17
19
|
type CompletionBatcher,
|
|
18
20
|
type CompletionMessageItem,
|
|
@@ -37,10 +39,23 @@ export interface SubagentRuntime {
|
|
|
37
39
|
settledRuns: Map<number, SingleResult>;
|
|
38
40
|
settledListeners: Map<number, Set<(result: SingleResult) => void>>;
|
|
39
41
|
registerRunResult: (runId: number, result: SingleResult) => void;
|
|
42
|
+
/** Sessions preserved on disk after a model-level handback (the run did real
|
|
43
|
+
* work but its model quota/auth failed), keyed by the original run id so a
|
|
44
|
+
* later `subagent({ resume: <runId> })` can continue in-context. Cleaned up
|
|
45
|
+
* on shutdown so a crashed/ended session never leaks temp session dirs. */
|
|
46
|
+
preservedSessions: Map<number, PreservedSession>;
|
|
40
47
|
/** Flip sessionActive off and release all session-scoped resources. */
|
|
41
48
|
shutdown: () => void;
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
export interface PreservedSession {
|
|
52
|
+
sessionId: string;
|
|
53
|
+
sessionDir: string;
|
|
54
|
+
agentName: string;
|
|
55
|
+
task: string;
|
|
56
|
+
vision: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
|
|
45
60
|
// Init-time decisions need the config synchronously; the full (migrating)
|
|
46
61
|
// async load runs per tool call.
|
|
@@ -53,9 +68,17 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
53
68
|
sessionActive: true,
|
|
54
69
|
sendCompletionGroup: (items) => {
|
|
55
70
|
if (!runtime.sessionActive || items.length === 0) return;
|
|
71
|
+
// A result arriving for one run does not mean sibling runs are done.
|
|
72
|
+
// Computing this at delivery (emit) time — not when the item was
|
|
73
|
+
// pushed — reflects the current monitor state, since finishing runs
|
|
74
|
+
// are removed from the monitor before their completion is pushed.
|
|
75
|
+
const active = monitor
|
|
76
|
+
.getRuns()
|
|
77
|
+
.filter((run) => run.status === "queued" || run.status === "running" || run.retained)
|
|
78
|
+
.map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
|
|
56
79
|
const message = {
|
|
57
80
|
customType: "subagent-result",
|
|
58
|
-
content: formatCompletionMessage(items),
|
|
81
|
+
content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
|
|
59
82
|
display: true,
|
|
60
83
|
};
|
|
61
84
|
if (completionGroupTriggersTurn(items)) {
|
|
@@ -76,6 +99,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
76
99
|
runControllers: new Map<number, AbortController>(),
|
|
77
100
|
settledRuns: new Map<number, SingleResult>(),
|
|
78
101
|
settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
|
|
102
|
+
preservedSessions: new Map<number, PreservedSession>(),
|
|
79
103
|
registerRunResult: (runId, result) => {
|
|
80
104
|
runtime.settledRuns.set(runId, result);
|
|
81
105
|
const listeners = runtime.settledListeners.get(runId);
|
|
@@ -97,6 +121,17 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
97
121
|
runtime.settledRuns.clear();
|
|
98
122
|
runtime.settledListeners.clear();
|
|
99
123
|
runtime.runControllers.clear();
|
|
124
|
+
// Best-effort cleanup of preserved sub-agent session dirs so an
|
|
125
|
+
// ended/crashed session does not leak temp files; the OS reclaims
|
|
126
|
+
// tmpdir eventually, but this keeps things tidy between sessions.
|
|
127
|
+
for (const { sessionDir } of runtime.preservedSessions.values()) {
|
|
128
|
+
try {
|
|
129
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
130
|
+
} catch {
|
|
131
|
+
/* best-effort */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
runtime.preservedSessions.clear();
|
|
100
135
|
// Clear the monitor so stale runs from this session never leak into the
|
|
101
136
|
// next one (the module-level singleton survives across sessions).
|
|
102
137
|
monitor.clear();
|
package/src/spawn.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
13
|
-
import {
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
15
16
|
import { tmpdir } from "node:os";
|
|
16
17
|
import { basename, join } from "node:path";
|
|
@@ -99,6 +100,16 @@ export interface SingleResult {
|
|
|
99
100
|
* message must surface these so the main agent is never misled by a rosy final
|
|
100
101
|
* text (e.g. a worker that ended with "keep waiting" while its build failed). */
|
|
101
102
|
failedTools?: Array<{ toolName: string; error: string }>;
|
|
103
|
+
/** The pi session id this run used (every run is session-backed so a
|
|
104
|
+
* model-level failure can be resumed on another model without re-scanning). */
|
|
105
|
+
sessionId?: string;
|
|
106
|
+
/** Directory holding the run's pi session file. Preserved across the initial
|
|
107
|
+
* attempt and any resume attempts; kept on disk only when a model-level
|
|
108
|
+
* failure is handed back, so a later `resume` can pick up the context. */
|
|
109
|
+
sessionDir?: string;
|
|
110
|
+
/** True when the result was produced by resuming an earlier session (a
|
|
111
|
+
* model-level fallback or an explicit resume) rather than a fresh start. */
|
|
112
|
+
resumed?: boolean;
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
export interface SubagentDetails {
|
|
@@ -348,6 +359,44 @@ export function getResultOutput(result: SingleResult): string {
|
|
|
348
359
|
return getFinalOutput(result.messages) || "(no output)";
|
|
349
360
|
}
|
|
350
361
|
|
|
362
|
+
/**
|
|
363
|
+
* True when a pi session file for `sessionId` already exists in `sessionDir`.
|
|
364
|
+
* Every sub-agent run is session-backed; the FIRST attempt creates the session
|
|
365
|
+
* (`--session-id`) and every later attempt on the same session RESUMES it
|
|
366
|
+
* (`--session`), so a model-level retry or fallback picks up the prior context
|
|
367
|
+
* instead of re-scanning. The session file is named `<timestamp>Z_<id>.jsonl`
|
|
368
|
+
* (pi's convention), so a suffix match is exact and cheap.
|
|
369
|
+
*/
|
|
370
|
+
export function sessionExists(sessionDir: string, sessionId: string): boolean {
|
|
371
|
+
try {
|
|
372
|
+
return readdirSync(sessionDir).some((file) => file.endsWith(`_${sessionId}.jsonl`));
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Build the continuation prompt sent to a RESUMED sub-agent session. The model
|
|
380
|
+
* sees the full prior history (loaded by `--session`) plus this new user turn,
|
|
381
|
+
* so it continues from where it stopped. Steering it not to redo finished work
|
|
382
|
+
* is what saves the re-scan the user wants to avoid.
|
|
383
|
+
*
|
|
384
|
+
* `reason` is a short clause describing why the session is resuming
|
|
385
|
+
* ("a transient provider error" / "your previous model hit a quota or auth
|
|
386
|
+
* limit, so a different model is now continuing").
|
|
387
|
+
*/
|
|
388
|
+
export function buildResumePrompt(task: string, reason: string): string {
|
|
389
|
+
return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Original task: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Reason clause for resuming on a DIFFERENT model after the configured model
|
|
393
|
+
* failed at the provider level (quota/auth/overloaded/...). */
|
|
394
|
+
export function buildFallbackResumeReason(fromModel?: string): string {
|
|
395
|
+
return fromModel
|
|
396
|
+
? `your previous model (${fromModel}) hit a quota, billing, or auth limit, so a different model is now continuing`
|
|
397
|
+
: "your previous model became unavailable, so a different model is now continuing";
|
|
398
|
+
}
|
|
399
|
+
|
|
351
400
|
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
352
401
|
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
|
|
353
402
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
@@ -424,6 +473,21 @@ export interface RunSingleOptions {
|
|
|
424
473
|
* SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS; pass [] to disable (e.g. when an isolated
|
|
425
474
|
* test wants to assert only the fallback path runs once). */
|
|
426
475
|
runLevelRetryDelaysMs?: readonly number[];
|
|
476
|
+
/** Directory holding this run's pi session. When set (with sessionId), the
|
|
477
|
+
* child is session-backed: it creates the session on the first attempt and
|
|
478
|
+
* RESUMES it on any later attempt (model-level retry/fallback), so a model
|
|
479
|
+
* switch inherits the prior context. When unset, the child runs ephemerally
|
|
480
|
+
* (--no-session) and cannot be resumed. The caller owns the directory's
|
|
481
|
+
* lifecycle; runSingleAgent neither creates nor removes it. */
|
|
482
|
+
sessionDir?: string;
|
|
483
|
+
/** Pi session id paired with sessionDir. The first attempt creates it
|
|
484
|
+
* (--session-id); later attempts resume (--session) once the session file
|
|
485
|
+
* exists. */
|
|
486
|
+
sessionId?: string;
|
|
487
|
+
/** Text sent to the child via stdin. Defaults to `Task: ${task}`. A resumed
|
|
488
|
+
* attempt passes a continuation prompt (see buildResumePrompt) so the model
|
|
489
|
+
* picks up the prior session instead of starting the task over. */
|
|
490
|
+
stdinText?: string;
|
|
427
491
|
signal?: AbortSignal;
|
|
428
492
|
onLive?: (e: SubagentLiveEvent) => void;
|
|
429
493
|
makeDetails: (results: SingleResult[]) => SubagentDetails;
|
|
@@ -458,7 +522,20 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
458
522
|
|
|
459
523
|
// Defense in depth: even if another extension ignores the depth marker, a
|
|
460
524
|
// child process can never expose a tool named `subagent` back to its model.
|
|
461
|
-
const args: string[] = ["--mode", "json", "-p", "--
|
|
525
|
+
const args: string[] = ["--mode", "json", "-p", "--exclude-tools", "subagent"];
|
|
526
|
+
// Session-backed: every run persists its pi session so a model-level retry or
|
|
527
|
+
// fallback can RESUME it (--session) instead of re-scanning from scratch. The
|
|
528
|
+
// first attempt creates the session (--session-id); once the session file
|
|
529
|
+
// exists, later attempts resume it. No sessionDir → ephemeral (--no-session).
|
|
530
|
+
if (options.sessionDir && options.sessionId) {
|
|
531
|
+
args.push("--session-dir", options.sessionDir);
|
|
532
|
+
args.push(
|
|
533
|
+
sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id",
|
|
534
|
+
options.sessionId,
|
|
535
|
+
);
|
|
536
|
+
} else {
|
|
537
|
+
args.push("--no-session");
|
|
538
|
+
}
|
|
462
539
|
if (agent.model) args.push("--model", agent.model);
|
|
463
540
|
// The configured level is clamped adaptively per model by pi.
|
|
464
541
|
args.push("--thinking", thinkingLevel);
|
|
@@ -467,6 +544,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
467
544
|
let tmpPromptDir: string | null = null;
|
|
468
545
|
let tmpPromptPath: string | null = null;
|
|
469
546
|
|
|
547
|
+
const hasSession = Boolean(options.sessionDir && options.sessionId);
|
|
470
548
|
const currentResult: SingleResult = {
|
|
471
549
|
agent: agentName,
|
|
472
550
|
agentSource: agent.source,
|
|
@@ -477,6 +555,9 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
477
555
|
usage: emptyUsage(),
|
|
478
556
|
model: agent.model,
|
|
479
557
|
thinking: thinkingLevel,
|
|
558
|
+
sessionId: options.sessionId,
|
|
559
|
+
sessionDir: options.sessionDir,
|
|
560
|
+
resumed: hasSession && sessionExists(options.sessionDir as string, options.sessionId as string),
|
|
480
561
|
};
|
|
481
562
|
|
|
482
563
|
try {
|
|
@@ -618,11 +699,11 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
618
699
|
currentResult.messages.push(event.message as Message);
|
|
619
700
|
}
|
|
620
701
|
};
|
|
621
|
-
// Send the task
|
|
622
|
-
// command line. This avoids OS
|
|
623
|
-
//
|
|
702
|
+
// Send the task (or a continuation prompt for a resumed session) through
|
|
703
|
+
// the child stdin pipe instead of the command line. This avoids OS
|
|
704
|
+
// argument-length limits and requires no extra temp file for the data.
|
|
624
705
|
proc.stdin?.on("error", () => undefined);
|
|
625
|
-
proc.stdin?.end(`Task: ${task}`);
|
|
706
|
+
proc.stdin?.end(options.stdinText ?? `Task: ${task}`);
|
|
626
707
|
|
|
627
708
|
// Decode stdout through a StringDecoder so multi-byte UTF-8 characters
|
|
628
709
|
// (CJK, emoji) split across chunk boundaries never produce U+FFFD
|
|
@@ -725,7 +806,8 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
725
806
|
}
|
|
726
807
|
|
|
727
808
|
/**
|
|
728
|
-
* Run one agent with three layers of resilience
|
|
809
|
+
* Run one agent with three layers of resilience, all session-backed so a model
|
|
810
|
+
* switch RESUMES the prior context instead of re-scanning from scratch:
|
|
729
811
|
*
|
|
730
812
|
* 1. Startup retry (inner loop): a concurrent pi startup race can make the child
|
|
731
813
|
* exit before any model/tool activity. Relaunch with backoff so the startup
|
|
@@ -733,23 +815,19 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
733
815
|
* model — and only a clean, silent, zero-activity exit qualifies (see
|
|
734
816
|
* isRetryableStartupFailure), so retrying can never duplicate real work.
|
|
735
817
|
* 2. Run-level retry on the SAME configured model (middle): when the provider
|
|
736
|
-
* rejects the model
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
* tried (default 3 attempts) and given up.
|
|
745
|
-
* 3. Model fallback (outer): when the same model is still failing after all its
|
|
746
|
-
* run-level retries, retry once with the main window's current model. The
|
|
747
|
-
* fallback gets its own startup-retry loop, since a startup race can hit any
|
|
748
|
-
* relaunch regardless of model.
|
|
818
|
+
* rejects the model with a TRANSIENT error (503/429/timeout/network/...) —
|
|
819
|
+
* NOT a terminal one (quota/billing/invalid key/auth — see isTerminalModelError)
|
|
820
|
+
* — RESUME the session on the same model up to
|
|
821
|
+
* SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff. Resuming
|
|
822
|
+
* preserves any work done before the hiccup.
|
|
823
|
+
* 3. Model fallback (outer): when the same model still fails, RESUME the session
|
|
824
|
+
* once on the main window's current model — the fallback inherits the prior
|
|
825
|
+
* context, so the user never pays to re-scan after a model switch.
|
|
749
826
|
*
|
|
750
|
-
* Terminal model errors short-circuit
|
|
751
|
-
*
|
|
752
|
-
*
|
|
827
|
+
* Terminal model errors short-circuit to the caller: the account is the
|
|
828
|
+
* bottleneck, so neither same-model retry nor a same-account fallback can help.
|
|
829
|
+
* The session is preserved on disk (the result carries sessionId/sessionDir) so
|
|
830
|
+
* a later manual resume on a working model can continue without re-scanning.
|
|
753
831
|
*
|
|
754
832
|
* The fallback is per-run only and never persisted: a transient provider hiccup
|
|
755
833
|
* must not silently downgrade the configured agent model.
|
|
@@ -802,55 +880,98 @@ export async function runSingleAgentWithModelFallback(
|
|
|
802
880
|
}
|
|
803
881
|
};
|
|
804
882
|
|
|
805
|
-
|
|
883
|
+
// One pi session backs the whole logical run, shared by the initial attempt
|
|
884
|
+
// and every resume (model-level retry / fallback), so a model switch inherits
|
|
885
|
+
// the prior context instead of re-scanning. Created here unless the caller
|
|
886
|
+
// passed one in (an explicit resume of a handed-back session).
|
|
887
|
+
const sessionId = options.sessionId ?? randomUUID();
|
|
888
|
+
const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
|
|
889
|
+
const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
|
|
806
890
|
|
|
807
|
-
// After a model-level failure, classify before reacting. A TERMINAL error
|
|
808
|
-
// (quota/billing/invalid key/auth) is account-scoped: neither same-model
|
|
809
|
-
// retry nor a same-account fallback can help, so hand the run straight back
|
|
810
|
-
// to the main agent instead of burning its time on a doomed retry.
|
|
811
|
-
if (agent && launchedRef && isModelLevelFailure(result) && isTerminalModelError(result)) return result;
|
|
812
|
-
|
|
813
|
-
// A TRANSIENT provider failure (503/429/timeout/network/stream/...) is usually
|
|
814
|
-
// a one-off hiccup. Relaunch the SAME configured model up to runDelays.length
|
|
815
|
-
// more times with backoff before degrading to a fallback model — the run's own
|
|
816
|
-
// provider retry already tried and failed, so each relaunch here is an
|
|
817
|
-
// independent, fresh attempt that can recover without losing the configured
|
|
818
|
-
// model's capability to review.
|
|
819
891
|
let modelRetries = 0;
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
892
|
+
let result: SingleResult | undefined;
|
|
893
|
+
try {
|
|
894
|
+
result = await runWithStartupRetry(baseOptions);
|
|
895
|
+
|
|
896
|
+
// A TERMINAL model error (quota/billing/invalid key/auth) is account-scoped:
|
|
897
|
+
// neither a same-model retry nor a same-account fallback can help. Skip the
|
|
898
|
+
// automatic retry/fallback and hand the run back — the session is preserved
|
|
899
|
+
// (see finally) so a later manual resume on a working model can continue
|
|
900
|
+
// without re-scanning.
|
|
901
|
+
const terminal = isModelLevelFailure(result) && isTerminalModelError(result);
|
|
902
|
+
|
|
903
|
+
// A TRANSIENT provider failure (503/429/timeout/network/...) usually
|
|
904
|
+
// recovers on a relaunch. RESUME the same session on the same configured
|
|
905
|
+
// model up to runDelays.length more times with backoff — resuming (not
|
|
906
|
+
// restarting) preserves any work done before the hiccup. This sits outside
|
|
907
|
+
// pi-ai's per-request provider retry, which by then already tried and gave up.
|
|
908
|
+
if (!terminal && agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
|
|
909
|
+
const retryOpts: RunSingleOptions = {
|
|
910
|
+
...baseOptions,
|
|
911
|
+
stdinText: buildResumePrompt(options.task, "a transient provider error"),
|
|
912
|
+
};
|
|
913
|
+
for (let attempt = 0; ; attempt++) {
|
|
914
|
+
const delay = runDelays[attempt];
|
|
915
|
+
if (delay === undefined) break;
|
|
916
|
+
try {
|
|
917
|
+
options.onLive?.({ kind: "status", status: "running" });
|
|
918
|
+
} catch { /* never throw from event handling */ }
|
|
919
|
+
const shouldRetry = await waitForStartupRetry(delay, options.signal);
|
|
920
|
+
if (!shouldRetry) break;
|
|
921
|
+
const retried = await runWithStartupRetry(retryOpts);
|
|
922
|
+
modelRetries++;
|
|
923
|
+
// failedTools reflect ONLY the final attempt (each runSingleAgent call
|
|
924
|
+
// accumulates its own), so the completion message stays accurate.
|
|
925
|
+
result = retried;
|
|
926
|
+
if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) break;
|
|
836
927
|
}
|
|
837
|
-
result = retried;
|
|
838
928
|
}
|
|
839
|
-
}
|
|
840
929
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
930
|
+
// Transient retries exhausted (or none) and still a model-level failure:
|
|
931
|
+
// RESUME the session on the main window's current model exactly once. The
|
|
932
|
+
// fallback inherits the prior context (no re-scan). Skipped when there is no
|
|
933
|
+
// fallback ref, it equals the configured model, or the failure is terminal
|
|
934
|
+
// (a same-account fallback would fail identically — leave it for manual resume).
|
|
935
|
+
if (
|
|
936
|
+
!terminal &&
|
|
937
|
+
agent &&
|
|
938
|
+
launchedRef &&
|
|
939
|
+
fallbackModelRef &&
|
|
940
|
+
launchedRef !== fallbackModelRef &&
|
|
941
|
+
isModelLevelFailure(result)
|
|
942
|
+
) {
|
|
943
|
+
const retried = await runWithStartupRetry({
|
|
944
|
+
...baseOptions,
|
|
945
|
+
stdinText: buildResumePrompt(options.task, buildFallbackResumeReason(launchedRef)),
|
|
946
|
+
agent: { ...agent, model: fallbackModelRef },
|
|
947
|
+
});
|
|
948
|
+
// The fallback replaces the result wholesale: failedTools reflect ONLY the
|
|
949
|
+
// fallback (final) attempt — stale build errors from the first model are
|
|
950
|
+
// not merged, so a clean final attempt is never misattributed a failure.
|
|
951
|
+
result = { ...retried, modelFallbackFrom: launchedRef };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// A terminal failure takes the bare result (no retries or fallback ran, so
|
|
955
|
+
// modelRetries stays undefined — matching the contract callers assert).
|
|
956
|
+
return terminal ? result : { ...result, modelRetries };
|
|
957
|
+
} finally {
|
|
958
|
+
// Keep the session on disk only for a model-level failure that did real work
|
|
959
|
+
// and is being handed back, so a later `resume` can continue it. A provider
|
|
960
|
+
// rejection before any work (no messages/tools/output) has nothing to resume,
|
|
961
|
+
// so it is cleaned up along with every success and task-level failure. Never
|
|
962
|
+
// remove a caller-provided sessionDir (an explicit resume owns its dir).
|
|
963
|
+
if (result) {
|
|
964
|
+
result.sessionId ??= sessionId;
|
|
965
|
+
result.sessionDir ??= sessionDir;
|
|
966
|
+
}
|
|
967
|
+
const hasWork =
|
|
968
|
+
!!result &&
|
|
969
|
+
(result.messages.length > 1 ||
|
|
970
|
+
(result.failedTools?.length ?? 0) > 0 ||
|
|
971
|
+
Boolean(getFinalOutput(result.messages)));
|
|
972
|
+
const keep = !!result && isModelLevelFailure(result) && hasWork;
|
|
973
|
+
if (!keep && !options.sessionDir) {
|
|
974
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
975
|
+
}
|
|
854
976
|
}
|
|
855
|
-
return { ...result, modelRetries };
|
|
856
977
|
}
|
package/src/tools.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
formatElapsed,
|
|
14
14
|
formatUsageCompact,
|
|
15
15
|
monitor,
|
|
16
|
+
runLabel,
|
|
16
17
|
statusLabel,
|
|
17
18
|
} from "./monitor.ts";
|
|
18
19
|
import type { SubagentRuntime } from "./runtime.ts";
|
|
@@ -262,6 +263,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
262
263
|
const activeLines = activeRuns.map((run) => {
|
|
263
264
|
const parts = [
|
|
264
265
|
`#${run.id} ${run.agent}`,
|
|
266
|
+
run.label,
|
|
265
267
|
run.model ?? "?",
|
|
266
268
|
formatUsageCompact(run.usage),
|
|
267
269
|
formatElapsed(run, now),
|
|
@@ -271,7 +273,8 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
271
273
|
const completed = [...runtime.settledRuns.entries()].slice(-5);
|
|
272
274
|
const completedLines = completed.map(([id, result]) => {
|
|
273
275
|
const usage = formatUsage(result.usage);
|
|
274
|
-
|
|
276
|
+
const label = runLabel(result.task);
|
|
277
|
+
return `- #${id} ${result.agent}${label ? ` · ${label}` : ""} · ${isFailedResult(result) ? "failed" : "completed"}${usage ? ` · ${usage}` : ""}`;
|
|
275
278
|
});
|
|
276
279
|
|
|
277
280
|
const sections: string[] = [];
|
package/src/widget.ts
CHANGED
|
@@ -126,7 +126,11 @@ export function registerWidget(pi: ExtensionAPI, runtime: SubagentRuntime): void
|
|
|
126
126
|
// uses the accent color, everything else is quiet.
|
|
127
127
|
if (!isChain && lines.length > 0) lines.push("");
|
|
128
128
|
const nodeBranch = isChain ? (chainContinues ? "├─ " : "└─ ") : "";
|
|
129
|
-
|
|
129
|
+
// Content label (task-derived) trails the agent name so concurrent
|
|
130
|
+
// same-agent runs read as what they do, not just their run id. Chain
|
|
131
|
+
// nodes already carry a distinguishing relationLabel.
|
|
132
|
+
const labelPart = !isChain && r.label ? ` ${dim(`· ${r.label}`)}` : "";
|
|
133
|
+
const left = `${dim(nodeBranch)}${icon} ${dim(`#${r.id}`)} ${isChain ? name : theme.fg("accent", theme.bold(name))}${labelPart}`;
|
|
130
134
|
|
|
131
135
|
// Right side: full model ref (provider/model), token usage (in/out +
|
|
132
136
|
// cache read/write), tool count, elapsed, and the soft activity-state
|