Commit 74e574d8a36f
Changed files (24)
docs
superpowers
dots
pi
agent
agents
extensions
docs/superpowers/plans/2026-09-11-pi-subagent-model-routing.md
@@ -0,0 +1,480 @@
+# Pi Subagent Model Routing Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Prefer the parent Pi session’s provider for subagents while selecting only explicitly allowed same-tier model fallbacks.
+
+**Architecture:** Extract deterministic routing from the subagent extension into a pure `model-routing.ts` module. The extension reads settings and catalog/credential state, calls the resolver with the parent provider, and only spawns Pi after a candidate is selected. Agent files request current model versions; same-tier historical versions live solely in settings fallback chains.
+
+**Tech Stack:** TypeScript extension modules, Bun’s `node:test` runner, Pi model registry, JSON settings, Markdown agent definitions.
+
+---
+
+## File structure
+
+| File | Responsibility |
+| --- | --- |
+| `dots/pi/agent/extensions/subagent/model-routing.ts` | Pure ordered candidate selection, exact model masking, catalog matching, and structured rejections. |
+| `dots/pi/agent/extensions/subagent/model-routing.test.ts` | Unit tests for provider priority, masks, same-tier fallbacks, credentials, and legacy behavior. |
+| `dots/pi/agent/extensions/subagent/index.ts` | Load and validate routing settings, collect model availability, invoke the resolver, and display failure diagnostics. |
+| `dots/pi/agent/settings.json` | Default provider fallback order plus explicit current-model fallback chains and Vertex masks. |
+| `dots/pi/agent/ensure-settings.sh` | Ensure new routing keys are merged into the runtime settings and print accurate configuration guidance. |
+| `dots/pi/agent/agents/*.md` | Pin each specialized agent to its current intended model tier. |
+| `dots/pi/agent/extensions/subagent/README.md` | Document active-provider-first resolution, masks, same-tier fallbacks, diagnostics, and current provider names. |
+
+This repository forbids commits unless the user explicitly asks. Do not add commit steps or create a commit while executing this plan.
+
+### Task 1: Define and test pure routing behavior
+
+**Files:**
+- Create: `dots/pi/agent/extensions/subagent/model-routing.ts`
+- Create: `dots/pi/agent/extensions/subagent/model-routing.test.ts`
+
+- [ ] **Step 1: Write failing tests for provider order and masks**
+
+Create `model-routing.test.ts` using Bun’s Node compatibility APIs. Define a shared catalog and credential set in the test file, then assert the parent provider wins and a provider-specific mask is skipped:
+
+```ts
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { resolveSubagentModel } from "./model-routing.ts";
+
+const catalog = [
+ { provider: "github-copilot", modelId: "claude-opus-5" },
+ { provider: "github-copilot", modelId: "claude-opus-4-8" },
+ { provider: "anthropic-vertex", modelId: "claude-opus-5" },
+ { provider: "anthropic-vertex", modelId: "claude-opus-4-8" },
+ { provider: "google", modelId: "claude-opus-4-8" },
+];
+const ready = new Set(catalog.map(({ provider, modelId }) => `${provider}\u0000${modelId}`));
+
+function resolve(overrides = {}) {
+ return resolveSubagentModel({
+ parentProvider: "github-copilot",
+ providerPreference: ["anthropic-vertex", "google"],
+ requestedModel: "claude-opus-5",
+ modelFallbacks: { "claude-opus-5": ["claude-opus-4-8"] },
+ providerModelMasks: {},
+ catalog,
+ ready,
+ ...overrides,
+ });
+}
+
+describe("resolveSubagentModel", () => {
+ it("prefers the parent provider for the requested model", () => {
+ assert.deepEqual(resolve(), {
+ selected: { provider: "github-copilot", modelId: "claude-opus-5" },
+ rejected: [],
+ });
+ });
+
+ it("skips a masked parent provider/model pair before selection", () => {
+ assert.deepEqual(
+ resolve({
+ providerModelMasks: { "github-copilot": ["claude-opus-5"] },
+ }),
+ {
+ selected: { provider: "github-copilot", modelId: "claude-opus-4-8" },
+ rejected: [{ provider: "github-copilot", modelId: "claude-opus-5", reason: "masked" }],
+ },
+ );
+ });
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: FAIL because `./model-routing.ts` does not exist.
+
+- [ ] **Step 3: Implement the minimal pure resolver**
+
+Create `model-routing.ts`. Keep it free of Pi SDK imports and I/O so tests can exercise it directly:
+
+```ts
+export type ModelEntry = { provider: string; modelId: string };
+export type RejectionReason = "masked" | "not-in-catalog" | "missing-credentials";
+export type Rejection = { provider: string; modelId: string; reason: RejectionReason };
+export type Resolution = {
+ selected: { provider: string; modelId: string } | null;
+ rejected: Rejection[];
+};
+
+export type ResolveSubagentModelOptions = {
+ parentProvider?: string;
+ providerPreference: string[];
+ requestedModel: string;
+ modelFallbacks: Record<string, string[]>;
+ providerModelMasks: Record<string, string[]>;
+ catalog: ModelEntry[];
+ ready: ReadonlySet<string>;
+};
+
+const key = (provider: string, modelId: string) => `${provider}\u0000${modelId}`;
+
+function orderedUnique(values: Array<string | undefined>): string[] {
+ return [...new Set(values.filter((value): value is string => Boolean(value)))];
+}
+
+function catalogModelId(catalog: ModelEntry[], provider: string, requested: string): string | undefined {
+ return catalog.find((entry) => entry.provider === provider && entry.modelId === requested)?.modelId
+ ?? catalog.find((entry) => entry.provider === provider && entry.modelId.startsWith(`${requested}@`))?.modelId;
+}
+
+export function resolveSubagentModel(options: ResolveSubagentModelOptions): Resolution {
+ const providers = orderedUnique([options.parentProvider, ...options.providerPreference]);
+ const models = orderedUnique([options.requestedModel, ...(options.modelFallbacks[options.requestedModel] ?? [])]);
+ const rejected: Rejection[] = [];
+
+ for (const provider of providers) {
+ const masked = new Set(options.providerModelMasks[provider] ?? []);
+ for (const requestedModel of models) {
+ if (masked.has(requestedModel)) {
+ rejected.push({ provider, modelId: requestedModel, reason: "masked" });
+ continue;
+ }
+ const modelId = catalogModelId(options.catalog, provider, requestedModel);
+ if (!modelId) {
+ rejected.push({ provider, modelId: requestedModel, reason: "not-in-catalog" });
+ continue;
+ }
+ if (!options.ready.has(key(provider, modelId))) {
+ rejected.push({ provider, modelId, reason: "missing-credentials" });
+ continue;
+ }
+ return { selected: { provider, modelId }, rejected };
+ }
+ }
+ return { selected: null, rejected };
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: PASS, with both tests green.
+
+### Task 2: Cover all approved routing constraints with tests
+
+**Files:**
+- Modify: `dots/pi/agent/extensions/subagent/model-routing.test.ts`
+
+- [ ] **Step 1: Add failing fallback, provider fallback, credential, and no-cross-tier tests**
+
+Append these cases within the existing `describe` block:
+
+```ts
+it("uses an allowed same-tier fallback on the parent provider", () => {
+ const result = resolve({
+ providerModelMasks: { "github-copilot": ["claude-opus-5"] },
+ });
+ assert.deepEqual(result.selected, { provider: "github-copilot", modelId: "claude-opus-4-8" });
+});
+
+it("moves to configured providers after parent candidates are unavailable", () => {
+ const result = resolve({
+ catalog: [{ provider: "anthropic-vertex", modelId: "claude-opus-4-8" }],
+ ready: new Set(["anthropic-vertex\u0000claude-opus-4-8"]),
+ });
+ assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-4-8" });
+});
+
+it("does not invent a cross-tier fallback", () => {
+ const result = resolve({
+ catalog: [{ provider: "github-copilot", modelId: "claude-sonnet-5" }],
+ ready: new Set(["github-copilot\u0000claude-sonnet-5"]),
+ });
+ assert.equal(result.selected, null);
+ assert.ok(result.rejected.every((entry) => entry.modelId.startsWith("claude-opus-")));
+});
+
+it("reports catalog and credential rejection reasons", () => {
+ const result = resolve({
+ catalog: [{ provider: "github-copilot", modelId: "claude-opus-5" }],
+ ready: new Set(),
+ });
+ assert.deepEqual(result.selected, null);
+ assert.ok(result.rejected.some((entry) => entry.reason === "missing-credentials"));
+ assert.ok(result.rejected.some((entry) => entry.reason === "not-in-catalog"));
+});
+
+it("retains static preference behavior when no parent provider or new settings exist", () => {
+ const result = resolve({
+ parentProvider: undefined,
+ providerPreference: ["anthropic-vertex", "github-copilot"],
+ modelFallbacks: {},
+ providerModelMasks: {},
+ });
+ assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-5" });
+});
+```
+
+- [ ] **Step 2: Run the expanded suite to verify the expected failure**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: the legacy-behavior test fails because the helper’s default `parentProvider` overrides `undefined`; correct the helper to use explicit complete argument objects rather than spreading defaults over an intentional `undefined` value.
+
+- [ ] **Step 3: Correct the test helper and resolver only as needed**
+
+Replace the helper with a base object that callers can explicitly override without an `undefined` value being lost:
+
+```ts
+const baseOptions = {
+ parentProvider: "github-copilot",
+ providerPreference: ["anthropic-vertex", "google"],
+ requestedModel: "claude-opus-5",
+ modelFallbacks: { "claude-opus-5": ["claude-opus-4-8"] },
+ providerModelMasks: {},
+ catalog,
+ ready,
+};
+
+function resolve(overrides: Partial<typeof baseOptions> = {}) {
+ return resolveSubagentModel({ ...baseOptions, ...overrides });
+}
+```
+
+For the legacy test, call `resolveSubagentModel` directly with `parentProvider: undefined`; do not change production logic to accommodate a test-helper limitation.
+
+- [ ] **Step 4: Run the expanded suite**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: PASS for all seven behavior cases.
+
+### Task 3: Integrate routing and pre-spawn diagnostics into the extension
+
+**Files:**
+- Modify: `dots/pi/agent/extensions/subagent/index.ts:13-80,265-425,618-850`
+
+- [ ] **Step 1: Add an integration test seam before changing spawning behavior**
+
+In `model-routing.test.ts`, add a test for Pi’s dated model aliases so the existing catalog behavior remains supported:
+
+```ts
+it("selects a dated catalog model for an undated requested ID", () => {
+ const result = resolveSubagentModel({
+ parentProvider: "anthropic-vertex",
+ providerPreference: [],
+ requestedModel: "claude-haiku-4-5",
+ modelFallbacks: {},
+ providerModelMasks: {},
+ catalog: [{ provider: "anthropic-vertex", modelId: "claude-haiku-4-5@20251001" }],
+ ready: new Set(["anthropic-vertex\u0000claude-haiku-4-5@20251001"]),
+ });
+ assert.deepEqual(result.selected, {
+ provider: "anthropic-vertex",
+ modelId: "claude-haiku-4-5@20251001",
+ });
+});
+```
+
+- [ ] **Step 2: Run the unit suite before integration**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts
+```
+
+Expected: PASS; the pure resolver already supports the dated alias form.
+
+- [ ] **Step 3: Replace the old resolver with an adapter around `resolveSubagentModel`**
+
+In `index.ts`:
+
+1. Add `import { resolveSubagentModel, type Rejection } from "./model-routing.ts";`.
+2. Delete `findModelAcrossProviders` entirely.
+3. Add these configuration types and helpers near `ModelCacheEntry`:
+
+```ts
+type SubagentRoutingSettings = {
+ providerPreference: string[];
+ modelFallbacks: Record<string, string[]>;
+ providerModelMasks: Record<string, string[]>;
+};
+
+function stringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+}
+
+function stringArrayMap(value: unknown): Record<string, string[]> {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+ return Object.fromEntries(Object.entries(value).map(([name, models]) => [name, stringArray(models)]));
+}
+
+function loadRoutingSettings(flagValue: string | undefined): SubagentRoutingSettings {
+ const defaults: SubagentRoutingSettings = {
+ providerPreference: flagValue?.split(",").map((item) => item.trim()).filter(Boolean) ?? DEFAULT_PROVIDER_PREFERENCE,
+ modelFallbacks: {},
+ providerModelMasks: {},
+ };
+ try {
+ const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".pi", "agent", "settings.json"), "utf-8"));
+ return {
+ providerPreference: flagValue ? defaults.providerPreference : stringArray(settings.subagentProviderPreference),
+ modelFallbacks: stringArrayMap(settings.subagentModelFallbacks),
+ providerModelMasks: stringArrayMap(settings.subagentProviderModelMasks),
+ };
+ } catch {
+ return defaults;
+ }
+}
+
+function formatRoutingFailure(requestedModel: string, rejected: Rejection[]): string {
+ const lines = [`No usable subagent model for ${requestedModel}.`];
+ for (const entry of rejected) lines.push(`- ${entry.provider}/${entry.modelId}: ${entry.reason}`);
+ return lines.join("\n");
+}
+```
+
+4. Change `runSingleAgent` to accept `parentProvider: string | undefined` and `routing: SubagentRoutingSettings` instead of `providerPreference`.
+5. Before appending `--provider`/`--model`, build a credential-ready set by checking every catalog entry with `await modelRegistry.getApiKeyAndHeaders(model)`. Call `resolveSubagentModel` with `parentProvider`, `routing`, `modelCache ?? []`, and the ready set.
+6. When no pair is selected, return a `SingleResult` with `exitCode: 1`, no messages, and `stderr: formatRoutingFailure(agent.model, result.rejected)`. Do not append bare `--model`; this is the required no-spawn behavior.
+7. When selected, append the resolved `--provider` and `--model` arguments exactly as today.
+8. At every `runSingleAgent` call site, pass `ctx.model?.provider` and one `routing` object loaded once at the beginning of `execute` with `loadRoutingSettings(pi.getFlag("--subagent-providers") as string | undefined)`.
+
+- [ ] **Step 4: Run type/transpile and routing tests**
+
+Run:
+
+```bash
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts && bun --check index.ts
+```
+
+Expected: tests PASS and Bun reports no syntax errors.
+
+### Task 4: Configure current agent tiers and supported Vertex fallback behavior
+
+**Files:**
+- Modify: `dots/pi/agent/settings.json:3-27`
+- Modify: `dots/pi/agent/ensure-settings.sh:10-61`
+- Modify: `dots/pi/agent/agents/github-triage-analyzer.md:1-6`
+- Modify: `dots/pi/agent/agents/github-triage-deep.md:1-6`
+- Modify: `dots/pi/agent/agents/oracle.md:1-6`
+- Modify: `dots/pi/agent/agents/planner.md:1-6`
+- Modify: `dots/pi/agent/agents/researcher.md:1-6`
+- Modify: `dots/pi/agent/agents/reviewer*.md:1-6`
+- Modify: `dots/pi/agent/agents/worker.md:1-5`
+
+- [ ] **Step 1: Update settings and merge defaults**
+
+Add these keys immediately after `subagentProviderPreference` in both the JSON template and the shell script’s `REQUIRED_SETTINGS` JSON:
+
+```json
+"subagentModelFallbacks": {
+ "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"],
+ "claude-sonnet-5": ["claude-sonnet-4-6", "claude-sonnet-4-5"],
+ "claude-haiku-4-5": []
+},
+"subagentProviderModelMasks": {
+ "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+},
+```
+
+Keep `subagentProviderPreference` as the static fallback list. The runtime parent provider is prepended by the extension, not stored in JSON. Update `ensure-settings.sh` output to print the two new keys and replace the stale `google-vertex-claude` example with `anthropic-vertex`.
+
+- [ ] **Step 2: Refresh model pins in agent definitions**
+
+Make only frontmatter model changes:
+
+```text
+claude-sonnet-5: github-triage-analyzer, oracle, planner, researcher, worker
+claude-opus-5: github-triage-deep and every reviewer*.md file
+claude-haiku-4-5: scout.md (unchanged)
+```
+
+Do not alter agent prompts, tool lists, or descriptions.
+
+- [ ] **Step 3: Validate configuration and pins against the catalog**
+
+Run:
+
+```bash
+jq empty dots/pi/agent/settings.json
+bash -n dots/pi/agent/ensure-settings.sh
+pi --list-models | rg 'anthropic-vertex +(claude-opus-5|claude-opus-4-8|claude-opus-4-7|claude-opus-4-6|claude-sonnet-5|claude-sonnet-4-6|claude-sonnet-4-5|claude-haiku-4-5)'
+rg '^model:' dots/pi/agent/agents
+```
+
+Expected: JSON and shell syntax are valid; all configured requested/fallback models appear for `anthropic-vertex`; agent pins are only Sonnet 5, Opus 5, or Haiku 4.5.
+
+### Task 5: Document runtime behavior and configuration
+
+**Files:**
+- Modify: `dots/pi/agent/extensions/subagent/README.md:1-258`
+- Modify: `dots/pi/agent/extensions/subagent/index.ts:1195-1278`
+
+- [ ] **Step 1: Update the README’s model-resolution and configuration sections**
+
+Replace the current static-preference description with these guarantees:
+
+```markdown
+1. The dispatching session's active provider is tried first.
+2. `subagentProviderPreference` supplies ordered fallback providers.
+3. For every provider, the requested model is tried before only its explicitly configured fallback IDs.
+4. `subagentProviderModelMasks` skips known-unavailable exact provider/model pairs before a child process is started.
+5. No compatible candidate produces a diagnostic; the extension does not spawn Pi or retry a failed task.
+```
+
+Use `anthropic-vertex` throughout. Include the exact default `subagentModelFallbacks` and `subagentProviderModelMasks` examples from Task 4 and explain that masks are provider-specific and must be updated after Vertex entitlement changes. Replace stale 4.5/4.6 examples with the current agent policy: scout/Haiku 4.5, standard work/Sonnet 5, deep work/Opus 5.
+
+- [ ] **Step 2: Update `/subagent-config` output**
+
+Extend the command output after “Provider Preference” to show:
+
+```ts
+lines.push("Model Fallbacks:");
+for (const [model, fallbacks] of Object.entries(routing.modelFallbacks)) {
+ lines.push(` ${model}: ${fallbacks.length ? fallbacks.join(" → ") : "none"}`);
+}
+lines.push("");
+lines.push("Provider/Model Masks:");
+for (const [provider, models] of Object.entries(routing.providerModelMasks)) {
+ lines.push(` ${provider}: ${models.length ? models.join(", ") : "none"}`);
+}
+```
+
+Load `routing` via the same `loadRoutingSettings` helper used by the tool. Update the example resolution wording to say the current session provider is considered first, rather than claiming the first configured provider will be selected.
+
+- [ ] **Step 3: Verify documentation references and working tree quality**
+
+Run:
+
+```bash
+rg -n 'google-vertex-claude|claude-sonnet-4-5@20250929|claude-opus-4-6' dots/pi/agent/extensions/subagent/README.md dots/pi/agent/ensure-settings.sh
+cd dots/pi/agent/extensions/subagent && bun test model-routing.test.ts && bun --check index.ts
+git diff --check
+git status --short
+```
+
+Expected: stale provider/model references are absent from the README and settings script; tests and syntax checks pass; `git diff --check` is clean; no commit is created.
+
+## Final verification checklist
+
+- [ ] `bun test dots/pi/agent/extensions/subagent/model-routing.test.ts` passes from the repository root.
+- [ ] `bun --check dots/pi/agent/extensions/subagent/index.ts` reports no syntax error.
+- [ ] `jq empty dots/pi/agent/settings.json` and `bash -n dots/pi/agent/ensure-settings.sh` pass.
+- [ ] `pi --list-models` confirms every default requested/fallback model is catalog-visible.
+- [ ] The configured Vertex mask causes `claude-opus-5` to select its first catalog-visible, credential-ready Opus fallback on Vertex, while the same model remains eligible on GitHub Copilot.
+- [ ] `git diff --check` passes and the working tree has no unrequested changes.
docs/superpowers/specs/2026-09-11-pi-subagent-model-routing-design.md
@@ -0,0 +1,116 @@
+# Pi subagent model routing design
+
+## Goal
+
+Make Pi subagent selection prefer the active session's provider while retaining a deterministic, configurable fallback path. Operators must be able to prevent routing a model to a provider where it is known to be unavailable, such as a model absent from a Vertex AI project entitlement.
+
+## Scope
+
+This applies to the subagent extension at `dots/pi/agent/extensions/subagent/` and the user-level agent definitions in `dots/pi/agent/agents/`.
+
+It does not probe provider entitlements at runtime or retry failed task executions. A model/provider pair is selected or rejected before a child Pi process is spawned.
+
+## Configuration
+
+Retain `subagentProviderPreference` as the ordered fallback provider list. Add two settings:
+
+```json
+{
+ "subagentModelFallbacks": {
+ "claude-opus-5": [
+ "claude-opus-4-8",
+ "claude-opus-4-7",
+ "claude-opus-4-6"
+ ],
+ "claude-sonnet-5": [
+ "claude-sonnet-4-6",
+ "claude-sonnet-4-5"
+ ],
+ "claude-haiku-4-5": []
+ },
+ "subagentProviderModelMasks": {
+ "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+ }
+}
+```
+
+- `subagentModelFallbacks` maps a requested model ID to an ordered list of permitted fallback model IDs.
+- Every fallback is explicitly configured and stays within the same model tier. In particular, Opus must never silently fall back to Sonnet or Haiku; Sonnet must never silently fall back to Haiku.
+- `subagentProviderModelMasks` maps a provider to exact model IDs that must not be routed there. A mask applies only to the named provider. It does not prevent selecting that model from GitHub Copilot or another provider.
+- Settings absent from an existing configuration preserve current behavior: configured provider order, requested model only, and no masks.
+
+## Resolution algorithm
+
+For each subagent invocation:
+
+1. Start provider candidates with the active parent session provider.
+2. Append `subagentProviderPreference`, removing duplicate providers while preserving order.
+3. Build model candidates from the requested agent model followed by its explicit configured fallback list. If the agent has no model, preserve inheritance from the parent session.
+4. Evaluate the Cartesian order by provider first, model second:
+ - active provider/requested model;
+ - active provider/fallback models in order;
+ - first fallback provider/requested model;
+ - that provider/fallback models in order; and so on.
+5. Skip a pair if the exact model is masked for that provider.
+6. For remaining pairs, select the first model known in Pi's catalog for the provider and usable with the provider's configured credentials.
+7. Spawn the child Pi process only once a candidate has been selected.
+
+This allows a GitHub Copilot parent session to use its own compatible model first, while a Vertex-backed session can deliberately select a known-enabled earlier same-tier release rather than attempting a masked model.
+
+## Failure diagnostics
+
+If no pair can be selected, do not spawn a child process. Return a diagnostic that lists candidate pairs and why each was rejected:
+
+- masked by configuration;
+- absent from Pi's model catalog for that provider;
+- catalog-present but missing provider credentials.
+
+An invocation failure after a child has started is a task/provider execution error and is not retried by this feature. Entitlement changes are handled by updating the masks and fallback lists deliberately.
+
+## Agent definitions
+
+Refresh stale model pins:
+
+- Planning, general implementation, and standard analysis agents use `claude-sonnet-5`.
+- Deep analysis and review agents use `claude-opus-5`.
+- The scout remains on `claude-haiku-4-5` for inexpensive reconnaissance.
+- Older versions belong in `subagentModelFallbacks`, rather than being pinned in individual definitions.
+
+## Implementation structure
+
+Extract routing into a small pure resolver that accepts:
+
+- parent provider;
+- configured provider preference;
+- requested model;
+- fallback map;
+- provider/model mask map;
+- catalog entries; and
+- credential availability.
+
+It returns either a selected `{ provider, modelId }` pair or structured rejection information for UI diagnostics. The extension remains responsible for loading settings/catalog data and spawning the selected child process.
+
+## Documentation
+
+Update the subagent README, settings template/merge script, and `/subagent-config` output to describe:
+
+- active-provider-first routing;
+- fallback providers;
+- model-family fallback chains;
+- provider-specific masks;
+- current provider naming (`anthropic-vertex`); and
+- how to update masks when Vertex model access changes.
+
+## Validation
+
+Add focused resolver tests for:
+
+1. The active provider winning when it offers the requested unmasked model.
+2. A masked active-provider pair being skipped before spawn.
+3. Selection of a same-tier fallback on the active provider.
+4. Selection from the configured provider fallback list when no allowed candidate exists on the active provider.
+5. No cross-tier fallback from Opus to Sonnet/Haiku or Sonnet to Haiku.
+6. Distinct structured rejection reasons for mask, catalog absence, and unavailable credentials.
+7. Existing settings with no new keys retaining current static-order/requested-model behavior.
+
+Also parse the settings JSON and ensure all model IDs in updated agent definitions and default fallback settings are present in the current Pi catalog.
dots/pi/agent/agents/github-triage-analyzer.md
@@ -2,7 +2,7 @@
name: github-triage-analyzer
description: Investigates a single GitHub issue or PR against its codebase. Returns structured classification and findings.
tools: read, bash, web_search
-model: claude-sonnet-4-6
+model: claude-sonnet-5
---
You are a GitHub issue/PR investigator. You receive a single issue or PR and investigate it against the codebase to produce structured findings.
dots/pi/agent/agents/github-triage-deep.md
@@ -2,7 +2,7 @@
name: github-triage-deep
description: Deep investigation of a single GitHub issue or PR. Traces code paths, forms root cause hypotheses, suggests fixes.
tools: read, bash, web_search
-model: claude-opus-4
+model: claude-opus-5
---
You are a GitHub issue/PR deep investigator. You receive a single issue or PR and perform thorough code analysis to determine root cause and suggest fixes.
dots/pi/agent/agents/oracle.md
@@ -2,7 +2,7 @@
name: oracle
description: Deep analysis, debugging, and architecture decisions
tools: read, grep, find, ls, bash
-model: claude-sonnet-4-6
+model: claude-sonnet-5
---
You are a deep analysis specialist. You investigate complex problems, debug tricky issues, and provide architecture guidance.
dots/pi/agent/agents/planner.md
@@ -2,7 +2,7 @@
name: planner
description: Creates implementation plans from context and requirements
tools: read, grep, find, ls
-model: claude-sonnet-4-6
+model: claude-sonnet-5
---
You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan.
dots/pi/agent/agents/researcher.md
@@ -2,7 +2,7 @@
name: researcher
description: Deep research on technical topics, frameworks, and integrations
tools: read, bash, web_search, github_search, stack_overflow_search
-model: claude-sonnet-4
+model: claude-sonnet-5
---
You are a technical researcher. Your job is to conduct thorough research on technical topics, frameworks, integrations, and architectural patterns.
dots/pi/agent/agents/reviewer-ghactions.md
@@ -2,7 +2,7 @@
name: reviewer-ghactions
description: GitHub Actions workflow review for correctness, efficiency, and best practices
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a GitHub Actions workflow reviewer. Your job is to find correctness issues, inefficiencies, and anti-patterns in GitHub Actions workflows and composite actions.
dots/pi/agent/agents/reviewer-go.md
@@ -2,7 +2,7 @@
name: reviewer-go
description: Go-focused code review for idioms, error handling, concurrency, and performance patterns
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a Go-focused code reviewer. Your job is to find Go anti-patterns, concurrency bugs, error handling gaps, and idiomatic issues.
dots/pi/agent/agents/reviewer-k8s.md
@@ -2,7 +2,7 @@
name: reviewer-k8s
description: Kubernetes-focused code review for manifests, RBAC, resource management, and operational correctness
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a Kubernetes-focused code reviewer. Your job is to find operational issues, misconfigurations, and anti-patterns in Kubernetes manifests and Go code that interacts with the Kubernetes API.
dots/pi/agent/agents/reviewer-nix.md
@@ -2,7 +2,7 @@
name: reviewer-nix
description: Nix-focused code review for idioms, module patterns, eval cost, and reproducibility
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a Nix-focused code reviewer. Your job is to find Nix anti-patterns, module design issues, evaluation performance problems, and reproducibility gaps.
dots/pi/agent/agents/reviewer-performance.md
@@ -2,7 +2,7 @@
name: reviewer-performance
description: Performance-focused code review for complexity, allocations, caching, and concurrency
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a performance-focused code reviewer. Your job is to find performance regressions, inefficiencies, and scalability issues.
dots/pi/agent/agents/reviewer-python.md
@@ -2,7 +2,7 @@
name: reviewer-python
description: Python-focused code review for type safety, error handling, packaging, and anti-patterns
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a Python-focused code reviewer. Your job is to find Python anti-patterns, type safety issues, error handling gaps, and packaging problems.
dots/pi/agent/agents/reviewer-security.md
@@ -2,7 +2,7 @@
name: reviewer-security
description: Security-focused code review for vulnerabilities, injection, auth, and secrets
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a security-focused code reviewer. Your job is to find vulnerabilities, injection risks, authentication flaws, and secret exposure.
dots/pi/agent/agents/reviewer-shell.md
@@ -2,7 +2,7 @@
name: reviewer-shell
description: Shell script review for robustness, portability, quoting, and error handling
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a shell script reviewer. Your job is to find robustness issues, quoting bugs, error handling gaps, and portability problems in Bash and POSIX shell scripts.
dots/pi/agent/agents/reviewer-tekton.md
@@ -2,7 +2,7 @@
name: reviewer-tekton
description: Tekton-focused code review for pipeline/task design, parameter handling, workspace patterns, and API correctness
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a Tekton-focused code reviewer. Your job is to find issues in Tekton Pipeline YAML definitions, Go code that implements Tekton controllers/APIs, and Tekton usage patterns.
dots/pi/agent/agents/reviewer.md
@@ -2,7 +2,7 @@
name: reviewer
description: General code review for bugs, logic errors, maintainability, and code smells
tools: read, grep, find, ls, bash
-model: claude-opus-4-6
+model: claude-opus-5
---
You are a senior code reviewer focused on **general code quality**. Your job is to find bugs, logic errors, maintainability issues, and code smells.
dots/pi/agent/agents/worker.md
@@ -1,7 +1,7 @@
---
name: worker
description: General-purpose subagent with full capabilities, isolated context
-model: claude-sonnet-4-6
+model: claude-sonnet-5
---
You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation.
dots/pi/agent/extensions/subagent/index.ts
@@ -23,6 +23,7 @@ import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-a
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
import { Type } from "@sinclair/typebox";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
+import { resolveSubagentModel, type Rejection } from "./model-routing.ts";
const MAX_PARALLEL_TASKS = 16;
const MAX_CONCURRENCY = 8;
@@ -38,6 +39,13 @@ interface ModelCacheEntry {
provider: string;
modelId: string;
}
+
+type SubagentRoutingSettings = {
+ providerPreference: string[];
+ modelFallbacks: Record<string, string[]>;
+ providerModelMasks: Record<string, string[]>;
+};
+
let modelCache: ModelCacheEntry[] | null = null;
/**
@@ -258,119 +266,49 @@ async function mapWithConcurrencyLimit<TIn, TOut>(
return results;
}
-/**
- * Find a model by ID across all providers, preferring those with API keys
- * Supports fuzzy matching: "claude-haiku-4-5" will match "claude-haiku-4-5@20251001"
- *
- * @param modelId - The model ID to search for (e.g., "claude-sonnet-4-5", "claude-haiku-4-5@20251001")
- * @param modelRegistry - The model registry from context
- * @param preferredProviders - Ordered list of preferred providers (empty = all providers)
- * @returns { provider, model } or null if not found
- */
-async function findModelAcrossProviders(
- modelId: string,
- modelRegistry: any,
- preferredProviders: string[],
-): Promise<{ provider: string; modelId: string } | null> {
- // Known providers - expand this list as needed
- const knownProviders = [
- "anthropic",
- "openai",
- "google",
- "google-vertex",
- "google-vertex-claude",
- "vertex",
- "llama-cpp",
- "openrouter",
- "groq",
- "xai",
- "deepseek",
- "copilot",
- "codex",
- ];
-
- // If no preference list, use all known providers
- const providersToTry = preferredProviders.length > 0 ? preferredProviders : knownProviders;
+function stringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+}
- /**
- * Try to find a model on a provider, with fuzzy matching support
- * Returns the actual model ID if found (which may include version suffix)
- */
- const tryFindModel = async (providerName: string, requestedId: string): Promise<string | null> => {
- // Try exact match first
- let model = modelRegistry.find(providerName, requestedId);
- if (model) return requestedId;
-
- // Use model cache for fuzzy matching
- if (modelCache && modelCache.length > 0) {
- // Find models on this provider that match the requested ID
- for (const entry of modelCache) {
- if (entry.provider === providerName) {
- // Match if exact or if model ID starts with requested ID followed by @
- if (entry.modelId === requestedId || entry.modelId.startsWith(requestedId + "@")) {
- // Verify it actually exists (double-check with registry)
- const verifyModel = modelRegistry.find(providerName, entry.modelId);
- if (verifyModel) {
- return entry.modelId;
- }
- }
- }
- }
- }
-
- return null;
+function stringArrayMap(value: unknown): Record<string, string[]> {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+ return Object.fromEntries(Object.entries(value).map(([name, models]) => [name, stringArray(models)]));
+}
+
+function loadRoutingSettings(flagValue: string | undefined): SubagentRoutingSettings {
+ const defaults: SubagentRoutingSettings = {
+ providerPreference: flagValue?.split(",").map((item) => item.trim()).filter(Boolean) ?? DEFAULT_PROVIDER_PREFERENCE,
+ modelFallbacks: {},
+ providerModelMasks: {},
};
- // First pass: try preferred providers with API keys (exact or fuzzy match)
- for (const providerName of providersToTry) {
- const foundModelId = await tryFindModel(providerName, modelId);
- if (foundModelId) {
- const model = modelRegistry.find(providerName, foundModelId);
- if (model) {
- const auth = await modelRegistry.getApiKeyAndHeaders(model);
- if (auth.ok && auth.apiKey) {
- return { provider: providerName, modelId: foundModelId };
- }
- }
- }
+ try {
+ const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".pi", "agent", "settings.json"), "utf-8"));
+ return {
+ providerPreference: flagValue ? defaults.providerPreference : stringArray(settings.subagentProviderPreference),
+ modelFallbacks: stringArrayMap(settings.subagentModelFallbacks),
+ providerModelMasks: stringArrayMap(settings.subagentProviderModelMasks),
+ };
+ } catch {
+ return defaults;
}
+}
- // Second pass: try other known providers with API keys (not in preference list)
- if (preferredProviders.length > 0) {
- for (const providerName of knownProviders) {
- if (preferredProviders.includes(providerName)) continue;
- const foundModelId = await tryFindModel(providerName, modelId);
- if (foundModelId) {
- const model = modelRegistry.find(providerName, foundModelId);
- if (model) {
- const auth = await modelRegistry.getApiKeyAndHeaders(model);
- if (auth.ok && auth.apiKey) {
- return { provider: providerName, modelId: foundModelId };
- }
- }
- }
- }
+function formatRoutingFailure(requestedModel: string, rejected: Rejection[]): string {
+ const lines = [`No usable subagent model for ${requestedModel}.`];
+ for (const entry of rejected) lines.push(`- ${entry.provider}/${entry.modelId}: ${entry.reason}`);
+ return lines.join("\n");
+}
+
+async function credentialReadyModels(modelRegistry: any): Promise<Set<string>> {
+ const ready = new Set<string>();
+ for (const entry of modelCache ?? []) {
+ const model = modelRegistry.find(entry.provider, entry.modelId);
+ if (!model) continue;
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
+ if (auth.ok && auth.apiKey) ready.add(`${entry.provider}\u0000${entry.modelId}`);
}
-
- // Third pass: try preferred providers without API keys (will fail but with clearer error)
- for (const providerName of providersToTry) {
- const foundModelId = await tryFindModel(providerName, modelId);
- if (foundModelId) {
- return { provider: providerName, modelId: foundModelId };
- }
- }
-
- // Last resort: try any known provider (if we had a preference list)
- if (preferredProviders.length > 0) {
- for (const providerName of knownProviders) {
- const foundModelId = await tryFindModel(providerName, modelId);
- if (foundModelId) {
- return { provider: providerName, modelId: foundModelId };
- }
- }
- }
-
- return null;
+ return ready;
}
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
@@ -394,7 +332,8 @@ async function runSingleAgent(
onUpdate: OnUpdateCallback | undefined,
makeDetails: (results: SingleResult[]) => SubagentDetails,
modelRegistry: any,
- providerPreference: string[],
+ parentProvider: string | undefined,
+ routing: SubagentRoutingSettings,
): Promise<SingleResult> {
const agent = agents.find((a) => a.name === agentName);
@@ -413,18 +352,32 @@ async function runSingleAgent(
const args: string[] = ["--mode", "json", "-p", "--no-session"];
- // Resolve model across providers
if (agent.model) {
- const resolved = await findModelAcrossProviders(agent.model, modelRegistry, providerPreference);
- if (resolved) {
- args.push("--provider", resolved.provider);
- args.push("--model", resolved.modelId);
- } else {
- // Fallback to just passing model ID (will likely fail but with pi's error message)
- args.push("--model", agent.model);
+ const resolution = resolveSubagentModel({
+ parentProvider,
+ providerPreference: routing.providerPreference,
+ requestedModel: agent.model,
+ modelFallbacks: routing.modelFallbacks,
+ providerModelMasks: routing.providerModelMasks,
+ catalog: modelCache ?? [],
+ ready: await credentialReadyModels(modelRegistry),
+ });
+ if (!resolution.selected) {
+ return {
+ agent: agentName,
+ agentSource: agent.source,
+ task,
+ exitCode: 1,
+ messages: [],
+ stderr: formatRoutingFailure(agent.model, resolution.rejected),
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
+ step,
+ };
}
+ args.push("--provider", resolution.selected.provider);
+ args.push("--model", resolution.selected.modelId);
}
-
+
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
let tmpPromptDir: string | null = null;
@@ -617,28 +570,7 @@ export default function (pi: ExtensionAPI) {
const agents = discovery.agents;
const confirmProjectAgents = params.confirmProjectAgents ?? true;
- // Get provider preference from (in order):
- // 1. --subagent-providers flag
- // 2. settings.json "subagentProviderPreference"
- // 3. DEFAULT_PROVIDER_PREFERENCE (empty = all providers)
- let providerPreference = DEFAULT_PROVIDER_PREFERENCE;
- const flagValue = pi.getFlag("--subagent-providers") as string;
- if (flagValue) {
- providerPreference = flagValue.split(",").map(p => p.trim()).filter(Boolean);
- } else {
- // Try to load from settings.json
- try {
- const settingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
- if (fs.existsSync(settingsPath)) {
- const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
- if (Array.isArray(settings.subagentProviderPreference)) {
- providerPreference = settings.subagentProviderPreference;
- }
- }
- } catch {
- // Fall back to default
- }
- }
+ const routing = loadRoutingSettings(pi.getFlag("--subagent-providers") as string | undefined);
const hasChain = (params.chain?.length ?? 0) > 0;
const hasTasks = (params.tasks?.length ?? 0) > 0;
@@ -726,7 +658,8 @@ export default function (pi: ExtensionAPI) {
chainUpdate,
makeDetails("chain"),
ctx.modelRegistry,
- providerPreference,
+ ctx.model?.provider,
+ routing,
);
results.push(result);
@@ -808,7 +741,8 @@ export default function (pi: ExtensionAPI) {
},
makeDetails("parallel"),
ctx.modelRegistry,
- providerPreference,
+ ctx.model?.provider,
+ routing,
);
allResults[index] = result;
emitParallelUpdate();
@@ -844,7 +778,8 @@ export default function (pi: ExtensionAPI) {
onUpdate,
makeDetails("single"),
ctx.modelRegistry,
- providerPreference,
+ ctx.model?.provider,
+ routing,
);
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
if (isError) {
@@ -1187,7 +1122,7 @@ export default function (pi: ExtensionAPI) {
// Register flag for provider preference
pi.registerFlag("subagent-providers", {
- description: "Comma-separated list of preferred providers for subagent model resolution (e.g., 'google-vertex-claude,google,llama-cpp')",
+ description: "Comma-separated fallback providers for subagent model resolution after the active provider (e.g., 'anthropic-vertex,google,llama-cpp')",
type: "string",
});
@@ -1195,27 +1130,9 @@ export default function (pi: ExtensionAPI) {
pi.registerCommand("subagent-config", {
description: "Show subagent provider preference configuration",
handler: async (_args, ctx) => {
- let providerPreference = DEFAULT_PROVIDER_PREFERENCE;
- let source = "default";
- const flagValue = pi.getFlag("--subagent-providers") as string;
-
- if (flagValue) {
- providerPreference = flagValue.split(",").map(p => p.trim()).filter(Boolean);
- source = "flag";
- } else {
- try {
- const settingsPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
- if (fs.existsSync(settingsPath)) {
- const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
- if (Array.isArray(settings.subagentProviderPreference)) {
- providerPreference = settings.subagentProviderPreference;
- source = "settings.json";
- }
- }
- } catch {
- // Ignore
- }
- }
+ const flagValue = pi.getFlag("--subagent-providers") as string | undefined;
+ const routing = loadRoutingSettings(flagValue);
+ const source = flagValue ? "flag" : "settings.json";
// Build a formatted message
const lines: string[] = [];
@@ -1238,36 +1155,32 @@ export default function (pi: ExtensionAPI) {
// Current setting
lines.push("Provider Preference:");
- if (providerPreference.length > 0) {
- lines.push(` Source: ${source}`);
- lines.push(` Order: ${providerPreference.join(" → ")}`);
- } else {
- lines.push(" No preference (tries all providers with API keys)");
+ lines.push(` Source: ${source}`);
+ lines.push(` Fallback order: ${routing.providerPreference.join(" → ") || "none"}`);
+ lines.push(` Active provider: ${ctx.model?.provider ?? "none"} (tried first)`);
+ lines.push("");
+
+ lines.push("Model Fallbacks:");
+ for (const [model, fallbacks] of Object.entries(routing.modelFallbacks)) {
+ lines.push(` ${model}: ${fallbacks.length ? fallbacks.join(" → ") : "none"}`);
}
lines.push("");
-
- // Configuration methods
+ lines.push("Provider/Model Masks:");
+ for (const [provider, models] of Object.entries(routing.providerModelMasks)) {
+ lines.push(` ${provider}: ${models.length ? models.join(", ") : "none"}`);
+ }
+ lines.push("");
+
lines.push("Configuration:");
- lines.push(" 1. Flag: pi --subagent-providers google-vertex-claude,google");
- lines.push(" 2. Settings: Add to ~/.pi/agent/settings.json:");
- lines.push(' "subagentProviderPreference": ["google-vertex-claude", ...]');
+ lines.push(" 1. Flag: pi --subagent-providers anthropic-vertex,google");
+ lines.push(" 2. Settings: Add subagentProviderPreference, subagentModelFallbacks,");
+ lines.push(" and subagentProviderModelMasks to ~/.pi/agent/settings.json.");
lines.push("");
-
- // How it works
+
lines.push("Resolution Order:");
- lines.push(" 1. Try preferred providers WITH API keys");
- lines.push(" 2. Try other providers WITH API keys");
- lines.push(" 3. Fallback to any provider (may fail)");
- lines.push("");
-
- // Example
- lines.push("Example:");
- lines.push(" Agent defines: model: claude-haiku-4-5");
- if (providerPreference.length > 0) {
- lines.push(` Resolves to: ${providerPreference[0]}/claude-haiku-4-5`);
- } else {
- lines.push(" Resolves to: (first provider with API key)");
- }
+ lines.push(" 1. Try the active session provider first");
+ lines.push(" 2. Try configured fallback providers in order");
+ lines.push(" 3. Skip masked pairs and use only explicit same-tier fallbacks");
const output = lines.join("\n");
dots/pi/agent/extensions/subagent/model-routing.test.ts
@@ -0,0 +1,124 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { resolveSubagentModel } from "./model-routing.ts";
+
+const catalog = [
+ { provider: "github-copilot", modelId: "claude-opus-5" },
+ { provider: "github-copilot", modelId: "claude-opus-4-8" },
+ { provider: "github-copilot", modelId: "claude-opus-4-7" },
+ { provider: "anthropic-vertex", modelId: "claude-opus-5" },
+ { provider: "anthropic-vertex", modelId: "claude-opus-4-8" },
+ { provider: "anthropic-vertex", modelId: "claude-opus-4-7" },
+ { provider: "google", modelId: "claude-opus-4-8" },
+];
+const ready = new Set(catalog.map(({ provider, modelId }) => `${provider}\u0000${modelId}`));
+
+const baseOptions = {
+ parentProvider: "github-copilot",
+ providerPreference: ["anthropic-vertex", "google"],
+ requestedModel: "claude-opus-5",
+ modelFallbacks: { "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7"] },
+ providerModelMasks: {},
+ catalog,
+ ready,
+};
+
+function resolve(overrides: Partial<typeof baseOptions> = {}) {
+ return resolveSubagentModel({ ...baseOptions, ...overrides });
+}
+
+describe("resolveSubagentModel", () => {
+ it("prefers the parent provider for the requested model", () => {
+ assert.deepEqual(resolve(), {
+ selected: { provider: "github-copilot", modelId: "claude-opus-5" },
+ rejected: [],
+ });
+ });
+
+ it("skips a masked parent provider/model pair before selection", () => {
+ assert.deepEqual(
+ resolve({
+ providerModelMasks: { "github-copilot": ["claude-opus-5"] },
+ }),
+ {
+ selected: { provider: "github-copilot", modelId: "claude-opus-4-8" },
+ rejected: [{ provider: "github-copilot", modelId: "claude-opus-5", reason: "masked" }],
+ },
+ );
+ });
+
+ it("uses an allowed same-tier fallback on the parent provider", () => {
+ const result = resolve({
+ providerModelMasks: { "github-copilot": ["claude-opus-5"] },
+ });
+ assert.deepEqual(result.selected, { provider: "github-copilot", modelId: "claude-opus-4-8" });
+ });
+
+ it("uses the configured Vertex mask without masking the same Copilot model", () => {
+ const result = resolve({
+ parentProvider: "anthropic-vertex",
+ providerModelMasks: { "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"] },
+ });
+ assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-4-7" });
+ assert.deepEqual(result.rejected, [
+ { provider: "anthropic-vertex", modelId: "claude-opus-5", reason: "masked" },
+ { provider: "anthropic-vertex", modelId: "claude-opus-4-8", reason: "masked" },
+ ]);
+ });
+
+ it("moves to configured providers after parent candidates are unavailable", () => {
+ const result = resolve({
+ catalog: [{ provider: "anthropic-vertex", modelId: "claude-opus-4-8" }],
+ ready: new Set(["anthropic-vertex\u0000claude-opus-4-8"]),
+ });
+ assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-4-8" });
+ });
+
+ it("does not invent a cross-tier fallback", () => {
+ const result = resolve({
+ catalog: [{ provider: "github-copilot", modelId: "claude-sonnet-5" }],
+ ready: new Set(["github-copilot\u0000claude-sonnet-5"]),
+ });
+ assert.equal(result.selected, null);
+ assert.ok(result.rejected.every((entry) => entry.modelId.startsWith("claude-opus-")));
+ });
+
+ it("reports catalog and credential rejection reasons", () => {
+ const result = resolve({
+ catalog: [{ provider: "github-copilot", modelId: "claude-opus-5" }],
+ ready: new Set(),
+ });
+ assert.deepEqual(result.selected, null);
+ assert.ok(result.rejected.some((entry) => entry.reason === "missing-credentials"));
+ assert.ok(result.rejected.some((entry) => entry.reason === "not-in-catalog"));
+ });
+
+ it("retains static preference behavior when no parent provider or new settings exist", () => {
+ const result = resolveSubagentModel({
+ parentProvider: undefined,
+ providerPreference: ["anthropic-vertex", "github-copilot"],
+ requestedModel: "claude-opus-5",
+ modelFallbacks: {},
+ providerModelMasks: {},
+ catalog,
+ ready,
+ });
+ assert.deepEqual(result.selected, { provider: "anthropic-vertex", modelId: "claude-opus-5" });
+ });
+
+ it("selects a dated catalog model for an undated requested ID", () => {
+ const result = resolveSubagentModel({
+ parentProvider: "anthropic-vertex",
+ providerPreference: [],
+ requestedModel: "claude-haiku-4-5",
+ modelFallbacks: {},
+ providerModelMasks: {},
+ catalog: [{ provider: "anthropic-vertex", modelId: "claude-haiku-4-5@20251001" }],
+ ready: new Set(["anthropic-vertex\u0000claude-haiku-4-5@20251001"]),
+ });
+ assert.deepEqual(result.selected, {
+ provider: "anthropic-vertex",
+ modelId: "claude-haiku-4-5@20251001",
+ });
+ });
+});
dots/pi/agent/extensions/subagent/model-routing.ts
@@ -0,0 +1,58 @@
+export type ModelEntry = { provider: string; modelId: string };
+export type RejectionReason = "masked" | "not-in-catalog" | "missing-credentials";
+export type Rejection = { provider: string; modelId: string; reason: RejectionReason };
+export type Resolution = {
+ selected: { provider: string; modelId: string } | null;
+ rejected: Rejection[];
+};
+
+export type ResolveSubagentModelOptions = {
+ parentProvider?: string;
+ providerPreference: string[];
+ requestedModel: string;
+ modelFallbacks: Record<string, string[]>;
+ providerModelMasks: Record<string, string[]>;
+ catalog: ModelEntry[];
+ ready: ReadonlySet<string>;
+};
+
+const key = (provider: string, modelId: string) => `${provider}\u0000${modelId}`;
+
+function orderedUnique(values: Array<string | undefined>): string[] {
+ return [...new Set(values.filter((value): value is string => Boolean(value)))];
+}
+
+function catalogModelId(catalog: ModelEntry[], provider: string, requested: string): string | undefined {
+ return (
+ catalog.find((entry) => entry.provider === provider && entry.modelId === requested)?.modelId ??
+ catalog.find((entry) => entry.provider === provider && entry.modelId.startsWith(`${requested}@`))?.modelId
+ );
+}
+
+export function resolveSubagentModel(options: ResolveSubagentModelOptions): Resolution {
+ const providers = orderedUnique([options.parentProvider, ...options.providerPreference]);
+ const models = orderedUnique([options.requestedModel, ...(options.modelFallbacks[options.requestedModel] ?? [])]);
+ const rejected: Rejection[] = [];
+
+ for (const provider of providers) {
+ const masked = new Set(options.providerModelMasks[provider] ?? []);
+ for (const requestedModel of models) {
+ if (masked.has(requestedModel)) {
+ rejected.push({ provider, modelId: requestedModel, reason: "masked" });
+ continue;
+ }
+ const modelId = catalogModelId(options.catalog, provider, requestedModel);
+ if (!modelId) {
+ rejected.push({ provider, modelId: requestedModel, reason: "not-in-catalog" });
+ continue;
+ }
+ if (!options.ready.has(key(provider, modelId))) {
+ rejected.push({ provider, modelId, reason: "missing-credentials" });
+ continue;
+ }
+ return { selected: { provider, modelId }, rejected };
+ }
+ }
+
+ return { selected: null, rejected };
+}
dots/pi/agent/extensions/subagent/README.md
@@ -1,258 +1,129 @@
-# Subagent Extension - Multi-Model Provider Support
+# Subagent Extension
-Delegate tasks to specialized subagents with isolated context windows and intelligent multi-provider model resolution.
+Delegate tasks to specialized subagents with isolated context windows and deterministic multi-provider model routing.
## Features
-- **Isolated context**: Each subagent runs in a separate `pi` process
-- **Multi-model support**: Each agent can use a different model
-- **Intelligent provider resolution**: Automatically finds models across providers
-- **Provider preference**: Configure which providers to prefer for model lookup
-- **Streaming output**: See tool calls and progress as they happen
-- **Parallel execution**: Run multiple agents concurrently
-- **Chain workflows**: Sequential execution with output passing
+- **Isolated context:** each subagent runs in a separate `pi` process.
+- **Active-provider-first routing:** a child first tries the provider selected by the parent session.
+- **Provider fallbacks:** configured providers are tried after the active provider, in order.
+- **Same-tier model fallbacks:** a requested model uses only its explicitly configured fallback versions.
+- **Provider/model masks:** known-unavailable pairs are skipped before Pi spawns a child process.
+- **Parallel and chain workflows:** run independent tasks concurrently or pass output between sequential agents.
-## Model Resolution
+## Model resolution
-When an agent specifies a model ID (e.g., `claude-sonnet-4-5`), the extension:
+For an agent with `model: claude-opus-5`, the extension:
-1. First tries **preferred providers with API keys** (in order)
-2. Then tries **other providers with API keys**
-3. Falls back to any provider (will fail if no API key configured)
+1. tries the parent session provider with `claude-opus-5`;
+2. tries that provider with the configured Opus fallback versions;
+3. repeats for `subagentProviderPreference` providers, in order;
+4. skips exact pairs in `subagentProviderModelMasks`;
+5. selects the first catalog-visible pair with credentials.
-This allows you to:
-- Use `google-vertex-claude` for Claude models (if configured)
-- Fall back to `anthropic` if vertex isn't available
-- Use local models via `llama-cpp`
-- Configure provider preferences globally or per-session
+The model catalog says that Pi knows a model ID, not that a Vertex AI project is entitled to invoke it. Use a provider/model mask for models that are catalog-visible but not enabled for your Vertex project.
+
+If no pair is usable, the extension does not spawn Pi. It reports whether each candidate was masked, absent from the catalog, or missing credentials. It does not retry a child that has already started and failed.
## Configuration
-### Provider Preference
-
-Configure provider preference in **three ways** (priority order):
-
-#### 1. Command-line flag (highest priority)
-
-```bash
-pi --subagent-providers google-vertex-claude,google,llama-cpp
-```
-
-#### 2. Settings file (`~/.pi/agent/settings.json`)
+Configure fallbacks and masks in `~/.pi/agent/settings.json`:
```json
{
"subagentProviderPreference": [
- "google-vertex-claude",
- "google",
- "llama-cpp",
- "anthropic",
- "openai"
- ]
+ "anthropic-vertex",
+ "google",
+ "llama-cpp"
+ ],
+ "subagentModelFallbacks": {
+ "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"],
+ "claude-sonnet-5": ["claude-sonnet-4-6", "claude-sonnet-4-5"],
+ "claude-haiku-4-5": []
+ },
+ "subagentProviderModelMasks": {
+ "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+ }
}
```
-#### 3. Default behavior (lowest priority)
+`subagentProviderPreference` is the fallback order, not an override of the active session provider. Duplicates are removed, preserving order.
-If not configured, the extension tries **all providers**, preferring those with API keys.
+`subagentModelFallbacks` is explicit and model-family scoped. Do not configure an Opus-to-Sonnet or Sonnet-to-Haiku fallback: model tiers must not be silently downgraded.
-### View Current Configuration
+`subagentProviderModelMasks` is provider-specific. The example prevents Opus 5 and Opus 4.8 from being sent to Vertex, but still permits them through `github-copilot`. Update the mask deliberately after Vertex model entitlements change.
+
+### Per-session provider fallback override
+
+The `--subagent-providers` flag replaces `subagentProviderPreference` for a session but does not replace model fallbacks or masks:
```bash
-pi
-> /subagent-config
+pi --subagent-providers github-copilot,anthropic-vertex,google
```
-## Agent Definitions
+### View current configuration
-Create agents in `~/.pi/agent/agents/*.md`:
-
-```markdown
----
-name: scout
-description: Fast reconnaissance
-tools: read, grep, find, ls
-model: claude-haiku-4-5
----
-
-You are a scout. Quickly investigate a codebase...
+```text
+/subagent-config
```
-The `model:` field is just the model ID - the provider is resolved automatically.
+The command shows the active provider, fallback order, model fallback chains, masks, and model-cache status.
-### Available Models by Provider
+## Agent definitions
-**Anthropic Claude via Google Vertex:**
-- `claude-opus-4-6`
-- `claude-sonnet-4-5@20250929`
-- `claude-haiku-4-5@20251001`
-
-**Google Gemini:**
-- `gemini-2.5-flash`
-- `gemini-2.5-pro`
-
-**Local (llama-cpp):**
-- `Qwen/Qwen3-8B-GGUF:Q4_K_M`
-- `bartowski/DeepSeek-R1-Distill-Qwen-7B-GGUF:Q4_K_M`
-
-## Usage Examples
-
-### Simple Usage
-
-```
-Use scout to find all Tekton tasks
-```
-
-### Chain Workflow
-
-```
-Chain: scout finds auth code, planner creates refactor plan, worker implements it
-```
-
-This might use:
-- scout → `claude-haiku-4-5` via `google-vertex-claude`
-- planner → `claude-sonnet-4-5@20250929` via `google-vertex-claude`
-- worker → `claude-sonnet-4-5@20250929` via `google-vertex-claude`
-
-## Example Agents
-
-### Scout (Fast, Cheap)
-
-```markdown
----
-name: scout
-description: Fast reconnaissance
-tools: read, grep, find, ls, bash
-model: claude-haiku-4-5
----
-
-Quickly investigate and return compressed findings.
-```
-
-### Planner (Detailed Analysis)
+Agents live in `~/.pi/agent/agents/*.md` and specify a model ID without its provider:
```markdown
---
name: planner
description: Creates implementation plans
tools: read, grep, find, ls
-model: claude-sonnet-4-5@20250929
+model: claude-sonnet-5
---
-Receive context and create detailed implementation plans.
+You are a planning specialist.
```
-### Local Researcher
+Current conventions are:
-```markdown
----
-name: local-researcher
-description: Research using local model
-tools: web_search, github_search, stack_overflow_search
-model: Qwen/Qwen3-8B-GGUF:Q4_K_M
----
+| Workload | Model |
+| --- | --- |
+| Fast reconnaissance | `claude-haiku-4-5` |
+| Planning, implementation, standard analysis | `claude-sonnet-5` |
+| Deep analysis and code review | `claude-opus-5` |
-Research using a local model to save API costs.
+## Usage
+
+### Single agent
+
+```text
+Use scout to find all Tekton tasks.
```
-## Cost Optimization
+### Chain
-Configure provider preference to optimize costs:
-
-```json
-{
- "subagentProviderPreference": [
- "llama-cpp", // Free (local)
- "google-vertex-claude", // Discounted Claude via Vertex
- "google", // Gemini
- "anthropic" // Full-price Claude (fallback)
- ]
-}
+```text
+Use a chain: scout finds auth code, planner creates a plan from {previous}, then worker implements it.
```
-Now agents requesting `claude-haiku-4-5` will:
-1. Try `llama-cpp` first (no claude there, skip)
-2. Try `google-vertex-claude` (found! use it)
-3. Skip remaining providers
+### Parallel work
+
+```text
+Run separate reviewer-go and reviewer-security subagents in parallel.
+```
+
+The tool supports single (`agent` + `task`), parallel (`tasks`), and sequential (`chain`) modes. Project-local agents require `agentScope: "project"` or `"both"`; only enable them for trusted repositories.
## Troubleshooting
-### "No API key configured for provider"
+### A model is available in Pi but fails on Vertex
-**Problem:** Agent fails because the subagent process doesn't have an API key.
+Add it to `subagentProviderModelMasks.anthropic-vertex` and configure a known-enabled version in the same model family under `subagentModelFallbacks`.
-**Solution:** Add provider preference to route to a configured provider:
+### An unexpected provider was selected
-```json
-{
- "subagentProviderPreference": ["google-vertex-claude"]
-}
-```
+Check `/subagent-config`. The parent session provider is always considered first; change the parent model/provider or set a fallback order with `--subagent-providers`.
-### Model not found
+### No usable subagent model
-**Problem:** Agent specifies a model that doesn't exist on any provider.
-
-**Solution:** Check available models:
-
-```bash
-pi --list-models | grep claude-haiku
-```
-
-Update agent definition with correct model ID.
-
-### Wrong provider being used
-
-**Problem:** Agent uses an unexpected provider.
-
-**Solution:**
-1. Check current config: `/subagent-config`
-2. Add explicit preference in settings
-3. Or use flag: `pi --subagent-providers google-vertex-claude`
-
-## API Reference
-
-### Tool Parameters
-
-```typescript
-{
- agent: "scout", // Single mode
- task: "find auth code",
-
- // OR parallel mode:
- tasks: [
- { agent: "scout", task: "..." },
- { agent: "planner", task: "..." }
- ],
-
- // OR chain mode:
- chain: [
- { agent: "scout", task: "find auth" },
- { agent: "planner", task: "plan using {previous}" }
- ],
-
- agentScope: "user" | "project" | "both", // Default: "user"
- confirmProjectAgents: true, // Default: true
- cwd: "/path/to/working/dir" // Optional
-}
-```
-
-### Settings Schema
-
-```json
-{
- "subagentProviderPreference": ["provider1", "provider2", "..."]
-}
-```
-
-### Command-line Flag
-
-```bash
---subagent-providers <comma-separated-list>
-```
-
-## Security
-
-**Project-local agents** (`.pi/agents/*.md`) can execute arbitrary code. Only use `agentScope: "both"` or `agentScope: "project"` for repositories you trust.
-
-By default, only user-level agents (`~/.pi/agent/agents/`) are loaded.
+Read the tool diagnostic. It identifies masked pairs, catalog-absent models, and models without configured credentials. Run `pi --list-models` to inspect Pi’s current catalog.
dots/pi/agent/ensure-settings.sh
@@ -17,6 +17,14 @@ REQUIRED_SETTINGS='{
"google",
"llama-cpp"
],
+ "subagentModelFallbacks": {
+ "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"],
+ "claude-sonnet-5": ["claude-sonnet-4-6", "claude-sonnet-4-5"],
+ "claude-haiku-4-5": []
+ },
+ "subagentProviderModelMasks": {
+ "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+ },
"packages": [
"npm:@aliou/pi-processes@0.12.0",
"npm:@twogiants/pi-anthropic-vertex",
@@ -49,6 +57,8 @@ if command -v jq >/dev/null 2>&1; then
echo " - treeFilterMode: no-tools"
echo " - subagentProviderPreference: anthropic-vertex, google, llama-cpp"
+ echo " - subagentModelFallbacks: same-tier Claude fallbacks"
+ echo " - subagentProviderModelMasks: anthropic-vertex/claude-opus-5, claude-opus-4-8"
echo " - packages: @aliou/pi-processes@0.12.0, @tmustier/pi-usage-extension@0.9.4"
else
echo "⚠️ jq not found - cannot merge settings automatically"
@@ -57,6 +67,8 @@ else
echo " - quietStartup: true"
echo " - treeFilterMode: no-tools"
- echo " - subagentProviderPreference: [\"google-vertex-claude\", \"vertex\", ...]"
+ echo " - subagentProviderPreference: [\"anthropic-vertex\", \"google\", ...]"
+ echo " - subagentModelFallbacks: {\"claude-opus-5\": [\"claude-opus-4-8\", ...]}"
+ echo " - subagentProviderModelMasks: {\"anthropic-vertex\": [\"claude-opus-5\", \"claude-opus-4-8\"]}"
exit 1
fi
dots/pi/agent/settings.json
@@ -20,6 +20,14 @@
"anthropic",
"openai"
],
+ "subagentModelFallbacks": {
+ "claude-opus-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"],
+ "claude-sonnet-5": ["claude-sonnet-4-6", "claude-sonnet-4-5"],
+ "claude-haiku-4-5": []
+ },
+ "subagentProviderModelMasks": {
+ "anthropic-vertex": ["claude-opus-5", "claude-opus-4-8"]
+ },
"packages": [
"npm:@aliou/pi-processes@0.12.0",
"npm:@twogiants/pi-anthropic-vertex",