| # ADR-0022: Per-Agent Explicit Model Binding (`model_endpoint`) |
| |
| ## Status |
| |
| Accepted |
| |
| ## Context |
| |
| ADR-0010 gave per-agent model selection through four **logical tiers** |
| (`tiny`/`fast`/`balanced`/`strong`), and `config/models.yaml` binds each tier to one |
| concrete catalogue model. That is the right default, but it has two limits: |
| |
| 1. **A cast can express at most four distinct models** β one per tier. Two agents on |
| the same tier always share a model. |
| 2. **The unbound specialist models are unreachable.** Several catalogue entries have |
| `profile=None` (Nemotron Cascade 14B, Nemotron 30B, MiniCPM-o) precisely so they do |
| *not* displace a tier default β but then no manifest could ever cast them. |
| |
| Both bite the hackathon strategy directly: the unfair advantage is running *different |
| sponsor models in one cast* (Judge β Nemotron, a worker β MiniCPM) to qualify for |
| multiple tracks from a single submission. And the Fishbowl Lab needed to let a user pick |
| concrete Modal-hosted models per cast member β with the pick actually driving the run |
| (the Lab's model controls were previously cosmetic: `on_summon` ignored |
| `collect_world_config` and always built the scenario's default cast). |
|
|
| ## Decision |
|
|
| Add an optional, additive per-agent override that names a **specific catalogue model**, |
| leaving the tier system as the default and fallback. |
|
|
| - **Manifest.** `AgentManifest` gains `model_endpoint: str | None = None` β a |
| `modal/catalogue.py` endpoint slug. `None` β route by `model_profile` (unchanged). |
| - **Routing.** `ManifestAgent` routes by a **route key** β |
| `self._route_key = model_endpoint or model_profile` β and calls |
| `router.for_profile(self._route_key)`. The `ModelRouter` already accepts any key; |
| `_spec_for` now resolves a non-tier key against the catalogue (`_catalogue_spec`: |
| `modal_catalogue.binding_for(key)` for the live model string / endpoint URL / api key, |
| with decoding inherited from the model's tier β an unbound specialist β `balanced`). |
| An unknown non-tier key degrades to the `fast` tier rather than crashing. Offline this |
| path is never reached: `_build` serves the deterministic stub for any key, with the key |
| folded into the stub's `variant` so a different pick still varies (reproducible) output. |
| - **Composed runs.** `Registry.from_world(world)` builds an in-memory registry from a |
| validated `WorldConfig`, so a UI- (or LLM-) composed run flows through the same |
| `build_scenario` / `build_router` / `governor_for` path as a config-file run. |
| - **Fishbowl Lab.** The cast section is a `@gr.render` over the scenario: one model |
| `gr.Dropdown` per non-judge player (the Judge picks in Β§04), its choices sourced *only* |
| from `modal_catalogue.entries()`. Picks accumulate in a `cast_models` state; |
| `collect_world_config` maps each onto the agent's `model_endpoint` (re-checking the key |
| against the catalogue), and `Summon` runs the composed world. Only catalogue-hosted |
| models are offerable, and the selection is load-bearing. |
|
|
| ## Consequences |
|
|
| - A cast can pin **any** catalogue model per agent, including the unbound specialists β |
| enabling genuine multi-sponsor-model casts from one engine, one submission. |
| - The tier abstraction (ADR-0010) is untouched: it remains the default, the decoding |
| source, the offline-variant tag, and the fallback. `model_endpoint` is purely additive, |
| so every existing manifest, scenario, and test is byte-identical (defaults to `None`). |
| - The Lab's model picker is now functional, not cosmetic: the model you choose is the |
| model that speaks (offline β the deterministic stub, demo still reproducible). A bad |
| compose degrades to the scenario's default cast, so Summon never breaks the demo. |
| - A run cannot point at an undeployed model: the UI offers only catalogue entries, and |
| `collect_world_config` re-validates the key, dropping anything out-of-band or stale. |
| - Offline determinism is preserved end-to-end (the route key, not just the tier, seeds |
| the stub). |
|
|
| ## Alternatives considered |
|
|
| - **Per-tier rebinding** (let the run choose which catalogue model backs each of the four |
| tiers): zero engine change, but still capped at four distinct models and still cannot |
| reach the unbound specialists. Rejected as too weak for the multi-sponsor goal. |
| - **Widening `ModelProfile` to an arbitrary `str`**: would dissolve the tier contract that |
| drives decoding defaults, the `MODEL_<TIER>` env overrides, and the offline variant. |
| Rejected in favour of a separate, additive field that keeps both concepts crisp. |
|
|
| ## Code |
|
|
| - `src/core/manifest.py` β `AgentManifest.model_endpoint` |
| - `src/agents/base.py` β `ManifestAgent._route_key` |
| - `src/models/router.py` β `ModelRouter._spec_for` / `_catalogue_spec` |
| - `src/core/registry.py` β `Registry.from_world` |
| - `src/ui/fishbowl/lab.py` β `model_choices`, `_cast_defaults`, `_judge_manifest`, the |
| cast `gr.render`, `collect_world_config` |
| - `src/ui/fishbowl/app.py` β `_compose_session`, the `Summon` wiring |
|
|
| See also: ADR-0010 (logical-profile routing), ADR-0011 (declarative validatable config), |
| ADR-0015 (LiteLLM gateway to Modal models), ADR-0019 (single model catalogue), ADR-0021 |
| (Fishbowl Gradio presenter). |
|
|