@ferris1225/pi-subagents 0.16.1 → 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 +427 -418
- package/package.json +1 -1
- package/src/background.ts +38 -9
- package/src/index.ts +116 -10
- package/src/monitor.ts +26 -1
- package/src/prompt.ts +4 -0
- package/src/spawn.ts +22 -5
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
|
|
|
@@ -124,7 +140,7 @@ function formatUsage(usage: UsageStats): string {
|
|
|
124
140
|
return parts.join(" ");
|
|
125
141
|
}
|
|
126
142
|
|
|
127
|
-
function formatCompletionBlock(result: SingleResult, maxResultLines: number): string {
|
|
143
|
+
function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd?: string): string {
|
|
128
144
|
const status = isFailedResult(result) ? "failed" : "completed";
|
|
129
145
|
const usage = formatUsage(result.usage);
|
|
130
146
|
const output = getResultOutput(result);
|
|
@@ -135,11 +151,19 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number): st
|
|
|
135
151
|
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
|
|
136
152
|
if (truncated) {
|
|
137
153
|
// The full text lives on disk so the main agent can read it on demand.
|
|
138
|
-
lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent)})`);
|
|
154
|
+
lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent, cwd)})`);
|
|
139
155
|
}
|
|
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)
|
|
@@ -191,6 +215,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
191
215
|
sessionActive = false;
|
|
192
216
|
completionBatcher.dispose();
|
|
193
217
|
backgroundQueue.cancelAll();
|
|
218
|
+
// Clear the monitor so stale runs from this session never leak into the
|
|
219
|
+
// next one (the module-level singleton survives across sessions).
|
|
220
|
+
monitor.clear();
|
|
194
221
|
});
|
|
195
222
|
|
|
196
223
|
pi.registerTool({
|
|
@@ -212,6 +239,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
212
239
|
"Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
|
|
213
240
|
"subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
|
|
214
241
|
"Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
|
|
242
|
+
"NEVER sleep, wait, poll, or call other tools alongside subagent — it ends the turn immediately. The main agent is auto-resumed when results arrive; manual waiting only blocks the turn and delays delivery.",
|
|
215
243
|
],
|
|
216
244
|
parameters: SubagentParams,
|
|
217
245
|
|
|
@@ -249,6 +277,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
249
277
|
monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
|
|
250
278
|
const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
|
|
251
279
|
if (!run) return; // already finished — stay idempotent
|
|
280
|
+
if (opts?.retain) monitor.setRetained(runId, true);
|
|
252
281
|
if (opts?.silent || !sessionActive) return;
|
|
253
282
|
const icon = status === "done" ? "✓" : "✗";
|
|
254
283
|
ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
|
|
@@ -388,6 +417,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
388
417
|
stderr: errorMessage,
|
|
389
418
|
stopReason: signal.aborted ? "aborted" : "error",
|
|
390
419
|
errorMessage,
|
|
420
|
+
dispatchFailed: true,
|
|
391
421
|
};
|
|
392
422
|
}
|
|
393
423
|
};
|
|
@@ -433,11 +463,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
433
463
|
// success, a stuck one needs a human).
|
|
434
464
|
monitor.removeRun(parentRunId);
|
|
435
465
|
if (!sessionActive) return;
|
|
436
|
-
const items: CompletionMessageItem[] = chain.map((r) =>
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
+
});
|
|
441
480
|
sendCompletionGroup(items);
|
|
442
481
|
completionBatcher.flush();
|
|
443
482
|
},
|
|
@@ -446,6 +485,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
446
485
|
// in-flight chain run was already finished by its launchInLoop path).
|
|
447
486
|
monitor.removeRun(parentRunId);
|
|
448
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
|
+
},
|
|
449
512
|
);
|
|
450
513
|
};
|
|
451
514
|
|
|
@@ -488,8 +551,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
488
551
|
stderr: errorMessage,
|
|
489
552
|
stopReason: backgroundSignal.aborted ? "aborted" : "error",
|
|
490
553
|
errorMessage,
|
|
554
|
+
dispatchFailed: true,
|
|
491
555
|
};
|
|
492
|
-
|
|
556
|
+
// The dedicated 派发失败 notification below replaces the generic
|
|
557
|
+
// failure toast for dispatch crashes, so finish silently here.
|
|
558
|
+
finishRun(runId, "failed", { silent: true });
|
|
493
559
|
}
|
|
494
560
|
|
|
495
561
|
if (!sessionActive) return;
|
|
@@ -511,13 +577,31 @@ export default function (pi: ExtensionAPI): void {
|
|
|
511
577
|
return;
|
|
512
578
|
}
|
|
513
579
|
const failed = isFailedResult(result);
|
|
514
|
-
|
|
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);
|
|
515
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.
|
|
516
591
|
const completion: CompletionMessageItem = {
|
|
517
592
|
agent: result.agent,
|
|
518
|
-
block:
|
|
593
|
+
block: modelLevel
|
|
594
|
+
? `${formatCompletionBlock(result, config.maxResultLines, ctx.cwd)}\n\n${modelLevelTakeoverNote(result)}`
|
|
595
|
+
: formatCompletionBlock(result, config.maxResultLines, ctx.cwd),
|
|
519
596
|
triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
|
|
520
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
|
+
}
|
|
521
605
|
if (failed) {
|
|
522
606
|
// Failures never wait and never hide behind a success turn: deliver
|
|
523
607
|
// first so the wake-up leads with the failure; held successes follow.
|
|
@@ -528,6 +612,28 @@ export default function (pi: ExtensionAPI): void {
|
|
|
528
612
|
}
|
|
529
613
|
},
|
|
530
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
|
+
},
|
|
531
637
|
);
|
|
532
638
|
|
|
533
639
|
return pending;
|
package/src/monitor.ts
CHANGED
|
@@ -42,6 +42,10 @@ export interface RunView {
|
|
|
42
42
|
relationLabel?: string;
|
|
43
43
|
/** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
|
|
44
44
|
annotation?: string;
|
|
45
|
+
/** True when a finished run is intentionally kept in the widget (e.g. an
|
|
46
|
+
* auto-fix chain parent whose chain is still running). beginTurn preserves
|
|
47
|
+
* retained runs so they are not swept between turns. */
|
|
48
|
+
retained?: boolean;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
/** Optional chain metadata for runs spawned by an auto-fix loop. */
|
|
@@ -298,7 +302,12 @@ export class MonitorStore {
|
|
|
298
302
|
beginTurn(): void {
|
|
299
303
|
// Clear finished runs from a previous turn, but keep any still-active
|
|
300
304
|
// (queued/running) ones so a concurrent sub-agent call is not wiped.
|
|
301
|
-
|
|
305
|
+
// Retained runs (e.g. an auto-fix chain parent whose chain is still
|
|
306
|
+
// running) are also preserved — their status is "done" but they must
|
|
307
|
+
// stay visible until the chain resolves.
|
|
308
|
+
this.runs = this.runs.filter(
|
|
309
|
+
(r) => r.status === "queued" || r.status === "running" || r.retained,
|
|
310
|
+
);
|
|
302
311
|
this.notify();
|
|
303
312
|
}
|
|
304
313
|
|
|
@@ -357,11 +366,27 @@ export class MonitorStore {
|
|
|
357
366
|
this.notify();
|
|
358
367
|
}
|
|
359
368
|
|
|
369
|
+
/** Mark a run as retained (kept in the widget despite being finished). */
|
|
370
|
+
setRetained(id: number, retained: boolean): void {
|
|
371
|
+
const run = this.find(id);
|
|
372
|
+
if (!run) return;
|
|
373
|
+
run.retained = retained;
|
|
374
|
+
this.notify();
|
|
375
|
+
}
|
|
376
|
+
|
|
360
377
|
/** Look up a run by id without removing it. */
|
|
361
378
|
findRun(id: number): RunView | undefined {
|
|
362
379
|
return this.find(id);
|
|
363
380
|
}
|
|
364
381
|
|
|
382
|
+
/** Remove all runs (used on session shutdown so stale state never leaks
|
|
383
|
+
* into the next session). Does not reset the id counter so in-flight
|
|
384
|
+
* finishRun calls from the old session remain safe no-ops. */
|
|
385
|
+
clear(): void {
|
|
386
|
+
this.runs = [];
|
|
387
|
+
this.notify();
|
|
388
|
+
}
|
|
389
|
+
|
|
365
390
|
/** Remove a run (finished runs leave the widget). Returns the removed run. */
|
|
366
391
|
removeRun(id: number): RunView | undefined {
|
|
367
392
|
const index = this.runs.findIndex((r) => r.id === id);
|
package/src/prompt.ts
CHANGED
|
@@ -41,6 +41,10 @@ It immediately ends the current main-agent turn so the user can keep working. Wh
|
|
|
41
41
|
finishes, its result is sent back as a message that automatically resumes the main agent;
|
|
42
42
|
if the main agent is busy, the result waits as a follow-up.
|
|
43
43
|
|
|
44
|
+
NEVER run sleep, wait, or polling commands (e.g. Start-Sleep, sleep, timeout) to wait for
|
|
45
|
+
a sub-agent — the turn already ended and the main agent is auto-resumed when results arrive.
|
|
46
|
+
Manual waiting blocks the turn, delays result delivery, and wastes the user's time.
|
|
47
|
+
|
|
44
48
|
Available agents:
|
|
45
49
|
${catalog}
|
|
46
50
|
|
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 {
|
|
@@ -128,9 +132,14 @@ export function truncateResultOutput(output: string, maxLines: number): Truncate
|
|
|
128
132
|
return { text: kept.join("\n"), truncated: true };
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
/** Persist the full result where the main agent can read it on demand. Returns the file path.
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
/** Persist the full result where the main agent can read it on demand. Returns the file path.
|
|
136
|
+
* Results are grouped under a per-project subdirectory so concurrent projects don't
|
|
137
|
+
* litter a single flat folder. */
|
|
138
|
+
export function writeResultArtifact(output: string, agentName: string, cwd?: string): string {
|
|
139
|
+
const projectSlug = cwd
|
|
140
|
+
? basename(cwd).replace(/[^\w.-]+/g, "_") || "default"
|
|
141
|
+
: "default";
|
|
142
|
+
const dir = join(tmpdir(), "pi-subagents-results", projectSlug);
|
|
134
143
|
mkdirSync(dir, { recursive: true });
|
|
135
144
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
136
145
|
// A random suffix keeps same-millisecond writes from clobbering each other.
|
|
@@ -153,6 +162,10 @@ export function isFailedResult(result: SingleResult): boolean {
|
|
|
153
162
|
export function isModelLevelFailure(result: SingleResult): boolean {
|
|
154
163
|
if (!isFailedResult(result)) return false;
|
|
155
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;
|
|
156
169
|
// An idle timeout (stdout went silent) signals a stalled provider connection,
|
|
157
170
|
// not a task-level failure: allow model fallback even if the model produced
|
|
158
171
|
// partial output before going quiet.
|
|
@@ -166,7 +179,10 @@ export function isModelLevelFailure(result: SingleResult): boolean {
|
|
|
166
179
|
|
|
167
180
|
export function getResultOutput(result: SingleResult): string {
|
|
168
181
|
if (isFailedResult(result)) {
|
|
169
|
-
|
|
182
|
+
const error = result.errorMessage || result.stderr;
|
|
183
|
+
const partial = getFinalOutput(result.messages);
|
|
184
|
+
if (error && partial) return `${error}\n\n--- Partial output ---\n${partial}`;
|
|
185
|
+
return error || partial || "(no output)";
|
|
170
186
|
}
|
|
171
187
|
return getFinalOutput(result.messages) || "(no output)";
|
|
172
188
|
}
|
|
@@ -506,12 +522,13 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
506
522
|
|
|
507
523
|
currentResult.exitCode = exitCode;
|
|
508
524
|
if (wasAborted) {
|
|
525
|
+
currentResult.stopReason = "aborted";
|
|
526
|
+
currentResult.errorMessage ??= "Subagent was aborted";
|
|
509
527
|
if (onLive) {
|
|
510
528
|
try {
|
|
511
529
|
onLive({ kind: "status", status: "failed" });
|
|
512
530
|
} catch { /* never throw from event handling */ }
|
|
513
531
|
}
|
|
514
|
-
throw new Error("Subagent was aborted");
|
|
515
532
|
}
|
|
516
533
|
return currentResult;
|
|
517
534
|
} finally {
|