Replace the scattered per-agent .txt-file mapping (whose names embedded model-family identity like tier-qwen-med and tier-kimi and went stale the moment a model was swapped) with a single source-of-truth manifest at .opencode/models/tiers.yaml. The four tier slots get model-agnostic slot-based names (tier-min, tier-0, tier-1, tier-2); the model in each slot is configured ONLY in the manifest. Derived artifacts (per-agent .txt files and the mapping table block in tier-dispatcher.md) are now generated by tools/sync_tier_models.py. A drift-detection test in tests/auto_agents/test_tier_model_registry.py fails CI if any derived file diverges from the manifest, if an agent referenced by the manifest lacks an agent file, if the manifest cites a provider not declared in opencode.json, or if opencode.json carries a stale tier-* entry. To swap a model in a slot: edit tiers.yaml -> run python3 tools/sync_tier_models.py -> commit. The runtime dispatcher re-reads the .txt files per cycle (no restart); the static OpenCode config path needs a server restart. Tier rename mapping (escalation_tier integers UNCHANGED): tier-qwen-small -> tier-min (slot -1) tier-qwen-med -> tier-0 (slot 0, default first attempt) tier-qwen-large -> tier-1 (slot 1) tier-kimi -> tier-2 (slot 2) Vestigial tier-* agents removed (declared but never in the active mapping): tier-haiku, tier-sonnet, tier-opus, tier-codex, tier-gpt5-mini, tier-gpt5-nano, tier-o4-mini. estimator-implementation.md now reasons in capability descriptors (cheapest / default / advanced / complex) instead of model-family labels (qwen-small / qwen-med / qwen-large / kimi), so the estimator stays correct across model swaps. The stale "default tier = gpt-5-mini" docstring claim (already drifted to claude-haiku-4-5) is removed. Companion prose updates across every consumer of tier names: - Agent prompts: tier-dispatcher.md, implementation-worker.md, estimator-implementation.md - Skills: implementer-pr-context, implementer-workspace - Python: dispatch_implementer.py, _opencode_worker.py, _pr_context_sentinel.py, implementer_workspace.py, setup_auto_labels.py, _attempt_history.py - Tests: test_worker_permissions.py (parametrize list + byte-identity test now covers 4 slot files instead of 3 family-named files), test_opencode_worker_models.py (synthetic-fixture names updated) - Docs: .opencode/models/README.md, docs/development/models.md, docs/development/agent-system-specification.md, docs/development/auto-agents-tier-2-3-plan.md, docs/development/implementer-in-cycle-escalation-plan.md, docs/development/final-working-harvest-plan.md Validation: 1625 tests pass (+6 net new from the tier-registry test file), 3 skipped. python3 tools/sync_tier_models.py --check exits 0. local_ci_gate.sh --gate lint PASS. Files: 9 added, 22 deleted, 18 modified. The drift-detection test ran green on every step of the refactor, catching one out-of-sync .txt file before commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
Centralised model registry
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
# 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 — everyPOST /session/{id}/prompt_asyncre-resolves the agent's model fromopencode.json'sagent.<name>.model, which is loaded into memory at server startup. So an edit to.opencode/models/<x>.txtonly takes effect after the next OpenCode restart re-reads the{file:...}interpolation.The dispatcher's
POST /sessionoverride still serves two real purposes:
- Observability — every dispatched session record carries the intended model, so the session list shows what the registry meant to use.
- 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:
.opencode/models/<agent-name>.txt— per-agent override..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 thetier-*selector that invoked it (the entire purpose of thetier-*/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.txtfile 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
_dispatch_runtime.dispatch_onecalls_opencode_worker.run_session_blocking(agent="pr-review-worker", …).run_session_blockingcalls_resolve_role_model("pr-review-worker").- The resolver reads
.opencode/models/pr-review-worker.txt(absent), then.opencode/models/default.txt, parses the model line, and returns aResolvedModel(provider_id="openai", full_id="openai/gpt-5-mini"). run_session_blockingbuilds thePOST /sessionbody as{"title": "...", "model": {"providerID": "openai", "id": "openai/gpt-5-mini"}}and dispatches. The session record now carries the intended model — useful for operator visibility.- The actual model used for generation depends on
opencode.json'sagent.<name>.modelcached 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'smodelID, 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:
# 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:
# 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
# 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
# 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:
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
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-implementoragent-evolution-pool-supervisor
Per OpenCode's agent inheritance rule,
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:
- 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. _opencode_worker.run_session_blockinggains 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 totransport-errorwhen every candidate is tried.- A
model-fallback-engagedstatus 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/reloadendpoint 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:
- 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).
- Generated files drift. The renderer would commit generated
.mdfiles that need to stay in sync with the registry. The{file:...}interpolation inopencode.jsonaccomplishes 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 blamehistory. - 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:
- 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 intoPOST /sessionbody when resolver succeeds; field omitted when resolver returnsNone; per-agent override wins overdefault.txt; malformed registry degrades gracefully.- Integration: every agent wired in
opencode.json'sagentblock resolves to a non-Nonemodel against the production registry. Guards against shipping a config that the resolver cannot honour.