Files
cleveragents-core/docs/development/models.md
T
drew 6178be3aa7 feat(models): single-source model registry via models.yaml + sync_models.py
.opencode/models/models.yaml is now the ONE file humans edit to assign a
model to an agent. tools/sync_models.py regenerates every derived surface
— the .opencode/models/*.txt files, opencode.json's `agent` block, and
each non-tier agent's .md `model:` frontmatter — so an assignment cannot
drift across surfaces. `--check` verifies with no writes and is enforced
in CI by test_model_registry_in_sync_with_manifest.

Hardened after adversarial review:
- Deletes orphan <agent>.txt files left behind when an override is
  dropped from the manifest. The dispatcher's resolver reads
  <agent>.txt before default.txt, so a stale file would silently pin
  the old model. Tier .txt files are left to sync_tier_models.py.
- Rejects a manifest key that does not name a real agent (no matching
  .opencode/agents/<name>.md) instead of silently appending a bogus
  opencode.json entry and leaving the real agent on the default model.
- Validates the regenerated opencode.json BEFORE writing it, so a bad
  render aborts cleanly instead of corrupting the file on disk.

Tier-ladder agents (task-implementor-tier-*) remain governed separately
by tiers.yaml + sync_tier_models.py and are passed through untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:33:18 -04:00

356 lines
15 KiB
Markdown

# Centralised model registry
> **⚠️ Superseded (2026-05-21).** The model registry is now driven by a
> single manifest — **`.opencode/models/models.yaml`** — with
> `tools/sync_models.py` generating every derived surface (the
> `.opencode/models/*.txt` files, the `opencode.json` `agent` block, and
> each agent's `.md` `model:` frontmatter). `sync_models.py` runs
> automatically at controller-pipeline startup.
>
> See **[`.opencode/models/README.md`](../../.opencode/models/README.md)**
> for the current design and operator workflow. Below, the claim that
> `.opencode/models/*.txt` is the "single source of truth", the
> "No `model:` frontmatter" rule, and the "Why not a renderer / single
> registry file" sections are **obsolete** — `models.yaml` +
> `sync_models.py` are exactly that renderer and single file. The
> tier-ladder content (`tiers.yaml` / `sync_tier_models.py`) and the
> Stage 2 fallback-chain ideas remain accurate.
This document explains where each agent's model assignment lives, how to
swap a model for the next dispatch, and what the planned Stage 2
fallback layer will add. Everything described here is in production as
of the 2026-05-10 "Stage 1 model centralisation" change.
## TL;DR — how to swap a model
```bash
# Switch the dispatcher fleet from Qwen to Sonnet for the next cycle.
echo "anthropic/claude-sonnet-4-6" > .opencode/models/default.txt
# Restart OpenCode so it re-reads opencode.json's {file:...}
# interpolation. (See "Why a restart is required" below.)
kill <opencode-pid>
bash scripts/opencode-builder.sh # or however you started it
# Next dispatched session uses the new model.
python3 tools/dispatch_review.py --once
```
> **Why a restart is required (Stage 1 limitation).** Stage 1 ships
> with a dispatcher-side runtime override at `POST /session`, but
> OpenCode does **not** propagate the session-level model to the
> per-prompt generation path — every `POST /session/{id}/prompt_async`
> re-resolves the agent's model from `opencode.json`'s
> `agent.<name>.model`, which is loaded into memory at server startup.
> So an edit to `.opencode/models/<x>.txt` only takes effect after
> the next OpenCode restart re-reads the `{file:...}` interpolation.
>
> The dispatcher's `POST /session` override still serves two real
> purposes:
>
> 1. **Observability** — every dispatched session record carries the
> intended model, so the session list shows what the registry
> *meant* to use.
> 2. **Drift detection** — when the override on the session record
> disagrees with what OpenCode actually used for generation,
> operators have evidence the static cache is stale.
>
> True restart-free swaps are reserved for Stage 2 (see "Stage 2"
> section).
## Where each model is defined
| File | Purpose |
|---|---|
| `.opencode/models/*.txt` | **Single source of truth.** One file per role; each contains exactly one `providerID/modelID` line. |
| `opencode.json``agent.<name>.model` | References the corresponding `.opencode/models/<name>.txt` via `{file:...}` interpolation. OpenCode reads this at startup to populate its static agent registry. |
| `.opencode/agents/*.md` | **No `model:` frontmatter.** Stripped during Stage 1 — keeping a `model:` line here would create two sources of truth with undocumented precedence. |
| `tools/_opencode_worker.py``_resolve_role_model(agent_name)` | Reads `.opencode/models/<agent_name>.txt` first, falls back to `default.txt`, returns a `ResolvedModel` (or `None`). |
The registry file naming convention matches the agent name 1:1, with
`default.txt` as the catch-all for agents that share a worker model
(today: the bulk of `git-*-util`, `session-health-*-util`, the
dispatcher entry-point workers, etc.). Tier selectors (`tier-min`,
`tier-0`, `tier-1`, `tier-2`) each have their own `.txt` file so
swapping one slot's pinned model is independent. **Tier `.txt` files
are generated** from `.opencode/models/tiers.yaml` by
`tools/sync_tier_models.py` — see `.opencode/models/README.md` for
the operator workflow.
## Lookup precedence
The resolver tries two paths, in order:
1. `.opencode/models/<agent-name>.txt` — per-agent override.
2. `.opencode/models/default.txt` — fallback.
If neither exists (or both are malformed), the resolver returns
`None`. The worker then creates the session **without** a `model`
field and OpenCode falls back to its own static resolution (the
`agent.<name>.model` from `opencode.json`, then the agent's `.md`
frontmatter, then OpenCode's global default). This is the same fail-
open semantic OpenCode uses for any missing config — the dispatcher
remains functional even with the registry directory absent.
Two agents have NO `model:` line and intentionally do not appear in
the registry:
- `task-implementor` — inherits from the `tier-*` selector that
invoked it (the entire purpose of the `tier-*/task-*` split).
- `agent-evolution-pool-supervisor` — inherits the same way.
These agents continue to work without explicit assignments.
## File format
Every `.opencode/models/<name>.txt`:
- Contains **exactly one non-empty line**.
- Starts with the provider id, then `/`, then the model id (e.g.
`anthropic/claude-haiku-4-5`).
- May have a single trailing newline. Leading and trailing whitespace
on the model line are stripped by the resolver.
- Does NOT support comments, blank lines, or multi-line content.
Stage 2 will add a separate `<name>.fallbacks.txt` file for the
fallback chain (see below); for now, multi-line files are rejected
as malformed.
The provider id must already be declared in `opencode.json`'s
`provider.<name>` block. If you reference an unknown provider, OpenCode
will 400 the `POST /session` call with an opaque message — the cost of
catching this at OpenCode's edge instead of in the resolver.
## How the dispatcher uses the registry
1. `_dispatch_runtime.dispatch_one` calls
`_opencode_worker.run_session_blocking(agent="pr-review-worker", …)`.
2. `run_session_blocking` calls `_resolve_role_model("pr-review-worker")`.
3. The resolver reads `.opencode/models/pr-review-worker.txt` (absent),
then `.opencode/models/default.txt`, parses the model line, and
returns a `ResolvedModel(provider_id="openai",
full_id="openai/gpt-5-mini")`.
4. `run_session_blocking` builds the `POST /session` body as
`{"title": "...", "model": {"providerID": "openai", "id":
"openai/gpt-5-mini"}}` and dispatches. The session record now
carries the intended model — useful for operator visibility.
5. The actual model used for generation depends on
`opencode.json`'s `agent.<name>.model` cached at OpenCode startup,
NOT the session-level override. Stage 1 therefore requires the two
to agree (which they do as long as the operator restarts OpenCode
after editing a model file). The override still serves as a
consistency sentinel — if it disagrees with the assistant
message's `modelID`, the operator knows the static cache is stale.
Subagents the worker invokes via the Task tool (e.g.
`implementation-worker``tier-dispatcher``tier-0`
`task-implementor`) pick their model from OpenCode's static agent
registry — which is itself driven by the same `.opencode/models/*.txt`
files via `{file:...}` interpolation in `opencode.json`.
We tried adding `model` to the `prompt_async` body to bypass the
startup cache. OpenCode 0.x silently dropped those requests (returned
200 OK but never produced an assistant message). Schema for runtime
prompt-time overrides is undocumented and is the open question for
Stage 2.
## Operator workflows
### Swap one role
For non-tier agents (e.g. the default worker, `ca-test-infra-improver`),
edit the `.txt` file directly:
```bash
# Pin ca-test-infra-improver to a newer Haiku release.
echo "anthropic/claude-haiku-5-1" > .opencode/models/ca-test-infra-improver.txt
```
For tier slots (`tier-min`, `tier-0`, `tier-1`, `tier-2`), edit the
manifest and re-run the generator — the `.txt` files are generated,
not hand-edited:
```bash
# 1. Change the `model:` line for the desired slot in tiers.yaml
$EDITOR .opencode/models/tiers.yaml
# 2. Regenerate the .txt files and the tier-dispatcher mapping table
python3 tools/sync_tier_models.py
# 3. Verify everything stays in sync (CI also runs this)
python3 tools/sync_tier_models.py --check
```
Effect (either path): the dispatcher uses the new model on the next
session that invokes the affected agent (or any subagent that calls
into it). Other roles unchanged.
### Swap the bulk worker
```bash
# Move every default-tier agent to a different model.
echo "openai/gpt-5-codex" > .opencode/models/default.txt
```
Effect: every agent in the registry that didn't have its own per-role
file now uses the new value on the next dispatch. The `tier-*` files
are unaffected — they still have their explicit pins.
### Verify what's currently active
```bash
# Print the model the dispatcher would resolve for a given agent name.
python3 -c "
import importlib.util, pathlib
spec = importlib.util.spec_from_file_location(
'_opencode_worker',
pathlib.Path('tools/_opencode_worker.py'),
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
r = mod._resolve_role_model('pr-review-worker')
print(f'pr-review-worker -> {r.full_id} (from {r.role_file})' if r else 'no override')
"
```
For the static (OpenCode-side) view, hit OpenCode's `/config`
endpoint:
```bash
curl -s http://127.0.0.1:4096/config | python3 -c "
import json, sys
cfg = json.load(sys.stdin)
print(cfg['agent']['pr-review-worker']['model'])
"
```
If the two values disagree after editing a model file, the OpenCode
server has stale config — restart it to re-read the `{file:...}`
interpolation. The dispatcher path is always live.
### Roll back
```bash
git checkout HEAD -- .opencode/models/default.txt
# next dispatched session uses the committed model
```
Because the registry files are committed and tiny, the rollback story
is the same as for any other config: `git checkout`, no migration.
## Inheriting subagents
Two agents intentionally have NO model assignment anywhere
(no `.opencode/models/<name>.txt`, no `model:` in `.md`, no
`agent.<name>.model` in `opencode.json`):
- `task-implementor`
- `agent-evolution-pool-supervisor`
Per [OpenCode's agent inheritance rule](https://opencode.ai/docs/agents#model),
subagents without an explicit model inherit from the agent that
invoked them. `task-implementor` always runs under a `tier-*` parent,
so it gets the tier's model; `agent-evolution-pool-supervisor` runs
under whichever supervisor or entry-point invoked the pool.
Adding model inheritance to more subagents is a future opportunity —
it would let us delete more `.opencode/models/*.txt` files in favour
of cascading from `default.txt` through the call chain — but Stage 1
deliberately preserves today's bit-for-bit semantics, so no other
subagent is converted to inherit yet.
## Stage 2 (planned) — fallback chain + restart-free swaps
Stage 2 has two related goals:
### 2a. Fallback chain (failover)
The Stage 1 design intentionally accommodates fallback as a purely
additive extension. The plan, NOT shipped yet:
1. Add `.opencode/models/<name>.fallbacks.txt` (optional). Multi-line
ordered list of fallback model ids — first line is tried after the
primary fails, second line after that, etc.
2. `_opencode_worker.run_session_blocking` gains a candidate loop:
try the primary, catch a documented set of retryable errors
(HTTP 4xx with "endpoint paused", 401, 403, 5xx, connection refused,
DNS NXDOMAIN, timeout), fall through to the next candidate; only
exhaust to `transport-error` when every candidate is tried.
3. A `model-fallback-engaged` status comment is posted on the PR so
operators see the live failover.
The Stage 1 file layout already reserves the namespace: a future
`<name>.fallbacks.txt` is the natural companion to today's
`<name>.txt`, and the resolver's "missing file = no override" semantic
extends cleanly to "missing fallbacks file = no fallback chain."
### 2b. Restart-free model swaps
Stage 1's restart requirement comes from OpenCode caching
`agent.<name>.model` at server startup. The clean fix requires one
of:
- **Option A (preferred):** discover the working schema for a
runtime model override on `POST /session/{id}/prompt_async`. The
obvious `{providerID, id}` and `{providerID, modelID}` shapes
cause OpenCode to silently drop the request — diagnostic output
needed.
- **Option B:** OpenCode adds a `/config/reload` endpoint we can
call after editing `.opencode/models/`.
- **Option C:** the dispatcher hot-restarts OpenCode automatically
when it sees a registry mtime newer than the OpenCode process
start time. This is the lowest-effort path and lets operators
treat restarts as an implementation detail. Cost: ~5s downtime
per swap; OpenCode startup takes a second or two.
Until Stage 2 ships, treat the restart as part of the swap procedure.
## Why not a renderer?
Earlier proposals included a `tools/render-agents.py` build step that
would expand a TOML registry into the `model:` lines of each agent
`.md` file. We rejected that path for two reasons:
1. **Two-step swap.** Operators would have to edit one file, run the
renderer, then restart OpenCode. With Stage 2 a renderer-based
solution would also need the failover loop wired in twice (in the
renderer for "static" baselines AND in the dispatcher for
"dynamic" failover).
2. **Generated files drift.** The renderer would commit generated
`.md` files that need to stay in sync with the registry. The
`{file:...}` interpolation in `opencode.json` accomplishes the
same thing with no generated artefact and no commit drift.
(Stage 1 still requires a restart, so the "no two-step swap"
advantage of the runtime override is partly aspirational — it pays
off fully once Stage 2 lands.)
## Why not a single registry file (e.g. `models.toml`)?
OpenCode's `{file:...}` interpolation reads one entire file's content
as a single string — there's no sub-path syntax like
`{file:./models.toml#tier_haiku}`. A single registry file therefore
requires either a renderer (rejected; see above) or a runtime
override + custom parser. The per-file layout adopted in Stage 1:
- Maps 1:1 to OpenCode's native `{file:...}` capability with no glue.
- Gives each role a distinct `git blame` history.
- Keeps the runtime override trivially small (~25 LOC).
If the registry grows to ~100 roles, consolidating into a TOML +
custom parser is a clean future refactor with no API surface change.
## Test coverage
The Stage 1 contract is tested by
[`tests/auto_agents/test_opencode_worker_models.py`](../../tests/auto_agents/test_opencode_worker_models.py):
- Resolver: per-agent precedence, default fallback, return-None on
missing files, whitespace stripping, multi-line rejection,
malformed-line rejection, slash-split correctness for multi-`/`
model ids.
- `run_session_blocking`: model threaded into `POST /session` body
when resolver succeeds; field omitted when resolver returns
`None`; per-agent override wins over `default.txt`; malformed
registry degrades gracefully.
- Integration: every agent wired in `opencode.json`'s `agent` block
resolves to a non-`None` model against the production registry.
Guards against shipping a config that the resolver cannot honour.