1eac4ea233
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>
340 lines
14 KiB
Markdown
340 lines
14 KiB
Markdown
# 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
|
|
|
|
```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.
|