From bc4483fbedab0e26c636e7050ea1cc5f4cf8fdd1 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Wed, 14 Oct 2026 11:31:34 -0700 Subject: [PATCH 1/9] feat(sdk): central model resolver with config, session and fallback sources resolveModel now applies MODEL_RESOLUTION_PRECEDENCE (user-override > config > session > charter > task-auto > default), returns a rationale for every choice, passes a session-picked model through to sub-agent spawns with source `session`, and selects from the tier fallback chain (source `fallback`) when the chosen model is listed in unavailableModels. Task-auto picks now read the tier chain head instead of hard-coding IDs. Coordinator spawn guidance (squad.agent.md) and model-selection-reference are model-ID agnostic: they defer to the resolver precedence, acknowledge model + source + rationale, and keep catalog IDs in a separate appendix. A coordinator contract test enforces the no-model-ID rule. Refs #1720 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79bd3e85-5096-4dae-a1ef-937c21d2c48e --- .changeset/central-model-resolver.md | 6 + .github/agents/squad.agent.md | 12 +- .squad-templates/model-selection-reference.md | 114 ++++++---- .squad-templates/squad.agent.md | 12 +- .../templates/model-selection-reference.md | 114 ++++++---- .../templates/squad.agent.md.template | 12 +- packages/squad-sdk/src/agents/index.ts | 1 + .../squad-sdk/src/agents/model-selector.ts | 214 +++++++++++++----- .../templates/model-selection-reference.md | 114 ++++++---- .../templates/squad.agent.md.template | 12 +- templates/model-selection-reference.md | 114 ++++++---- templates/squad.agent.md.template | 12 +- test/coordinator.test.ts | 100 ++++++++ test/model-preference.test.ts | 141 ++++++++++++ 14 files changed, 713 insertions(+), 265 deletions(-) create mode 100644 .changeset/central-model-resolver.md diff --git a/.changeset/central-model-resolver.md b/.changeset/central-model-resolver.md new file mode 100644 index 000000000..dedb31758 --- /dev/null +++ b/.changeset/central-model-resolver.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-sdk": minor +"@bradygaster/squad-cli": patch +--- + +Central model resolver (#1720): `resolveModel` in `@bradygaster/squad-sdk` adds `config`, `session` and `fallback` sources, applies the exported `MODEL_RESOLUTION_PRECEDENCE` (user-override > config > session > charter > task-auto > default), and returns a `rationale` for every choice. A session-picked model is inherited by every sub-agent spawn with source `session`, and `unavailableModels` triggers fallback-chain selection. Coordinator spawn guidance and `model-selection-reference.md` no longer hard-code model IDs; spawn acknowledgments show model, source and rationale, with catalog IDs kept in a separate appendix. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 47dea3af4..e5ae6a307 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -393,11 +393,13 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn. Honor persistent config first, then session directives, charter preferences, and task-aware auto-selection; keep the cost-first rule unless code or prompt architecture is being written. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for the platform default fallback. +When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. -**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full layer hierarchy, role mapping, fallback chains, spawn formatting, and valid models catalog. +Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. + +**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full precedence, role-to-tier mapping, fallback rules, spawn formatting, and the model catalog appendix. ### Per-Agent Reasoning Effort @@ -424,7 +426,7 @@ When the resolved reasoning effort is not `auto` or default, include it in the a **Spawn output format — show the model choice and effort:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-4.7-1m-internal · xhigh) — deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · xhigh) — deep architecture analysis — charter preference`). ### Per-Agent Context Tier @@ -451,7 +453,7 @@ When the resolved context tier is not `auto` or default, include it in the agent **Spawn output format — show the model choice and tier:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-5 · long context) — 1M-token window for deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · long context) — deep architecture analysis — 1M-token window`). ### Client Compatibility diff --git a/.squad-templates/model-selection-reference.md b/.squad-templates/model-selection-reference.md index ffc51eb71..b862c1fba 100644 --- a/.squad-templates/model-selection-reference.md +++ b/.squad-templates/model-selection-reference.md @@ -2,65 +2,75 @@ ## Per-Agent Model Selection -Before spawning an agent, determine which model to use. Check these layers in order — first match wins: +Before spawning an agent, resolve its model with the central resolver (`resolveModel` in `@bradygaster/squad-sdk`). Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. -**Layer 0 — Persistent Config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. +Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. + +| # | Source | Where the model comes from | +|---|--------|----------------------------| +| 1 | `user-override` | Explicit per-spawn directive in the current request ("use {model} for this task") | +| 2 | `config` | Persistent `.squad/config.json`: `agentModelOverrides.{agentName}`, otherwise `defaultModel` | +| 3 | `session` | The model picked for this session (by the user or the session itself); inherited by every sub-agent spawn | +| 4 | `charter` | The agent charter's `## Model` → `**Preferred:**` value, unless it is `auto` | +| 5 | `task-auto` | Task-aware tier selection (cost first, unless code is being written) | +| 6 | `default` | Nothing else matched | +| — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | + +`auto` at any layer means "no preference here"; continue to the next layer. + +**`user-override` — per-spawn directive:** A directive that names a model for one specific spawn ("have {agent} use {model} for this") applies to that spawn only. It does not persist and does not change the session model. + +**`config` — persistent config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. Because `config` outranks `session`, a session pick does not replace a saved config preference; tell the user to clear the config preference if they want the session pick to win. - **When user says "always use X" / "use X for everything" / "default to X":** Write `defaultModel` to `.squad/config.json`. Acknowledge: `✅ Model preference saved: {model} — all future sessions will use this until changed.` - **When user says "use X for {agent}":** Write to `agentModelOverrides.{agent}` in `.squad/config.json`. Acknowledge: `✅ {Agent} will always use {model} — saved to config.` - **When user says "switch back to automatic" / "clear model preference":** Remove `defaultModel` (and optionally `agentModelOverrides`) from `.squad/config.json`. Acknowledge: `✅ Model preference cleared — returning to automatic selection.` -**Layer 1 — Session Directive:** Did the user specify a model for this session? ("use opus for this session", "save costs"). If yes, use that model. Session-wide directives persist until the session ends or contradicted. +**`session` — session model:** If the user picked a model for this session ("use {model} for this session"), or the session itself is running on a model the user chose, that is the session model. Session-wide directives persist until the session ends or are contradicted. Pass the session model to **every** sub-agent spawn unchanged, with source `session`; sub-agents inherit it rather than re-resolving from their charter or task. Pass it through even when it is not listed in Appendix A (for example, an opt-in or preview model the user selected). -**Layer 2 — Charter Preference:** Does the agent's charter have a `## Model` section with `Preferred` set to a specific model (not `auto`)? If yes, use that model. +**`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**Layer 3 — Task-Aware Auto-Selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to determine output type, then select accordingly: +**`task-auto` — task-aware auto-selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to its output type, select the tier, and use the head of that tier's fallback chain (Appendix A): -| Task Output | Model | Tier | Rule | -|-------------|-------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | `gpt-5.6-terra` | Standard | Quality and accuracy matter for code. Use standard tier. | -| Writing prompts or agent designs (structured text that functions like code) | `gpt-5.6-terra` | Standard | Prompts are executable — treat like code. | -| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | `gpt-5.6-luna` | Fast | Cost first. Luna handles non-code tasks. | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | Premium | Vision capability required. Overrides cost rule. | +| Task Output | Tier | Rule | +|-------------|------|------| +| Writing code (implementation, refactoring, test code, bug fixes) | Standard | Quality and accuracy matter for code. | +| Writing prompts or agent designs (structured text that functions like code) | Standard | Prompts are executable — treat like code. | +| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | Fast | Cost first. | +| Visual/design work requiring image analysis | Premium | Vision capability required. Overrides cost rule. | -**Role-to-model mapping** (applying cost-first principle): +**Role-to-tier mapping** (applying the cost-first principle): -| Role | Default Model | Why | Override When | +| Role | Default Tier | Why | Override When | |------|--------------|-----|---------------| -| Core Dev / Backend / Frontend | `gpt-5.6-terra` | Writes code — quality first | Heavy code gen → `gpt-5.3-codex` | -| Tester / QA | `gpt-5.6-terra` | Writes test code — quality first | Simple test scaffolding → `claude-haiku-4.5` | -| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → haiku | -| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → sonnet; research/analysis → haiku | -| Copilot SDK Expert | `gpt-5.6-terra` | Technical analysis that often touches code | Pure research → `gpt-5.6-luna` | -| Designer / Visual | `gpt-5.6-sol` | Vision-capable model required | — (never downgrade — vision is non-negotiable) | -| DevRel / Writer | `gpt-5.6-luna` | Docs and writing — not code | — | -| Scribe / Logger | `gpt-5.6-luna` | Mechanical file ops — cheapest possible | — (never bump Scribe) | -| Git / Release | `gpt-5.6-luna` | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | +| Core Dev / Backend / Frontend | Standard | Writes code — quality first | Heavy code gen → code specialist | +| Tester / QA | Standard | Writes test code — quality first | Simple test scaffolding → fast | +| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → fast | +| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → standard; research/analysis → fast | +| Copilot SDK Expert | Standard | Technical analysis that often touches code | Pure research → fast | +| Designer / Visual | Premium | Vision-capable model required | — (never downgrade — vision is non-negotiable) | +| DevRel / Writer | Fast | Docs and writing — not code | — | +| Scribe / Logger | Fast | Mechanical file ops — cheapest possible | — (never bump Scribe) | +| Git / Release | Fast | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | **Task complexity adjustments** (apply at most ONE — no cascading): - **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) -- **Bump DOWN to fast/cheap:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps -- **Switch to code specialist (`gpt-5.3-codex`):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) -- **Switch to analytical diversity (`gemini-3.1-pro`):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection +- **Bump DOWN to fast:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps +- **Switch to the code specialist (Appendix A):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) +- **Switch to the analytical-diversity model (Appendix A):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection -**Layer 4 — Default:** If nothing else matched, use `gpt-5.6-luna`. Cost wins when in doubt, unless code is being produced. +**`default`:** If nothing else matched, use the head of the fast tier chain. Cost wins when in doubt, unless code is being produced. -**Fallback chains — when a model is unavailable:** +**`fallback` — when a model is unavailable:** -If a spawn fails because the selected model is unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), silently retry with the next model in the chain. Do NOT tell the user about fallback attempts. Maximum 3 retries before using the platform default fallback. - -``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) -``` +If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model in its tier's fallback chain (Appendix A). The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the platform default fallback — it lets the platform choose the model. **Fallback rules:** -- If the user specified a provider ("use Claude"), fall back within that provider only before using the platform default fallback +- If the user specified a provider ("use {provider}"), fall back within that provider only before using the platform default fallback - Never fall back UP in tier — a fast/cheap task should not land on a premium model -- Log fallbacks to the orchestration log for debugging, but never surface to the user unless asked +- Log fallbacks (source `fallback` plus rationale) to the orchestration log for debugging, but never surface retries to the user unless asked **Passing the model to spawns:** @@ -76,23 +86,35 @@ prompt: | ... ``` -Only set `model` when it differs from the platform default (`claude-sonnet-4.6`). If the resolved model IS `claude-sonnet-4.6`, you MAY omit the `model` parameter — the platform uses it as default. - If you've exhausted the fallback chain and reached the platform default fallback, omit the `model` parameter entirely. -**Spawn output format — show the model choice:** +**Spawn output format — show the model, source and rationale:** -When spawning, include the model in your acknowledgment: +Every spawn acknowledgment names the resolved model, its source and a short rationale (the resolver's `rationale`, shortened if needed). Substitute the actual resolved model for `{model}`: ``` -🔧 Runtime Engineer (claude-sonnet-5) — refactoring auth module -🎨 Experience Engineer (gpt-5.6-sol · vision) — designing color system -📋 Scribe (gpt-5.6-luna · fast) — logging session -⚡ Lead (gpt-5.6-sol · bumped for architecture) — reviewing proposal -📝 Docs Engineer (gpt-5.6-luna · fast) — updating docs +{emoji} {Name} ({model} · {source}) — {task summary} — {rationale} + +🔧 Runtime Engineer ({model} · session) — refactoring auth module — session model inherited by this spawn +🎨 Experience Engineer ({model} · task-auto) — designing color system — visual task needs vision → premium tier +📋 Scribe ({model} · task-auto) — logging session — mechanical task → fast tier +⚡ Lead ({model} · config) — reviewing proposal — agentModelOverrides.lead in .squad/config.json +📝 Docs Engineer ({model} · fallback) — updating docs — charter model unavailable; next in fast chain +``` + +## Appendix A — Model catalog snapshot + +This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. + +**Fallback chains:** + +``` +Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) +Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) +Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` -Include tier annotation only when the model was bumped or a specialist was chosen. Default-tier spawns just show the model name. +**Specialists:** code specialist `gpt-5.3-codex` (standard); analytical diversity `gemini-3.1-pro` (standard). **Valid models (current platform catalog):** diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index 47dea3af4..e5ae6a307 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -393,11 +393,13 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn. Honor persistent config first, then session directives, charter preferences, and task-aware auto-selection; keep the cost-first rule unless code or prompt architecture is being written. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for the platform default fallback. +When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. -**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full layer hierarchy, role mapping, fallback chains, spawn formatting, and valid models catalog. +Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. + +**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full precedence, role-to-tier mapping, fallback rules, spawn formatting, and the model catalog appendix. ### Per-Agent Reasoning Effort @@ -424,7 +426,7 @@ When the resolved reasoning effort is not `auto` or default, include it in the a **Spawn output format — show the model choice and effort:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-4.7-1m-internal · xhigh) — deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · xhigh) — deep architecture analysis — charter preference`). ### Per-Agent Context Tier @@ -451,7 +453,7 @@ When the resolved context tier is not `auto` or default, include it in the agent **Spawn output format — show the model choice and tier:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-5 · long context) — 1M-token window for deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · long context) — deep architecture analysis — 1M-token window`). ### Client Compatibility diff --git a/packages/squad-cli/templates/model-selection-reference.md b/packages/squad-cli/templates/model-selection-reference.md index ffc51eb71..b862c1fba 100644 --- a/packages/squad-cli/templates/model-selection-reference.md +++ b/packages/squad-cli/templates/model-selection-reference.md @@ -2,65 +2,75 @@ ## Per-Agent Model Selection -Before spawning an agent, determine which model to use. Check these layers in order — first match wins: +Before spawning an agent, resolve its model with the central resolver (`resolveModel` in `@bradygaster/squad-sdk`). Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. -**Layer 0 — Persistent Config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. +Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. + +| # | Source | Where the model comes from | +|---|--------|----------------------------| +| 1 | `user-override` | Explicit per-spawn directive in the current request ("use {model} for this task") | +| 2 | `config` | Persistent `.squad/config.json`: `agentModelOverrides.{agentName}`, otherwise `defaultModel` | +| 3 | `session` | The model picked for this session (by the user or the session itself); inherited by every sub-agent spawn | +| 4 | `charter` | The agent charter's `## Model` → `**Preferred:**` value, unless it is `auto` | +| 5 | `task-auto` | Task-aware tier selection (cost first, unless code is being written) | +| 6 | `default` | Nothing else matched | +| — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | + +`auto` at any layer means "no preference here"; continue to the next layer. + +**`user-override` — per-spawn directive:** A directive that names a model for one specific spawn ("have {agent} use {model} for this") applies to that spawn only. It does not persist and does not change the session model. + +**`config` — persistent config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. Because `config` outranks `session`, a session pick does not replace a saved config preference; tell the user to clear the config preference if they want the session pick to win. - **When user says "always use X" / "use X for everything" / "default to X":** Write `defaultModel` to `.squad/config.json`. Acknowledge: `✅ Model preference saved: {model} — all future sessions will use this until changed.` - **When user says "use X for {agent}":** Write to `agentModelOverrides.{agent}` in `.squad/config.json`. Acknowledge: `✅ {Agent} will always use {model} — saved to config.` - **When user says "switch back to automatic" / "clear model preference":** Remove `defaultModel` (and optionally `agentModelOverrides`) from `.squad/config.json`. Acknowledge: `✅ Model preference cleared — returning to automatic selection.` -**Layer 1 — Session Directive:** Did the user specify a model for this session? ("use opus for this session", "save costs"). If yes, use that model. Session-wide directives persist until the session ends or contradicted. +**`session` — session model:** If the user picked a model for this session ("use {model} for this session"), or the session itself is running on a model the user chose, that is the session model. Session-wide directives persist until the session ends or are contradicted. Pass the session model to **every** sub-agent spawn unchanged, with source `session`; sub-agents inherit it rather than re-resolving from their charter or task. Pass it through even when it is not listed in Appendix A (for example, an opt-in or preview model the user selected). -**Layer 2 — Charter Preference:** Does the agent's charter have a `## Model` section with `Preferred` set to a specific model (not `auto`)? If yes, use that model. +**`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**Layer 3 — Task-Aware Auto-Selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to determine output type, then select accordingly: +**`task-auto` — task-aware auto-selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to its output type, select the tier, and use the head of that tier's fallback chain (Appendix A): -| Task Output | Model | Tier | Rule | -|-------------|-------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | `gpt-5.6-terra` | Standard | Quality and accuracy matter for code. Use standard tier. | -| Writing prompts or agent designs (structured text that functions like code) | `gpt-5.6-terra` | Standard | Prompts are executable — treat like code. | -| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | `gpt-5.6-luna` | Fast | Cost first. Luna handles non-code tasks. | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | Premium | Vision capability required. Overrides cost rule. | +| Task Output | Tier | Rule | +|-------------|------|------| +| Writing code (implementation, refactoring, test code, bug fixes) | Standard | Quality and accuracy matter for code. | +| Writing prompts or agent designs (structured text that functions like code) | Standard | Prompts are executable — treat like code. | +| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | Fast | Cost first. | +| Visual/design work requiring image analysis | Premium | Vision capability required. Overrides cost rule. | -**Role-to-model mapping** (applying cost-first principle): +**Role-to-tier mapping** (applying the cost-first principle): -| Role | Default Model | Why | Override When | +| Role | Default Tier | Why | Override When | |------|--------------|-----|---------------| -| Core Dev / Backend / Frontend | `gpt-5.6-terra` | Writes code — quality first | Heavy code gen → `gpt-5.3-codex` | -| Tester / QA | `gpt-5.6-terra` | Writes test code — quality first | Simple test scaffolding → `claude-haiku-4.5` | -| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → haiku | -| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → sonnet; research/analysis → haiku | -| Copilot SDK Expert | `gpt-5.6-terra` | Technical analysis that often touches code | Pure research → `gpt-5.6-luna` | -| Designer / Visual | `gpt-5.6-sol` | Vision-capable model required | — (never downgrade — vision is non-negotiable) | -| DevRel / Writer | `gpt-5.6-luna` | Docs and writing — not code | — | -| Scribe / Logger | `gpt-5.6-luna` | Mechanical file ops — cheapest possible | — (never bump Scribe) | -| Git / Release | `gpt-5.6-luna` | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | +| Core Dev / Backend / Frontend | Standard | Writes code — quality first | Heavy code gen → code specialist | +| Tester / QA | Standard | Writes test code — quality first | Simple test scaffolding → fast | +| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → fast | +| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → standard; research/analysis → fast | +| Copilot SDK Expert | Standard | Technical analysis that often touches code | Pure research → fast | +| Designer / Visual | Premium | Vision-capable model required | — (never downgrade — vision is non-negotiable) | +| DevRel / Writer | Fast | Docs and writing — not code | — | +| Scribe / Logger | Fast | Mechanical file ops — cheapest possible | — (never bump Scribe) | +| Git / Release | Fast | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | **Task complexity adjustments** (apply at most ONE — no cascading): - **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) -- **Bump DOWN to fast/cheap:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps -- **Switch to code specialist (`gpt-5.3-codex`):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) -- **Switch to analytical diversity (`gemini-3.1-pro`):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection +- **Bump DOWN to fast:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps +- **Switch to the code specialist (Appendix A):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) +- **Switch to the analytical-diversity model (Appendix A):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection -**Layer 4 — Default:** If nothing else matched, use `gpt-5.6-luna`. Cost wins when in doubt, unless code is being produced. +**`default`:** If nothing else matched, use the head of the fast tier chain. Cost wins when in doubt, unless code is being produced. -**Fallback chains — when a model is unavailable:** +**`fallback` — when a model is unavailable:** -If a spawn fails because the selected model is unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), silently retry with the next model in the chain. Do NOT tell the user about fallback attempts. Maximum 3 retries before using the platform default fallback. - -``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) -``` +If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model in its tier's fallback chain (Appendix A). The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the platform default fallback — it lets the platform choose the model. **Fallback rules:** -- If the user specified a provider ("use Claude"), fall back within that provider only before using the platform default fallback +- If the user specified a provider ("use {provider}"), fall back within that provider only before using the platform default fallback - Never fall back UP in tier — a fast/cheap task should not land on a premium model -- Log fallbacks to the orchestration log for debugging, but never surface to the user unless asked +- Log fallbacks (source `fallback` plus rationale) to the orchestration log for debugging, but never surface retries to the user unless asked **Passing the model to spawns:** @@ -76,23 +86,35 @@ prompt: | ... ``` -Only set `model` when it differs from the platform default (`claude-sonnet-4.6`). If the resolved model IS `claude-sonnet-4.6`, you MAY omit the `model` parameter — the platform uses it as default. - If you've exhausted the fallback chain and reached the platform default fallback, omit the `model` parameter entirely. -**Spawn output format — show the model choice:** +**Spawn output format — show the model, source and rationale:** -When spawning, include the model in your acknowledgment: +Every spawn acknowledgment names the resolved model, its source and a short rationale (the resolver's `rationale`, shortened if needed). Substitute the actual resolved model for `{model}`: ``` -🔧 Runtime Engineer (claude-sonnet-5) — refactoring auth module -🎨 Experience Engineer (gpt-5.6-sol · vision) — designing color system -📋 Scribe (gpt-5.6-luna · fast) — logging session -⚡ Lead (gpt-5.6-sol · bumped for architecture) — reviewing proposal -📝 Docs Engineer (gpt-5.6-luna · fast) — updating docs +{emoji} {Name} ({model} · {source}) — {task summary} — {rationale} + +🔧 Runtime Engineer ({model} · session) — refactoring auth module — session model inherited by this spawn +🎨 Experience Engineer ({model} · task-auto) — designing color system — visual task needs vision → premium tier +📋 Scribe ({model} · task-auto) — logging session — mechanical task → fast tier +⚡ Lead ({model} · config) — reviewing proposal — agentModelOverrides.lead in .squad/config.json +📝 Docs Engineer ({model} · fallback) — updating docs — charter model unavailable; next in fast chain +``` + +## Appendix A — Model catalog snapshot + +This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. + +**Fallback chains:** + +``` +Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) +Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) +Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` -Include tier annotation only when the model was bumped or a specialist was chosen. Default-tier spawns just show the model name. +**Specialists:** code specialist `gpt-5.3-codex` (standard); analytical diversity `gemini-3.1-pro` (standard). **Valid models (current platform catalog):** diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index 47dea3af4..e5ae6a307 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -393,11 +393,13 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn. Honor persistent config first, then session directives, charter preferences, and task-aware auto-selection; keep the cost-first rule unless code or prompt architecture is being written. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for the platform default fallback. +When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. -**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full layer hierarchy, role mapping, fallback chains, spawn formatting, and valid models catalog. +Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. + +**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full precedence, role-to-tier mapping, fallback rules, spawn formatting, and the model catalog appendix. ### Per-Agent Reasoning Effort @@ -424,7 +426,7 @@ When the resolved reasoning effort is not `auto` or default, include it in the a **Spawn output format — show the model choice and effort:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-4.7-1m-internal · xhigh) — deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · xhigh) — deep architecture analysis — charter preference`). ### Per-Agent Context Tier @@ -451,7 +453,7 @@ When the resolved context tier is not `auto` or default, include it in the agent **Spawn output format — show the model choice and tier:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-5 · long context) — 1M-token window for deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · long context) — deep architecture analysis — 1M-token window`). ### Client Compatibility diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index 6920b3b84..43bdb88a5 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -64,6 +64,7 @@ export { // --- M1-9 Model Selection + M3-5 Model Fallback --- export { resolveModel, + MODEL_RESOLUTION_PRECEDENCE, inferTierFromModel, isTierFallbackAllowed, ModelFallbackExecutor, diff --git a/packages/squad-sdk/src/agents/model-selector.ts b/packages/squad-sdk/src/agents/model-selector.ts index e7c135cd2..0dae5b68a 100644 --- a/packages/squad-sdk/src/agents/model-selector.ts +++ b/packages/squad-sdk/src/agents/model-selector.ts @@ -25,16 +25,54 @@ export type TaskType = 'code' | 'prompt' | 'docs' | 'visual' | 'planning' | 'mec export type ModelTier = 'premium' | 'standard' | 'fast'; /** - * Source of the model resolution. + * Source of the model resolution, in precedence order: + * - `user-override`: explicit per-spawn directive ("use X for this task") + * - `config`: persistent `.squad/config.json` override (per-agent or `defaultModel`) + * - `session`: model picked for the session; inherited by every sub-agent spawn + * - `charter`: the agent charter's `## Model` → `**Preferred:**` value (not `auto`) + * - `task-auto`: task-aware auto-selection + * - `default`: nothing else matched + * - `fallback`: chosen from the fallback chain after the preferred model was unavailable */ -export type ModelResolutionSource = 'user-override' | 'charter' | 'task-auto' | 'default'; +export type ModelResolutionSource = + | 'user-override' + | 'config' + | 'session' + | 'charter' + | 'task-auto' + | 'default' + | 'fallback'; + +/** + * Resolution precedence (first match wins). Fallback is not a layer: it only + * applies after the selected model is known to be unavailable. + */ +export const MODEL_RESOLUTION_PRECEDENCE: readonly ModelResolutionSource[] = [ + 'user-override', + 'config', + 'session', + 'charter', + 'task-auto', + 'default', +] as const; /** * Options for model resolution. */ export interface ModelResolutionOptions { - /** User-specified model override */ + /** Explicit per-spawn model directive (highest precedence) */ userOverride?: string; + /** + * Persistent `.squad/config.json` model for this agent: the caller passes + * `agentModelOverrides[agent]` when present, otherwise `defaultModel`. + */ + configModel?: string; + /** + * Model picked for the current session (by the user or the session itself). + * Passed unchanged to every sub-agent spawn so it is inherited with source + * `session`. Any model id passes through, including opt-in or preview ids. + */ + sessionModel?: string; /** Model preference from agent's charter (## Model section) */ charterPreference?: string; /** Type of task being performed */ @@ -50,6 +88,12 @@ export interface ModelResolutionOptions { config?: { costPolicy?: CostPolicyConfig }; /** Per-session cost policy override (wins over `config.costPolicy`). */ sessionCostPolicy?: SessionCostPolicyOverride; + /** + * Model ids known to be unavailable (plan restriction, org policy, rate + * limit, deprecation). When the resolved model is listed, the next available + * entry of its fallback chain is chosen with source `fallback`. + */ + unavailableModels?: Iterable; } /** @@ -66,6 +110,15 @@ export interface ResolvedModel { fallbackChain: string[]; /** Cost-policy outcome, present only when a policy was applied. */ policy?: CostPolicyOutcome; + /** Human-readable explanation of why this model was chosen. */ + rationale: string; + /** Model and source that were preferred before an availability fallback. */ + fallbackFrom?: { model: string; source: ModelResolutionSource }; + /** + * True when every fallback candidate was unavailable: omit the `model` + * parameter so the platform picks its default. `model` is then empty. + */ + omitModel?: boolean; } /** @@ -77,46 +130,56 @@ export interface EffectiveCostPolicy { } /** - * Resolve the appropriate model using the 4-layer priority system, then - * finalize against the effective cost policy (cost-ceiling axis). + * Central model resolver. Applies {@link MODEL_RESOLUTION_PRECEDENCE}, then + * finalizes against the effective cost policy (cost-ceiling axis), then walks + * the fallback chain if the chosen model is known to be unavailable. * * @param options - Model resolution options - * @returns Resolved model with tier, fallback chain, and optional policy outcome + * @returns Resolved model with tier, source, rationale, fallback chain, and optional policy outcome */ export function resolveModel(options: ModelResolutionOptions): ResolvedModel { const base = resolveBaseModel(options); const policy = buildEffectiveCostPolicy(options.config, options.sessionCostPolicy); - if (!policy) return base; - return finalizeResolvedModel(base, policy, buildCatalogCategoryMap()); + const finalized = policy ? finalizeResolvedModel(base, policy, buildCatalogCategoryMap()) : base; + return applyAvailabilityFallback(finalized, options.unavailableModels); +} + +function isSet(value: string | undefined): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function explicitModel(model: string, source: ModelResolutionSource, rationale: string): ResolvedModel { + const tier = inferTierFromModel(model); + return { + model, + tier, + source, + fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], + rationale, + }; } /** - * Layered base resolution (the original 4-layer selector). Unchanged behavior; - * split out so {@link resolveModel} can finalize the result against a policy. + * Layered base resolution. Explicit layers (user-override, config, session, + * charter) are never altered by economy mode. */ function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { - const { userOverride, charterPreference, taskType, economyMode } = options; + const { userOverride, configModel, sessionModel, charterPreference, taskType, economyMode } = options; - // Layer 1: User Override (explicit — economy does not apply) - if (userOverride && userOverride.trim().length > 0) { - const tier = inferTierFromModel(userOverride); - return { - model: userOverride, - tier, - source: 'user-override', - fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], - }; + if (isSet(userOverride)) { + return explicitModel(userOverride, 'user-override', `explicit per-spawn directive selected '${userOverride}'`); } - // Layer 2: Charter Preference (explicit — economy does not apply) - if (charterPreference && charterPreference.trim().length > 0 && charterPreference !== 'auto') { - const tier = inferTierFromModel(charterPreference); - return { - model: charterPreference, - tier, - source: 'charter', - fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], - }; + if (isSet(configModel) && configModel !== 'auto') { + return explicitModel(configModel, 'config', `persistent .squad/config.json override selected '${configModel}'`); + } + + if (isSet(sessionModel) && sessionModel !== 'auto') { + return explicitModel(sessionModel, 'session', `session model '${sessionModel}' inherited by this spawn`); + } + + if (isSet(charterPreference) && charterPreference !== 'auto') { + return explicitModel(charterPreference, 'charter', `charter preference selected '${charterPreference}'`); } // Layer 3: Task-Aware Auto-Selection (economy mode applies) @@ -135,6 +198,48 @@ function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { tier: defaultTier, source: 'default', fallbackChain: [...MODELS.FALLBACK_CHAINS[defaultTier]], + rationale: `no override, session, charter, or task match; default '${defaultModel}'` + + (economyMode ? ' (economy mode)' : ''), + }; +} + +/** + * Replace an unavailable model with the first available fallback-chain entry. + * When every candidate is unavailable, signal that the `model` parameter + * should be omitted so the platform default applies. + */ +function applyAvailabilityFallback( + resolved: ResolvedModel, + unavailableModels?: Iterable, +): ResolvedModel { + if (!unavailableModels) return resolved; + const unavailable = new Set(Array.from(unavailableModels, (id) => id.toLowerCase())); + if (!unavailable.has(resolved.model.toLowerCase())) return resolved; + + const fallbackFrom = { model: resolved.model, source: resolved.source }; + const next = resolved.fallbackChain.find( + (id) => id.toLowerCase() !== resolved.model.toLowerCase() && !unavailable.has(id.toLowerCase()), + ); + if (next) { + const index = resolved.fallbackChain.indexOf(next); + return { + ...resolved, + model: next, + tier: inferTierFromModel(next), + source: 'fallback', + fallbackChain: resolved.fallbackChain.slice(index), + fallbackFrom, + rationale: `'${resolved.model}' (${resolved.source}) unavailable; fell back to '${next}'`, + }; + } + return { + ...resolved, + model: '', + source: 'fallback', + fallbackChain: [], + fallbackFrom, + omitModel: true, + rationale: `'${resolved.model}' (${resolved.source}) and its fallback chain are unavailable; omit the model parameter for the platform default`, }; } @@ -146,31 +251,13 @@ function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { * @returns Resolved model or undefined if no match */ function selectModelForTask(taskType: TaskType, economyMode?: boolean): ResolvedModel | undefined { - let model: string | undefined; - let tier: ModelTier | undefined; - - switch (taskType) { - case 'code': - model = 'gpt-5.6-terra'; - tier = 'standard'; - break; - case 'prompt': - model = 'gpt-5.6-terra'; - tier = 'standard'; - break; - case 'visual': - model = 'gpt-5.6-sol'; - tier = 'premium'; - break; - case 'docs': - case 'planning': - case 'mechanical': - model = 'gpt-5.6-luna'; - tier = 'fast'; - break; - default: - return undefined; - } + const taskTier = TASK_TIERS[taskType]; + if (!taskTier) return undefined; + + // Task-aware picks are the head of the tier's fallback chain, so the + // catalog in runtime constants stays the single source of model ids. + let model: string = MODELS.FALLBACK_CHAINS[taskTier][0]; + let tier: ModelTier = taskTier; if (economyMode) { model = applyEconomyMode(model); @@ -182,9 +269,19 @@ function selectModelForTask(taskType: TaskType, economyMode?: boolean): Resolved tier, source: 'task-auto', fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], + rationale: `${taskType} task → ${taskTier} tier '${model}'` + (economyMode ? ' (economy mode)' : ''), }; } +const TASK_TIERS: Record = { + code: 'standard', + prompt: 'standard', + visual: 'premium', + docs: 'fast', + planning: 'fast', + mechanical: 'fast', +}; + export function inferTierFromModel(model: string): ModelTier { const lowerModel = model.toLowerCase(); const catalogModel = MODEL_CATALOG.find(modelInfo => modelInfo.id === lowerModel); @@ -290,7 +387,7 @@ function findInCeilingReplacement( * Branches: * - uncategorized model ⇒ passthrough (no policy action). * - within ceiling ⇒ prune the fallback chain to the ceiling. - * - over ceiling + EXPLICIT source (user-override/charter) ⇒ warn-and-allow; + * - over ceiling + EXPLICIT source (user-override/config/session/charter) ⇒ warn-and-allow; * the explicit model is kept as chain head, the rest pruned. * - over ceiling + IMPLICIT source (task-auto/default) ⇒ deterministic * downgrade to the best in-ceiling model. @@ -320,7 +417,11 @@ export function finalizeResolvedModel( }; } - const isExplicit = base.source === 'user-override' || base.source === 'charter'; + const isExplicit = + base.source === 'user-override' || + base.source === 'config' || + base.source === 'session' || + base.source === 'charter'; if (isExplicit) { // AC6: honor the explicit over-ceiling selection, but warn loudly. @@ -356,6 +457,7 @@ export function finalizeResolvedModel( tier: newTier, source: base.source, fallbackChain: prunedChain.length > 0 ? prunedChain : [replacement], + rationale: `${base.rationale ?? base.source}; downgraded to '${replacement}' to meet cost ceiling '${maxCategory}'`, policy: { action: 'downgraded-to-ceiling', originalModel: base.model, diff --git a/packages/squad-sdk/templates/model-selection-reference.md b/packages/squad-sdk/templates/model-selection-reference.md index ffc51eb71..b862c1fba 100644 --- a/packages/squad-sdk/templates/model-selection-reference.md +++ b/packages/squad-sdk/templates/model-selection-reference.md @@ -2,65 +2,75 @@ ## Per-Agent Model Selection -Before spawning an agent, determine which model to use. Check these layers in order — first match wins: +Before spawning an agent, resolve its model with the central resolver (`resolveModel` in `@bradygaster/squad-sdk`). Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. -**Layer 0 — Persistent Config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. +Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. + +| # | Source | Where the model comes from | +|---|--------|----------------------------| +| 1 | `user-override` | Explicit per-spawn directive in the current request ("use {model} for this task") | +| 2 | `config` | Persistent `.squad/config.json`: `agentModelOverrides.{agentName}`, otherwise `defaultModel` | +| 3 | `session` | The model picked for this session (by the user or the session itself); inherited by every sub-agent spawn | +| 4 | `charter` | The agent charter's `## Model` → `**Preferred:**` value, unless it is `auto` | +| 5 | `task-auto` | Task-aware tier selection (cost first, unless code is being written) | +| 6 | `default` | Nothing else matched | +| — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | + +`auto` at any layer means "no preference here"; continue to the next layer. + +**`user-override` — per-spawn directive:** A directive that names a model for one specific spawn ("have {agent} use {model} for this") applies to that spawn only. It does not persist and does not change the session model. + +**`config` — persistent config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. Because `config` outranks `session`, a session pick does not replace a saved config preference; tell the user to clear the config preference if they want the session pick to win. - **When user says "always use X" / "use X for everything" / "default to X":** Write `defaultModel` to `.squad/config.json`. Acknowledge: `✅ Model preference saved: {model} — all future sessions will use this until changed.` - **When user says "use X for {agent}":** Write to `agentModelOverrides.{agent}` in `.squad/config.json`. Acknowledge: `✅ {Agent} will always use {model} — saved to config.` - **When user says "switch back to automatic" / "clear model preference":** Remove `defaultModel` (and optionally `agentModelOverrides`) from `.squad/config.json`. Acknowledge: `✅ Model preference cleared — returning to automatic selection.` -**Layer 1 — Session Directive:** Did the user specify a model for this session? ("use opus for this session", "save costs"). If yes, use that model. Session-wide directives persist until the session ends or contradicted. +**`session` — session model:** If the user picked a model for this session ("use {model} for this session"), or the session itself is running on a model the user chose, that is the session model. Session-wide directives persist until the session ends or are contradicted. Pass the session model to **every** sub-agent spawn unchanged, with source `session`; sub-agents inherit it rather than re-resolving from their charter or task. Pass it through even when it is not listed in Appendix A (for example, an opt-in or preview model the user selected). -**Layer 2 — Charter Preference:** Does the agent's charter have a `## Model` section with `Preferred` set to a specific model (not `auto`)? If yes, use that model. +**`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**Layer 3 — Task-Aware Auto-Selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to determine output type, then select accordingly: +**`task-auto` — task-aware auto-selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to its output type, select the tier, and use the head of that tier's fallback chain (Appendix A): -| Task Output | Model | Tier | Rule | -|-------------|-------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | `gpt-5.6-terra` | Standard | Quality and accuracy matter for code. Use standard tier. | -| Writing prompts or agent designs (structured text that functions like code) | `gpt-5.6-terra` | Standard | Prompts are executable — treat like code. | -| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | `gpt-5.6-luna` | Fast | Cost first. Luna handles non-code tasks. | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | Premium | Vision capability required. Overrides cost rule. | +| Task Output | Tier | Rule | +|-------------|------|------| +| Writing code (implementation, refactoring, test code, bug fixes) | Standard | Quality and accuracy matter for code. | +| Writing prompts or agent designs (structured text that functions like code) | Standard | Prompts are executable — treat like code. | +| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | Fast | Cost first. | +| Visual/design work requiring image analysis | Premium | Vision capability required. Overrides cost rule. | -**Role-to-model mapping** (applying cost-first principle): +**Role-to-tier mapping** (applying the cost-first principle): -| Role | Default Model | Why | Override When | +| Role | Default Tier | Why | Override When | |------|--------------|-----|---------------| -| Core Dev / Backend / Frontend | `gpt-5.6-terra` | Writes code — quality first | Heavy code gen → `gpt-5.3-codex` | -| Tester / QA | `gpt-5.6-terra` | Writes test code — quality first | Simple test scaffolding → `claude-haiku-4.5` | -| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → haiku | -| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → sonnet; research/analysis → haiku | -| Copilot SDK Expert | `gpt-5.6-terra` | Technical analysis that often touches code | Pure research → `gpt-5.6-luna` | -| Designer / Visual | `gpt-5.6-sol` | Vision-capable model required | — (never downgrade — vision is non-negotiable) | -| DevRel / Writer | `gpt-5.6-luna` | Docs and writing — not code | — | -| Scribe / Logger | `gpt-5.6-luna` | Mechanical file ops — cheapest possible | — (never bump Scribe) | -| Git / Release | `gpt-5.6-luna` | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | +| Core Dev / Backend / Frontend | Standard | Writes code — quality first | Heavy code gen → code specialist | +| Tester / QA | Standard | Writes test code — quality first | Simple test scaffolding → fast | +| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → fast | +| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → standard; research/analysis → fast | +| Copilot SDK Expert | Standard | Technical analysis that often touches code | Pure research → fast | +| Designer / Visual | Premium | Vision-capable model required | — (never downgrade — vision is non-negotiable) | +| DevRel / Writer | Fast | Docs and writing — not code | — | +| Scribe / Logger | Fast | Mechanical file ops — cheapest possible | — (never bump Scribe) | +| Git / Release | Fast | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | **Task complexity adjustments** (apply at most ONE — no cascading): - **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) -- **Bump DOWN to fast/cheap:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps -- **Switch to code specialist (`gpt-5.3-codex`):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) -- **Switch to analytical diversity (`gemini-3.1-pro`):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection +- **Bump DOWN to fast:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps +- **Switch to the code specialist (Appendix A):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) +- **Switch to the analytical-diversity model (Appendix A):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection -**Layer 4 — Default:** If nothing else matched, use `gpt-5.6-luna`. Cost wins when in doubt, unless code is being produced. +**`default`:** If nothing else matched, use the head of the fast tier chain. Cost wins when in doubt, unless code is being produced. -**Fallback chains — when a model is unavailable:** +**`fallback` — when a model is unavailable:** -If a spawn fails because the selected model is unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), silently retry with the next model in the chain. Do NOT tell the user about fallback attempts. Maximum 3 retries before using the platform default fallback. - -``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) -``` +If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model in its tier's fallback chain (Appendix A). The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the platform default fallback — it lets the platform choose the model. **Fallback rules:** -- If the user specified a provider ("use Claude"), fall back within that provider only before using the platform default fallback +- If the user specified a provider ("use {provider}"), fall back within that provider only before using the platform default fallback - Never fall back UP in tier — a fast/cheap task should not land on a premium model -- Log fallbacks to the orchestration log for debugging, but never surface to the user unless asked +- Log fallbacks (source `fallback` plus rationale) to the orchestration log for debugging, but never surface retries to the user unless asked **Passing the model to spawns:** @@ -76,23 +86,35 @@ prompt: | ... ``` -Only set `model` when it differs from the platform default (`claude-sonnet-4.6`). If the resolved model IS `claude-sonnet-4.6`, you MAY omit the `model` parameter — the platform uses it as default. - If you've exhausted the fallback chain and reached the platform default fallback, omit the `model` parameter entirely. -**Spawn output format — show the model choice:** +**Spawn output format — show the model, source and rationale:** -When spawning, include the model in your acknowledgment: +Every spawn acknowledgment names the resolved model, its source and a short rationale (the resolver's `rationale`, shortened if needed). Substitute the actual resolved model for `{model}`: ``` -🔧 Runtime Engineer (claude-sonnet-5) — refactoring auth module -🎨 Experience Engineer (gpt-5.6-sol · vision) — designing color system -📋 Scribe (gpt-5.6-luna · fast) — logging session -⚡ Lead (gpt-5.6-sol · bumped for architecture) — reviewing proposal -📝 Docs Engineer (gpt-5.6-luna · fast) — updating docs +{emoji} {Name} ({model} · {source}) — {task summary} — {rationale} + +🔧 Runtime Engineer ({model} · session) — refactoring auth module — session model inherited by this spawn +🎨 Experience Engineer ({model} · task-auto) — designing color system — visual task needs vision → premium tier +📋 Scribe ({model} · task-auto) — logging session — mechanical task → fast tier +⚡ Lead ({model} · config) — reviewing proposal — agentModelOverrides.lead in .squad/config.json +📝 Docs Engineer ({model} · fallback) — updating docs — charter model unavailable; next in fast chain +``` + +## Appendix A — Model catalog snapshot + +This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. + +**Fallback chains:** + +``` +Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) +Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) +Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` -Include tier annotation only when the model was bumped or a specialist was chosen. Default-tier spawns just show the model name. +**Specialists:** code specialist `gpt-5.3-codex` (standard); analytical diversity `gemini-3.1-pro` (standard). **Valid models (current platform catalog):** diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index 47dea3af4..e5ae6a307 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -393,11 +393,13 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn. Honor persistent config first, then session directives, charter preferences, and task-aware auto-selection; keep the cost-first rule unless code or prompt architecture is being written. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for the platform default fallback. +When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. -**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full layer hierarchy, role mapping, fallback chains, spawn formatting, and valid models catalog. +Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. + +**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full precedence, role-to-tier mapping, fallback rules, spawn formatting, and the model catalog appendix. ### Per-Agent Reasoning Effort @@ -424,7 +426,7 @@ When the resolved reasoning effort is not `auto` or default, include it in the a **Spawn output format — show the model choice and effort:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-4.7-1m-internal · xhigh) — deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · xhigh) — deep architecture analysis — charter preference`). ### Per-Agent Context Tier @@ -451,7 +453,7 @@ When the resolved context tier is not `auto` or default, include it in the agent **Spawn output format — show the model choice and tier:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-5 · long context) — 1M-token window for deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · long context) — deep architecture analysis — 1M-token window`). ### Client Compatibility diff --git a/templates/model-selection-reference.md b/templates/model-selection-reference.md index ffc51eb71..b862c1fba 100644 --- a/templates/model-selection-reference.md +++ b/templates/model-selection-reference.md @@ -2,65 +2,75 @@ ## Per-Agent Model Selection -Before spawning an agent, determine which model to use. Check these layers in order — first match wins: +Before spawning an agent, resolve its model with the central resolver (`resolveModel` in `@bradygaster/squad-sdk`). Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. -**Layer 0 — Persistent Config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. +Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. + +| # | Source | Where the model comes from | +|---|--------|----------------------------| +| 1 | `user-override` | Explicit per-spawn directive in the current request ("use {model} for this task") | +| 2 | `config` | Persistent `.squad/config.json`: `agentModelOverrides.{agentName}`, otherwise `defaultModel` | +| 3 | `session` | The model picked for this session (by the user or the session itself); inherited by every sub-agent spawn | +| 4 | `charter` | The agent charter's `## Model` → `**Preferred:**` value, unless it is `auto` | +| 5 | `task-auto` | Task-aware tier selection (cost first, unless code is being written) | +| 6 | `default` | Nothing else matched | +| — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | + +`auto` at any layer means "no preference here"; continue to the next layer. + +**`user-override` — per-spawn directive:** A directive that names a model for one specific spawn ("have {agent} use {model} for this") applies to that spawn only. It does not persist and does not change the session model. + +**`config` — persistent config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. Because `config` outranks `session`, a session pick does not replace a saved config preference; tell the user to clear the config preference if they want the session pick to win. - **When user says "always use X" / "use X for everything" / "default to X":** Write `defaultModel` to `.squad/config.json`. Acknowledge: `✅ Model preference saved: {model} — all future sessions will use this until changed.` - **When user says "use X for {agent}":** Write to `agentModelOverrides.{agent}` in `.squad/config.json`. Acknowledge: `✅ {Agent} will always use {model} — saved to config.` - **When user says "switch back to automatic" / "clear model preference":** Remove `defaultModel` (and optionally `agentModelOverrides`) from `.squad/config.json`. Acknowledge: `✅ Model preference cleared — returning to automatic selection.` -**Layer 1 — Session Directive:** Did the user specify a model for this session? ("use opus for this session", "save costs"). If yes, use that model. Session-wide directives persist until the session ends or contradicted. +**`session` — session model:** If the user picked a model for this session ("use {model} for this session"), or the session itself is running on a model the user chose, that is the session model. Session-wide directives persist until the session ends or are contradicted. Pass the session model to **every** sub-agent spawn unchanged, with source `session`; sub-agents inherit it rather than re-resolving from their charter or task. Pass it through even when it is not listed in Appendix A (for example, an opt-in or preview model the user selected). -**Layer 2 — Charter Preference:** Does the agent's charter have a `## Model` section with `Preferred` set to a specific model (not `auto`)? If yes, use that model. +**`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**Layer 3 — Task-Aware Auto-Selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to determine output type, then select accordingly: +**`task-auto` — task-aware auto-selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to its output type, select the tier, and use the head of that tier's fallback chain (Appendix A): -| Task Output | Model | Tier | Rule | -|-------------|-------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | `gpt-5.6-terra` | Standard | Quality and accuracy matter for code. Use standard tier. | -| Writing prompts or agent designs (structured text that functions like code) | `gpt-5.6-terra` | Standard | Prompts are executable — treat like code. | -| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | `gpt-5.6-luna` | Fast | Cost first. Luna handles non-code tasks. | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | Premium | Vision capability required. Overrides cost rule. | +| Task Output | Tier | Rule | +|-------------|------|------| +| Writing code (implementation, refactoring, test code, bug fixes) | Standard | Quality and accuracy matter for code. | +| Writing prompts or agent designs (structured text that functions like code) | Standard | Prompts are executable — treat like code. | +| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | Fast | Cost first. | +| Visual/design work requiring image analysis | Premium | Vision capability required. Overrides cost rule. | -**Role-to-model mapping** (applying cost-first principle): +**Role-to-tier mapping** (applying the cost-first principle): -| Role | Default Model | Why | Override When | +| Role | Default Tier | Why | Override When | |------|--------------|-----|---------------| -| Core Dev / Backend / Frontend | `gpt-5.6-terra` | Writes code — quality first | Heavy code gen → `gpt-5.3-codex` | -| Tester / QA | `gpt-5.6-terra` | Writes test code — quality first | Simple test scaffolding → `claude-haiku-4.5` | -| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → haiku | -| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → sonnet; research/analysis → haiku | -| Copilot SDK Expert | `gpt-5.6-terra` | Technical analysis that often touches code | Pure research → `gpt-5.6-luna` | -| Designer / Visual | `gpt-5.6-sol` | Vision-capable model required | — (never downgrade — vision is non-negotiable) | -| DevRel / Writer | `gpt-5.6-luna` | Docs and writing — not code | — | -| Scribe / Logger | `gpt-5.6-luna` | Mechanical file ops — cheapest possible | — (never bump Scribe) | -| Git / Release | `gpt-5.6-luna` | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | +| Core Dev / Backend / Frontend | Standard | Writes code — quality first | Heavy code gen → code specialist | +| Tester / QA | Standard | Writes test code — quality first | Simple test scaffolding → fast | +| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → fast | +| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → standard; research/analysis → fast | +| Copilot SDK Expert | Standard | Technical analysis that often touches code | Pure research → fast | +| Designer / Visual | Premium | Vision-capable model required | — (never downgrade — vision is non-negotiable) | +| DevRel / Writer | Fast | Docs and writing — not code | — | +| Scribe / Logger | Fast | Mechanical file ops — cheapest possible | — (never bump Scribe) | +| Git / Release | Fast | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | **Task complexity adjustments** (apply at most ONE — no cascading): - **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) -- **Bump DOWN to fast/cheap:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps -- **Switch to code specialist (`gpt-5.3-codex`):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) -- **Switch to analytical diversity (`gemini-3.1-pro`):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection +- **Bump DOWN to fast:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps +- **Switch to the code specialist (Appendix A):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) +- **Switch to the analytical-diversity model (Appendix A):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection -**Layer 4 — Default:** If nothing else matched, use `gpt-5.6-luna`. Cost wins when in doubt, unless code is being produced. +**`default`:** If nothing else matched, use the head of the fast tier chain. Cost wins when in doubt, unless code is being produced. -**Fallback chains — when a model is unavailable:** +**`fallback` — when a model is unavailable:** -If a spawn fails because the selected model is unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), silently retry with the next model in the chain. Do NOT tell the user about fallback attempts. Maximum 3 retries before using the platform default fallback. - -``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) -``` +If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model in its tier's fallback chain (Appendix A). The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the platform default fallback — it lets the platform choose the model. **Fallback rules:** -- If the user specified a provider ("use Claude"), fall back within that provider only before using the platform default fallback +- If the user specified a provider ("use {provider}"), fall back within that provider only before using the platform default fallback - Never fall back UP in tier — a fast/cheap task should not land on a premium model -- Log fallbacks to the orchestration log for debugging, but never surface to the user unless asked +- Log fallbacks (source `fallback` plus rationale) to the orchestration log for debugging, but never surface retries to the user unless asked **Passing the model to spawns:** @@ -76,23 +86,35 @@ prompt: | ... ``` -Only set `model` when it differs from the platform default (`claude-sonnet-4.6`). If the resolved model IS `claude-sonnet-4.6`, you MAY omit the `model` parameter — the platform uses it as default. - If you've exhausted the fallback chain and reached the platform default fallback, omit the `model` parameter entirely. -**Spawn output format — show the model choice:** +**Spawn output format — show the model, source and rationale:** -When spawning, include the model in your acknowledgment: +Every spawn acknowledgment names the resolved model, its source and a short rationale (the resolver's `rationale`, shortened if needed). Substitute the actual resolved model for `{model}`: ``` -🔧 Runtime Engineer (claude-sonnet-5) — refactoring auth module -🎨 Experience Engineer (gpt-5.6-sol · vision) — designing color system -📋 Scribe (gpt-5.6-luna · fast) — logging session -⚡ Lead (gpt-5.6-sol · bumped for architecture) — reviewing proposal -📝 Docs Engineer (gpt-5.6-luna · fast) — updating docs +{emoji} {Name} ({model} · {source}) — {task summary} — {rationale} + +🔧 Runtime Engineer ({model} · session) — refactoring auth module — session model inherited by this spawn +🎨 Experience Engineer ({model} · task-auto) — designing color system — visual task needs vision → premium tier +📋 Scribe ({model} · task-auto) — logging session — mechanical task → fast tier +⚡ Lead ({model} · config) — reviewing proposal — agentModelOverrides.lead in .squad/config.json +📝 Docs Engineer ({model} · fallback) — updating docs — charter model unavailable; next in fast chain +``` + +## Appendix A — Model catalog snapshot + +This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. + +**Fallback chains:** + +``` +Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) +Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) +Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` -Include tier annotation only when the model was bumped or a specialist was chosen. Default-tier spawns just show the model name. +**Specialists:** code specialist `gpt-5.3-codex` (standard); analytical diversity `gemini-3.1-pro` (standard). **Valid models (current platform catalog):** diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index 47dea3af4..e5ae6a307 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -393,11 +393,13 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn. Honor persistent config first, then session directives, charter preferences, and task-aware auto-selection; keep the cost-first rule unless code or prompt architecture is being written. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -Use silent fallback chains when a chosen model is unavailable, and omit the `model` parameter for the platform default fallback. +When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. -**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full layer hierarchy, role mapping, fallback chains, spawn formatting, and valid models catalog. +Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. + +**On-demand reference:** Read `.squad/templates/model-selection-reference.md` for the full precedence, role-to-tier mapping, fallback rules, spawn formatting, and the model catalog appendix. ### Per-Agent Reasoning Effort @@ -424,7 +426,7 @@ When the resolved reasoning effort is not `auto` or default, include it in the a **Spawn output format — show the model choice and effort:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-4.7-1m-internal · xhigh) — deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default reasoning effort, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · xhigh) — deep architecture analysis — charter preference`). ### Per-Agent Context Tier @@ -451,7 +453,7 @@ When the resolved context tier is not `auto` or default, include it in the agent **Spawn output format — show the model choice and tier:** -Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink (claude-opus-5 · long context) — 1M-token window for deep architecture analysis`). +Follow `.squad/templates/model-selection-reference.md` for the base model-selection rules. When an agent uses a non-default context tier, append it in the acknowledgment (for example, `🧠 DeepThink ({model} · charter · long context) — deep architecture analysis — 1M-token window`). ### Client Compatibility diff --git a/test/coordinator.test.ts b/test/coordinator.test.ts index 6b4afbbbe..08386abad 100644 --- a/test/coordinator.test.ts +++ b/test/coordinator.test.ts @@ -24,6 +24,10 @@ import { type ResolvedModel, type ModelTier, } from '@bradygaster/squad-sdk/agents'; +import { MODEL_CATALOG } from '@bradygaster/squad-sdk/config'; +import { MODELS } from '@bradygaster/squad-sdk'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; // --- Helpers --- @@ -580,3 +584,99 @@ describe('isTierFallbackAllowed', () => { expect(isTierFallbackAllowed('premium', 'standard', false)).toBe(false); }); }); + +// ============================================================================= +// Contract: coordinator spawn guidance is model-ID agnostic (#1720) +// ============================================================================= + +describe('coordinator spawn guidance contract (#1720)', () => { + const ROOT = resolve(import.meta.dirname, '..'); + const SPAWN_SECTIONS = [ + '### Per-Agent Model Selection', + '### Per-Agent Reasoning Effort', + '### Per-Agent Context Tier', + '### How to Spawn an Agent', + ]; + const AGENT_FILES = ['.squad-templates/squad.agent.md', '.github/agents/squad.agent.md']; + const REFERENCE_FILE = '.squad-templates/model-selection-reference.md'; + const SPAWN_REFERENCE_FILE = '.squad-templates/spawn-reference.md'; + + const knownIds = new Set([ + ...MODEL_CATALOG.map((m) => m.id.toLowerCase()), + ...Object.values(MODELS.FALLBACK_CHAINS).flat().map((id) => id.toLowerCase()), + ]); + const ID_PATTERN = /\b(?:gpt|claude|gemini|grok|o[1-9])-[a-z0-9][a-z0-9.-]*|\b(?:opus|sonnet|haiku|codex)\b/gi; + + function section(text: string, heading: string, file: string): { body: string; startLine: number } { + const lines = text.split(/\r?\n/); + const start = lines.findIndex((l) => l.trim() === heading); + expect(start, `${file} is missing spawn-guidance section '${heading}'`).toBeGreaterThanOrEqual(0); + let end = lines.findIndex((l, i) => i > start && /^#{2,3} /.test(l)); + if (end === -1) end = lines.length; + return { body: lines.slice(start, end).join('\n'), startLine: start + 1 }; + } + + function offenders(label: string, body: string, startLine: number): string[] { + const found: string[] = []; + body.split('\n').forEach((line, i) => { + for (const id of knownIds) { + if (new RegExp(`(^|[^a-z0-9.-])${id.replace(/[.]/g, '\\.')}($|[^a-z0-9.-])`, 'i').test(line)) { + found.push(`${label}:${startLine + i}: '${id}'`); + } + } + for (const match of line.matchAll(ID_PATTERN)) { + const hit = `${label}:${startLine + i}: '${match[0].toLowerCase()}'`; + if (!found.includes(hit)) found.push(hit); + } + }); + return found; + } + + function guidanceTexts(): { label: string; body: string; startLine: number }[] { + const out: { label: string; body: string; startLine: number }[] = []; + for (const file of AGENT_FILES) { + const text = readFileSync(resolve(ROOT, file), 'utf8'); + for (const heading of SPAWN_SECTIONS) { + out.push({ label: `${file} › ${heading}`, ...section(text, heading, file) }); + } + } + const reference = readFileSync(resolve(ROOT, REFERENCE_FILE), 'utf8'); + // Catalog data lives in '## Appendix A'; without it, the whole file is guidance. + const appendixAt = reference.search(/^## Appendix A\b/m); + const guidance = appendixAt > 0 ? reference.slice(0, appendixAt) : reference; + out.push({ label: `${REFERENCE_FILE} (guidance before Appendix A)`, body: guidance, startLine: 1 }); + out.push({ label: SPAWN_REFERENCE_FILE, body: readFileSync(resolve(ROOT, SPAWN_REFERENCE_FILE), 'utf8'), startLine: 1 }); + return out; + } + + it('contains no hard-coded model IDs', () => { + const found = guidanceTexts().flatMap(({ label, body, startLine }) => offenders(label, body, startLine)); + expect(found, `Hard-coded model IDs in coordinator spawn guidance:\n${found.join('\n')}`).toEqual([]); + }); + + it('defers to the central resolver precedence and acknowledges model, source and rationale', () => { + const order = ['user-override', 'config', 'session', 'charter', 'task-auto', 'default']; + for (const file of AGENT_FILES) { + const { body } = section(readFileSync(resolve(ROOT, file), 'utf8'), '### Per-Agent Model Selection', file); + const positions = order.map((source) => body.indexOf(`**${source}**`)); + expect( + positions.every((p, i) => p >= 0 && (i === 0 || p > positions[i - 1]!)), + `${file} Per-Agent Model Selection must list sources in order ${order.join(' > ')}; positions ${JSON.stringify( + Object.fromEntries(order.map((s, i) => [s, positions[i]])), + )}`, + ).toBe(true); + expect(body, `${file} spawn acknowledgment must show model, source and rationale`).toContain( + '({model} · {source})', + ); + expect(body, `${file} spawn acknowledgment must include a rationale`).toContain('{rationale}'); + } + const reference = readFileSync(resolve(ROOT, REFERENCE_FILE), 'utf8'); + expect( + /^## Appendix A\b/m.test(reference), + `${REFERENCE_FILE} must keep catalog IDs in '## Appendix A', separate from spawn guidance`, + ).toBe(true); + for (const source of [...order, 'fallback']) { + expect(reference, `${REFERENCE_FILE} must document resolver source '${source}'`).toContain(`\`${source}\``); + } + }); +}); diff --git a/test/model-preference.test.ts b/test/model-preference.test.ts index e4f94e881..a53acf933 100644 --- a/test/model-preference.test.ts +++ b/test/model-preference.test.ts @@ -31,6 +31,13 @@ import { resolveContextTier, clampContextTier, } from '@bradygaster/squad-sdk/config'; +import { + resolveModel as resolveAgentModel, + MODEL_RESOLUTION_PRECEDENCE, + type ModelResolutionOptions, + type ResolvedModel, +} from '@bradygaster/squad-sdk/agents'; +import { MODELS } from '@bradygaster/squad-sdk'; // Temp directory for each test let squadDir: string; @@ -1018,3 +1025,137 @@ describe('clampContextTier', () => { expect(clampContextTier('turbo', ['default', 'long_context'])).toBe('default'); }); }); + +// ============================================================================ +// Central model resolver (agents/model-selector) — #1720 +// ============================================================================ + +describe('central resolver: config, session, fallback sources + rationale (#1720)', () => { + type Opts = ModelResolutionOptions; + const describeCall = (opts: Opts) => + `resolveAgentModel(${JSON.stringify(opts, (_k, v) => (v instanceof Set ? [...v] : v))})`; + + function expectResolution(opts: Opts, model: string, source: string): ResolvedModel { + const result = resolveAgentModel(opts); + expect( + { model: result.model, source: result.source }, + `${describeCall(opts)} should pick '${model}' from '${source}', got '${result.model}' from '${result.source}'`, + ).toEqual({ model, source }); + expect( + typeof result.rationale === 'string' && result.rationale.length > 0, + `${describeCall(opts)} returned no rationale`, + ).toBe(true); + return result; + } + + it('exports the documented precedence order', () => { + expect( + [...MODEL_RESOLUTION_PRECEDENCE], + 'MODEL_RESOLUTION_PRECEDENCE must be user-override > config > session > charter > task-auto > default', + ).toEqual(['user-override', 'config', 'session', 'charter', 'task-auto', 'default']); + }); + + it('config source: persistent .squad/config.json model wins over session, charter and task', () => { + const r = expectResolution( + { configModel: 'cfg-model', sessionModel: 'sess-model', charterPreference: 'charter-model', taskType: 'code' }, + 'cfg-model', + 'config', + ); + expect(r.rationale, `config rationale should name the model and config file: '${r.rationale}'`).toMatch( + /config\.json.*'cfg-model'/, + ); + }); + + it('session source: session model wins over charter and task', () => { + const r = expectResolution( + { sessionModel: 'sess-model', charterPreference: 'charter-model', taskType: 'visual' }, + 'sess-model', + 'session', + ); + expect(r.rationale, `session rationale should say the model is inherited: '${r.rationale}'`).toMatch( + /session model 'sess-model' inherited/, + ); + }); + + it('user-override still beats config and session', () => { + expectResolution( + { userOverride: 'spawn-model', configModel: 'cfg-model', sessionModel: 'sess-model' }, + 'spawn-model', + 'user-override', + ); + }); + + it('applies every precedence layer in order as higher layers are removed', () => { + const full: Opts = { + userOverride: 'L-user', + configModel: 'L-config', + sessionModel: 'L-session', + charterPreference: 'L-charter', + taskType: 'code', + }; + const keys: (keyof Opts)[] = ['userOverride', 'configModel', 'sessionModel', 'charterPreference', 'taskType']; + const observed: string[] = []; + const opts: Opts = { ...full }; + for (const key of [...keys, undefined]) { + const result = resolveAgentModel(opts); + observed.push(`${result.source}`); + expect(result.rationale, `${describeCall(opts)} returned no rationale`).toBeTruthy(); + if (key) delete opts[key]; + } + expect(observed, `sources observed while peeling layers off ${JSON.stringify(full)}`).toEqual([ + ...MODEL_RESOLUTION_PRECEDENCE, + ]); + }); + + it("'auto' at config or session means no preference and falls through", () => { + expectResolution({ configModel: 'auto', sessionModel: 'auto', charterPreference: 'charter-model' }, 'charter-model', 'charter'); + }); + + it('session inheritance: every sub-agent spawn inherits the session model, whatever its charter or task', () => { + const sessionModel = 'hydrafusion'; + const spawns: Opts[] = [ + { charterPreference: 'auto', taskType: 'docs' }, + { charterPreference: 'gpt-5.6-sol', taskType: 'visual' }, + { taskType: 'code', economyMode: true }, + { taskType: 'mechanical' }, + {}, + ]; + for (const spawn of spawns) { + expectResolution({ ...spawn, sessionModel }, sessionModel, 'session'); + } + }); + + it('task-auto and default results carry a rationale', () => { + const task = expectResolution({ taskType: 'docs' }, MODELS.FALLBACK_CHAINS.fast[0], 'task-auto'); + expect(task.rationale, `task-auto rationale should name the task type: '${task.rationale}'`).toMatch(/docs task/); + const def = expectResolution({}, MODELS.FALLBACK_CHAINS.fast[0], 'default'); + expect(def.rationale, `default rationale should say nothing matched: '${def.rationale}'`).toMatch(/default/); + }); + + it('fallback source: an unavailable chosen model is replaced by the next available chain entry', () => { + const chain = MODELS.FALLBACK_CHAINS.standard; + const opts: Opts = { charterPreference: chain[0], unavailableModels: [chain[0], chain[1]] }; + const r = expectResolution(opts, chain[2], 'fallback'); + expect(r.fallbackFrom, `${describeCall(opts)} should record what it fell back from`).toEqual({ + model: chain[0], + source: 'charter', + }); + expect(r.rationale, `fallback rationale should name the unavailable model: '${r.rationale}'`).toContain( + `'${chain[0]}' (charter) unavailable`, + ); + }); + + it('fallback source: an exhausted chain asks the caller to omit the model parameter', () => { + const chain = MODELS.FALLBACK_CHAINS.fast; + const opts: Opts = { taskType: 'docs', unavailableModels: [...chain] }; + const r = resolveAgentModel(opts); + expect( + { source: r.source, omitModel: r.omitModel, model: r.model }, + `${describeCall(opts)} should signal omit-model fallback`, + ).toEqual({ source: 'fallback', omitModel: true, model: '' }); + }); + + it('available models are not touched by the fallback step', () => { + expectResolution({ sessionModel: 'sess-model', unavailableModels: ['other-model'] }, 'sess-model', 'session'); + }); +}); From 14cae4866d1511bd2f7d7d14094c5a0f26120e12 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Wed, 14 Oct 2026 11:42:31 -0700 Subject: [PATCH 2/9] fix(sdk): wire central model resolver into spawning; forward fallback and auto sentinel Revision of the central model resolver after review: - The root `resolveModel` export is now a compatible string adapter over the central resolver, so it reads .squad/config.json through the same precedence and no longer returns a literal `auto`. Guidance names the provenance-returning entry point `@bradygaster/squad-sdk/agents`. - AgentLifecycleManager.spawnAgent applies persistent config (agentModelOverrides, defaultModel, economyMode) and records provenance on AgentHandle.modelResolution, OTel span attributes and an agent:milestone `model.resolved` event. - Availability fallback moves forward from the chosen chain position; out-of-chain models search from the head; no remaining candidate sets omitModel. - `auto` (any case/whitespace) is no preference at every layer, including the per-spawn override. - ResolvedModel.rationale stays optional for consumer-built values; resolveModel returns ModelResolution with rationale guaranteed. Refs #1720 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e022a055-6a4e-4c88-b619-bd46be161840 --- .changeset/central-model-resolver.md | 4 +- .github/agents/squad.agent.md | 4 +- .squad-templates/model-selection-reference.md | 8 +- .squad-templates/squad.agent.md | 4 +- .../templates/model-selection-reference.md | 8 +- .../templates/squad.agent.md.template | 4 +- packages/squad-sdk/src/agents/index.ts | 5 + packages/squad-sdk/src/agents/lifecycle.ts | 87 ++++++++++++- .../squad-sdk/src/agents/model-selector.ts | 117 ++++++++++++++---- packages/squad-sdk/src/config/models.ts | 73 ++++------- .../templates/model-selection-reference.md | 8 +- .../templates/squad.agent.md.template | 4 +- templates/model-selection-reference.md | 8 +- templates/squad.agent.md.template | 4 +- test/lifecycle.test.ts | 83 +++++++++++++ test/model-preference.test.ts | 101 +++++++++++++++ 16 files changed, 422 insertions(+), 100 deletions(-) diff --git a/.changeset/central-model-resolver.md b/.changeset/central-model-resolver.md index dedb31758..0e5f1966b 100644 --- a/.changeset/central-model-resolver.md +++ b/.changeset/central-model-resolver.md @@ -3,4 +3,6 @@ "@bradygaster/squad-cli": patch --- -Central model resolver (#1720): `resolveModel` in `@bradygaster/squad-sdk` adds `config`, `session` and `fallback` sources, applies the exported `MODEL_RESOLUTION_PRECEDENCE` (user-override > config > session > charter > task-auto > default), and returns a `rationale` for every choice. A session-picked model is inherited by every sub-agent spawn with source `session`, and `unavailableModels` triggers fallback-chain selection. Coordinator spawn guidance and `model-selection-reference.md` no longer hard-code model IDs; spawn acknowledgments show model, source and rationale, with catalog IDs kept in a separate appendix. +Central model resolver (#1720): `resolveModel` from `@bradygaster/squad-sdk/agents` adds `config`, `session` and `fallback` sources, applies the exported `MODEL_RESOLUTION_PRECEDENCE` (user-override > config > session > charter > task-auto > default), and returns a `rationale` for every choice (typed as `ModelResolution`; `ResolvedModel.rationale` stays optional so consumer-built values remain valid). `auto` (any case) means "no preference" at every layer, including a per-spawn override. A session-picked model is inherited by every sub-agent spawn with source `session`. When a model listed in `unavailableModels` is in its tier chain, fallback moves only forward to the next available entry; models outside the chain search it from the head; with no candidate left, `omitModel` is set. + +The root `resolveModel` export (string-returning, reads `.squad/config.json`) is now a compatible adapter over the central resolver, so it no longer returns a literal `auto`. `AgentLifecycleManager.spawnAgent` now applies persistent `.squad/config.json` preferences (`agentModelOverrides`, `defaultModel`, `economyMode`) and records provenance on `AgentHandle.modelResolution`, OTel span attributes and an `agent:milestone` `model.resolved` event. New helpers: `isModelPreference`, `persistentConfigModel`. Coordinator spawn guidance and `model-selection-reference.md` no longer hard-code model IDs; spawn acknowledgments show model, source and rationale, with catalog IDs kept in a separate appendix. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index e5ae6a307..c052c255d 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -393,9 +393,9 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. +When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. diff --git a/.squad-templates/model-selection-reference.md b/.squad-templates/model-selection-reference.md index b862c1fba..32ae22934 100644 --- a/.squad-templates/model-selection-reference.md +++ b/.squad-templates/model-selection-reference.md @@ -2,7 +2,7 @@ ## Per-Agent Model Selection -Before spawning an agent, resolve its model with the central resolver (`resolveModel` in `@bradygaster/squad-sdk`). Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. +Before spawning an agent, resolve its model with the central resolver (`resolveModel` from `@bradygaster/squad-sdk/agents`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. @@ -16,7 +16,7 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium | 6 | `default` | Nothing else matched | | — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | -`auto` at any layer means "no preference here"; continue to the next layer. +`auto` at any layer (any letter case) means "no preference here"; continue to the next layer. This includes a per-spawn directive, the persistent config fields, the session model and the charter. **`user-override` — per-spawn directive:** A directive that names a model for one specific spawn ("have {agent} use {model} for this") applies to that spawn only. It does not persist and does not change the session model. @@ -63,7 +63,7 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`fallback` — when a model is unavailable:** -If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model in its tier's fallback chain (Appendix A). The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. +If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model **after** it in its tier's fallback chain (Appendix A) — never back toward the head of the chain. A model that is not in its tier's chain (for example a session-picked opt-in model) tries that chain from the head. The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the platform default fallback — it lets the platform choose the model. @@ -86,7 +86,7 @@ prompt: | ... ``` -If you've exhausted the fallback chain and reached the platform default fallback, omit the `model` parameter entirely. +If no later fallback candidate is available (the resolver sets `omitModel`), omit the `model` parameter entirely. **Spawn output format — show the model, source and rationale:** diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index e5ae6a307..c052c255d 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -393,9 +393,9 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. +When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. diff --git a/packages/squad-cli/templates/model-selection-reference.md b/packages/squad-cli/templates/model-selection-reference.md index b862c1fba..32ae22934 100644 --- a/packages/squad-cli/templates/model-selection-reference.md +++ b/packages/squad-cli/templates/model-selection-reference.md @@ -2,7 +2,7 @@ ## Per-Agent Model Selection -Before spawning an agent, resolve its model with the central resolver (`resolveModel` in `@bradygaster/squad-sdk`). Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. +Before spawning an agent, resolve its model with the central resolver (`resolveModel` from `@bradygaster/squad-sdk/agents`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. @@ -16,7 +16,7 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium | 6 | `default` | Nothing else matched | | — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | -`auto` at any layer means "no preference here"; continue to the next layer. +`auto` at any layer (any letter case) means "no preference here"; continue to the next layer. This includes a per-spawn directive, the persistent config fields, the session model and the charter. **`user-override` — per-spawn directive:** A directive that names a model for one specific spawn ("have {agent} use {model} for this") applies to that spawn only. It does not persist and does not change the session model. @@ -63,7 +63,7 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`fallback` — when a model is unavailable:** -If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model in its tier's fallback chain (Appendix A). The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. +If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model **after** it in its tier's fallback chain (Appendix A) — never back toward the head of the chain. A model that is not in its tier's chain (for example a session-picked opt-in model) tries that chain from the head. The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the platform default fallback — it lets the platform choose the model. @@ -86,7 +86,7 @@ prompt: | ... ``` -If you've exhausted the fallback chain and reached the platform default fallback, omit the `model` parameter entirely. +If no later fallback candidate is available (the resolver sets `omitModel`), omit the `model` parameter entirely. **Spawn output format — show the model, source and rationale:** diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index e5ae6a307..c052c255d 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -393,9 +393,9 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. +When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index 43bdb88a5..061d7623f 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -72,8 +72,12 @@ export { buildEffectiveCostPolicy, buildCatalogCategoryMap, pruneChainToCeiling, + isModelPreference, + persistentConfigModel, type ModelResolutionOptions, type ResolvedModel, + type ModelResolution, + type PersistentModelConfig, type EffectiveCostPolicy, type TaskType, type ModelTier, @@ -90,6 +94,7 @@ export { type AgentStatus, type SpawnAgentOptions, type LifecycleManagerConfig, + type AgentModelResolution, } from './lifecycle.js'; // --- M1-11 History Shadows --- diff --git a/packages/squad-sdk/src/agents/lifecycle.ts b/packages/squad-sdk/src/agents/lifecycle.ts index 92423752d..f8556256c 100644 --- a/packages/squad-sdk/src/agents/lifecycle.ts +++ b/packages/squad-sdk/src/agents/lifecycle.ts @@ -10,7 +10,14 @@ import { SquadClientWithPool } from '../client/index.js'; import type { SquadSession, SquadSessionConfig, SquadReasoningEffort, SquadContextTier } from '../adapter/types.js'; import { compileCharterFull, type CharterCompileOptions } from './charter-compiler.js'; -import { resolveModel, type ModelResolutionOptions, type TaskType } from './model-selector.js'; +import { + resolveModel, + persistentConfigModel, + type ModelResolution, + type ModelResolutionOptions, + type ModelResolutionSource, + type TaskType, +} from './model-selector.js'; import { VALID_REASONING_EFFORTS, VALID_CONTEXT_TIERS } from '../config/models.js'; import type { CostPolicyConfig, SessionCostPolicyOverride } from '../config/models.js'; import type { EventBus } from '../runtime/event-bus.js'; @@ -40,6 +47,8 @@ export interface AgentHandle { sessionId: string; /** Model being used for this agent */ model: string; + /** How {@link model} was resolved (source, rationale, availability fallback). */ + modelResolution?: AgentModelResolution; /** Current agent status */ status: AgentStatus; /** Timestamp when agent was spawned */ @@ -60,6 +69,20 @@ export interface AgentHandle { destroy(): Promise; } +/** + * Provenance of an agent's model, recorded at spawn time. + */ +export interface AgentModelResolution { + /** Precedence layer that selected the model. */ + source: ModelResolutionSource; + /** Human-readable explanation from the central resolver. */ + rationale: string; + /** Model and source preferred before an availability fallback. */ + fallbackFrom?: { model: string; source: ModelResolutionSource }; + /** True when the model parameter was omitted so the platform default applies. */ + omitModel?: boolean; +} + /** * Options for spawning an agent. */ @@ -164,7 +187,7 @@ export class AgentLifecycleManager { * Pipeline: * 1. Read charter.md from team root * 2. Compile charter with team context - * 3. Resolve model using 4-layer priority + * 3. Resolve model via the central resolver (persistent config included) * 4. Create session via Squad Client * 5. Set up event handlers * 6. Return AgentHandle @@ -226,9 +249,14 @@ export class AgentLifecycleManager { const agentConfig = compileCharterFull(compileOptions); - // Step 3: Resolve model (with cost-policy finalization, issue #1080/#1183) + // Step 3: Resolve model (with cost-policy finalization, issue #1080/#1183). + // Persistent .squad/config.json lives beside the agents/ directory. + const squadDir = path.dirname(path.dirname(path.dirname(charterPath))); + const persistent = await this.readPersistentModelConfig(squadDir); const modelOptions: ModelResolutionOptions = { userOverride: modelOverride, + configModel: persistentConfigModel(persistent, agentName), + economyMode: persistent?.economyMode === true, charterPreference: agentConfig.resolvedModel !== undefined ? agentConfig.resolvedModel : this.extractModelPreference(charterContent), @@ -239,6 +267,9 @@ export class AgentLifecycleManager { }; const resolvedModel = resolveModel(modelOptions); + span.setAttribute('model.id', resolvedModel.model); + span.setAttribute('model.source', resolvedModel.source); + await this.emitModelResolved(agentName, resolvedModel); // Surface the cost-policy outcome — this was the #1089 bug: the policy // result was computed but never wired/emitted. Never swallow it. @@ -264,7 +295,7 @@ export class AgentLifecycleManager { ? rawTier as SquadContextTier : undefined; const sessionConfig: SquadSessionConfig = { - model: resolvedModel.model, + ...(resolvedModel.omitModel ? {} : { model: resolvedModel.model }), systemMessage: { content: agentConfig.prompt, }, @@ -279,6 +310,12 @@ export class AgentLifecycleManager { agentName, sessionId: session.sessionId, model: resolvedModel.model, + modelResolution: { + source: resolvedModel.source, + rationale: resolvedModel.rationale, + ...(resolvedModel.fallbackFrom ? { fallbackFrom: resolvedModel.fallbackFrom } : {}), + ...(resolvedModel.omitModel ? { omitModel: true } : {}), + }, session, idleTimeout, onDestroy: () => this.agents.delete(session.sessionId), @@ -400,6 +437,45 @@ export class AgentLifecycleManager { return modelMatch ? modelMatch[1]!.trim() : undefined; } + /** + * Read the persistent model fields of `.squad/config.json`, if present. + * @private + */ + private async readPersistentModelConfig( + squadDir: string, + ): Promise<{ defaultModel?: unknown; agentModelOverrides?: unknown; economyMode?: unknown } | undefined> { + const raw = await this.storage.read(path.join(squadDir, 'config.json')); + if (raw === undefined) return undefined; + try { + const parsed: unknown = JSON.parse(raw); + return parsed !== null && typeof parsed === 'object' ? (parsed as Record) : undefined; + } catch { + return undefined; + } + } + + /** + * Record model-resolution provenance as an `agent:milestone` event + * (`event: 'model.resolved'`) when an event bus is configured. + * @private + */ + private async emitModelResolved(agentName: string, resolved: ModelResolution): Promise { + if (!this.eventBus) return; + await this.eventBus.emit({ + type: 'agent:milestone', + agentName, + payload: { + event: 'model.resolved', + model: resolved.model, + source: resolved.source, + rationale: resolved.rationale, + ...(resolved.fallbackFrom ? { fallbackFrom: resolved.fallbackFrom } : {}), + ...(resolved.omitModel ? { omitModel: true } : {}), + }, + timestamp: new Date(), + }); + } + /** * Surface a cost-policy outcome (downgrade / warn-allow / fail-closed). * @@ -444,6 +520,7 @@ class AgentHandleImpl implements AgentHandle { agentName: string; sessionId: string; model: string; + modelResolution?: AgentModelResolution; status: AgentStatus; createdAt: Date; lastActivityAt: Date; @@ -456,6 +533,7 @@ class AgentHandleImpl implements AgentHandle { agentName: string; sessionId: string; model: string; + modelResolution?: AgentModelResolution; session: SquadSession; idleTimeout: number; onDestroy: () => void; @@ -463,6 +541,7 @@ class AgentHandleImpl implements AgentHandle { this.agentName = config.agentName; this.sessionId = config.sessionId; this.model = config.model; + this.modelResolution = config.modelResolution; this.session = config.session; this.idleTimeout = config.idleTimeout; this.onDestroy = config.onDestroy; diff --git a/packages/squad-sdk/src/agents/model-selector.ts b/packages/squad-sdk/src/agents/model-selector.ts index 0dae5b68a..dae0e3d1c 100644 --- a/packages/squad-sdk/src/agents/model-selector.ts +++ b/packages/squad-sdk/src/agents/model-selector.ts @@ -75,8 +75,13 @@ export interface ModelResolutionOptions { sessionModel?: string; /** Model preference from agent's charter (## Model section) */ charterPreference?: string; - /** Type of task being performed */ - taskType: TaskType; + /** Type of task being performed. When omitted, task-aware selection is skipped. */ + taskType?: TaskType; + /** + * Caller-chosen task-aware model. When set (and not `auto`) it replaces the + * tier pick of the `task-auto` layer; economy mode still applies to it. + */ + taskModel?: string; /** Agent role (for context) */ agentRole?: string; /** When true, apply economy mode substitution at Layer 3/4 */ @@ -91,7 +96,9 @@ export interface ModelResolutionOptions { /** * Model ids known to be unavailable (plan restriction, org policy, rate * limit, deprecation). When the resolved model is listed, the next available - * entry of its fallback chain is chosen with source `fallback`. + * fallback-chain entry after it is chosen with source `fallback`; a model + * that is not in its chain searches the chain from the head. When no + * candidate remains, `omitModel` is set. */ unavailableModels?: Iterable; } @@ -110,8 +117,12 @@ export interface ResolvedModel { fallbackChain: string[]; /** Cost-policy outcome, present only when a policy was applied. */ policy?: CostPolicyOutcome; - /** Human-readable explanation of why this model was chosen. */ - rationale: string; + /** + * Human-readable explanation of why this model was chosen. Always set on + * {@link resolveModel} output (see {@link ModelResolution}); optional here so + * consumer-constructed values stay valid. + */ + rationale?: string; /** Model and source that were preferred before an availability fallback. */ fallbackFrom?: { model: string; source: ModelResolutionSource }; /** @@ -121,6 +132,9 @@ export interface ResolvedModel { omitModel?: boolean; } +/** Output of {@link resolveModel}: a {@link ResolvedModel} with guaranteed provenance. */ +export type ModelResolution = ResolvedModel & { rationale: string }; + /** * Effective (merged) cost policy after combining persistent config + session * override. Only produced when a `maxCategory` is actually set. @@ -137,15 +151,49 @@ export interface EffectiveCostPolicy { * @param options - Model resolution options * @returns Resolved model with tier, source, rationale, fallback chain, and optional policy outcome */ -export function resolveModel(options: ModelResolutionOptions): ResolvedModel { +export function resolveModel(options: ModelResolutionOptions): ModelResolution { const base = resolveBaseModel(options); const policy = buildEffectiveCostPolicy(options.config, options.sessionCostPolicy); const finalized = policy ? finalizeResolvedModel(base, policy, buildCatalogCategoryMap()) : base; - return applyAvailabilityFallback(finalized, options.unavailableModels); + const resolved = applyAvailabilityFallback(finalized, options.unavailableModels); + return { ...resolved, rationale: resolved.rationale ?? resolved.source }; } -function isSet(value: string | undefined): value is string { - return typeof value === 'string' && value.trim().length > 0; +/** + * True when a layer value expresses a model preference. Empty values and the + * `auto` sentinel (any case, surrounding whitespace ignored) mean "no + * preference here; continue to the next layer". + */ +export function isModelPreference(value: string | null | undefined): value is string { + if (typeof value !== 'string') return false; + const trimmed = value.trim(); + return trimmed.length > 0 && trimmed.toLowerCase() !== 'auto'; +} + +/** Persistent `.squad/config.json` model fields consulted by {@link persistentConfigModel}. */ +export interface PersistentModelConfig { + defaultModel?: unknown; + agentModelOverrides?: unknown; +} + +/** + * Pick the persistent `config` layer model for an agent: its + * `agentModelOverrides` entry, otherwise `defaultModel`. `auto` at either + * field means no preference there. + */ +export function persistentConfigModel( + config: PersistentModelConfig | null | undefined, + agentName?: string, +): string | undefined { + if (!config || typeof config !== 'object') return undefined; + const overrides = config.agentModelOverrides; + if (agentName && overrides && typeof overrides === 'object') { + const agentModel = (overrides as Record)[agentName]; + if (typeof agentModel === 'string' && isModelPreference(agentModel)) return agentModel.trim(); + } + const defaultModel = config.defaultModel; + if (typeof defaultModel === 'string' && isModelPreference(defaultModel)) return defaultModel.trim(); + return undefined; } function explicitModel(model: string, source: ModelResolutionSource, rationale: string): ResolvedModel { @@ -164,26 +212,34 @@ function explicitModel(model: string, source: ModelResolutionSource, rationale: * charter) are never altered by economy mode. */ function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { - const { userOverride, configModel, sessionModel, charterPreference, taskType, economyMode } = options; + const { taskType, taskModel, economyMode } = options; + const userOverride = options.userOverride?.trim(); + const configModel = options.configModel?.trim(); + const sessionModel = options.sessionModel?.trim(); + const charterPreference = options.charterPreference?.trim(); - if (isSet(userOverride)) { + if (isModelPreference(userOverride)) { return explicitModel(userOverride, 'user-override', `explicit per-spawn directive selected '${userOverride}'`); } - if (isSet(configModel) && configModel !== 'auto') { + if (isModelPreference(configModel)) { return explicitModel(configModel, 'config', `persistent .squad/config.json override selected '${configModel}'`); } - if (isSet(sessionModel) && sessionModel !== 'auto') { + if (isModelPreference(sessionModel)) { return explicitModel(sessionModel, 'session', `session model '${sessionModel}' inherited by this spawn`); } - if (isSet(charterPreference) && charterPreference !== 'auto') { + if (isModelPreference(charterPreference)) { return explicitModel(charterPreference, 'charter', `charter preference selected '${charterPreference}'`); } // Layer 3: Task-Aware Auto-Selection (economy mode applies) - const autoSelected = selectModelForTask(taskType, economyMode); + const autoSelected = isModelPreference(taskModel) + ? selectTaskModel(taskModel.trim(), economyMode) + : taskType + ? selectModelForTask(taskType, economyMode) + : undefined; if (autoSelected) { return autoSelected; } @@ -204,9 +260,11 @@ function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { } /** - * Replace an unavailable model with the first available fallback-chain entry. - * When every candidate is unavailable, signal that the `model` parameter - * should be omitted so the platform default applies. + * Replace an unavailable model with the next available fallback-chain entry. + * A model found in its chain searches only the entries after it (never back + * toward the head); a model not in its chain searches the chain from the + * head. When no candidate remains, signal that the `model` parameter should + * be omitted so the platform default applies. */ function applyAvailabilityFallback( resolved: ResolvedModel, @@ -217,11 +275,13 @@ function applyAvailabilityFallback( if (!unavailable.has(resolved.model.toLowerCase())) return resolved; const fallbackFrom = { model: resolved.model, source: resolved.source }; - const next = resolved.fallbackChain.find( - (id) => id.toLowerCase() !== resolved.model.toLowerCase() && !unavailable.has(id.toLowerCase()), + const position = resolved.fallbackChain.findIndex((id) => id.toLowerCase() === resolved.model.toLowerCase()); + const start = position + 1; // 0 when the model is not in its chain + const index = resolved.fallbackChain.findIndex( + (id, i) => i >= start && !unavailable.has(id.toLowerCase()), ); + const next = index >= 0 ? resolved.fallbackChain[index] : undefined; if (next) { - const index = resolved.fallbackChain.indexOf(next); return { ...resolved, model: next, @@ -239,7 +299,7 @@ function applyAvailabilityFallback( fallbackChain: [], fallbackFrom, omitModel: true, - rationale: `'${resolved.model}' (${resolved.source}) and its fallback chain are unavailable; omit the model parameter for the platform default`, + rationale: `'${resolved.model}' (${resolved.source}) unavailable and no later fallback-chain candidate is available; omit the model parameter for the platform default`, }; } @@ -273,6 +333,19 @@ function selectModelForTask(taskType: TaskType, economyMode?: boolean): Resolved }; } +/** Caller-supplied task model (legacy `taskModel`), with optional economy substitution. */ +function selectTaskModel(taskModel: string, economyMode?: boolean): ResolvedModel { + const model = economyMode ? applyEconomyMode(taskModel) : taskModel; + const tier = inferTierFromModel(model); + return { + model, + tier, + source: 'task-auto', + fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], + rationale: `task-aware model '${model}'` + (economyMode ? ' (economy mode)' : ''), + }; +} + const TASK_TIERS: Record = { code: 'standard', prompt: 'standard', diff --git a/packages/squad-sdk/src/config/models.ts b/packages/squad-sdk/src/config/models.ts index 6e8cb5596..2d0cda9f6 100644 --- a/packages/squad-sdk/src/config/models.ts +++ b/packages/squad-sdk/src/config/models.ts @@ -12,6 +12,7 @@ import type { StorageProvider } from '../storage/index.js'; import { FSStorageProvider } from '../storage/index.js'; import type { ModelId, ModelTier } from '../runtime/config.js'; import type { SquadReasoningEffort, SquadContextTier } from '../adapter/types.js'; +import { resolveModel as resolveCentralModel, persistentConfigModel } from '../agents/model-selector.js'; /** * Per-token pricing in USD. @@ -1475,18 +1476,14 @@ export function resolveContextTier(options: { } /** - * Resolves the effective model for an agent spawn using the 5-layer hierarchy: - * Layer 0: Persistent config (.squad/config.json defaultModel) - * Layer 1: Session-wide user directive ("always use opus") - * Layer 2: Charter preference (agent's ## Model section) - * Layer 3: Task-aware auto-selection (code → sonnet, docs → haiku) - * Layer 4: Default (gpt-5.6-luna) + * Legacy string-returning adapter over the central model resolver + * (`resolveModel` in `@bradygaster/squad-sdk/agents`). It reads persistent + * `.squad/config.json` preferences and delegates precedence, the `auto` + * sentinel, and economy-mode handling to the central resolver: + * config (agentModelOverrides, then defaultModel) > session directive > + * charter preference > task model > default. * - * Per-agent overrides from config.json take priority over the global defaultModel. - * - * Economy mode modifier: when active (via economyMode option or config.json), - * shifts model selection at Layer 3 and Layer 4 to cheaper alternatives per - * ECONOMY_MODEL_MAP. Layers 0–2 (explicit user preferences) are never overridden. + * Use the central resolver directly when you need the source and rationale. * * @param options - Resolution inputs * @returns Resolved model ID @@ -1505,44 +1502,26 @@ export function resolveModel(options: { const { agentName, squadDir, sessionDirective, charterPreference, taskModel } = options; const storage = options.storage ?? new FSStorageProvider(); - // Layer 0a: Per-agent persistent override (explicit — economy does not apply) - if (squadDir && agentName) { - const agentOverrides = readAgentModelOverrides(squadDir, storage); - if (agentOverrides[agentName]) { - return agentOverrides[agentName]!; - } - } - - // Layer 0b: Global persistent config (explicit — economy does not apply) - if (squadDir) { - const persistedModel = readModelPreference(squadDir, storage); - if (persistedModel) { - return persistedModel; - } - } - - // Layer 1: Session-wide user directive (explicit — economy does not apply) - if (sessionDirective) { - return sessionDirective; - } - - // Layer 2: Charter preference (explicit — economy does not apply) - if (charterPreference) { - return charterPreference; - } - - // Determine if economy mode is active (option takes precedence over config) - const isEconomy = + const configModel = squadDir + ? persistentConfigModel( + { + agentModelOverrides: agentName ? readAgentModelOverrides(squadDir, storage) : undefined, + defaultModel: readModelPreference(squadDir, storage) ?? undefined, + }, + agentName, + ) + : undefined; + + const economyMode = options.economyMode !== undefined ? options.economyMode : (squadDir ? readEconomyMode(squadDir, storage) : false); - // Layer 3: Task-aware auto-selection (economy mode applies) - if (taskModel) { - return isEconomy ? applyEconomyMode(taskModel) : taskModel; - } - - // Layer 4: Default (economy mode applies) - const defaultModel = 'gpt-5.6-luna'; - return isEconomy ? applyEconomyMode(defaultModel) : defaultModel; + return resolveCentralModel({ + configModel, + sessionModel: sessionDirective ?? undefined, + charterPreference: charterPreference ?? undefined, + taskModel: taskModel ?? undefined, + economyMode, + }).model; } diff --git a/packages/squad-sdk/templates/model-selection-reference.md b/packages/squad-sdk/templates/model-selection-reference.md index b862c1fba..32ae22934 100644 --- a/packages/squad-sdk/templates/model-selection-reference.md +++ b/packages/squad-sdk/templates/model-selection-reference.md @@ -2,7 +2,7 @@ ## Per-Agent Model Selection -Before spawning an agent, resolve its model with the central resolver (`resolveModel` in `@bradygaster/squad-sdk`). Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. +Before spawning an agent, resolve its model with the central resolver (`resolveModel` from `@bradygaster/squad-sdk/agents`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. @@ -16,7 +16,7 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium | 6 | `default` | Nothing else matched | | — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | -`auto` at any layer means "no preference here"; continue to the next layer. +`auto` at any layer (any letter case) means "no preference here"; continue to the next layer. This includes a per-spawn directive, the persistent config fields, the session model and the charter. **`user-override` — per-spawn directive:** A directive that names a model for one specific spawn ("have {agent} use {model} for this") applies to that spawn only. It does not persist and does not change the session model. @@ -63,7 +63,7 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`fallback` — when a model is unavailable:** -If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model in its tier's fallback chain (Appendix A). The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. +If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model **after** it in its tier's fallback chain (Appendix A) — never back toward the head of the chain. A model that is not in its tier's chain (for example a session-picked opt-in model) tries that chain from the head. The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the platform default fallback — it lets the platform choose the model. @@ -86,7 +86,7 @@ prompt: | ... ``` -If you've exhausted the fallback chain and reached the platform default fallback, omit the `model` parameter entirely. +If no later fallback candidate is available (the resolver sets `omitModel`), omit the `model` parameter entirely. **Spawn output format — show the model, source and rationale:** diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index e5ae6a307..c052c255d 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -393,9 +393,9 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. +When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. diff --git a/templates/model-selection-reference.md b/templates/model-selection-reference.md index b862c1fba..32ae22934 100644 --- a/templates/model-selection-reference.md +++ b/templates/model-selection-reference.md @@ -2,7 +2,7 @@ ## Per-Agent Model Selection -Before spawning an agent, resolve its model with the central resolver (`resolveModel` in `@bradygaster/squad-sdk`). Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. +Before spawning an agent, resolve its model with the central resolver (`resolveModel` from `@bradygaster/squad-sdk/agents`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. @@ -16,7 +16,7 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium | 6 | `default` | Nothing else matched | | — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | -`auto` at any layer means "no preference here"; continue to the next layer. +`auto` at any layer (any letter case) means "no preference here"; continue to the next layer. This includes a per-spawn directive, the persistent config fields, the session model and the charter. **`user-override` — per-spawn directive:** A directive that names a model for one specific spawn ("have {agent} use {model} for this") applies to that spawn only. It does not persist and does not change the session model. @@ -63,7 +63,7 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`fallback` — when a model is unavailable:** -If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model in its tier's fallback chain (Appendix A). The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. +If the resolved model is known to be unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), or a spawn fails for that reason, silently retry with the next model **after** it in its tier's fallback chain (Appendix A) — never back toward the head of the chain. A model that is not in its tier's chain (for example a session-picked opt-in model) tries that chain from the head. The source becomes `fallback` and the rationale names the unavailable model and its original source. Do NOT tell the user about retry attempts. Maximum 3 retries before using the platform default fallback. `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the platform default fallback — it lets the platform choose the model. @@ -86,7 +86,7 @@ prompt: | ... ``` -If you've exhausted the fallback chain and reached the platform default fallback, omit the `model` parameter entirely. +If no later fallback candidate is available (the resolver sets `omitModel`), omit the `model` parameter entirely. **Spawn output format — show the model, source and rationale:** diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index e5ae6a307..c052c255d 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -393,9 +393,9 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`, unless `auto`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. -When the resolved model is unavailable, walk its tier fallback chain silently (source `fallback`); once the chain is exhausted, omit the `model` parameter for the platform default. +When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. Every spawn acknowledgment shows the model, its source and a short rationale: `{emoji} {Name} ({model} · {source}) — {task summary} — {rationale}`. diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index edb4fd4c1..1915d13fe 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -443,6 +443,89 @@ Test collaboration. }); }); +describe('Agent Lifecycle Manager — persistent model preference + provenance (#1720)', () => { + let tempDir: string; + let squadDir: string; + let mockClient: SquadClientWithPool; + let sessionConfigs: Array>; + let events: Array>; + let manager: AgentLifecycleManager; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'squad-model-pref-spawn-')); + squadDir = path.join(tempDir, '.squad'); + const agentsDir = path.join(squadDir, 'agents', 'pref-agent'); + await fs.mkdir(agentsDir, { recursive: true }); + await fs.writeFile( + path.join(agentsDir, 'charter.md'), + `# Pref Agent\n\n## Identity\n\n**Name:** Pref Agent\n**Role:** Test\n\n## Model\n\n**Preferred:** charter-model\n`, + 'utf-8', + ); + mockClient = createMockClient(); + sessionConfigs = []; + const create = mockClient.createSession.bind(mockClient); + (mockClient as any).createSession = async (config: Record) => { + sessionConfigs.push(config); + return create(config as any); + }; + const eventBus = new EventBus(); + events = []; + eventBus.subscribe('agent:milestone', (e) => { + events.push(e.payload as Record); + }); + manager = new AgentLifecycleManager({ client: mockClient, teamRoot: tempDir, defaultIdleTimeout: 60_000, eventBus }); + }); + + afterEach(async () => { + await manager.shutdown(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function writeConfig(config: Record): Promise { + await fs.writeFile(path.join(squadDir, 'config.json'), JSON.stringify({ version: 1, ...config }), 'utf-8'); + } + + it('per-agent persistent config beats charter and task in the created session', async () => { + await writeConfig({ defaultModel: 'global-model', agentModelOverrides: { 'pref-agent': 'persisted-model' } }); + const handle = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work', taskType: 'visual' }); + + expect(sessionConfigs[0]?.model, `createSession model with agentModelOverrides set: ${JSON.stringify(sessionConfigs[0])}`).toBe('persisted-model'); + expect(handle.model).toBe('persisted-model'); + expect(handle.modelResolution?.source, `handle provenance: ${JSON.stringify(handle.modelResolution)}`).toBe('config'); + expect(handle.modelResolution?.rationale).toMatch(/config\.json.*'persisted-model'/); + }); + + it('global defaultModel applies when the agent has no override', async () => { + await writeConfig({ defaultModel: 'global-model' }); + const handle = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work' }); + expect(sessionConfigs[0]?.model, `createSession model with defaultModel set: ${JSON.stringify(sessionConfigs[0])}`).toBe('global-model'); + expect(handle.modelResolution?.source).toBe('config'); + }); + + it("'auto' persisted preference falls through to the charter", async () => { + await writeConfig({ defaultModel: 'auto' }); + const handle = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work' }); + expect(sessionConfigs[0]?.model, `createSession model with defaultModel 'auto': ${JSON.stringify(sessionConfigs[0])}`).toBe('charter-model'); + expect(handle.modelResolution?.source).toBe('charter'); + }); + + it('explicit spawn override still beats persistent config', async () => { + await writeConfig({ defaultModel: 'global-model' }); + const handle = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work', modelOverride: 'spawn-model' }); + expect(sessionConfigs[0]?.model).toBe('spawn-model'); + expect(handle.modelResolution?.source).toBe('user-override'); + }); + + it('records resolution provenance as a model.resolved milestone event', async () => { + await writeConfig({ agentModelOverrides: { 'pref-agent': 'persisted-model' } }); + await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work' }); + const resolved = events.filter((e) => e.event === 'model.resolved'); + expect(resolved, `model.resolved events: ${JSON.stringify(events)}`).toHaveLength(1); + expect(resolved[0]).toMatchObject({ model: 'persisted-model', source: 'config' }); + expect(String(resolved[0]!.rationale)).toMatch(/config\.json/); + }); +}); + // --- Mock Client --- function createMockClient(): SquadClientWithPool { diff --git a/test/model-preference.test.ts b/test/model-preference.test.ts index a53acf933..362a9173a 100644 --- a/test/model-preference.test.ts +++ b/test/model-preference.test.ts @@ -1159,3 +1159,104 @@ describe('central resolver: config, session, fallback sources + rationale (#1720 expectResolution({ sessionModel: 'sess-model', unavailableModels: ['other-model'] }, 'sess-model', 'session'); }); }); + +// ============================================================================ +// Central resolver revision: forward fallback, sentinel, legacy adapter (#1720) +// ============================================================================ + +describe('central resolver: forward fallback from the chosen chain position (#1720)', () => { + const chain = MODELS.FALLBACK_CHAINS.standard; + + it('a middle-chain pick falls forward to the next available candidate, never back to the head', () => { + const opts: ModelResolutionOptions = { charterPreference: chain[2], taskType: 'code', unavailableModels: [chain[2]] }; + const r = resolveAgentModel(opts); + expect( + { model: r.model, source: r.source }, + `resolveModel(charterPreference=${chain[2]}, unavailable=[${chain[2]}]) must pick next candidate ${chain[3]}, got '${r.model}' (${r.source})`, + ).toEqual({ model: chain[3], source: 'fallback' }); + expect(r.fallbackChain[0], `fallbackChain must start at the chosen fallback '${chain[3]}'`).toBe(chain[3]); + }); + + it('skips unavailable subsequent candidates and keeps searching forward', () => { + const opts: ModelResolutionOptions = { charterPreference: chain[1], taskType: 'code', unavailableModels: [chain[1], chain[2]] }; + const r = resolveAgentModel(opts); + expect(r.model, `charter ${chain[1]} with ${chain[1]},${chain[2]} unavailable must pick ${chain[3]}, got '${r.model}'`).toBe(chain[3]); + }); + + it('the last chain entry being unavailable exhausts the chain: omit the model rather than wrapping to the head', () => { + const last = chain[chain.length - 1]!; + const r = resolveAgentModel({ charterPreference: last, taskType: 'code', unavailableModels: [last] }); + expect( + { omitModel: r.omitModel, model: r.model, source: r.source }, + `last-entry '${last}' unavailable must omit the model param, got '${r.model}' omit=${r.omitModel}`, + ).toEqual({ omitModel: true, model: '', source: 'fallback' }); + expect(r.fallbackFrom, 'exhausted fallback must still record what it fell back from').toEqual({ model: last, source: 'charter' }); + }); + + it('an out-of-chain pick searches its tier chain from the head', () => { + const r = resolveAgentModel({ sessionModel: 'custom-out-of-chain', taskType: 'code', unavailableModels: ['custom-out-of-chain', chain[0]] }); + expect(r.model, `out-of-chain 'custom-out-of-chain' unavailable must pick first available ${chain[1]}, got '${r.model}'`).toBe(chain[1]); + expect(r.fallbackFrom).toEqual({ model: 'custom-out-of-chain', source: 'session' }); + }); +}); + +describe("central resolver: 'auto' sentinel at every layer (#1720)", () => { + it("userOverride 'auto' means no preference and falls through to config", () => { + const opts: ModelResolutionOptions = { userOverride: 'auto', configModel: 'saved-model', taskType: 'docs' }; + const r = resolveAgentModel(opts); + expect( + { model: r.model, source: r.source }, + `resolveModel(${JSON.stringify(opts)}) must skip the 'auto' override; got '${r.model}' (${r.source})`, + ).toEqual({ model: 'saved-model', source: 'config' }); + }); + + it("'auto' is case- and whitespace-insensitive at every explicit layer", () => { + const opts: ModelResolutionOptions = { + userOverride: ' AUTO ', + configModel: 'Auto', + sessionModel: 'auto ', + charterPreference: 'AUTO', + taskType: 'docs', + }; + const r = resolveAgentModel(opts); + expect( + { model: r.model, source: r.source }, + `resolveModel(${JSON.stringify(opts)}) must treat every 'auto' as no preference; got '${r.model}' (${r.source})`, + ).toEqual({ model: MODELS.FALLBACK_CHAINS.fast[0], source: 'task-auto' }); + }); +}); + +describe('legacy config resolveModel delegates to the central resolver (#1720)', () => { + it("charter 'auto' with a task model returns the task model, not the literal sentinel", () => { + const taskModel = MODELS.FALLBACK_CHAINS.fast[0]; + const got = resolveModel({ charterPreference: 'auto', taskModel }); + expect(got, `legacy resolveModel({charterPreference:'auto', taskModel:'${taskModel}'}) returned '${got}'`).toBe(taskModel); + }); + + it("'auto' session directive and config defaultModel fall through", () => { + writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ version: 1, defaultModel: 'auto', agentModelOverrides: { fenster: 'auto' } })); + const got = resolveModel({ agentName: 'fenster', squadDir, sessionDirective: 'auto', charterPreference: 'charter-model' }); + expect(got, `legacy resolveModel with every higher layer 'auto' must pick the charter model, got '${got}'`).toBe('charter-model'); + }); + + it('agrees with the central resolver for persistent config', () => { + writeFileSync(join(squadDir, 'config.json'), JSON.stringify({ version: 1, agentModelOverrides: { fenster: 'persisted' } })); + const legacy = resolveModel({ agentName: 'fenster', squadDir, sessionDirective: 'sess', charterPreference: 'charter' }); + const central = resolveAgentModel({ configModel: 'persisted', sessionModel: 'sess', charterPreference: 'charter', taskType: 'code' }); + expect(legacy, `legacy '${legacy}' must equal central '${central.model}'`).toBe(central.model); + }); +}); + +describe('documented resolver entry point (#1720)', () => { + it('model-selection-reference names the subpath that exports the provenance-returning resolver', async () => { + const reference = readFileSync(join(import.meta.dirname, '..', '.squad-templates', 'model-selection-reference.md'), 'utf8'); + const match = reference.match(/`resolveModel` (?:from|in) `(@bradygaster\/squad-sdk[^`]*)`/); + expect(match, 'model-selection-reference.md must name the module that exports resolveModel').toBeTruthy(); + const mod = (await import(match![1]!)) as { resolveModel: (o: unknown) => unknown }; + const r = mod.resolveModel({ taskType: 'docs', charterPreference: 'auto' }) as ResolvedModel; + expect( + typeof r === 'object' && r !== null && typeof r.source === 'string' && typeof r.rationale === 'string', + `${match![1]} resolveModel must return {model, source, rationale}; got ${JSON.stringify(r)}`, + ).toBe(true); + }); +}); From fa499e113942b531e793e45bc3a730f44bf5cf08 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Wed, 14 Oct 2026 11:51:25 -0700 Subject: [PATCH 3/9] fix(sdk): wire session/unavailable models into spawn; forward-only fallback under cost policy - SpawnAgentOptions gains sessionModel and unavailableModels, forwarded to the central resolver (user-override > config > session > charter > task > default); an exhausted chain omits the model key from the SDK session config. - Explicit over-ceiling cost-policy finalization keeps only chain entries after the chosen model, so availability fallback never moves back toward the head. - Export resolveAgentModel from @bradygaster/squad-sdk/agents as an additive alias of resolveModel; docs, changeset and tests name the literal export. Refs #1720 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8a345356-f72f-4e9d-a28b-678c7c934eaa --- .changeset/central-model-resolver.md | 4 +- .squad-templates/model-selection-reference.md | 2 +- .../templates/model-selection-reference.md | 2 +- packages/squad-sdk/src/agents/index.ts | 1 + packages/squad-sdk/src/agents/lifecycle.ts | 18 ++++++ .../squad-sdk/src/agents/model-selector.ts | 15 ++++- .../templates/model-selection-reference.md | 2 +- templates/model-selection-reference.md | 2 +- test/lifecycle.test.ts | 50 ++++++++++++++++ test/model-preference.test.ts | 60 ++++++++++++++++--- 10 files changed, 141 insertions(+), 15 deletions(-) diff --git a/.changeset/central-model-resolver.md b/.changeset/central-model-resolver.md index 0e5f1966b..c301a022b 100644 --- a/.changeset/central-model-resolver.md +++ b/.changeset/central-model-resolver.md @@ -3,6 +3,6 @@ "@bradygaster/squad-cli": patch --- -Central model resolver (#1720): `resolveModel` from `@bradygaster/squad-sdk/agents` adds `config`, `session` and `fallback` sources, applies the exported `MODEL_RESOLUTION_PRECEDENCE` (user-override > config > session > charter > task-auto > default), and returns a `rationale` for every choice (typed as `ModelResolution`; `ResolvedModel.rationale` stays optional so consumer-built values remain valid). `auto` (any case) means "no preference" at every layer, including a per-spawn override. A session-picked model is inherited by every sub-agent spawn with source `session`. When a model listed in `unavailableModels` is in its tier chain, fallback moves only forward to the next available entry; models outside the chain search it from the head; with no candidate left, `omitModel` is set. +Central model resolver (#1720): `resolveAgentModel` (an alias of `resolveModel`) from `@bradygaster/squad-sdk/agents` adds `config`, `session` and `fallback` sources, applies the exported `MODEL_RESOLUTION_PRECEDENCE` (user-override > config > session > charter > task-auto > default), and returns a `rationale` for every choice (typed as `ModelResolution`; `ResolvedModel.rationale` stays optional so consumer-built values remain valid). `auto` (any case) means "no preference" at every layer, including a per-spawn override. A session-picked model is inherited by every sub-agent spawn with source `session`. When a model listed in `unavailableModels` is in its tier chain, fallback moves only forward to the next available entry; models outside the chain search it from the head; with no candidate left, `omitModel` is set. -The root `resolveModel` export (string-returning, reads `.squad/config.json`) is now a compatible adapter over the central resolver, so it no longer returns a literal `auto`. `AgentLifecycleManager.spawnAgent` now applies persistent `.squad/config.json` preferences (`agentModelOverrides`, `defaultModel`, `economyMode`) and records provenance on `AgentHandle.modelResolution`, OTel span attributes and an `agent:milestone` `model.resolved` event. New helpers: `isModelPreference`, `persistentConfigModel`. Coordinator spawn guidance and `model-selection-reference.md` no longer hard-code model IDs; spawn acknowledgments show model, source and rationale, with catalog IDs kept in a separate appendix. +The root `resolveModel` export (string-returning, reads `.squad/config.json`) is now a compatible adapter over the central resolver, so it no longer returns a literal `auto`. `AgentLifecycleManager.spawnAgent` now applies persistent `.squad/config.json` preferences (`agentModelOverrides`, `defaultModel`, `economyMode`) and records provenance on `AgentHandle.modelResolution`, OTel span attributes and an `agent:milestone` `model.resolved` event. New helpers: `isModelPreference`, `persistentConfigModel`. Coordinator spawn guidance and `model-selection-reference.md` no longer hard-code model IDs; spawn acknowledgments show model, source and rationale, with catalog IDs kept in a separate appendix. `AgentLifecycleManager.spawnAgent` accepts `sessionModel` and `unavailableModels`; an exhausted chain omits `model` from the session config. Under a cost policy, an explicit over-ceiling pick keeps the forward-only fallback cursor. diff --git a/.squad-templates/model-selection-reference.md b/.squad-templates/model-selection-reference.md index 32ae22934..36d47b58e 100644 --- a/.squad-templates/model-selection-reference.md +++ b/.squad-templates/model-selection-reference.md @@ -2,7 +2,7 @@ ## Per-Agent Model Selection -Before spawning an agent, resolve its model with the central resolver (`resolveModel` from `@bradygaster/squad-sdk/agents`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. +Before spawning an agent, resolve its model with the central resolver (`resolveAgentModel` from `@bradygaster/squad-sdk/agents`, also exported there under its original name `resolveModel`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. diff --git a/packages/squad-cli/templates/model-selection-reference.md b/packages/squad-cli/templates/model-selection-reference.md index 32ae22934..36d47b58e 100644 --- a/packages/squad-cli/templates/model-selection-reference.md +++ b/packages/squad-cli/templates/model-selection-reference.md @@ -2,7 +2,7 @@ ## Per-Agent Model Selection -Before spawning an agent, resolve its model with the central resolver (`resolveModel` from `@bradygaster/squad-sdk/agents`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. +Before spawning an agent, resolve its model with the central resolver (`resolveAgentModel` from `@bradygaster/squad-sdk/agents`, also exported there under its original name `resolveModel`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index 061d7623f..e1dc46917 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -64,6 +64,7 @@ export { // --- M1-9 Model Selection + M3-5 Model Fallback --- export { resolveModel, + resolveAgentModel, MODEL_RESOLUTION_PRECEDENCE, inferTierFromModel, isTierFallbackAllowed, diff --git a/packages/squad-sdk/src/agents/lifecycle.ts b/packages/squad-sdk/src/agents/lifecycle.ts index f8556256c..e1d519020 100644 --- a/packages/squad-sdk/src/agents/lifecycle.ts +++ b/packages/squad-sdk/src/agents/lifecycle.ts @@ -99,6 +99,20 @@ export interface SpawnAgentOptions { /** User-specified model override */ modelOverride?: string; + /** + * Model picked for the current session, inherited by this spawn with source + * `session` (below explicit override and persistent config, above charter). + * Any id passes through, including opt-in preview models. + */ + sessionModel?: string; + + /** + * Model ids known to be unavailable. The resolver falls forward along the + * fallback chain; when no candidate remains, `model` is omitted from the + * session config so the platform default applies. + */ + unavailableModels?: Iterable; + /** User-specified reasoning effort override */ reasoningEffortOverride?: SquadReasoningEffort; @@ -204,6 +218,8 @@ export class AgentLifecycleManager { task, taskType = 'code', modelOverride, + sessionModel, + unavailableModels, reasoningEffortOverride, contextTierOverride, sessionCostPolicy, @@ -256,6 +272,7 @@ export class AgentLifecycleManager { const modelOptions: ModelResolutionOptions = { userOverride: modelOverride, configModel: persistentConfigModel(persistent, agentName), + sessionModel, economyMode: persistent?.economyMode === true, charterPreference: agentConfig.resolvedModel !== undefined ? agentConfig.resolvedModel @@ -264,6 +281,7 @@ export class AgentLifecycleManager { agentRole: agentName, config: this.costPolicy ? { costPolicy: this.costPolicy } : undefined, sessionCostPolicy, + unavailableModels, }; const resolvedModel = resolveModel(modelOptions); diff --git a/packages/squad-sdk/src/agents/model-selector.ts b/packages/squad-sdk/src/agents/model-selector.ts index dae0e3d1c..643e42068 100644 --- a/packages/squad-sdk/src/agents/model-selector.ts +++ b/packages/squad-sdk/src/agents/model-selector.ts @@ -159,6 +159,14 @@ export function resolveModel(options: ModelResolutionOptions): ModelResolution { return { ...resolved, rationale: resolved.rationale ?? resolved.source }; } +/** + * Documented provenance-returning entry point for agent model resolution. + * Alias of {@link resolveModel} in `@bradygaster/squad-sdk/agents`; distinct + * from the root `resolveModel` export, which is a legacy adapter returning + * only the model id. + */ +export const resolveAgentModel: (options: ModelResolutionOptions) => ModelResolution = resolveModel; + /** * True when a layer value expresses a model preference. Empty values and the * `auto` sentinel (any case, surrounding whitespace ignored) mean "no @@ -497,8 +505,11 @@ export function finalizeResolvedModel( base.source === 'charter'; if (isExplicit) { - // AC6: honor the explicit over-ceiling selection, but warn loudly. - const prunedRest = pruneChainToCeiling(base.fallbackChain, maxCategory, catalogMap).filter( + // AC6: honor the explicit over-ceiling selection, but warn loudly. Keep + // only chain entries after the model's own position so availability + // fallback stays forward-only (never back toward the chain head). + const position = base.fallbackChain.findIndex((id) => id.toLowerCase() === base.model.toLowerCase()); + const prunedRest = pruneChainToCeiling(base.fallbackChain.slice(position + 1), maxCategory, catalogMap).filter( (id) => id !== base.model, ); return { diff --git a/packages/squad-sdk/templates/model-selection-reference.md b/packages/squad-sdk/templates/model-selection-reference.md index 32ae22934..36d47b58e 100644 --- a/packages/squad-sdk/templates/model-selection-reference.md +++ b/packages/squad-sdk/templates/model-selection-reference.md @@ -2,7 +2,7 @@ ## Per-Agent Model Selection -Before spawning an agent, resolve its model with the central resolver (`resolveModel` from `@bradygaster/squad-sdk/agents`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. +Before spawning an agent, resolve its model with the central resolver (`resolveAgentModel` from `@bradygaster/squad-sdk/agents`, also exported there under its original name `resolveModel`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. diff --git a/templates/model-selection-reference.md b/templates/model-selection-reference.md index 32ae22934..36d47b58e 100644 --- a/templates/model-selection-reference.md +++ b/templates/model-selection-reference.md @@ -2,7 +2,7 @@ ## Per-Agent Model Selection -Before spawning an agent, resolve its model with the central resolver (`resolveModel` from `@bradygaster/squad-sdk/agents`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. +Before spawning an agent, resolve its model with the central resolver (`resolveAgentModel` from `@bradygaster/squad-sdk/agents`, also exported there under its original name `resolveModel`; it returns the model with its `source` and `rationale`). The root `@bradygaster/squad-sdk` export `resolveModel` is a legacy adapter over the same resolver that reads `.squad/config.json` and returns only the model ID. Apply the precedence below; first match wins. Every result carries a **source** and a **rationale**, and both appear in the spawn acknowledgment. Spawn guidance never names a model ID. It reasons in sources and tiers (`premium`, `standard`, `fast`); **Appendix A** is the only place that turns a tier into model IDs. diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 1915d13fe..c84ff04bb 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -18,6 +18,7 @@ import { } from '@bradygaster/squad-sdk/agents'; import { SquadClientWithPool } from '@bradygaster/squad-sdk/client'; import { EventBus } from '@bradygaster/squad-sdk/runtime/event-bus'; +import { MODELS } from '@bradygaster/squad-sdk'; describe('Agent Lifecycle Manager', () => { let tempDir: string; @@ -524,6 +525,55 @@ describe('Agent Lifecycle Manager — persistent model preference + provenance ( expect(resolved[0]).toMatchObject({ model: 'persisted-model', source: 'config' }); expect(String(resolved[0]!.rationale)).toMatch(/config\.json/); }); + + it('inherits the session model (e.g. opt-in hydrafusion) over the charter in the created session', async () => { + const handle = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work', taskType: 'code', sessionModel: 'hydrafusion' }); + expect(sessionConfigs[0]?.model, `createSession with sessionModel 'hydrafusion': ${JSON.stringify(sessionConfigs[0])}`).toBe('hydrafusion'); + expect(handle.model).toBe('hydrafusion'); + expect(handle.modelResolution?.source, `handle provenance: ${JSON.stringify(handle.modelResolution)}`).toBe('session'); + const resolved = events.filter((e) => e.event === 'model.resolved'); + expect(resolved[0], `model.resolved events: ${JSON.stringify(events)}`).toMatchObject({ model: 'hydrafusion', source: 'session' }); + }); + + it("'auto' session model falls through to the charter", async () => { + const handle = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work', sessionModel: ' AUTO ' }); + expect(sessionConfigs[0]?.model, `createSession with sessionModel ' AUTO ': ${JSON.stringify(sessionConfigs[0])}`).toBe('charter-model'); + expect(handle.modelResolution?.source).toBe('charter'); + }); + + it('explicit spawn override and persistent config both beat the session model', async () => { + const explicit = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work', modelOverride: 'spawn-model', sessionModel: 'hydrafusion' }); + expect(sessionConfigs[0]?.model, `override vs session: ${JSON.stringify(sessionConfigs[0])}`).toBe('spawn-model'); + expect(explicit.modelResolution?.source).toBe('user-override'); + + await writeConfig({ agentModelOverrides: { 'pref-agent': 'persisted-model' } }); + const persisted = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work', sessionModel: 'hydrafusion' }); + expect(sessionConfigs[1]?.model, `config vs session: ${JSON.stringify(sessionConfigs[1])}`).toBe('persisted-model'); + expect(persisted.modelResolution?.source).toBe('config'); + }); + + it('forwards unavailableModels: falls forward along the chain and records fallback provenance', async () => { + const chain = MODELS.FALLBACK_CHAINS.standard; + const handle = await manager.spawnAgent({ + agentName: 'pref-agent', task: 'Work', modelOverride: chain[1], unavailableModels: [chain[1]!], + }); + expect(sessionConfigs[0]?.model, `createSession with '${chain[1]}' unavailable: ${JSON.stringify(sessionConfigs[0])}`).toBe(chain[2]); + expect(handle.modelResolution).toMatchObject({ source: 'fallback', fallbackFrom: { model: chain[1], source: 'user-override' } }); + }); + + it('exhausted fallback omits the model key from the SDK session config (no undefined own property)', async () => { + const chain = MODELS.FALLBACK_CHAINS.standard; + const last = chain[chain.length - 1]!; + const handle = await manager.spawnAgent({ agentName: 'pref-agent', task: 'Work', sessionModel: last, unavailableModels: [last] }); + const config = sessionConfigs[0]!; + expect( + Object.prototype.hasOwnProperty.call(config, 'model'), + `createSession config must not carry a 'model' key when '${last}' (last chain entry) is unavailable: ${JSON.stringify(Object.keys(config))} model=${String(config.model)}`, + ).toBe(false); + expect(handle.modelResolution, `handle provenance: ${JSON.stringify(handle.modelResolution)}`).toMatchObject({ + source: 'fallback', omitModel: true, fallbackFrom: { model: last, source: 'session' }, + }); + }); }); // --- Mock Client --- diff --git a/test/model-preference.test.ts b/test/model-preference.test.ts index 362a9173a..d52875f98 100644 --- a/test/model-preference.test.ts +++ b/test/model-preference.test.ts @@ -32,7 +32,8 @@ import { clampContextTier, } from '@bradygaster/squad-sdk/config'; import { - resolveModel as resolveAgentModel, + resolveAgentModel, + resolveModel as centralResolveModel, MODEL_RESOLUTION_PRECEDENCE, type ModelResolutionOptions, type ResolvedModel, @@ -1248,15 +1249,60 @@ describe('legacy config resolveModel delegates to the central resolver (#1720)', }); describe('documented resolver entry point (#1720)', () => { - it('model-selection-reference names the subpath that exports the provenance-returning resolver', async () => { + it('@bradygaster/squad-sdk/agents exports resolveAgentModel as the same function as its resolveModel', () => { + expect(typeof resolveAgentModel, '@bradygaster/squad-sdk/agents must export resolveAgentModel').toBe('function'); + expect(resolveAgentModel, 'resolveAgentModel must be an alias of the agents resolveModel, not a fork').toBe(centralResolveModel); + }); + + it('model-selection-reference names the subpath that exports resolveAgentModel with provenance', async () => { const reference = readFileSync(join(import.meta.dirname, '..', '.squad-templates', 'model-selection-reference.md'), 'utf8'); - const match = reference.match(/`resolveModel` (?:from|in) `(@bradygaster\/squad-sdk[^`]*)`/); - expect(match, 'model-selection-reference.md must name the module that exports resolveModel').toBeTruthy(); - const mod = (await import(match![1]!)) as { resolveModel: (o: unknown) => unknown }; - const r = mod.resolveModel({ taskType: 'docs', charterPreference: 'auto' }) as ResolvedModel; + const match = reference.match(/`resolveAgentModel` (?:from|in) `(@bradygaster\/squad-sdk[^`]*)`/); + expect(match, 'model-selection-reference.md must name the module that exports resolveAgentModel').toBeTruthy(); + const mod = (await import(match![1]!)) as { resolveAgentModel?: (o: unknown) => unknown }; + expect(typeof mod.resolveAgentModel, `${match![1]} must export resolveAgentModel`).toBe('function'); + const r = mod.resolveAgentModel!({ taskType: 'docs', charterPreference: 'auto' }) as ResolvedModel; expect( typeof r === 'object' && r !== null && typeof r.source === 'string' && typeof r.rationale === 'string', - `${match![1]} resolveModel must return {model, source, rationale}; got ${JSON.stringify(r)}`, + `${match![1]} resolveAgentModel must return {model, source, rationale}; got ${JSON.stringify(r)}`, ).toBe(true); }); }); + +describe('cost policy composes with forward-only availability fallback (#1720)', () => { + const versatile = { costPolicy: { maxCategory: 'versatile' as const } }; + const chain = MODELS.FALLBACK_CHAINS.standard; + + it('explicit over-ceiling pick that is unavailable never falls back to an earlier chain entry', () => { + const opts: ModelResolutionOptions = { userOverride: 'gpt-5.4', config: versatile, unavailableModels: ['gpt-5.4'] }; + const r = resolveAgentModel(opts); + const earlier = chain.slice(0, chain.indexOf('gpt-5.4')); + expect(earlier, `${JSON.stringify(opts)} chose '${r.model}', which precedes 'gpt-5.4' in ${JSON.stringify(chain)}`).not.toContain(r.model); + expect( + { model: r.model, source: r.source, fallbackFrom: r.fallbackFrom }, + `${JSON.stringify(opts)} must fall forward to the next in-ceiling entry after 'gpt-5.4'`, + ).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback', fallbackFrom: { model: 'gpt-5.4', source: 'user-override' } }); + expect(r.policy?.action).toBe('warn-allow-explicit'); + }); + + it('explicit over-ceiling pick with every later in-ceiling entry unavailable omits the model instead of wrapping', () => { + const opts: ModelResolutionOptions = { + userOverride: 'gpt-5.4', config: versatile, unavailableModels: ['gpt-5.4', 'claude-sonnet-4.5'], + }; + const r = resolveAgentModel(opts); + expect({ model: r.model, omitModel: r.omitModel }, `${JSON.stringify(opts)} chose '${r.model}'`).toEqual({ model: '', omitModel: true }); + }); + + it('session over-ceiling pick keeps the forward cursor under a session cost policy', () => { + const opts: ModelResolutionOptions = { + sessionModel: 'gpt-5.5', sessionCostPolicy: { maxCategory: 'versatile' }, unavailableModels: ['gpt-5.5'], + }; + const r = resolveAgentModel(opts); + expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} chose '${r.model}'`).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback' }); + }); + + it('within-ceiling pick that is unavailable moves forward over pruned entries', () => { + const opts: ModelResolutionOptions = { userOverride: 'claude-sonnet-4.6', config: versatile, unavailableModels: ['claude-sonnet-4.6'] }; + const r = resolveAgentModel(opts); + expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} chose '${r.model}'`).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback' }); + }); +}); From e7568cb38678fd8583c0ecdbc9a1300ec8da120d Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Wed, 14 Oct 2026 12:21:23 -0700 Subject: [PATCH 4/9] fix(sdk): availability fallback honors the active cost ceiling An unavailable out-of-catalog pick (e.g. session-selected hydrafusion) bypassed cost-ceiling pruning, so availability fallback could choose a catalog model above config.costPolicy / sessionCostPolicy. Fallback now skips catalog-known candidates above the effective ceiling and omits the model when none remain; available explicit picks still pass through. Refs #1720 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: befc2d3a-18c3-4002-9194-2fbd510375d5 --- .changeset/central-model-resolver.md | 2 +- .../squad-sdk/src/agents/model-selector.ts | 32 +++++--- test/lifecycle.test.ts | 31 ++++++++ test/model-preference.test.ts | 76 +++++++++++++++++++ 4 files changed, 130 insertions(+), 11 deletions(-) diff --git a/.changeset/central-model-resolver.md b/.changeset/central-model-resolver.md index c301a022b..af7ee16af 100644 --- a/.changeset/central-model-resolver.md +++ b/.changeset/central-model-resolver.md @@ -5,4 +5,4 @@ Central model resolver (#1720): `resolveAgentModel` (an alias of `resolveModel`) from `@bradygaster/squad-sdk/agents` adds `config`, `session` and `fallback` sources, applies the exported `MODEL_RESOLUTION_PRECEDENCE` (user-override > config > session > charter > task-auto > default), and returns a `rationale` for every choice (typed as `ModelResolution`; `ResolvedModel.rationale` stays optional so consumer-built values remain valid). `auto` (any case) means "no preference" at every layer, including a per-spawn override. A session-picked model is inherited by every sub-agent spawn with source `session`. When a model listed in `unavailableModels` is in its tier chain, fallback moves only forward to the next available entry; models outside the chain search it from the head; with no candidate left, `omitModel` is set. -The root `resolveModel` export (string-returning, reads `.squad/config.json`) is now a compatible adapter over the central resolver, so it no longer returns a literal `auto`. `AgentLifecycleManager.spawnAgent` now applies persistent `.squad/config.json` preferences (`agentModelOverrides`, `defaultModel`, `economyMode`) and records provenance on `AgentHandle.modelResolution`, OTel span attributes and an `agent:milestone` `model.resolved` event. New helpers: `isModelPreference`, `persistentConfigModel`. Coordinator spawn guidance and `model-selection-reference.md` no longer hard-code model IDs; spawn acknowledgments show model, source and rationale, with catalog IDs kept in a separate appendix. `AgentLifecycleManager.spawnAgent` accepts `sessionModel` and `unavailableModels`; an exhausted chain omits `model` from the session config. Under a cost policy, an explicit over-ceiling pick keeps the forward-only fallback cursor. +The root `resolveModel` export (string-returning, reads `.squad/config.json`) is now a compatible adapter over the central resolver, so it no longer returns a literal `auto`. `AgentLifecycleManager.spawnAgent` now applies persistent `.squad/config.json` preferences (`agentModelOverrides`, `defaultModel`, `economyMode`) and records provenance on `AgentHandle.modelResolution`, OTel span attributes and an `agent:milestone` `model.resolved` event. New helpers: `isModelPreference`, `persistentConfigModel`. Coordinator spawn guidance and `model-selection-reference.md` no longer hard-code model IDs; spawn acknowledgments show model, source and rationale, with catalog IDs kept in a separate appendix. `AgentLifecycleManager.spawnAgent` accepts `sessionModel` and `unavailableModels`; an exhausted chain omits `model` from the session config. Under a cost policy, an explicit over-ceiling pick keeps the forward-only fallback cursor. Availability fallback also honors the active cost ceiling (`config.costPolicy` or `sessionCostPolicy`): catalog-known candidates above it are skipped, so an unavailable out-of-catalog pick (for example `hydrafusion`) falls back only to an in-ceiling entry or omits `model`; an available explicit pick still passes through unchanged. diff --git a/packages/squad-sdk/src/agents/model-selector.ts b/packages/squad-sdk/src/agents/model-selector.ts index 643e42068..f6daaa517 100644 --- a/packages/squad-sdk/src/agents/model-selector.ts +++ b/packages/squad-sdk/src/agents/model-selector.ts @@ -97,8 +97,9 @@ export interface ModelResolutionOptions { * Model ids known to be unavailable (plan restriction, org policy, rate * limit, deprecation). When the resolved model is listed, the next available * fallback-chain entry after it is chosen with source `fallback`; a model - * that is not in its chain searches the chain from the head. When no - * candidate remains, `omitModel` is set. + * that is not in its chain searches the chain from the head. Under an + * active cost ceiling, catalog-known candidates above it are skipped. When + * no candidate remains, `omitModel` is set. */ unavailableModels?: Iterable; } @@ -154,8 +155,10 @@ export interface EffectiveCostPolicy { export function resolveModel(options: ModelResolutionOptions): ModelResolution { const base = resolveBaseModel(options); const policy = buildEffectiveCostPolicy(options.config, options.sessionCostPolicy); - const finalized = policy ? finalizeResolvedModel(base, policy, buildCatalogCategoryMap()) : base; - const resolved = applyAvailabilityFallback(finalized, options.unavailableModels); + const catalogMap = policy ? buildCatalogCategoryMap() : undefined; + const finalized = policy && catalogMap ? finalizeResolvedModel(base, policy, catalogMap) : base; + const ceiling = policy && catalogMap ? { maxCategory: policy.maxCategory, catalogMap } : undefined; + const resolved = applyAvailabilityFallback(finalized, options.unavailableModels, ceiling); return { ...resolved, rationale: resolved.rationale ?? resolved.source }; } @@ -271,12 +274,15 @@ function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { * Replace an unavailable model with the next available fallback-chain entry. * A model found in its chain searches only the entries after it (never back * toward the head); a model not in its chain searches the chain from the - * head. When no candidate remains, signal that the `model` parameter should - * be omitted so the platform default applies. + * head. Under an active cost ceiling, catalog-known candidates above the + * ceiling are skipped: an automatic fallback is never an explicit opt-in. + * When no candidate remains, signal that the `model` parameter should be + * omitted so the platform default applies. */ function applyAvailabilityFallback( resolved: ResolvedModel, unavailableModels?: Iterable, + ceiling?: { maxCategory: GitHubModelCategory; catalogMap: Map }, ): ResolvedModel { if (!unavailableModels) return resolved; const unavailable = new Set(Array.from(unavailableModels, (id) => id.toLowerCase())); @@ -285,19 +291,25 @@ function applyAvailabilityFallback( const fallbackFrom = { model: resolved.model, source: resolved.source }; const position = resolved.fallbackChain.findIndex((id) => id.toLowerCase() === resolved.model.toLowerCase()); const start = position + 1; // 0 when the model is not in its chain + const withinCeiling = (id: string): boolean => { + if (!ceiling) return true; + const cat = ceiling.catalogMap.get(id.toLowerCase()); + return cat === undefined || CATEGORY_ORDER[cat] <= CATEGORY_ORDER[ceiling.maxCategory]; + }; const index = resolved.fallbackChain.findIndex( - (id, i) => i >= start && !unavailable.has(id.toLowerCase()), + (id, i) => i >= start && !unavailable.has(id.toLowerCase()) && withinCeiling(id), ); const next = index >= 0 ? resolved.fallbackChain[index] : undefined; + const ceilingNote = ceiling ? ` within cost ceiling '${ceiling.maxCategory}'` : ''; if (next) { return { ...resolved, model: next, tier: inferTierFromModel(next), source: 'fallback', - fallbackChain: resolved.fallbackChain.slice(index), + fallbackChain: resolved.fallbackChain.slice(index).filter(withinCeiling), fallbackFrom, - rationale: `'${resolved.model}' (${resolved.source}) unavailable; fell back to '${next}'`, + rationale: `'${resolved.model}' (${resolved.source}) unavailable; fell back to '${next}'${ceilingNote}`, }; } return { @@ -307,7 +319,7 @@ function applyAvailabilityFallback( fallbackChain: [], fallbackFrom, omitModel: true, - rationale: `'${resolved.model}' (${resolved.source}) unavailable and no later fallback-chain candidate is available; omit the model parameter for the platform default`, + rationale: `'${resolved.model}' (${resolved.source}) unavailable and no later fallback-chain candidate is available${ceilingNote}; omit the model parameter for the platform default`, }; } diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index c84ff04bb..0e510d70f 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -574,6 +574,37 @@ describe('Agent Lifecycle Manager — persistent model preference + provenance ( source: 'fallback', omitModel: true, fallbackFrom: { model: last, source: 'session' }, }); }); + + it('unavailable session hydrafusion under a lightweight session ceiling omits the model key instead of an over-ceiling fallback', async () => { + const spawn = { agentName: 'pref-agent', task: 'Work', taskType: 'code' as const, sessionModel: 'hydrafusion', sessionCostPolicy: { maxCategory: 'lightweight' as const }, unavailableModels: ['hydrafusion'] }; + const handle = await manager.spawnAgent(spawn); + const config = sessionConfigs[0]!; + expect( + Object.prototype.hasOwnProperty.call(config, 'model'), + `spawnAgent(${JSON.stringify(spawn)}) with ceiling lightweight passed createSession model '${String(config.model)}'; no in-ceiling fallback exists, so 'model' must be omitted`, + ).toBe(false); + expect(handle.modelResolution, `handle provenance: ${JSON.stringify(handle.modelResolution)}`).toMatchObject({ + source: 'fallback', omitModel: true, fallbackFrom: { model: 'hydrafusion', source: 'session' }, + }); + }); + + it('unavailable session hydrafusion under a versatile config ceiling passes only an allowed fallback to createSession', async () => { + const cappedManager = new AgentLifecycleManager({ + client: mockClient, teamRoot: tempDir, defaultIdleTimeout: 60_000, costPolicy: { maxCategory: 'versatile' }, + }); + try { + const unavailable = ['hydrafusion', 'gpt-5.6-terra', 'claude-sonnet-5', 'claude-sonnet-4.6']; + const spawn = { agentName: 'pref-agent', task: 'Work', taskType: 'code' as const, sessionModel: 'hydrafusion', unavailableModels: unavailable }; + const handle = await cappedManager.spawnAgent(spawn); + expect( + sessionConfigs[0]?.model, + `spawnAgent(${JSON.stringify(spawn)}) with config ceiling versatile passed createSession model '${String(sessionConfigs[0]?.model)}'; powerful 'gpt-5.5' must be skipped`, + ).toBe('claude-sonnet-4.5'); + expect(handle.modelResolution).toMatchObject({ source: 'fallback', fallbackFrom: { model: 'hydrafusion', source: 'session' } }); + } finally { + await cappedManager.shutdown(); + } + }); }); // --- Mock Client --- diff --git a/test/model-preference.test.ts b/test/model-preference.test.ts index d52875f98..1354dd12d 100644 --- a/test/model-preference.test.ts +++ b/test/model-preference.test.ts @@ -30,11 +30,14 @@ import { writeAgentContextTierOverrides, resolveContextTier, clampContextTier, + CATEGORY_ORDER, + type GitHubModelCategory, } from '@bradygaster/squad-sdk/config'; import { resolveAgentModel, resolveModel as centralResolveModel, MODEL_RESOLUTION_PRECEDENCE, + buildCatalogCategoryMap, type ModelResolutionOptions, type ResolvedModel, } from '@bradygaster/squad-sdk/agents'; @@ -1306,3 +1309,76 @@ describe('cost policy composes with forward-only availability fallback (#1720)', expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} chose '${r.model}'`).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback' }); }); }); + +describe('availability fallback respects the active cost ceiling (#1720)', () => { + const standard = MODELS.FALLBACK_CHAINS.standard; + const fast = MODELS.FALLBACK_CHAINS.fast; + const ceilingSources = [ + { label: 'config.costPolicy', policy: (maxCategory: GitHubModelCategory) => ({ config: { costPolicy: { maxCategory } } }) }, + { label: 'sessionCostPolicy', policy: (maxCategory: GitHubModelCategory) => ({ sessionCostPolicy: { maxCategory } }) }, + ] as const; + const categories = buildCatalogCategoryMap(); + const overCeiling = (ids: readonly string[], ceiling: GitHubModelCategory) => + ids.filter((id) => { + const cat = categories.get(id); + return cat !== undefined && CATEGORY_ORDER[cat] > CATEGORY_ORDER[ceiling]; + }); + + for (const { label, policy } of ceilingSources) { + it(`[${label}] available out-of-catalog 'hydrafusion' is still honored unchanged under a lightweight ceiling`, () => { + const opts: ModelResolutionOptions = { sessionModel: 'hydrafusion', taskType: 'code', ...policy('lightweight') }; + const r = resolveAgentModel(opts); + expect( + { model: r.model, source: r.source, omitModel: r.omitModel }, + `${JSON.stringify(opts)} (ceiling lightweight via ${label}) picked '${r.model}' (${r.source}); explicit opt-in must pass through`, + ).toEqual({ model: 'hydrafusion', source: 'session', omitModel: undefined }); + }); + + it(`[${label}] unavailable 'hydrafusion' with no in-ceiling chain entry omits the model instead of exceeding a lightweight ceiling`, () => { + const opts: ModelResolutionOptions = { + sessionModel: 'hydrafusion', taskType: 'code', unavailableModels: ['hydrafusion'], ...policy('lightweight'), + }; + const r = resolveAgentModel(opts); + expect( + { model: r.model, omitModel: r.omitModel, source: r.source, fallbackFrom: r.fallbackFrom }, + `${JSON.stringify(opts)} (ceiling lightweight via ${label}) picked '${r.model}' (category ${categories.get(r.model) ?? 'none'}); ` + + 'an automatic fallback must not exceed the ceiling', + ).toEqual({ model: '', omitModel: true, source: 'fallback', fallbackFrom: { model: 'hydrafusion', source: 'session' } }); + }); + + it(`[${label}] unavailable explicit out-of-catalog pick skips over-ceiling entries and lands on an allowed later entry`, () => { + // versatile ceiling: gpt-5.5 (powerful) must be skipped, not chosen, after the versatile entries are unavailable. + const unavailable = ['hydrafusion', ...standard.slice(0, standard.indexOf('gpt-5.5'))]; + const opts: ModelResolutionOptions = { + userOverride: 'hydrafusion', taskType: 'code', unavailableModels: unavailable, ...policy('versatile'), + }; + const r = resolveAgentModel(opts); + const picked = categories.get(r.model); + expect( + { model: r.model, source: r.source }, + `${JSON.stringify(opts)} (ceiling versatile via ${label}) picked '${r.model}' (category ${picked ?? 'none'})`, + ).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback' }); + expect( + overCeiling(r.fallbackChain, 'versatile'), + `${JSON.stringify(opts)} (ceiling versatile via ${label}) left over-ceiling entries in fallbackChain ${JSON.stringify(r.fallbackChain)}`, + ).toEqual([]); + }); + + it(`[${label}] unavailable fast-tier out-of-catalog pick falls back to an in-ceiling lightweight entry`, () => { + const opts: ModelResolutionOptions = { + configModel: 'custom-mini', unavailableModels: ['custom-mini'], ...policy('lightweight'), + }; + const r = resolveAgentModel(opts); + expect( + { model: r.model, source: r.source }, + `${JSON.stringify(opts)} (ceiling lightweight via ${label}) picked '${r.model}' (category ${categories.get(r.model) ?? 'none'})`, + ).toEqual({ model: fast[0], source: 'fallback' }); + }); + } + + it('without a cost policy the out-of-catalog availability fallback is unchanged', () => { + const opts: ModelResolutionOptions = { sessionModel: 'hydrafusion', taskType: 'code', unavailableModels: ['hydrafusion'] }; + const r = resolveAgentModel(opts); + expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} picked '${r.model}'`).toEqual({ model: standard[0], source: 'fallback' }); + }); +}); From a2d42f3359df91231dd59003ae334b680e95da03 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Wed, 14 Oct 2026 13:09:10 -0700 Subject: [PATCH 5/9] feat(sdk): quality-profile and public-catalog aware model selection Task-aware selection runs under a quality profile: quality-first (default), balanced or economy (explicit opt-in via session, economyMode or persistent modelProfile). Profiles flow through the central resolver, spawn and the compatibility adapter; explicit overrides and cost ceilings are untouched. availableModels (live runtime list) is authoritative for availability. Catalog, fallback chains and economy map now list only public GA models (docs.github.com supported-models), tiered by Copilot model-picker category. Removed from recommendations: claude-opus-4.7, claude-opus-4.6, claude-sonnet-4.6, claude-sonnet-4.5, gemini-3.1-pro (still passed through when named explicitly). Closes #1598 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: eeb9fa1f-ceee-4184-afcd-5cd32d9d04dc --- .changeset/quality-model-profiles.md | 9 + .github/agents/squad.agent.md | 2 +- .squad-templates/model-selection-reference.md | 75 ++-- .squad-templates/skills/economy-mode/SKILL.md | 26 +- .../skills/model-selection/SKILL.md | 42 ++- .squad-templates/squad.agent.md | 2 +- .../templates/model-selection-reference.md | 75 ++-- .../templates/skills/economy-mode/SKILL.md | 26 +- .../templates/skills/model-selection/SKILL.md | 42 ++- .../templates/squad.agent.md.template | 2 +- packages/squad-sdk/src/agents/index.ts | 6 + packages/squad-sdk/src/agents/lifecycle.ts | 30 +- .../squad-sdk/src/agents/model-selector.ts | 186 ++++++++-- packages/squad-sdk/src/config/models.ts | 284 ++++++++++++---- packages/squad-sdk/src/config/schema.ts | 6 +- packages/squad-sdk/src/runtime/constants.ts | 28 +- .../templates/model-selection-reference.md | 75 ++-- .../templates/skills/economy-mode/SKILL.md | 26 +- .../templates/skills/model-selection/SKILL.md | 42 ++- .../templates/squad.agent.md.template | 2 +- templates/model-selection-reference.md | 75 ++-- templates/skills/economy-mode/SKILL.md | 26 +- templates/skills/model-selection/SKILL.md | 42 ++- templates/squad.agent.md.template | 2 +- test/agents.test.ts | 54 +-- test/catalog-refresh.test.ts | 42 ++- test/compat-v041.test.ts | 22 +- test/config-integration.test.ts | 2 +- test/config-schema.test.ts | 2 +- test/economy-mode.test.ts | 40 ++- test/feature-parity.test.ts | 2 +- test/integration.test.ts | 10 +- test/lifecycle.test.ts | 15 +- test/model-fallback.test.ts | 14 +- test/model-preference.test.ts | 47 +-- test/model-quality-profiles.test.ts | 320 ++++++++++++++++++ test/model-selector-policy.test.ts | 10 +- test/models.test.ts | 50 +-- test/telemetry-auto-wiring.test.ts | 12 +- 39 files changed, 1284 insertions(+), 489 deletions(-) create mode 100644 .changeset/quality-model-profiles.md create mode 100644 test/model-quality-profiles.test.ts diff --git a/.changeset/quality-model-profiles.md b/.changeset/quality-model-profiles.md new file mode 100644 index 000000000..057f51288 --- /dev/null +++ b/.changeset/quality-model-profiles.md @@ -0,0 +1,9 @@ +--- +"@bradygaster/squad-sdk": minor +--- + +Quality-profile and public-catalog aware model selection (#1598). Task-aware selection now runs under a quality profile: `quality-first` (the new default), `balanced` or `economy` (explicit opt-in). Under `quality-first`, architecture, research, verification, review, security, design and visual work use the premium tier; code, prompts, docs, writing and planning use standard; mechanical work (Scribe, git, release) stays fast. The `default` layer now uses the head of the standard chain, or the economy default under `economy`. The profile comes from a session profile (`sessionProfile`), `economyMode: true`, or the persistent `.squad/config.json` `modelProfile` (new `readModelProfile` / `writeModelProfile`, `isModelSelectionProfile`, `MODEL_SELECTION_PROFILES`, `PROFILE_TASK_TIERS`, `resolveModelProfile`). `ResolvedModel.profile`, rationales, the `model.profile` span attribute and the `model.resolved` event report the active profile. Profiles never change explicit user-override, config, session or charter models, and cost ceilings still apply. `economyMode` stays compatible: an explicit `economyMode: false` cancels a persisted `economy` profile. + +`resolveAgentModel` and `AgentLifecycleManager.spawnAgent` accept `availableModels`, the runtime's live model list. When supplied, it is authoritative for availability: a missing recommended model falls forward through its chain, and a missing explicit model falls back visibly. + +The model catalog, fallback chains (`MODELS.FALLBACK_CHAINS`, `DEFAULT_FALLBACK_CHAINS`, `DEFAULT_CONFIG.models.tiers`) and `ECONOMY_MODEL_MAP` now list only public GA models from the GitHub Copilot supported-models table, with tiers placed by the Copilot model-picker category. Added: `claude-opus-5.5`, `gpt-6.1-sol`, `gpt-6-sol`, `gemini-4-argon`, `claude-sonnet-5.5`, `grok-4.7`, `grok-4.6`, `claude-haiku-5.5`, `gpt-6-luna`, `gemini-3.8-flash`, `mai-code-1.1-flash`. Removed from recommendations, catalog and fallback chains: `claude-opus-4.7`, `claude-opus-4.6`, `claude-sonnet-4.6`, `claude-sonnet-4.5`, `gemini-3.1-pro`. These IDs are still passed through unchanged when a user names one explicitly. diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index c052c255d..57a62308e 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -393,7 +393,7 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (task-aware tier under the active quality profile; default `quality-first`, with `balanced` or `economy` only by explicit opt-in) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. diff --git a/.squad-templates/model-selection-reference.md b/.squad-templates/model-selection-reference.md index 36d47b58e..ddc15e74e 100644 --- a/.squad-templates/model-selection-reference.md +++ b/.squad-templates/model-selection-reference.md @@ -12,8 +12,8 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium | 2 | `config` | Persistent `.squad/config.json`: `agentModelOverrides.{agentName}`, otherwise `defaultModel` | | 3 | `session` | The model picked for this session (by the user or the session itself); inherited by every sub-agent spawn | | 4 | `charter` | The agent charter's `## Model` → `**Preferred:**` value, unless it is `auto` | -| 5 | `task-auto` | Task-aware tier selection (cost first, unless code is being written) | -| 6 | `default` | Nothing else matched | +| 5 | `task-auto` | Task-aware tier selection under the active quality profile (default `quality-first`) | +| 6 | `default` | Nothing else matched (head of the standard tier; fast tier only under the `economy` profile) | | — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | `auto` at any layer (any letter case) means "no preference here"; continue to the next layer. This includes a per-spawn directive, the persistent config fields, the session model and the charter. @@ -30,36 +30,55 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**`task-auto` — task-aware auto-selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to its output type, select the tier, and use the head of that tier's fallback chain (Appendix A): +**Quality profile:** Task-aware selection runs under one of three profiles. The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. -| Task Output | Tier | Rule | -|-------------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | Standard | Quality and accuracy matter for code. | -| Writing prompts or agent designs (structured text that functions like code) | Standard | Prompts are executable — treat like code. | -| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | Fast | Cost first. | -| Visual/design work requiring image analysis | Premium | Vision capability required. Overrides cost rule. | +| Profile | When it is active | Intent | +|---------|-------------------|--------| +| `quality-first` | Default when nothing else is set | Pick the tier that gives the best result for the task; avoid reruns caused by an underpowered model | +| `balanced` | Explicit opt-in | Keep premium for quality-sensitive gates; research and language-heavy work stay standard rather than automatically cheap | +| `economy` | Explicit opt-in (`economyMode: true` or `modelProfile: "economy"`) | Cost first; then apply the economy map. Always visible: the rationale says `(economy profile)` | -**Role-to-tier mapping** (applying the cost-first principle): +Profile precedence: session profile ("use the balanced profile for this session") → `economyMode: true` → persistent `modelProfile` in `.squad/config.json` → `quality-first`. An explicit `economyMode: false` cancels a persisted `economy` profile. Cost ceilings (`maxCategory`) still apply under every profile. + +- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced` or `economy`). Acknowledge: `✅ Model profile saved: {profile}`. +- **When user says "use {profile} for this session":** Treat it as the session profile; do not write config. + +**`task-auto` — task-aware auto-selection:** Match the agent's task to its type, look up the tier for the active profile, and use the head of that tier's fallback chain (Appendix A): + +| Task type | `quality-first` | `balanced` | `economy` | +|-----------|-----------------|------------|-----------| +| Architecture, verification, review, security, design, visual | Premium | Premium | Premium → economy map | +| Research | Premium | Standard | Fast | +| Writing code, prompts or agent designs | Standard | Standard | Standard → economy map | +| Docs, writing | Standard | Standard | Fast | +| Planning, triage | Standard | Fast | Fast | +| Mechanical ops (Scribe, git, release, changelogs, version bumps) | Fast | Fast | Fast | + +Quality-sensitive work that is not code (architecture, research, verification, reviewer gates, security, design) can use the premium tier. Mechanical work stays fast under every profile. + +**Role-to-tier mapping** (under `quality-first`): | Role | Default Tier | Why | Override When | |------|--------------|-----|---------------| | Core Dev / Backend / Frontend | Standard | Writes code — quality first | Heavy code gen → code specialist | -| Tester / QA | Standard | Writes test code — quality first | Simple test scaffolding → fast | -| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → fast | -| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → standard; research/analysis → fast | -| Copilot SDK Expert | Standard | Technical analysis that often touches code | Pure research → fast | +| Tester / QA | Standard | Writes test code; verification gates → premium | Simple test scaffolding → fast | +| Lead / Architect | auto (per-task) | Architecture and review are quality-sensitive | Architecture proposals, reviewer gates → premium; triage/planning → standard | +| Prompt Engineer | auto (per-task) | Prompt design is executable, like code | Prompt architecture → standard; research → premium | +| Copilot SDK Expert | Standard | Technical analysis that often touches code | Deep research → premium | | Designer / Visual | Premium | Vision-capable model required | — (never downgrade — vision is non-negotiable) | -| DevRel / Writer | Fast | Docs and writing — not code | — | -| Scribe / Logger | Fast | Mechanical file ops — cheapest possible | — (never bump Scribe) | +| DevRel / Writer | Standard | Language-heavy work benefits from a stronger model | Economy profile → fast | +| Scribe / Logger | Fast | Mechanical file ops | — (never bump Scribe) | | Git / Release | Fast | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | **Task complexity adjustments** (apply at most ONE — no cascading): -- **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) +- **Bump UP to premium:** multi-agent coordination (output feeds 3+ agents) - **Bump DOWN to fast:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps - **Switch to the code specialist (Appendix A):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) - **Switch to the analytical-diversity model (Appendix A):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection -**`default`:** If nothing else matched, use the head of the fast tier chain. Cost wins when in doubt, unless code is being produced. +**Live availability:** When the runtime supplies a live model list (`availableModels`) or a list of unavailable models, it is authoritative for availability. A recommended model missing from the live list falls forward to the next listed candidate; an explicitly chosen model missing from it falls back visibly (source `fallback`, rationale names it). Recommendations and defaults come only from the public GA catalog in Appendix A; a model that is merely exposed by a tool schema is not a recommendation. + +**`default`:** If nothing else matched, use the head of the standard tier chain. Under the `economy` profile, use the economy default from the fast tier instead. **`fallback` — when a model is unavailable:** @@ -106,18 +125,22 @@ Every spawn acknowledgment names the resolved model, its source and a short rati This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. +Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. + **Fallback chains:** ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol → (omit model param) +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → (omit model param) +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` -**Specialists:** code specialist `gpt-5.3-codex` (standard); analytical diversity `gemini-3.1-pro` (standard). +**Economy default:** `gpt-5.6-luna`. The economy map moves premium to standard (`claude-opus-5.5` → `claude-sonnet-5.5`; `claude-opus-5`, `claude-opus-4.8` → `claude-sonnet-5`; GPT Sol models → `gpt-5.6-terra`) and standard or fast Anthropic/Terra models to `gpt-5.6-luna`. + +**Specialists:** code specialists `gpt-5.3-codex` (powerful) and `mai-code-1.1-flash` (lightweight); analytical diversity `gemini-4-argon` (powerful), `grok-4.7` and `grok-4.6` (versatile). -**Valid models (current platform catalog):** +**Valid models (public GA catalog):** -Premium: `gpt-5.6-sol`, `claude-opus-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6` -Standard: `gpt-5.6-terra`, `claude-sonnet-5`, `claude-sonnet-4.6`, `claude-sonnet-4.5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `gemini-3.1-pro` -Fast/Cheap: `gpt-5.6-luna`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini` +Premium (powerful): `claude-opus-5.5`, `gpt-6.1-sol`, `claude-opus-5`, `gpt-6-sol`, `claude-opus-4.8`, `gpt-5.6-sol`, `gemini-4-argon` +Standard: `claude-sonnet-5.5`, `gpt-5.6-terra`, `claude-sonnet-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `grok-4.7`, `grok-4.6` +Fast: `claude-haiku-5.5`, `gpt-6-luna`, `gpt-5.6-luna`, `gemini-3.8-flash`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `mai-code-1.1-flash` diff --git a/.squad-templates/skills/economy-mode/SKILL.md b/.squad-templates/skills/economy-mode/SKILL.md index 646038978..0c03f0593 100644 --- a/.squad-templates/skills/economy-mode/SKILL.md +++ b/.squad-templates/skills/economy-mode/SKILL.md @@ -29,25 +29,25 @@ Use this skill when the user wants to reduce costs across an entire session or p | Method | How | |--------|-----| | Session phrase | "use economy mode", "save costs", "go cheap", "reduce costs" | -| Persistent config | `"economyMode": true` in `.squad/config.json` | +| Persistent config | `"economyMode": true` or `"modelProfile": "economy"` in `.squad/config.json` | | CLI flag | `squad --economy` | **Deactivation:** "turn off economy mode", "disable economy mode", or remove `economyMode` from `config.json`. ## Economy Model Selection Table -When economy mode is **active**, Layer 3 auto-selection uses this table instead of the normal defaults: +Economy mode is the opt-in `economy` quality profile. The default is `quality-first`. When economy mode is **active**, Layer 3 auto-selection uses this table instead of the normal defaults: -| Task Output | Normal Mode | Economy Mode | +| Task Output | Normal Mode (`quality-first`) | Economy Mode | |-------------|-------------|--------------| -| Writing code (implementation, refactoring, bug fixes) | `gpt-5.6-terra` | `gpt-5.6-luna` | -| Writing prompts or agent designs | `gpt-5.6-terra` | `gpt-5.6-luna` | -| Docs, planning, triage, changelogs, mechanical ops | `gpt-5.6-luna` | `gpt-5.6-luna` | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | `gpt-5.6-terra` | -| Architecture, code review, security audits | `gpt-5.6-sol` | `gpt-5.6-terra` | -| Scribe / logger / mechanical file ops | `gpt-5.6-luna` | `gpt-5.6-luna` | +| Writing code (implementation, refactoring, bug fixes) | `claude-sonnet-5.5` | `gpt-5.6-luna` | +| Writing prompts or agent designs | `claude-sonnet-5.5` | `gpt-5.6-luna` | +| Docs, planning, triage, research | `claude-sonnet-5.5` (research: `claude-opus-5.5`) | `gpt-5.6-luna` | +| Visual/design work requiring image analysis | `claude-opus-5.5` | `claude-sonnet-5.5` | +| Architecture, code review, verification, security audits | `claude-opus-5.5` | `claude-sonnet-5.5` | +| Scribe / logger / git / release / mechanical file ops | `claude-haiku-5.5` | `gpt-5.6-luna` | -**Prefer `gpt-5.6-luna`** for all economy-mode tasks where cost is the priority. +**Prefer `gpt-5.6-luna`** for all economy-mode tasks where cost is the priority. Cost ceilings (`maxCategory`) still apply. If the live model list does not include a mapped model, selection moves forward through its tier's fallback chain. ## AGENT WORKFLOW @@ -104,12 +104,12 @@ After updating economy mode state and including the `💰` indicator in spawn ac } ``` -- `economyMode` — when `true`, Layer 3 uses the economy table. Optional; absent = economy mode off. +- `economyMode` — when `true`, Layer 3 uses the economy table. Optional; absent = economy mode off. An explicit `economyMode: false` option cancels a persisted `modelProfile: "economy"`. - Combines with `defaultModel` and `agentModelOverrides` — Layer 0 always wins. ## Anti-Patterns -- **Don't override Layer 0 in economy mode.** If the user set `defaultModel: "claude-opus-4.6"`, they want quality. Economy mode only affects Layer 3 auto-selection. +- **Don't override Layer 0 in economy mode.** If the user set `defaultModel: "claude-opus-5.5"`, they want quality. Economy mode only affects Layer 3 auto-selection. - **Don't silently apply economy mode.** Always acknowledge when activated or deactivated. - **Don't treat economy mode as permanent by default.** Session phrases activate session-only; only "always" or `config.json` persist it. -- **Don't bump premium tasks down too far.** Visual, architecture, and security work shifts from Sol to Terra in economy mode — it does NOT go to fast/cheap models. +- **Don't bump premium tasks down too far.** Visual, architecture, and security work shifts from premium to standard in economy mode — it does NOT go to fast/cheap models. diff --git a/.squad-templates/skills/model-selection/SKILL.md b/.squad-templates/skills/model-selection/SKILL.md index 7dc450124..98eefb15b 100644 --- a/.squad-templates/skills/model-selection/SKILL.md +++ b/.squad-templates/skills/model-selection/SKILL.md @@ -36,8 +36,8 @@ Resolution is **first-match-wins** — the highest layer with a value wins. | **0b** | Global Config | `.squad/config.json` → `defaultModel` | Persistent (survives sessions) | | **1** | Session Directive | User said "use X" in current session | Session-only | | **2** | Charter Preference | Agent's `charter.md` → `## Model` section | Persistent (in charter) | -| **3** | Task-Aware Auto | Code/prompts → Terra, docs → Luna, visual → Sol | Computed per-spawn | -| **4** | Default | `gpt-5.6-luna` | Hardcoded fallback | +| **3** | Task-Aware Auto | Task type → tier under the active quality profile (default `quality-first`) | Computed per-spawn | +| **4** | Default | Head of the standard chain (`claude-sonnet-5.5`); `gpt-5.6-luna` under the `economy` profile | Hardcoded fallback | **Key principle:** Layer 0 (persistent config) beats everything. If the user said "always use opus" and it was saved to config.json, every agent gets opus regardless of role or task type. This is intentional — the user explicitly chose quality over cost. @@ -56,12 +56,13 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. CHECK Layer 0b: Is there a `defaultModel` in config.json? → Use it. 3. CHECK Layer 1: Did the user give a session directive? → Use it. 4. CHECK Layer 2: Does the agent's charter have a `## Model` section? → Use it. -5. CHECK Layer 3: Determine task type: -- Code (implementation, tests, refactoring, bug fixes) → `gpt-5.6-terra` -- Prompts, agent designs → `gpt-5.6-terra` -- Visual/design with image analysis → `gpt-5.6-sol` -- Non-code (docs, planning, triage, changelogs) → `gpt-5.6-luna` -6. FALLBACK Layer 4: `gpt-5.6-luna` +5. CHECK Layer 3: Determine task type and use the head of its tier chain for the active profile (`quality-first` unless the session or `.squad/config.json` `modelProfile` says `balanced` or `economy`, or `economyMode` is on): +- Architecture, research, verification, reviewer gates, security, design, visual → premium (`claude-opus-5.5`); `balanced` and `economy` move research down +- Code (implementation, tests, refactoring, bug fixes), prompts, agent designs → standard (`claude-sonnet-5.5`) +- Docs, writing, planning, triage → standard under `quality-first`; fast under `economy` (and planning under `balanced`) +- Mechanical (Scribe, git, release, changelogs, version bumps) → fast (`claude-haiku-5.5`) +- `economy` then applies the economy map (premium → standard, standard → `gpt-5.6-luna`) and says so in the rationale +6. FALLBACK Layer 4: head of the standard chain; `gpt-5.6-luna` under `economy` 7. INCLUDE model in spawn acknowledgment: `🔧 {Name} ({resolved_model}) — {task}` ### When User Sets a Preference @@ -78,6 +79,13 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. WRITE to `agentModelOverrides.{agent}` in `.squad/config.json` 3. ACKNOWLEDGE: `✅ {Agent} will always use {model} — saved to config.` +**Profile trigger:** "use the {quality-first|balanced|economy} profile" / "always use economy" + +1. WRITE `modelProfile` to `.squad/config.json` (merge, don't overwrite); for "this session" only, keep it in session context instead +2. ACKNOWLEDGE: `✅ Model profile saved: {profile}` + +A profile never replaces an explicit model (config, session, charter or per-spawn), including an opt-in model the user chose. + ### When User Clears a Preference **Trigger phrases:** "switch back to automatic", "clear model preference", "use default models" @@ -100,26 +108,30 @@ After resolving the model and including it in the spawn template, this skill is ```json { "version": 1, - "defaultModel": "claude-opus-4.6", + "defaultModel": "claude-opus-5.5", + "modelProfile": "balanced", "agentModelOverrides": { - "agent-alpha": "claude-sonnet-4.6", - "agent-beta": "claude-haiku-4.5" + "agent-alpha": "claude-sonnet-5.5", + "agent-beta": "claude-haiku-5.5" } } ``` - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` -- Both fields are optional. When absent, Layers 1-4 apply normally. +- `modelProfile` — `quality-first` (default), `balanced` or `economy`; affects only Layers 3-4 +- All fields are optional. When absent, Layers 1-4 apply normally. ## Fallback Chains If a model is unavailable (rate limit, plan restriction), retry within the same tier: ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol → (omit model param) +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → (omit model param) +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` +Chains list only public GA models (https://docs.github.com/en/copilot/reference/ai-models/supported-models, verified 2026-10-14). When the runtime supplies a live model list, it decides availability. + **Never fall UP in tier.** A fast task won't land on a premium model via fallback. diff --git a/.squad-templates/squad.agent.md b/.squad-templates/squad.agent.md index c052c255d..57a62308e 100644 --- a/.squad-templates/squad.agent.md +++ b/.squad-templates/squad.agent.md @@ -393,7 +393,7 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (task-aware tier under the active quality profile; default `quality-first`, with `balanced` or `economy` only by explicit opt-in) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. diff --git a/packages/squad-cli/templates/model-selection-reference.md b/packages/squad-cli/templates/model-selection-reference.md index 36d47b58e..ddc15e74e 100644 --- a/packages/squad-cli/templates/model-selection-reference.md +++ b/packages/squad-cli/templates/model-selection-reference.md @@ -12,8 +12,8 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium | 2 | `config` | Persistent `.squad/config.json`: `agentModelOverrides.{agentName}`, otherwise `defaultModel` | | 3 | `session` | The model picked for this session (by the user or the session itself); inherited by every sub-agent spawn | | 4 | `charter` | The agent charter's `## Model` → `**Preferred:**` value, unless it is `auto` | -| 5 | `task-auto` | Task-aware tier selection (cost first, unless code is being written) | -| 6 | `default` | Nothing else matched | +| 5 | `task-auto` | Task-aware tier selection under the active quality profile (default `quality-first`) | +| 6 | `default` | Nothing else matched (head of the standard tier; fast tier only under the `economy` profile) | | — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | `auto` at any layer (any letter case) means "no preference here"; continue to the next layer. This includes a per-spawn directive, the persistent config fields, the session model and the charter. @@ -30,36 +30,55 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**`task-auto` — task-aware auto-selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to its output type, select the tier, and use the head of that tier's fallback chain (Appendix A): +**Quality profile:** Task-aware selection runs under one of three profiles. The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. -| Task Output | Tier | Rule | -|-------------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | Standard | Quality and accuracy matter for code. | -| Writing prompts or agent designs (structured text that functions like code) | Standard | Prompts are executable — treat like code. | -| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | Fast | Cost first. | -| Visual/design work requiring image analysis | Premium | Vision capability required. Overrides cost rule. | +| Profile | When it is active | Intent | +|---------|-------------------|--------| +| `quality-first` | Default when nothing else is set | Pick the tier that gives the best result for the task; avoid reruns caused by an underpowered model | +| `balanced` | Explicit opt-in | Keep premium for quality-sensitive gates; research and language-heavy work stay standard rather than automatically cheap | +| `economy` | Explicit opt-in (`economyMode: true` or `modelProfile: "economy"`) | Cost first; then apply the economy map. Always visible: the rationale says `(economy profile)` | -**Role-to-tier mapping** (applying the cost-first principle): +Profile precedence: session profile ("use the balanced profile for this session") → `economyMode: true` → persistent `modelProfile` in `.squad/config.json` → `quality-first`. An explicit `economyMode: false` cancels a persisted `economy` profile. Cost ceilings (`maxCategory`) still apply under every profile. + +- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced` or `economy`). Acknowledge: `✅ Model profile saved: {profile}`. +- **When user says "use {profile} for this session":** Treat it as the session profile; do not write config. + +**`task-auto` — task-aware auto-selection:** Match the agent's task to its type, look up the tier for the active profile, and use the head of that tier's fallback chain (Appendix A): + +| Task type | `quality-first` | `balanced` | `economy` | +|-----------|-----------------|------------|-----------| +| Architecture, verification, review, security, design, visual | Premium | Premium | Premium → economy map | +| Research | Premium | Standard | Fast | +| Writing code, prompts or agent designs | Standard | Standard | Standard → economy map | +| Docs, writing | Standard | Standard | Fast | +| Planning, triage | Standard | Fast | Fast | +| Mechanical ops (Scribe, git, release, changelogs, version bumps) | Fast | Fast | Fast | + +Quality-sensitive work that is not code (architecture, research, verification, reviewer gates, security, design) can use the premium tier. Mechanical work stays fast under every profile. + +**Role-to-tier mapping** (under `quality-first`): | Role | Default Tier | Why | Override When | |------|--------------|-----|---------------| | Core Dev / Backend / Frontend | Standard | Writes code — quality first | Heavy code gen → code specialist | -| Tester / QA | Standard | Writes test code — quality first | Simple test scaffolding → fast | -| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → fast | -| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → standard; research/analysis → fast | -| Copilot SDK Expert | Standard | Technical analysis that often touches code | Pure research → fast | +| Tester / QA | Standard | Writes test code; verification gates → premium | Simple test scaffolding → fast | +| Lead / Architect | auto (per-task) | Architecture and review are quality-sensitive | Architecture proposals, reviewer gates → premium; triage/planning → standard | +| Prompt Engineer | auto (per-task) | Prompt design is executable, like code | Prompt architecture → standard; research → premium | +| Copilot SDK Expert | Standard | Technical analysis that often touches code | Deep research → premium | | Designer / Visual | Premium | Vision-capable model required | — (never downgrade — vision is non-negotiable) | -| DevRel / Writer | Fast | Docs and writing — not code | — | -| Scribe / Logger | Fast | Mechanical file ops — cheapest possible | — (never bump Scribe) | +| DevRel / Writer | Standard | Language-heavy work benefits from a stronger model | Economy profile → fast | +| Scribe / Logger | Fast | Mechanical file ops | — (never bump Scribe) | | Git / Release | Fast | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | **Task complexity adjustments** (apply at most ONE — no cascading): -- **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) +- **Bump UP to premium:** multi-agent coordination (output feeds 3+ agents) - **Bump DOWN to fast:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps - **Switch to the code specialist (Appendix A):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) - **Switch to the analytical-diversity model (Appendix A):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection -**`default`:** If nothing else matched, use the head of the fast tier chain. Cost wins when in doubt, unless code is being produced. +**Live availability:** When the runtime supplies a live model list (`availableModels`) or a list of unavailable models, it is authoritative for availability. A recommended model missing from the live list falls forward to the next listed candidate; an explicitly chosen model missing from it falls back visibly (source `fallback`, rationale names it). Recommendations and defaults come only from the public GA catalog in Appendix A; a model that is merely exposed by a tool schema is not a recommendation. + +**`default`:** If nothing else matched, use the head of the standard tier chain. Under the `economy` profile, use the economy default from the fast tier instead. **`fallback` — when a model is unavailable:** @@ -106,18 +125,22 @@ Every spawn acknowledgment names the resolved model, its source and a short rati This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. +Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. + **Fallback chains:** ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol → (omit model param) +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → (omit model param) +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` -**Specialists:** code specialist `gpt-5.3-codex` (standard); analytical diversity `gemini-3.1-pro` (standard). +**Economy default:** `gpt-5.6-luna`. The economy map moves premium to standard (`claude-opus-5.5` → `claude-sonnet-5.5`; `claude-opus-5`, `claude-opus-4.8` → `claude-sonnet-5`; GPT Sol models → `gpt-5.6-terra`) and standard or fast Anthropic/Terra models to `gpt-5.6-luna`. + +**Specialists:** code specialists `gpt-5.3-codex` (powerful) and `mai-code-1.1-flash` (lightweight); analytical diversity `gemini-4-argon` (powerful), `grok-4.7` and `grok-4.6` (versatile). -**Valid models (current platform catalog):** +**Valid models (public GA catalog):** -Premium: `gpt-5.6-sol`, `claude-opus-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6` -Standard: `gpt-5.6-terra`, `claude-sonnet-5`, `claude-sonnet-4.6`, `claude-sonnet-4.5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `gemini-3.1-pro` -Fast/Cheap: `gpt-5.6-luna`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini` +Premium (powerful): `claude-opus-5.5`, `gpt-6.1-sol`, `claude-opus-5`, `gpt-6-sol`, `claude-opus-4.8`, `gpt-5.6-sol`, `gemini-4-argon` +Standard: `claude-sonnet-5.5`, `gpt-5.6-terra`, `claude-sonnet-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `grok-4.7`, `grok-4.6` +Fast: `claude-haiku-5.5`, `gpt-6-luna`, `gpt-5.6-luna`, `gemini-3.8-flash`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `mai-code-1.1-flash` diff --git a/packages/squad-cli/templates/skills/economy-mode/SKILL.md b/packages/squad-cli/templates/skills/economy-mode/SKILL.md index 646038978..0c03f0593 100644 --- a/packages/squad-cli/templates/skills/economy-mode/SKILL.md +++ b/packages/squad-cli/templates/skills/economy-mode/SKILL.md @@ -29,25 +29,25 @@ Use this skill when the user wants to reduce costs across an entire session or p | Method | How | |--------|-----| | Session phrase | "use economy mode", "save costs", "go cheap", "reduce costs" | -| Persistent config | `"economyMode": true` in `.squad/config.json` | +| Persistent config | `"economyMode": true` or `"modelProfile": "economy"` in `.squad/config.json` | | CLI flag | `squad --economy` | **Deactivation:** "turn off economy mode", "disable economy mode", or remove `economyMode` from `config.json`. ## Economy Model Selection Table -When economy mode is **active**, Layer 3 auto-selection uses this table instead of the normal defaults: +Economy mode is the opt-in `economy` quality profile. The default is `quality-first`. When economy mode is **active**, Layer 3 auto-selection uses this table instead of the normal defaults: -| Task Output | Normal Mode | Economy Mode | +| Task Output | Normal Mode (`quality-first`) | Economy Mode | |-------------|-------------|--------------| -| Writing code (implementation, refactoring, bug fixes) | `gpt-5.6-terra` | `gpt-5.6-luna` | -| Writing prompts or agent designs | `gpt-5.6-terra` | `gpt-5.6-luna` | -| Docs, planning, triage, changelogs, mechanical ops | `gpt-5.6-luna` | `gpt-5.6-luna` | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | `gpt-5.6-terra` | -| Architecture, code review, security audits | `gpt-5.6-sol` | `gpt-5.6-terra` | -| Scribe / logger / mechanical file ops | `gpt-5.6-luna` | `gpt-5.6-luna` | +| Writing code (implementation, refactoring, bug fixes) | `claude-sonnet-5.5` | `gpt-5.6-luna` | +| Writing prompts or agent designs | `claude-sonnet-5.5` | `gpt-5.6-luna` | +| Docs, planning, triage, research | `claude-sonnet-5.5` (research: `claude-opus-5.5`) | `gpt-5.6-luna` | +| Visual/design work requiring image analysis | `claude-opus-5.5` | `claude-sonnet-5.5` | +| Architecture, code review, verification, security audits | `claude-opus-5.5` | `claude-sonnet-5.5` | +| Scribe / logger / git / release / mechanical file ops | `claude-haiku-5.5` | `gpt-5.6-luna` | -**Prefer `gpt-5.6-luna`** for all economy-mode tasks where cost is the priority. +**Prefer `gpt-5.6-luna`** for all economy-mode tasks where cost is the priority. Cost ceilings (`maxCategory`) still apply. If the live model list does not include a mapped model, selection moves forward through its tier's fallback chain. ## AGENT WORKFLOW @@ -104,12 +104,12 @@ After updating economy mode state and including the `💰` indicator in spawn ac } ``` -- `economyMode` — when `true`, Layer 3 uses the economy table. Optional; absent = economy mode off. +- `economyMode` — when `true`, Layer 3 uses the economy table. Optional; absent = economy mode off. An explicit `economyMode: false` option cancels a persisted `modelProfile: "economy"`. - Combines with `defaultModel` and `agentModelOverrides` — Layer 0 always wins. ## Anti-Patterns -- **Don't override Layer 0 in economy mode.** If the user set `defaultModel: "claude-opus-4.6"`, they want quality. Economy mode only affects Layer 3 auto-selection. +- **Don't override Layer 0 in economy mode.** If the user set `defaultModel: "claude-opus-5.5"`, they want quality. Economy mode only affects Layer 3 auto-selection. - **Don't silently apply economy mode.** Always acknowledge when activated or deactivated. - **Don't treat economy mode as permanent by default.** Session phrases activate session-only; only "always" or `config.json` persist it. -- **Don't bump premium tasks down too far.** Visual, architecture, and security work shifts from Sol to Terra in economy mode — it does NOT go to fast/cheap models. +- **Don't bump premium tasks down too far.** Visual, architecture, and security work shifts from premium to standard in economy mode — it does NOT go to fast/cheap models. diff --git a/packages/squad-cli/templates/skills/model-selection/SKILL.md b/packages/squad-cli/templates/skills/model-selection/SKILL.md index 7dc450124..98eefb15b 100644 --- a/packages/squad-cli/templates/skills/model-selection/SKILL.md +++ b/packages/squad-cli/templates/skills/model-selection/SKILL.md @@ -36,8 +36,8 @@ Resolution is **first-match-wins** — the highest layer with a value wins. | **0b** | Global Config | `.squad/config.json` → `defaultModel` | Persistent (survives sessions) | | **1** | Session Directive | User said "use X" in current session | Session-only | | **2** | Charter Preference | Agent's `charter.md` → `## Model` section | Persistent (in charter) | -| **3** | Task-Aware Auto | Code/prompts → Terra, docs → Luna, visual → Sol | Computed per-spawn | -| **4** | Default | `gpt-5.6-luna` | Hardcoded fallback | +| **3** | Task-Aware Auto | Task type → tier under the active quality profile (default `quality-first`) | Computed per-spawn | +| **4** | Default | Head of the standard chain (`claude-sonnet-5.5`); `gpt-5.6-luna` under the `economy` profile | Hardcoded fallback | **Key principle:** Layer 0 (persistent config) beats everything. If the user said "always use opus" and it was saved to config.json, every agent gets opus regardless of role or task type. This is intentional — the user explicitly chose quality over cost. @@ -56,12 +56,13 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. CHECK Layer 0b: Is there a `defaultModel` in config.json? → Use it. 3. CHECK Layer 1: Did the user give a session directive? → Use it. 4. CHECK Layer 2: Does the agent's charter have a `## Model` section? → Use it. -5. CHECK Layer 3: Determine task type: -- Code (implementation, tests, refactoring, bug fixes) → `gpt-5.6-terra` -- Prompts, agent designs → `gpt-5.6-terra` -- Visual/design with image analysis → `gpt-5.6-sol` -- Non-code (docs, planning, triage, changelogs) → `gpt-5.6-luna` -6. FALLBACK Layer 4: `gpt-5.6-luna` +5. CHECK Layer 3: Determine task type and use the head of its tier chain for the active profile (`quality-first` unless the session or `.squad/config.json` `modelProfile` says `balanced` or `economy`, or `economyMode` is on): +- Architecture, research, verification, reviewer gates, security, design, visual → premium (`claude-opus-5.5`); `balanced` and `economy` move research down +- Code (implementation, tests, refactoring, bug fixes), prompts, agent designs → standard (`claude-sonnet-5.5`) +- Docs, writing, planning, triage → standard under `quality-first`; fast under `economy` (and planning under `balanced`) +- Mechanical (Scribe, git, release, changelogs, version bumps) → fast (`claude-haiku-5.5`) +- `economy` then applies the economy map (premium → standard, standard → `gpt-5.6-luna`) and says so in the rationale +6. FALLBACK Layer 4: head of the standard chain; `gpt-5.6-luna` under `economy` 7. INCLUDE model in spawn acknowledgment: `🔧 {Name} ({resolved_model}) — {task}` ### When User Sets a Preference @@ -78,6 +79,13 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. WRITE to `agentModelOverrides.{agent}` in `.squad/config.json` 3. ACKNOWLEDGE: `✅ {Agent} will always use {model} — saved to config.` +**Profile trigger:** "use the {quality-first|balanced|economy} profile" / "always use economy" + +1. WRITE `modelProfile` to `.squad/config.json` (merge, don't overwrite); for "this session" only, keep it in session context instead +2. ACKNOWLEDGE: `✅ Model profile saved: {profile}` + +A profile never replaces an explicit model (config, session, charter or per-spawn), including an opt-in model the user chose. + ### When User Clears a Preference **Trigger phrases:** "switch back to automatic", "clear model preference", "use default models" @@ -100,26 +108,30 @@ After resolving the model and including it in the spawn template, this skill is ```json { "version": 1, - "defaultModel": "claude-opus-4.6", + "defaultModel": "claude-opus-5.5", + "modelProfile": "balanced", "agentModelOverrides": { - "agent-alpha": "claude-sonnet-4.6", - "agent-beta": "claude-haiku-4.5" + "agent-alpha": "claude-sonnet-5.5", + "agent-beta": "claude-haiku-5.5" } } ``` - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` -- Both fields are optional. When absent, Layers 1-4 apply normally. +- `modelProfile` — `quality-first` (default), `balanced` or `economy`; affects only Layers 3-4 +- All fields are optional. When absent, Layers 1-4 apply normally. ## Fallback Chains If a model is unavailable (rate limit, plan restriction), retry within the same tier: ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol → (omit model param) +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → (omit model param) +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` +Chains list only public GA models (https://docs.github.com/en/copilot/reference/ai-models/supported-models, verified 2026-10-14). When the runtime supplies a live model list, it decides availability. + **Never fall UP in tier.** A fast task won't land on a premium model via fallback. diff --git a/packages/squad-cli/templates/squad.agent.md.template b/packages/squad-cli/templates/squad.agent.md.template index c052c255d..57a62308e 100644 --- a/packages/squad-cli/templates/squad.agent.md.template +++ b/packages/squad-cli/templates/squad.agent.md.template @@ -393,7 +393,7 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (task-aware tier under the active quality profile; default `quality-first`, with `balanced` or `economy` only by explicit opt-in) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index e1dc46917..4e0959a86 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -75,6 +75,12 @@ export { pruneChainToCeiling, isModelPreference, persistentConfigModel, + resolveModelProfile, + PROFILE_TASK_TIERS, + DEFAULT_MODEL_SELECTION_PROFILE, + MODEL_SELECTION_PROFILES, + isModelSelectionProfile, + type ModelSelectionProfile, type ModelResolutionOptions, type ResolvedModel, type ModelResolution, diff --git a/packages/squad-sdk/src/agents/lifecycle.ts b/packages/squad-sdk/src/agents/lifecycle.ts index e1d519020..806db72e0 100644 --- a/packages/squad-sdk/src/agents/lifecycle.ts +++ b/packages/squad-sdk/src/agents/lifecycle.ts @@ -18,7 +18,7 @@ import { type ModelResolutionSource, type TaskType, } from './model-selector.js'; -import { VALID_REASONING_EFFORTS, VALID_CONTEXT_TIERS } from '../config/models.js'; +import { VALID_REASONING_EFFORTS, VALID_CONTEXT_TIERS, isModelSelectionProfile, type ModelSelectionProfile } from '../config/models.js'; import type { CostPolicyConfig, SessionCostPolicyOverride } from '../config/models.js'; import type { EventBus } from '../runtime/event-bus.js'; import { ConfigurationError, SessionLifecycleError } from '../adapter/errors.js'; @@ -77,6 +77,8 @@ export interface AgentModelResolution { source: ModelResolutionSource; /** Human-readable explanation from the central resolver. */ rationale: string; + /** Quality profile in effect for the resolution. */ + profile?: ModelSelectionProfile; /** Model and source preferred before an availability fallback. */ fallbackFrom?: { model: string; source: ModelResolutionSource }; /** True when the model parameter was omitted so the platform default applies. */ @@ -113,6 +115,20 @@ export interface SpawnAgentOptions { */ unavailableModels?: Iterable; + /** + * Live model list from the runtime (for example the session model enum). + * Authoritative for availability: ids not listed are skipped with visible + * fallback provenance. Omit when no live list is known. + */ + availableModels?: Iterable; + + /** + * Quality profile for this session; wins over `.squad/config.json` + * `modelProfile` and legacy `economyMode`. Explicit model choices are never + * changed by a profile. + */ + sessionProfile?: ModelSelectionProfile; + /** User-specified reasoning effort override */ reasoningEffortOverride?: SquadReasoningEffort; @@ -220,6 +236,8 @@ export class AgentLifecycleManager { modelOverride, sessionModel, unavailableModels, + availableModels, + sessionProfile, reasoningEffortOverride, contextTierOverride, sessionCostPolicy, @@ -274,6 +292,8 @@ export class AgentLifecycleManager { configModel: persistentConfigModel(persistent, agentName), sessionModel, economyMode: persistent?.economyMode === true, + profile: isModelSelectionProfile(persistent?.modelProfile) ? persistent.modelProfile : undefined, + sessionProfile, charterPreference: agentConfig.resolvedModel !== undefined ? agentConfig.resolvedModel : this.extractModelPreference(charterContent), @@ -282,11 +302,13 @@ export class AgentLifecycleManager { config: this.costPolicy ? { costPolicy: this.costPolicy } : undefined, sessionCostPolicy, unavailableModels, + availableModels, }; const resolvedModel = resolveModel(modelOptions); span.setAttribute('model.id', resolvedModel.model); span.setAttribute('model.source', resolvedModel.source); + if (resolvedModel.profile) span.setAttribute('model.profile', resolvedModel.profile); await this.emitModelResolved(agentName, resolvedModel); // Surface the cost-policy outcome — this was the #1089 bug: the policy @@ -331,6 +353,7 @@ export class AgentLifecycleManager { modelResolution: { source: resolvedModel.source, rationale: resolvedModel.rationale, + ...(resolvedModel.profile ? { profile: resolvedModel.profile } : {}), ...(resolvedModel.fallbackFrom ? { fallbackFrom: resolvedModel.fallbackFrom } : {}), ...(resolvedModel.omitModel ? { omitModel: true } : {}), }, @@ -461,7 +484,9 @@ export class AgentLifecycleManager { */ private async readPersistentModelConfig( squadDir: string, - ): Promise<{ defaultModel?: unknown; agentModelOverrides?: unknown; economyMode?: unknown } | undefined> { + ): Promise< + { defaultModel?: unknown; agentModelOverrides?: unknown; economyMode?: unknown; modelProfile?: unknown } | undefined + > { const raw = await this.storage.read(path.join(squadDir, 'config.json')); if (raw === undefined) return undefined; try { @@ -487,6 +512,7 @@ export class AgentLifecycleManager { model: resolved.model, source: resolved.source, rationale: resolved.rationale, + ...(resolved.profile ? { profile: resolved.profile } : {}), ...(resolved.fallbackFrom ? { fallbackFrom: resolved.fallbackFrom } : {}), ...(resolved.omitModel ? { omitModel: true } : {}), }, diff --git a/packages/squad-sdk/src/agents/model-selector.ts b/packages/squad-sdk/src/agents/model-selector.ts index f6daaa517..5895e5d21 100644 --- a/packages/squad-sdk/src/agents/model-selector.ts +++ b/packages/squad-sdk/src/agents/model-selector.ts @@ -5,7 +5,10 @@ import { MODELS } from '../runtime/constants.js'; import { applyEconomyMode, + DEFAULT_MODEL_SELECTION_PROFILE, + isModelSelectionProfile, MODEL_CATALOG, + type ModelSelectionProfile, CATEGORY_ORDER, type GitHubModelCategory, type CostPolicyConfig, @@ -14,10 +17,33 @@ import { } from '../config/models.js'; import type { EventBus } from '../runtime/event-bus.js'; +export { + DEFAULT_MODEL_SELECTION_PROFILE, + MODEL_SELECTION_PROFILES, + isModelSelectionProfile, + type ModelSelectionProfile, +} from '../config/models.js'; + /** - * Task types that influence model selection. + * Task types that influence model selection. Quality-sensitive non-code work + * (architecture, research, verification, review, security, design) is + * distinguished from language work (docs, writing, planning) and mechanical + * work (Scribe, git, release bookkeeping). */ -export type TaskType = 'code' | 'prompt' | 'docs' | 'visual' | 'planning' | 'mechanical'; +export type TaskType = + | 'code' + | 'prompt' + | 'docs' + | 'visual' + | 'planning' + | 'mechanical' + | 'architecture' + | 'research' + | 'verification' + | 'review' + | 'security' + | 'design' + | 'writing'; /** * Model tier classification. @@ -84,8 +110,18 @@ export interface ModelResolutionOptions { taskModel?: string; /** Agent role (for context) */ agentRole?: string; - /** When true, apply economy mode substitution at Layer 3/4 */ + /** + * Legacy economy switch. `true` selects the `economy` profile unless a + * session profile is set. + */ economyMode?: boolean; + /** + * Persistent quality profile (`.squad/config.json` `modelProfile`). Defaults + * to `quality-first`. Only automatic layers (task-auto, default) use it. + */ + profile?: ModelSelectionProfile; + /** Quality profile chosen for the current session; wins over `profile` and `economyMode`. */ + sessionProfile?: ModelSelectionProfile; /** * Persistent config carrying an optional cost policy (cost-ceiling axis). * When present with a `maxCategory`, resolution is finalized against it. @@ -102,6 +138,13 @@ export interface ModelResolutionOptions { * no candidate remains, `omitModel` is set. */ unavailableModels?: Iterable; + /** + * Live model list supplied by the runtime (for example the session's model + * enum). When present it is authoritative for availability: any id not in + * it is treated as unavailable, for every source, and handled like + * {@link unavailableModels}. Omit when no live list is known. + */ + availableModels?: Iterable; } /** @@ -124,6 +167,8 @@ export interface ResolvedModel { * consumer-constructed values stay valid. */ rationale?: string; + /** Quality profile in effect for this resolution (set by {@link resolveModel}). */ + profile?: ModelSelectionProfile; /** Model and source that were preferred before an availability fallback. */ fallbackFrom?: { model: string; source: ModelResolutionSource }; /** @@ -153,13 +198,41 @@ export interface EffectiveCostPolicy { * @returns Resolved model with tier, source, rationale, fallback chain, and optional policy outcome */ export function resolveModel(options: ModelResolutionOptions): ModelResolution { - const base = resolveBaseModel(options); + const profile = resolveModelProfile(options); + const base = resolveBaseModel(options, profile); const policy = buildEffectiveCostPolicy(options.config, options.sessionCostPolicy); const catalogMap = policy ? buildCatalogCategoryMap() : undefined; const finalized = policy && catalogMap ? finalizeResolvedModel(base, policy, catalogMap) : base; const ceiling = policy && catalogMap ? { maxCategory: policy.maxCategory, catalogMap } : undefined; - const resolved = applyAvailabilityFallback(finalized, options.unavailableModels, ceiling); - return { ...resolved, rationale: resolved.rationale ?? resolved.source }; + const isUnavailable = buildAvailabilityCheck(options.unavailableModels, options.availableModels); + const resolved = applyAvailabilityFallback(finalized, isUnavailable, ceiling); + return { ...resolved, profile, rationale: resolved.rationale ?? resolved.source }; +} + +/** + * Effective quality profile: session profile, then legacy `economyMode: true`, + * then the persistent profile, then `quality-first`. Unknown values are ignored. + */ +export function resolveModelProfile( + options: Pick, +): ModelSelectionProfile { + if (isModelSelectionProfile(options.sessionProfile)) return options.sessionProfile; + if (options.economyMode === true) return 'economy'; + if (isModelSelectionProfile(options.profile)) return options.profile; + return DEFAULT_MODEL_SELECTION_PROFILE; +} + +function buildAvailabilityCheck( + unavailableModels?: Iterable, + availableModels?: Iterable, +): ((id: string) => boolean) | undefined { + if (!unavailableModels && !availableModels) return undefined; + const unavailable = new Set(Array.from(unavailableModels ?? [], (id) => id.toLowerCase())); + const available = availableModels ? new Set(Array.from(availableModels, (id) => id.toLowerCase())) : undefined; + return (id: string) => { + const key = id.toLowerCase(); + return unavailable.has(key) || (available !== undefined && !available.has(key)); + }; } /** @@ -220,10 +293,11 @@ function explicitModel(model: string, source: ModelResolutionSource, rationale: /** * Layered base resolution. Explicit layers (user-override, config, session, - * charter) are never altered by economy mode. + * charter) are never altered by the quality profile or economy mode. */ -function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { - const { taskType, taskModel, economyMode } = options; +function resolveBaseModel(options: ModelResolutionOptions, profile: ModelSelectionProfile): ResolvedModel { + const { taskType, taskModel } = options; + const economyMode = profile === 'economy'; const userOverride = options.userOverride?.trim(); const configModel = options.configModel?.trim(); const sessionModel = options.sessionModel?.trim(); @@ -245,28 +319,29 @@ function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { return explicitModel(charterPreference, 'charter', `charter preference selected '${charterPreference}'`); } - // Layer 3: Task-Aware Auto-Selection (economy mode applies) + // Layer 3: Task-Aware Auto-Selection (profile applies) const autoSelected = isModelPreference(taskModel) ? selectTaskModel(taskModel.trim(), economyMode) : taskType - ? selectModelForTask(taskType, economyMode) + ? selectModelForTask(taskType, profile) : undefined; if (autoSelected) { return autoSelected; } - // Layer 4: Default (economy mode applies) + // Layer 4: Default (profile applies). Economy keeps the cost-first default; + // quality-first and balanced use the standard chain head. const defaultModel = economyMode ? applyEconomyMode(MODELS.SELECTOR_DEFAULT) - : MODELS.SELECTOR_DEFAULT; + : MODELS.FALLBACK_CHAINS.standard[0]; const defaultTier = inferTierFromModel(defaultModel); return { model: defaultModel, tier: defaultTier, source: 'default', fallbackChain: [...MODELS.FALLBACK_CHAINS[defaultTier]], - rationale: `no override, session, charter, or task match; default '${defaultModel}'` + - (economyMode ? ' (economy mode)' : ''), + rationale: `${profile}: no override, session, charter, or task match; default '${defaultModel}'` + + (economyMode ? ' (economy profile)' : ''), }; } @@ -281,12 +356,10 @@ function resolveBaseModel(options: ModelResolutionOptions): ResolvedModel { */ function applyAvailabilityFallback( resolved: ResolvedModel, - unavailableModels?: Iterable, + isUnavailable?: (id: string) => boolean, ceiling?: { maxCategory: GitHubModelCategory; catalogMap: Map }, ): ResolvedModel { - if (!unavailableModels) return resolved; - const unavailable = new Set(Array.from(unavailableModels, (id) => id.toLowerCase())); - if (!unavailable.has(resolved.model.toLowerCase())) return resolved; + if (!isUnavailable || !isUnavailable(resolved.model)) return resolved; const fallbackFrom = { model: resolved.model, source: resolved.source }; const position = resolved.fallbackChain.findIndex((id) => id.toLowerCase() === resolved.model.toLowerCase()); @@ -297,7 +370,7 @@ function applyAvailabilityFallback( return cat === undefined || CATEGORY_ORDER[cat] <= CATEGORY_ORDER[ceiling.maxCategory]; }; const index = resolved.fallbackChain.findIndex( - (id, i) => i >= start && !unavailable.has(id.toLowerCase()) && withinCeiling(id), + (id, i) => i >= start && !isUnavailable(id) && withinCeiling(id), ); const next = index >= 0 ? resolved.fallbackChain[index] : undefined; const ceilingNote = ceiling ? ` within cost ceiling '${ceiling.maxCategory}'` : ''; @@ -324,15 +397,17 @@ function applyAvailabilityFallback( } /** - * Select model based on task type, with optional economy mode substitution. + * Select model based on task type and quality profile. The economy profile + * also applies economy substitution. * * @param taskType - Type of task being performed - * @param economyMode - When true, downgrade model to cheaper alternative + * @param profile - Effective quality profile * @returns Resolved model or undefined if no match */ -function selectModelForTask(taskType: TaskType, economyMode?: boolean): ResolvedModel | undefined { - const taskTier = TASK_TIERS[taskType]; +function selectModelForTask(taskType: TaskType, profile: ModelSelectionProfile): ResolvedModel | undefined { + const taskTier = PROFILE_TASK_TIERS[profile][taskType]; if (!taskTier) return undefined; + const economyMode = profile === 'economy'; // Task-aware picks are the head of the tier's fallback chain, so the // catalog in runtime constants stays the single source of model ids. @@ -349,7 +424,7 @@ function selectModelForTask(taskType: TaskType, economyMode?: boolean): Resolved tier, source: 'task-auto', fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], - rationale: `${taskType} task → ${taskTier} tier '${model}'` + (economyMode ? ' (economy mode)' : ''), + rationale: `${profile}: ${taskType} task → ${taskTier} tier '${model}'` + (economyMode ? ' (economy profile)' : ''), }; } @@ -362,17 +437,62 @@ function selectTaskModel(taskModel: string, economyMode?: boolean): ResolvedMode tier, source: 'task-auto', fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], - rationale: `task-aware model '${model}'` + (economyMode ? ' (economy mode)' : ''), + rationale: `task-aware model '${model}'` + (economyMode ? ' (economy profile)' : ''), }; } -const TASK_TIERS: Record = { - code: 'standard', - prompt: 'standard', - visual: 'premium', - docs: 'fast', - planning: 'fast', - mechanical: 'fast', +/** + * Task → tier per quality profile (#1598). quality-first sends + * quality-sensitive non-code work to premium even when no code is written; + * economy keeps the historical cost-first table and then substitutes via + * {@link applyEconomyMode}. + */ +export const PROFILE_TASK_TIERS: Readonly>>> = { + 'quality-first': { + architecture: 'premium', + research: 'premium', + verification: 'premium', + review: 'premium', + security: 'premium', + design: 'premium', + visual: 'premium', + code: 'standard', + prompt: 'standard', + docs: 'standard', + writing: 'standard', + planning: 'standard', + mechanical: 'fast', + }, + balanced: { + architecture: 'premium', + verification: 'premium', + review: 'premium', + security: 'premium', + design: 'premium', + visual: 'premium', + research: 'standard', + code: 'standard', + prompt: 'standard', + docs: 'standard', + writing: 'standard', + planning: 'fast', + mechanical: 'fast', + }, + economy: { + architecture: 'premium', + verification: 'premium', + review: 'premium', + security: 'premium', + design: 'premium', + visual: 'premium', + code: 'standard', + prompt: 'standard', + research: 'fast', + docs: 'fast', + writing: 'fast', + planning: 'fast', + mechanical: 'fast', + }, }; export function inferTierFromModel(model: string): ModelTier { diff --git a/packages/squad-sdk/src/config/models.ts b/packages/squad-sdk/src/config/models.ts index 2d0cda9f6..f869af049 100644 --- a/packages/squad-sdk/src/config/models.ts +++ b/packages/squad-sdk/src/config/models.ts @@ -104,11 +104,11 @@ export interface ModelInfo { /** Model tier (quality axis) */ tier: ModelTier; - /** Provider (anthropic, openai, google) */ - provider: 'anthropic' | 'openai' | 'google'; + /** Provider */ + provider: 'anthropic' | 'openai' | 'google' | 'xai' | 'microsoft'; /** Model family */ - family: 'claude' | 'gpt' | 'gemini'; + family: 'claude' | 'gpt' | 'gemini' | 'grok' | 'mai'; /** * GitHub Copilot billing cost-ceiling category (cost axis). @@ -135,11 +135,15 @@ export interface ModelInfo { /** * Full model catalog. * - * Restricted to model IDs verified reachable from the GitHub Copilot CLI - * surface (the `copilot-cli` integration subset, 17 enabled models, verified - * 2026-07-13). Each entry carries an optional {@link ModelInfo.githubCategory} - * (cost axis) sourced from the models API `model_picker_category`, kept - * separate from {@link ModelInfo.tier} (quality axis). + * Restricted to public, generally available (GA) GitHub Copilot model IDs. + * Availability source: https://docs.github.com/en/copilot/reference/ai-models/supported-models + * (all entries listed as GA, verified 2026-10-14). Preview and internal IDs + * (for example `gpt-5.6-sol-fast`) are never recommended here; opt-in IDs + * such as `hydrafusion` still pass through when supplied explicitly. + * + * Each entry carries a {@link ModelInfo.githubCategory} (cost axis) taken + * from the GitHub Copilot models API `model_picker_category` (verified + * 2026-10-14), kept separate from {@link ModelInfo.tier} (quality axis). * * Notes: * - No hardcoded per-token pricing is added for models whose pricing is not @@ -147,36 +151,33 @@ export interface ModelInfo { * on newer entries rather than guessed. * - Out-of-catalog IDs still pass through the selector (0-cost estimate + * default chain); this catalog drives routing quality/cost, not correctness. + * - A runtime-supplied live model list (`availableModels`) is authoritative + * for availability; this catalog only orders recommendations. * - * Refs: #1080, #1183. + * Refs: #1080, #1183, #1598. */ export const MODEL_CATALOG: ModelInfo[] = [ // Premium tier (quality) — powerful category (cost) { - id: 'claude-opus-5', + id: 'claude-opus-5.5', tier: 'premium', provider: 'anthropic', family: 'claude', githubCategory: 'powerful', vision: true, - useCases: ['architecture proposals', 'security audits', 'complex design'], - cost: 10, - speed: 3, + useCases: ['architecture proposals', 'security audits', 'complex design', 'research', 'verification'], }, { - id: 'claude-opus-4.8', + id: 'gpt-6.1-sol', tier: 'premium', - provider: 'anthropic', - family: 'claude', + provider: 'openai', + family: 'gpt', githubCategory: 'powerful', vision: true, - useCases: ['architecture proposals', 'security audits', 'complex design'], - cost: 10, - speed: 3, - pricing: { inputPerToken: 0.000005, outputPerToken: 0.000025 }, + useCases: ['architecture proposals', 'security audits', 'complex design', 'research', 'verification'], }, { - id: 'claude-opus-4.7', + id: 'claude-opus-5', tier: 'premium', provider: 'anthropic', family: 'claude', @@ -185,10 +186,18 @@ export const MODEL_CATALOG: ModelInfo[] = [ useCases: ['architecture proposals', 'security audits', 'complex design'], cost: 10, speed: 3, - pricing: { inputPerToken: 0.000005, outputPerToken: 0.000025 }, }, { - id: 'claude-opus-4.6', + id: 'gpt-6-sol', + tier: 'premium', + provider: 'openai', + family: 'gpt', + githubCategory: 'powerful', + vision: true, + useCases: ['architecture proposals', 'security audits', 'complex design', 'research', 'verification'], + }, + { + id: 'claude-opus-4.8', tier: 'premium', provider: 'anthropic', family: 'claude', @@ -199,9 +208,6 @@ export const MODEL_CATALOG: ModelInfo[] = [ speed: 3, pricing: { inputPerToken: 0.000005, outputPerToken: 0.000025 }, }, - // gpt-5.6 family — CLI-observed reachable models (2026-07-13). - // githubCategory per live Copilot API (canonical billing axis): - // sol=powerful, terra=versatile, luna=lightweight. { id: 'gpt-5.6-sol', tier: 'premium', @@ -214,34 +220,40 @@ export const MODEL_CATALOG: ModelInfo[] = [ speed: 7, pricing: { inputPerToken: 0.000005, outputPerToken: 0.00003 }, }, + { + id: 'gemini-4-argon', + tier: 'premium', + provider: 'google', + family: 'gemini', + githubCategory: 'powerful', + vision: true, + useCases: ['code reviews', 'second opinion', 'diversity'], + }, // Standard tier (quality) — versatile/powerful category (cost) { - id: 'claude-sonnet-5', + id: 'claude-sonnet-5.5', tier: 'standard', provider: 'anthropic', family: 'claude', githubCategory: 'versatile', vision: true, - useCases: ['code generation', 'test writing', 'refactoring'], - cost: 5, - speed: 7, - pricing: { inputPerToken: 0.000002, outputPerToken: 0.00001 }, + useCases: ['code generation', 'prompt engineering', 'writing', 'refactoring'], }, { - id: 'claude-sonnet-4.6', + id: 'gpt-5.6-terra', tier: 'standard', - provider: 'anthropic', - family: 'claude', + provider: 'openai', + family: 'gpt', githubCategory: 'versatile', vision: true, - useCases: ['code generation', 'test writing', 'refactoring', 'prompt engineering'], + useCases: ['general purpose', 'code generation', 'analysis'], cost: 5, - speed: 7, - pricing: { inputPerToken: 0.000003, outputPerToken: 0.000015 }, + speed: 8, + pricing: { inputPerToken: 0.000002, outputPerToken: 0.000012 }, }, { - id: 'claude-sonnet-4.5', + id: 'claude-sonnet-5', tier: 'standard', provider: 'anthropic', family: 'claude', @@ -250,7 +262,7 @@ export const MODEL_CATALOG: ModelInfo[] = [ useCases: ['code generation', 'test writing', 'refactoring'], cost: 5, speed: 7, - pricing: { inputPerToken: 0.000003, outputPerToken: 0.000015 }, + pricing: { inputPerToken: 0.000002, outputPerToken: 0.00001 }, }, { id: 'gpt-5.5', @@ -264,18 +276,6 @@ export const MODEL_CATALOG: ModelInfo[] = [ speed: 7, pricing: { inputPerToken: 0.000005, outputPerToken: 0.00003 }, }, - { - id: 'gpt-5.6-terra', - tier: 'standard', - provider: 'openai', - family: 'gpt', - githubCategory: 'versatile', - vision: true, - useCases: ['general purpose', 'code generation', 'analysis'], - cost: 5, - speed: 8, - pricing: { inputPerToken: 0.000002, outputPerToken: 0.000012 }, - }, { id: 'gpt-5.4', tier: 'standard', @@ -301,18 +301,43 @@ export const MODEL_CATALOG: ModelInfo[] = [ pricing: { inputPerToken: 0.00000175, outputPerToken: 0.000014 }, }, { - id: 'gemini-3.1-pro', + id: 'grok-4.7', tier: 'standard', - provider: 'google', - family: 'gemini', - githubCategory: 'powerful', + provider: 'xai', + family: 'grok', + githubCategory: 'versatile', vision: true, - useCases: ['code reviews', 'second opinion', 'diversity'], - cost: 5, - speed: 7, + useCases: ['second opinion', 'diversity'], + }, + { + id: 'grok-4.6', + tier: 'standard', + provider: 'xai', + family: 'grok', + githubCategory: 'versatile', + vision: true, + useCases: ['second opinion', 'diversity'], }, - // Fast tier (quality) — lightweight category (cost) + // Fast tier (quality) — lightweight category (cost), except gemini-3.8-flash (versatile) + { + id: 'claude-haiku-5.5', + tier: 'fast', + provider: 'anthropic', + family: 'claude', + githubCategory: 'lightweight', + vision: true, + useCases: ['boilerplate', 'changelogs', 'simple fixes'], + }, + { + id: 'gpt-6-luna', + tier: 'fast', + provider: 'openai', + family: 'gpt', + githubCategory: 'lightweight', + vision: true, + useCases: ['scaffolding', 'simple tasks'], + }, { id: 'gpt-5.6-luna', tier: 'fast', @@ -325,6 +350,15 @@ export const MODEL_CATALOG: ModelInfo[] = [ speed: 9, pricing: { inputPerToken: 0.0000002, outputPerToken: 0.0000012 }, }, + { + id: 'gemini-3.8-flash', + tier: 'fast', + provider: 'google', + family: 'gemini', + githubCategory: 'versatile', + vision: true, + useCases: ['fast drafts', 'simple tasks', 'diversity'], + }, { id: 'claude-haiku-4.5', tier: 'fast', @@ -361,17 +395,26 @@ export const MODEL_CATALOG: ModelInfo[] = [ speed: 10, pricing: { inputPerToken: 0.00000025, outputPerToken: 0.000002 }, }, + { + id: 'mai-code-1.1-flash', + tier: 'fast', + provider: 'microsoft', + family: 'mai', + githubCategory: 'lightweight', + vision: true, + useCases: ['fast code edits', 'code diversity'], + }, ]; /** - * Default fallback chains per tier — real, CLI-reachable IDs ordered by preference. - * Preferred model order follows the tier routing policy (GPT-first for premium - * and standard, then provider fallbacks). + * Default fallback chains per tier — public GA IDs ordered by preference + * (quality first within each tier, alternating providers for resilience). + * Must stay identical to `MODELS.FALLBACK_CHAINS` and `DEFAULT_CONFIG.models.tiers`. */ export const DEFAULT_FALLBACK_CHAINS: Record = { - premium: ['gpt-5.6-sol', 'claude-opus-5', 'claude-opus-4.8', 'claude-opus-4.7', 'claude-opus-4.6', 'claude-sonnet-4.6'], - standard: ['gpt-5.6-terra', 'claude-sonnet-5', 'claude-sonnet-4.6', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex', 'claude-sonnet-4.5', 'gemini-3.1-pro'], - fast: ['gpt-5.6-luna', 'claude-haiku-4.5', 'gpt-5.4-mini', 'gpt-5-mini'], + premium: ['claude-opus-5.5', 'gpt-6.1-sol', 'claude-opus-5', 'gpt-6-sol', 'claude-opus-4.8', 'gpt-5.6-sol'], + standard: ['claude-sonnet-5.5', 'gpt-5.6-terra', 'claude-sonnet-5', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex'], + fast: ['claude-haiku-5.5', 'gpt-6-luna', 'gpt-5.6-luna', 'gemini-3.8-flash', 'claude-haiku-4.5', 'gpt-5.4-mini', 'gpt-5-mini'], }; /** @@ -624,21 +667,22 @@ export function estimateCost(model: string, inputTokens: number, outputTokens: n * economy mode is active. Layers 0–2 (explicit preferences) are * never substituted — the user's explicit choice always wins. * - * Table source: issue #500 + * Table source: issue #500, refreshed to public GA ids in #1598. */ export const ECONOMY_MODEL_MAP: Record = { // Premium → standard downgrade (architecture/review tasks) - 'gpt-5.6-sol': 'gpt-5.6-terra', - 'claude-opus-5': 'claude-sonnet-4.5', - 'claude-opus-4.8': 'claude-sonnet-4.5', - 'claude-opus-4.7': 'claude-sonnet-4.5', - 'claude-opus-4.6': 'claude-sonnet-4.5', + 'gpt-6.1-sol': 'gpt-5.6-terra', + 'gpt-6-sol': 'gpt-5.6-terra', + 'gpt-5.6-sol': 'gpt-5.6-terra', + 'claude-opus-5.5': 'claude-sonnet-5.5', + 'claude-opus-5': 'claude-sonnet-5', + 'claude-opus-4.8': 'claude-sonnet-5', // Standard → fast downgrade (code writing, docs, planning, triage) - 'gpt-5.6-terra': 'gpt-5.6-luna', + 'claude-sonnet-5.5': 'gpt-5.6-luna', + 'gpt-5.6-terra': 'gpt-5.6-luna', 'claude-sonnet-5': 'gpt-5.6-luna', - 'claude-sonnet-4.6': 'gpt-5.6-luna', - 'claude-sonnet-4.5': 'gpt-5.6-luna', // Fast → cheapest fast (scribe/mechanical, docs) + 'claude-haiku-5.5': 'gpt-5.6-luna', 'claude-haiku-4.5': 'gpt-5.6-luna', }; @@ -650,6 +694,31 @@ export function applyEconomyMode(model: string): string { return ECONOMY_MODEL_MAP[model] ?? model; } +/** + * Quality profile for automatic model selection (#1598). Explicit model + * choices (user override, config model, session model, charter) are never + * changed by a profile. + * - `quality-first` (default): quality-sensitive work (architecture, research, + * verification, review, security, design) gets premium; code, prompt and + * language-heavy work gets standard; mechanical work gets fast. + * - `balanced`: premium for architecture/security/review-style work, standard + * for code and language-heavy work, fast for planning and mechanical work. + * - `economy`: opt-in cost-first selection plus {@link ECONOMY_MODEL_MAP} + * substitution. Legacy `economyMode: true` selects this profile. + */ +export type ModelSelectionProfile = 'quality-first' | 'balanced' | 'economy'; + +/** All supported {@link ModelSelectionProfile} values. */ +export const MODEL_SELECTION_PROFILES: readonly ModelSelectionProfile[] = ['quality-first', 'balanced', 'economy'] as const; + +/** Profile used when neither the session nor `.squad/config.json` selects one. */ +export const DEFAULT_MODEL_SELECTION_PROFILE: ModelSelectionProfile = 'quality-first'; + +/** True when `value` is a supported {@link ModelSelectionProfile}. */ +export function isModelSelectionProfile(value: unknown): value is ModelSelectionProfile { + return typeof value === 'string' && (MODEL_SELECTION_PROFILES as readonly string[]).includes(value); +} + /** * Shape of model preference fields within `.squad/config.json`. */ @@ -657,6 +726,8 @@ export interface ModelPreferenceConfig { defaultModel?: string; agentModelOverrides?: Record; economyMode?: boolean; + /** Persistent {@link ModelSelectionProfile}; unknown values are ignored. */ + modelProfile?: ModelSelectionProfile; defaultReasoningEffort?: string; agentReasoningEffortOverrides?: Record; defaultContextTier?: string; @@ -716,6 +787,62 @@ export function writeEconomyMode(squadDir: string, enabled: boolean, storage: St storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); } +/** + * Reads the persistent model selection profile (`modelProfile`) from + * `.squad/config.json`. Unknown values are ignored. + * + * @param squadDir - Path to the `.squad/` directory + * @returns The profile, or null when unset or invalid + */ +export function readModelProfile(squadDir: string, storage: StorageProvider = new FSStorageProvider()): ModelSelectionProfile | null { + const configPath = join(squadDir, 'config.json'); + if (!storage.existsSync(configPath)) return null; + try { + const raw = storage.readSync(configPath); + if (raw === undefined) return null; + const parsed = JSON.parse(raw); + const value = parsed !== null && typeof parsed === 'object' ? parsed.modelProfile : undefined; + return isModelSelectionProfile(value) ? value : null; + } catch { + return null; + } +} + +/** + * Writes the persistent model selection profile to `.squad/config.json`. + * Merges with existing config; `null` removes the field. + * + * @param squadDir - Path to the `.squad/` directory + * @param profile - Profile to persist, or null to clear + */ +export function writeModelProfile( + squadDir: string, + profile: ModelSelectionProfile | null, + storage: StorageProvider = new FSStorageProvider(), +): void { + const configPath = join(squadDir, 'config.json'); + let config: Record = { version: 1 }; + if (storage.existsSync(configPath)) { + try { + const raw = storage.readSync(configPath); + if (raw !== undefined) config = JSON.parse(raw); + } catch { + config = { version: 1 }; + } + } + + if (profile === null) { + delete config.modelProfile; + } else { + if (!isModelSelectionProfile(profile)) { + throw new Error(`Unknown model profile '${String(profile)}'; expected one of ${MODEL_SELECTION_PROFILES.join(', ')}`); + } + config.modelProfile = profile; + } + + storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); +} + /** * Reads the persistent model preference from `.squad/config.json`. * @@ -1479,7 +1606,7 @@ export function resolveContextTier(options: { * Legacy string-returning adapter over the central model resolver * (`resolveModel` in `@bradygaster/squad-sdk/agents`). It reads persistent * `.squad/config.json` preferences and delegates precedence, the `auto` - * sentinel, and economy-mode handling to the central resolver: + * sentinel, quality profiles, and economy-mode handling to the central resolver: * config (agentModelOverrides, then defaultModel) > session directive > * charter preference > task model > default. * @@ -1496,6 +1623,8 @@ export function resolveModel(options: { taskModel?: string | null; /** When true, apply economy mode substitution at Layer 3/4. Overrides config. */ economyMode?: boolean; + /** Session-level quality profile; wins over persisted `modelProfile` and `economyMode`. */ + profile?: ModelSelectionProfile; /** Storage provider for config file access. */ storage?: StorageProvider; }): string { @@ -1516,6 +1645,9 @@ export function resolveModel(options: { options.economyMode !== undefined ? options.economyMode : (squadDir ? readEconomyMode(squadDir, storage) : false); + let profile = squadDir ? readModelProfile(squadDir, storage) ?? undefined : undefined; + // An explicit `economyMode: false` keeps its legacy meaning: economy is off. + if (options.economyMode === false && profile === 'economy') profile = undefined; return resolveCentralModel({ configModel, @@ -1523,5 +1655,7 @@ export function resolveModel(options: { charterPreference: charterPreference ?? undefined, taskModel: taskModel ?? undefined, economyMode, + profile, + sessionProfile: options.profile, }).model; } diff --git a/packages/squad-sdk/src/config/schema.ts b/packages/squad-sdk/src/config/schema.ts index 26127fda2..03efd83ec 100644 --- a/packages/squad-sdk/src/config/schema.ts +++ b/packages/squad-sdk/src/config/schema.ts @@ -100,9 +100,9 @@ export const DEFAULT_CONFIG: SquadConfig = { default: 'gpt-5.6-terra', defaultTier: 'standard', tiers: { - premium: ['gpt-5.6-sol', 'claude-opus-5', 'claude-opus-4.8', 'claude-opus-4.7', 'claude-opus-4.6'], - standard: ['gpt-5.6-terra', 'claude-sonnet-5', 'claude-sonnet-4.6', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex', 'claude-sonnet-4.5', 'gemini-3.1-pro'], - fast: ['gpt-5.6-luna', 'claude-haiku-4.5', 'gpt-5.4-mini', 'gpt-5-mini'], + premium: ['claude-opus-5.5', 'gpt-6.1-sol', 'claude-opus-5', 'gpt-6-sol', 'claude-opus-4.8', 'gpt-5.6-sol'], + standard: ['claude-sonnet-5.5', 'gpt-5.6-terra', 'claude-sonnet-5', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex'], + fast: ['claude-haiku-5.5', 'gpt-6-luna', 'gpt-5.6-luna', 'gemini-3.8-flash', 'claude-haiku-4.5', 'gpt-5.4-mini', 'gpt-5-mini'], }, }, agents: [], diff --git a/packages/squad-sdk/src/runtime/constants.ts b/packages/squad-sdk/src/runtime/constants.ts index f75209a02..1e59a3d4f 100644 --- a/packages/squad-sdk/src/runtime/constants.ts +++ b/packages/squad-sdk/src/runtime/constants.ts @@ -13,34 +13,42 @@ export const MODELS = { /** Default model for config files and new projects (env-overridable) */ DEFAULT: process.env['SQUAD_DEFAULT_MODEL'] ?? 'gpt-5.6-terra', - /** Default model for model-selector Layer 4 — cost-first */ + /** + * Economy-profile default for model-selector Layer 4 (cost-first). The + * quality-first and balanced profiles default to the standard chain head. + */ SELECTOR_DEFAULT: 'gpt-5.6-luna', - /** Default tier for the model-selector Layer 4 fallback */ + /** Tier of {@link MODELS.SELECTOR_DEFAULT} */ SELECTOR_DEFAULT_TIER: 'fast', - /** Fallback chains by tier — policy-ordered, GPT-first for premium/standard */ + /** + * Fallback chains by tier — public GA model ids only (#1598), quality first, + * alternating providers. Must match `DEFAULT_FALLBACK_CHAINS` and + * `DEFAULT_CONFIG.models.tiers`. + */ FALLBACK_CHAINS: { premium: [ - 'gpt-5.6-sol', + 'claude-opus-5.5', + 'gpt-6.1-sol', 'claude-opus-5', + 'gpt-6-sol', 'claude-opus-4.8', - 'claude-opus-4.7', - 'claude-opus-4.6', - 'claude-sonnet-4.6', + 'gpt-5.6-sol', ], standard: [ + 'claude-sonnet-5.5', 'gpt-5.6-terra', 'claude-sonnet-5', - 'claude-sonnet-4.6', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex', - 'claude-sonnet-4.5', - 'gemini-3.1-pro', ], fast: [ + 'claude-haiku-5.5', + 'gpt-6-luna', 'gpt-5.6-luna', + 'gemini-3.8-flash', 'claude-haiku-4.5', 'gpt-5.4-mini', 'gpt-5-mini', diff --git a/packages/squad-sdk/templates/model-selection-reference.md b/packages/squad-sdk/templates/model-selection-reference.md index 36d47b58e..ddc15e74e 100644 --- a/packages/squad-sdk/templates/model-selection-reference.md +++ b/packages/squad-sdk/templates/model-selection-reference.md @@ -12,8 +12,8 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium | 2 | `config` | Persistent `.squad/config.json`: `agentModelOverrides.{agentName}`, otherwise `defaultModel` | | 3 | `session` | The model picked for this session (by the user or the session itself); inherited by every sub-agent spawn | | 4 | `charter` | The agent charter's `## Model` → `**Preferred:**` value, unless it is `auto` | -| 5 | `task-auto` | Task-aware tier selection (cost first, unless code is being written) | -| 6 | `default` | Nothing else matched | +| 5 | `task-auto` | Task-aware tier selection under the active quality profile (default `quality-first`) | +| 6 | `default` | Nothing else matched (head of the standard tier; fast tier only under the `economy` profile) | | — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | `auto` at any layer (any letter case) means "no preference here"; continue to the next layer. This includes a per-spawn directive, the persistent config fields, the session model and the charter. @@ -30,36 +30,55 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**`task-auto` — task-aware auto-selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to its output type, select the tier, and use the head of that tier's fallback chain (Appendix A): +**Quality profile:** Task-aware selection runs under one of three profiles. The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. -| Task Output | Tier | Rule | -|-------------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | Standard | Quality and accuracy matter for code. | -| Writing prompts or agent designs (structured text that functions like code) | Standard | Prompts are executable — treat like code. | -| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | Fast | Cost first. | -| Visual/design work requiring image analysis | Premium | Vision capability required. Overrides cost rule. | +| Profile | When it is active | Intent | +|---------|-------------------|--------| +| `quality-first` | Default when nothing else is set | Pick the tier that gives the best result for the task; avoid reruns caused by an underpowered model | +| `balanced` | Explicit opt-in | Keep premium for quality-sensitive gates; research and language-heavy work stay standard rather than automatically cheap | +| `economy` | Explicit opt-in (`economyMode: true` or `modelProfile: "economy"`) | Cost first; then apply the economy map. Always visible: the rationale says `(economy profile)` | -**Role-to-tier mapping** (applying the cost-first principle): +Profile precedence: session profile ("use the balanced profile for this session") → `economyMode: true` → persistent `modelProfile` in `.squad/config.json` → `quality-first`. An explicit `economyMode: false` cancels a persisted `economy` profile. Cost ceilings (`maxCategory`) still apply under every profile. + +- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced` or `economy`). Acknowledge: `✅ Model profile saved: {profile}`. +- **When user says "use {profile} for this session":** Treat it as the session profile; do not write config. + +**`task-auto` — task-aware auto-selection:** Match the agent's task to its type, look up the tier for the active profile, and use the head of that tier's fallback chain (Appendix A): + +| Task type | `quality-first` | `balanced` | `economy` | +|-----------|-----------------|------------|-----------| +| Architecture, verification, review, security, design, visual | Premium | Premium | Premium → economy map | +| Research | Premium | Standard | Fast | +| Writing code, prompts or agent designs | Standard | Standard | Standard → economy map | +| Docs, writing | Standard | Standard | Fast | +| Planning, triage | Standard | Fast | Fast | +| Mechanical ops (Scribe, git, release, changelogs, version bumps) | Fast | Fast | Fast | + +Quality-sensitive work that is not code (architecture, research, verification, reviewer gates, security, design) can use the premium tier. Mechanical work stays fast under every profile. + +**Role-to-tier mapping** (under `quality-first`): | Role | Default Tier | Why | Override When | |------|--------------|-----|---------------| | Core Dev / Backend / Frontend | Standard | Writes code — quality first | Heavy code gen → code specialist | -| Tester / QA | Standard | Writes test code — quality first | Simple test scaffolding → fast | -| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → fast | -| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → standard; research/analysis → fast | -| Copilot SDK Expert | Standard | Technical analysis that often touches code | Pure research → fast | +| Tester / QA | Standard | Writes test code; verification gates → premium | Simple test scaffolding → fast | +| Lead / Architect | auto (per-task) | Architecture and review are quality-sensitive | Architecture proposals, reviewer gates → premium; triage/planning → standard | +| Prompt Engineer | auto (per-task) | Prompt design is executable, like code | Prompt architecture → standard; research → premium | +| Copilot SDK Expert | Standard | Technical analysis that often touches code | Deep research → premium | | Designer / Visual | Premium | Vision-capable model required | — (never downgrade — vision is non-negotiable) | -| DevRel / Writer | Fast | Docs and writing — not code | — | -| Scribe / Logger | Fast | Mechanical file ops — cheapest possible | — (never bump Scribe) | +| DevRel / Writer | Standard | Language-heavy work benefits from a stronger model | Economy profile → fast | +| Scribe / Logger | Fast | Mechanical file ops | — (never bump Scribe) | | Git / Release | Fast | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | **Task complexity adjustments** (apply at most ONE — no cascading): -- **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) +- **Bump UP to premium:** multi-agent coordination (output feeds 3+ agents) - **Bump DOWN to fast:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps - **Switch to the code specialist (Appendix A):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) - **Switch to the analytical-diversity model (Appendix A):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection -**`default`:** If nothing else matched, use the head of the fast tier chain. Cost wins when in doubt, unless code is being produced. +**Live availability:** When the runtime supplies a live model list (`availableModels`) or a list of unavailable models, it is authoritative for availability. A recommended model missing from the live list falls forward to the next listed candidate; an explicitly chosen model missing from it falls back visibly (source `fallback`, rationale names it). Recommendations and defaults come only from the public GA catalog in Appendix A; a model that is merely exposed by a tool schema is not a recommendation. + +**`default`:** If nothing else matched, use the head of the standard tier chain. Under the `economy` profile, use the economy default from the fast tier instead. **`fallback` — when a model is unavailable:** @@ -106,18 +125,22 @@ Every spawn acknowledgment names the resolved model, its source and a short rati This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. +Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. + **Fallback chains:** ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol → (omit model param) +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → (omit model param) +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` -**Specialists:** code specialist `gpt-5.3-codex` (standard); analytical diversity `gemini-3.1-pro` (standard). +**Economy default:** `gpt-5.6-luna`. The economy map moves premium to standard (`claude-opus-5.5` → `claude-sonnet-5.5`; `claude-opus-5`, `claude-opus-4.8` → `claude-sonnet-5`; GPT Sol models → `gpt-5.6-terra`) and standard or fast Anthropic/Terra models to `gpt-5.6-luna`. + +**Specialists:** code specialists `gpt-5.3-codex` (powerful) and `mai-code-1.1-flash` (lightweight); analytical diversity `gemini-4-argon` (powerful), `grok-4.7` and `grok-4.6` (versatile). -**Valid models (current platform catalog):** +**Valid models (public GA catalog):** -Premium: `gpt-5.6-sol`, `claude-opus-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6` -Standard: `gpt-5.6-terra`, `claude-sonnet-5`, `claude-sonnet-4.6`, `claude-sonnet-4.5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `gemini-3.1-pro` -Fast/Cheap: `gpt-5.6-luna`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini` +Premium (powerful): `claude-opus-5.5`, `gpt-6.1-sol`, `claude-opus-5`, `gpt-6-sol`, `claude-opus-4.8`, `gpt-5.6-sol`, `gemini-4-argon` +Standard: `claude-sonnet-5.5`, `gpt-5.6-terra`, `claude-sonnet-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `grok-4.7`, `grok-4.6` +Fast: `claude-haiku-5.5`, `gpt-6-luna`, `gpt-5.6-luna`, `gemini-3.8-flash`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `mai-code-1.1-flash` diff --git a/packages/squad-sdk/templates/skills/economy-mode/SKILL.md b/packages/squad-sdk/templates/skills/economy-mode/SKILL.md index 646038978..0c03f0593 100644 --- a/packages/squad-sdk/templates/skills/economy-mode/SKILL.md +++ b/packages/squad-sdk/templates/skills/economy-mode/SKILL.md @@ -29,25 +29,25 @@ Use this skill when the user wants to reduce costs across an entire session or p | Method | How | |--------|-----| | Session phrase | "use economy mode", "save costs", "go cheap", "reduce costs" | -| Persistent config | `"economyMode": true` in `.squad/config.json` | +| Persistent config | `"economyMode": true` or `"modelProfile": "economy"` in `.squad/config.json` | | CLI flag | `squad --economy` | **Deactivation:** "turn off economy mode", "disable economy mode", or remove `economyMode` from `config.json`. ## Economy Model Selection Table -When economy mode is **active**, Layer 3 auto-selection uses this table instead of the normal defaults: +Economy mode is the opt-in `economy` quality profile. The default is `quality-first`. When economy mode is **active**, Layer 3 auto-selection uses this table instead of the normal defaults: -| Task Output | Normal Mode | Economy Mode | +| Task Output | Normal Mode (`quality-first`) | Economy Mode | |-------------|-------------|--------------| -| Writing code (implementation, refactoring, bug fixes) | `gpt-5.6-terra` | `gpt-5.6-luna` | -| Writing prompts or agent designs | `gpt-5.6-terra` | `gpt-5.6-luna` | -| Docs, planning, triage, changelogs, mechanical ops | `gpt-5.6-luna` | `gpt-5.6-luna` | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | `gpt-5.6-terra` | -| Architecture, code review, security audits | `gpt-5.6-sol` | `gpt-5.6-terra` | -| Scribe / logger / mechanical file ops | `gpt-5.6-luna` | `gpt-5.6-luna` | +| Writing code (implementation, refactoring, bug fixes) | `claude-sonnet-5.5` | `gpt-5.6-luna` | +| Writing prompts or agent designs | `claude-sonnet-5.5` | `gpt-5.6-luna` | +| Docs, planning, triage, research | `claude-sonnet-5.5` (research: `claude-opus-5.5`) | `gpt-5.6-luna` | +| Visual/design work requiring image analysis | `claude-opus-5.5` | `claude-sonnet-5.5` | +| Architecture, code review, verification, security audits | `claude-opus-5.5` | `claude-sonnet-5.5` | +| Scribe / logger / git / release / mechanical file ops | `claude-haiku-5.5` | `gpt-5.6-luna` | -**Prefer `gpt-5.6-luna`** for all economy-mode tasks where cost is the priority. +**Prefer `gpt-5.6-luna`** for all economy-mode tasks where cost is the priority. Cost ceilings (`maxCategory`) still apply. If the live model list does not include a mapped model, selection moves forward through its tier's fallback chain. ## AGENT WORKFLOW @@ -104,12 +104,12 @@ After updating economy mode state and including the `💰` indicator in spawn ac } ``` -- `economyMode` — when `true`, Layer 3 uses the economy table. Optional; absent = economy mode off. +- `economyMode` — when `true`, Layer 3 uses the economy table. Optional; absent = economy mode off. An explicit `economyMode: false` option cancels a persisted `modelProfile: "economy"`. - Combines with `defaultModel` and `agentModelOverrides` — Layer 0 always wins. ## Anti-Patterns -- **Don't override Layer 0 in economy mode.** If the user set `defaultModel: "claude-opus-4.6"`, they want quality. Economy mode only affects Layer 3 auto-selection. +- **Don't override Layer 0 in economy mode.** If the user set `defaultModel: "claude-opus-5.5"`, they want quality. Economy mode only affects Layer 3 auto-selection. - **Don't silently apply economy mode.** Always acknowledge when activated or deactivated. - **Don't treat economy mode as permanent by default.** Session phrases activate session-only; only "always" or `config.json` persist it. -- **Don't bump premium tasks down too far.** Visual, architecture, and security work shifts from Sol to Terra in economy mode — it does NOT go to fast/cheap models. +- **Don't bump premium tasks down too far.** Visual, architecture, and security work shifts from premium to standard in economy mode — it does NOT go to fast/cheap models. diff --git a/packages/squad-sdk/templates/skills/model-selection/SKILL.md b/packages/squad-sdk/templates/skills/model-selection/SKILL.md index 7dc450124..98eefb15b 100644 --- a/packages/squad-sdk/templates/skills/model-selection/SKILL.md +++ b/packages/squad-sdk/templates/skills/model-selection/SKILL.md @@ -36,8 +36,8 @@ Resolution is **first-match-wins** — the highest layer with a value wins. | **0b** | Global Config | `.squad/config.json` → `defaultModel` | Persistent (survives sessions) | | **1** | Session Directive | User said "use X" in current session | Session-only | | **2** | Charter Preference | Agent's `charter.md` → `## Model` section | Persistent (in charter) | -| **3** | Task-Aware Auto | Code/prompts → Terra, docs → Luna, visual → Sol | Computed per-spawn | -| **4** | Default | `gpt-5.6-luna` | Hardcoded fallback | +| **3** | Task-Aware Auto | Task type → tier under the active quality profile (default `quality-first`) | Computed per-spawn | +| **4** | Default | Head of the standard chain (`claude-sonnet-5.5`); `gpt-5.6-luna` under the `economy` profile | Hardcoded fallback | **Key principle:** Layer 0 (persistent config) beats everything. If the user said "always use opus" and it was saved to config.json, every agent gets opus regardless of role or task type. This is intentional — the user explicitly chose quality over cost. @@ -56,12 +56,13 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. CHECK Layer 0b: Is there a `defaultModel` in config.json? → Use it. 3. CHECK Layer 1: Did the user give a session directive? → Use it. 4. CHECK Layer 2: Does the agent's charter have a `## Model` section? → Use it. -5. CHECK Layer 3: Determine task type: -- Code (implementation, tests, refactoring, bug fixes) → `gpt-5.6-terra` -- Prompts, agent designs → `gpt-5.6-terra` -- Visual/design with image analysis → `gpt-5.6-sol` -- Non-code (docs, planning, triage, changelogs) → `gpt-5.6-luna` -6. FALLBACK Layer 4: `gpt-5.6-luna` +5. CHECK Layer 3: Determine task type and use the head of its tier chain for the active profile (`quality-first` unless the session or `.squad/config.json` `modelProfile` says `balanced` or `economy`, or `economyMode` is on): +- Architecture, research, verification, reviewer gates, security, design, visual → premium (`claude-opus-5.5`); `balanced` and `economy` move research down +- Code (implementation, tests, refactoring, bug fixes), prompts, agent designs → standard (`claude-sonnet-5.5`) +- Docs, writing, planning, triage → standard under `quality-first`; fast under `economy` (and planning under `balanced`) +- Mechanical (Scribe, git, release, changelogs, version bumps) → fast (`claude-haiku-5.5`) +- `economy` then applies the economy map (premium → standard, standard → `gpt-5.6-luna`) and says so in the rationale +6. FALLBACK Layer 4: head of the standard chain; `gpt-5.6-luna` under `economy` 7. INCLUDE model in spawn acknowledgment: `🔧 {Name} ({resolved_model}) — {task}` ### When User Sets a Preference @@ -78,6 +79,13 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. WRITE to `agentModelOverrides.{agent}` in `.squad/config.json` 3. ACKNOWLEDGE: `✅ {Agent} will always use {model} — saved to config.` +**Profile trigger:** "use the {quality-first|balanced|economy} profile" / "always use economy" + +1. WRITE `modelProfile` to `.squad/config.json` (merge, don't overwrite); for "this session" only, keep it in session context instead +2. ACKNOWLEDGE: `✅ Model profile saved: {profile}` + +A profile never replaces an explicit model (config, session, charter or per-spawn), including an opt-in model the user chose. + ### When User Clears a Preference **Trigger phrases:** "switch back to automatic", "clear model preference", "use default models" @@ -100,26 +108,30 @@ After resolving the model and including it in the spawn template, this skill is ```json { "version": 1, - "defaultModel": "claude-opus-4.6", + "defaultModel": "claude-opus-5.5", + "modelProfile": "balanced", "agentModelOverrides": { - "agent-alpha": "claude-sonnet-4.6", - "agent-beta": "claude-haiku-4.5" + "agent-alpha": "claude-sonnet-5.5", + "agent-beta": "claude-haiku-5.5" } } ``` - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` -- Both fields are optional. When absent, Layers 1-4 apply normally. +- `modelProfile` — `quality-first` (default), `balanced` or `economy`; affects only Layers 3-4 +- All fields are optional. When absent, Layers 1-4 apply normally. ## Fallback Chains If a model is unavailable (rate limit, plan restriction), retry within the same tier: ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol → (omit model param) +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → (omit model param) +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` +Chains list only public GA models (https://docs.github.com/en/copilot/reference/ai-models/supported-models, verified 2026-10-14). When the runtime supplies a live model list, it decides availability. + **Never fall UP in tier.** A fast task won't land on a premium model via fallback. diff --git a/packages/squad-sdk/templates/squad.agent.md.template b/packages/squad-sdk/templates/squad.agent.md.template index c052c255d..57a62308e 100644 --- a/packages/squad-sdk/templates/squad.agent.md.template +++ b/packages/squad-sdk/templates/squad.agent.md.template @@ -393,7 +393,7 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (task-aware tier under the active quality profile; default `quality-first`, with `balanced` or `economy` only by explicit opt-in) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. diff --git a/templates/model-selection-reference.md b/templates/model-selection-reference.md index 36d47b58e..ddc15e74e 100644 --- a/templates/model-selection-reference.md +++ b/templates/model-selection-reference.md @@ -12,8 +12,8 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium | 2 | `config` | Persistent `.squad/config.json`: `agentModelOverrides.{agentName}`, otherwise `defaultModel` | | 3 | `session` | The model picked for this session (by the user or the session itself); inherited by every sub-agent spawn | | 4 | `charter` | The agent charter's `## Model` → `**Preferred:**` value, unless it is `auto` | -| 5 | `task-auto` | Task-aware tier selection (cost first, unless code is being written) | -| 6 | `default` | Nothing else matched | +| 5 | `task-auto` | Task-aware tier selection under the active quality profile (default `quality-first`) | +| 6 | `default` | Nothing else matched (head of the standard tier; fast tier only under the `economy` profile) | | — | `fallback` | Not a layer: replaces the chosen model only after it is known to be unavailable | `auto` at any layer (any letter case) means "no preference here"; continue to the next layer. This includes a per-spawn directive, the persistent config fields, the session model and the charter. @@ -30,36 +30,55 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**`task-auto` — task-aware auto-selection:** Use the governing principle: **cost first, unless code is being written.** Match the agent's task to its output type, select the tier, and use the head of that tier's fallback chain (Appendix A): +**Quality profile:** Task-aware selection runs under one of three profiles. The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. -| Task Output | Tier | Rule | -|-------------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | Standard | Quality and accuracy matter for code. | -| Writing prompts or agent designs (structured text that functions like code) | Standard | Prompts are executable — treat like code. | -| NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | Fast | Cost first. | -| Visual/design work requiring image analysis | Premium | Vision capability required. Overrides cost rule. | +| Profile | When it is active | Intent | +|---------|-------------------|--------| +| `quality-first` | Default when nothing else is set | Pick the tier that gives the best result for the task; avoid reruns caused by an underpowered model | +| `balanced` | Explicit opt-in | Keep premium for quality-sensitive gates; research and language-heavy work stay standard rather than automatically cheap | +| `economy` | Explicit opt-in (`economyMode: true` or `modelProfile: "economy"`) | Cost first; then apply the economy map. Always visible: the rationale says `(economy profile)` | -**Role-to-tier mapping** (applying the cost-first principle): +Profile precedence: session profile ("use the balanced profile for this session") → `economyMode: true` → persistent `modelProfile` in `.squad/config.json` → `quality-first`. An explicit `economyMode: false` cancels a persisted `economy` profile. Cost ceilings (`maxCategory`) still apply under every profile. + +- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced` or `economy`). Acknowledge: `✅ Model profile saved: {profile}`. +- **When user says "use {profile} for this session":** Treat it as the session profile; do not write config. + +**`task-auto` — task-aware auto-selection:** Match the agent's task to its type, look up the tier for the active profile, and use the head of that tier's fallback chain (Appendix A): + +| Task type | `quality-first` | `balanced` | `economy` | +|-----------|-----------------|------------|-----------| +| Architecture, verification, review, security, design, visual | Premium | Premium | Premium → economy map | +| Research | Premium | Standard | Fast | +| Writing code, prompts or agent designs | Standard | Standard | Standard → economy map | +| Docs, writing | Standard | Standard | Fast | +| Planning, triage | Standard | Fast | Fast | +| Mechanical ops (Scribe, git, release, changelogs, version bumps) | Fast | Fast | Fast | + +Quality-sensitive work that is not code (architecture, research, verification, reviewer gates, security, design) can use the premium tier. Mechanical work stays fast under every profile. + +**Role-to-tier mapping** (under `quality-first`): | Role | Default Tier | Why | Override When | |------|--------------|-----|---------------| | Core Dev / Backend / Frontend | Standard | Writes code — quality first | Heavy code gen → code specialist | -| Tester / QA | Standard | Writes test code — quality first | Simple test scaffolding → fast | -| Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → fast | -| Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → standard; research/analysis → fast | -| Copilot SDK Expert | Standard | Technical analysis that often touches code | Pure research → fast | +| Tester / QA | Standard | Writes test code; verification gates → premium | Simple test scaffolding → fast | +| Lead / Architect | auto (per-task) | Architecture and review are quality-sensitive | Architecture proposals, reviewer gates → premium; triage/planning → standard | +| Prompt Engineer | auto (per-task) | Prompt design is executable, like code | Prompt architecture → standard; research → premium | +| Copilot SDK Expert | Standard | Technical analysis that often touches code | Deep research → premium | | Designer / Visual | Premium | Vision-capable model required | — (never downgrade — vision is non-negotiable) | -| DevRel / Writer | Fast | Docs and writing — not code | — | -| Scribe / Logger | Fast | Mechanical file ops — cheapest possible | — (never bump Scribe) | +| DevRel / Writer | Standard | Language-heavy work benefits from a stronger model | Economy profile → fast | +| Scribe / Logger | Fast | Mechanical file ops | — (never bump Scribe) | | Git / Release | Fast | Mechanical ops — changelogs, tags, version bumps | — (never bump mechanical ops) | **Task complexity adjustments** (apply at most ONE — no cascading): -- **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) +- **Bump UP to premium:** multi-agent coordination (output feeds 3+ agents) - **Bump DOWN to fast:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps - **Switch to the code specialist (Appendix A):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) - **Switch to the analytical-diversity model (Appendix A):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection -**`default`:** If nothing else matched, use the head of the fast tier chain. Cost wins when in doubt, unless code is being produced. +**Live availability:** When the runtime supplies a live model list (`availableModels`) or a list of unavailable models, it is authoritative for availability. A recommended model missing from the live list falls forward to the next listed candidate; an explicitly chosen model missing from it falls back visibly (source `fallback`, rationale names it). Recommendations and defaults come only from the public GA catalog in Appendix A; a model that is merely exposed by a tool schema is not a recommendation. + +**`default`:** If nothing else matched, use the head of the standard tier chain. Under the `economy` profile, use the economy default from the fast tier instead. **`fallback` — when a model is unavailable:** @@ -106,18 +125,22 @@ Every spawn acknowledgment names the resolved model, its source and a short rati This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. +Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. + **Fallback chains:** ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol → (omit model param) +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → (omit model param) +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` -**Specialists:** code specialist `gpt-5.3-codex` (standard); analytical diversity `gemini-3.1-pro` (standard). +**Economy default:** `gpt-5.6-luna`. The economy map moves premium to standard (`claude-opus-5.5` → `claude-sonnet-5.5`; `claude-opus-5`, `claude-opus-4.8` → `claude-sonnet-5`; GPT Sol models → `gpt-5.6-terra`) and standard or fast Anthropic/Terra models to `gpt-5.6-luna`. + +**Specialists:** code specialists `gpt-5.3-codex` (powerful) and `mai-code-1.1-flash` (lightweight); analytical diversity `gemini-4-argon` (powerful), `grok-4.7` and `grok-4.6` (versatile). -**Valid models (current platform catalog):** +**Valid models (public GA catalog):** -Premium: `gpt-5.6-sol`, `claude-opus-5`, `claude-opus-4.8`, `claude-opus-4.7`, `claude-opus-4.6` -Standard: `gpt-5.6-terra`, `claude-sonnet-5`, `claude-sonnet-4.6`, `claude-sonnet-4.5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `gemini-3.1-pro` -Fast/Cheap: `gpt-5.6-luna`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini` +Premium (powerful): `claude-opus-5.5`, `gpt-6.1-sol`, `claude-opus-5`, `gpt-6-sol`, `claude-opus-4.8`, `gpt-5.6-sol`, `gemini-4-argon` +Standard: `claude-sonnet-5.5`, `gpt-5.6-terra`, `claude-sonnet-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `grok-4.7`, `grok-4.6` +Fast: `claude-haiku-5.5`, `gpt-6-luna`, `gpt-5.6-luna`, `gemini-3.8-flash`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `mai-code-1.1-flash` diff --git a/templates/skills/economy-mode/SKILL.md b/templates/skills/economy-mode/SKILL.md index 646038978..0c03f0593 100644 --- a/templates/skills/economy-mode/SKILL.md +++ b/templates/skills/economy-mode/SKILL.md @@ -29,25 +29,25 @@ Use this skill when the user wants to reduce costs across an entire session or p | Method | How | |--------|-----| | Session phrase | "use economy mode", "save costs", "go cheap", "reduce costs" | -| Persistent config | `"economyMode": true` in `.squad/config.json` | +| Persistent config | `"economyMode": true` or `"modelProfile": "economy"` in `.squad/config.json` | | CLI flag | `squad --economy` | **Deactivation:** "turn off economy mode", "disable economy mode", or remove `economyMode` from `config.json`. ## Economy Model Selection Table -When economy mode is **active**, Layer 3 auto-selection uses this table instead of the normal defaults: +Economy mode is the opt-in `economy` quality profile. The default is `quality-first`. When economy mode is **active**, Layer 3 auto-selection uses this table instead of the normal defaults: -| Task Output | Normal Mode | Economy Mode | +| Task Output | Normal Mode (`quality-first`) | Economy Mode | |-------------|-------------|--------------| -| Writing code (implementation, refactoring, bug fixes) | `gpt-5.6-terra` | `gpt-5.6-luna` | -| Writing prompts or agent designs | `gpt-5.6-terra` | `gpt-5.6-luna` | -| Docs, planning, triage, changelogs, mechanical ops | `gpt-5.6-luna` | `gpt-5.6-luna` | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | `gpt-5.6-terra` | -| Architecture, code review, security audits | `gpt-5.6-sol` | `gpt-5.6-terra` | -| Scribe / logger / mechanical file ops | `gpt-5.6-luna` | `gpt-5.6-luna` | +| Writing code (implementation, refactoring, bug fixes) | `claude-sonnet-5.5` | `gpt-5.6-luna` | +| Writing prompts or agent designs | `claude-sonnet-5.5` | `gpt-5.6-luna` | +| Docs, planning, triage, research | `claude-sonnet-5.5` (research: `claude-opus-5.5`) | `gpt-5.6-luna` | +| Visual/design work requiring image analysis | `claude-opus-5.5` | `claude-sonnet-5.5` | +| Architecture, code review, verification, security audits | `claude-opus-5.5` | `claude-sonnet-5.5` | +| Scribe / logger / git / release / mechanical file ops | `claude-haiku-5.5` | `gpt-5.6-luna` | -**Prefer `gpt-5.6-luna`** for all economy-mode tasks where cost is the priority. +**Prefer `gpt-5.6-luna`** for all economy-mode tasks where cost is the priority. Cost ceilings (`maxCategory`) still apply. If the live model list does not include a mapped model, selection moves forward through its tier's fallback chain. ## AGENT WORKFLOW @@ -104,12 +104,12 @@ After updating economy mode state and including the `💰` indicator in spawn ac } ``` -- `economyMode` — when `true`, Layer 3 uses the economy table. Optional; absent = economy mode off. +- `economyMode` — when `true`, Layer 3 uses the economy table. Optional; absent = economy mode off. An explicit `economyMode: false` option cancels a persisted `modelProfile: "economy"`. - Combines with `defaultModel` and `agentModelOverrides` — Layer 0 always wins. ## Anti-Patterns -- **Don't override Layer 0 in economy mode.** If the user set `defaultModel: "claude-opus-4.6"`, they want quality. Economy mode only affects Layer 3 auto-selection. +- **Don't override Layer 0 in economy mode.** If the user set `defaultModel: "claude-opus-5.5"`, they want quality. Economy mode only affects Layer 3 auto-selection. - **Don't silently apply economy mode.** Always acknowledge when activated or deactivated. - **Don't treat economy mode as permanent by default.** Session phrases activate session-only; only "always" or `config.json` persist it. -- **Don't bump premium tasks down too far.** Visual, architecture, and security work shifts from Sol to Terra in economy mode — it does NOT go to fast/cheap models. +- **Don't bump premium tasks down too far.** Visual, architecture, and security work shifts from premium to standard in economy mode — it does NOT go to fast/cheap models. diff --git a/templates/skills/model-selection/SKILL.md b/templates/skills/model-selection/SKILL.md index 7dc450124..98eefb15b 100644 --- a/templates/skills/model-selection/SKILL.md +++ b/templates/skills/model-selection/SKILL.md @@ -36,8 +36,8 @@ Resolution is **first-match-wins** — the highest layer with a value wins. | **0b** | Global Config | `.squad/config.json` → `defaultModel` | Persistent (survives sessions) | | **1** | Session Directive | User said "use X" in current session | Session-only | | **2** | Charter Preference | Agent's `charter.md` → `## Model` section | Persistent (in charter) | -| **3** | Task-Aware Auto | Code/prompts → Terra, docs → Luna, visual → Sol | Computed per-spawn | -| **4** | Default | `gpt-5.6-luna` | Hardcoded fallback | +| **3** | Task-Aware Auto | Task type → tier under the active quality profile (default `quality-first`) | Computed per-spawn | +| **4** | Default | Head of the standard chain (`claude-sonnet-5.5`); `gpt-5.6-luna` under the `economy` profile | Hardcoded fallback | **Key principle:** Layer 0 (persistent config) beats everything. If the user said "always use opus" and it was saved to config.json, every agent gets opus regardless of role or task type. This is intentional — the user explicitly chose quality over cost. @@ -56,12 +56,13 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. CHECK Layer 0b: Is there a `defaultModel` in config.json? → Use it. 3. CHECK Layer 1: Did the user give a session directive? → Use it. 4. CHECK Layer 2: Does the agent's charter have a `## Model` section? → Use it. -5. CHECK Layer 3: Determine task type: -- Code (implementation, tests, refactoring, bug fixes) → `gpt-5.6-terra` -- Prompts, agent designs → `gpt-5.6-terra` -- Visual/design with image analysis → `gpt-5.6-sol` -- Non-code (docs, planning, triage, changelogs) → `gpt-5.6-luna` -6. FALLBACK Layer 4: `gpt-5.6-luna` +5. CHECK Layer 3: Determine task type and use the head of its tier chain for the active profile (`quality-first` unless the session or `.squad/config.json` `modelProfile` says `balanced` or `economy`, or `economyMode` is on): +- Architecture, research, verification, reviewer gates, security, design, visual → premium (`claude-opus-5.5`); `balanced` and `economy` move research down +- Code (implementation, tests, refactoring, bug fixes), prompts, agent designs → standard (`claude-sonnet-5.5`) +- Docs, writing, planning, triage → standard under `quality-first`; fast under `economy` (and planning under `balanced`) +- Mechanical (Scribe, git, release, changelogs, version bumps) → fast (`claude-haiku-5.5`) +- `economy` then applies the economy map (premium → standard, standard → `gpt-5.6-luna`) and says so in the rationale +6. FALLBACK Layer 4: head of the standard chain; `gpt-5.6-luna` under `economy` 7. INCLUDE model in spawn acknowledgment: `🔧 {Name} ({resolved_model}) — {task}` ### When User Sets a Preference @@ -78,6 +79,13 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. WRITE to `agentModelOverrides.{agent}` in `.squad/config.json` 3. ACKNOWLEDGE: `✅ {Agent} will always use {model} — saved to config.` +**Profile trigger:** "use the {quality-first|balanced|economy} profile" / "always use economy" + +1. WRITE `modelProfile` to `.squad/config.json` (merge, don't overwrite); for "this session" only, keep it in session context instead +2. ACKNOWLEDGE: `✅ Model profile saved: {profile}` + +A profile never replaces an explicit model (config, session, charter or per-spawn), including an opt-in model the user chose. + ### When User Clears a Preference **Trigger phrases:** "switch back to automatic", "clear model preference", "use default models" @@ -100,26 +108,30 @@ After resolving the model and including it in the spawn template, this skill is ```json { "version": 1, - "defaultModel": "claude-opus-4.6", + "defaultModel": "claude-opus-5.5", + "modelProfile": "balanced", "agentModelOverrides": { - "agent-alpha": "claude-sonnet-4.6", - "agent-beta": "claude-haiku-4.5" + "agent-alpha": "claude-sonnet-5.5", + "agent-beta": "claude-haiku-5.5" } } ``` - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` -- Both fields are optional. When absent, Layers 1-4 apply normally. +- `modelProfile` — `quality-first` (default), `balanced` or `economy`; affects only Layers 3-4 +- All fields are optional. When absent, Layers 1-4 apply normally. ## Fallback Chains If a model is unavailable (rate limit, plan restriction), retry within the same tier: ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 → (omit model param) -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro → (omit model param) -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol → (omit model param) +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → (omit model param) +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini → (omit model param) ``` +Chains list only public GA models (https://docs.github.com/en/copilot/reference/ai-models/supported-models, verified 2026-10-14). When the runtime supplies a live model list, it decides availability. + **Never fall UP in tier.** A fast task won't land on a premium model via fallback. diff --git a/templates/squad.agent.md.template b/templates/squad.agent.md.template index c052c255d..57a62308e 100644 --- a/templates/squad.agent.md.template +++ b/templates/squad.agent.md.template @@ -393,7 +393,7 @@ After routing determines WHO handles work, select a **response MODE** (Direct / ### Per-Agent Model Selection -Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (cost first, unless code or prompt architecture is being written) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. +Resolve a model before every spawn with the central resolver precedence (first match wins): **user-override** (explicit per-spawn directive) → **config** (`.squad/config.json` `agentModelOverrides.{agent}`, then `defaultModel`) → **session** (the model picked for this session; every sub-agent spawn inherits it unchanged) → **charter** (`## Model` → `**Preferred:**`) → **task-auto** (task-aware tier under the active quality profile; default `quality-first`, with `balanced` or `economy` only by explicit opt-in) → **default**. `auto` at any layer means no preference; continue to the next. Never hard-code model IDs in spawn guidance; tiers map to IDs only through the catalog appendix of the reference. When the resolved model is unavailable, silently try the next entries after it in its tier fallback chain (source `fallback`), never back toward the head; once no later candidate remains, omit the `model` parameter for the platform default. diff --git a/test/agents.test.ts b/test/agents.test.ts index e76dff353..0d86af51f 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -182,9 +182,10 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); + // An explicitly named stale ID is still passed through unchanged. expect(result.model).toBe('claude-opus-4.6'); expect(result.tier).toBe('premium'); - expect(result.fallbackChain).toContain('claude-opus-4.7'); + expect(result.fallbackChain).toContain('claude-opus-5.5'); }); it('should infer fast tier for haiku models', () => { @@ -225,7 +226,7 @@ describe('Per-Agent Model Selection (M1-9)', () => { expect(result.source).not.toBe('charter'); expect(result.source).toBe('task-auto'); - expect(result.model).toBe('gpt-5.6-terra'); + expect(result.model).toBe('claude-sonnet-5.5'); }); it('should prefer user override over charter', () => { @@ -250,7 +251,7 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); - expect(result.model).toBe('gpt-5.6-terra'); + expect(result.model).toBe('claude-sonnet-5.5'); expect(result.tier).toBe('standard'); expect(result.source).toBe('task-auto'); }); @@ -262,7 +263,7 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); - expect(result.model).toBe('gpt-5.6-terra'); + expect(result.model).toBe('claude-sonnet-5.5'); expect(result.tier).toBe('standard'); expect(result.source).toBe('task-auto'); }); @@ -274,32 +275,32 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); - expect(result.model).toBe('gpt-5.6-sol'); + expect(result.model).toBe('claude-opus-5.5'); expect(result.tier).toBe('premium'); expect(result.source).toBe('task-auto'); }); - it('should select fast tier for docs tasks', () => { + it('should select standard tier for docs tasks (quality-first)', () => { const options: ModelResolutionOptions = { taskType: 'docs', }; const result = resolveModel(options); - expect(result.model).toBe('gpt-5.6-luna'); - expect(result.tier).toBe('fast'); + expect(result.model).toBe('claude-sonnet-5.5'); + expect(result.tier).toBe('standard'); expect(result.source).toBe('task-auto'); }); - it('should select fast tier for planning tasks', () => { + it('should select standard tier for planning tasks (quality-first)', () => { const options: ModelResolutionOptions = { taskType: 'planning', }; const result = resolveModel(options); - expect(result.model).toBe('gpt-5.6-luna'); - expect(result.tier).toBe('fast'); + expect(result.model).toBe('claude-sonnet-5.5'); + expect(result.tier).toBe('standard'); expect(result.source).toBe('task-auto'); }); @@ -310,7 +311,7 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); - expect(result.model).toBe('gpt-5.6-luna'); + expect(result.model).toBe('claude-haiku-5.5'); expect(result.tier).toBe('fast'); expect(result.source).toBe('task-auto'); }); @@ -327,8 +328,8 @@ describe('Per-Agent Model Selection (M1-9)', () => { // Should fall through to task-auto first const result = resolveModel(options); - expect(result.model).toBe('gpt-5.6-luna'); - expect(result.tier).toBe('fast'); + expect(result.model).toBe('claude-sonnet-5.5'); + expect(result.tier).toBe('standard'); }); }); @@ -342,12 +343,12 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); expect(result.fallbackChain).toEqual([ - 'gpt-5.6-sol', + 'claude-opus-5.5', + 'gpt-6.1-sol', 'claude-opus-5', + 'gpt-6-sol', 'claude-opus-4.8', - 'claude-opus-4.7', - 'claude-opus-4.6', - 'claude-sonnet-4.6', + 'gpt-5.6-sol', ]); }); @@ -358,7 +359,7 @@ describe('Per-Agent Model Selection (M1-9)', () => { }); expect(result.tier).toBe('premium'); - expect(result.fallbackChain[0]).toBe('gpt-5.6-sol'); + expect(result.fallbackChain[0]).toBe('claude-opus-5.5'); expect(result.fallbackChain).toContain('gpt-5.6-sol'); }); @@ -369,28 +370,29 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); - // Standard routing prefers GPT Terra before the Claude fallbacks. + // Standard routing: newest public GA standard models first. expect(result.fallbackChain).toEqual([ + 'claude-sonnet-5.5', 'gpt-5.6-terra', 'claude-sonnet-5', - 'claude-sonnet-4.6', 'gpt-5.5', 'gpt-5.4', 'gpt-5.3-codex', - 'claude-sonnet-4.5', - 'gemini-3.1-pro', ]); }); it('should provide fast fallback chain', () => { const options: ModelResolutionOptions = { - taskType: 'docs', + taskType: 'mechanical', }; const result = resolveModel(options); expect(result.fallbackChain).toEqual([ + 'claude-haiku-5.5', + 'gpt-6-luna', 'gpt-5.6-luna', + 'gemini-3.8-flash', 'claude-haiku-4.5', 'gpt-5.4-mini', 'gpt-5-mini', @@ -453,7 +455,7 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); expect(result.source).toBe('task-auto'); - expect(result.model).toBe('gpt-5.6-terra'); + expect(result.model).toBe('claude-sonnet-5.5'); }); it('should include agentRole in context without affecting resolution', () => { @@ -464,7 +466,7 @@ describe('Per-Agent Model Selection (M1-9)', () => { const result = resolveModel(options); - expect(result.model).toBe('gpt-5.6-terra'); + expect(result.model).toBe('claude-sonnet-5.5'); expect(result.source).toBe('task-auto'); }); }); diff --git a/test/catalog-refresh.test.ts b/test/catalog-refresh.test.ts index 407756c1a..74ea9b597 100644 --- a/test/catalog-refresh.test.ts +++ b/test/catalog-refresh.test.ts @@ -46,20 +46,22 @@ const DEAD_MODEL_IDS = [ ]; /** - * The exact set of CLI-reachable seed model IDs. - * gpt-5.6-sol/terra/luna added 2026-07-13 (env-observed as reachable via - * Copilot CLI; tamirdresher follow-up request on PR #1444). + * The exact set of public GA seed model IDs. + * gpt-5.6-sol/terra/luna added 2026-07-13 (tamirdresher follow-up on PR #1444). + * Refreshed 2026-10-14 for #1598 against the public GA table at + * https://docs.github.com/en/copilot/reference/ai-models/supported-models: + * stale claude-opus-4.7/4.6, claude-sonnet-4.6/4.5 and gemini-3.1-pro removed. */ const EXPECTED_CATALOG_IDS = [ 'claude-haiku-4.5', + 'claude-haiku-5.5', 'claude-opus-5', - 'claude-opus-4.6', - 'claude-opus-4.7', + 'claude-opus-5.5', 'claude-opus-4.8', - 'claude-sonnet-4.5', - 'claude-sonnet-4.6', 'claude-sonnet-5', - 'gemini-3.1-pro', + 'claude-sonnet-5.5', + 'gemini-3.8-flash', + 'gemini-4-argon', 'gpt-5-mini', 'gpt-5.3-codex', 'gpt-5.4', @@ -68,6 +70,12 @@ const EXPECTED_CATALOG_IDS = [ 'gpt-5.6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', + 'gpt-6-luna', + 'gpt-6-sol', + 'gpt-6.1-sol', + 'grok-4.6', + 'grok-4.7', + 'mai-code-1.1-flash', ]; const CONFIG_CHAINS = DEFAULT_FALLBACK_CHAINS as Record; @@ -121,22 +129,22 @@ describe('catalog refresh invariants (#1080/#1183)', () => { }); // ── Ordering invariants (tamirdresher PR #1444 follow-up) ────────────────── - // Prefer the NEWEST model in each series at [0]. Verified 2026-07-13. + // Prefer the NEWEST public GA model in each tier at [0]. Verified 2026-10-14 (#1598). - it('config premium chain[0] is gpt-5.6-sol (GPT-first premium routing)', () => { - expect(CONFIG_CHAINS.premium[0]).toBe('gpt-5.6-sol'); + it('config premium chain[0] is claude-opus-5.5 (newest GA premium)', () => { + expect(CONFIG_CHAINS.premium[0]).toBe('claude-opus-5.5'); }); - it('config standard chain[0] is gpt-5.6-terra (Terra-first standard routing)', () => { - expect(CONFIG_CHAINS.standard[0]).toBe('gpt-5.6-terra'); + it('config standard chain[0] is claude-sonnet-5.5 (newest GA standard)', () => { + expect(CONFIG_CHAINS.standard[0]).toBe('claude-sonnet-5.5'); }); - it('runtime premium chain[0] is gpt-5.6-sol (GPT-first premium routing)', () => { - expect(RUNTIME_CHAINS.premium[0]).toBe('gpt-5.6-sol'); + it('runtime premium chain[0] is claude-opus-5.5 (newest GA premium)', () => { + expect(RUNTIME_CHAINS.premium[0]).toBe('claude-opus-5.5'); }); - it('runtime standard chain[0] is gpt-5.6-terra (Terra-first standard routing)', () => { - expect(RUNTIME_CHAINS.standard[0]).toBe('gpt-5.6-terra'); + it('runtime standard chain[0] is claude-sonnet-5.5 (newest GA standard)', () => { + expect(RUNTIME_CHAINS.standard[0]).toBe('claude-sonnet-5.5'); }); // ── GPT-5.6 catalog membership (tamirdresher PR #1444 follow-up) ────────── diff --git a/test/compat-v041.test.ts b/test/compat-v041.test.ts index 3adc63df5..e57c9a5a2 100644 --- a/test/compat-v041.test.ts +++ b/test/compat-v041.test.ts @@ -468,13 +468,13 @@ describe('Compat v0.4.1: Event Bus Shape', () => { describe('Compat v0.4.1: Model Catalog', () => { it('catalog contains expected premium models', () => { expect(isModelAvailable('claude-opus-5')).toBe(true); - expect(isModelAvailable('claude-opus-4.6')).toBe(true); + expect(isModelAvailable('claude-opus-5.5')).toBe(true); expect(isModelAvailable('claude-opus-4.8')).toBe(true); }); it('catalog contains expected standard models', () => { - expect(isModelAvailable('claude-sonnet-4.5')).toBe(true); - expect(isModelAvailable('claude-sonnet-4.6')).toBe(true); + expect(isModelAvailable('claude-sonnet-5')).toBe(true); + expect(isModelAvailable('claude-sonnet-5.5')).toBe(true); expect(isModelAvailable('gpt-5.4')).toBe(true); }); @@ -484,21 +484,21 @@ describe('Compat v0.4.1: Model Catalog', () => { expect(isModelAvailable('gpt-5-mini')).toBe(true); }); - it('premium fallback chain starts with GPT Sol', () => { - expect(DEFAULT_FALLBACK_CHAINS.premium[0]).toBe('gpt-5.6-sol'); + it('premium fallback chain starts with Claude Opus 5.5', () => { + expect(DEFAULT_FALLBACK_CHAINS.premium[0]).toBe('claude-opus-5.5'); }); - it('standard fallback chain starts with GPT Terra', () => { - expect(DEFAULT_FALLBACK_CHAINS.standard[0]).toBe('gpt-5.6-terra'); + it('standard fallback chain starts with Claude Sonnet 5.5', () => { + expect(DEFAULT_FALLBACK_CHAINS.standard[0]).toBe('claude-sonnet-5.5'); }); - it('fast fallback chain starts with Luna', () => { - expect(DEFAULT_FALLBACK_CHAINS.fast[0]).toBe('gpt-5.6-luna'); + it('fast fallback chain starts with Claude Haiku 5.5', () => { + expect(DEFAULT_FALLBACK_CHAINS.fast[0]).toBe('claude-haiku-5.5'); }); it('getModelInfo returns correct tier for known models', () => { - expect(getModelInfo('claude-opus-4.6')!.tier).toBe('premium'); - expect(getModelInfo('claude-sonnet-4.5')!.tier).toBe('standard'); + expect(getModelInfo('claude-opus-5.5')!.tier).toBe('premium'); + expect(getModelInfo('claude-sonnet-5.5')!.tier).toBe('standard'); expect(getModelInfo('claude-haiku-4.5')!.tier).toBe('fast'); }); }); diff --git a/test/config-integration.test.ts b/test/config-integration.test.ts index 8ca356750..fb016a3f1 100644 --- a/test/config-integration.test.ts +++ b/test/config-integration.test.ts @@ -905,7 +905,7 @@ describe('Integration: Full pipeline — discover → compile → resolve → fi it('ModelRegistry validates models from compiled config', () => { const registry = new ModelRegistry(); - expect(registry.isModelAvailable('claude-sonnet-4.5')).toBe(true); + expect(registry.isModelAvailable('claude-sonnet-5.5')).toBe(true); expect(registry.isModelAvailable('nonexistent-model')).toBe(false); const chain = registry.getFallbackChain('standard'); diff --git a/test/config-schema.test.ts b/test/config-schema.test.ts index f3e22b0aa..091b64df0 100644 --- a/test/config-schema.test.ts +++ b/test/config-schema.test.ts @@ -31,7 +31,7 @@ describe('Configuration Schema', () => { it('should have tier definitions', () => { expect(DEFAULT_CONFIG.models.tiers.premium).toContain('claude-opus-5'); expect(DEFAULT_CONFIG.models.tiers.premium).toContain('claude-opus-4.8'); - expect(DEFAULT_CONFIG.models.tiers.standard).toContain('claude-sonnet-4.6'); + expect(DEFAULT_CONFIG.models.tiers.standard).toContain('claude-sonnet-5.5'); expect(DEFAULT_CONFIG.models.tiers.fast).toContain('gpt-5.6-luna'); }); }); diff --git a/test/economy-mode.test.ts b/test/economy-mode.test.ts index 9f21abed4..89854224b 100644 --- a/test/economy-mode.test.ts +++ b/test/economy-mode.test.ts @@ -38,16 +38,17 @@ afterEach(() => { describe('ECONOMY_MODEL_MAP', () => { it('maps premium models to standard', () => { + expect(ECONOMY_MODEL_MAP['gpt-6.1-sol']).toBe('gpt-5.6-terra'); + expect(ECONOMY_MODEL_MAP['gpt-6-sol']).toBe('gpt-5.6-terra'); expect(ECONOMY_MODEL_MAP['gpt-5.6-sol']).toBe('gpt-5.6-terra'); - expect(ECONOMY_MODEL_MAP['claude-opus-5']).toBe('claude-sonnet-4.5'); - expect(ECONOMY_MODEL_MAP['claude-opus-4.6']).toBe('claude-sonnet-4.5'); - expect(ECONOMY_MODEL_MAP['claude-opus-4.7']).toBe('claude-sonnet-4.5'); - expect(ECONOMY_MODEL_MAP['claude-opus-4.8']).toBe('claude-sonnet-4.5'); + expect(ECONOMY_MODEL_MAP['claude-opus-5.5']).toBe('claude-sonnet-5.5'); + expect(ECONOMY_MODEL_MAP['claude-opus-5']).toBe('claude-sonnet-5'); + expect(ECONOMY_MODEL_MAP['claude-opus-4.8']).toBe('claude-sonnet-5'); }); it('maps standard sonnet models to fast', () => { - expect(ECONOMY_MODEL_MAP['claude-sonnet-4.6']).toBe('gpt-5.6-luna'); - expect(ECONOMY_MODEL_MAP['claude-sonnet-4.5']).toBe('gpt-5.6-luna'); + expect(ECONOMY_MODEL_MAP['claude-sonnet-5.5']).toBe('gpt-5.6-luna'); + expect(ECONOMY_MODEL_MAP['claude-sonnet-5']).toBe('gpt-5.6-luna'); }); it('maps Terra to the preferred fast model', () => { @@ -55,6 +56,7 @@ describe('ECONOMY_MODEL_MAP', () => { }); it('maps haiku to cheapest fast', () => { + expect(ECONOMY_MODEL_MAP['claude-haiku-5.5']).toBe('gpt-5.6-luna'); expect(ECONOMY_MODEL_MAP['claude-haiku-4.5']).toBe('gpt-5.6-luna'); }); }); @@ -62,8 +64,8 @@ describe('ECONOMY_MODEL_MAP', () => { describe('applyEconomyMode', () => { it('returns economy model for known models', () => { expect(applyEconomyMode('gpt-5.6-sol')).toBe('gpt-5.6-terra'); - expect(applyEconomyMode('claude-opus-4.6')).toBe('claude-sonnet-4.5'); - expect(applyEconomyMode('claude-sonnet-4.6')).toBe('gpt-5.6-luna'); + expect(applyEconomyMode('claude-opus-5.5')).toBe('claude-sonnet-5.5'); + expect(applyEconomyMode('claude-sonnet-5.5')).toBe('gpt-5.6-luna'); expect(applyEconomyMode('gpt-5.6-terra')).toBe('gpt-5.6-luna'); expect(applyEconomyMode('claude-haiku-4.5')).toBe('gpt-5.6-luna'); }); @@ -72,6 +74,8 @@ describe('applyEconomyMode', () => { expect(applyEconomyMode('gpt-5.6-luna')).toBe('gpt-5.6-luna'); expect(applyEconomyMode('gpt-5-mini')).toBe('gpt-5-mini'); expect(applyEconomyMode('unknown-model-xyz')).toBe('unknown-model-xyz'); + // Stale ids are no longer mapped; explicit use passes through unchanged. + expect(applyEconomyMode('claude-opus-4.6')).toBe('claude-opus-4.6'); }); }); @@ -161,16 +165,16 @@ describe('resolveModel economy mode (option)', () => { expect(resolveModel({ economyMode: true })).toBe('gpt-5.6-luna'); }); - it('Layer 4 default: uses Luna when economyMode: false', () => { - expect(resolveModel({ economyMode: false })).toBe('gpt-5.6-luna'); + it('Layer 4 default: uses the quality-first standard default when economyMode: false', () => { + expect(resolveModel({ economyMode: false })).toBe('claude-sonnet-5.5'); }); it('Layer 3 code task: uses Luna instead of sonnet when economyMode: true', () => { - expect(resolveModel({ taskModel: 'claude-sonnet-4.6', economyMode: true })).toBe('gpt-5.6-luna'); + expect(resolveModel({ taskModel: 'claude-sonnet-5.5', economyMode: true })).toBe('gpt-5.6-luna'); }); it('Layer 3 architecture task: uses sonnet instead of opus when economyMode: true', () => { - expect(resolveModel({ taskModel: 'claude-opus-4.6', economyMode: true })).toBe('claude-sonnet-4.5'); + expect(resolveModel({ taskModel: 'claude-opus-5.5', economyMode: true })).toBe('claude-sonnet-5.5'); }); it('Layer 3 docs task: uses Luna instead of haiku when economyMode: true', () => { @@ -223,7 +227,7 @@ describe('resolveModel economy mode (from config)', () => { join(squadDir, 'config.json'), JSON.stringify({ version: 1, economyMode: true }) ); - expect(resolveModel({ squadDir, taskModel: 'claude-sonnet-4.6' })).toBe('gpt-5.6-luna'); + expect(resolveModel({ squadDir, taskModel: 'claude-sonnet-5.5' })).toBe('gpt-5.6-luna'); }); it('uses normal model when economyMode absent from config', () => { @@ -241,7 +245,7 @@ describe('resolveModel economy mode (from config)', () => { ); // Option says true, config says false → option wins expect( - resolveModel({ squadDir, taskModel: 'claude-sonnet-4.6', economyMode: true }) + resolveModel({ squadDir, taskModel: 'claude-sonnet-5.5', economyMode: true }) ).toBe('gpt-5.6-luna'); }); }); @@ -267,14 +271,14 @@ describe('SDK resolveModel (agents) economy mode', () => { expect(result.model).toBe('gpt-5.6-luna'); }); - it('visual task → Terra when economyMode: true', () => { + it('visual task → standard Sonnet 5.5 (not premium) when economyMode: true', () => { const result = sdkResolveModel({ taskType: 'visual', economyMode: true }); - expect(result.model).toBe('gpt-5.6-terra'); + expect(result.model).toBe('claude-sonnet-5.5'); }); - it('code task → gpt-5.6-terra when economyMode: false', () => { + it('code task → standard chain head when economyMode: false', () => { const result = sdkResolveModel({ taskType: 'code', economyMode: false }); - expect(result.model).toBe('gpt-5.6-terra'); + expect(result.model).toBe('claude-sonnet-5.5'); }); it('user override NOT affected by economy mode', () => { diff --git a/test/feature-parity.test.ts b/test/feature-parity.test.ts index bc7270761..f9be9b59c 100644 --- a/test/feature-parity.test.ts +++ b/test/feature-parity.test.ts @@ -931,7 +931,7 @@ describe('Feature Parity: Model Registry', () => { it('looks up model info by ID', () => { const registry = new ModelRegistry(); - const info = registry.getModelInfo('claude-sonnet-4.5'); + const info = registry.getModelInfo('claude-sonnet-5.5'); expect(info).toBeDefined(); expect(info!.tier).toBe('standard'); expect(info!.provider).toBe('anthropic'); diff --git a/test/integration.test.ts b/test/integration.test.ts index 4744fa5e8..62779c3aa 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -433,9 +433,13 @@ describe('Integration: Charter → Model → Session Pipeline', () => { const codeResult = results[0]; expect(codeResult.tier).toBe('standard'); - // Docs/planning tasks should get fast tier (cost optimization) + // Docs tasks get standard tier under the default quality-first profile const docsResult = results[1]; - expect(docsResult.tier).toBe('fast'); + expect(docsResult.tier).toBe('standard'); + + // Mechanical tasks stay on the fast tier + const mechanicalResult = results[5]; + expect(mechanicalResult.tier).toBe('fast'); }); it('should provide fallback chains for each tier', () => { @@ -446,7 +450,7 @@ describe('Integration: Charter → Model → Session Pipeline', () => { expect(premiumResult.tier).toBe('premium'); expect(premiumResult.fallbackChain.length).toBeGreaterThan(1); - expect(premiumResult.fallbackChain[0]).toBe('gpt-5.6-sol'); + expect(premiumResult.fallbackChain[0]).toBe('claude-opus-5.5'); }); }); }); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 0e510d70f..fd3fa2e10 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -395,7 +395,7 @@ Test collaboration. }); it('downgrades an implicit over-ceiling pick end-to-end AND surfaces the change', async () => { - // visual → task-auto gpt-5.6-sol (powerful); ceiling versatile ⇒ downgrade. + // visual → task-auto claude-opus-5.5 (powerful); ceiling versatile ⇒ downgrade. const handle = await manager.spawnAgent({ agentName: 'policy-agent', task: 'Draw a diagram', @@ -404,7 +404,7 @@ Test collaboration. }); // The wired policy must actually change the session model (the #1089 dead-code bug). - expect(handle.model).toBe('claude-sonnet-4.6'); + expect(handle.model).toBe('claude-sonnet-5.5'); // The policy change must be SURFACED, not swallowed: emitted on the bus. const policyEvents = events.filter( @@ -436,7 +436,7 @@ Test collaboration. task: 'Draw', taskType: 'visual', }); - expect(handle.model).toBe('gpt-5.6-sol'); + expect(handle.model).toBe(MODELS.FALLBACK_CHAINS.premium[0]); const policyEvents = events.filter( (e) => (e.payload as { event?: string })?.event === 'model.policy', ); @@ -593,14 +593,15 @@ describe('Agent Lifecycle Manager — persistent model preference + provenance ( client: mockClient, teamRoot: tempDir, defaultIdleTimeout: 60_000, costPolicy: { maxCategory: 'versatile' }, }); try { - const unavailable = ['hydrafusion', 'gpt-5.6-terra', 'claude-sonnet-5', 'claude-sonnet-4.6']; + // Every versatile standard entry is unavailable; the remaining powerful entries must be skipped, so the model is omitted. + const unavailable = ['hydrafusion', 'claude-sonnet-5.5', 'gpt-5.6-terra', 'claude-sonnet-5']; const spawn = { agentName: 'pref-agent', task: 'Work', taskType: 'code' as const, sessionModel: 'hydrafusion', unavailableModels: unavailable }; const handle = await cappedManager.spawnAgent(spawn); expect( - sessionConfigs[0]?.model, + Object.prototype.hasOwnProperty.call(sessionConfigs[0] ?? {}, 'model'), `spawnAgent(${JSON.stringify(spawn)}) with config ceiling versatile passed createSession model '${String(sessionConfigs[0]?.model)}'; powerful 'gpt-5.5' must be skipped`, - ).toBe('claude-sonnet-4.5'); - expect(handle.modelResolution).toMatchObject({ source: 'fallback', fallbackFrom: { model: 'hydrafusion', source: 'session' } }); + ).toBe(false); + expect(handle.modelResolution).toMatchObject({ source: 'fallback', omitModel: true, fallbackFrom: { model: 'hydrafusion', source: 'session' } }); } finally { await cappedManager.shutdown(); } diff --git a/test/model-fallback.test.ts b/test/model-fallback.test.ts index fb082b543..ae2b77d07 100644 --- a/test/model-fallback.test.ts +++ b/test/model-fallback.test.ts @@ -45,9 +45,9 @@ describe('Cross-tier fallback — standard chain exhaustion', () => { expect(visitedSet).toEqual(chainSet); }); - it('premium chain starts with GPT Sol and walks through all premium options', () => { + it('premium chain starts with Claude Opus 5.5 and walks through all premium options', () => { const chain = DEFAULT_FALLBACK_CHAINS.premium; - expect(chain[0]).toBe('gpt-5.6-sol'); + expect(chain[0]).toBe('claude-opus-5.5'); const attempted = new Set(); let count = 0; @@ -68,7 +68,7 @@ describe('Cross-tier fallback — standard chain exhaustion', () => { it('fast chain walks through all fast options', () => { const chain = DEFAULT_FALLBACK_CHAINS.fast; - expect(chain[0]).toBe('gpt-5.6-luna'); + expect(chain[0]).toBe('claude-haiku-5.5'); const attempted = new Set(); let count = 0; @@ -149,7 +149,7 @@ describe('Provider preference — Claude family preference', () => { const registry = new ModelRegistry(); it('getFallbackChain with preferSameProvider starts with same provider', () => { - const chain = registry.getFallbackChain('standard', true, 'claude-sonnet-4.5'); + const chain = registry.getFallbackChain('standard', true, 'claude-sonnet-5.5'); // First models should be Anthropic (Claude family) const firstTwo = chain.slice(0, 2); @@ -160,7 +160,7 @@ describe('Provider preference — Claude family preference', () => { }); it('prefer Claude: all anthropic models come before other providers', () => { - const chain = registry.getFallbackChain('standard', true, 'claude-sonnet-4.5'); + const chain = registry.getFallbackChain('standard', true, 'claude-sonnet-5.5'); const standardAnthropicCount = MODEL_CATALOG.filter( m => m.tier === 'standard' && m.provider === 'anthropic' ).length; @@ -260,8 +260,8 @@ describe('Model fallback — edge cases', () => { it('getNextFallback with empty attempted set returns second in chain', () => { const next = registry.getNextFallback('claude-opus-4.8', 'premium'); - // With no attempted set, it should return the next in chain after current - expect(next).toBe('claude-opus-5'); + // With no attempted set, it returns the first same-provider entry other than current + expect(next).toBe('claude-opus-5.5'); }); it('getNextFallback for unknown model returns null', () => { diff --git a/test/model-preference.test.ts b/test/model-preference.test.ts index 1354dd12d..4336de240 100644 --- a/test/model-preference.test.ts +++ b/test/model-preference.test.ts @@ -244,8 +244,8 @@ describe('writeAgentModelOverrides', () => { // ============================================================================ describe('resolveModel', () => { - it('Layer 4: returns default Luna when nothing is set', () => { - expect(resolveModel({})).toBe('gpt-5.6-luna'); + it('Layer 4: returns the quality-first standard default when nothing is set', () => { + expect(resolveModel({})).toBe(MODELS.FALLBACK_CHAINS.standard[0]); }); it('Layer 3: task model wins over default', () => { @@ -1130,9 +1130,9 @@ describe('central resolver: config, session, fallback sources + rationale (#1720 }); it('task-auto and default results carry a rationale', () => { - const task = expectResolution({ taskType: 'docs' }, MODELS.FALLBACK_CHAINS.fast[0], 'task-auto'); - expect(task.rationale, `task-auto rationale should name the task type: '${task.rationale}'`).toMatch(/docs task/); - const def = expectResolution({}, MODELS.FALLBACK_CHAINS.fast[0], 'default'); + const task = expectResolution({ taskType: 'mechanical' }, MODELS.FALLBACK_CHAINS.fast[0], 'task-auto'); + expect(task.rationale, `task-auto rationale should name the task type: '${task.rationale}'`).toMatch(/mechanical task/); + const def = expectResolution({}, MODELS.FALLBACK_CHAINS.standard[0], 'default'); expect(def.rationale, `default rationale should say nothing matched: '${def.rationale}'`).toMatch(/default/); }); @@ -1151,7 +1151,7 @@ describe('central resolver: config, session, fallback sources + rationale (#1720 it('fallback source: an exhausted chain asks the caller to omit the model parameter', () => { const chain = MODELS.FALLBACK_CHAINS.fast; - const opts: Opts = { taskType: 'docs', unavailableModels: [...chain] }; + const opts: Opts = { taskType: 'mechanical', unavailableModels: [...chain] }; const r = resolveAgentModel(opts); expect( { source: r.source, omitModel: r.omitModel, model: r.model }, @@ -1220,7 +1220,7 @@ describe("central resolver: 'auto' sentinel at every layer (#1720)", () => { configModel: 'Auto', sessionModel: 'auto ', charterPreference: 'AUTO', - taskType: 'docs', + taskType: 'mechanical', }; const r = resolveAgentModel(opts); expect( @@ -1280,10 +1280,12 @@ describe('cost policy composes with forward-only availability fallback (#1720)', const r = resolveAgentModel(opts); const earlier = chain.slice(0, chain.indexOf('gpt-5.4')); expect(earlier, `${JSON.stringify(opts)} chose '${r.model}', which precedes 'gpt-5.4' in ${JSON.stringify(chain)}`).not.toContain(r.model); + // Every entry after 'gpt-5.4' is over the versatile ceiling, so the forward + // search exhausts and omits the model rather than stepping back to the head. expect( - { model: r.model, source: r.source, fallbackFrom: r.fallbackFrom }, - `${JSON.stringify(opts)} must fall forward to the next in-ceiling entry after 'gpt-5.4'`, - ).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback', fallbackFrom: { model: 'gpt-5.4', source: 'user-override' } }); + { model: r.model, source: r.source, omitModel: r.omitModel, fallbackFrom: r.fallbackFrom }, + `${JSON.stringify(opts)} must not wrap back past 'gpt-5.4' in ${JSON.stringify(chain)}`, + ).toEqual({ model: '', source: 'fallback', omitModel: true, fallbackFrom: { model: 'gpt-5.4', source: 'user-override' } }); expect(r.policy?.action).toBe('warn-allow-explicit'); }); @@ -1300,13 +1302,18 @@ describe('cost policy composes with forward-only availability fallback (#1720)', sessionModel: 'gpt-5.5', sessionCostPolicy: { maxCategory: 'versatile' }, unavailableModels: ['gpt-5.5'], }; const r = resolveAgentModel(opts); - expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} chose '${r.model}'`).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback' }); + const earlier = chain.slice(0, chain.indexOf('gpt-5.5')); + expect(earlier, `${JSON.stringify(opts)} chose '${r.model}', which precedes 'gpt-5.5'`).not.toContain(r.model); + expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} chose '${r.model}'`).toEqual({ model: '', source: 'fallback' }); }); it('within-ceiling pick that is unavailable moves forward over pruned entries', () => { - const opts: ModelResolutionOptions = { userOverride: 'claude-sonnet-4.6', config: versatile, unavailableModels: ['claude-sonnet-4.6'] }; + // gemini-3.8-flash (versatile) follows gpt-5.6-luna in the fast chain and is pruned by a lightweight ceiling. + const opts: ModelResolutionOptions = { + userOverride: 'gpt-5.6-luna', config: { costPolicy: { maxCategory: 'lightweight' } }, unavailableModels: ['gpt-5.6-luna'], + }; const r = resolveAgentModel(opts); - expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} chose '${r.model}'`).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback' }); + expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} chose '${r.model}'`).toEqual({ model: 'claude-haiku-4.5', source: 'fallback' }); }); }); @@ -1347,20 +1354,20 @@ describe('availability fallback respects the active cost ceiling (#1720)', () => }); it(`[${label}] unavailable explicit out-of-catalog pick skips over-ceiling entries and lands on an allowed later entry`, () => { - // versatile ceiling: gpt-5.5 (powerful) must be skipped, not chosen, after the versatile entries are unavailable. - const unavailable = ['hydrafusion', ...standard.slice(0, standard.indexOf('gpt-5.5'))]; + // lightweight ceiling: gemini-3.8-flash (versatile) must be skipped, not chosen, after the earlier lightweight entries are unavailable. + const unavailable = ['custom-mini', ...fast.slice(0, fast.indexOf('gemini-3.8-flash'))]; const opts: ModelResolutionOptions = { - userOverride: 'hydrafusion', taskType: 'code', unavailableModels: unavailable, ...policy('versatile'), + userOverride: 'custom-mini', unavailableModels: unavailable, ...policy('lightweight'), }; const r = resolveAgentModel(opts); const picked = categories.get(r.model); expect( { model: r.model, source: r.source }, - `${JSON.stringify(opts)} (ceiling versatile via ${label}) picked '${r.model}' (category ${picked ?? 'none'})`, - ).toEqual({ model: 'claude-sonnet-4.5', source: 'fallback' }); + `${JSON.stringify(opts)} (ceiling lightweight via ${label}) picked '${r.model}' (category ${picked ?? 'none'})`, + ).toEqual({ model: 'claude-haiku-4.5', source: 'fallback' }); expect( - overCeiling(r.fallbackChain, 'versatile'), - `${JSON.stringify(opts)} (ceiling versatile via ${label}) left over-ceiling entries in fallbackChain ${JSON.stringify(r.fallbackChain)}`, + overCeiling(r.fallbackChain, 'lightweight'), + `${JSON.stringify(opts)} (ceiling lightweight via ${label}) left over-ceiling entries in fallbackChain ${JSON.stringify(r.fallbackChain)}`, ).toEqual([]); }); diff --git a/test/model-quality-profiles.test.ts b/test/model-quality-profiles.test.ts new file mode 100644 index 000000000..0e1a6078a --- /dev/null +++ b/test/model-quality-profiles.test.ts @@ -0,0 +1,320 @@ +/** + * Quality-profile and live-catalog aware model selection (#1598). + * + * quality-first is the default; balanced and economy are explicit opt-in + * profiles at session and persistent level. A runtime-supplied live model + * catalog is authoritative for availability. Recommendations use only public + * GA model ids. + * + * @module test/model-quality-profiles + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs'; +import { + MODEL_CATALOG, + DEFAULT_FALLBACK_CHAINS, + ECONOMY_MODEL_MAP, + DEFAULT_CONFIG, + resolveModel as adapterResolveModel, + readModelProfile, + writeModelProfile, +} from '@bradygaster/squad-sdk/config'; +import { + resolveAgentModel, + buildCatalogCategoryMap, + AgentLifecycleManager, + type ModelResolutionOptions, + type TaskType, +} from '@bradygaster/squad-sdk/agents'; +import { MODELS } from '@bradygaster/squad-sdk/runtime/constants'; +import { EventBus } from '@bradygaster/squad-sdk/runtime/event-bus'; +import type { SquadClientWithPool } from '@bradygaster/squad-sdk/client'; + +const STALE_IDS = ['claude-opus-4.7', 'claude-opus-4.6', 'claude-sonnet-4.6', 'claude-sonnet-4.5', 'gemini-3.1-pro']; +const NEVER_RECOMMENDED = ['gpt-5.6-sol-fast', 'hydrafusion', 'hydrafusion-max']; + +function resolveWith(options: ModelResolutionOptions) { + const result = resolveAgentModel(options); + return { result, label: `resolveAgentModel(${JSON.stringify(options)}) → ${JSON.stringify(result)}` }; +} + +describe('quality-first is the default profile (#1598)', () => { + const premiumTasks: TaskType[] = ['architecture', 'research', 'verification', 'review', 'security', 'design', 'visual']; + for (const taskType of premiumTasks) { + it(`${taskType} work resolves to the premium chain head with no profile configured`, () => { + const { result, label } = resolveWith({ taskType }); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.premium[0]); + expect(result.tier, label).toBe('premium'); + expect(result.profile, label).toBe('quality-first'); + expect(result.rationale, label).toMatch(/quality-first/); + }); + } + + for (const taskType of ['code', 'prompt', 'docs', 'writing', 'planning'] as TaskType[]) { + it(`${taskType} work resolves to the standard chain head, never cost-first fast`, () => { + const { result, label } = resolveWith({ taskType }); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.standard[0]); + expect(result.tier, label).toBe('standard'); + }); + } + + it('mechanical work stays on the fast chain head', () => { + const { result, label } = resolveWith({ taskType: 'mechanical' }); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.fast[0]); + expect(result.tier, label).toBe('fast'); + }); + + it('no task type falls to the standard default, not the fast cost-first default', () => { + const { result, label } = resolveWith({}); + expect(result.source, label).toBe('default'); + expect(result.tier, label).toBe('standard'); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.standard[0]); + }); +}); + +describe('balanced and economy are explicit profiles (#1598)', () => { + it('balanced keeps language-heavy docs on standard and planning on fast', () => { + const docs = resolveWith({ taskType: 'docs', profile: 'balanced' }); + expect(docs.result.tier, docs.label).toBe('standard'); + expect(docs.result.profile, docs.label).toBe('balanced'); + const planning = resolveWith({ taskType: 'planning', profile: 'balanced' }); + expect(planning.result.tier, planning.label).toBe('fast'); + const arch = resolveWith({ taskType: 'architecture', profile: 'balanced' }); + expect(arch.result.tier, arch.label).toBe('premium'); + }); + + it('economy profile is opt-in and visibly active in the rationale', () => { + const { result, label } = resolveWith({ taskType: 'docs', profile: 'economy' }); + expect(result.model, label).toBe('gpt-5.6-luna'); + expect(result.profile, label).toBe('economy'); + expect(result.rationale, label).toMatch(/economy/); + }); + + it('legacy economyMode: true maps to the economy profile', () => { + const { result, label } = resolveWith({ taskType: 'architecture', economyMode: true }); + expect(result.profile, label).toBe('economy'); + expect(result.tier, label).toBe('standard'); + expect(MODELS.FALLBACK_CHAINS.premium, label).not.toContain(result.model); + }); + + it('session profile beats the persistent profile and legacy economyMode', () => { + const { result, label } = resolveWith({ taskType: 'research', profile: 'economy', economyMode: true, sessionProfile: 'quality-first' }); + expect(result.profile, label).toBe('quality-first'); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.premium[0]); + }); + + it('explicit layers are never altered by any profile (hydrafusion opt-in kept)', () => { + for (const profile of ['economy', 'balanced', 'quality-first'] as const) { + const user = resolveWith({ userOverride: 'hydrafusion-max', taskType: 'mechanical', profile }); + expect(user.result.model, user.label).toBe('hydrafusion-max'); + expect(user.result.source, user.label).toBe('user-override'); + const session = resolveWith({ sessionModel: 'hydrafusion', taskType: 'docs', profile }); + expect(session.result.model, session.label).toBe('hydrafusion'); + expect(session.result.source, session.label).toBe('session'); + const config = resolveWith({ configModel: 'claude-opus-4.6', taskType: 'docs', profile }); + expect(config.result.model, `${config.label}: arbitrary explicit ids must not be disallowed`).toBe('claude-opus-4.6'); + } + }); + + it('economy still honors the cost ceiling', () => { + const catalog = buildCatalogCategoryMap(); + for (const taskType of ['architecture', 'code', 'docs'] as TaskType[]) { + const { result, label } = resolveWith({ taskType, profile: 'economy', sessionCostPolicy: { maxCategory: 'lightweight' } }); + expect(catalog.get(result.model), label).toBe('lightweight'); + } + }); + + it('quality-first premium picks are downgraded under a versatile ceiling', () => { + const catalog = buildCatalogCategoryMap(); + const { result, label } = resolveWith({ taskType: 'security', config: { costPolicy: { maxCategory: 'versatile' } } }); + expect(['lightweight', 'versatile'], label).toContain(catalog.get(result.model)); + expect(result.policy?.action, label).toBe('downgraded-to-ceiling'); + }); +}); + +describe('live runtime catalog is authoritative for availability (#1598)', () => { + it('skips a chain head absent from the live catalog with visible fallback provenance', () => { + const live = MODELS.FALLBACK_CHAINS.premium.slice(1); + const { result, label } = resolveWith({ taskType: 'architecture', availableModels: live }); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.premium[1]); + expect(result.source, label).toBe('fallback'); + expect(result.fallbackFrom, label).toMatchObject({ model: MODELS.FALLBACK_CHAINS.premium[0], source: 'task-auto' }); + }); + + it('stale ids in the live catalog never win over a current GA candidate', () => { + const live = [...STALE_IDS, 'claude-opus-5']; + const { result, label } = resolveWith({ taskType: 'review', availableModels: live }); + expect(result.model, label).toBe('claude-opus-5'); + expect(STALE_IDS, label).not.toContain(result.model); + }); + + it('omits the model when nothing in the chain is live (Unit A omission preserved)', () => { + const { result, label } = resolveWith({ taskType: 'mechanical', availableModels: ['some-other-model'] }); + expect(result.omitModel, label).toBe(true); + expect(result.model, label).toBe(''); + }); + + it('an explicit model missing from the live catalog falls forward visibly, never silently', () => { + const { result, label } = resolveWith({ sessionModel: 'hydrafusion', availableModels: MODELS.FALLBACK_CHAINS.standard }); + expect(result.source, label).toBe('fallback'); + expect(result.fallbackFrom, label).toEqual({ model: 'hydrafusion', source: 'session' }); + expect(result.rationale, label).toMatch(/hydrafusion/); + }); + + it('an explicit model present in the live catalog is kept', () => { + const { result, label } = resolveWith({ sessionModel: 'hydrafusion', availableModels: ['hydrafusion'] }); + expect(result.model, label).toBe('hydrafusion'); + expect(result.source, label).toBe('session'); + }); +}); + +describe('catalog, constants and config defaults agree on public GA ids (#1598)', () => { + const surfaces: Record = { + 'MODELS.FALLBACK_CHAINS': Object.values(MODELS.FALLBACK_CHAINS).flat(), + DEFAULT_FALLBACK_CHAINS: Object.values(DEFAULT_FALLBACK_CHAINS).flat(), + 'DEFAULT_CONFIG.models.tiers': Object.values(DEFAULT_CONFIG.models.tiers).flat(), + MODEL_CATALOG: MODEL_CATALOG.map((m) => m.id), + ECONOMY_MODEL_MAP: Object.entries(ECONOMY_MODEL_MAP).flat(), + 'MODELS defaults': [MODELS.DEFAULT, MODELS.SELECTOR_DEFAULT, MODELS.NUCLEAR_FALLBACK], + }; + + for (const [surface, ids] of Object.entries(surfaces)) { + it(`${surface} carries no stale or non-public ids`, () => { + for (const id of [...STALE_IDS, ...NEVER_RECOMMENDED]) { + expect(ids, `${surface} contains '${id}'`).not.toContain(id); + } + }); + } + + it('runtime and config fallback chains are identical', () => { + expect(DEFAULT_FALLBACK_CHAINS, 'DEFAULT_FALLBACK_CHAINS vs MODELS.FALLBACK_CHAINS').toEqual(MODELS.FALLBACK_CHAINS); + expect(DEFAULT_CONFIG.models.tiers, 'DEFAULT_CONFIG.models.tiers vs MODELS.FALLBACK_CHAINS').toEqual(MODELS.FALLBACK_CHAINS); + }); + + it('every chain id is in MODEL_CATALOG with the matching tier and a cost category', () => { + for (const [tier, chain] of Object.entries(MODELS.FALLBACK_CHAINS)) { + for (const id of chain) { + const info = MODEL_CATALOG.find((m) => m.id === id); + expect(info, `chain ${tier} id '${id}' missing from MODEL_CATALOG`).toBeDefined(); + expect(info?.tier, `chain ${tier} id '${id}' catalog tier`).toBe(tier); + expect(info?.githubCategory, `chain ${tier} id '${id}' githubCategory`).toBeDefined(); + } + } + }); +}); + +describe('persistent profile via the compatibility adapter (#1598)', () => { + let squadDir: string; + beforeEach(() => { + squadDir = mkdtempSync(path.join(os.tmpdir(), 'squad-profile-')); + }); + afterEach(() => { + rmSync(squadDir, { recursive: true, force: true }); + }); + + it('writeModelProfile merges and readModelProfile round-trips; null clears', () => { + writeFileSync(path.join(squadDir, 'config.json'), JSON.stringify({ version: 1, defaultModel: 'keep-me' })); + writeModelProfile(squadDir, 'balanced'); + const written = JSON.parse(readFileSync(path.join(squadDir, 'config.json'), 'utf-8')); + expect(written, JSON.stringify(written)).toMatchObject({ defaultModel: 'keep-me', modelProfile: 'balanced' }); + expect(readModelProfile(squadDir)).toBe('balanced'); + writeModelProfile(squadDir, null); + expect(readModelProfile(squadDir), 'cleared profile').toBeNull(); + }); + + it('readModelProfile ignores unknown values', () => { + writeFileSync(path.join(squadDir, 'config.json'), JSON.stringify({ version: 1, modelProfile: 'turbo' })); + expect(readModelProfile(squadDir), "modelProfile 'turbo'").toBeNull(); + }); + + it('adapter defaults to quality-first and honors persisted economy / balanced', () => { + expect(adapterResolveModel({ squadDir }), 'no config').toBe(MODELS.FALLBACK_CHAINS.standard[0]); + writeModelProfile(squadDir, 'economy'); + expect(adapterResolveModel({ squadDir }), "modelProfile 'economy'").toBe(MODELS.SELECTOR_DEFAULT); + expect(adapterResolveModel({ squadDir, profile: 'quality-first' }), 'session profile beats persisted economy').toBe( + MODELS.FALLBACK_CHAINS.standard[0], + ); + }); +}); + +describe('profiles wired into AgentLifecycleManager.spawnAgent (#1598)', () => { + let tempDir: string; + let squadDir: string; + let sessionConfigs: Array>; + let events: Array>; + let manager: AgentLifecycleManager; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'squad-profile-spawn-')); + squadDir = path.join(tempDir, '.squad'); + const agentDir = path.join(squadDir, 'agents', 'auto-agent'); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, 'charter.md'), + `# Auto Agent\n\n## Identity\n\n**Name:** Auto Agent\n**Role:** Test\n\n## Model\n\n**Preferred:** auto\n`, + 'utf-8', + ); + sessionConfigs = []; + const client = { + async createSession(config: Record) { + sessionConfigs.push(config); + return { sessionId: `s-${sessionConfigs.length}`, async sendMessage() {}, async close() {}, on() {}, off() {} }; + }, + async deleteSession() {}, + } as unknown as SquadClientWithPool; + const eventBus = new EventBus(); + events = []; + eventBus.subscribe('agent:milestone', (e) => { + events.push(e.payload as Record); + }); + manager = new AgentLifecycleManager({ client, teamRoot: tempDir, defaultIdleTimeout: 60_000, eventBus }); + }); + + afterEach(async () => { + await manager.shutdown(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + async function writeConfig(config: Record): Promise { + await fs.writeFile(path.join(squadDir, 'config.json'), JSON.stringify({ version: 1, ...config }), 'utf-8'); + } + + it('research spawn defaults to a premium model and reports the profile', async () => { + const handle = await manager.spawnAgent({ agentName: 'auto-agent', task: 'Verify claims', taskType: 'research' }); + expect(sessionConfigs[0]?.model, `createSession for research: ${JSON.stringify(sessionConfigs[0])}`).toBe(MODELS.FALLBACK_CHAINS.premium[0]); + expect(handle.modelResolution?.profile, JSON.stringify(handle.modelResolution)).toBe('quality-first'); + const resolved = events.find((e) => e.event === 'model.resolved'); + expect(resolved, JSON.stringify(events)).toMatchObject({ profile: 'quality-first' }); + }); + + it('persistent modelProfile economy applies; session profile overrides it', async () => { + await writeConfig({ modelProfile: 'economy' }); + const economy = await manager.spawnAgent({ agentName: 'auto-agent', task: 'Docs', taskType: 'docs' }); + expect(sessionConfigs[0]?.model, `createSession with modelProfile economy: ${JSON.stringify(sessionConfigs[0])}`).toBe('gpt-5.6-luna'); + expect(economy.modelResolution?.profile).toBe('economy'); + + const quality = await manager.spawnAgent({ agentName: 'auto-agent', task: 'Docs', taskType: 'docs', sessionProfile: 'quality-first' }); + expect(sessionConfigs[1]?.model, `createSession with sessionProfile quality-first: ${JSON.stringify(sessionConfigs[1])}`).toBe( + MODELS.FALLBACK_CHAINS.standard[0], + ); + expect(quality.modelResolution?.profile).toBe('quality-first'); + }); + + it('legacy persistent economyMode still selects economy', async () => { + await writeConfig({ economyMode: true }); + const handle = await manager.spawnAgent({ agentName: 'auto-agent', task: 'Docs', taskType: 'docs' }); + expect(sessionConfigs[0]?.model, `createSession with economyMode true: ${JSON.stringify(sessionConfigs[0])}`).toBe('gpt-5.6-luna'); + expect(handle.modelResolution?.profile).toBe('economy'); + }); + + it('forwards the live catalog: an absent chain head is skipped before dispatch', async () => { + const live = MODELS.FALLBACK_CHAINS.premium.slice(1); + const handle = await manager.spawnAgent({ agentName: 'auto-agent', task: 'Review', taskType: 'review', availableModels: live }); + expect(sessionConfigs[0]?.model, `createSession with live catalog ${JSON.stringify(live)}: ${JSON.stringify(sessionConfigs[0])}`).toBe(live[0]); + expect(handle.modelResolution?.fallbackFrom).toMatchObject({ model: MODELS.FALLBACK_CHAINS.premium[0] }); + }); +}); diff --git a/test/model-selector-policy.test.ts b/test/model-selector-policy.test.ts index 1781779fb..6cf7b35fa 100644 --- a/test/model-selector-policy.test.ts +++ b/test/model-selector-policy.test.ts @@ -24,15 +24,15 @@ import type { GitHubModelCategory } from '@bradygaster/squad-sdk/config'; describe('cost policy — resolveModel integration', () => { it('AC5: implicit over-ceiling selection is downgraded deterministically', () => { const resolved = resolveModel({ - taskType: 'visual', // task-auto → gpt-5.6-sol (powerful, premium) + taskType: 'visual', // task-auto → claude-opus-5.5 (powerful, premium) sessionCostPolicy: { maxCategory: 'versatile' }, }); expect(resolved.policy).toBeDefined(); expect(resolved.policy!.action).toBe('downgraded-to-ceiling'); - expect(resolved.policy!.originalModel).toBe('gpt-5.6-sol'); - expect(resolved.policy!.finalModel).toBe('claude-sonnet-4.6'); - expect(resolved.model).toBe('claude-sonnet-4.6'); + expect(resolved.policy!.originalModel).toBe('claude-opus-5.5'); + expect(resolved.policy!.finalModel).toBe('claude-sonnet-5.5'); + expect(resolved.model).toBe('claude-sonnet-5.5'); // pruned chain must not contain any powerful model const catMap = buildCatalogCategoryMap(); for (const id of resolved.fallbackChain) { @@ -68,7 +68,7 @@ describe('cost policy — resolveModel integration', () => { it('no policy configured ⇒ behaves exactly as today (no policy field)', () => { const resolved = resolveModel({ taskType: 'visual' }); - expect(resolved.model).toBe('gpt-5.6-sol'); + expect(resolved.model).toBe('claude-opus-5.5'); expect(resolved.policy).toBeUndefined(); }); }); diff --git a/test/models.test.ts b/test/models.test.ts index 4bad9e5fb..f24048b3e 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -30,23 +30,24 @@ describe('MODEL_CATALOG', () => { it('includes expected premium models', () => { const modelIds = MODEL_CATALOG.map(m => m.id); + expect(modelIds).toContain('claude-opus-5.5'); expect(modelIds).toContain('claude-opus-5'); - expect(modelIds).toContain('claude-opus-4.6'); expect(modelIds).toContain('claude-opus-4.8'); - expect(modelIds).toContain('claude-opus-4.7'); + expect(modelIds).toContain('gpt-6.1-sol'); }); it('includes expected standard models', () => { const modelIds = MODEL_CATALOG.map(m => m.id); - expect(modelIds).toContain('claude-sonnet-4.5'); + expect(modelIds).toContain('claude-sonnet-5.5'); expect(modelIds).toContain('gpt-5.4'); - expect(modelIds).toContain('gemini-3.1-pro'); + expect(modelIds).toContain('gpt-5.6-terra'); }); it('includes expected fast models', () => { const modelIds = MODEL_CATALOG.map(m => m.id); + expect(modelIds).toContain('claude-haiku-5.5'); expect(modelIds).toContain('claude-haiku-4.5'); expect(modelIds).toContain('gpt-5.4-mini'); expect(modelIds).toContain('gpt-5-mini'); @@ -54,13 +55,17 @@ describe('MODEL_CATALOG', () => { }); it('assigns correct providers', () => { - const claude = MODEL_CATALOG.find(m => m.id === 'claude-sonnet-4.5'); + const claude = MODEL_CATALOG.find(m => m.id === 'claude-sonnet-5.5'); const gpt = MODEL_CATALOG.find(m => m.id === 'gpt-5.4'); - const gemini = MODEL_CATALOG.find(m => m.id === 'gemini-3.1-pro'); + const gemini = MODEL_CATALOG.find(m => m.id === 'gemini-4-argon'); + const grok = MODEL_CATALOG.find(m => m.id === 'grok-4.7'); + const mai = MODEL_CATALOG.find(m => m.id === 'mai-code-1.1-flash'); expect(claude?.provider).toBe('anthropic'); expect(gpt?.provider).toBe('openai'); expect(gemini?.provider).toBe('google'); + expect(grok?.provider).toBe('xai'); + expect(mai?.provider).toBe('microsoft'); }); }); @@ -77,16 +82,16 @@ describe('DEFAULT_FALLBACK_CHAINS', () => { expect(DEFAULT_FALLBACK_CHAINS.fast.length).toBeGreaterThan(0); }); - it('starts premium chain with GPT Sol', () => { - expect(DEFAULT_FALLBACK_CHAINS.premium[0]).toBe('gpt-5.6-sol'); + it('starts premium chain with Claude Opus 5.5', () => { + expect(DEFAULT_FALLBACK_CHAINS.premium[0]).toBe('claude-opus-5.5'); }); - it('starts standard chain with GPT Terra', () => { - expect(DEFAULT_FALLBACK_CHAINS.standard[0]).toBe('gpt-5.6-terra'); + it('starts standard chain with Claude Sonnet 5.5', () => { + expect(DEFAULT_FALLBACK_CHAINS.standard[0]).toBe('claude-sonnet-5.5'); }); - it('starts fast chain with Luna', () => { - expect(DEFAULT_FALLBACK_CHAINS.fast[0]).toBe('gpt-5.6-luna'); + it('starts fast chain with Claude Haiku 5.5', () => { + expect(DEFAULT_FALLBACK_CHAINS.fast[0]).toBe('claude-haiku-5.5'); }); }); @@ -95,10 +100,10 @@ describe('ModelRegistry', () => { describe('getModelInfo', () => { it('returns info for valid model', () => { - const info = registry.getModelInfo('claude-sonnet-4.5'); + const info = registry.getModelInfo('claude-sonnet-5.5'); expect(info).toBeDefined(); - expect(info?.id).toBe('claude-sonnet-4.5'); + expect(info?.id).toBe('claude-sonnet-5.5'); expect(info?.tier).toBe('standard'); expect(info?.provider).toBe('anthropic'); }); @@ -112,7 +117,7 @@ describe('ModelRegistry', () => { describe('isModelAvailable', () => { it('returns true for catalog models', () => { - expect(registry.isModelAvailable('claude-opus-4.6')).toBe(true); + expect(registry.isModelAvailable('claude-opus-5.5')).toBe(true); expect(registry.isModelAvailable('gpt-5.4')).toBe(true); expect(registry.isModelAvailable('claude-haiku-4.5')).toBe(true); }); @@ -179,7 +184,7 @@ describe('ModelRegistry', () => { }); it('prefers same provider when enabled', () => { - const chain = registry.getFallbackChain('standard', true, 'claude-sonnet-4.5'); + const chain = registry.getFallbackChain('standard', true, 'claude-sonnet-5.5'); // First models should be Claude (Anthropic) const firstFew = chain.slice(0, 2); @@ -198,11 +203,12 @@ describe('ModelRegistry', () => { it('returns next model in chain', () => { const next = registry.getNextFallback('claude-opus-4.8', 'premium'); - expect(next).toBe('claude-opus-5'); + // Same-provider preference walks Anthropic entries first, newest first. + expect(next).toBe('claude-opus-5.5'); }); it('skips already attempted models', () => { - const attempted = new Set(['claude-opus-4.7', 'claude-opus-4.6']); + const attempted = new Set(['claude-opus-5.5', 'claude-opus-5']); const next = registry.getNextFallback('claude-opus-4.8', 'premium', attempted); expect(next).not.toBeNull(); @@ -246,7 +252,7 @@ describe('ModelRegistry', () => { const ids = registry.getAllModelIds(); expect(ids.length).toBe(MODEL_CATALOG.length); - expect(ids).toContain('claude-opus-4.6'); + expect(ids).toContain('claude-opus-5.5'); expect(ids).toContain('gpt-5.4'); expect(ids).toContain('claude-haiku-4.5'); }); @@ -268,10 +274,10 @@ describe('ModelRegistry', () => { describe('convenience functions', () => { it('getModelInfo works', () => { - const info = getModelInfo('claude-sonnet-4.5'); + const info = getModelInfo('claude-sonnet-5.5'); expect(info).toBeDefined(); - expect(info?.id).toBe('claude-sonnet-4.5'); + expect(info?.id).toBe('claude-sonnet-5.5'); }); it('getFallbackChain works', () => { @@ -281,7 +287,7 @@ describe('convenience functions', () => { }); it('isModelAvailable works', () => { - expect(isModelAvailable('claude-opus-4.6')).toBe(true); + expect(isModelAvailable('claude-opus-5.5')).toBe(true); expect(isModelAvailable('nonexistent-model')).toBe(false); }); }); diff --git a/test/telemetry-auto-wiring.test.ts b/test/telemetry-auto-wiring.test.ts index defd917cb..b7b95005c 100644 --- a/test/telemetry-auto-wiring.test.ts +++ b/test/telemetry-auto-wiring.test.ts @@ -156,9 +156,9 @@ describe('initSquadTelemetry — auto-wiring', () => { describe('estimateCost()', () => { it('returns correct cost for a known model', () => { - const cost = estimateCost('claude-sonnet-4.5', 1000, 500); - // pricing: input $0.000003/token, output $0.000015/token - expect(cost).toBeCloseTo(0.003 + 0.0075, 6); + const cost = estimateCost('claude-sonnet-5', 1000, 500); + // pricing: input $0.000002/token, output $0.00001/token + expect(cost).toBeCloseTo(0.002 + 0.005, 6); }); it('returns 0 for an unknown model', () => { @@ -167,7 +167,7 @@ describe('estimateCost()', () => { }); it('returns 0 for zero tokens', () => { - const cost = estimateCost('claude-sonnet-4.5', 0, 0); + const cost = estimateCost('claude-sonnet-5', 0, 0); expect(cost).toBe(0); }); @@ -184,7 +184,7 @@ describe('estimateCost()', () => { }); it('works for premium-tier models', () => { - const cost = estimateCost('claude-opus-4.6', 1000, 500); + const cost = estimateCost('claude-opus-4.8', 1000, 500); // Pricing: input $0.000005/token, output $0.000025/token. expect(cost).toBeCloseTo(0.005 + 0.0125, 6); }); @@ -208,7 +208,7 @@ describe('MODEL_CATALOG pricing', () => { }); it('premium models cost more per token than fast models', () => { - const premium = MODEL_CATALOG.find(m => m.id === 'claude-opus-4.6')!; + const premium = MODEL_CATALOG.find(m => m.id === 'claude-opus-4.8')!; const fast = MODEL_CATALOG.find(m => m.id === 'claude-haiku-4.5')!; expect(premium.pricing!.inputPerToken).toBeGreaterThan(fast.pricing!.inputPerToken); expect(premium.pricing!.outputPerToken).toBeGreaterThan(fast.pricing!.outputPerToken); From 8c08b7fa67f3448284112c7ad62389274a422f52 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Wed, 14 Oct 2026 13:25:42 -0700 Subject: [PATCH 6/9] feat(sdk): opt-in HydraFusion model support Add hydrafusion and hydrafusion-max as preview, opt-in catalog entries (premium tier) excluded from every default chain and GA profile, and an opt-in 'hydrafusion' quality profile that sends quality-sensitive tasks to hydrafusion first, then the quality-first premium chain. Scribe and mechanical work stay fast. hydrafusion-max is used only when named explicitly. Implicit in-ceiling replacement never picks a preview model. Docs updated in .squad-templates and synced to mirrors. Closes #2014 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a61585a-655c-46db-8ea9-52e83531d7eb --- .changeset/hydrafusion-opt-in.md | 5 + .squad-templates/model-selection-reference.md | 19 +- .../skills/model-selection/SKILL.md | 15 +- .../templates/model-selection-reference.md | 19 +- .../templates/skills/model-selection/SKILL.md | 15 +- .../squad-sdk/src/agents/model-selector.ts | 67 +++- packages/squad-sdk/src/config/models.ts | 53 ++- .../templates/model-selection-reference.md | 19 +- .../templates/skills/model-selection/SKILL.md | 15 +- templates/model-selection-reference.md | 19 +- templates/skills/model-selection/SKILL.md | 15 +- test/catalog-refresh.test.ts | 3 + test/model-hydrafusion.test.ts | 314 ++++++++++++++++++ test/model-preference.test.ts | 3 +- test/model-quality-profiles.test.ts | 11 +- test/models.test.ts | 6 +- 16 files changed, 553 insertions(+), 45 deletions(-) create mode 100644 .changeset/hydrafusion-opt-in.md create mode 100644 test/model-hydrafusion.test.ts diff --git a/.changeset/hydrafusion-opt-in.md b/.changeset/hydrafusion-opt-in.md new file mode 100644 index 000000000..4dd5c6c5b --- /dev/null +++ b/.changeset/hydrafusion-opt-in.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-sdk": minor +--- + +Add opt-in HydraFusion support (#2014). `hydrafusion` and `hydrafusion-max` are catalog entries marked `preview` (premium tier) and are excluded from every default chain and GA profile. A new opt-in `hydrafusion` quality profile sends architecture, research, verification, review, security and design tasks to `hydrafusion` first, then the quality-first premium chain; Scribe and mechanical work stay on the fast tier. `hydrafusion-max` is used only when named explicitly. If HydraFusion is unavailable, resolution falls forward to the premium chain within the cost ceiling. diff --git a/.squad-templates/model-selection-reference.md b/.squad-templates/model-selection-reference.md index ddc15e74e..eb8ec3a35 100644 --- a/.squad-templates/model-selection-reference.md +++ b/.squad-templates/model-selection-reference.md @@ -30,17 +30,18 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**Quality profile:** Task-aware selection runs under one of three profiles. The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. +**Quality profile:** Task-aware selection runs under one of three GA profiles, plus one opt-in preview profile (Appendix A, *Opt-in preview models*). The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. | Profile | When it is active | Intent | |---------|-------------------|--------| | `quality-first` | Default when nothing else is set | Pick the tier that gives the best result for the task; avoid reruns caused by an underpowered model | | `balanced` | Explicit opt-in | Keep premium for quality-sensitive gates; research and language-heavy work stay standard rather than automatically cheap | | `economy` | Explicit opt-in (`economyMode: true` or `modelProfile: "economy"`) | Cost first; then apply the economy map. Always visible: the rationale says `(economy profile)` | +| Opt-in preview profile | Explicit opt-in only (`modelProfile` or a session profile; name in Appendix A) | Research preview. Quality-sensitive work goes to the preview model first, then falls through to the quality-first premium chain; every other task follows `quality-first` | Profile precedence: session profile ("use the balanced profile for this session") → `economyMode: true` → persistent `modelProfile` in `.squad/config.json` → `quality-first`. An explicit `economyMode: false` cancels a persisted `economy` profile. Cost ceilings (`maxCategory`) still apply under every profile. -- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced` or `economy`). Acknowledge: `✅ Model profile saved: {profile}`. +- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced`, `economy`, or the opt-in preview profile in Appendix A). Acknowledge: `✅ Model profile saved: {profile}`. - **When user says "use {profile} for this session":** Treat it as the session profile; do not write config. **`task-auto` — task-aware auto-selection:** Match the agent's task to its type, look up the tier for the active profile, and use the head of that tier's fallback chain (Appendix A): @@ -56,6 +57,8 @@ Profile precedence: session profile ("use the balanced profile for this session" Quality-sensitive work that is not code (architecture, research, verification, reviewer gates, security, design) can use the premium tier. Mechanical work stays fast under every profile. +**Opt-in preview models:** Research-preview models are never chosen by default and are in no fallback chain, GA profile or example. They may be unavailable, for example in corporate environments. A user opts in through the opt-in preview profile or by picking the model explicitly (per-spawn, config or session); see Appendix A, *Opt-in preview models*. Under the preview profile, Scribe and mechanical ops stay fast and reviewer gates still run. + **Role-to-tier mapping** (under `quality-first`): | Role | Default Tier | Why | Override When | @@ -125,7 +128,7 @@ Every spawn acknowledgment names the resolved model, its source and a short rati This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. -Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. +Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. The research-preview `hydrafusion` and `hydrafusion-max` IDs are in `MODEL_CATALOG` (marked `preview`, premium tier) but not in any chain below; they are used only on opt-in. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. **Fallback chains:** @@ -144,3 +147,13 @@ Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash Premium (powerful): `claude-opus-5.5`, `gpt-6.1-sol`, `claude-opus-5`, `gpt-6-sol`, `claude-opus-4.8`, `gpt-5.6-sol`, `gemini-4-argon` Standard: `claude-sonnet-5.5`, `gpt-5.6-terra`, `claude-sonnet-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `grok-4.7`, `grok-4.6` Fast: `claude-haiku-5.5`, `gpt-6-luna`, `gpt-5.6-luna`, `gemini-3.8-flash`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `mai-code-1.1-flash` + +### Opt-in preview models + +**HydraFusion (research preview):** `hydrafusion` and `hydrafusion-max` are GitHub multi-model orchestration models. They are a research preview and may be unavailable, for example in corporate environments where preview models are turned off. Squad never chooses them by default: they are in no fallback chain, no GA profile and no example. Squad treats them as model IDs; the runtime runs the orchestration itself. + +- **Opt in with the profile:** `modelProfile: "hydrafusion"` in `.squad/config.json`, or "use the hydrafusion profile for this session". Architecture, research, verification, review, security and design tasks then resolve to `hydrafusion` with the quality-first premium chain behind it. Visual work, code, docs, planning and other tasks follow `quality-first` unchanged; Scribe and mechanical ops stay on the fast tier. Reviewer gates still run. +- **Opt in with a model:** pick `hydrafusion` as the session model, `defaultModel`, `agentModelOverrides.{agent}` or a per-spawn directive. That pick is honored like any explicit model and the session model is inherited by every sub-agent spawn. +- **`hydrafusion-max`** is used only when named explicitly (per-spawn directive, config or session model). It is never chosen implicitly, including under the `hydrafusion` profile. +- **Fallback:** if `hydrafusion` is unavailable, resolution falls forward to the head of the quality-first premium chain (source `fallback`), never back to `hydrafusion` and never over the active cost ceiling. If nothing in the ceiling is left, the `model` parameter is omitted. +- **VS Code** runs every agent on the single session model the user picked; per-agent selection (including this profile) applies where Squad passes a model per spawn, such as the Copilot CLI and app. diff --git a/.squad-templates/skills/model-selection/SKILL.md b/.squad-templates/skills/model-selection/SKILL.md index 98eefb15b..3e89e752c 100644 --- a/.squad-templates/skills/model-selection/SKILL.md +++ b/.squad-templates/skills/model-selection/SKILL.md @@ -62,6 +62,7 @@ Resolution is **first-match-wins** — the highest layer with a value wins. - Docs, writing, planning, triage → standard under `quality-first`; fast under `economy` (and planning under `balanced`) - Mechanical (Scribe, git, release, changelogs, version bumps) → fast (`claude-haiku-5.5`) - `economy` then applies the economy map (premium → standard, standard → `gpt-5.6-luna`) and says so in the rationale +- `hydrafusion` (opt-in only) sends architecture, research, verification, reviewer gates, security and design to `hydrafusion`, then the premium chain; everything else (including visual and Scribe) follows `quality-first` 6. FALLBACK Layer 4: head of the standard chain; `gpt-5.6-luna` under `economy` 7. INCLUDE model in spawn acknowledgment: `🔧 {Name} ({resolved_model}) — {task}` @@ -79,13 +80,23 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. WRITE to `agentModelOverrides.{agent}` in `.squad/config.json` 3. ACKNOWLEDGE: `✅ {Agent} will always use {model} — saved to config.` -**Profile trigger:** "use the {quality-first|balanced|economy} profile" / "always use economy" +**Profile trigger:** "use the {quality-first|balanced|economy|hydrafusion} profile" / "always use economy" 1. WRITE `modelProfile` to `.squad/config.json` (merge, don't overwrite); for "this session" only, keep it in session context instead 2. ACKNOWLEDGE: `✅ Model profile saved: {profile}` A profile never replaces an explicit model (config, session, charter or per-spawn), including an opt-in model the user chose. +### HydraFusion (research preview, opt-in) + +`hydrafusion` and `hydrafusion-max` are GitHub multi-model orchestration models in research preview. They may be unavailable, for example in corporate environments where preview models are turned off, so Squad never uses them by default: they are in no fallback chain, no GA profile and no example. The runtime runs the orchestration; Squad only passes the model ID. + +- **Opt in:** `modelProfile: "hydrafusion"` (or "use the hydrafusion profile for this session"), or pick `hydrafusion` as the session model, `defaultModel`, an `agentModelOverrides` entry or a per-spawn model. A session model is inherited by every sub-agent spawn. +- **`hydrafusion-max`:** only when named explicitly by ID. Never chosen implicitly, even under the `hydrafusion` profile. +- **Fallback:** when `hydrafusion` is unavailable, fall forward to the head of the premium chain; never back, never over the cost ceiling; omit the model if nothing in the ceiling is left. +- **Unchanged:** Scribe and mechanical ops stay fast; reviewer gates still run. +- **VS Code** uses the single session model for every agent; per-agent selection applies where Squad passes a model per spawn (Copilot CLI and app). + ### When User Clears a Preference **Trigger phrases:** "switch back to automatic", "clear model preference", "use default models" @@ -119,7 +130,7 @@ After resolving the model and including it in the spawn template, this skill is - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` -- `modelProfile` — `quality-first` (default), `balanced` or `economy`; affects only Layers 3-4 +- `modelProfile` — `quality-first` (default), `balanced`, `economy` or `hydrafusion` (opt-in research preview); affects only Layers 3-4 - All fields are optional. When absent, Layers 1-4 apply normally. ## Fallback Chains diff --git a/packages/squad-cli/templates/model-selection-reference.md b/packages/squad-cli/templates/model-selection-reference.md index ddc15e74e..eb8ec3a35 100644 --- a/packages/squad-cli/templates/model-selection-reference.md +++ b/packages/squad-cli/templates/model-selection-reference.md @@ -30,17 +30,18 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**Quality profile:** Task-aware selection runs under one of three profiles. The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. +**Quality profile:** Task-aware selection runs under one of three GA profiles, plus one opt-in preview profile (Appendix A, *Opt-in preview models*). The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. | Profile | When it is active | Intent | |---------|-------------------|--------| | `quality-first` | Default when nothing else is set | Pick the tier that gives the best result for the task; avoid reruns caused by an underpowered model | | `balanced` | Explicit opt-in | Keep premium for quality-sensitive gates; research and language-heavy work stay standard rather than automatically cheap | | `economy` | Explicit opt-in (`economyMode: true` or `modelProfile: "economy"`) | Cost first; then apply the economy map. Always visible: the rationale says `(economy profile)` | +| Opt-in preview profile | Explicit opt-in only (`modelProfile` or a session profile; name in Appendix A) | Research preview. Quality-sensitive work goes to the preview model first, then falls through to the quality-first premium chain; every other task follows `quality-first` | Profile precedence: session profile ("use the balanced profile for this session") → `economyMode: true` → persistent `modelProfile` in `.squad/config.json` → `quality-first`. An explicit `economyMode: false` cancels a persisted `economy` profile. Cost ceilings (`maxCategory`) still apply under every profile. -- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced` or `economy`). Acknowledge: `✅ Model profile saved: {profile}`. +- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced`, `economy`, or the opt-in preview profile in Appendix A). Acknowledge: `✅ Model profile saved: {profile}`. - **When user says "use {profile} for this session":** Treat it as the session profile; do not write config. **`task-auto` — task-aware auto-selection:** Match the agent's task to its type, look up the tier for the active profile, and use the head of that tier's fallback chain (Appendix A): @@ -56,6 +57,8 @@ Profile precedence: session profile ("use the balanced profile for this session" Quality-sensitive work that is not code (architecture, research, verification, reviewer gates, security, design) can use the premium tier. Mechanical work stays fast under every profile. +**Opt-in preview models:** Research-preview models are never chosen by default and are in no fallback chain, GA profile or example. They may be unavailable, for example in corporate environments. A user opts in through the opt-in preview profile or by picking the model explicitly (per-spawn, config or session); see Appendix A, *Opt-in preview models*. Under the preview profile, Scribe and mechanical ops stay fast and reviewer gates still run. + **Role-to-tier mapping** (under `quality-first`): | Role | Default Tier | Why | Override When | @@ -125,7 +128,7 @@ Every spawn acknowledgment names the resolved model, its source and a short rati This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. -Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. +Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. The research-preview `hydrafusion` and `hydrafusion-max` IDs are in `MODEL_CATALOG` (marked `preview`, premium tier) but not in any chain below; they are used only on opt-in. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. **Fallback chains:** @@ -144,3 +147,13 @@ Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash Premium (powerful): `claude-opus-5.5`, `gpt-6.1-sol`, `claude-opus-5`, `gpt-6-sol`, `claude-opus-4.8`, `gpt-5.6-sol`, `gemini-4-argon` Standard: `claude-sonnet-5.5`, `gpt-5.6-terra`, `claude-sonnet-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `grok-4.7`, `grok-4.6` Fast: `claude-haiku-5.5`, `gpt-6-luna`, `gpt-5.6-luna`, `gemini-3.8-flash`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `mai-code-1.1-flash` + +### Opt-in preview models + +**HydraFusion (research preview):** `hydrafusion` and `hydrafusion-max` are GitHub multi-model orchestration models. They are a research preview and may be unavailable, for example in corporate environments where preview models are turned off. Squad never chooses them by default: they are in no fallback chain, no GA profile and no example. Squad treats them as model IDs; the runtime runs the orchestration itself. + +- **Opt in with the profile:** `modelProfile: "hydrafusion"` in `.squad/config.json`, or "use the hydrafusion profile for this session". Architecture, research, verification, review, security and design tasks then resolve to `hydrafusion` with the quality-first premium chain behind it. Visual work, code, docs, planning and other tasks follow `quality-first` unchanged; Scribe and mechanical ops stay on the fast tier. Reviewer gates still run. +- **Opt in with a model:** pick `hydrafusion` as the session model, `defaultModel`, `agentModelOverrides.{agent}` or a per-spawn directive. That pick is honored like any explicit model and the session model is inherited by every sub-agent spawn. +- **`hydrafusion-max`** is used only when named explicitly (per-spawn directive, config or session model). It is never chosen implicitly, including under the `hydrafusion` profile. +- **Fallback:** if `hydrafusion` is unavailable, resolution falls forward to the head of the quality-first premium chain (source `fallback`), never back to `hydrafusion` and never over the active cost ceiling. If nothing in the ceiling is left, the `model` parameter is omitted. +- **VS Code** runs every agent on the single session model the user picked; per-agent selection (including this profile) applies where Squad passes a model per spawn, such as the Copilot CLI and app. diff --git a/packages/squad-cli/templates/skills/model-selection/SKILL.md b/packages/squad-cli/templates/skills/model-selection/SKILL.md index 98eefb15b..3e89e752c 100644 --- a/packages/squad-cli/templates/skills/model-selection/SKILL.md +++ b/packages/squad-cli/templates/skills/model-selection/SKILL.md @@ -62,6 +62,7 @@ Resolution is **first-match-wins** — the highest layer with a value wins. - Docs, writing, planning, triage → standard under `quality-first`; fast under `economy` (and planning under `balanced`) - Mechanical (Scribe, git, release, changelogs, version bumps) → fast (`claude-haiku-5.5`) - `economy` then applies the economy map (premium → standard, standard → `gpt-5.6-luna`) and says so in the rationale +- `hydrafusion` (opt-in only) sends architecture, research, verification, reviewer gates, security and design to `hydrafusion`, then the premium chain; everything else (including visual and Scribe) follows `quality-first` 6. FALLBACK Layer 4: head of the standard chain; `gpt-5.6-luna` under `economy` 7. INCLUDE model in spawn acknowledgment: `🔧 {Name} ({resolved_model}) — {task}` @@ -79,13 +80,23 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. WRITE to `agentModelOverrides.{agent}` in `.squad/config.json` 3. ACKNOWLEDGE: `✅ {Agent} will always use {model} — saved to config.` -**Profile trigger:** "use the {quality-first|balanced|economy} profile" / "always use economy" +**Profile trigger:** "use the {quality-first|balanced|economy|hydrafusion} profile" / "always use economy" 1. WRITE `modelProfile` to `.squad/config.json` (merge, don't overwrite); for "this session" only, keep it in session context instead 2. ACKNOWLEDGE: `✅ Model profile saved: {profile}` A profile never replaces an explicit model (config, session, charter or per-spawn), including an opt-in model the user chose. +### HydraFusion (research preview, opt-in) + +`hydrafusion` and `hydrafusion-max` are GitHub multi-model orchestration models in research preview. They may be unavailable, for example in corporate environments where preview models are turned off, so Squad never uses them by default: they are in no fallback chain, no GA profile and no example. The runtime runs the orchestration; Squad only passes the model ID. + +- **Opt in:** `modelProfile: "hydrafusion"` (or "use the hydrafusion profile for this session"), or pick `hydrafusion` as the session model, `defaultModel`, an `agentModelOverrides` entry or a per-spawn model. A session model is inherited by every sub-agent spawn. +- **`hydrafusion-max`:** only when named explicitly by ID. Never chosen implicitly, even under the `hydrafusion` profile. +- **Fallback:** when `hydrafusion` is unavailable, fall forward to the head of the premium chain; never back, never over the cost ceiling; omit the model if nothing in the ceiling is left. +- **Unchanged:** Scribe and mechanical ops stay fast; reviewer gates still run. +- **VS Code** uses the single session model for every agent; per-agent selection applies where Squad passes a model per spawn (Copilot CLI and app). + ### When User Clears a Preference **Trigger phrases:** "switch back to automatic", "clear model preference", "use default models" @@ -119,7 +130,7 @@ After resolving the model and including it in the spawn template, this skill is - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` -- `modelProfile` — `quality-first` (default), `balanced` or `economy`; affects only Layers 3-4 +- `modelProfile` — `quality-first` (default), `balanced`, `economy` or `hydrafusion` (opt-in research preview); affects only Layers 3-4 - All fields are optional. When absent, Layers 1-4 apply normally. ## Fallback Chains diff --git a/packages/squad-sdk/src/agents/model-selector.ts b/packages/squad-sdk/src/agents/model-selector.ts index 5895e5d21..9049f77b8 100644 --- a/packages/squad-sdk/src/agents/model-selector.ts +++ b/packages/squad-sdk/src/agents/model-selector.ts @@ -6,7 +6,9 @@ import { MODELS } from '../runtime/constants.js'; import { applyEconomyMode, DEFAULT_MODEL_SELECTION_PROFILE, + HYDRAFUSION_MODEL_ID, isModelSelectionProfile, + isPreviewModel, MODEL_CATALOG, type ModelSelectionProfile, CATEGORY_ORDER, @@ -407,6 +409,16 @@ function applyAvailabilityFallback( function selectModelForTask(taskType: TaskType, profile: ModelSelectionProfile): ResolvedModel | undefined { const taskTier = PROFILE_TASK_TIERS[profile][taskType]; if (!taskTier) return undefined; + + if (profile === 'hydrafusion' && HYDRAFUSION_PROFILE_TASKS.has(taskType)) { + return { + model: HYDRAFUSION_MODEL_ID, + tier: 'premium', + source: 'task-auto', + fallbackChain: [HYDRAFUSION_MODEL_ID, ...MODELS.FALLBACK_CHAINS.premium], + rationale: `hydrafusion: ${taskType} task → opt-in '${HYDRAFUSION_MODEL_ID}', then the quality-first premium chain`, + }; + } const economyMode = profile === 'economy'; // Task-aware picks are the head of the tier's fallback chain, so the @@ -441,28 +453,48 @@ function selectTaskModel(taskModel: string, economyMode?: boolean): ResolvedMode }; } +/** quality-first task → tier table, shared by the opt-in `hydrafusion` profile. */ +const QUALITY_FIRST_TASK_TIERS: Readonly> = { + architecture: 'premium', + research: 'premium', + verification: 'premium', + review: 'premium', + security: 'premium', + design: 'premium', + visual: 'premium', + code: 'standard', + prompt: 'standard', + docs: 'standard', + writing: 'standard', + planning: 'standard', + mechanical: 'fast', +}; + +/** + * Quality-sensitive task types that the opt-in `hydrafusion` profile sends to + * {@link HYDRAFUSION_MODEL_ID} first (#2014). Other tasks, including Scribe and + * other mechanical work, follow `quality-first` unchanged. + */ +const HYDRAFUSION_PROFILE_TASKS: ReadonlySet = new Set([ + 'architecture', + 'research', + 'verification', + 'review', + 'security', + 'design', +]); + /** * Task → tier per quality profile (#1598). quality-first sends * quality-sensitive non-code work to premium even when no code is written; * economy keeps the historical cost-first table and then substitutes via - * {@link applyEconomyMode}. + * {@link applyEconomyMode}. The opt-in `hydrafusion` profile uses the + * quality-first tiers; {@link selectModelForTask} puts `hydrafusion` first for + * quality-sensitive tasks. */ export const PROFILE_TASK_TIERS: Readonly>>> = { - 'quality-first': { - architecture: 'premium', - research: 'premium', - verification: 'premium', - review: 'premium', - security: 'premium', - design: 'premium', - visual: 'premium', - code: 'standard', - prompt: 'standard', - docs: 'standard', - writing: 'standard', - planning: 'standard', - mechanical: 'fast', - }, + 'quality-first': QUALITY_FIRST_TASK_TIERS, + hydrafusion: QUALITY_FIRST_TASK_TIERS, balanced: { architecture: 'premium', verification: 'premium', @@ -585,7 +617,8 @@ function findInCeilingReplacement( }; add(base.model); for (const id of base.fallbackChain) add(id); - for (const info of MODEL_CATALOG) add(info.id); + // Preview entries are opt-in only and never an implicit replacement (#2014). + for (const info of MODEL_CATALOG) if (!isPreviewModel(info.id)) add(info.id); for (const id of candidates) { const cat = catalogMap.get(id); diff --git a/packages/squad-sdk/src/config/models.ts b/packages/squad-sdk/src/config/models.ts index f869af049..3da4b0ca9 100644 --- a/packages/squad-sdk/src/config/models.ts +++ b/packages/squad-sdk/src/config/models.ts @@ -105,10 +105,10 @@ export interface ModelInfo { tier: ModelTier; /** Provider */ - provider: 'anthropic' | 'openai' | 'google' | 'xai' | 'microsoft'; + provider: 'anthropic' | 'openai' | 'google' | 'xai' | 'microsoft' | 'github'; /** Model family */ - family: 'claude' | 'gpt' | 'gemini' | 'grok' | 'mai'; + family: 'claude' | 'gpt' | 'gemini' | 'grok' | 'mai' | 'hydrafusion'; /** * GitHub Copilot billing cost-ceiling category (cost axis). @@ -130,6 +130,14 @@ export interface ModelInfo { /** Uncached input/output token pricing in USD (if known) */ pricing?: ModelPricing; + + /** + * Research-preview, opt-in only (#2014). Preview entries are known to the + * resolver (tier, cost category) but never appear in a default fallback + * chain, a GA profile, or an example, and are never chosen implicitly. + * They may be unavailable, for example in corporate environments. + */ + preview?: boolean; } /** @@ -404,8 +412,40 @@ export const MODEL_CATALOG: ModelInfo[] = [ vision: true, useCases: ['fast code edits', 'code diversity'], }, + + // Research preview — opt-in only (#2014). Multi-model orchestration ids; + // the runtime handles orchestration, cost and fallback. Excluded from every + // default chain and GA profile. `hydrafusion` is used implicitly only under + // the opt-in `hydrafusion` profile; `hydrafusion-max` only when named. + { + id: 'hydrafusion', + tier: 'premium', + provider: 'github', + family: 'hydrafusion', + githubCategory: 'powerful', + preview: true, + useCases: ['architecture proposals', 'reviewer gates', 'security audits', 'research', 'design'], + }, + { + id: 'hydrafusion-max', + tier: 'premium', + provider: 'github', + family: 'hydrafusion', + githubCategory: 'powerful', + preview: true, + useCases: ['explicit opt-in only'], + }, ]; +/** Opt-in preview model the `hydrafusion` profile puts first for quality-sensitive work (#2014). */ +export const HYDRAFUSION_MODEL_ID = 'hydrafusion'; + +/** True when `id` is a research-preview, opt-in-only {@link MODEL_CATALOG} entry. */ +export function isPreviewModel(id: string): boolean { + const key = id.trim().toLowerCase(); + return MODEL_CATALOG.some((m) => m.preview === true && m.id === key); +} + /** * Default fallback chains per tier — public GA IDs ordered by preference * (quality first within each tier, alternating providers for resilience). @@ -705,11 +745,16 @@ export function applyEconomyMode(model: string): string { * for code and language-heavy work, fast for planning and mechanical work. * - `economy`: opt-in cost-first selection plus {@link ECONOMY_MODEL_MAP} * substitution. Legacy `economyMode: true` selects this profile. + * - `hydrafusion` (#2014): opt-in research preview. Quality-sensitive work + * (architecture, research, verification, review, security, design) tries + * {@link HYDRAFUSION_MODEL_ID} first, then the quality-first premium chain; + * everything else follows `quality-first`. `hydrafusion-max` is never + * chosen by this profile. */ -export type ModelSelectionProfile = 'quality-first' | 'balanced' | 'economy'; +export type ModelSelectionProfile = 'quality-first' | 'balanced' | 'economy' | 'hydrafusion'; /** All supported {@link ModelSelectionProfile} values. */ -export const MODEL_SELECTION_PROFILES: readonly ModelSelectionProfile[] = ['quality-first', 'balanced', 'economy'] as const; +export const MODEL_SELECTION_PROFILES: readonly ModelSelectionProfile[] = ['quality-first', 'balanced', 'economy', 'hydrafusion'] as const; /** Profile used when neither the session nor `.squad/config.json` selects one. */ export const DEFAULT_MODEL_SELECTION_PROFILE: ModelSelectionProfile = 'quality-first'; diff --git a/packages/squad-sdk/templates/model-selection-reference.md b/packages/squad-sdk/templates/model-selection-reference.md index ddc15e74e..eb8ec3a35 100644 --- a/packages/squad-sdk/templates/model-selection-reference.md +++ b/packages/squad-sdk/templates/model-selection-reference.md @@ -30,17 +30,18 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**Quality profile:** Task-aware selection runs under one of three profiles. The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. +**Quality profile:** Task-aware selection runs under one of three GA profiles, plus one opt-in preview profile (Appendix A, *Opt-in preview models*). The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. | Profile | When it is active | Intent | |---------|-------------------|--------| | `quality-first` | Default when nothing else is set | Pick the tier that gives the best result for the task; avoid reruns caused by an underpowered model | | `balanced` | Explicit opt-in | Keep premium for quality-sensitive gates; research and language-heavy work stay standard rather than automatically cheap | | `economy` | Explicit opt-in (`economyMode: true` or `modelProfile: "economy"`) | Cost first; then apply the economy map. Always visible: the rationale says `(economy profile)` | +| Opt-in preview profile | Explicit opt-in only (`modelProfile` or a session profile; name in Appendix A) | Research preview. Quality-sensitive work goes to the preview model first, then falls through to the quality-first premium chain; every other task follows `quality-first` | Profile precedence: session profile ("use the balanced profile for this session") → `economyMode: true` → persistent `modelProfile` in `.squad/config.json` → `quality-first`. An explicit `economyMode: false` cancels a persisted `economy` profile. Cost ceilings (`maxCategory`) still apply under every profile. -- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced` or `economy`). Acknowledge: `✅ Model profile saved: {profile}`. +- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced`, `economy`, or the opt-in preview profile in Appendix A). Acknowledge: `✅ Model profile saved: {profile}`. - **When user says "use {profile} for this session":** Treat it as the session profile; do not write config. **`task-auto` — task-aware auto-selection:** Match the agent's task to its type, look up the tier for the active profile, and use the head of that tier's fallback chain (Appendix A): @@ -56,6 +57,8 @@ Profile precedence: session profile ("use the balanced profile for this session" Quality-sensitive work that is not code (architecture, research, verification, reviewer gates, security, design) can use the premium tier. Mechanical work stays fast under every profile. +**Opt-in preview models:** Research-preview models are never chosen by default and are in no fallback chain, GA profile or example. They may be unavailable, for example in corporate environments. A user opts in through the opt-in preview profile or by picking the model explicitly (per-spawn, config or session); see Appendix A, *Opt-in preview models*. Under the preview profile, Scribe and mechanical ops stay fast and reviewer gates still run. + **Role-to-tier mapping** (under `quality-first`): | Role | Default Tier | Why | Override When | @@ -125,7 +128,7 @@ Every spawn acknowledgment names the resolved model, its source and a short rati This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. -Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. +Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. The research-preview `hydrafusion` and `hydrafusion-max` IDs are in `MODEL_CATALOG` (marked `preview`, premium tier) but not in any chain below; they are used only on opt-in. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. **Fallback chains:** @@ -144,3 +147,13 @@ Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash Premium (powerful): `claude-opus-5.5`, `gpt-6.1-sol`, `claude-opus-5`, `gpt-6-sol`, `claude-opus-4.8`, `gpt-5.6-sol`, `gemini-4-argon` Standard: `claude-sonnet-5.5`, `gpt-5.6-terra`, `claude-sonnet-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `grok-4.7`, `grok-4.6` Fast: `claude-haiku-5.5`, `gpt-6-luna`, `gpt-5.6-luna`, `gemini-3.8-flash`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `mai-code-1.1-flash` + +### Opt-in preview models + +**HydraFusion (research preview):** `hydrafusion` and `hydrafusion-max` are GitHub multi-model orchestration models. They are a research preview and may be unavailable, for example in corporate environments where preview models are turned off. Squad never chooses them by default: they are in no fallback chain, no GA profile and no example. Squad treats them as model IDs; the runtime runs the orchestration itself. + +- **Opt in with the profile:** `modelProfile: "hydrafusion"` in `.squad/config.json`, or "use the hydrafusion profile for this session". Architecture, research, verification, review, security and design tasks then resolve to `hydrafusion` with the quality-first premium chain behind it. Visual work, code, docs, planning and other tasks follow `quality-first` unchanged; Scribe and mechanical ops stay on the fast tier. Reviewer gates still run. +- **Opt in with a model:** pick `hydrafusion` as the session model, `defaultModel`, `agentModelOverrides.{agent}` or a per-spawn directive. That pick is honored like any explicit model and the session model is inherited by every sub-agent spawn. +- **`hydrafusion-max`** is used only when named explicitly (per-spawn directive, config or session model). It is never chosen implicitly, including under the `hydrafusion` profile. +- **Fallback:** if `hydrafusion` is unavailable, resolution falls forward to the head of the quality-first premium chain (source `fallback`), never back to `hydrafusion` and never over the active cost ceiling. If nothing in the ceiling is left, the `model` parameter is omitted. +- **VS Code** runs every agent on the single session model the user picked; per-agent selection (including this profile) applies where Squad passes a model per spawn, such as the Copilot CLI and app. diff --git a/packages/squad-sdk/templates/skills/model-selection/SKILL.md b/packages/squad-sdk/templates/skills/model-selection/SKILL.md index 98eefb15b..3e89e752c 100644 --- a/packages/squad-sdk/templates/skills/model-selection/SKILL.md +++ b/packages/squad-sdk/templates/skills/model-selection/SKILL.md @@ -62,6 +62,7 @@ Resolution is **first-match-wins** — the highest layer with a value wins. - Docs, writing, planning, triage → standard under `quality-first`; fast under `economy` (and planning under `balanced`) - Mechanical (Scribe, git, release, changelogs, version bumps) → fast (`claude-haiku-5.5`) - `economy` then applies the economy map (premium → standard, standard → `gpt-5.6-luna`) and says so in the rationale +- `hydrafusion` (opt-in only) sends architecture, research, verification, reviewer gates, security and design to `hydrafusion`, then the premium chain; everything else (including visual and Scribe) follows `quality-first` 6. FALLBACK Layer 4: head of the standard chain; `gpt-5.6-luna` under `economy` 7. INCLUDE model in spawn acknowledgment: `🔧 {Name} ({resolved_model}) — {task}` @@ -79,13 +80,23 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. WRITE to `agentModelOverrides.{agent}` in `.squad/config.json` 3. ACKNOWLEDGE: `✅ {Agent} will always use {model} — saved to config.` -**Profile trigger:** "use the {quality-first|balanced|economy} profile" / "always use economy" +**Profile trigger:** "use the {quality-first|balanced|economy|hydrafusion} profile" / "always use economy" 1. WRITE `modelProfile` to `.squad/config.json` (merge, don't overwrite); for "this session" only, keep it in session context instead 2. ACKNOWLEDGE: `✅ Model profile saved: {profile}` A profile never replaces an explicit model (config, session, charter or per-spawn), including an opt-in model the user chose. +### HydraFusion (research preview, opt-in) + +`hydrafusion` and `hydrafusion-max` are GitHub multi-model orchestration models in research preview. They may be unavailable, for example in corporate environments where preview models are turned off, so Squad never uses them by default: they are in no fallback chain, no GA profile and no example. The runtime runs the orchestration; Squad only passes the model ID. + +- **Opt in:** `modelProfile: "hydrafusion"` (or "use the hydrafusion profile for this session"), or pick `hydrafusion` as the session model, `defaultModel`, an `agentModelOverrides` entry or a per-spawn model. A session model is inherited by every sub-agent spawn. +- **`hydrafusion-max`:** only when named explicitly by ID. Never chosen implicitly, even under the `hydrafusion` profile. +- **Fallback:** when `hydrafusion` is unavailable, fall forward to the head of the premium chain; never back, never over the cost ceiling; omit the model if nothing in the ceiling is left. +- **Unchanged:** Scribe and mechanical ops stay fast; reviewer gates still run. +- **VS Code** uses the single session model for every agent; per-agent selection applies where Squad passes a model per spawn (Copilot CLI and app). + ### When User Clears a Preference **Trigger phrases:** "switch back to automatic", "clear model preference", "use default models" @@ -119,7 +130,7 @@ After resolving the model and including it in the spawn template, this skill is - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` -- `modelProfile` — `quality-first` (default), `balanced` or `economy`; affects only Layers 3-4 +- `modelProfile` — `quality-first` (default), `balanced`, `economy` or `hydrafusion` (opt-in research preview); affects only Layers 3-4 - All fields are optional. When absent, Layers 1-4 apply normally. ## Fallback Chains diff --git a/templates/model-selection-reference.md b/templates/model-selection-reference.md index ddc15e74e..eb8ec3a35 100644 --- a/templates/model-selection-reference.md +++ b/templates/model-selection-reference.md @@ -30,17 +30,18 @@ Spawn guidance never names a model ID. It reasons in sources and tiers (`premium **`charter` — charter preference:** Does the agent's charter have a `## Model` section with `**Preferred:**` set to a specific model (not `auto`)? If yes, use that model. -**Quality profile:** Task-aware selection runs under one of three profiles. The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. +**Quality profile:** Task-aware selection runs under one of three GA profiles, plus one opt-in preview profile (Appendix A, *Opt-in preview models*). The profile changes only `task-auto` and `default`; it never replaces a `user-override`, `config`, `session` or `charter` model, and never replaces an explicit opt-in model such as one the user picked for the session. | Profile | When it is active | Intent | |---------|-------------------|--------| | `quality-first` | Default when nothing else is set | Pick the tier that gives the best result for the task; avoid reruns caused by an underpowered model | | `balanced` | Explicit opt-in | Keep premium for quality-sensitive gates; research and language-heavy work stay standard rather than automatically cheap | | `economy` | Explicit opt-in (`economyMode: true` or `modelProfile: "economy"`) | Cost first; then apply the economy map. Always visible: the rationale says `(economy profile)` | +| Opt-in preview profile | Explicit opt-in only (`modelProfile` or a session profile; name in Appendix A) | Research preview. Quality-sensitive work goes to the preview model first, then falls through to the quality-first premium chain; every other task follows `quality-first` | Profile precedence: session profile ("use the balanced profile for this session") → `economyMode: true` → persistent `modelProfile` in `.squad/config.json` → `quality-first`. An explicit `economyMode: false` cancels a persisted `economy` profile. Cost ceilings (`maxCategory`) still apply under every profile. -- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced` or `economy`). Acknowledge: `✅ Model profile saved: {profile}`. +- **When user says "use the {profile} profile" / "always use {profile}":** Write `modelProfile` to `.squad/config.json` (`quality-first`, `balanced`, `economy`, or the opt-in preview profile in Appendix A). Acknowledge: `✅ Model profile saved: {profile}`. - **When user says "use {profile} for this session":** Treat it as the session profile; do not write config. **`task-auto` — task-aware auto-selection:** Match the agent's task to its type, look up the tier for the active profile, and use the head of that tier's fallback chain (Appendix A): @@ -56,6 +57,8 @@ Profile precedence: session profile ("use the balanced profile for this session" Quality-sensitive work that is not code (architecture, research, verification, reviewer gates, security, design) can use the premium tier. Mechanical work stays fast under every profile. +**Opt-in preview models:** Research-preview models are never chosen by default and are in no fallback chain, GA profile or example. They may be unavailable, for example in corporate environments. A user opts in through the opt-in preview profile or by picking the model explicitly (per-spawn, config or session); see Appendix A, *Opt-in preview models*. Under the preview profile, Scribe and mechanical ops stay fast and reviewer gates still run. + **Role-to-tier mapping** (under `quality-first`): | Role | Default Tier | Why | Override When | @@ -125,7 +128,7 @@ Every spawn acknowledgment names the resolved model, its source and a short rati This appendix is catalog data, not spawn guidance. It mirrors `MODELS.FALLBACK_CHAINS` and `MODEL_CATALOG` in the SDK and is maintained with the model catalog. The guidance above refers only to tiers and sources; these lists turn a tier into model IDs. -Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. +Source of truth for public availability: the GitHub Copilot supported-models table (https://docs.github.com/en/copilot/reference/ai-models/supported-models), verified 2026-10-14. Only public GA models are listed; internal and preview models are never recommended. The research-preview `hydrafusion` and `hydrafusion-max` IDs are in `MODEL_CATALOG` (marked `preview`, premium tier) but not in any chain below; they are used only on opt-in. Tiers place models by their Copilot `model_picker_category` (`powerful`, `versatile`, `lightweight`), not by guessed pricing. An opt-in model the user names explicitly is still passed through unchanged. **Fallback chains:** @@ -144,3 +147,13 @@ Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash Premium (powerful): `claude-opus-5.5`, `gpt-6.1-sol`, `claude-opus-5`, `gpt-6-sol`, `claude-opus-4.8`, `gpt-5.6-sol`, `gemini-4-argon` Standard: `claude-sonnet-5.5`, `gpt-5.6-terra`, `claude-sonnet-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, `grok-4.7`, `grok-4.6` Fast: `claude-haiku-5.5`, `gpt-6-luna`, `gpt-5.6-luna`, `gemini-3.8-flash`, `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5-mini`, `mai-code-1.1-flash` + +### Opt-in preview models + +**HydraFusion (research preview):** `hydrafusion` and `hydrafusion-max` are GitHub multi-model orchestration models. They are a research preview and may be unavailable, for example in corporate environments where preview models are turned off. Squad never chooses them by default: they are in no fallback chain, no GA profile and no example. Squad treats them as model IDs; the runtime runs the orchestration itself. + +- **Opt in with the profile:** `modelProfile: "hydrafusion"` in `.squad/config.json`, or "use the hydrafusion profile for this session". Architecture, research, verification, review, security and design tasks then resolve to `hydrafusion` with the quality-first premium chain behind it. Visual work, code, docs, planning and other tasks follow `quality-first` unchanged; Scribe and mechanical ops stay on the fast tier. Reviewer gates still run. +- **Opt in with a model:** pick `hydrafusion` as the session model, `defaultModel`, `agentModelOverrides.{agent}` or a per-spawn directive. That pick is honored like any explicit model and the session model is inherited by every sub-agent spawn. +- **`hydrafusion-max`** is used only when named explicitly (per-spawn directive, config or session model). It is never chosen implicitly, including under the `hydrafusion` profile. +- **Fallback:** if `hydrafusion` is unavailable, resolution falls forward to the head of the quality-first premium chain (source `fallback`), never back to `hydrafusion` and never over the active cost ceiling. If nothing in the ceiling is left, the `model` parameter is omitted. +- **VS Code** runs every agent on the single session model the user picked; per-agent selection (including this profile) applies where Squad passes a model per spawn, such as the Copilot CLI and app. diff --git a/templates/skills/model-selection/SKILL.md b/templates/skills/model-selection/SKILL.md index 98eefb15b..3e89e752c 100644 --- a/templates/skills/model-selection/SKILL.md +++ b/templates/skills/model-selection/SKILL.md @@ -62,6 +62,7 @@ Resolution is **first-match-wins** — the highest layer with a value wins. - Docs, writing, planning, triage → standard under `quality-first`; fast under `economy` (and planning under `balanced`) - Mechanical (Scribe, git, release, changelogs, version bumps) → fast (`claude-haiku-5.5`) - `economy` then applies the economy map (premium → standard, standard → `gpt-5.6-luna`) and says so in the rationale +- `hydrafusion` (opt-in only) sends architecture, research, verification, reviewer gates, security and design to `hydrafusion`, then the premium chain; everything else (including visual and Scribe) follows `quality-first` 6. FALLBACK Layer 4: head of the standard chain; `gpt-5.6-luna` under `economy` 7. INCLUDE model in spawn acknowledgment: `🔧 {Name} ({resolved_model}) — {task}` @@ -79,13 +80,23 @@ Resolution is **first-match-wins** — the highest layer with a value wins. 2. WRITE to `agentModelOverrides.{agent}` in `.squad/config.json` 3. ACKNOWLEDGE: `✅ {Agent} will always use {model} — saved to config.` -**Profile trigger:** "use the {quality-first|balanced|economy} profile" / "always use economy" +**Profile trigger:** "use the {quality-first|balanced|economy|hydrafusion} profile" / "always use economy" 1. WRITE `modelProfile` to `.squad/config.json` (merge, don't overwrite); for "this session" only, keep it in session context instead 2. ACKNOWLEDGE: `✅ Model profile saved: {profile}` A profile never replaces an explicit model (config, session, charter or per-spawn), including an opt-in model the user chose. +### HydraFusion (research preview, opt-in) + +`hydrafusion` and `hydrafusion-max` are GitHub multi-model orchestration models in research preview. They may be unavailable, for example in corporate environments where preview models are turned off, so Squad never uses them by default: they are in no fallback chain, no GA profile and no example. The runtime runs the orchestration; Squad only passes the model ID. + +- **Opt in:** `modelProfile: "hydrafusion"` (or "use the hydrafusion profile for this session"), or pick `hydrafusion` as the session model, `defaultModel`, an `agentModelOverrides` entry or a per-spawn model. A session model is inherited by every sub-agent spawn. +- **`hydrafusion-max`:** only when named explicitly by ID. Never chosen implicitly, even under the `hydrafusion` profile. +- **Fallback:** when `hydrafusion` is unavailable, fall forward to the head of the premium chain; never back, never over the cost ceiling; omit the model if nothing in the ceiling is left. +- **Unchanged:** Scribe and mechanical ops stay fast; reviewer gates still run. +- **VS Code** uses the single session model for every agent; per-agent selection applies where Squad passes a model per spawn (Copilot CLI and app). + ### When User Clears a Preference **Trigger phrases:** "switch back to automatic", "clear model preference", "use default models" @@ -119,7 +130,7 @@ After resolving the model and including it in the spawn template, this skill is - `defaultModel` — applies to ALL agents unless overridden by `agentModelOverrides` - `agentModelOverrides` — per-agent overrides that take priority over `defaultModel` -- `modelProfile` — `quality-first` (default), `balanced` or `economy`; affects only Layers 3-4 +- `modelProfile` — `quality-first` (default), `balanced`, `economy` or `hydrafusion` (opt-in research preview); affects only Layers 3-4 - All fields are optional. When absent, Layers 1-4 apply normally. ## Fallback Chains diff --git a/test/catalog-refresh.test.ts b/test/catalog-refresh.test.ts index 74ea9b597..0b8b32663 100644 --- a/test/catalog-refresh.test.ts +++ b/test/catalog-refresh.test.ts @@ -76,6 +76,9 @@ const EXPECTED_CATALOG_IDS = [ 'grok-4.6', 'grok-4.7', 'mai-code-1.1-flash', + // Research preview, opt-in only (#2014); never in a default chain. + 'hydrafusion', + 'hydrafusion-max', ]; const CONFIG_CHAINS = DEFAULT_FALLBACK_CHAINS as Record; diff --git a/test/model-hydrafusion.test.ts b/test/model-hydrafusion.test.ts new file mode 100644 index 000000000..c3fc7c386 --- /dev/null +++ b/test/model-hydrafusion.test.ts @@ -0,0 +1,314 @@ +/** + * HydraFusion opt-in support (#2014). + * + * `hydrafusion` and `hydrafusion-max` are research-preview model ids. Squad + * knows them (premium tier, catalog entries marked preview) but never picks + * them implicitly: they appear in no default chain and no GA profile. The + * opt-in `hydrafusion` profile puts `hydrafusion` first for quality-sensitive + * work and falls through to the quality-first premium chain. `hydrafusion-max` + * is used only when named explicitly. + * + * @module test/model-hydrafusion + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { + MODEL_CATALOG, + DEFAULT_FALLBACK_CHAINS, + DEFAULT_CONFIG, + ECONOMY_MODEL_MAP, + readModelProfile, + writeModelProfile, + resolveModel as adapterResolveModel, +} from '@bradygaster/squad-sdk/config'; +import { + resolveAgentModel, + buildCatalogCategoryMap, + isModelSelectionProfile, + MODEL_SELECTION_PROFILES, + AgentLifecycleManager, + type ModelResolutionOptions, + type TaskType, +} from '@bradygaster/squad-sdk/agents'; +import { MODELS } from '@bradygaster/squad-sdk/runtime/constants'; +import type { SquadClientWithPool } from '@bradygaster/squad-sdk/client'; + +const HF = 'hydrafusion'; +const HF_MAX = 'hydrafusion-max'; +const PREVIEW_IDS = [HF, HF_MAX]; +const ALL_TASKS: TaskType[] = [ + 'code', 'prompt', 'docs', 'visual', 'planning', 'mechanical', 'architecture', + 'research', 'verification', 'review', 'security', 'design', 'writing', +]; +const HF_TASKS: TaskType[] = ['architecture', 'research', 'verification', 'review', 'security', 'design']; +const GA_PROFILES = ['quality-first', 'balanced', 'economy'] as const; +const CEILINGS = [undefined, 'powerful', 'versatile', 'lightweight'] as const; + +function resolveWith(options: ModelResolutionOptions) { + const result = resolveAgentModel(options); + return { result, label: `resolveAgentModel(${JSON.stringify(options)}) → ${JSON.stringify(result)}` }; +} + +function withCeiling(options: ModelResolutionOptions, ceiling: (typeof CEILINGS)[number]): ModelResolutionOptions { + return ceiling ? { ...options, sessionCostPolicy: { maxCategory: ceiling } } : options; +} + +describe('HydraFusion catalog entries are preview / opt-in (#2014)', () => { + for (const id of PREVIEW_IDS) { + it(`MODEL_CATALOG lists '${id}' as a premium, preview-only entry`, () => { + const info = MODEL_CATALOG.find((m) => m.id === id) as (Record | undefined); + expect(info, `MODEL_CATALOG entry for '${id}'`).toBeDefined(); + expect(info?.tier, `MODEL_CATALOG '${id}' tier`).toBe('premium'); + expect(info?.preview, `MODEL_CATALOG '${id}' must be marked preview (opt-in)`).toBe(true); + expect(info?.githubCategory, `MODEL_CATALOG '${id}' needs a cost category so ceilings apply`).toBe('powerful'); + }); + + it(`an explicit '${id}' is inferred as premium tier with the premium fallback chain`, () => { + const { result, label } = resolveWith({ sessionModel: id }); + expect(result.tier, label).toBe('premium'); + expect(result.fallbackChain, label).toEqual(MODELS.FALLBACK_CHAINS.premium); + }); + } + + it('the only preview catalog entries are the HydraFusion opt-in ids', () => { + const preview = MODEL_CATALOG.filter((m) => (m as { preview?: boolean }).preview === true).map((m) => m.id); + expect(preview.sort(), `preview MODEL_CATALOG ids ${JSON.stringify(preview)}`).toEqual([...PREVIEW_IDS].sort()); + }); + + const defaultSurfaces: Record = { + 'MODELS.FALLBACK_CHAINS': Object.values(MODELS.FALLBACK_CHAINS).flat(), + DEFAULT_FALLBACK_CHAINS: Object.values(DEFAULT_FALLBACK_CHAINS).flat(), + 'DEFAULT_CONFIG.models.tiers': Object.values(DEFAULT_CONFIG.models.tiers).flat(), + 'DEFAULT_CONFIG.models.default': [DEFAULT_CONFIG.models.default], + ECONOMY_MODEL_MAP: Object.entries(ECONOMY_MODEL_MAP).flat(), + 'MODELS defaults': [MODELS.DEFAULT, MODELS.SELECTOR_DEFAULT, MODELS.NUCLEAR_FALLBACK], + }; + for (const [surface, ids] of Object.entries(defaultSurfaces)) { + it(`${surface} contains no preview id`, () => { + for (const id of PREVIEW_IDS) expect(ids, `${surface} contains '${id}'`).not.toContain(id); + }); + } +}); + +describe('HydraFusion is never auto-selected under GA profiles (#2014)', () => { + for (const profile of GA_PROFILES) { + for (const ceiling of CEILINGS) { + it(`${profile} profile${ceiling ? ` with ${ceiling} ceiling` : ''} never resolves or chains a preview id`, () => { + for (const taskType of [...ALL_TASKS, undefined]) { + const { result, label } = resolveWith(withCeiling({ taskType, profile }, ceiling)); + for (const id of PREVIEW_IDS) { + expect(result.model, label).not.toBe(id); + expect(result.fallbackChain, label).not.toContain(id); + } + } + }); + } + } +}); + +describe('opt-in hydrafusion profile (#2014)', () => { + it("'hydrafusion' is a supported profile", () => { + expect(isModelSelectionProfile(HF), `isModelSelectionProfile('${HF}')`).toBe(true); + expect(MODEL_SELECTION_PROFILES, 'MODEL_SELECTION_PROFILES').toContain(HF); + }); + + for (const taskType of HF_TASKS) { + it(`${taskType} work picks hydrafusion first, then the quality-first premium chain`, () => { + const { result, label } = resolveWith({ taskType, profile: HF }); + expect(result.model, label).toBe(HF); + expect(result.tier, label).toBe('premium'); + expect(result.source, label).toBe('task-auto'); + expect(result.profile, label).toBe(HF); + expect(result.fallbackChain, label).toEqual([HF, ...MODELS.FALLBACK_CHAINS.premium]); + expect(result.rationale, label).toMatch(/hydrafusion/); + }); + } + + it('session profile hydrafusion beats a persistent GA profile', () => { + const { result, label } = resolveWith({ taskType: 'review', profile: 'economy', sessionProfile: HF }); + expect(result.model, label).toBe(HF); + }); + + it('Scribe / mechanical work stays on the fast chain head', () => { + const { result, label } = resolveWith({ taskType: 'mechanical', profile: HF, agentRole: 'scribe' }); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.fast[0]); + expect(result.tier, label).toBe('fast'); + expect(result.fallbackChain, label).not.toContain(HF); + }); + + for (const taskType of ['code', 'prompt', 'docs', 'writing', 'planning', 'visual'] as TaskType[]) { + it(`${taskType} work follows quality-first unchanged`, () => { + const hf = resolveWith({ taskType, profile: HF }); + const qf = resolveWith({ taskType, profile: 'quality-first' }); + expect(hf.result.model, `${hf.label} vs ${qf.label}`).toBe(qf.result.model); + expect(hf.result.fallbackChain, hf.label).not.toContain(HF); + }); + } + + it('no task type falls to the standard default', () => { + const { result, label } = resolveWith({ profile: HF }); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.standard[0]); + }); + + it('explicit layers still win over the hydrafusion profile', () => { + const { result, label } = resolveWith({ taskType: 'architecture', profile: HF, configModel: 'claude-opus-5' }); + expect(result.model, label).toBe('claude-opus-5'); + expect(result.source, label).toBe('config'); + }); +}); + +describe('hydrafusion-max is only used when named explicitly (#2014)', () => { + for (const profile of [...GA_PROFILES, HF] as const) { + for (const ceiling of CEILINGS) { + it(`${profile} profile${ceiling ? ` with ${ceiling} ceiling` : ''} never picks hydrafusion-max implicitly`, () => { + for (const taskType of [...ALL_TASKS, undefined]) { + const { result, label } = resolveWith(withCeiling({ taskType, profile }, ceiling)); + expect(result.model, label).not.toBe(HF_MAX); + expect(result.fallbackChain, label).not.toContain(HF_MAX); + } + }); + } + } + + for (const layer of ['userOverride', 'configModel', 'sessionModel'] as const) { + it(`an explicit ${layer} of hydrafusion-max is honored under the hydrafusion profile`, () => { + const { result, label } = resolveWith({ [layer]: HF_MAX, taskType: 'architecture', profile: HF }); + expect(result.model, label).toBe(HF_MAX); + expect(result.tier, label).toBe('premium'); + expect(result.source, label).not.toBe('task-auto'); + }); + } +}); + +describe('hydrafusion unavailable: forward-only fallback within the cost ceiling (#2014)', () => { + it('missing from the live catalog → quality-first premium chain head, visibly', () => { + const live = [...MODELS.FALLBACK_CHAINS.premium]; + const { result, label } = resolveWith({ taskType: 'security', profile: HF, availableModels: live }); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.premium[0]); + expect(result.source, label).toBe('fallback'); + expect(result.fallbackFrom, label).toEqual({ model: HF, source: 'task-auto' }); + expect(result.rationale, label).toMatch(/hydrafusion/); + }); + + it('keeps moving forward along the premium chain, never back to hydrafusion', () => { + const unavailable = [HF, MODELS.FALLBACK_CHAINS.premium[0], MODELS.FALLBACK_CHAINS.premium[1]]; + const { result, label } = resolveWith({ taskType: 'architecture', profile: HF, unavailableModels: unavailable }); + expect(result.model, label).toBe(MODELS.FALLBACK_CHAINS.premium[2]); + expect(result.fallbackFrom, label).toEqual({ model: HF, source: 'task-auto' }); + expect(result.fallbackChain, label).toEqual(MODELS.FALLBACK_CHAINS.premium.slice(2)); + expect(result.fallbackChain, label).not.toContain(HF); + }); + + it('never falls over the cost ceiling: versatile ceiling downgrades the implicit pick', () => { + const catalog = buildCatalogCategoryMap(); + for (const taskType of HF_TASKS) { + const { result, label } = resolveWith({ taskType, profile: HF, sessionCostPolicy: { maxCategory: 'versatile' } }); + expect(result.model, label).not.toBe(HF); + expect(['lightweight', 'versatile'], label).toContain(catalog.get(result.model)); + } + }); + + it('an explicit hydrafusion over a versatile ceiling falls forward only within the ceiling', () => { + const catalog = buildCatalogCategoryMap(); + const { result, label } = resolveWith({ + sessionModel: HF, + unavailableModels: [HF], + sessionCostPolicy: { maxCategory: 'versatile' }, + }); + expect(result.policy?.action, label).toBe('warn-allow-explicit'); + if (!result.omitModel) { + expect(['lightweight', 'versatile'], label).toContain(catalog.get(result.model)); + } + expect(result.model, label).not.toBe(HF); + }); + + it('no candidate left → model omitted', () => { + const { result, label } = resolveWith({ taskType: 'review', profile: HF, availableModels: ['some-other-model'] }); + expect(result.omitModel, label).toBe(true); + expect(result.model, label).toBe(''); + }); +}); + +describe('persistent hydrafusion profile via config (#2014)', () => { + let squadDir: string; + beforeEach(() => { + squadDir = mkdtempSync(path.join(os.tmpdir(), 'squad-hf-profile-')); + }); + afterEach(() => { + rmSync(squadDir, { recursive: true, force: true }); + }); + + it("modelProfile 'hydrafusion' round-trips through config.json", () => { + writeFileSync(path.join(squadDir, 'config.json'), JSON.stringify({ version: 1 })); + expect(() => writeModelProfile(squadDir, HF as never), `writeModelProfile('${HF}')`).not.toThrow(); + expect(readModelProfile(squadDir), `readModelProfile after writing '${HF}'`).toBe(HF); + expect(adapterResolveModel({ squadDir }), 'adapter default under hydrafusion profile').toBe(MODELS.FALLBACK_CHAINS.standard[0]); + }); +}); + +describe('spawns through AgentLifecycleManager.createSession (#2014)', () => { + let tempDir: string; + let squadDir: string; + let sessionConfigs: Array>; + let manager: AgentLifecycleManager; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'squad-hf-spawn-')); + squadDir = path.join(tempDir, '.squad'); + for (const agent of ['auto-agent', 'scribe']) { + const agentDir = path.join(squadDir, 'agents', agent); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, 'charter.md'), + `# ${agent}\n\n## Identity\n\n**Name:** ${agent}\n**Role:** Test\n\n## Model\n\n**Preferred:** auto\n`, + 'utf-8', + ); + } + sessionConfigs = []; + const client = { + async createSession(config: Record) { + sessionConfigs.push(config); + return { sessionId: `s-${sessionConfigs.length}`, async sendMessage() {}, async close() {}, on() {}, off() {} }; + }, + async deleteSession() {}, + } as unknown as SquadClientWithPool; + manager = new AgentLifecycleManager({ client, teamRoot: tempDir, defaultIdleTimeout: 60_000 }); + }); + + afterEach(async () => { + await manager.shutdown(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('a session model of hydrafusion is inherited by every spawn, including Scribe', async () => { + const lead = await manager.spawnAgent({ agentName: 'auto-agent', task: 'Review', taskType: 'review', sessionModel: HF }); + const scribe = await manager.spawnAgent({ agentName: 'scribe', task: 'Log', taskType: 'mechanical', sessionModel: HF }); + expect(sessionConfigs[0]?.model, `createSession for review with sessionModel '${HF}': ${JSON.stringify(sessionConfigs[0])}`).toBe(HF); + expect(lead.modelResolution?.source, JSON.stringify(lead.modelResolution)).toBe('session'); + expect(sessionConfigs[1]?.model, `createSession for scribe with sessionModel '${HF}': ${JSON.stringify(sessionConfigs[1])}`).toBe(HF); + expect(scribe.modelResolution?.source, JSON.stringify(scribe.modelResolution)).toBe('session'); + }); + + it("persistent modelProfile 'hydrafusion' sends reviews to hydrafusion and keeps Scribe on fast", async () => { + await fs.writeFile(path.join(squadDir, 'config.json'), JSON.stringify({ version: 1, modelProfile: HF }), 'utf-8'); + await manager.spawnAgent({ agentName: 'auto-agent', task: 'Review', taskType: 'review' }); + await manager.spawnAgent({ agentName: 'scribe', task: 'Log', taskType: 'mechanical' }); + expect(sessionConfigs[0]?.model, `createSession for review with modelProfile '${HF}': ${JSON.stringify(sessionConfigs[0])}`).toBe(HF); + expect(sessionConfigs[1]?.model, `createSession for scribe with modelProfile '${HF}': ${JSON.stringify(sessionConfigs[1])}`).toBe( + MODELS.FALLBACK_CHAINS.fast[0], + ); + }); + + it('session profile hydrafusion with hydrafusion absent from the live catalog dispatches the premium chain head', async () => { + const live = [...MODELS.FALLBACK_CHAINS.premium]; + const handle = await manager.spawnAgent({ + agentName: 'auto-agent', task: 'Architect', taskType: 'architecture', sessionProfile: HF as never, availableModels: live, + }); + expect(sessionConfigs[0]?.model, `createSession with live ${JSON.stringify(live)}: ${JSON.stringify(sessionConfigs[0])}`).toBe(live[0]); + expect(handle.modelResolution?.fallbackFrom, JSON.stringify(handle.modelResolution)).toEqual({ model: HF, source: 'task-auto' }); + }); +}); diff --git a/test/model-preference.test.ts b/test/model-preference.test.ts index 4336de240..081158248 100644 --- a/test/model-preference.test.ts +++ b/test/model-preference.test.ts @@ -1384,7 +1384,8 @@ describe('availability fallback respects the active cost ceiling (#1720)', () => } it('without a cost policy the out-of-catalog availability fallback is unchanged', () => { - const opts: ModelResolutionOptions = { sessionModel: 'hydrafusion', taskType: 'code', unavailableModels: ['hydrafusion'] }; + // 'hydrafusion' is a catalog entry since #2014; use a truly out-of-catalog id. + const opts: ModelResolutionOptions = { sessionModel: 'custom-preview-model', taskType: 'code', unavailableModels: ['custom-preview-model'] }; const r = resolveAgentModel(opts); expect({ model: r.model, source: r.source }, `${JSON.stringify(opts)} picked '${r.model}'`).toEqual({ model: standard[0], source: 'fallback' }); }); diff --git a/test/model-quality-profiles.test.ts b/test/model-quality-profiles.test.ts index 0e1a6078a..5593d73fc 100644 --- a/test/model-quality-profiles.test.ts +++ b/test/model-quality-profiles.test.ts @@ -177,7 +177,8 @@ describe('catalog, constants and config defaults agree on public GA ids (#1598)' 'MODELS.FALLBACK_CHAINS': Object.values(MODELS.FALLBACK_CHAINS).flat(), DEFAULT_FALLBACK_CHAINS: Object.values(DEFAULT_FALLBACK_CHAINS).flat(), 'DEFAULT_CONFIG.models.tiers': Object.values(DEFAULT_CONFIG.models.tiers).flat(), - MODEL_CATALOG: MODEL_CATALOG.map((m) => m.id), + // Preview ids are allowed in the catalog only as opt-in entries (#2014). + 'MODEL_CATALOG (non-preview)': MODEL_CATALOG.filter((m) => m.preview !== true).map((m) => m.id), ECONOMY_MODEL_MAP: Object.entries(ECONOMY_MODEL_MAP).flat(), 'MODELS defaults': [MODELS.DEFAULT, MODELS.SELECTOR_DEFAULT, MODELS.NUCLEAR_FALLBACK], }; @@ -190,6 +191,14 @@ describe('catalog, constants and config defaults agree on public GA ids (#1598)' }); } + it('preview catalog entries are only the opt-in HydraFusion ids and never internal ids', () => { + const preview = MODEL_CATALOG.filter((m) => m.preview === true).map((m) => m.id); + for (const id of preview) { + expect(['hydrafusion', 'hydrafusion-max'], `MODEL_CATALOG preview entry '${id}' is not an approved opt-in id`).toContain(id); + } + expect(MODEL_CATALOG.map((m) => m.id), "MODEL_CATALOG contains internal id 'gpt-5.6-sol-fast'").not.toContain('gpt-5.6-sol-fast'); + }); + it('runtime and config fallback chains are identical', () => { expect(DEFAULT_FALLBACK_CHAINS, 'DEFAULT_FALLBACK_CHAINS vs MODELS.FALLBACK_CHAINS').toEqual(MODELS.FALLBACK_CHAINS); expect(DEFAULT_CONFIG.models.tiers, 'DEFAULT_CONFIG.models.tiers vs MODELS.FALLBACK_CHAINS').toEqual(MODELS.FALLBACK_CHAINS); diff --git a/test/models.test.ts b/test/models.test.ts index f24048b3e..2c976d841 100644 --- a/test/models.test.ts +++ b/test/models.test.ts @@ -23,8 +23,10 @@ describe('MODEL_CATALOG', () => { expect(fastModels.length).toBeGreaterThan(0); }); - it('marks every catalog model as vision-capable', () => { - expect(MODEL_CATALOG.every(model => model.vision === true)).toBe(true); + it('marks every GA catalog model as vision-capable', () => { + // Preview opt-in entries (#2014) do not claim capabilities that are not documented. + const notVision = MODEL_CATALOG.filter(model => model.preview !== true && model.vision !== true).map(model => model.id); + expect(notVision, `GA catalog models without vision: ${JSON.stringify(notVision)}`).toEqual([]); }); it('includes expected premium models', () => { From 82dea6899eb27d8e170ea906c52c5b3b5a1d7ef9 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 15 Oct 2026 09:09:04 -0700 Subject: [PATCH 7/9] docs: update model-selection guide for quality profiles and HydraFusion opt-in (#2225) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5958f622-3345-44b2-b0ac-f0394b732686 --- README.md | 2 + .../content/docs/features/model-selection.md | 174 +++++++++++++----- 2 files changed, 129 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 8f41f05f4..a564a4f24 100644 --- a/README.md +++ b/README.md @@ -430,6 +430,8 @@ When agents finish, the coordinator records follow-up work and leaves a breadcru **And it's all in git.** Anyone who clones your repo gets the team — with all their accumulated knowledge. +**Each agent gets the right model.** Squad picks models quality-first by default: premium for architecture, research and review, standard for code and docs, fast for mechanical work. Opt in to the `balanced` or `economy` profile, or to the `hydrafusion` research preview with `"modelProfile": "hydrafusion"` in `.squad/config.json`. Per-agent selection works in the Copilot CLI and app; VS Code runs every agent on the one session model. See [Model Selection](https://bradygaster.github.io/squad/docs/features/model-selection/). + --- ## What Gets Created diff --git a/docs/src/content/docs/features/model-selection.md b/docs/src/content/docs/features/model-selection.md index af4e18ddb..55fe566f9 100644 --- a/docs/src/content/docs/features/model-selection.md +++ b/docs/src/content/docs/features/model-selection.md @@ -1,6 +1,6 @@ --- title: Per-Agent Model Selection -description: Route each agent to the right model based on task type, with persistent overrides and economy mode. +description: Route each agent to the right model based on task type, with quality profiles, persistent overrides, economy mode, and opt-in HydraFusion. order: 34 --- @@ -31,31 +31,68 @@ Use Sonnet for code, Haiku for everything else Switch back to automatic model selection ``` -Squad adjusts model selection based on your directive. Agents writing code get quality models (Sonnet/Opus), agents doing docs/logs get cost-optimized models (Haiku). You can override anytime — and persistent overrides survive across sessions. +**Try this to opt in to HydraFusion (research preview):** +``` +Use the hydrafusion profile for this session +``` + +Squad adjusts model selection based on your directive. By default, Squad picks for quality: architecture, research, review, security and design work gets premium models, code and docs get standard models, and only mechanical work (Scribe, git, release) gets fast models. You can override anytime — and persistent overrides survive across sessions. --- ## How It Works -Squad routes each agent to the right model based on what they're doing — not a one-size-fits-all default. The governing principle: **cost first, unless code is being written** — but your preferences always take priority. +Squad routes each agent to the right model based on what they're doing — not a one-size-fits-all default. The governing principle: **quality first** — pick the tier that gives the best result for the task, so you don't pay for reruns caused by an underpowered model. Your preferences always take priority, and cost-first selection is one opt-in profile away. + +Every spawn shows the resolved model, where it came from (its **source**) and a short **rationale**: + +``` +📋 Scribe (claude-haiku-5.5 · task-auto) — logging session — mechanical task → fast tier +``` -## 5-Layer Model Resolution +## Model Resolution Layers Model selection uses a layered system. First match wins: -1. **Persistent Config** (`.squad/config.json`) — You said "always use opus"? It's saved to disk. Every session, every agent, until you change it. Per-agent overrides (`agentModelOverrides`) take priority over the global `defaultModel`. -2. **Session Directive** — You said "use opus for this session"? Done. Applies until the session ends. -3. **Charter Preference** — The agent's charter specifies a `## Model` section with a preferred model. -4. **Task-Aware Auto-Selection** — The coordinator checks what the agent is actually doing: +1. **User Override** — You said "use opus for this task"? That one spawn uses it. It doesn't persist or change the session model. +2. **Persistent Config** (`.squad/config.json`) — You said "always use opus"? It's saved to disk. Every session, every agent, until you change it. Per-agent overrides (`agentModelOverrides`) take priority over the global `defaultModel`. +3. **Session Model** — You said "use opus for this session", or picked a model for the session? Every sub-agent spawn inherits it until the session ends. +4. **Charter Preference** — The agent's charter specifies a `## Model` section with a preferred model. +5. **Task-Aware Auto-Selection** — The coordinator checks what the agent is actually doing and picks a tier under the active [model profile](#model-profiles). +6. **Default** — If nothing matched, the head of the standard chain (`claude-sonnet-5.5`). Under the `economy` profile, `gpt-5.6-luna`. + +`auto` at any layer means "no preference here" — Squad moves on to the next layer. If the chosen model is unavailable, Squad [falls back](#fallback-chains) and the source becomes `fallback`. + +## Model Profiles + +The profile changes only task-aware auto-selection and the default. It never replaces a model you picked (user override, config, session or charter). `quality-first` is the default; the others are opt-in. -| Task Output | Model | Tier | -|-------------|-------|------| -| Writing code (implementation, refactoring, tests, bug fixes) | `gpt-5.6-terra` | Standard | -| Writing prompts or agent designs | `gpt-5.6-terra` | Standard | -| Non-code work (docs, planning, triage, changelogs) | `gpt-5.6-luna` | Fast | -| Visual/design work requiring image analysis | `gpt-5.6-sol` | Premium | +| Profile | When it's active | Intent | +|---------|------------------|--------| +| `quality-first` | Default | Best result for the task; premium for quality-sensitive work | +| `balanced` | Opt-in | Premium for review-style gates; research stays standard, planning drops to fast | +| `economy` | Opt-in (`"modelProfile": "economy"` or `"economyMode": true`) | Cost first, then the [economy map](#economy-mode) | +| `hydrafusion` | Opt-in, research preview | Quality-sensitive work tries [HydraFusion](#hydrafusion-research-preview) first; everything else follows `quality-first` | -5. **Default** — If nothing matched, `gpt-5.6-luna`. Cost wins when in doubt. +Task-aware tiers per GA profile: + +| Task type | `quality-first` | `balanced` | `economy` | +|-----------|-----------------|------------|-----------| +| Architecture, verification, review, security, design, visual | Premium | Premium | Premium → economy map | +| Research | Premium | Standard | Fast | +| Writing code, prompts or agent designs | Standard | Standard | Standard → economy map | +| Docs, writing | Standard | Standard | Fast | +| Planning, triage | Standard | Fast | Fast | +| Mechanical ops (Scribe, git, release, changelogs) | Fast | Fast | Fast | + +Each tier resolves to the head of its [fallback chain](#fallback-chains): `claude-opus-5.5` (premium), `claude-sonnet-5.5` (standard) or `claude-haiku-5.5` (fast). + +**Set a profile:** + +- `"use the balanced profile"` / `"always use balanced"` — saves `modelProfile` to `.squad/config.json` +- `"use the economy profile for this session"` — session only; config is not changed + +Profile precedence: session profile → `economyMode: true` → persistent `modelProfile` → `quality-first`. Cost ceilings still apply under every profile. ## Persistent Model Preferences @@ -64,56 +101,63 @@ Squad stores your model preferences in `.squad/config.json`: ```json { "version": 1, - "defaultModel": "gpt-5.6-terra", + "defaultModel": "claude-opus-5.5", + "modelProfile": "balanced", "agentModelOverrides": { - "fenster": "gpt-5.6-terra", - "mcmanus": "gpt-5.6-luna" + "fenster": "gpt-5.3-codex", + "mcmanus": "claude-haiku-5.5" } } ``` - **`defaultModel`** — applies to ALL agents unless overridden. Set with "always use X". - **`agentModelOverrides`** — per-agent overrides. Set with "use X for {agent}". +- **`modelProfile`** — `quality-first` (default), `balanced`, `economy` or `hydrafusion`. Affects only task-aware selection and the default. - **Clear with** "switch back to automatic" — removes `defaultModel`, returns to auto-selection. -## Role-to-Model Mapping +## Role-to-Tier Mapping -| Role | Default Model | Why | +Under the default `quality-first` profile: + +| Role | Default Tier | Why | |------|--------------|-----| -| Core Dev / Backend / Frontend | `gpt-5.6-terra` | Writes code — quality first | -| Tester / QA | `gpt-5.6-terra` | Writes test code | -| Lead / Architect | auto (per-task) | Mixed: code review vs. planning | -| Prompt Engineer | auto (per-task) | Prompt design is like code | -| DevRel / Writer | `gpt-5.6-luna` | Docs — not code | -| Scribe / Logger | `gpt-5.6-luna` | Mechanical file ops | -| Git / Release | `gpt-5.6-luna` | Changelogs, tags, version bumps | -| Designer / Visual | `gpt-5.6-sol` | Vision capability required | +| Core Dev / Backend / Frontend | Standard | Writes code — quality first | +| Tester / QA | Standard | Writes test code; verification gates go premium | +| Lead / Architect | auto (per-task) | Architecture and review → premium; planning/triage → standard | +| Prompt Engineer | auto (per-task) | Prompt design is like code; research → premium | +| DevRel / Writer | Standard | Language-heavy work benefits from a stronger model | +| Scribe / Logger | Fast | Mechanical file ops | +| Git / Release | Fast | Changelogs, tags, version bumps | +| Designer / Visual | Premium | Vision capability required | + +## Model Catalog -## 17-Model Catalog +Squad recommends only public GA models from the [GitHub Copilot supported models](https://docs.github.com/en/copilot/reference/ai-models/supported-models) table, placed into three tiers: -Squad supports 17 models across three tiers: +- **Premium:** claude-opus-5.5, gpt-6.1-sol, claude-opus-5, gpt-6-sol, claude-opus-4.8, gpt-5.6-sol, gemini-4-argon +- **Standard:** claude-sonnet-5.5, gpt-5.6-terra, claude-sonnet-5, gpt-5.5, gpt-5.4, gpt-5.3-codex, grok-4.7, grok-4.6 +- **Fast/Cheap:** claude-haiku-5.5, gpt-6-luna, gpt-5.6-luna, gemini-3.8-flash, claude-haiku-4.5, gpt-5.4-mini, gpt-5-mini, mai-code-1.1-flash -- **Premium:** gpt-5.6-sol, claude-opus-5, claude-opus-4.8, claude-opus-4.7, claude-opus-4.6 -- **Standard:** gpt-5.6-terra, claude-sonnet-5, claude-sonnet-4.6, claude-sonnet-4.5, gpt-5.5, gpt-5.4, gpt-5.3-codex, gemini-3.1-pro -- **Fast/Cheap:** gpt-5.6-luna, claude-haiku-4.5, gpt-5.4-mini, gpt-5-mini +The research-preview `hydrafusion` and `hydrafusion-max` models are in the catalog but in no fallback chain or GA profile — see [HydraFusion](#hydrafusion-research-preview). Any other model you name explicitly is passed through unchanged. ## Fallback Chains -If a model is unavailable (plan restriction, rate limit, deprecation), Squad silently retries with the next in chain: +If a model is unavailable (plan restriction, org policy, rate limit, deprecation), Squad silently retries with the next model in the chain: ``` -Premium: gpt-5.6-sol → claude-opus-5 → claude-opus-4.8 → claude-opus-4.7 → claude-opus-4.6 → claude-sonnet-4.6 -Standard: gpt-5.6-terra → claude-sonnet-5 → claude-sonnet-4.6 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4.5 → gemini-3.1-pro -Fast: gpt-5.6-luna → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini +Premium: claude-opus-5.5 → gpt-6.1-sol → claude-opus-5 → gpt-6-sol → claude-opus-4.8 → gpt-5.6-sol +Standard: claude-sonnet-5.5 → gpt-5.6-terra → claude-sonnet-5 → gpt-5.5 → gpt-5.4 → gpt-5.3-codex +Fast: claude-haiku-5.5 → gpt-6-luna → gpt-5.6-luna → gemini-3.8-flash → claude-haiku-4.5 → gpt-5.4-mini → gpt-5-mini ``` -Never falls back UP in tier — a fast task won't land on a premium model. +Fallback only moves forward — never back toward the head of the chain, and never UP in tier — a fast task won't land on a premium model. If no candidate is left, Squad omits the model and lets the platform choose. ## User Overrides Tell the coordinator what you want: -- `"use opus for this"` — one-off premium for current task +- `"use opus for this"` — one-off model for the current task (user override) +- `"use opus for this session"` — session model, inherited by every sub-agent spawn - `"always use opus"` — **persistent** preference saved to `.squad/config.json` (survives sessions) - `"use gpt-5.3-codex for Fenster"` — **persistent** per-agent override - `"switch back to automatic"` — clears persistent preference @@ -132,18 +176,21 @@ Switch to economy mode Turn off economy mode ``` -When economy mode is active, Squad remaps models using the `ECONOMY_MODEL_MAP`: +Economy mode is the opt-in `economy` [profile](#model-profiles). Research, docs and planning drop to the fast tier, then Squad remaps models using the `ECONOMY_MODEL_MAP`: -| Normal Tier | Economy Model | -|-------------|--------------| -| Standard (Terra) | `gpt-5.6-luna` | -| Fast (Luna) | `gpt-5.6-luna` | +| Normal Model | Economy Model | +|--------------|---------------| +| `claude-opus-5.5` (premium) | `claude-sonnet-5.5` | +| `claude-opus-5`, `claude-opus-4.8` (premium) | `claude-sonnet-5` | +| `gpt-6.1-sol`, `gpt-6-sol`, `gpt-5.6-sol` (premium) | `gpt-5.6-terra` | +| `claude-sonnet-5.5`, `claude-sonnet-5`, `gpt-5.6-terra` (standard) | `gpt-5.6-luna` | +| `claude-haiku-5.5`, `claude-haiku-4.5` (fast) | `gpt-5.6-luna` | -**Fallback chains in economy mode** run the same logic as normal fallback chains, but start one tier lower. A code task that would normally use `gpt-5.6-terra` uses `gpt-5.6-luna` instead. +A code task that would normally use `claude-sonnet-5.5` uses `gpt-5.6-luna` instead, and the default becomes `gpt-5.6-luna`. Spawn rationales say `(economy profile)` so the downgrade is always visible. Economy mode never changes a model you picked explicitly. **Cost tradeoffs:** Economy mode trades output quality for lower cost and reduced rate limit pressure. Use it for bulk triage, log analysis, or changelog generation — not for architecture work or complex refactors where quality matters. -**Persistent economy mode** saves to `.squad/config.json`: +**Persistent economy mode** saves to `.squad/config.json` (`"modelProfile": "economy"` works too; an explicit `"economyMode": false` cancels a persisted `economy` profile): ```json { "version": 1, @@ -153,6 +200,33 @@ When economy mode is active, Squad remaps models using the `ECONOMY_MODEL_MAP`: Economy mode is also triggered automatically by the [rate limiting](rate-limiting.md) system when headroom drops to Amber state — you do not have to enable it manually for rate limit protection. +## HydraFusion (Research Preview) + +[HydraFusion](https://github.blog/changelog/2026-09-30-hydrafusion-in-vs-code-and-the-github-copilot-app/) is a GitHub Copilot model that orchestrates multiple models within a turn. It is a **research preview** and is subject to change, so Squad never uses it by default: `hydrafusion` and `hydrafusion-max` are in no fallback chain and no GA profile. Squad only passes the model ID; Copilot runs the orchestration. + +**Requirements:** HydraFusion is available to Copilot Pro, Pro+, Business and Enterprise users. For Copilot Business and Enterprise, an administrator must enable preview features in organization or enterprise settings. Your client may also need HydraFusion turned on (for example, the HydraFusion setting in the GitHub Copilot app). See the [GitHub changelog](https://github.blog/changelog/2026-09-30-hydrafusion-in-vs-code-and-the-github-copilot-app/) for current details. + +**Opt in with the profile** — persistently in `.squad/config.json`: + +```json +{ + "version": 1, + "modelProfile": "hydrafusion" +} +``` + +…or for one session: `"use the hydrafusion profile for this session"`. Architecture, research, verification, review, security and design tasks then resolve to `hydrafusion`, with the quality-first premium chain behind it. Visual work, code, docs, planning and other tasks follow `quality-first` unchanged; Scribe and mechanical ops stay on the fast tier, and reviewer gates still run. + +**Opt in with a model** — pick `hydrafusion` as your session model, `defaultModel`, an `agentModelOverrides` entry or a one-off override. It's honored like any explicit model, and a session model is inherited by every sub-agent spawn. + +**`hydrafusion-max`** is used only when you name it explicitly (one-off override, config or session model). Squad never picks it on its own, not even under the `hydrafusion` profile. + +**Fallback:** if HydraFusion is unavailable (your plan, org policy, or preview features turned off), Squad falls forward to the next GA premium model — the head of the premium chain, `claude-opus-5.5` — within any cost ceiling. It never retries HydraFusion. + +## VS Code: One Session Model + +In VS Code, every agent runs on the single session model you pick in the Copilot model picker. Per-agent selection — task-aware tiers, profiles (including `hydrafusion`), charter preferences and per-agent overrides — applies only where Squad passes a model per spawn: the Copilot CLI and the GitHub Copilot app. To use HydraFusion for everything in VS Code, pick it in the model picker. See [VS Code](vscode.md) for details. + ## Sample Prompts ``` @@ -165,7 +239,13 @@ Override to premium model for a single high-stakes task. always use haiku to save costs ``` -Set session-wide preference for the cheapest model tier. +Set a persistent preference for a fast-tier model. + +``` +always use the hydrafusion profile +``` + +Opt in to HydraFusion for quality-sensitive work (research preview). ``` what model did Kane use for that last task? From 26054dd86c776ca33d985fe642bf3f566b6c90cb Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 15 Oct 2026 09:10:59 -0700 Subject: [PATCH 8/9] fix(sdk): address PR #2225 review findings (fallback chain, omitModel, preview recs, economy profile) - Normalize resolveModel fallbackChain: availability-filtered and forward-only from the selected model; out-of-chain picks keep head-first fallback. - ModelFallbackExecutor.execute handles omitModel via an optional platformDefault callback; never calls fn with an empty model id. - ModelRegistry.getRecommendedModels excludes preview entries. - readEconomyMode recognizes modelProfile 'economy'; economy off removes it. - AgentLifecycleManager: a profile-picked preview model that is unavailable or rejected by createSession falls forward to the premium chain with a single user notice instead of failing the spawn. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5958f622-3345-44b2-b0ac-f0394b732686 --- .changeset/central-model-resolver.md | 2 +- .changeset/hydrafusion-opt-in.md | 2 +- .changeset/quality-model-profiles.md | 2 +- packages/squad-sdk/src/agents/index.ts | 1 + packages/squad-sdk/src/agents/lifecycle.ts | 70 ++++++++++++-- .../squad-sdk/src/agents/model-selector.ts | 47 +++++++++- packages/squad-sdk/src/config/models.ts | 13 ++- test/agents.test.ts | 7 +- test/economy-mode.test.ts | 28 ++++++ test/model-fallback.test.ts | 82 ++++++++++++++++ test/model-hydrafusion.test.ts | 93 ++++++++++++++++++- 11 files changed, 327 insertions(+), 20 deletions(-) diff --git a/.changeset/central-model-resolver.md b/.changeset/central-model-resolver.md index af7ee16af..f166b26df 100644 --- a/.changeset/central-model-resolver.md +++ b/.changeset/central-model-resolver.md @@ -3,6 +3,6 @@ "@bradygaster/squad-cli": patch --- -Central model resolver (#1720): `resolveAgentModel` (an alias of `resolveModel`) from `@bradygaster/squad-sdk/agents` adds `config`, `session` and `fallback` sources, applies the exported `MODEL_RESOLUTION_PRECEDENCE` (user-override > config > session > charter > task-auto > default), and returns a `rationale` for every choice (typed as `ModelResolution`; `ResolvedModel.rationale` stays optional so consumer-built values remain valid). `auto` (any case) means "no preference" at every layer, including a per-spawn override. A session-picked model is inherited by every sub-agent spawn with source `session`. When a model listed in `unavailableModels` is in its tier chain, fallback moves only forward to the next available entry; models outside the chain search it from the head; with no candidate left, `omitModel` is set. +Central model resolver (#1720): `resolveAgentModel` (an alias of `resolveModel`) from `@bradygaster/squad-sdk/agents` adds `config`, `session` and `fallback` sources, applies the exported `MODEL_RESOLUTION_PRECEDENCE` (user-override > config > session > charter > task-auto > default), and returns a `rationale` for every choice (typed as `ModelResolution`; `ResolvedModel.rationale` stays optional so consumer-built values remain valid). `auto` (any case) means "no preference" at every layer, including a per-spawn override. A session-picked model is inherited by every sub-agent spawn with source `session`. When a model listed in `unavailableModels` is in its tier chain, fallback moves only forward to the next available entry; models outside the chain search it from the head; with no candidate left, `omitModel` is set. The returned `fallbackChain` lists only available entries, starting at the selected model when it is in its chain, so retries never move back toward the head. `ModelFallbackExecutor.execute` accepts an optional `{ platformDefault }` callback that runs model-less when the resolution has `omitModel`; it never calls the model callback with an empty id. The root `resolveModel` export (string-returning, reads `.squad/config.json`) is now a compatible adapter over the central resolver, so it no longer returns a literal `auto`. `AgentLifecycleManager.spawnAgent` now applies persistent `.squad/config.json` preferences (`agentModelOverrides`, `defaultModel`, `economyMode`) and records provenance on `AgentHandle.modelResolution`, OTel span attributes and an `agent:milestone` `model.resolved` event. New helpers: `isModelPreference`, `persistentConfigModel`. Coordinator spawn guidance and `model-selection-reference.md` no longer hard-code model IDs; spawn acknowledgments show model, source and rationale, with catalog IDs kept in a separate appendix. `AgentLifecycleManager.spawnAgent` accepts `sessionModel` and `unavailableModels`; an exhausted chain omits `model` from the session config. Under a cost policy, an explicit over-ceiling pick keeps the forward-only fallback cursor. Availability fallback also honors the active cost ceiling (`config.costPolicy` or `sessionCostPolicy`): catalog-known candidates above it are skipped, so an unavailable out-of-catalog pick (for example `hydrafusion`) falls back only to an in-ceiling entry or omits `model`; an available explicit pick still passes through unchanged. diff --git a/.changeset/hydrafusion-opt-in.md b/.changeset/hydrafusion-opt-in.md index 4dd5c6c5b..493ee3e1a 100644 --- a/.changeset/hydrafusion-opt-in.md +++ b/.changeset/hydrafusion-opt-in.md @@ -2,4 +2,4 @@ "@bradygaster/squad-sdk": minor --- -Add opt-in HydraFusion support (#2014). `hydrafusion` and `hydrafusion-max` are catalog entries marked `preview` (premium tier) and are excluded from every default chain and GA profile. A new opt-in `hydrafusion` quality profile sends architecture, research, verification, review, security and design tasks to `hydrafusion` first, then the quality-first premium chain; Scribe and mechanical work stay on the fast tier. `hydrafusion-max` is used only when named explicitly. If HydraFusion is unavailable, resolution falls forward to the premium chain within the cost ceiling. +Add opt-in HydraFusion support (#2014). `hydrafusion` and `hydrafusion-max` are catalog entries marked `preview` (premium tier) and are excluded from every default chain and GA profile. A new opt-in `hydrafusion` quality profile sends architecture, research, verification, review, security and design tasks to `hydrafusion` first, then the quality-first premium chain; Scribe and mechanical work stay on the fast tier. `hydrafusion-max` is used only when named explicitly. If HydraFusion is unavailable (live model list, or `createSession` rejecting a profile-picked `hydrafusion`, for example under org policy), `AgentLifecycleManager.spawnAgent` falls forward to the premium chain within the cost ceiling instead of failing the spawn, and tells the user once (console warning plus an `agent:milestone` `model.unavailable` event). `ModelRegistry.getRecommendedModels` never recommends preview entries; they stay available through explicit catalog lookup. diff --git a/.changeset/quality-model-profiles.md b/.changeset/quality-model-profiles.md index 057f51288..5ef4e25b4 100644 --- a/.changeset/quality-model-profiles.md +++ b/.changeset/quality-model-profiles.md @@ -2,7 +2,7 @@ "@bradygaster/squad-sdk": minor --- -Quality-profile and public-catalog aware model selection (#1598). Task-aware selection now runs under a quality profile: `quality-first` (the new default), `balanced` or `economy` (explicit opt-in). Under `quality-first`, architecture, research, verification, review, security, design and visual work use the premium tier; code, prompts, docs, writing and planning use standard; mechanical work (Scribe, git, release) stays fast. The `default` layer now uses the head of the standard chain, or the economy default under `economy`. The profile comes from a session profile (`sessionProfile`), `economyMode: true`, or the persistent `.squad/config.json` `modelProfile` (new `readModelProfile` / `writeModelProfile`, `isModelSelectionProfile`, `MODEL_SELECTION_PROFILES`, `PROFILE_TASK_TIERS`, `resolveModelProfile`). `ResolvedModel.profile`, rationales, the `model.profile` span attribute and the `model.resolved` event report the active profile. Profiles never change explicit user-override, config, session or charter models, and cost ceilings still apply. `economyMode` stays compatible: an explicit `economyMode: false` cancels a persisted `economy` profile. +Quality-profile and public-catalog aware model selection (#1598). Task-aware selection now runs under a quality profile: `quality-first` (the new default), `balanced` or `economy` (explicit opt-in). Under `quality-first`, architecture, research, verification, review, security, design and visual work use the premium tier; code, prompts, docs, writing and planning use standard; mechanical work (Scribe, git, release) stays fast. The `default` layer now uses the head of the standard chain, or the economy default under `economy`. The profile comes from a session profile (`sessionProfile`), `economyMode: true`, or the persistent `.squad/config.json` `modelProfile` (new `readModelProfile` / `writeModelProfile`, `isModelSelectionProfile`, `MODEL_SELECTION_PROFILES`, `PROFILE_TASK_TIERS`, `resolveModelProfile`). `ResolvedModel.profile`, rationales, the `model.profile` span attribute and the `model.resolved` event report the active profile. Profiles never change explicit user-override, config, session or charter models, and cost ceilings still apply. `economyMode` stays compatible: an explicit `economyMode: false` cancels a persisted `economy` profile. `readEconomyMode` (and `squad economy`) also reports enabled for `modelProfile: "economy"`, and `writeEconomyMode(dir, false)` (`squad economy off`) also removes a persisted `economy` profile, leaving other profiles unchanged. `resolveAgentModel` and `AgentLifecycleManager.spawnAgent` accept `availableModels`, the runtime's live model list. When supplied, it is authoritative for availability: a missing recommended model falls forward through its chain, and a missing explicit model falls back visibly. diff --git a/packages/squad-sdk/src/agents/index.ts b/packages/squad-sdk/src/agents/index.ts index 4e0959a86..aeb207ab0 100644 --- a/packages/squad-sdk/src/agents/index.ts +++ b/packages/squad-sdk/src/agents/index.ts @@ -90,6 +90,7 @@ export { type ModelTier, type ModelResolutionSource, type FallbackExecutorConfig, + type FallbackExecuteOptions, type FallbackAttempt, type FallbackResult, } from './model-selector.js'; diff --git a/packages/squad-sdk/src/agents/lifecycle.ts b/packages/squad-sdk/src/agents/lifecycle.ts index 806db72e0..f7bceb6c0 100644 --- a/packages/squad-sdk/src/agents/lifecycle.ts +++ b/packages/squad-sdk/src/agents/lifecycle.ts @@ -18,7 +18,7 @@ import { type ModelResolutionSource, type TaskType, } from './model-selector.js'; -import { VALID_REASONING_EFFORTS, VALID_CONTEXT_TIERS, isModelSelectionProfile, type ModelSelectionProfile } from '../config/models.js'; +import { VALID_REASONING_EFFORTS, VALID_CONTEXT_TIERS, isModelSelectionProfile, isPreviewModel, type ModelSelectionProfile } from '../config/models.js'; import type { CostPolicyConfig, SessionCostPolicyOverride } from '../config/models.js'; import type { EventBus } from '../runtime/event-bus.js'; import { ConfigurationError, SessionLifecycleError } from '../adapter/errors.js'; @@ -197,6 +197,8 @@ export class AgentLifecycleManager { private costPolicy?: CostPolicyConfig; private eventBus?: EventBus; private agents: Map = new Map(); + /** Preview models already reported as unavailable (one notice per model). */ + private unavailablePreviewNotified: Set = new Set(); private idleCheckTimer: NodeJS.Timeout | null = null; constructor(config: LifecycleManagerConfig) { @@ -305,11 +307,14 @@ export class AgentLifecycleManager { availableModels, }; - const resolvedModel = resolveModel(modelOptions); + let resolvedModel = resolveModel(modelOptions); span.setAttribute('model.id', resolvedModel.model); span.setAttribute('model.source', resolvedModel.source); if (resolvedModel.profile) span.setAttribute('model.profile', resolvedModel.profile); await this.emitModelResolved(agentName, resolvedModel); + if (resolvedModel.fallbackFrom && isPreviewModel(resolvedModel.fallbackFrom.model)) { + await this.noticePreviewUnavailable(agentName, resolvedModel.fallbackFrom.model, resolvedModel); + } // Surface the cost-policy outcome — this was the #1089 bug: the policy // result was computed but never wired/emitted. Never swallow it. @@ -334,16 +339,35 @@ export class AgentLifecycleManager { const validTier = rawTier && rawTier !== 'auto' && (VALID_CONTEXT_TIERS as readonly string[]).includes(rawTier) ? rawTier as SquadContextTier : undefined; - const sessionConfig: SquadSessionConfig = { - ...(resolvedModel.omitModel ? {} : { model: resolvedModel.model }), + const buildSessionConfig = (resolved: ModelResolution): SquadSessionConfig => ({ + ...(resolved.omitModel ? {} : { model: resolved.model }), systemMessage: { content: agentConfig.prompt, }, ...(validEffort ? { reasoningEffort: validEffort } : {}), ...(validTier ? { contextTier: validTier } : {}), - }; + }); - const session = await this.client.createSession(sessionConfig); + let session: SquadSession; + try { + session = await this.client.createSession(buildSessionConfig(resolvedModel)); + } catch (createError) { + // A profile-picked preview model can be rejected at session creation + // (org policy, preview features off). Fall forward along its chain + // once instead of failing the spawn; explicit picks are not retried. + const rejected = resolvedModel.model; + if (resolvedModel.omitModel || resolvedModel.source !== 'task-auto' || !isPreviewModel(rejected)) { + throw createError; + } + resolvedModel = resolveModel({ + ...modelOptions, + unavailableModels: [...(unavailableModels ?? []), rejected], + }); + span.setAttribute('model.id', resolvedModel.model); + await this.emitModelResolved(agentName, resolvedModel); + await this.noticePreviewUnavailable(agentName, rejected, resolvedModel); + session = await this.client.createSession(buildSessionConfig(resolvedModel)); + } // Step 5: Create handle with event handlers const handle = new AgentHandleImpl({ @@ -520,6 +544,40 @@ export class AgentLifecycleManager { }); } + /** + * Tell the user, once per preview model per manager, that an opt-in preview + * model is unavailable and which model is used instead. Logged via + * console.warn and emitted as `agent:milestone` (`event: 'model.unavailable'`). + * @private + */ + private async noticePreviewUnavailable( + agentName: string, + previewModel: string, + resolved: ModelResolution, + ): Promise { + const key = previewModel.toLowerCase(); + if (this.unavailablePreviewNotified.has(key)) return; + this.unavailablePreviewNotified.add(key); + const replacement = resolved.omitModel ? 'the platform default model' : `'${resolved.model}'`; + const message = + `Preview model '${previewModel}' is not available (plan, org policy, or preview features); ` + + `using ${replacement} instead. This notice is shown once.`; + console.warn(`[squad:model] ${message}`); + if (this.eventBus) { + await this.eventBus.emit({ + type: 'agent:milestone', + agentName, + payload: { + event: 'model.unavailable', + model: previewModel, + replacement: resolved.omitModel ? null : resolved.model, + message, + }, + timestamp: new Date(), + }); + } + } + /** * Surface a cost-policy outcome (downgrade / warn-allow / fail-closed). * diff --git a/packages/squad-sdk/src/agents/model-selector.ts b/packages/squad-sdk/src/agents/model-selector.ts index 9049f77b8..b330e7df4 100644 --- a/packages/squad-sdk/src/agents/model-selector.ts +++ b/packages/squad-sdk/src/agents/model-selector.ts @@ -207,10 +207,26 @@ export function resolveModel(options: ModelResolutionOptions): ModelResolution { const finalized = policy && catalogMap ? finalizeResolvedModel(base, policy, catalogMap) : base; const ceiling = policy && catalogMap ? { maxCategory: policy.maxCategory, catalogMap } : undefined; const isUnavailable = buildAvailabilityCheck(options.unavailableModels, options.availableModels); - const resolved = applyAvailabilityFallback(finalized, isUnavailable, ceiling); + const resolved = normalizeFallbackChain(applyAvailabilityFallback(finalized, isUnavailable, ceiling), isUnavailable); return { ...resolved, profile, rationale: resolved.rationale ?? resolved.source }; } +/** + * Make the returned chain safe to retry in order: drop entries known to be + * unavailable and, when the selected model is in its chain, start the chain + * at that model so retries never move back toward the head. A model outside + * its chain keeps head-first fallback. + */ +function normalizeFallbackChain(resolved: ResolvedModel, isUnavailable?: (id: string) => boolean): ResolvedModel { + if (resolved.omitModel) return resolved; + const key = resolved.model.toLowerCase(); + const position = resolved.fallbackChain.findIndex((id) => id.toLowerCase() === key); + const forward = position > 0 ? resolved.fallbackChain.slice(position) : resolved.fallbackChain; + const fallbackChain = isUnavailable ? forward.filter((id) => !isUnavailable(id)) : forward; + if (position <= 0 && fallbackChain.length === resolved.fallbackChain.length) return resolved; + return { ...resolved, fallbackChain }; +} + /** * Effective quality profile: session profile, then legacy `economyMode: true`, * then the persistent profile, then `quality-first`. Unknown values are ignored. @@ -759,10 +775,13 @@ export interface FallbackAttempt { export interface FallbackResult { value: T; + /** Model that succeeded; empty when the platform default was used. */ model: string; tier: ModelTier; attempts: FallbackAttempt[]; didFallback: boolean; + /** True when the request ran without a `model` parameter (platform default). */ + omitModel?: boolean; } export interface FallbackExecutorConfig { @@ -770,6 +789,16 @@ export interface FallbackExecutorConfig { eventBus?: EventBus; } +/** Per-call options for {@link ModelFallbackExecutor.execute}. */ +export interface FallbackExecuteOptions { + /** + * Runs the request without a `model` parameter so the platform picks its + * default. Called instead of the model callback when the resolution has + * `omitModel` (every fallback candidate was unavailable). + */ + platformDefault?: () => Promise; +} + export class ModelFallbackExecutor { private allowCrossTier: boolean; private eventBus?: EventBus; @@ -784,9 +813,21 @@ export class ModelFallbackExecutor { resolved: ResolvedModel, agentName: string, fn: (model: string) => Promise, + options: FallbackExecuteOptions = {}, ): Promise> { const attempts: FallbackAttempt[] = []; const originalTier = resolved.tier; + if (resolved.omitModel) { + // Never send an empty model id: run model-less, or fail before calling fn. + if (!options.platformDefault) { + throw new Error( + `No available model for agent '${agentName}' (resolution has omitModel); ` + + `pass a platformDefault callback to run without a model parameter.`, + ); + } + const value = await options.platformDefault(); + return { value, model: '', tier: originalTier, attempts, didFallback: false, omitModel: true }; + } const candidates = this.buildCandidateList(resolved); for (const candidate of candidates) { @@ -825,8 +866,8 @@ export class ModelFallbackExecutor { const seen = new Set(); const result: string[] = []; const add = (m: string) => { if (!seen.has(m)) { seen.add(m); result.push(m); } }; - add(resolved.model); - for (const fb of resolved.fallbackChain) add(fb); + if (resolved.model) add(resolved.model); + for (const fb of resolved.fallbackChain) if (fb) add(fb); return result; } diff --git a/packages/squad-sdk/src/config/models.ts b/packages/squad-sdk/src/config/models.ts index 3da4b0ca9..2109697f1 100644 --- a/packages/squad-sdk/src/config/models.ts +++ b/packages/squad-sdk/src/config/models.ts @@ -599,9 +599,12 @@ export class ModelRegistry { */ getRecommendedModels(useCase: string, tier?: ModelTier): ModelInfo[] { const useCaseLower = useCase.toLowerCase(); - const candidates = tier + // Preview entries are opt-in only (#2014): never recommended implicitly, + // but still available through explicit catalog lookup. + const candidates = (tier ? this.getModelsByTier(tier) - : Array.from(this.catalog.values()); + : Array.from(this.catalog.values()) + ).filter(model => model.preview !== true); // Score models by use case match const scored = candidates @@ -783,7 +786,7 @@ export interface ModelPreferenceConfig { * Reads the economy mode setting from `.squad/config.json`. * * @param squadDir - Path to the `.squad/` directory - * @returns True if economyMode is enabled, false otherwise + * @returns True if `economyMode` is enabled or `modelProfile` is `economy`, false otherwise */ export function readEconomyMode(squadDir: string, storage: StorageProvider = new FSStorageProvider()): boolean { const configPath = join(squadDir, 'config.json'); @@ -796,7 +799,7 @@ export function readEconomyMode(squadDir: string, storage: StorageProvider = new const parsed = JSON.parse(raw); return parsed !== null && typeof parsed === 'object' && - parsed.economyMode === true; + (parsed.economyMode === true || parsed.modelProfile === 'economy'); } catch { return false; } @@ -827,6 +830,8 @@ export function writeEconomyMode(squadDir: string, enabled: boolean, storage: St config.economyMode = true; } else { delete config.economyMode; + // A persisted economy profile also selects economy; other profiles stay. + if (config.modelProfile === 'economy') delete config.modelProfile; } storage.writeSync(configPath, JSON.stringify(config, null, 2) + '\n'); diff --git a/test/agents.test.ts b/test/agents.test.ts index 0d86af51f..02180b12a 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -352,15 +352,16 @@ describe('Per-Agent Model Selection (M1-9)', () => { ]); }); - it('should classify catalog premium Sol with the premium fallback chain', () => { + it('should classify catalog premium Sol with the premium fallback chain, from its own position', () => { const result = resolveModel({ userOverride: 'gpt-5.6-sol', taskType: 'code', }); expect(result.tier).toBe('premium'); - expect(result.fallbackChain[0]).toBe('claude-opus-5.5'); - expect(result.fallbackChain).toContain('gpt-5.6-sol'); + // Retries are forward-only: never back toward the chain head (PR #2225). + expect(result.fallbackChain[0]).toBe('gpt-5.6-sol'); + expect(result.fallbackChain).not.toContain('claude-opus-5.5'); }); it('should provide standard fallback chain', () => { diff --git a/test/economy-mode.test.ts b/test/economy-mode.test.ts index 89854224b..2c2831baf 100644 --- a/test/economy-mode.test.ts +++ b/test/economy-mode.test.ts @@ -154,6 +154,34 @@ describe('writeEconomyMode', () => { writeEconomyMode(squadDir, false); expect(readEconomyMode(squadDir)).toBe(false); }); + + it("reports enabled for a persisted modelProfile 'economy' without economyMode", () => { + const config = { version: 1, modelProfile: 'economy' }; + writeFileSync(join(squadDir, 'config.json'), JSON.stringify(config)); + expect(readEconomyMode(squadDir), `readEconomyMode(${JSON.stringify(config)})`).toBe(true); + }); + + it("economy off clears a persisted modelProfile 'economy' so selection leaves economy", () => { + const config = { version: 1, modelProfile: 'economy', defaultModel: 'auto' }; + writeFileSync(join(squadDir, 'config.json'), JSON.stringify(config)); + writeEconomyMode(squadDir, false); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf-8')); + const label = `after writeEconomyMode(false) on ${JSON.stringify(config)} → ${JSON.stringify(raw)}`; + expect(raw.modelProfile, label).toBeUndefined(); + expect(raw.defaultModel, label).toBe('auto'); + expect(readEconomyMode(squadDir), label).toBe(false); + expect(resolveModel({ squadDir }), label).toBe(sdkResolveModel({}).model); + }); + + it('economy off keeps a non-economy modelProfile', () => { + const config = { version: 1, economyMode: true, modelProfile: 'balanced' }; + writeFileSync(join(squadDir, 'config.json'), JSON.stringify(config)); + writeEconomyMode(squadDir, false); + const raw = JSON.parse(readFileSync(join(squadDir, 'config.json'), 'utf-8')); + const label = `after writeEconomyMode(false) on ${JSON.stringify(config)} → ${JSON.stringify(raw)}`; + expect(raw.modelProfile, label).toBe('balanced'); + expect(raw.economyMode, label).toBeUndefined(); + }); }); // ============================================================================ diff --git a/test/model-fallback.test.ts b/test/model-fallback.test.ts index ae2b77d07..7f09c0993 100644 --- a/test/model-fallback.test.ts +++ b/test/model-fallback.test.ts @@ -13,6 +13,12 @@ import { MODEL_CATALOG, DEFAULT_FALLBACK_CHAINS, } from '@bradygaster/squad-sdk/config'; +import { + resolveAgentModel, + ModelFallbackExecutor, + type ModelResolutionOptions, +} from '@bradygaster/squad-sdk/agents'; +import { MODELS } from '@bradygaster/squad-sdk/runtime/constants'; // ============================================================================ // Cross-tier fallback: standard chain tries all models in order @@ -293,3 +299,79 @@ describe('Model fallback — edge cases', () => { } }); }); + +// ============================================================================ +// Resolver → executor: retry chain is availability-filtered and forward-only +// (PR #2225 review) +// ============================================================================ + +async function attemptedModels(options: ModelResolutionOptions): Promise<{ tried: Array; label: string }> { + const resolved = resolveAgentModel(options); + const tried: Array = []; + await new ModelFallbackExecutor({ allowCrossTier: true }) + .execute(resolved, 'agent', async (model) => { + tried.push(model); + throw new Error(`fail ${model}`); + }) + .catch(() => undefined); + return { tried, label: `resolveAgentModel(${JSON.stringify(options)}) → ${JSON.stringify(resolved)}; tried ${JSON.stringify(tried)}` }; +} + +describe('Resolver → ModelFallbackExecutor retry chain (PR #2225)', () => { + const standard = MODELS.FALLBACK_CHAINS.standard; + + it('only available entries are retried (no unavailable middle-chain model)', async () => { + const options = { taskType: 'code' as const, availableModels: ['claude-sonnet-5.5', 'claude-sonnet-5'] }; + const resolved = resolveAgentModel(options); + const { tried, label } = await attemptedModels(options); + expect(resolved.fallbackChain, label).toEqual(['claude-sonnet-5.5', 'claude-sonnet-5']); + expect(tried, label).toEqual(['claude-sonnet-5.5', 'claude-sonnet-5']); + }); + + it('a middle-chain selection never retries earlier chain entries', async () => { + const options = { userOverride: standard[2] }; + const { tried, label } = await attemptedModels(options); + expect(tried, label).toEqual(standard.slice(2)); + }); + + it('a middle-chain selection with a live list retries only available later entries', async () => { + const options = { userOverride: standard[2], availableModels: [standard[0], standard[2], standard[4]] }; + const { tried, label } = await attemptedModels(options); + expect(tried, label).toEqual([standard[2], standard[4]]); + }); + + it('an out-of-chain selection keeps head-first fallback over available entries', async () => { + const options = { userOverride: 'my-custom-model', availableModels: ['my-custom-model', standard[0], standard[3]] }; + const { tried, label } = await attemptedModels(options); + expect(tried, label).toEqual(['my-custom-model', standard[0], standard[3]]); + }); +}); + +describe('Resolver → ModelFallbackExecutor exhausted chain (omitModel, PR #2225)', () => { + const options: ModelResolutionOptions = { taskType: 'code', availableModels: ['not-a-chain-model'] }; + + it('runs the platform-default callback instead of calling fn with an empty model id', async () => { + const resolved = resolveAgentModel(options); + const tried: string[] = []; + let platformCalls = 0; + const result = await new ModelFallbackExecutor().execute( + resolved, + 'agent', + async (model) => { tried.push(model); return `model:${model}`; }, + { platformDefault: async () => { platformCalls++; return 'platform-default'; } }, + ); + const label = `resolveAgentModel(${JSON.stringify(options)}) → ${JSON.stringify(resolved)}; fn got ${JSON.stringify(tried)}`; + expect(resolved.omitModel, label).toBe(true); + expect(tried, label).toEqual([]); + expect(platformCalls, label).toBe(1); + expect(result, label).toMatchObject({ value: 'platform-default', model: '', omitModel: true, didFallback: false }); + }); + + it('without a platform-default callback it rejects and never calls fn with an empty model id', async () => { + const resolved = resolveAgentModel(options); + const tried: string[] = []; + const run = new ModelFallbackExecutor().execute(resolved, 'agent', async (model) => { tried.push(model); return model; }); + await expect(run, `execute(${JSON.stringify(resolved)})`).rejects.toThrow(/omitModel/); + expect(tried, `fn calls for ${JSON.stringify(resolved)}`).toEqual([]); + }); +}); diff --git a/test/model-hydrafusion.test.ts b/test/model-hydrafusion.test.ts index c3fc7c386..daeec109d 100644 --- a/test/model-hydrafusion.test.ts +++ b/test/model-hydrafusion.test.ts @@ -11,7 +11,7 @@ * @module test/model-hydrafusion */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; @@ -21,6 +21,7 @@ import { DEFAULT_FALLBACK_CHAINS, DEFAULT_CONFIG, ECONOMY_MODEL_MAP, + ModelRegistry, readModelProfile, writeModelProfile, resolveModel as adapterResolveModel, @@ -36,6 +37,7 @@ import { } from '@bradygaster/squad-sdk/agents'; import { MODELS } from '@bradygaster/squad-sdk/runtime/constants'; import type { SquadClientWithPool } from '@bradygaster/squad-sdk/client'; +import { EventBus } from '@bradygaster/squad-sdk/runtime/event-bus'; const HF = 'hydrafusion'; const HF_MAX = 'hydrafusion-max'; @@ -94,6 +96,25 @@ describe('HydraFusion catalog entries are preview / opt-in (#2014)', () => { } }); +describe('ModelRegistry recommendations never include preview ids (#2014)', () => { + const registry = new ModelRegistry(); + const useCases = [...new Set(MODEL_CATALOG.flatMap((m) => m.useCases ?? []))]; + for (const useCase of useCases) { + it(`getRecommendedModels('${useCase}') contains no preview id`, () => { + const ids = registry.getRecommendedModels(useCase).map((m) => m.id); + expect(ids.filter((id) => PREVIEW_IDS.includes(id)), `getRecommendedModels('${useCase}') → ${JSON.stringify(ids)}`).toEqual([]); + const premium = registry.getRecommendedModels(useCase, 'premium').map((m) => m.id); + expect(premium.filter((id) => PREVIEW_IDS.includes(id)), `getRecommendedModels('${useCase}', 'premium') → ${JSON.stringify(premium)}`).toEqual([]); + }); + } + + it('preview ids stay available through explicit catalog lookup', () => { + for (const id of PREVIEW_IDS) { + expect(registry.getModelInfo(id)?.preview, `getModelInfo('${id}')`).toBe(true); + } + }); +}); + describe('HydraFusion is never auto-selected under GA profiles (#2014)', () => { for (const profile of GA_PROFILES) { for (const ceiling of CEILINGS) { @@ -280,10 +301,80 @@ describe('spawns through AgentLifecycleManager.createSession (#2014)', () => { }); afterEach(async () => { + vi.restoreAllMocks(); await manager.shutdown(); await fs.rm(tempDir, { recursive: true, force: true }); }); + describe('hydrafusion profile but hydrafusion not available to the user (org policy / preview off)', () => { + const premiumNext = MODELS.FALLBACK_CHAINS.premium[0]; + let warnings: string[]; + let notices: Array>; + + function rejectingClient(rejected: string): SquadClientWithPool { + return { + async createSession(config: Record) { + sessionConfigs.push(config); + if (config.model === rejected) throw new Error(`Model '${rejected}' is not enabled by your organization's policy`); + return { sessionId: `s-${sessionConfigs.length}`, async sendMessage() {}, async close() {}, on() {}, off() {} }; + }, + async deleteSession() {}, + } as unknown as SquadClientWithPool; + } + + beforeEach(async () => { + warnings = []; + notices = []; + vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { warnings.push(args.map(String).join(' ')); }); + await fs.writeFile(path.join(squadDir, 'config.json'), JSON.stringify({ version: 1, modelProfile: HF }), 'utf-8'); + }); + + async function managerWith(client: SquadClientWithPool): Promise { + await manager.shutdown(); + const eventBus = new EventBus(); + eventBus.subscribe('agent:milestone', (event) => { + if ((event.payload as Record)?.event === 'model.unavailable') notices.push(event.payload as Record); + }); + manager = new AgentLifecycleManager({ client, teamRoot: tempDir, defaultIdleTimeout: 60_000, eventBus }); + return manager; + } + + it('createSession rejecting hydrafusion: spawn succeeds on the next premium-chain model, user told once', async () => { + const m = await managerWith(rejectingClient(HF)); + const first = await m.spawnAgent({ agentName: 'auto-agent', task: 'Review', taskType: 'review' }); + const second = await m.spawnAgent({ agentName: 'auto-agent', task: 'Audit', taskType: 'security' }); + const label = `createSession calls ${JSON.stringify(sessionConfigs.map((c) => c.model))}; warnings ${JSON.stringify(warnings)}`; + expect(sessionConfigs.map((c) => c.model), label).toEqual([HF, premiumNext, HF, premiumNext]); + expect(first.model, label).toBe(premiumNext); + expect(second.model, label).toBe(premiumNext); + expect(first.modelResolution?.fallbackFrom, label).toEqual({ model: HF, source: 'task-auto' }); + expect(warnings.filter((w) => w.includes(HF)), label).toHaveLength(1); + expect(warnings.find((w) => w.includes(HF)), label).toContain(premiumNext); + expect(notices, label).toHaveLength(1); + expect(notices[0], label).toMatchObject({ model: HF, replacement: premiumNext }); + }); + + it('hydrafusion missing from the live model list: next premium-chain model, user told once', async () => { + const m = await managerWith(rejectingClient(HF)); + const live = [...MODELS.FALLBACK_CHAINS.premium, ...MODELS.FALLBACK_CHAINS.fast]; + await m.spawnAgent({ agentName: 'auto-agent', task: 'Review', taskType: 'review', availableModels: live }); + await m.spawnAgent({ agentName: 'auto-agent', task: 'Design', taskType: 'design', availableModels: live }); + const label = `createSession calls ${JSON.stringify(sessionConfigs.map((c) => c.model))}; warnings ${JSON.stringify(warnings)}`; + expect(sessionConfigs.map((c) => c.model), label).toEqual([premiumNext, premiumNext]); + expect(warnings.filter((w) => w.includes(HF)), label).toHaveLength(1); + expect(notices, label).toHaveLength(1); + }); + + it('an explicit model rejected by createSession still fails the spawn (no silent swap)', async () => { + const m = await managerWith(rejectingClient(HF_MAX)); + await expect( + m.spawnAgent({ agentName: 'auto-agent', task: 'Review', taskType: 'review', modelOverride: HF_MAX }), + `explicit modelOverride '${HF_MAX}' rejected by createSession`, + ).rejects.toThrow(HF_MAX); + expect(sessionConfigs.map((c) => c.model)).toEqual([HF_MAX]); + }); + }); + it('a session model of hydrafusion is inherited by every spawn, including Scribe', async () => { const lead = await manager.spawnAgent({ agentName: 'auto-agent', task: 'Review', taskType: 'review', sessionModel: HF }); const scribe = await manager.spawnAgent({ agentName: 'scribe', task: 'Log', taskType: 'mechanical', sessionModel: HF }); From 097cb9fc027e1925a43769b9c8b50916ff0802c7 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 15 Oct 2026 09:28:17 -0700 Subject: [PATCH 9/9] fix: address PR #2225 follow-ups Part of #2225 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5958f622-3345-44b2-b0ac-f0394b732686 --- .changeset/economy-model-fallback.md | 5 ++ .changeset/economy-status-model-ids.md | 5 ++ docs/src/content/docs/features/vscode.md | 6 +- .../squad-cli/src/cli/commands/economy.ts | 8 +-- .../squad-sdk/src/agents/model-selector.ts | 34 +++++++++-- test/economy-mode.test.ts | 32 +++++++++- test/model-fallback.test.ts | 60 +++++++++++++++++++ 7 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 .changeset/economy-model-fallback.md create mode 100644 .changeset/economy-status-model-ids.md diff --git a/.changeset/economy-model-fallback.md b/.changeset/economy-model-fallback.md new file mode 100644 index 000000000..d43329bc0 --- /dev/null +++ b/.changeset/economy-model-fallback.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-sdk": patch +--- + +Allow automatic economy-profile model resolutions to retry available cheaper-tier models after exhausting their selected tier. diff --git a/.changeset/economy-status-model-ids.md b/.changeset/economy-status-model-ids.md new file mode 100644 index 000000000..a45add031 --- /dev/null +++ b/.changeset/economy-status-model-ids.md @@ -0,0 +1,5 @@ +--- +"@bradygaster/squad-cli": patch +--- + +Update `squad economy` status examples to match the current economy model substitutions. diff --git a/docs/src/content/docs/features/vscode.md b/docs/src/content/docs/features/vscode.md index 27addaf8e..40c589a2e 100644 --- a/docs/src/content/docs/features/vscode.md +++ b/docs/src/content/docs/features/vscode.md @@ -50,7 +50,7 @@ When you assign work to an agent, the coordinator spawns that agent as a sub-age ### No Per-Spawn Model Selection -VS Code accepts the session model (your Copilot model picker). No per-spawn dynamic selection. Cost optimization deferred — use Haiku via model picker for cheaper runs. +VS Code uses the session model selected in the Copilot model picker. Per-spawn model selection isn't available; choose a different session model in the picker when needed. ### Sub-Agents Run Sync (But Parallel) @@ -96,7 +96,7 @@ Use single-root workspaces (multi-root has path resolution bugs). Accept file modification approval once — subsequent writes are automatic. -For initial setup, heavy parallel work (5+ agents), SQL workflows, or cost optimization (per-spawn model selection) → use CLI. +For initial setup, heavy parallel work (5+ agents), SQL workflows, or per-spawn model selection → use CLI. Check the model picker at top of chat if agents seem slow or expensive — switch to Haiku for cost savings. @@ -240,6 +240,6 @@ const decision = await coordinator.route(userTask, { - [Getting Started](../get-started/installation.md) — Installation and setup guide - [Parallel Execution](parallel-execution.md) — How Squadron fan-outs agents -- [Model Selection](model-selection.md) — Cost-first routing strategy +- [Model Selection](model-selection.md) — Per-agent selection and the session model picker - [CLI Reference](../reference/cli.md) — Shell commands and features - [SDK API Reference](../reference/api-reference.md) — Full SDK type and function reference diff --git a/packages/squad-cli/src/cli/commands/economy.ts b/packages/squad-cli/src/cli/commands/economy.ts index 67cfb801c..b586d307f 100644 --- a/packages/squad-cli/src/cli/commands/economy.ts +++ b/packages/squad-cli/src/cli/commands/economy.ts @@ -60,10 +60,10 @@ export async function runEconomy(cwd: string, subArgs: string[]): Promise console.log(` Config: ${DIM}${join(squadDir, 'config.json')}${RESET}\n`); if (enabled) { console.log(` When active, auto-selected models are downgraded:`); - console.log(` ${DIM}claude-opus-4.6 → claude-sonnet-4.5 (architecture/review)${RESET}`); - console.log(` ${DIM}gpt-5.6-sol → gpt-5.6-terra (premium auto-selection)${RESET}`); - console.log(` ${DIM}gpt-5.6-terra → gpt-5.6-luna (code writing)${RESET}`); - console.log(` ${DIM}claude-haiku-4.5 → gpt-5.6-luna (docs/mechanical)${RESET}`); + console.log(` ${DIM}claude-opus-5.5 → claude-sonnet-5.5 (architecture/review)${RESET}`); + console.log(` ${DIM}gpt-6.1-sol → gpt-5.6-terra (premium auto-selection)${RESET}`); + console.log(` ${DIM}gpt-5.6-terra → gpt-5.6-luna (code writing)${RESET}`); + console.log(` ${DIM}claude-haiku-5.5 → gpt-5.6-luna (docs/mechanical)${RESET}`); console.log(` Explicit overrides (config.json, charter) are never changed.\n`); } else { console.log(` Usage: squad economy on | off\n`); diff --git a/packages/squad-sdk/src/agents/model-selector.ts b/packages/squad-sdk/src/agents/model-selector.ts index b330e7df4..89269dbcb 100644 --- a/packages/squad-sdk/src/agents/model-selector.ts +++ b/packages/squad-sdk/src/agents/model-selector.ts @@ -357,7 +357,7 @@ function resolveBaseModel(options: ModelResolutionOptions, profile: ModelSelecti model: defaultModel, tier: defaultTier, source: 'default', - fallbackChain: [...MODELS.FALLBACK_CHAINS[defaultTier]], + fallbackChain: getFallbackChain(defaultTier, economyMode), rationale: `${profile}: no override, session, charter, or task match; default '${defaultModel}'` + (economyMode ? ' (economy profile)' : ''), }; @@ -451,7 +451,7 @@ function selectModelForTask(taskType: TaskType, profile: ModelSelectionProfile): model, tier, source: 'task-auto', - fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], + fallbackChain: getFallbackChain(tier, economyMode === true), rationale: `${profile}: ${taskType} task → ${taskTier} tier '${model}'` + (economyMode ? ' (economy profile)' : ''), }; } @@ -464,11 +464,23 @@ function selectTaskModel(taskModel: string, economyMode?: boolean): ResolvedMode model, tier, source: 'task-auto', - fallbackChain: [...MODELS.FALLBACK_CHAINS[tier]], + fallbackChain: getFallbackChain(tier, economyMode === true), rationale: `task-aware model '${model}'` + (economyMode ? ' (economy profile)' : ''), }; } +function getFallbackChain(tier: ModelTier, includeCheaperTiers: boolean): string[] { + const chain = [...MODELS.FALLBACK_CHAINS[tier]]; + if (!includeCheaperTiers) return chain; + + for (const cheaperTier of Object.keys(TIER_ORDER) as ModelTier[]) { + if (TIER_ORDER[cheaperTier] > TIER_ORDER[tier]) { + chain.push(...MODELS.FALLBACK_CHAINS[cheaperTier]); + } + } + return chain; +} + /** quality-first task → tier table, shared by the opt-in `hydrafusion` profile. */ const QUALITY_FIRST_TASK_TIERS: Readonly> = { architecture: 'premium', @@ -817,6 +829,9 @@ export class ModelFallbackExecutor { ): Promise> { const attempts: FallbackAttempt[] = []; const originalTier = resolved.tier; + const economyAutoResolution = + resolved.profile === 'economy' && + isAutomaticModelResolution(resolved); if (resolved.omitModel) { // Never send an empty model id: run model-less, or fail before calling fn. if (!options.platformDefault) { @@ -832,7 +847,13 @@ export class ModelFallbackExecutor { for (const candidate of candidates) { const candidateTier = inferTierFromModel(candidate); - if (!isTierFallbackAllowed(originalTier, candidateTier, this.allowCrossTier)) { + const allowedByEconomyProfile = + economyAutoResolution && + TIER_ORDER[candidateTier] >= TIER_ORDER[originalTier]; + const tierAllowed = economyAutoResolution + ? allowedByEconomyProfile + : isTierFallbackAllowed(originalTier, candidateTier, this.allowCrossTier); + if (!tierAllowed) { continue; } try { @@ -876,3 +897,8 @@ export class ModelFallbackExecutor { await this.eventBus.emit({ type: type as any, payload, timestamp: new Date() }); } } + +function isAutomaticModelResolution(resolved: ResolvedModel): boolean { + const source = resolved.source === 'fallback' ? resolved.fallbackFrom?.source : resolved.source; + return source === 'task-auto' || source === 'default'; +} diff --git a/test/economy-mode.test.ts b/test/economy-mode.test.ts index 2c2831baf..9a8ec2145 100644 --- a/test/economy-mode.test.ts +++ b/test/economy-mode.test.ts @@ -7,8 +7,8 @@ * - config.json read/write round-trips correctly */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { @@ -21,6 +21,7 @@ import { import { resolveModel as sdkResolveModel, } from '@bradygaster/squad-sdk/agents'; +import { runEconomy } from '../packages/squad-cli/src/cli/commands/economy.js'; let squadDir: string; @@ -61,6 +62,33 @@ describe('ECONOMY_MODEL_MAP', () => { }); }); +describe('squad economy status', () => { + it('shows current economy substitutions and preserves the explicit override notice', async () => { + const teamDir = join(squadDir, '.squad'); + mkdirSync(teamDir); + writeFileSync(join(teamDir, 'config.json'), JSON.stringify({ version: 1, economyMode: true })); + + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + await runEconomy(squadDir, []); + const output = log.mock.calls.map((call) => call.join(' ')).join('\n'); + + expect(output).toContain('claude-opus-5.5 → claude-sonnet-5.5'); + expect(output).toContain('gpt-6.1-sol → gpt-5.6-terra'); + expect(output).toContain('gpt-5.6-terra → gpt-5.6-luna'); + expect(output).toContain('claude-haiku-5.5 → gpt-5.6-luna'); + expect(output).toContain('Explicit overrides (config.json, charter) are never changed.'); + + expect(ECONOMY_MODEL_MAP['claude-opus-5.5']).toBe('claude-sonnet-5.5'); + expect(ECONOMY_MODEL_MAP['gpt-6.1-sol']).toBe('gpt-5.6-terra'); + expect(ECONOMY_MODEL_MAP['gpt-5.6-terra']).toBe('gpt-5.6-luna'); + expect(ECONOMY_MODEL_MAP['claude-haiku-5.5']).toBe('gpt-5.6-luna'); + } finally { + log.mockRestore(); + } + }); +}); + describe('applyEconomyMode', () => { it('returns economy model for known models', () => { expect(applyEconomyMode('gpt-5.6-sol')).toBe('gpt-5.6-terra'); diff --git a/test/model-fallback.test.ts b/test/model-fallback.test.ts index 7f09c0993..1e4d4e1d8 100644 --- a/test/model-fallback.test.ts +++ b/test/model-fallback.test.ts @@ -375,3 +375,63 @@ describe('Resolver → ModelFallbackExecutor exhausted chain (omitModel, PR #222 expect(tried, `fn calls for ${JSON.stringify(resolved)}`).toEqual([]); }); }); + +describe('Economy-profile runtime fallback', () => { + it('retries an available cheaper tier with default executor settings', async () => { + const resolved = resolveAgentModel({ + taskType: 'visual', + profile: 'economy', + availableModels: ['claude-sonnet-5.5', 'gpt-5.6-luna'], + }); + const tried: string[] = []; + + await new ModelFallbackExecutor() + .execute(resolved, 'agent', async (model) => { + tried.push(model); + throw new Error(`fail ${model}`); + }) + .catch(() => undefined); + + expect(resolved.model).toBe('claude-sonnet-5.5'); + expect(tried).toEqual(['claude-sonnet-5.5', 'gpt-5.6-luna']); + expect(resolved.fallbackChain).toEqual(['claude-sonnet-5.5', 'gpt-5.6-luna']); + }); + + it('does not add cheaper-tier retries for explicit model preferences', async () => { + const resolved = resolveAgentModel({ + userOverride: 'claude-sonnet-5.5', + profile: 'economy', + availableModels: ['claude-sonnet-5.5', 'gpt-5.6-luna'], + }); + const tried: string[] = []; + + await new ModelFallbackExecutor() + .execute(resolved, 'agent', async (model) => { + tried.push(model); + throw new Error(`fail ${model}`); + }) + .catch(() => undefined); + + expect(resolved.fallbackChain).not.toContain('gpt-5.6-luna'); + expect(tried).toEqual(['claude-sonnet-5.5']); + }); + + it('keeps non-economy fallback chains within the selected tier', async () => { + const resolved = resolveAgentModel({ + taskType: 'visual', + profile: 'quality-first', + availableModels: ['claude-opus-5.5', 'claude-sonnet-5.5'], + }); + const tried: string[] = []; + + await new ModelFallbackExecutor() + .execute(resolved, 'agent', async (model) => { + tried.push(model); + throw new Error(`fail ${model}`); + }) + .catch(() => undefined); + + expect(resolved.fallbackChain).not.toContain('claude-sonnet-5.5'); + expect(tried).toEqual(['claude-opus-5.5']); + }); +});