multi-agent-lab / docs /adr /0022-per-agent-explicit-model-binding.md
agharsallah
feat: Enhance model routing and deployment flexibility
c1656a8
|
Raw
History Blame Contribute Delete
5.2 kB
# 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).