@ferris1225/pi-subagents 0.25.0 → 0.26.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 CHANGED
@@ -31,8 +31,11 @@ report their results back to the main agent automatically.
31
31
  - **Per-agent configuration** — enable agents, choose a model and thinking level per
32
32
  agent, and tune limits from `/subagents-setup`.
33
33
  - **Automatic model fallback** — if an agent's model fails at the provider level before
34
- producing any output, the run is retried once with the main window's current model.
35
- This is per-run only and never persisted.
34
+ producing any output, the SAME model is retried up to five times (bounded backoff) for
35
+ transient errors (503/429/timeout/network/...); if it still fails, the run is retried
36
+ once with the main window's current model. Terminal errors (quota exhausted, billing,
37
+ an invalid API key) skip both and are handed straight back to the main agent. The
38
+ fallback is per-run only and never persisted.
36
39
  - **Idle watchdog** — a sub-agent that produces no output for a configurable duration is
37
40
  terminated and retried with the fallback model.
38
41
  - **Leaf processes** — sub-agents cannot access the `subagent` tool, so delegation
@@ -53,10 +56,12 @@ Several tools now offer some form of sub-agents. What this extension does differ
53
56
  calls `subagent_wait` (event-driven, returns the actual result) instead of
54
57
  sleeping or polling.
55
58
  - **Failures are handled, not reported.** Three layers of resilience: a provider-
56
- level model failure retries once with the main window's model; an idle watchdog
57
- terminates a run that goes silent (a stalled stream) and retries it; and a
58
- concurrent-startup race is retried with backoff automatically. The widget and the
59
- completion message tell you when any of these happened.
59
+ level model failure first retries the same model up to five times on a transient
60
+ provider error, then retries once with the main window's model; terminal errors
61
+ (quota/auth) short-circuit straight to the main agent; an idle watchdog terminates
62
+ a run that goes silent (a stalled stream) and retries it; and a concurrent-startup
63
+ race is retried with backoff automatically. The widget and the completion message
64
+ tell you when any of these happened.
60
65
  - **A quality gate that closes the loop.** When a reviewer returns `REVIEW_FAIL`,
61
66
  the extension dispatches a worker briefed with the concrete findings, then a
62
67
  re-review — up to `maxFixRounds` times — and only then wakes the main agent with
@@ -466,7 +471,7 @@ idle timeout; **Full re-setup** re-runs the whole first-time wizard.
466
471
  | `agentScope` | `user`, `project`, or `both`; controls which user/project agent directories are discovered. |
467
472
  | `maxConcurrency` | Max sub-agent processes running at once (1–16, default 4), and the max tasks one parallel `subagent` call accepts. Extra work waits in the queue. |
468
473
  | `maxFixRounds` | Auto-fix rounds when a reviewer returns `REVIEW_FAIL`: the extension dispatches a `worker` (briefed with the review's findings) then a `reviewer` re-review, repeating up to this many times before waking the main agent with the full chain. `0` disables it (the main agent handles fixes itself). Default 2. |
469
- | `idleTimeoutSec` | Idle timeout in seconds: a sub-agent that produces no output for this long is terminated and retried with the fallback model (if one is available). `0` disables the idle watchdog. Default 90. A long but active run is never interrupted. |
474
+ | `idleTimeoutSec` | Idle timeout in seconds: a sub-agent whose stdout goes silent for this long is terminated and retried (same model first, then the main-window fallback, like any transient provider failure). `0` disables the idle watchdog. Default 90. A long but active run is never interrupted. |
470
475
 
471
476
  ### Configuration migration
472
477
 
package/package.json CHANGED
@@ -1,53 +1,53 @@
1
- {
2
- "name": "@ferris1225/pi-subagents",
3
- "version": "0.25.0",
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
- "type": "module",
6
- "license": "MIT",
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "keywords": [
11
- "pi-package",
12
- "pi-extension",
13
- "subagent",
14
- "sub-agent",
15
- "delegation"
16
- ],
17
- "files": [
18
- "src",
19
- "agents",
20
- "README.md",
21
- "LICENSE"
22
- ],
23
- "pi": {
24
- "extensions": [
25
- "./src/index.ts"
26
- ]
27
- },
28
- "scripts": {
29
- "check": "tsc --noEmit",
30
- "test": "vitest run tests",
31
- "prepack": "npm run check && npm test"
32
- },
33
- "peerDependencies": {
34
- "@earendil-works/pi-agent-core": ">=0.80.6",
35
- "@earendil-works/pi-ai": ">=0.80.6",
36
- "@earendil-works/pi-coding-agent": ">=0.80.6",
37
- "@earendil-works/pi-tui": ">=0.80.6",
38
- "typebox": "*"
39
- },
40
- "devDependencies": {
41
- "@earendil-works/pi-agent-core": "^0.83.0",
42
- "@earendil-works/pi-ai": "^0.83.0",
43
- "@earendil-works/pi-coding-agent": "^0.83.0",
44
- "@earendil-works/pi-tui": "^0.83.0",
45
- "@types/node": "^22.10.0",
46
- "typebox": "^1.3.9",
47
- "typescript": "^5.9.0",
48
- "vitest": "^4.1.0"
49
- },
50
- "engines": {
51
- "node": ">=22.19.0"
52
- }
53
- }
1
+ {
2
+ "name": "@ferris1225/pi-subagents",
3
+ "version": "0.26.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "keywords": [
11
+ "pi-package",
12
+ "pi-extension",
13
+ "subagent",
14
+ "sub-agent",
15
+ "delegation"
16
+ ],
17
+ "files": [
18
+ "src",
19
+ "agents",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./src/index.ts"
26
+ ]
27
+ },
28
+ "scripts": {
29
+ "check": "tsc --noEmit",
30
+ "test": "vitest run tests",
31
+ "prepack": "npm run check && npm test"
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-agent-core": ">=0.80.6",
35
+ "@earendil-works/pi-ai": ">=0.80.6",
36
+ "@earendil-works/pi-coding-agent": ">=0.80.6",
37
+ "@earendil-works/pi-tui": ">=0.80.6",
38
+ "typebox": "*"
39
+ },
40
+ "devDependencies": {
41
+ "@earendil-works/pi-agent-core": "^0.83.0",
42
+ "@earendil-works/pi-ai": "^0.83.0",
43
+ "@earendil-works/pi-coding-agent": "^0.83.0",
44
+ "@earendil-works/pi-tui": "^0.83.0",
45
+ "@types/node": "^22.10.0",
46
+ "typebox": "^1.3.9",
47
+ "typescript": "^5.9.0",
48
+ "vitest": "^4.1.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.19.0"
52
+ }
53
+ }
package/src/index.ts CHANGED
@@ -167,10 +167,13 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd
167
167
  const fallbackNote = result.modelFallbackFrom
168
168
  ? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
169
169
  : "";
170
- const retryNote = result.startupRetries
170
+ const startupRetryNote = result.startupRetries
171
171
  ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
172
172
  : "";
173
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${retryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
173
+ const modelRetryNote = result.modelRetries
174
+ ? ` (recovered after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on a transient provider error)`
175
+ : "";
176
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
174
177
  // A run can exit cleanly while its last tools failed (e.g. a build that broke):
175
178
  // the final text alone may claim more than the tools achieved, so surface the
176
179
  // failures explicitly and tell the main agent to verify before relying on it.
@@ -196,8 +199,11 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number, cwd
196
199
  * produced usable output (or the run stalled), so the task is handed back to the
197
200
  * main window instead of being left as a dead failure. */
198
201
  function modelLevelTakeoverNote(result: SingleResult): string {
202
+ const sameModel = result.modelRetries
203
+ ? `, after ${result.modelRetries} same-model retr${result.modelRetries === 1 ? "y" : "ies"} on transient errors`
204
+ : "";
199
205
  const retry = result.modelFallbackFrom ? ", and the retry with the main-window model also failed" : "";
200
- 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.`;
206
+ return `The sub-agent could not complete this task: its model was unavailable or failed (or the run stalled)${sameModel}${retry}. Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
201
207
  }
202
208
 
203
209
  /** Resolve a run-id request to actual ids: an exact numeric match always wins
package/src/spawn.ts CHANGED
@@ -41,6 +41,20 @@ export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
41
41
  /** A genuine startup race fails well before a model request can complete. */
42
42
  export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
43
43
 
44
+ /** Backoff schedule (ms) for retrying a run whose configured model failed at the
45
+ * provider level with a TRANSIENT error (503/429/timeout/network/overloaded/...) —
46
+ * i.e. NOT a terminal error (quota exhausted, billing, invalid API key). The same
47
+ * model is relaunched (each relaunch gets its own startup-retry inner loop), so a
48
+ * one-off provider hiccup recovers without demoting the configured agent model.
49
+ *
50
+ * This sits OUTSIDE pi-ai's per-request provider retry (default 3 attempts, 2/4/8s
51
+ * backoff): when the provider still can't recover after its own retries, the
52
+ * child exits carrying the final error, and this layer relaunches the whole run
53
+ * up to len(delays) more times before falling back to the main-window model.
54
+ *
55
+ * Bounded and capped so a stubborn outage does not stall a dispatch forever. */
56
+ export const SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
57
+
44
58
  export interface UsageStats {
45
59
  input: number;
46
60
  output: number;
@@ -74,6 +88,12 @@ export interface SingleResult {
74
88
  * exit (a concurrent pi startup race) before it produced a result. Set only when
75
89
  * the run actually recovered after retrying, so callers can surface it. */
76
90
  startupRetries?: number;
91
+ /** How many times the SAME configured model was relaunched after a transient
92
+ * provider-level failure (503/429/timeout/network/...) before the run produced
93
+ * a result. Set on recovery and on fall-back to the main-window model; left
94
+ * undefined for a terminal (quota/billing/invalid-key) error that short-
95
+ * circuits before any retry, since no relaunch happened. */
96
+ modelRetries?: number;
77
97
  /** Tool calls that failed inside the run (from tool_execution_end events). A
78
98
  * clean process exit can still hide a failed build/test/tool — the completion
79
99
  * message must surface these so the main agent is never misled by a rosy final
@@ -218,6 +238,39 @@ export function isModelLevelFailure(result: SingleResult): boolean {
218
238
  return result.messages.length > 0 || result.stderr.trim().length > 0;
219
239
  }
220
240
 
241
+ /** Patterns that signal a TERMINAL provider/account error: retrying the same
242
+ * model (or falling back to the main-window model under the same account) cannot
243
+ * fix it, so the run skips both run-level retry and model fallback and is handed
244
+ * back to the main agent. This is the complement of pi-ai's transient-error set
245
+ * (429/5xx/overloaded/network/timeout/...): anything NOT matching here is treated
246
+ * as transient and retried on the same model before degrading.
247
+ *
248
+ * Mirrors pi-ai's NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN (quota/billing/
249
+ * subscription-limit text) and adds the auth/credential failures the user cited
250
+ * ("key无效"). Auth is account-scoped, so a fallback model on the same provider
251
+ * would fail identically — hand it to the main agent immediately. */
252
+ const TERMINAL_MODEL_ERROR_PATTERN =
253
+ /insufficient_quota|quota\s+exceeded|exceeded[^.\n]{0,40}quota|out\s+of\s+budget|billing|usage\s+limit|usage_limit|gousagelimiterror|freeusagelimiterror|monthly\s+usage\s+limit\s+reached|available\s+balance|invalid\s+(?:api\s+)?key|incorrect\s+api\s+key|unauthori[sz]ed|\b401\b|\b403\b|forbidden|permission\s+denied/i;
254
+
255
+ /** True when a model-level failure carries a TERMINAL error message — quota
256
+ * exhaustion, billing, an invalid API key, auth rejection. Such a run is NEVER
257
+ * retried on the same model and never falls back to the main-window model: the
258
+ * account is the bottleneck, so it is handed back to the main agent to fix.
259
+ *
260
+ * Caller must first confirm `isModelLevelFailure(result)` — aborts and
261
+ * dispatch-crafted results never reach this classifier. */
262
+ export function isTerminalModelError(result: SingleResult): boolean {
263
+ const message = result.errorMessage?.trim();
264
+ if (message) return TERMINAL_MODEL_ERROR_PATTERN.test(message);
265
+ // Only consult stderr when there is no structured errorMessage: pi-ai surfaces
266
+ // provider errors via message_end -> errorMessage, so a transient errorMessage
267
+ // (e.g. "503 Service Unavailable") must not be overridden by noisy stderr that
268
+ // happens to mention a terminal-looking word (an npm warning, a proxy banner).
269
+ // This keeps transient failures retryable even when stderr is chatty.
270
+ const stderr = result.stderr.trim();
271
+ return stderr.length > 0 && TERMINAL_MODEL_ERROR_PATTERN.test(stderr);
272
+ }
273
+
221
274
  /**
222
275
  * True when a failed run produced NO model, tool, output, or usage activity
223
276
  * within the startup window — the signature of a concurrent pi startup race,
@@ -365,6 +418,12 @@ export interface RunSingleOptions {
365
418
  * (a concurrent pi startup race). Defaults to SUBAGENT_STARTUP_RETRY_DELAYS_MS;
366
419
  * pass a shorter array in tests to keep them fast. */
367
420
  startupRetryDelaysMs?: readonly number[];
421
+ /** Run-level backoff schedule (ms) for relaunching the SAME configured model
422
+ * after a transient provider-level failure (503/429/timeout/network/...). Each
423
+ * relaunch gets its own startup-retry inner loop. Defaults to
424
+ * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS; pass [] to disable (e.g. when an isolated
425
+ * test wants to assert only the fallback path runs once). */
426
+ runLevelRetryDelaysMs?: readonly number[];
368
427
  signal?: AbortSignal;
369
428
  onLive?: (e: SubagentLiveEvent) => void;
370
429
  makeDetails: (results: SingleResult[]) => SubagentDetails;
@@ -666,17 +725,31 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
666
725
  }
667
726
 
668
727
  /**
669
- * Run one agent with two layers of resilience against transient dispatch failures:
728
+ * Run one agent with three layers of resilience against transient dispatch failures:
670
729
  *
671
730
  * 1. Startup retry (inner loop): a concurrent pi startup race can make the child
672
731
  * exit before any model/tool activity. Relaunch with backoff so the startup
673
732
  * lock clears. The SAME model is retried — the race is in the host, not the
674
733
  * model — and only a clean, silent, zero-activity exit qualifies (see
675
734
  * isRetryableStartupFailure), so retrying can never duplicate real work.
676
- * 2. Model fallback (outer): when the provider rejects the configured model
677
- * before producing output (see isModelLevelFailure), retry once with the main
678
- * window's current model. The fallback gets its own startup-retry loop, since
679
- * a startup race can hit any relaunch regardless of model.
735
+ * 2. Run-level retry on the SAME configured model (middle): when the provider
736
+ * rejects the model before producing output with a TRANSIENT error
737
+ * (503/429/timeout/network/stream/...) i.e. NOT a terminal one (quota
738
+ * exhausted, billing, an invalid API key, auth rejected see
739
+ * isTerminalModelError) — relaunch the whole run up to
740
+ * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff, so a
741
+ * one-off provider hiccup recovers without demoting the configured agent
742
+ * model. Each relaunch gets its own inner startup-retry loop. This sits
743
+ * outside pi-ai's per-request provider retry, which by then has already
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.
749
+ *
750
+ * Terminal model errors short-circuit straight to the caller (modelRetries is
751
+ * set): the account is the bottleneck, so neither same-model retry nor a
752
+ * same-account fallback can help, and the run is left for the main agent to fix.
680
753
  *
681
754
  * The fallback is per-run only and never persisted: a transient provider hiccup
682
755
  * must not silently downgrade the configured agent model.
@@ -687,7 +760,11 @@ export async function runSingleAgentWithModelFallback(
687
760
  ): Promise<SingleResult> {
688
761
  const agent = options.agent;
689
762
  const launchedRef = agent?.model;
690
- const delays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
763
+ const startupDelays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
764
+ // Run-level retry is opted out of with an explicit empty array (e.g. a test
765
+ // that wants to assert ONLY the fallback path runs once); undefined means
766
+ // "use the default 5-attempt transient-error schedule".
767
+ const runDelays = options.runLevelRetryDelaysMs ?? SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS;
691
768
 
692
769
  const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
693
770
  let lastResult: SingleResult;
@@ -700,12 +777,12 @@ export async function runSingleAgentWithModelFallback(
700
777
  if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
701
778
  return lastResult;
702
779
  }
703
- const delay = delays[attempt];
780
+ const delay = startupDelays[attempt];
704
781
  if (delay === undefined) {
705
782
  // Exhausted: the agent never reached a model. Surface the concurrency-race
706
783
  // cause as a dispatch-level failure (no model was ever reached, so this
707
- // must NOT trigger model fallback) so the main agent can retry or lower
708
- // maxConcurrency.
784
+ // must NOT trigger run-level retry or model fallback) so the main agent
785
+ // can retry or lower maxConcurrency.
709
786
  lastResult.errorMessage = formatStartupRetryExhaustedError(
710
787
  lastResult.model ?? opts.agent?.model ?? "default",
711
788
  attempt + 1,
@@ -725,15 +802,55 @@ export async function runSingleAgentWithModelFallback(
725
802
  }
726
803
  };
727
804
 
728
- const result = await runWithStartupRetry(options);
729
- if (!agent || !launchedRef || !fallbackModelRef || launchedRef === fallbackModelRef) return result;
730
- if (!isModelLevelFailure(result)) return result;
731
- const retried = await runWithStartupRetry({ ...options, agent: { ...agent, model: fallbackModelRef } });
732
- // The fallback replaces the result wholesale: `retried.failedTools` reflect
733
- // ONLY the fallback (final) attempt. The original attempt's failedTools are
734
- // intentionally not merged a fallback relaunch redoes the work, so attaching
735
- // the first attempt's stale build errors to a clean final attempt would
736
- // misattribute failures the worker already fixed. This makes the README's
737
- // "failed tool calls from the run's final attempt" claim accurate.
738
- return { ...retried, modelFallbackFrom: launchedRef };
805
+ let result = await runWithStartupRetry(options);
806
+
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
+ let modelRetries = 0;
820
+ if (agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
821
+ for (let attempt = 0; ; attempt++) {
822
+ const delay = runDelays[attempt];
823
+ if (delay === undefined) break;
824
+ try {
825
+ options.onLive?.({ kind: "status", status: "running" });
826
+ } catch { /* never throw from event handling */ }
827
+ const shouldRetry = await waitForStartupRetry(delay, options.signal);
828
+ if (!shouldRetry) return { ...result, modelRetries };
829
+ const retried = await runWithStartupRetry(options);
830
+ modelRetries++;
831
+ if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) {
832
+ // Each relaunch redoes the work; failedTools reflect ONLY the final
833
+ // attempt (no stale build errors from earlier transient failures),
834
+ // so the completion message's claim stays accurate.
835
+ return { ...retried, modelRetries };
836
+ }
837
+ result = retried;
838
+ }
839
+ }
840
+
841
+ // Same-model retries exhausted (or none configured) and still failing: fall
842
+ // back to the main window's current model exactly once. Skipped when there is
843
+ // no fallback ref or it equals the configured model — a same-ref rerun would
844
+ // just repeat the already-exhausted failure for nothing.
845
+ if (agent && launchedRef && fallbackModelRef && launchedRef !== fallbackModelRef && isModelLevelFailure(result)) {
846
+ const retried = await runWithStartupRetry({ ...options, agent: { ...agent, model: fallbackModelRef } });
847
+ // The fallback replaces the result wholesale: `retried.failedTools` reflect
848
+ // ONLY the fallback (final) attempt. The original attempt's failedTools are
849
+ // intentionally not merged — a fallback relaunch redoes the work, so attaching
850
+ // the first attempt's stale build errors to a clean final attempt would
851
+ // misattribute failures the worker already fixed. This makes the README's
852
+ // "failed tool calls from the run's final attempt" claim accurate.
853
+ return { ...retried, modelFallbackFrom: launchedRef, modelRetries };
854
+ }
855
+ return { ...result, modelRetries };
739
856
  }