@ferris1225/pi-subagents 0.24.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/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,17 @@ 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;
97
+ /** Tool calls that failed inside the run (from tool_execution_end events). A
98
+ * clean process exit can still hide a failed build/test/tool — the completion
99
+ * message must surface these so the main agent is never misled by a rosy final
100
+ * text (e.g. a worker that ended with "keep waiting" while its build failed). */
101
+ failedTools?: Array<{ toolName: string; error: string }>;
77
102
  }
78
103
 
79
104
  export interface SubagentDetails {
@@ -123,6 +148,29 @@ export function reviewVerdict(output: string): "pass" | "fail" | undefined {
123
148
  return undefined;
124
149
  }
125
150
 
151
+ /** Tool errors are usually the trailing lines of a long output (build logs);
152
+ * keep the last non-empty lines, clipped to RESULT_LINE_MAX each. */
153
+ export function extractToolErrorText(content: unknown): string {
154
+ const parts = Array.isArray(content) ? content : [];
155
+ const text = parts
156
+ .filter(
157
+ (part): part is { type: "text"; text: string } =>
158
+ typeof part === "object" &&
159
+ part !== null &&
160
+ (part as { type?: unknown }).type === "text" &&
161
+ typeof (part as { text?: unknown }).text === "string",
162
+ )
163
+ .map((part) => part.text)
164
+ .join("\n");
165
+ return text
166
+ .split("\n")
167
+ .map((line) => line.trim())
168
+ .filter(Boolean)
169
+ .slice(-3)
170
+ .map((line) => (line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line))
171
+ .join("\n");
172
+ }
173
+
126
174
  /** Hard cap for a single line inside a truncated result (minified blobs must not blow up). */
127
175
  export const RESULT_LINE_MAX = 200;
128
176
 
@@ -190,6 +238,39 @@ export function isModelLevelFailure(result: SingleResult): boolean {
190
238
  return result.messages.length > 0 || result.stderr.trim().length > 0;
191
239
  }
192
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
+
193
274
  /**
194
275
  * True when a failed run produced NO model, tool, output, or usage activity
195
276
  * within the startup window — the signature of a concurrent pi startup race,
@@ -337,6 +418,12 @@ export interface RunSingleOptions {
337
418
  * (a concurrent pi startup race). Defaults to SUBAGENT_STARTUP_RETRY_DELAYS_MS;
338
419
  * pass a shorter array in tests to keep them fast. */
339
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[];
340
427
  signal?: AbortSignal;
341
428
  onLive?: (e: SubagentLiveEvent) => void;
342
429
  makeDetails: (results: SingleResult[]) => SubagentDetails;
@@ -493,6 +580,12 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
493
580
  onLive({ kind: "tool_end", toolName: event.toolName ?? "unknown", isError: Boolean(event.isError) });
494
581
  } catch { /* never throw from event handling */ }
495
582
  }
583
+ if (event.isError) {
584
+ (currentResult.failedTools ??= []).push({
585
+ toolName: event.toolName ?? "unknown",
586
+ error: extractToolErrorText(event.result?.content),
587
+ });
588
+ }
496
589
  }
497
590
 
498
591
  if (event.type === "message_end" && event.message) {
@@ -632,17 +725,31 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
632
725
  }
633
726
 
634
727
  /**
635
- * 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:
636
729
  *
637
730
  * 1. Startup retry (inner loop): a concurrent pi startup race can make the child
638
731
  * exit before any model/tool activity. Relaunch with backoff so the startup
639
732
  * lock clears. The SAME model is retried — the race is in the host, not the
640
733
  * model — and only a clean, silent, zero-activity exit qualifies (see
641
734
  * isRetryableStartupFailure), so retrying can never duplicate real work.
642
- * 2. Model fallback (outer): when the provider rejects the configured model
643
- * before producing output (see isModelLevelFailure), retry once with the main
644
- * window's current model. The fallback gets its own startup-retry loop, since
645
- * 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.
646
753
  *
647
754
  * The fallback is per-run only and never persisted: a transient provider hiccup
648
755
  * must not silently downgrade the configured agent model.
@@ -653,7 +760,11 @@ export async function runSingleAgentWithModelFallback(
653
760
  ): Promise<SingleResult> {
654
761
  const agent = options.agent;
655
762
  const launchedRef = agent?.model;
656
- 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;
657
768
 
658
769
  const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
659
770
  let lastResult: SingleResult;
@@ -666,12 +777,12 @@ export async function runSingleAgentWithModelFallback(
666
777
  if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
667
778
  return lastResult;
668
779
  }
669
- const delay = delays[attempt];
780
+ const delay = startupDelays[attempt];
670
781
  if (delay === undefined) {
671
782
  // Exhausted: the agent never reached a model. Surface the concurrency-race
672
783
  // cause as a dispatch-level failure (no model was ever reached, so this
673
- // must NOT trigger model fallback) so the main agent can retry or lower
674
- // maxConcurrency.
784
+ // must NOT trigger run-level retry or model fallback) so the main agent
785
+ // can retry or lower maxConcurrency.
675
786
  lastResult.errorMessage = formatStartupRetryExhaustedError(
676
787
  lastResult.model ?? opts.agent?.model ?? "default",
677
788
  attempt + 1,
@@ -691,9 +802,55 @@ export async function runSingleAgentWithModelFallback(
691
802
  }
692
803
  };
693
804
 
694
- const result = await runWithStartupRetry(options);
695
- if (!agent || !launchedRef || !fallbackModelRef || launchedRef === fallbackModelRef) return result;
696
- if (!isModelLevelFailure(result)) return result;
697
- const retried = await runWithStartupRetry({ ...options, agent: { ...agent, model: fallbackModelRef } });
698
- 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 };
699
856
  }