@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/README.md +579 -476
- package/package.json +1 -1
- package/src/background.ts +112 -106
- package/src/fixloop.ts +84 -76
- package/src/index.ts +503 -14
- package/src/monitor.ts +6 -1
- package/src/spawn.ts +171 -14
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
|
|
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.
|
|
643
|
-
* before producing output
|
|
644
|
-
*
|
|
645
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
}
|