Two related fixes from empirical testing of OpenCode's model
resolution against the controller's tier-escalation ladder.
ROOT-CAUSE FINDING (empirical, 2026-05-18)
==========================================
Spun up a probe agent (.opencode/agents/model-probe.md), asked the
model to self-identify, and tried three model-routing mechanisms:
1. **POST /session ``model`` in body**: OpenCode 0.x silently
IGNORES this — all probes returned ``openai/gpt-5.3-chat-latest``
(OpenCode's fallback default), not the requested haiku/sonnet/opus.
2. **.md frontmatter ``model:`` line** (after OpenCode restart):
HONORED — pinning to claude-haiku-4-5 yielded haiku, pinning to
sonnet yielded sonnet, etc.
3. **opencode.json ``agent.<name>.model``**: HONORED — same result
as .md frontmatter.
CONSEQUENCE: pre-batch-Q the tier variants had NO ``model:`` in
their .md frontmatter; the model lived only in
``.opencode/models/task-implementor-tier-<N>.txt`` files that the
dispatcher passed via POST /session body. Since OpenCode ignores
that pass-through, ALL FOUR tier variants ran on the SAME default
model (gpt-5.3 in this configuration) — the entire tier-escalation
ladder was cosmetic for model selection. The trial-2 sessions
logged ``model override -> claude-haiku-4-5`` but the actual
generation was on something else entirely.
WHAT THIS COMMIT DOES
=====================
1. **sync_tier_models.py rewrite** (already in batch P, refined here):
inject ``model: <providerID/modelID>`` line into each generated
tier variant's .md frontmatter. This is the mechanism OpenCode
actually reads at startup. The model values come from tiers.yaml
(source of truth).
2. **Drop the dead .opencode/models/task-implementor-tier-*.txt
files** (Option B): the dispatcher pass-through they fed was
empirically dead — OpenCode doesn't read the model from POST
/session. ``sync_tier_models.py`` now removes any stale .txt
files on each run (so a developer can't accidentally re-create
them).
3. **Test updates**:
- ``test_no_stale_variant_txt_files_remain``: pins that the .txt
files stay deleted (was ``test_every_tier_has_a_variant_txt``).
- ``test_each_variant_md_carries_correct_model_from_manifest``:
pins that the .md frontmatter model: matches tiers.yaml (was
``test_each_variant_txt_matches_manifest_model``).
- ``test_every_agent_file_reference_in_opencode_json_resolves``:
relaxed to skip when opencode.json has no agent block (which is
the Option B steady state). Still pins {file:...} resolution
for any future use.
- ``test_each_variant_md_matches_renderer_output_for_its_tier``:
updated to call the new renderer signature ``render_task_
implementer_variant(source, model)`` (was the byte-copy
identity test, retired because variants now differ by the
injected model: line).
- ``TestTaskImplementorVariantsAreByteIdentical`` → renamed
``test_variants_identical_except_for_model_line``: strips the
model: line via regex and asserts the rest is byte-identical.
OPERATOR WORKFLOW (unchanged surface)
=====================================
To swap a tier's model:
1. Edit ``.opencode/models/tiers.yaml`` (one line)
2. ``python3 tools/sync_tier_models.py`` (regenerates .md; removes
any stale .txt)
3. Commit both files
4. Restart OpenCode (it caches .md frontmatter at startup)
What's still ahead (deferred):
- ``_resolve_role_model()`` in tools/_opencode_worker.py is now
proven dead code (reads .txt files that don't exist; injects
model into POST /session body that OpenCode ignores). Should be
deleted in a follow-up — kept now to minimize blast radius.
- The misleading "if generation uses a different model, restart
OpenCode so opencode.json's {file:...} re-resolves" log line in
``_opencode_worker.py:1569`` is wrong post-Option-B; should be
retired with the dead code above.
- Whether opencode.json's agent block supports a ``permission`` field
is the gate for an even deeper simplification (Option D). Skipped
for now per operator direction; the controller path uses .md
frontmatter for permissions.
3148 tests pass, 4 skipped, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.opencode/models/ — Model registry
Two kinds of files live here:
tiers.yaml— the manifest. Single source of truth for the implementer pipeline's tier → model mapping. Edit this file when you want to swap which model serves a tier slot.<agent>.txt— per-agent model assignment. Each contains exactly oneproviderID/modelIDline. The tier-.txtfiles are generated* fromtiers.yamlbytools/sync_tier_models.py— do not hand-edit them. Non-tier.txtfiles (e.g.default.txt,ca-test-infra-improver.txt) are hand-maintained as before.
How agents pick their model
Two paths read from this directory; both honour the same convention.
⚠️ Operational footgun: editing a .txt file does NOT live-update generation
This is the single highest-leverage operational fact in this directory.
Both paths below require an OpenCode server restart for an edit to a
.txt file to actually change what model OpenCode runs. The dispatcher
PASSES the resolved model on every POST /session, but empirical
testing against OpenCode 0.x found that OpenCode re-resolves
agent.<name>.model from its own startup-cached opencode.json every
generation — the dispatcher's pass-through is recorded in the session
metadata for observability/auditability, not consumed by the
generation path. See the long comment in
tools/_opencode_worker.py
(run_session_blocking, around the POST /session body assembly).
The implication an operator MUST internalise:
- Edit
.opencode/models/<role>.txt(ortiers.yaml+ regen). - Restart OpenCode (
pkill -f 'opencode serve'then re-launch). - Verify with
tools/audit_opencode_session_models.py(or by reading a fresh session's metadata) that the new model is in effect.
Skipping step 2 produces the worst kind of silent regression: the dispatcher's logs say it requested the new model, the cycle archive records the new model, but OpenCode generates with the old cached value. There is no in-process diagnostic that surfaces the mismatch — it is invisible from the dispatcher side.
The G11 estimator-driven adaptive tier selection (final-working-harvest-plan.md § G11) and the in-cycle escalation plan (implementer-in-cycle-escalation-plan.md) both depend on the worker actually generating with the dispatcher-requested model. If model swaps land without a restart, those features silently misbehave (a Tier 1 escalation will run on the previously-cached Tier 0 model and produce the same outcome the Tier 0 attempt produced — exactly the cycle they were meant to break out of).
Runtime (dispatcher path)
tools/_opencode_worker.py resolves a model for an agent by:
- Looking for
.opencode/models/<agent-name>.txt. - If absent, falling back to
.opencode/models/default.txt. - Parsing the single line as
providerID/modelIDand passing it as themodelfield onPOST /session.
This is the path used by dispatch_review.py, dispatch_implementer.py,
conflict_drive.py, and any other code that calls
_opencode_worker.run_session_blocking. The dispatcher includes the
resolved model on every session so the cycle archive carries the
intended model — but per the footgun above, OpenCode's generation
uses its startup-cached agent.<name>.model from opencode.json. Both
sides must agree, which is why an OpenCode restart after a .txt edit
remains required.
Static (OpenCode config path)
opencode.json declares an agent.<name>.model for every agent that has an
explicit assignment, using {file:./.opencode/models/<name>.txt} so OpenCode
reads the same file at config-load time. This covers interactive sessions and
any Task-tool subagent invocations that bypass the dispatcher API override.
OpenCode caches its agent registry at process start, so static-path changes
require a server restart (pkill -f 'opencode serve' then re-launch).
How to swap a model
For a tier slot (use the manifest)
# 1. Edit .opencode/models/tiers.yaml — change the `model:` line for
# the slot you want to repoint. Example: change Tier 0 to Sonnet.
$EDITOR .opencode/models/tiers.yaml
# 2. Regenerate the .txt files and the mapping table in tier-dispatcher.md
python3 tools/sync_tier_models.py
# 3. Verify everything stays in sync (CI also runs this)
python3 tools/sync_tier_models.py --check # exits 0 = clean
# 4. RESTART OpenCode so the new model actually takes effect.
# The dispatcher records the new model on every POST /session,
# but OpenCode generates from its startup-cached opencode.json —
# skipping the restart is the silent-regression footgun called
# out at the top of this README.
pkill -f 'opencode serve' && /usr/local/bin/opencode serve & # or your launcher
# 5. Verify the new model is actually in effect on the next dispatch.
python3 tools/dispatch_implementer.py --once
The provider half of every model: value (e.g. local-claude/…) must
already be declared in opencode.json's provider block; the
drift-detection test tests/auto_agents/test_tier_model_registry.py
verifies this on every CI run.
For a non-tier agent (still by .txt edit)
# Switch the default worker model for the next dispatch
echo "anthropic/claude-sonnet-4-6" > .opencode/models/default.txt
python3 tools/dispatch_implementer.py --once
Inventory
Tier slots — defined in tiers.yaml, .txt files generated
| Tier | Agent | Where the model lives |
|---|---|---|
| -1 | tier-min |
tiers.yaml (entry tier: -1) → tier-min.txt generated |
| 0 | tier-0 |
tiers.yaml (entry tier: 0) → tier-0.txt generated |
| 1 | tier-1 |
tiers.yaml (entry tier: 1) → tier-1.txt generated |
| 2 | tier-2 |
tiers.yaml (entry tier: 2) → tier-2.txt generated |
The matching agent files (.opencode/agents/tier-{min,0,1,2}.md) are
model-agnostic pass-throughs — they reference the slot, never a
specific model. The actual model that serves the slot is whatever
tiers.yaml says today.
Hand-maintained role files
| Role file | Agents that resolve to it |
|---|---|
default.txt |
All agents that don't have a name-matched file (pr-review-worker, implementation-worker, auto-agents, supervisor, every git-*-util, every session-health-*-util, work-group-util, async-agent-util, tier-dispatcher, estimator-implementation, conflict-resolver-worker, pr-merge-worker, pr-merge-supervisor) |
ca-test-infra-improver.txt |
ca-test-infra-improver |
Agents that deliberately inherit from their caller (no model: line, no
override file): task-implementor, agent-evolution-pool-supervisor.
File format
tiers.yaml
YAML list at tiers:, one entry per tier. Each entry has tier:
(int), agent: (str), model: (str providerID/modelID),
capability: (str, one of cheapest / default / advanced /
complex — consumed by estimator-implementation), and
description: (str, one-line operator-facing summary). See the file
itself for the canonical example.
<agent>.txt
- Exactly one line per file (the model id).
- Trailing newline is stripped by the resolver and by OpenCode's
{file:...}interpolation. Do not put anything else in these files (no comments, no blank lines, no extra whitespace). - The model id must be valid
providerID/modelIDsyntax (e.g.local-claude/claude-sonnet-4-6,anthropic/claude-sonnet-4-6). The provider id must already be declared inopencode.json'sproviderblock. - Tier
.txtfiles are generated. Hand-editing them is a CI failure (tests/auto_agents/test_tier_model_registry.py). Edittiers.yamland re-run the generator instead.
See also
tiers.yaml— the manifest itself.../../tools/sync_tier_models.py— generator.../../tests/auto_agents/test_tier_model_registry.py— drift-detection test.../../docs/development/models.md— full design rationale and the planned Stage 2 fallback layer.../../opencode.json—agent.<name>.modelblock that references these files.../../tools/_opencode_worker.py— runtime resolver:_resolve_role_model(agent_name).