@bacnh85/pi-subagent 0.9.2 → 0.10.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/extensions/index.ts +98 -18
- package/extensions/model.ts +5 -3
- package/extensions/security.ts +30 -0
- package/extensions/service.ts +48 -2
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
startHeartbeat,
|
|
40
40
|
} from "./runner.ts";
|
|
41
41
|
import {
|
|
42
|
+
isRateLimitError,
|
|
42
43
|
normalizeTimeout,
|
|
43
44
|
resolveSafeCwd,
|
|
44
45
|
validateAgentTools,
|
|
@@ -570,29 +571,108 @@ export default function (pi: ExtensionAPI) {
|
|
|
570
571
|
};
|
|
571
572
|
}
|
|
572
573
|
|
|
574
|
+
// Retry loop: rate-limit model fallback
|
|
575
|
+
const candidates = getModelCandidates(agent);
|
|
576
|
+
const triedModels: string[] = [];
|
|
577
|
+
|
|
573
578
|
const stopHeartbeat = onUpdate ? startHeartbeat(() => {
|
|
574
579
|
onHeartbeat?.();
|
|
575
580
|
onUpdate({ content: [{ type: "text", text: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
|
|
576
581
|
}) : undefined;
|
|
577
582
|
try {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
583
|
+
const tryWithFallback = async (): Promise<SubAgentResult> => {
|
|
584
|
+
const remaining = candidates.filter(m => !triedModels.includes(m));
|
|
585
|
+
const isParentFallback = remaining.length === 0;
|
|
586
|
+
const fallbackResolved = await resolveModel(remaining, ctx.model, ctx.modelRegistry);
|
|
587
|
+
if (!fallbackResolved.model) {
|
|
588
|
+
return {
|
|
589
|
+
agent: agentName,
|
|
590
|
+
task,
|
|
591
|
+
exitCode: 1,
|
|
592
|
+
status: "error" as const,
|
|
593
|
+
stopReason: "error" as const,
|
|
594
|
+
messages: [],
|
|
595
|
+
stderr: [
|
|
596
|
+
`All models rate-limited or unavailable.`,
|
|
597
|
+
`Tried: ${triedModels.join(" → ") || "(none)"}.`,
|
|
598
|
+
`Remaining candidates: ${remaining.join(", ") || "none"}.`,
|
|
599
|
+
`Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
|
|
600
|
+
].join(" "),
|
|
601
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
602
|
+
errorMessage: `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
|
|
606
|
+
if (triedModels.includes(triedName)) {
|
|
607
|
+
// Already tried this model (e.g., all candidates unavailable
|
|
608
|
+
// and parent fallback) — no further options.
|
|
609
|
+
return {
|
|
610
|
+
agent: agentName,
|
|
611
|
+
task,
|
|
612
|
+
exitCode: 1,
|
|
613
|
+
status: "error" as const,
|
|
614
|
+
stopReason: "error" as const,
|
|
615
|
+
messages: [],
|
|
616
|
+
stderr: [
|
|
617
|
+
`All available models exhausted.`,
|
|
618
|
+
`Tried: ${triedModels.join(" → ")}.`,
|
|
619
|
+
].join(" "),
|
|
620
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
621
|
+
errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
triedModels.push(triedName);
|
|
625
|
+
// Also track the raw candidate name so candidates.filter() can
|
|
626
|
+
// exclude it even when the agent uses unqualified names.
|
|
627
|
+
// Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
|
|
628
|
+
if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
|
|
629
|
+
triedModels.push(fallbackResolved.matchedCandidate);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const result = await runSubAgent({
|
|
633
|
+
cwd: safeCwd,
|
|
634
|
+
systemPrompt: params.instructions
|
|
635
|
+
? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
|
|
636
|
+
: agent.systemPrompt,
|
|
637
|
+
task,
|
|
638
|
+
tools,
|
|
639
|
+
model: fallbackResolved.model,
|
|
640
|
+
modelRuntime,
|
|
641
|
+
authStorage,
|
|
642
|
+
modelRegistry,
|
|
643
|
+
signal: parentSignal,
|
|
644
|
+
timeoutMs: effectiveTimeoutMs,
|
|
645
|
+
agentName,
|
|
646
|
+
thinkingLevel: agent.thinking,
|
|
647
|
+
onMessage: onProgress,
|
|
648
|
+
onProgress: onActivity,
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
if (result.errorMessage && isRateLimitError(result.errorMessage)) {
|
|
652
|
+
// If the model that just rate-limited was the parent fallback
|
|
653
|
+
// (no remaining candidates), stop — no further options.
|
|
654
|
+
if (isParentFallback) {
|
|
655
|
+
return {
|
|
656
|
+
agent: agentName,
|
|
657
|
+
task,
|
|
658
|
+
exitCode: 1,
|
|
659
|
+
status: "error" as const,
|
|
660
|
+
stopReason: "error" as const,
|
|
661
|
+
messages: [],
|
|
662
|
+
stderr: [
|
|
663
|
+
`All available models exhausted.`,
|
|
664
|
+
`Tried: ${triedModels.join(" → ")}.`,
|
|
665
|
+
].join(" "),
|
|
666
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
667
|
+
errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
return tryWithFallback();
|
|
671
|
+
}
|
|
672
|
+
return result;
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
return tryWithFallback();
|
|
596
676
|
} finally {
|
|
597
677
|
stopHeartbeat?.();
|
|
598
678
|
}
|
package/extensions/model.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
export interface ResolvedModel {
|
|
18
18
|
model: Model<any> | null;
|
|
19
19
|
attempted: string[];
|
|
20
|
+
/** The raw candidate name that matched, if a candidate resolved. Undefined for parent fallback. */
|
|
21
|
+
matchedCandidate?: string;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/** Known provider prefixes for unqualified model names. */
|
|
@@ -47,16 +49,16 @@ export async function resolveModel(
|
|
|
47
49
|
const idx = modelName.indexOf("/");
|
|
48
50
|
if (idx > 0) {
|
|
49
51
|
const found = tryAvailable(modelName);
|
|
50
|
-
if (found) return { model: found, attempted };
|
|
52
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
51
53
|
continue;
|
|
52
54
|
}
|
|
53
55
|
for (const [provider, pattern] of KNOWN_PROVIDERS) {
|
|
54
56
|
if (!pattern.test(modelName)) continue;
|
|
55
57
|
const found = tryAvailable(`${provider}/${modelName}`);
|
|
56
|
-
if (found) return { model: found, attempted };
|
|
58
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
57
59
|
}
|
|
58
60
|
const found = tryAvailable(`anthropic/${modelName}`);
|
|
59
|
-
if (found) return { model: found, attempted };
|
|
61
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
if (parentModel) {
|
package/extensions/security.ts
CHANGED
|
@@ -501,3 +501,33 @@ export function truncateParallelOutput(output: string): string {
|
|
|
501
501
|
}
|
|
502
502
|
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
|
|
503
503
|
}
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// Rate-limit error detection
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
const RATE_LIMIT_PATTERNS = [
|
|
510
|
+
/\b429\b/,
|
|
511
|
+
/\b529\b/,
|
|
512
|
+
/rate[\s_]limit/i,
|
|
513
|
+
/ratelimit/i,
|
|
514
|
+
/too[\s_]many[\s_]requests/i,
|
|
515
|
+
/quota[\s_]exhausted/i,
|
|
516
|
+
/quota[\s_]exceeded/i,
|
|
517
|
+
/exceeded[\s_](?:your[\s_])?(?:current[\s_])?quota/i,
|
|
518
|
+
/insufficient_quota/i,
|
|
519
|
+
/resource[\s_]exhausted/i,
|
|
520
|
+
/capacity[\s_]exceeded/i,
|
|
521
|
+
/usage[\s_]limit/i,
|
|
522
|
+
/overloaded/i,
|
|
523
|
+
];
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Check if an error message indicates a rate-limit / quota-exhaustion condition.
|
|
527
|
+
*
|
|
528
|
+
* Used by the sub-agent runtime to trigger automatic model fallback when the
|
|
529
|
+
* primary model candidate hits a 429 or similar server-side capacity error.
|
|
530
|
+
*/
|
|
531
|
+
export function isRateLimitError(message: string): boolean {
|
|
532
|
+
return RATE_LIMIT_PATTERNS.some(p => p.test(message));
|
|
533
|
+
}
|
package/extensions/service.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type AgentConfig, getModelCandidates } from "./agents.ts";
|
|
|
3
3
|
import { runSubAgent, type SubAgentProgress, type SubAgentResult } from "./runner.ts";
|
|
4
4
|
import { resolveModel } from "./model.ts";
|
|
5
5
|
import {
|
|
6
|
+
isRateLimitError,
|
|
6
7
|
validateAgentTools,
|
|
7
8
|
normalizeTimeout,
|
|
8
9
|
resolveSafeCwd,
|
|
@@ -72,13 +73,43 @@ export async function runNamedAgent(options: {
|
|
|
72
73
|
|
|
73
74
|
const contract = options.instructions?.slice(0, MAX_INSTRUCTIONS_LENGTH);
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
// Retry loop: rate-limit model fallback
|
|
77
|
+
const candidates = getModelCandidates(options.agent);
|
|
78
|
+
const triedModels: string[] = [];
|
|
79
|
+
|
|
80
|
+
const tryWithFallback = async (): Promise<SubAgentResult> => {
|
|
81
|
+
const remaining = candidates.filter(m => !triedModels.includes(m));
|
|
82
|
+
const isParentFallback = remaining.length === 0;
|
|
83
|
+
const fallbackResolved = await resolveModel(remaining, options.ctx.model, options.ctx.modelRegistry);
|
|
84
|
+
if (!fallbackResolved.model) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`All models rate-limited or unavailable. Tried: ${triedModels.join(" → ") || "(none)"}. ` +
|
|
87
|
+
`Remaining candidates: ${remaining.join(", ") || "none"}. ` +
|
|
88
|
+
`Parent: ${options.ctx.model?.provider}/${options.ctx.model?.id}.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
|
|
92
|
+
if (triedModels.includes(triedName)) {
|
|
93
|
+
// Already tried this model (e.g., all candidates unavailable
|
|
94
|
+
// and parent fallback) — no further options.
|
|
95
|
+
throw new Error(
|
|
96
|
+
`All available models exhausted. Tried: ${triedModels.join(" → ")}.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
triedModels.push(triedName);
|
|
100
|
+
// Also track the raw candidate name so candidates.filter() can
|
|
101
|
+
// exclude it even when the agent uses unqualified names.
|
|
102
|
+
// Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
|
|
103
|
+
if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
|
|
104
|
+
triedModels.push(fallbackResolved.matchedCandidate);
|
|
105
|
+
}
|
|
106
|
+
|
|
76
107
|
const result = await runSubAgent({
|
|
77
108
|
cwd: safeCwd.path,
|
|
78
109
|
systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
|
|
79
110
|
task: options.task,
|
|
80
111
|
tools: toolValidation.tools,
|
|
81
|
-
model,
|
|
112
|
+
model: fallbackResolved.model,
|
|
82
113
|
modelRuntime,
|
|
83
114
|
authStorage,
|
|
84
115
|
modelRegistry,
|
|
@@ -89,7 +120,22 @@ export async function runNamedAgent(options: {
|
|
|
89
120
|
onMessage: options.onMessage,
|
|
90
121
|
onProgress: options.onProgress,
|
|
91
122
|
});
|
|
123
|
+
|
|
124
|
+
if (result.errorMessage && isRateLimitError(result.errorMessage)) {
|
|
125
|
+
// If the model that just rate-limited was the parent fallback
|
|
126
|
+
// (no remaining candidates), stop — no further options.
|
|
127
|
+
if (isParentFallback) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`All available models exhausted. Tried: ${triedModels.join(" → ")}.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return tryWithFallback();
|
|
133
|
+
}
|
|
92
134
|
return result;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return await tryWithFallback();
|
|
93
139
|
} finally {
|
|
94
140
|
// No manual timeout handling needed — runSubAgent handles timeouts internally.
|
|
95
141
|
}
|
package/package.json
CHANGED