pi-herdr-agents 0.0.4 → 0.1.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 CHANGED
@@ -314,7 +314,14 @@ exact IDs from your authenticated model catalog:
314
314
  `subagent({ agent: ... })`. Explicit `model` tool arguments take precedence,
315
315
  followed by agent frontmatter, per-agent config, the global default, and finally
316
316
  the parent model. Model values must be exact authenticated `provider/model-id`
317
- references.
317
+ references. A value can contain an ordered comma-separated fallback list, for
318
+ example `provider/preferred, provider/fallback`. The extension validates every
319
+ candidate before launch, retries the preferred model normally, then launches
320
+ later candidates only after a provider/agent request failure. A completed child
321
+ result, including a negative task result, never switches models. Completion
322
+ metadata and the status widget report the model actually used; an exhausted
323
+ list reports every attempted model. Workflow metadata accepts one exact model
324
+ only, to keep approved workflow runtimes deterministic.
318
325
 
319
326
  `config.json` is gitignored in the source tree so local overrides are not
320
327
  committed from a checkout. On an installed package root, treat it as disposable
@@ -356,7 +363,7 @@ subagent({
356
363
  | `agent` | string | — | Load defaults from agent definition |
357
364
  | `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter |
358
365
  | `interactive` | boolean | derived | Mark this spawn as interactive (don't wake the parent on stall/recovery). Defaults to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit`. |
359
- | `model` | string | configured or parent | Exact authenticated `provider/model-id`; resolution is tool argument → agent frontmatter → per-agent config → global config → parent |
366
+ | `model` | string | configured or parent | Exact authenticated `provider/model-id`, or an ordered comma-separated Pi fallback list; unavailable for Claude CLI and worktree spawns. Resolution is tool argument → agent frontmatter → per-agent config → global config → parent |
360
367
  | `thinking` | string | parent level | Pi thinking level (`off` through `max`); omit to inherit the parent |
361
368
  | `systemPrompt` | string | — | Role/system-prompt text for a bare spawn; overrides the body for Claude CLI agents, while named Pi agents keep their definition body |
362
369
  | `resumeSessionId` | string | — | Claude CLI session ID to resume; separate from the Pi `subagent_resume` tool |
@@ -683,7 +690,7 @@ and verify them with `/subagent list` plus a smoke launch.
683
690
  | ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
684
691
  | `name` | string | Optional explicit agent name used in `agent: "my-agent"`; defaults to the filename stem and must match it in role packs |
685
692
  | `description` | string | Shown in `subagents_list` output |
686
- | `model` | string | Optional exact authenticated Pi model default; omit to use per-agent config, global config, then the parent |
693
+ | `model` | string | Optional exact authenticated Pi model default or ordered comma-separated fallback list; omit to use per-agent config, global config, then the parent |
687
694
  | `cli` | string | Set to `claude` to launch the Claude CLI instead of Pi |
688
695
  | `cli-model` | string | Optional model name passed to a Claude CLI agent; separate from Pi model routing |
689
696
  | `thinking` | string | Optional Pi thinking default (`off` through `max`); omit to inherit the parent. Thinking overrides are not supported for Claude CLI agents |
@@ -46,7 +46,7 @@ For a worktree launch:
46
46
  - Uncommitted and untracked files from the parent checkout are not copied. Commit anything the child must see before spawning it, or pass the needed context in the task.
47
47
  - Worktree creation does not steal terminal focus.
48
48
 
49
- `worktree` cannot be set in agent frontmatter and is not exposed by the `/subagent <agent> <task>` shorthand. It is selected per call to the `subagent` tool.
49
+ `worktree` cannot be set in agent frontmatter and is not exposed by the `/subagent <agent> <task>` shorthand. It is selected per call to the `subagent` tool. Ordered model fallback lists are not supported for worktree subagents: a failed attempt retains its worktree and branch for review, so a retry cannot safely reuse the requested branch.
50
50
 
51
51
  ## Parent and worker responsibilities
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-herdr-agents",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "description": "Asynchronous Pi subagents and approved review workflows in Herdr, with optional isolated Git worktrees",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -53,6 +53,7 @@ import {
53
53
  import {
54
54
  buildAuthenticatedModelCatalog,
55
55
  resolveRuntimePlan,
56
+ resolveRuntimePlans,
56
57
  wrapPiModelRegistry,
57
58
  THINKING_LEVELS,
58
59
  isThinkingLevel,
@@ -202,7 +203,7 @@ const SubagentParams = Type.Object({
202
203
  model: Type.Optional(
203
204
  Type.String({
204
205
  description:
205
- "Exact authenticated provider/model-id. Omit to inherit the parent model. Select another model only when task capability, speed, cost, modality, or context requirements warrant it.",
206
+ "Exact authenticated provider/model-id, or an ordered comma-separated fallback list. Omit to inherit the parent model. Fallbacks are Pi-backed only and cannot be used with worktrees.",
206
207
  }),
207
208
  ),
208
209
  thinking: Type.Optional(ThinkingLevelSchema),
@@ -1147,6 +1148,7 @@ function resolveResultPresentation(
1147
1148
  | "summary"
1148
1149
  | "sessionFile"
1149
1150
  | "errorMessage"
1151
+ | "fallbackAttempts"
1150
1152
  | "worktree"
1151
1153
  >,
1152
1154
  name: string,
@@ -1173,6 +1175,9 @@ function resolveResultPresentation(
1173
1175
  : `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}`;
1174
1176
  }
1175
1177
 
1178
+ if (result.fallbackAttempts && result.fallbackAttempts.length > 1) {
1179
+ body += `\n\nModels attempted: ${result.fallbackAttempts.join(", ")}`;
1180
+ }
1176
1181
  if (result.worktree) body += `\n\n${formatWorktreeHandoff(result.worktree)}`;
1177
1182
  const runtimeWarning = runtimeMismatch
1178
1183
  ? `\n\nRuntime warning: ${runtimeMismatch}`
@@ -1215,6 +1220,8 @@ interface SubagentResult {
1215
1220
  error?: string;
1216
1221
  /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */
1217
1222
  errorMessage?: string;
1223
+ /** Ordered models launched for this run, including failed fallback attempts. */
1224
+ fallbackAttempts?: string[];
1218
1225
  ping?: { name: string; message: string };
1219
1226
  worktree?: WorktreeHandoff;
1220
1227
  }
@@ -2045,10 +2052,10 @@ async function launchSubagent(
2045
2052
  };
2046
2053
  },
2047
2054
  parentThinking: ThinkingLevel,
2048
- options?: { surface?: string },
2055
+ options?: { surface?: string; runtimePlan?: ResolvedRuntimePlan; id?: string },
2049
2056
  ): Promise<RunningSubagent> {
2050
2057
  const startTime = Date.now();
2051
- const id = Math.random().toString(16).slice(2, 10);
2058
+ const id = options?.id ?? Math.random().toString(16).slice(2, 10);
2052
2059
 
2053
2060
  const agentDefs = params.agent
2054
2061
  ? loadAgentDefaults(params.agent, runtime.pi)
@@ -2064,19 +2071,21 @@ async function launchSubagent(
2064
2071
  requireClaudeAdapter(agentDefs?.cli);
2065
2072
  if (!ctx.model)
2066
2073
  throw new Error("Subagent launch requires a resolved parent model");
2067
- const runtimePlan = resolveRuntimePlan(
2068
- { model: params.model, thinking: params.thinking },
2069
- {
2070
- model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2071
- thinking: agentDefs?.thinking,
2072
- },
2073
- {
2074
- provider: ctx.model.provider,
2075
- modelId: ctx.model.id,
2076
- thinking: parentThinking,
2077
- },
2078
- wrapPiModelRegistry(ctx.modelRegistry),
2079
- );
2074
+ const runtimePlan =
2075
+ options?.runtimePlan ??
2076
+ resolveRuntimePlan(
2077
+ { model: params.model, thinking: params.thinking },
2078
+ {
2079
+ model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2080
+ thinking: agentDefs?.thinking,
2081
+ },
2082
+ {
2083
+ provider: ctx.model.provider,
2084
+ modelId: ctx.model.id,
2085
+ thinking: parentThinking,
2086
+ },
2087
+ wrapPiModelRegistry(ctx.modelRegistry),
2088
+ );
2080
2089
  const effectiveModel = runtimePlan.model;
2081
2090
  const effectiveTools = params.tools ?? agentDefs?.tools;
2082
2091
  const effectiveSkills = params.skills ?? agentDefs?.skills;
@@ -2484,6 +2493,67 @@ async function launchSubagent(
2484
2493
  * the summary from the session file, and closes ordinary panes. Worktree
2485
2494
  * workspaces are retained for parent review.
2486
2495
  */
2496
+ function resolveSubagentRuntimePlans(
2497
+ params: typeof SubagentParams.static,
2498
+ ctx: Parameters<typeof launchSubagent>[1],
2499
+ parentThinking: ThinkingLevel,
2500
+ ): ResolvedRuntimePlan[] {
2501
+ const agentDefs = params.agent
2502
+ ? loadAgentDefaults(params.agent, runtime.pi)
2503
+ : null;
2504
+ if (params.agent && !agentDefs) {
2505
+ const diagnostic = discoverAgentCatalog(runtime.pi).diagnostics.find(
2506
+ (candidate) => candidate.agentName === params.agent,
2507
+ );
2508
+ throw new Error(
2509
+ diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
2510
+ );
2511
+ }
2512
+ if (!ctx.model) throw new Error("Subagent launch requires a resolved parent model");
2513
+ const plans = resolveRuntimePlans(
2514
+ { model: params.model, thinking: params.thinking },
2515
+ {
2516
+ model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2517
+ thinking: agentDefs?.thinking,
2518
+ },
2519
+ {
2520
+ provider: ctx.model.provider,
2521
+ modelId: ctx.model.id,
2522
+ thinking: parentThinking,
2523
+ },
2524
+ wrapPiModelRegistry(ctx.modelRegistry),
2525
+ );
2526
+ if (agentDefs?.cli === "claude" && plans.length > 1) {
2527
+ throw new Error("Model fallbacks are supported only for Pi-backed subagents.");
2528
+ }
2529
+ if (params.worktree && plans.length > 1) {
2530
+ throw new Error("Model fallbacks are not supported for worktree subagents.");
2531
+ }
2532
+ return plans;
2533
+ }
2534
+
2535
+ async function launchSubagentWithFallbacks(
2536
+ params: typeof SubagentParams.static,
2537
+ ctx: Parameters<typeof launchSubagent>[1],
2538
+ parentThinking: ThinkingLevel,
2539
+ plans: ResolvedRuntimePlan[],
2540
+ ): Promise<{ running: RunningSubagent; index: number }> {
2541
+ const failures: string[] = [];
2542
+ for (const [index, plan] of plans.entries()) {
2543
+ try {
2544
+ return {
2545
+ running: await launchSubagent(params, ctx, parentThinking, { runtimePlan: plan }),
2546
+ index,
2547
+ };
2548
+ } catch (error) {
2549
+ failures.push(`${plan.model}: ${error instanceof Error ? error.message : String(error)}`);
2550
+ }
2551
+ }
2552
+ throw new Error(
2553
+ `Subagent could not launch with any configured model. Attempted: ${plans.map((plan) => plan.model).join(", ")}. ${failures.join("; ")}`,
2554
+ );
2555
+ }
2556
+
2487
2557
  async function watchSubagent(
2488
2558
  running: RunningSubagent,
2489
2559
  signal: AbortSignal,
@@ -2676,6 +2746,64 @@ async function watchSubagent(
2676
2746
  }
2677
2747
  }
2678
2748
 
2749
+ async function watchSubagentWithFallbacks(
2750
+ initial: RunningSubagent,
2751
+ initialPlanIndex: number,
2752
+ params: typeof SubagentParams.static,
2753
+ ctx: Parameters<typeof launchSubagent>[1],
2754
+ parentThinking: ThinkingLevel,
2755
+ plans: ResolvedRuntimePlan[],
2756
+ signal: AbortSignal,
2757
+ ): Promise<{ running: RunningSubagent; result: SubagentResult }> {
2758
+ let running = initial;
2759
+ let nextPlan = initialPlanIndex + 1;
2760
+ const attempts = [running.runtimePlan?.model].filter(
2761
+ (model): model is string => !!model,
2762
+ );
2763
+
2764
+ for (;;) {
2765
+ const result = await watchSubagent(running, signal);
2766
+ const shouldRetry = !!result.errorMessage && nextPlan < plans.length;
2767
+ if (!shouldRetry) {
2768
+ return { running, result: { ...result, fallbackAttempts: attempts } };
2769
+ }
2770
+
2771
+ runningSubagents.delete(running.id);
2772
+ updateWidget();
2773
+ const launchErrors: string[] = [];
2774
+ let launchedFallback = false;
2775
+ while (nextPlan < plans.length) {
2776
+ const plan = plans[nextPlan++];
2777
+ attempts.push(plan.model);
2778
+ try {
2779
+ running = await launchSubagent(params, ctx, parentThinking, {
2780
+ runtimePlan: plan,
2781
+ id: initial.id,
2782
+ });
2783
+ running.abortController = initial.abortController;
2784
+ launchedFallback = true;
2785
+ startWidgetRefresh();
2786
+ startStatusRefresh(runtime.pi!);
2787
+ break;
2788
+ } catch (error) {
2789
+ launchErrors.push(
2790
+ `${plan.model}: ${error instanceof Error ? error.message : String(error)}`,
2791
+ );
2792
+ }
2793
+ }
2794
+ if (!launchedFallback) {
2795
+ return {
2796
+ running,
2797
+ result: {
2798
+ ...result,
2799
+ errorMessage: `${result.errorMessage}\n\nFallback launch failures: ${launchErrors.join("; ")}`,
2800
+ fallbackAttempts: attempts,
2801
+ },
2802
+ };
2803
+ }
2804
+ }
2805
+ }
2806
+
2679
2807
  export default function subagentsExtension(pi: ExtensionAPI) {
2680
2808
  runtime.pi = pi;
2681
2809
  let btwChild: BtwChild | undefined;
@@ -3604,7 +3732,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3604
3732
  `Unsupported parent thinking level: ${parentThinking}`,
3605
3733
  );
3606
3734
  }
3607
- const running = await launchSubagent(params, ctx, parentThinking);
3735
+ const runtimePlans = resolveSubagentRuntimePlans(
3736
+ params,
3737
+ ctx,
3738
+ parentThinking,
3739
+ );
3740
+ const { running, index: initialPlanIndex } = await launchSubagentWithFallbacks(
3741
+ params,
3742
+ ctx,
3743
+ parentThinking,
3744
+ runtimePlans,
3745
+ );
3608
3746
 
3609
3747
  // Create a separate AbortController for the watcher
3610
3748
  // (the tool's signal completes when we return)
@@ -3616,16 +3754,24 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3616
3754
  startStatusRefresh(pi);
3617
3755
 
3618
3756
  // Fire-and-forget: start watching in background
3619
- watchSubagent(running, watcherAbort.signal)
3620
- .then((result) => {
3621
- if (!shouldDeliverSubagentCompletion(running)) {
3622
- running.lifecycle = markDelivery(running.lifecycle, "suppressed");
3623
- runningSubagents.delete(running.id);
3757
+ watchSubagentWithFallbacks(
3758
+ running,
3759
+ initialPlanIndex,
3760
+ params,
3761
+ ctx,
3762
+ parentThinking,
3763
+ runtimePlans,
3764
+ watcherAbort.signal,
3765
+ )
3766
+ .then(({ running: completedRunning, result }) => {
3767
+ if (!shouldDeliverSubagentCompletion(completedRunning)) {
3768
+ completedRunning.lifecycle = markDelivery(completedRunning.lifecycle, "suppressed");
3769
+ runningSubagents.delete(completedRunning.id);
3624
3770
  updateWidget();
3625
3771
  return;
3626
3772
  }
3627
- running.lifecycle = markDelivery(running.lifecycle, "delivered");
3628
- runningSubagents.delete(running.id);
3773
+ completedRunning.lifecycle = markDelivery(completedRunning.lifecycle, "delivered");
3774
+ runningSubagents.delete(completedRunning.id);
3629
3775
  updateWidget();
3630
3776
  const completionApi = selectCompletionApi(pi, runtime.pi);
3631
3777
 
@@ -3655,26 +3801,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
3655
3801
 
3656
3802
  const presentation = resolveResultPresentation(
3657
3803
  result,
3658
- running.name,
3659
- running.runtimePlan?.runtimeMismatch,
3804
+ completedRunning.name,
3805
+ completedRunning.runtimePlan?.runtimeMismatch,
3660
3806
  );
3661
3807
 
3662
3808
  sendSubagentResult(completionApi, presentation, {
3663
- name: running.name,
3664
- task: running.task,
3665
- agent: running.agent,
3809
+ name: completedRunning.name,
3810
+ task: completedRunning.task,
3811
+ agent: completedRunning.agent,
3666
3812
  exitCode: result.exitCode,
3667
3813
  elapsed: result.elapsed,
3668
3814
  sessionFile: result.sessionFile,
3669
3815
  ...(result.errorMessage
3670
3816
  ? { errorMessage: result.errorMessage }
3671
3817
  : {}),
3818
+ ...(result.fallbackAttempts
3819
+ ? { fallbackAttempts: result.fallbackAttempts }
3820
+ : {}),
3672
3821
  ...(result.claudeSessionId
3673
3822
  ? { claudeSessionId: result.claudeSessionId }
3674
3823
  : {}),
3675
3824
  ...(result.worktree ? { worktree: result.worktree } : {}),
3676
- ...(running.runtimePlan
3677
- ? { runtimePlan: running.runtimePlan }
3825
+ ...(completedRunning.runtimePlan
3826
+ ? { runtimePlan: completedRunning.runtimePlan }
3678
3827
  : {}),
3679
3828
  });
3680
3829
  })
@@ -190,6 +190,16 @@ function selectField(
190
190
  return { source: "parent" };
191
191
  }
192
192
 
193
+ export function parseModelFallbacks(reference: string): string[] {
194
+ const candidates = reference.split(",").map((candidate) => candidate.trim());
195
+ if (candidates.some((candidate) => candidate === "")) {
196
+ throw new RuntimeResolutionError(
197
+ `model fallback list ${JSON.stringify(reference)} cannot contain an empty candidate`,
198
+ );
199
+ }
200
+ return candidates;
201
+ }
202
+
193
203
  export function resolveRuntimePlan(
194
204
  request: RuntimeRequest,
195
205
  agentDefaults: RuntimeRequest,
@@ -272,6 +282,30 @@ export function resolveRuntimePlan(
272
282
  };
273
283
  }
274
284
 
285
+ /** Resolve every configured fallback before launching the first child. */
286
+ export function resolveRuntimePlans(
287
+ request: RuntimeRequest,
288
+ agentDefaults: RuntimeRequest,
289
+ parent: ParentRuntime,
290
+ registry: ModelRegistryAdapter,
291
+ ): ResolvedRuntimePlan[] {
292
+ const selection = selectField(request.model, agentDefaults.model);
293
+ if (!selection.value) {
294
+ return [resolveRuntimePlan(request, agentDefaults, parent, registry)];
295
+ }
296
+
297
+ return parseModelFallbacks(selection.value).map((model) =>
298
+ resolveRuntimePlan(
299
+ selection.source === "request" ? { ...request, model } : { ...request, model: undefined },
300
+ selection.source === "agent"
301
+ ? { ...agentDefaults, model }
302
+ : agentDefaults,
303
+ parent,
304
+ registry,
305
+ ),
306
+ );
307
+ }
308
+
275
309
  function formatTokenCount(value: number | undefined): string | undefined {
276
310
  if (!value || value <= 0) return undefined;
277
311
  if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}m`;