@bacnh85/pi-subagent 0.9.1 → 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/CHANGELOG.md +6 -0
- package/extensions/index.ts +100 -35
- package/extensions/model.ts +5 -3
- package/extensions/runner.ts +12 -6
- package/extensions/security.ts +30 -0
- package/extensions/service.ts +52 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/extensions/index.ts
CHANGED
|
@@ -14,17 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
18
17
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
19
18
|
import {
|
|
20
|
-
AuthStorage,
|
|
21
19
|
CONFIG_DIR_NAME,
|
|
22
20
|
DynamicBorder,
|
|
23
21
|
type ExtensionAPI,
|
|
24
22
|
type ExtensionContext,
|
|
25
23
|
getAgentDir,
|
|
26
24
|
getMarkdownTheme,
|
|
27
|
-
ModelRegistry,
|
|
28
25
|
type ThemeColor,
|
|
29
26
|
} from "@earendil-works/pi-coding-agent";
|
|
30
27
|
import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
@@ -42,6 +39,7 @@ import {
|
|
|
42
39
|
startHeartbeat,
|
|
43
40
|
} from "./runner.ts";
|
|
44
41
|
import {
|
|
42
|
+
isRateLimitError,
|
|
45
43
|
normalizeTimeout,
|
|
46
44
|
resolveSafeCwd,
|
|
47
45
|
validateAgentTools,
|
|
@@ -463,20 +461,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
463
461
|
}
|
|
464
462
|
}
|
|
465
463
|
|
|
466
|
-
// Shared auth/model setup for SDK sessions
|
|
467
|
-
// ponytail: reuse parent modelRegistry instead of a fresh copy — avoids
|
|
468
|
-
// internal API casts (storeModelHeaders) and preserves env/headers/OAuth.
|
|
469
|
-
const authStorage = AuthStorage.inMemory();
|
|
470
464
|
const modelRegistry = ctx.modelRegistry;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
async function injectApiKey(model: Model<any>): Promise<void> {
|
|
474
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
475
|
-
if (auth.ok) {
|
|
476
|
-
if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
|
|
477
|
-
// ponytail: headers/env stay on the parent registry — no copy needed.
|
|
478
|
-
}
|
|
479
|
-
}
|
|
465
|
+
const modelRuntime = (modelRegistry as any).runtime;
|
|
466
|
+
const authStorage = (modelRegistry as any).authStorage;
|
|
480
467
|
|
|
481
468
|
// Helper: resolve a safe child working directory.
|
|
482
469
|
function resolveChildCwd(childCwd: string | undefined): string {
|
|
@@ -566,8 +553,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
566
553
|
let effectiveTimeoutMs: number | undefined;
|
|
567
554
|
let safeCwd: string;
|
|
568
555
|
try {
|
|
569
|
-
// Inject parent's API key so --api-key and other runtime overrides work
|
|
570
|
-
await injectApiKey(resolved.model);
|
|
571
556
|
tools = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
|
|
572
557
|
effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
|
|
573
558
|
safeCwd = resolveChildCwd(cwd);
|
|
@@ -586,28 +571,108 @@ export default function (pi: ExtensionAPI) {
|
|
|
586
571
|
};
|
|
587
572
|
}
|
|
588
573
|
|
|
574
|
+
// Retry loop: rate-limit model fallback
|
|
575
|
+
const candidates = getModelCandidates(agent);
|
|
576
|
+
const triedModels: string[] = [];
|
|
577
|
+
|
|
589
578
|
const stopHeartbeat = onUpdate ? startHeartbeat(() => {
|
|
590
579
|
onHeartbeat?.();
|
|
591
580
|
onUpdate({ content: [{ type: "text", text: `Subagent ${agentName} is still running…` }], details: heartbeatDetails?.() ?? makeDetails("single")([]) });
|
|
592
581
|
}) : undefined;
|
|
593
582
|
try {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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();
|
|
611
676
|
} finally {
|
|
612
677
|
stopHeartbeat?.();
|
|
613
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/runner.ts
CHANGED
|
@@ -17,10 +17,8 @@
|
|
|
17
17
|
import type { Message, Model } from "@earendil-works/pi-ai";
|
|
18
18
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
19
19
|
import {
|
|
20
|
-
AuthStorage,
|
|
21
20
|
createAgentSession,
|
|
22
21
|
createExtensionRuntime,
|
|
23
|
-
ModelRegistry,
|
|
24
22
|
type ResourceLoader,
|
|
25
23
|
SessionManager,
|
|
26
24
|
SettingsManager,
|
|
@@ -87,8 +85,11 @@ export async function runSubAgent(options: {
|
|
|
87
85
|
task: string;
|
|
88
86
|
tools: string[];
|
|
89
87
|
model: Model<any>;
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
/** Pi 0.80.10's canonical credential/model runtime. */
|
|
89
|
+
modelRuntime?: unknown;
|
|
90
|
+
/** Legacy Pi SDK session options retained for 0.80.6 tests and hosts. */
|
|
91
|
+
authStorage?: unknown;
|
|
92
|
+
modelRegistry?: unknown;
|
|
92
93
|
signal?: AbortSignal;
|
|
93
94
|
agentName?: string;
|
|
94
95
|
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
@@ -98,7 +99,7 @@ export async function runSubAgent(options: {
|
|
|
98
99
|
hardTimeoutMs?: number;
|
|
99
100
|
}): Promise<SubAgentResult> {
|
|
100
101
|
const {
|
|
101
|
-
cwd, systemPrompt, task, tools, model, authStorage, modelRegistry, signal,
|
|
102
|
+
cwd, systemPrompt, task, tools, model, modelRuntime, authStorage, modelRegistry, signal,
|
|
102
103
|
agentName = "subagent", thinkingLevel = "off", onMessage, onProgress,
|
|
103
104
|
timeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS, hardTimeoutMs = HARD_TIMEOUT_MS,
|
|
104
105
|
} = options;
|
|
@@ -142,7 +143,12 @@ export async function runSubAgent(options: {
|
|
|
142
143
|
result.status = classifyStopReason(result.stopReason, !timedOut, timedOut);
|
|
143
144
|
return result;
|
|
144
145
|
}
|
|
145
|
-
const { session } = await createAgentSession({
|
|
146
|
+
const { session } = await createAgentSession({
|
|
147
|
+
cwd, model, thinkingLevel, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager,
|
|
148
|
+
...(modelRuntime ? { modelRuntime } : {}),
|
|
149
|
+
// Pi 0.80.10 owns credentials in ModelRuntime; older SDKs still accept these.
|
|
150
|
+
...(authStorage ? { authStorage, modelRegistry } : {}),
|
|
151
|
+
} as any);
|
|
146
152
|
let unsubscribe: (() => void) | undefined;
|
|
147
153
|
let removeAbort: (() => void) | undefined;
|
|
148
154
|
try {
|
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
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
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,
|
|
@@ -44,13 +45,9 @@ export async function runNamedAgent(options: {
|
|
|
44
45
|
const { model, attempted } = await resolveModel(getModelCandidates(options.agent), options.ctx.model, options.ctx.modelRegistry);
|
|
45
46
|
if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
|
|
46
47
|
|
|
47
|
-
const authStorage = AuthStorage.inMemory();
|
|
48
48
|
const modelRegistry = options.ctx.modelRegistry;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
|
|
52
|
-
// ponytail: env and headers stay on the parent modelRegistry — reuse it directly.
|
|
53
|
-
}
|
|
49
|
+
const modelRuntime = (modelRegistry as any).runtime;
|
|
50
|
+
const authStorage = (modelRegistry as any).authStorage;
|
|
54
51
|
|
|
55
52
|
// Security: validate and normalise timeout.
|
|
56
53
|
const effectiveTimeoutMs = normalizeTimeout({ requested: options.timeout }).timeoutMs;
|
|
@@ -76,13 +73,44 @@ export async function runNamedAgent(options: {
|
|
|
76
73
|
|
|
77
74
|
const contract = options.instructions?.slice(0, MAX_INSTRUCTIONS_LENGTH);
|
|
78
75
|
|
|
79
|
-
|
|
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
|
+
|
|
80
107
|
const result = await runSubAgent({
|
|
81
108
|
cwd: safeCwd.path,
|
|
82
109
|
systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
|
|
83
110
|
task: options.task,
|
|
84
111
|
tools: toolValidation.tools,
|
|
85
|
-
model,
|
|
112
|
+
model: fallbackResolved.model,
|
|
113
|
+
modelRuntime,
|
|
86
114
|
authStorage,
|
|
87
115
|
modelRegistry,
|
|
88
116
|
signal: options.signal,
|
|
@@ -92,7 +120,22 @@ export async function runNamedAgent(options: {
|
|
|
92
120
|
onMessage: options.onMessage,
|
|
93
121
|
onProgress: options.onProgress,
|
|
94
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
|
+
}
|
|
95
134
|
return result;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return await tryWithFallback();
|
|
96
139
|
} finally {
|
|
97
140
|
// No manual timeout handling needed — runSubAgent handles timeouts internally.
|
|
98
141
|
}
|
package/package.json
CHANGED