Files
cleveragents-core/docs/development/models.md
T
drew 1eac4ea233 refactor(auto-agents): manifest-driven tier-model registry, slot-based naming
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>
2026-05-15 12:49:31 -04:00

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 — 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.jsonagent.<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-workertier-dispatchertier-0task-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-implementor
  • agent-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:

  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:

  • 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.