@ferris1225/pi-subagents 0.17.0 → 0.18.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 +7 -0
- package/package.json +1 -1
- package/src/background.ts +38 -9
- package/src/index.ts +109 -8
- package/src/spawn.ts +8 -0
package/README.md
CHANGED
|
@@ -389,6 +389,13 @@ for `idleTimeoutSec` seconds) are treated as model-level failures and do trigger
|
|
|
389
389
|
since a stalled SSE stream is usually a provider-side issue. Results carry a `model fell back
|
|
390
390
|
from …` note when it happened.
|
|
391
391
|
|
|
392
|
+
If the model is unavailable or broken and the fallback retry also fails (or no fallback model
|
|
393
|
+
is available), the task is **handed back to the main window**: the completion message tells
|
|
394
|
+
the main agent to execute the task itself with its own tools instead of leaving a dead
|
|
395
|
+
failure. A background task that crashes with an exception (spawn infra, delivery API, ...)
|
|
396
|
+
is never silently swallowed either — the user gets a `✗ … 派发失败` error notification and
|
|
397
|
+
the failure is delivered to the main agent, which can re-dispatch it.
|
|
398
|
+
|
|
392
399
|
Thinking strength uses this precedence: `agentThinkingLevels` entry → agent frontmatter `thinking` → `thinkingLevel` default.
|
|
393
400
|
|
|
394
401
|
## Agent discovery and overrides
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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/background.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tasks get their own AbortSignal rather than inheriting the foreground agent
|
|
5
5
|
* turn's signal. The owning extension cancels all work only on session teardown.
|
|
6
|
+
*
|
|
7
|
+
* Task exceptions are never swallowed: the per-task onError callback receives
|
|
8
|
+
* them (unless the task was cancelled) so callers can surface the failure to
|
|
9
|
+
* the user and the main agent instead of it vanishing into the queue.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
export type BackgroundTask = (signal: AbortSignal) => Promise<void>;
|
|
@@ -11,6 +15,9 @@ interface PendingTask {
|
|
|
11
15
|
task: BackgroundTask;
|
|
12
16
|
controller: AbortController;
|
|
13
17
|
onCancelled?: () => void;
|
|
18
|
+
/** Invoked when the task throws and was not cancelled (cancellation is not a
|
|
19
|
+
* failure — e.g. session shutdown races must never be reported as errors). */
|
|
20
|
+
onError?: (error: unknown) => void;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export class BackgroundTaskQueue {
|
|
@@ -33,15 +40,15 @@ export class BackgroundTaskQueue {
|
|
|
33
40
|
this.drain();
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
enqueue(task: BackgroundTask, onCancelled?: () => void): AbortController {
|
|
43
|
+
enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void): AbortController {
|
|
37
44
|
const controller = new AbortController();
|
|
38
45
|
if (this.stopped) {
|
|
39
46
|
controller.abort();
|
|
40
|
-
onCancelled
|
|
47
|
+
this.runCancelled(onCancelled);
|
|
41
48
|
return controller;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
this.pending.push({ task, controller, onCancelled });
|
|
51
|
+
this.pending.push({ task, controller, onCancelled, onError });
|
|
45
52
|
this.drain();
|
|
46
53
|
return controller;
|
|
47
54
|
}
|
|
@@ -53,25 +60,47 @@ export class BackgroundTaskQueue {
|
|
|
53
60
|
|
|
54
61
|
for (const entry of this.pending.splice(0)) {
|
|
55
62
|
entry.controller.abort();
|
|
56
|
-
entry.onCancelled
|
|
63
|
+
this.runCancelled(entry.onCancelled);
|
|
57
64
|
}
|
|
58
65
|
for (const controller of this.active) controller.abort();
|
|
59
66
|
}
|
|
60
67
|
|
|
68
|
+
/** Cancellation callbacks are user-supplied: a throw must never break the queue
|
|
69
|
+
* (mirrors the try/catch around onError in drain). */
|
|
70
|
+
private runCancelled(callback: (() => void) | undefined): void {
|
|
71
|
+
if (!callback) return;
|
|
72
|
+
try {
|
|
73
|
+
callback();
|
|
74
|
+
} catch {
|
|
75
|
+
/* cancellation callbacks must never break the queue */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
61
79
|
private drain(): void {
|
|
62
80
|
while (!this.stopped && this.active.size < this.concurrency) {
|
|
63
81
|
const entry = this.pending.shift();
|
|
64
82
|
if (!entry) return;
|
|
65
83
|
if (entry.controller.signal.aborted) {
|
|
66
|
-
entry.onCancelled
|
|
84
|
+
this.runCancelled(entry.onCancelled);
|
|
67
85
|
continue;
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
this.active.add(entry.controller);
|
|
71
|
-
void entry.task(entry.controller.signal)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
void entry.task(entry.controller.signal)
|
|
90
|
+
.catch((error: unknown) => {
|
|
91
|
+
// Cancellation is not a failure: aborted work (e.g. session
|
|
92
|
+
// shutdown) must never be reported as an exception.
|
|
93
|
+
if (entry.controller.signal.aborted) return;
|
|
94
|
+
try {
|
|
95
|
+
entry.onError?.(error);
|
|
96
|
+
} catch {
|
|
97
|
+
/* error reporting must never break the queue */
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
.finally(() => {
|
|
101
|
+
this.active.delete(entry.controller);
|
|
102
|
+
this.drain();
|
|
103
|
+
});
|
|
75
104
|
}
|
|
76
105
|
}
|
|
77
106
|
}
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
currentSubagentDepth,
|
|
33
33
|
getResultOutput,
|
|
34
34
|
isFailedResult,
|
|
35
|
+
isModelLevelFailure,
|
|
35
36
|
reviewVerdict,
|
|
36
37
|
runSingleAgentWithModelFallback,
|
|
37
38
|
truncateResultOutput,
|
|
@@ -92,6 +93,21 @@ function failedStartResult(agentName: string, task: string, errorMessage: string
|
|
|
92
93
|
stderr: errorMessage,
|
|
93
94
|
usage: emptyUsage(),
|
|
94
95
|
errorMessage,
|
|
96
|
+
dispatchFailed: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Failed result for a background task that crashed with an exception (spawn
|
|
101
|
+
* infra, delivery API, ...) instead of returning a normal result. */
|
|
102
|
+
function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
|
|
103
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
104
|
+
return {
|
|
105
|
+
...queuedResult(agent, task, thinking),
|
|
106
|
+
exitCode: 1,
|
|
107
|
+
stderr: errorMessage,
|
|
108
|
+
stopReason: "error",
|
|
109
|
+
errorMessage,
|
|
110
|
+
dispatchFailed: true,
|
|
95
111
|
};
|
|
96
112
|
}
|
|
97
113
|
|
|
@@ -140,6 +156,14 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd
|
|
|
140
156
|
return lines.join("\n");
|
|
141
157
|
}
|
|
142
158
|
|
|
159
|
+
/** Instruction appended to a model-level failure: the sub-agent's provider never
|
|
160
|
+
* produced usable output (or the run stalled), so the task is handed back to the
|
|
161
|
+
* main window instead of being left as a dead failure. */
|
|
162
|
+
function modelLevelTakeoverNote(result: SingleResult): string {
|
|
163
|
+
const retry = result.modelFallbackFrom ? ", and the retry with the main-window model also failed" : "";
|
|
164
|
+
return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${retry}. Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
|
|
165
|
+
}
|
|
166
|
+
|
|
143
167
|
export default function (pi: ExtensionAPI): void {
|
|
144
168
|
const configPath = getConfigPath(getAgentDir());
|
|
145
169
|
// Init-time decisions need the config synchronously; the full (migrating)
|
|
@@ -393,6 +417,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
393
417
|
stderr: errorMessage,
|
|
394
418
|
stopReason: signal.aborted ? "aborted" : "error",
|
|
395
419
|
errorMessage,
|
|
420
|
+
dispatchFailed: true,
|
|
396
421
|
};
|
|
397
422
|
}
|
|
398
423
|
};
|
|
@@ -438,11 +463,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
438
463
|
// success, a stuck one needs a human).
|
|
439
464
|
monitor.removeRun(parentRunId);
|
|
440
465
|
if (!sessionActive) return;
|
|
441
|
-
const items: CompletionMessageItem[] = chain.map((r) =>
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
466
|
+
const items: CompletionMessageItem[] = chain.map((r) => {
|
|
467
|
+
// A model-level chain run (worker or re-review whose provider never
|
|
468
|
+
// produced output) is handed to the main window like any other
|
|
469
|
+
// sub-agent run: the block carries the takeover note. Dispatch
|
|
470
|
+
// crashes (dispatchFailed) are excluded by the gate itself.
|
|
471
|
+
const modelLevel = isFailedResult(r) && isModelLevelFailure(r);
|
|
472
|
+
return {
|
|
473
|
+
agent: r.agent,
|
|
474
|
+
block: modelLevel
|
|
475
|
+
? `${formatCompletionBlock(r, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(r)}`
|
|
476
|
+
: formatCompletionBlock(r, config.maxResultLines, ctx.cwd),
|
|
477
|
+
triggerTurn: true,
|
|
478
|
+
};
|
|
479
|
+
});
|
|
446
480
|
sendCompletionGroup(items);
|
|
447
481
|
completionBatcher.flush();
|
|
448
482
|
},
|
|
@@ -451,6 +485,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
451
485
|
// in-flight chain run was already finished by its launchInLoop path).
|
|
452
486
|
monitor.removeRun(parentRunId);
|
|
453
487
|
},
|
|
488
|
+
(error) => {
|
|
489
|
+
// A crash inside the chain orchestration (failed runs are caught by
|
|
490
|
+
// launchInLoop and delivered as part of the chain) must not vanish:
|
|
491
|
+
// drop the retained parent row, notify, and deliver a failed result
|
|
492
|
+
// so the main agent knows the chain never completed.
|
|
493
|
+
monitor.removeRun(parentRunId);
|
|
494
|
+
if (!sessionActive) return;
|
|
495
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
496
|
+
try {
|
|
497
|
+
ctx.ui.notify(`✗ auto-fix chain 派发失败: ${errorMessage}`, "error");
|
|
498
|
+
// Keep the triggering review's findings: the chain crashed before any
|
|
499
|
+
// fix round ran, and the main agent needs the review to act on it.
|
|
500
|
+
sendCompletionGroup([
|
|
501
|
+
{
|
|
502
|
+
agent: initialReviewerResult.agent,
|
|
503
|
+
block: `${formatCompletionBlock(initialReviewerResult, config.maxResultLines, ctx.cwd)}\n\nAuto-fix chain crashed before completion: ${errorMessage}. The planned fix rounds did not run; the review above is the triggering reviewer's full output.`,
|
|
504
|
+
triggerTurn: true,
|
|
505
|
+
},
|
|
506
|
+
]);
|
|
507
|
+
completionBatcher.flush();
|
|
508
|
+
} catch {
|
|
509
|
+
/* a second delivery failure must not throw through the queue */
|
|
510
|
+
}
|
|
511
|
+
},
|
|
454
512
|
);
|
|
455
513
|
};
|
|
456
514
|
|
|
@@ -493,8 +551,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
493
551
|
stderr: errorMessage,
|
|
494
552
|
stopReason: backgroundSignal.aborted ? "aborted" : "error",
|
|
495
553
|
errorMessage,
|
|
554
|
+
dispatchFailed: true,
|
|
496
555
|
};
|
|
497
|
-
|
|
556
|
+
// The dedicated 派发失败 notification below replaces the generic
|
|
557
|
+
// failure toast for dispatch crashes, so finish silently here.
|
|
558
|
+
finishRun(runId, "failed", { silent: true });
|
|
498
559
|
}
|
|
499
560
|
|
|
500
561
|
if (!sessionActive) return;
|
|
@@ -516,13 +577,31 @@ export default function (pi: ExtensionAPI): void {
|
|
|
516
577
|
return;
|
|
517
578
|
}
|
|
518
579
|
const failed = isFailedResult(result);
|
|
519
|
-
|
|
580
|
+
// Model-level failures and dispatch crashes get their own dedicated
|
|
581
|
+
// 派发失败 notification below, so finishRun's generic failure toast is
|
|
582
|
+
// silenced for them (computed before finishRun for that reason).
|
|
583
|
+
const modelLevel = failed && isModelLevelFailure(result);
|
|
584
|
+
const dispatchFailed = result.dispatchFailed === true;
|
|
585
|
+
finishRun(runId, failed ? "failed" : "done", modelLevel || dispatchFailed ? { silent: true } : undefined);
|
|
520
586
|
if (!sessionActive) return;
|
|
587
|
+
// Model-level failure: the configured model is unavailable or broke
|
|
588
|
+
// and the retry with the main-window model (when distinct) also
|
|
589
|
+
// failed. Instead of leaving a dead failure, hand the task to the
|
|
590
|
+
// main window — the main agent executes it itself with its own tools.
|
|
521
591
|
const completion: CompletionMessageItem = {
|
|
522
592
|
agent: result.agent,
|
|
523
|
-
block:
|
|
593
|
+
block: modelLevel
|
|
594
|
+
? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result)}`
|
|
595
|
+
: formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
|
|
524
596
|
triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
|
|
525
597
|
};
|
|
598
|
+
if (modelLevel) {
|
|
599
|
+
ctx.ui.notify(`✗ ${result.agent} 派发失败: 模型不可用或出错,任务已交由主窗口执行`, "error");
|
|
600
|
+
} else if (dispatchFailed) {
|
|
601
|
+
// An exception inside the dispatch layer (spawn infra, temp-file/fs
|
|
602
|
+
// errors, ...): the main agent must know so it can re-dispatch.
|
|
603
|
+
ctx.ui.notify(`✗ ${result.agent} 派发失败: ${result.errorMessage ?? "dispatch crashed"}`, "error");
|
|
604
|
+
}
|
|
526
605
|
if (failed) {
|
|
527
606
|
// Failures never wait and never hide behind a success turn: deliver
|
|
528
607
|
// first so the wake-up leads with the failure; held successes follow.
|
|
@@ -533,6 +612,28 @@ export default function (pi: ExtensionAPI): void {
|
|
|
533
612
|
}
|
|
534
613
|
},
|
|
535
614
|
() => finishRun(runId, "failed"),
|
|
615
|
+
(error) => {
|
|
616
|
+
// The task body converts sub-agent failures into delivered results; an
|
|
617
|
+
// exception escaping it (spawn infra, delivery API, ...) must not
|
|
618
|
+
// vanish: notify the user and deliver a failed result so the main
|
|
619
|
+
// agent knows the dispatch failed and can re-dispatch.
|
|
620
|
+
const crashed = dispatchFailedResult(agent, task, error, thinkingLevel);
|
|
621
|
+
finishRun(runId, "failed", { silent: true });
|
|
622
|
+
if (!sessionActive) return;
|
|
623
|
+
try {
|
|
624
|
+
ctx.ui.notify(`✗ ${agent.name} 派发失败: ${crashed.errorMessage}`, "error");
|
|
625
|
+
sendCompletionGroup([
|
|
626
|
+
{
|
|
627
|
+
agent: agent.name,
|
|
628
|
+
block: formatCompletionBlock(crashed, config.maxResultLines, ctx.cwd),
|
|
629
|
+
triggerTurn: true,
|
|
630
|
+
},
|
|
631
|
+
]);
|
|
632
|
+
completionBatcher.flush();
|
|
633
|
+
} catch {
|
|
634
|
+
/* a second delivery failure must not throw through the queue */
|
|
635
|
+
}
|
|
636
|
+
},
|
|
536
637
|
);
|
|
537
638
|
|
|
538
639
|
return pending;
|
package/src/spawn.ts
CHANGED
|
@@ -57,6 +57,10 @@ export interface SingleResult {
|
|
|
57
57
|
errorMessage?: string;
|
|
58
58
|
/** Model the run degraded from: set when a failed run was retried with the main-window model. */
|
|
59
59
|
modelFallbackFrom?: string;
|
|
60
|
+
/** True when the result was synthesized from a thrown exception (spawn infra,
|
|
61
|
+
* temp-file/fs errors, delivery bugs) instead of being produced by the agent
|
|
62
|
+
* process. A dispatch failure is never a model-level failure. */
|
|
63
|
+
dispatchFailed?: boolean;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
export interface SubagentDetails {
|
|
@@ -158,6 +162,10 @@ export function isFailedResult(result: SingleResult): boolean {
|
|
|
158
162
|
export function isModelLevelFailure(result: SingleResult): boolean {
|
|
159
163
|
if (!isFailedResult(result)) return false;
|
|
160
164
|
if (result.stopReason === "aborted") return false;
|
|
165
|
+
// A result synthesized from a thrown exception (spawn infra, fs, delivery
|
|
166
|
+
// bugs) never came from the provider: it is a dispatch failure, not a
|
|
167
|
+
// model-level one, and must not be handed back as a model problem.
|
|
168
|
+
if (result.dispatchFailed) return false;
|
|
161
169
|
// An idle timeout (stdout went silent) signals a stalled provider connection,
|
|
162
170
|
// not a task-level failure: allow model fallback even if the model produced
|
|
163
171
|
// partial output before going quiet.
|