@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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2 (2026-07-16)
4
+
5
+ ### Pi SDK compatibility
6
+
7
+ - Removed use of the deleted `AuthStorage.inMemory()` API so delegated planner and other subagents start on Pi 0.80.10.
8
+
3
9
  ## 0.9.1 (2026-07-16)
4
10
 
5
11
  ### Activity-aware timeouts
@@ -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
- // Helper: inject parent's API key into child auth storage
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
- return await runSubAgent({
595
- cwd: safeCwd,
596
- systemPrompt: params.instructions
597
- ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
598
- : agent.systemPrompt,
599
- task,
600
- tools,
601
- model: resolved.model,
602
- authStorage,
603
- modelRegistry,
604
- signal: parentSignal,
605
- timeoutMs: effectiveTimeoutMs,
606
- agentName,
607
- thinkingLevel: agent.thinking,
608
- onMessage: onProgress,
609
- onProgress: onActivity,
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
  }
@@ -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) {
@@ -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
- authStorage: AuthStorage;
91
- modelRegistry: ModelRegistry;
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({ cwd, model, thinkingLevel, authStorage, modelRegistry, resourceLoader, tools, sessionManager: SessionManager.inMemory(cwd), settingsManager });
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 {
@@ -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
+ }
@@ -1,8 +1,9 @@
1
- import { AuthStorage, type ExtensionContext } from "@earendil-works/pi-coding-agent";
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 auth = await options.ctx.modelRegistry.getApiKeyAndHeaders(model);
50
- if (auth.ok) {
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
- try {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",