Files
cleveragents-core/.opencode/models
drew 751dbfed33 fix(agents): batch Q — tier-variant model injection + Option B (drop dead .txt files)
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>
2026-05-18 20:17:41 -04:00
..

.opencode/models/ — Model registry

Two kinds of files live here:

  1. 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.
  2. <agent>.txt — per-agent model assignment. Each contains exactly one providerID/modelID line. The tier- .txt files are generated* from tiers.yaml by tools/sync_tier_models.py — do not hand-edit them. Non-tier .txt files (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:

  1. Edit .opencode/models/<role>.txt (or tiers.yaml + regen).
  2. Restart OpenCode (pkill -f 'opencode serve' then re-launch).
  3. 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:

  1. Looking for .opencode/models/<agent-name>.txt.
  2. If absent, falling back to .opencode/models/default.txt.
  3. Parsing the single line as providerID/modelID and passing it as the model field on POST /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/modelID syntax (e.g. local-claude/claude-sonnet-4-6, anthropic/claude-sonnet-4-6). The provider id must already be declared in opencode.json's provider block.
  • Tier .txt files are generated. Hand-editing them is a CI failure (tests/auto_agents/test_tier_model_registry.py). Edit tiers.yaml and re-run the generator instead.

See also