Every agent's model assignment now lives in a single-line text file at
.opencode/models/<name>.txt; default.txt is the 27-agent catch-all.
opencode.json's agent.<name>.model uses
{file:./.opencode/models/<name>.txt} interpolation, and
tools/_opencode_worker.py reads the same files at session-create to
stamp the resolved model on the session record (observability + drift
sentinel; OpenCode does NOT propagate session-level model to
prompt_async — schema for that is undocumented and deferred to Stage
2). 39 .md frontmatter `model:` lines stripped; the two intentional
inheritors (task-implementor, agent-evolution-pool-supervisor) keep
their model-less frontmatter.
Operator workflow for swapping a model is now: edit
.opencode/models/<role>.txt, restart OpenCode so opencode.json's
{file:...} re-resolves, run the dispatcher. Live-swap without restart
was attempted (override on prompt_async); OpenCode 0.x silently
dropped those requests (200 OK, no assistant message) and the
prompt_async override was reverted. The session-create override
remains for observability + drift detection.
End-to-end validation (2026-05-10): dispatch_review.py on PR #25 with
default.txt=openai/gpt-5-mini produced a clean REQUEST_CHANGES review
in 26 s for ~$0.016; dispatch_implementer.py on PR #30 with
tier-qwen-* files remapped to openai/{gpt-5-nano, gpt-5-mini,
gpt-5-codex} ran the full implementation-worker → tier-dispatcher →
estimator-implementation → tier-qwen-med → task-implementor →
git-isolator-util chain in 16 min with model=gpt-5-mini end-to-end.
Also documents the printenv VAR form as the only allowed env-read in
implementation-worker.md and task-implementor.md (live testing
showed the worker burning 2–4 turns on permission-denied
trial-and-error trying printf and echo variants).
Tests: 21 in tests/auto_agents/test_opencode_worker_models.py
(resolver semantics with caplog assertions on every malformed-input
path; session-create body shape; prompt_async body never carries
model; three repo-level invariants — every {file:...} reference
resolves, every .md is wired or in the inheritor allowlist, no .md
has a model: frontmatter). 1027 auto_agents tests pass / 3 skipped.
Note: .opencode/models/default.txt and the three tier-qwen-*.txt
files are committed with their OpenAI swaps in place (gpt-5-mini,
gpt-5-nano, gpt-5-mini, gpt-5-codex respectively) because the
CleverThis HuggingFace endpoints are paused. Revert with `git diff
HEAD~1 -- .opencode/models/*.txt | git apply -R` if/when they come
back.
Co-authored-by: Cursor <cursoragent@cursor.com>
13 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 like
tier-haiku, tier-sonnet, etc. each get their own file so swapping
one tier's pinned model is independent.
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-qwen-med →
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
# Pin tier-haiku to a newer Haiku release without touching anything else.
echo "anthropic/claude-haiku-5-1" > .opencode/models/tier-haiku.txt
Effect: the dispatcher uses the new model on the next session that
invokes tier-haiku (or any subagent that calls into tier-haiku).
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.