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

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.