diff --git a/.opencode/agents/async-agent-util.md b/.opencode/agents/async-agent-util.md index bdcfee963..566e98e4d 100644 --- a/.opencode/agents/async-agent-util.md +++ b/.opencode/agents/async-agent-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Async agent utility. Centralized manager for all async agent operations via the OpenCode Server API at localhost:4096. Creates sessions, launches agents, diff --git a/.opencode/agents/auto-agents.md b/.opencode/agents/auto-agents.md index 3eba05bd0..2d04da4ff 100644 --- a/.opencode/agents/auto-agents.md +++ b/.opencode/agents/auto-agents.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Dispatcher heartbeat watchdog. The deterministic Python dispatchers (``tools/dispatch_implementer.py`` and ``tools/dispatch_review.py``) diff --git a/.opencode/agents/ca-test-infra-improver.md b/.opencode/agents/ca-test-infra-improver.md index 87f2a2249..ca7fd132c 100644 --- a/.opencode/agents/ca-test-infra-improver.md +++ b/.opencode/agents/ca-test-infra-improver.md @@ -1,4 +1,5 @@ --- +model: google/gemini-2.5-pro description: > Testing infrastructure improvement pool supervisor and worker. In pool mode (max_workers > 1), identifies analysis areas (CI timing, coverage gaps, test diff --git a/.opencode/agents/conflict-resolver-worker.md b/.opencode/agents/conflict-resolver-worker.md index 91c93b17f..4c11aaae5 100644 --- a/.opencode/agents/conflict-resolver-worker.md +++ b/.opencode/agents/conflict-resolver-worker.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-opus-4-6 description: > Conflict-resolver worker. Single-shot LLM invoked by the controller to integrate ONE pull request branch with its base (mid-rebase or diff --git a/.opencode/agents/git-checkout-util.md b/.opencode/agents/git-checkout-util.md index c05bb78b8..b0829fe16 100644 --- a/.opencode/agents/git-checkout-util.md +++ b/.opencode/agents/git-checkout-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git checkout utility — primitive. Inside an existing /tmp/ clone, checks out a branch (either an existing one, or a new one created from a base diff --git a/.opencode/agents/git-cleanup-util.md b/.opencode/agents/git-cleanup-util.md index 529485102..f9b7424b5 100644 --- a/.opencode/agents/git-cleanup-util.md +++ b/.opencode/agents/git-cleanup-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git cleanup utility — primitive. Removes a /tmp/ working directory after work is done. Refuses any path outside /tmp/ for safety. The most diff --git a/.opencode/agents/git-clone-util.md b/.opencode/agents/git-clone-util.md index c3978c7e1..c5cdadf37 100644 --- a/.opencode/agents/git-clone-util.md +++ b/.opencode/agents/git-clone-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git clone utility — primitive. Creates a fresh PAT-authenticated /tmp/ clone of a Forgejo repository at a unique timestamped path, configures diff --git a/.opencode/agents/git-commit-and-push-util.md b/.opencode/agents/git-commit-and-push-util.md index 8caa1fbeb..24db8d892 100644 --- a/.opencode/agents/git-commit-and-push-util.md +++ b/.opencode/agents/git-commit-and-push-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git commit-and-push utility — orchestrator. Composes `git-stage-util` + `git-create-commit-util` + `git-push-util` (regular push, no force). diff --git a/.opencode/agents/git-commit-util.md b/.opencode/agents/git-commit-util.md index 522583855..67f7a9ef1 100644 --- a/.opencode/agents/git-commit-util.md +++ b/.opencode/agents/git-commit-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git commit utility. Provides safe, standardized commit, push, and rebase operations with proper author attribution, conflict handling, and rollback. diff --git a/.opencode/agents/git-create-commit-util.md b/.opencode/agents/git-create-commit-util.md index 1084b37ec..10dbb8bf8 100644 --- a/.opencode/agents/git-create-commit-util.md +++ b/.opencode/agents/git-create-commit-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git create-commit utility — primitive. Inside an existing /tmp/ clone, creates a single commit from already-staged changes using the diff --git a/.opencode/agents/git-fetch-util.md b/.opencode/agents/git-fetch-util.md index 358df74b1..2e4009129 100644 --- a/.opencode/agents/git-fetch-util.md +++ b/.opencode/agents/git-fetch-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git fetch utility — primitive. Inside an existing /tmp/ clone, fetches the latest refs from the `origin` remote. Used before rebase operations diff --git a/.opencode/agents/git-force-push-with-lease-util.md b/.opencode/agents/git-force-push-with-lease-util.md index 3a92a45f1..c70711860 100644 --- a/.opencode/agents/git-force-push-with-lease-util.md +++ b/.opencode/agents/git-force-push-with-lease-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git force-push-with-lease utility — orchestrator. Composes (optional) `git-stage-util` + `git-create-commit-util` + `git-push-util` diff --git a/.opencode/agents/git-isolator-util.md b/.opencode/agents/git-isolator-util.md index 61dba7c44..80e0872b7 100644 --- a/.opencode/agents/git-isolator-util.md +++ b/.opencode/agents/git-isolator-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git isolator utility. Creates isolated git clones in /tmp/ for agent operations. Handles cloning, authentication, branch management, and diff --git a/.opencode/agents/git-push-util.md b/.opencode/agents/git-push-util.md index 53dc9e18c..45a8d1a09 100644 --- a/.opencode/agents/git-push-util.md +++ b/.opencode/agents/git-push-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git push utility — primitive. Inside an existing /tmp/ clone, pushes the named branch to `origin`. Authentication uses the PAT-in-URL pattern set diff --git a/.opencode/agents/git-rebase-and-push-util.md b/.opencode/agents/git-rebase-and-push-util.md index 0219089b4..a0b2edbbb 100644 --- a/.opencode/agents/git-rebase-and-push-util.md +++ b/.opencode/agents/git-rebase-and-push-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git rebase-and-push utility — orchestrator. Composes `git-fetch-util` + `git-rebase-util` + `git-push-util` with `force_with_lease=true`. Used diff --git a/.opencode/agents/git-rebase-util.md b/.opencode/agents/git-rebase-util.md index f5fc1cf11..331eeea8e 100644 --- a/.opencode/agents/git-rebase-util.md +++ b/.opencode/agents/git-rebase-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git rebase utility. Rebases a branch onto a target branch (usually master) and resolves any merge conflicts that arise. Ensures the rebase completes diff --git a/.opencode/agents/git-stage-util.md b/.opencode/agents/git-stage-util.md index 8b9cacfd3..d235c902b 100644 --- a/.opencode/agents/git-stage-util.md +++ b/.opencode/agents/git-stage-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Git stage utility — primitive. Inside an existing /tmp/ clone, stages all changes in the working tree (`git add -A`). Returns the count of staged diff --git a/.opencode/agents/implementation-worker.md b/.opencode/agents/implementation-worker.md index af3415591..baf0a7612 100644 --- a/.opencode/agents/implementation-worker.md +++ b/.opencode/agents/implementation-worker.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Implementation worker. Thin domain-specific wrapper over `tier-dispatcher` that knows how to dispatch implementation work. Receives a single work item diff --git a/.opencode/agents/pr-merge-supervisor.md b/.opencode/agents/pr-merge-supervisor.md index 101aa9969..832bb7bba 100644 --- a/.opencode/agents/pr-merge-supervisor.md +++ b/.opencode/agents/pr-merge-supervisor.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > PR merge supervisor. Continuously monitors open PRs, merging those that are ready and rebasing stale ones. A thin configuration wrapper over the generic supervisor subagent — diff --git a/.opencode/agents/pr-merge-worker.md b/.opencode/agents/pr-merge-worker.md index 77046b8dc..8f8c7b57e 100644 --- a/.opencode/agents/pr-merge-worker.md +++ b/.opencode/agents/pr-merge-worker.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > PR merge worker. Performs a single rebase operation with conflict resolution on a PR branch that is behind the base branch, then activates auto-merge that schedules an automatic merge once CI diff --git a/.opencode/agents/pr-review-worker-dispute.md b/.opencode/agents/pr-review-worker-dispute.md index c8521199f..e9b574b4a 100644 --- a/.opencode/agents/pr-review-worker-dispute.md +++ b/.opencode/agents/pr-review-worker-dispute.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-opus-4-6 description: > PR review worker — DISPUTE RE-EXAMINATION variant. Same role as pr-review-worker, but invoked specifically when the prior implementer diff --git a/.opencode/agents/pr-review-worker.md b/.opencode/agents/pr-review-worker.md index eda230b2c..0f7fe7efc 100644 --- a/.opencode/agents/pr-review-worker.md +++ b/.opencode/agents/pr-review-worker.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-sonnet-4-6 description: > PR review worker. Performs a single formal peer review on one pull request and then exits. Read-only by role; submits its verdict via diff --git a/.opencode/agents/session-health-full-util.md b/.opencode/agents/session-health-full-util.md index 14e14d095..327775b5c 100644 --- a/.opencode/agents/session-health-full-util.md +++ b/.opencode/agents/session-health-full-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Session health full util. Receives metadata for a single OpenCode session, fetches its recent message history directly, and produces a comprehensive health diff --git a/.opencode/agents/session-health-quick-util.md b/.opencode/agents/session-health-quick-util.md index 9022c17d6..0d76769fb 100644 --- a/.opencode/agents/session-health-quick-util.md +++ b/.opencode/agents/session-health-quick-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Session health quick util. Receives the last 3 text-only messages from a single OpenCode session and determines whether its terminal state is obviously diff --git a/.opencode/agents/session-health-util.md b/.opencode/agents/session-health-util.md index 3c635deb8..5dfdffb5b 100644 --- a/.opencode/agents/session-health-util.md +++ b/.opencode/agents/session-health-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Session health util. Coordinates a two-tier health evaluation for a single OpenCode session. Tier 1 (quick): fetches the last 3 text-only messages and diff --git a/.opencode/agents/supervisor.md b/.opencode/agents/supervisor.md index 48c83c622..e24d70104 100644 --- a/.opencode/agents/supervisor.md +++ b/.opencode/agents/supervisor.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > A generic supervisor. Its job is to manage a pool of workers asynchronously ensuring they stay up, stay healthy, and all workers remain saturated (in use) without going over. The supervisor will first collect the work to be done, break it up into groups to address the prirotiy they are handled, and then dispatch workers to handle the group. Any group with higher priority will continually be worked on until it is drained before moving on to the next group, and should a higher priority group get new work items it will stop work and move back to the higher priority group at any time. Exactly what the groups are, and what tasks are collected into each group must be passed in through its user prompt. All supervisors are instructed to run indefinately, however they may need to be urged on with a "continue" prompt from time to time. diff --git a/.opencode/agents/work-group-util.md b/.opencode/agents/work-group-util.md index 3993d87ab..5adca0d08 100644 --- a/.opencode/agents/work-group-util.md +++ b/.opencode/agents/work-group-util.md @@ -1,4 +1,5 @@ --- +model: local-claude/claude-haiku-4-5 description: > Work group discovery utility. Runs work-group fetch scripts, evaluates results, and returns a compact prioritized summary. Absorbs large script diff --git a/.opencode/models/README.md b/.opencode/models/README.md index 6b432513d..6b1f4f946 100644 --- a/.opencode/models/README.md +++ b/.opencode/models/README.md @@ -1,183 +1,80 @@ # `.opencode/models/` — Model registry -Two kinds of files live here: +## Single source of truth: `models.yaml` -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. **`.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. +`models.yaml` is the ONE file you edit to assign an LLM model to an +agent. Every other model-bearing surface is **generated** from it by +`tools/sync_models.py` — do not hand-edit the generated artifacts; the +next sync overwrites them. -## 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..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`](../../tools/_opencode_worker.py) -(`run_session_blocking`, around the `POST /session` body assembly). - -The implication an operator MUST internalise: - -1. Edit `.opencode/models/.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](../../docs/development/final-working-harvest-plan.md) -§ G11) and the in-cycle escalation plan -([implementer-in-cycle-escalation-plan.md](../../docs/development/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/.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..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..model` for every agent that has an -explicit assignment, using `{file:./.opencode/models/.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) - -```bash -# 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 +```yaml +# models.yaml +default: local-claude/claude-haiku-4-5 +agents: + conflict-resolver-worker: local-claude/claude-opus-4-6 + estimator-implementation: local-claude/claude-sonnet-4-6 + # ... only agents needing a NON-default model appear here ``` -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. +`tools/sync_models.py` regenerates, from `models.yaml`: -### For a non-tier agent (still by `.txt` edit) - -```bash -# 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 | +| Generated artifact | Consumed by | |---|---| -| `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` | +| `.opencode/models/.txt`, `default.txt` | the dispatcher's `_opencode_worker._resolve_role_model` | +| `opencode.json` → `agent..model` | OpenCode's static agent registry | +| `model:` frontmatter in `.opencode/agents/.md` | OpenCode's per-agent config | -Agents that deliberately inherit from their caller (no `model:` line, no -override file): `task-implementor`, `agent-evolution-pool-supervisor`. +Both OpenCode-facing surfaces (the `opencode.json` block **and** the +`.md` frontmatter) are written, because which one OpenCode honours has +varied across versions — writing both makes the assignment correct +regardless. Since all of them derive from the one manifest, they cannot +disagree. -## File format +## Swapping a model -### `tiers.yaml` +1. Edit `models.yaml`. +2. Apply it — either: + - relaunch the controller pipeline — `tools/run-controller-state-machine-pipeline.sh` + runs `sync_models.py` automatically at startup (before OpenCode boots); or + - run `python3 tools/sync_models.py` by hand. +3. Restart OpenCode — it caches its config at process start, so a + running server keeps the old model until restarted. (The pipeline + launcher restarts OpenCode for you.) -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. +Verify with no writes — `python3 tools/sync_models.py --check` exits 1 +on any drift between `models.yaml` and the generated artifacts (suitable +for CI or a pre-launch gate). -### `.txt` +## Tier-ladder agents — `tiers.yaml` -- 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. +The `task-implementor-tier-*` agents are governed separately by +`tiers.yaml` + `tools/sync_tier_models.py` (the escalation ladder has +its own manifest, generator, and drift test). `sync_models.py` passes +their `opencode.json` entries through untouched and never edits their +`.md` files. To change a tier model, edit `tiers.yaml` and run +`tools/sync_tier_models.py`. + +## Inheriting agents + +`task-implementor` and `agent-evolution-pool-supervisor` have no +`opencode.json` agent entry and no `model:` line — they deliberately +inherit their caller's model. `sync_models.py` does not touch them. + +## File reference + +| File | Role | +|---|---| +| `models.yaml` | **Source of truth** — per-agent model assignment (hand-edited) | +| `tiers.yaml` | Source of truth for the tier ladder (hand-edited) | +| `.txt`, `default.txt` | Generated by `sync_models.py` | +| `README.md` | This file | + +Every model id is `providerID/modelID`; the provider must be declared +in `opencode.json`'s `provider` block — `sync_models.py` validates this +and aborts on an unknown provider. ## See also -- [`tiers.yaml`](./tiers.yaml) — the manifest itself. -- [`../../tools/sync_tier_models.py`](../../tools/sync_tier_models.py) — generator. -- [`../../tests/auto_agents/test_tier_model_registry.py`](../../tests/auto_agents/test_tier_model_registry.py) — drift-detection test. -- [`../../docs/development/models.md`](../../docs/development/models.md) — full - design rationale and the planned Stage 2 fallback layer. -- [`../../opencode.json`](../../opencode.json) — `agent..model` block that - references these files. -- [`../../tools/_opencode_worker.py`](../../tools/_opencode_worker.py) — runtime - resolver: `_resolve_role_model(agent_name)`. +- [`../../tools/sync_models.py`](../../tools/sync_models.py) — the generator. +- [`../../tools/sync_tier_models.py`](../../tools/sync_tier_models.py) — the tier generator. +- [`../../docs/development/models.md`](../../docs/development/models.md) — design history and the planned Stage 2 fallback layer. diff --git a/.opencode/models/models.yaml b/.opencode/models/models.yaml new file mode 100644 index 000000000..ab2db7803 --- /dev/null +++ b/.opencode/models/models.yaml @@ -0,0 +1,39 @@ +# Model registry — single source of truth for per-agent LLM model +# assignment. +# +# Humans edit ONLY this file. `tools/sync_models.py` regenerates every +# derived artifact so a model assignment can never drift across surfaces: +# +# - .opencode/models/.txt — read by the dispatcher's resolver +# (_opencode_worker._resolve_role_model) +# - .opencode/models/default.txt — the fallback model +# - the `agent` block of opencode.json — read by OpenCode at startup +# (the channel that drives generation) +# +# opencode.json points each agent at `{file:.../.txt}`, so both +# consumers read the SAME generated file — the generation model and the +# dispatcher's recorded model cannot disagree. +# +# The pipeline launcher runs `tools/sync_models.py` at every startup +# (before OpenCode caches opencode.json), so editing this file + relaunch +# is all an operator needs. Run `python3 tools/sync_models.py` by hand to +# apply immediately, or `--check` to verify with no writes. +# +# Tier-ladder agents (task-implementor-tier-*) are governed separately by +# tiers.yaml + tools/sync_tier_models.py; sync_models.py leaves their +# opencode.json entries untouched. +# +# Every value is `providerID/modelID`; the provider MUST be declared in +# opencode.json's `provider` block. + +# Fallback model for every agent not listed under `agents:` below. +default: local-claude/claude-haiku-4-5 + +# Per-agent overrides. Only agents that need a NON-default model appear +# here; every other agent resolves to `default` above. +agents: + conflict-resolver-worker: local-claude/claude-opus-4-6 + estimator-implementation: local-claude/claude-sonnet-4-6 + pr-review-worker: local-claude/claude-sonnet-4-6 + pr-review-worker-dispute: local-claude/claude-opus-4-6 + ca-test-infra-improver: google/gemini-2.5-pro diff --git a/docs/development/models.md b/docs/development/models.md index b801c4013..068ba10d9 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -1,5 +1,21 @@ # Centralised model registry +> **⚠️ Superseded (2026-05-21).** The model registry is now driven by a +> single manifest — **`.opencode/models/models.yaml`** — with +> `tools/sync_models.py` generating every derived surface (the +> `.opencode/models/*.txt` files, the `opencode.json` `agent` block, and +> each agent's `.md` `model:` frontmatter). `sync_models.py` runs +> automatically at controller-pipeline startup. +> +> See **[`.opencode/models/README.md`](../../.opencode/models/README.md)** +> for the current design and operator workflow. Below, the claim that +> `.opencode/models/*.txt` is the "single source of truth", the +> "No `model:` frontmatter" rule, and the "Why not a renderer / single +> registry file" sections are **obsolete** — `models.yaml` + +> `sync_models.py` are exactly that renderer and single file. The +> tier-ladder content (`tiers.yaml` / `sync_tier_models.py`) and the +> Stage 2 fallback-chain ideas remain accurate. + 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 diff --git a/opencode.json b/opencode.json index 928834122..e9bd637c9 100644 --- a/opencode.json +++ b/opencode.json @@ -385,8 +385,8 @@ "agent": { "async-agent-util": { "model": "{file:./.opencode/models/default.txt}" }, "auto-agents": { "model": "{file:./.opencode/models/default.txt}" }, - "conflict-resolver-worker": { "model": "{file:./.opencode/models/default.txt}" }, - "estimator-implementation": { "model": "{file:./.opencode/models/default.txt}" }, + "conflict-resolver-worker": { "model": "{file:./.opencode/models/conflict-resolver-worker.txt}" }, + "estimator-implementation": { "model": "{file:./.opencode/models/estimator-implementation.txt}" }, "git-checkout-util": { "model": "{file:./.opencode/models/default.txt}" }, "git-cleanup-util": { "model": "{file:./.opencode/models/default.txt}" }, "git-clone-util": { "model": "{file:./.opencode/models/default.txt}" }, @@ -403,19 +403,17 @@ "implementation-worker": { "model": "{file:./.opencode/models/default.txt}" }, "pr-merge-supervisor": { "model": "{file:./.opencode/models/default.txt}" }, "pr-merge-worker": { "model": "{file:./.opencode/models/default.txt}" }, - "pr-review-worker": { "model": "local-claude/claude-sonnet-4-6" }, - "pr-review-worker-dispute": { "model": "local-claude/claude-opus-4-6" }, + "pr-review-worker": { "model": "{file:./.opencode/models/pr-review-worker.txt}" }, + "pr-review-worker-dispute": { "model": "{file:./.opencode/models/pr-review-worker-dispute.txt}" }, "session-health-full-util": { "model": "{file:./.opencode/models/default.txt}" }, "session-health-quick-util": { "model": "{file:./.opencode/models/default.txt}" }, "session-health-util": { "model": "{file:./.opencode/models/default.txt}" }, "supervisor": { "model": "{file:./.opencode/models/default.txt}" }, "work-group-util": { "model": "{file:./.opencode/models/default.txt}" }, - "task-implementor-tier-min": { "model": "local-claude/claude-haiku-4-5" }, "task-implementor-tier-0": { "model": "local-claude/claude-haiku-4-5" }, "task-implementor-tier-1": { "model": "local-claude/claude-sonnet-4-6" }, "task-implementor-tier-2": { "model": "local-claude/claude-opus-4-6" }, - "ca-test-infra-improver": { "model": "{file:./.opencode/models/ca-test-infra-improver.txt}" } } } diff --git a/tests/auto_agents/test_opencode_worker_models.py b/tests/auto_agents/test_opencode_worker_models.py index ff8683ff5..9d59c9e62 100644 --- a/tests/auto_agents/test_opencode_worker_models.py +++ b/tests/auto_agents/test_opencode_worker_models.py @@ -45,6 +45,7 @@ from __future__ import annotations import importlib.util import json +import subprocess import sys from pathlib import Path from typing import Any @@ -384,80 +385,32 @@ def test_every_md_agent_is_wired_or_in_inheritor_allowlist(): ) -# Agents that intentionally declare ``model:`` in their .md frontmatter. -# -# Background: the original Stage 1 centralization stripped ``model:`` from -# all .md files in favor of opencode.json + .opencode/models/.txt. -# But empirical testing (controller trial run-2, 2026-05-18) plus the -# .opencode/models/README.md operational note both confirm OpenCode reads -# ``agent..model`` from its own startup-cached config — the -# dispatcher's per-session model pass-through is recorded for -# observability but NOT consumed by the generation path. The ONLY way -# to actually change which model OpenCode runs for an agent is to bake -# the model into the agent's source — either in the agent's .md -# frontmatter or in opencode.json's agent block. We chose .md frontmatter -# for the controller-managed agents because they are single-purpose -# (each agent corresponds to exactly one MCP role) and the model choice -# is part of the role's identity. -_MD_FRONTMATTER_MODEL_ALLOWED = { - # Controller-managed estimator pinned to sonnet (batch P, 2026-05-18): - # the estimator's reasoning quality materially affects tier - # selection — picking tier 0 for a tier-2 PR wastes a cheap - # attempt; picking tier 2 for trivia burns budget. Haiku is too - # noisy for that judgment. - "estimator-implementation.md", - # Tier-implementer variants (2026-05-18): each tier in the - # escalation ladder gets its own model. Pre-fix, the model lived - # only in .opencode/models/.txt with the expectation that - # opencode.json's agent block would interpolate via {file:...}. - # But opencode.json has no agent block, and OpenCode reads model - # from .md frontmatter at startup (ignoring the dispatcher's - # per-session pass-through). Without the .md frontmatter pin, all - # 4 variants ran on the same default model, making the - # tier-escalation ladder cosmetic. The model: line in each tier - # variant's .md frontmatter is now generated from tiers.yaml by - # tools/sync_tier_models.py — DO NOT hand-edit; edit tiers.yaml - # and re-run the script. - "task-implementor-tier-min.md", - "task-implementor-tier-0.md", - "task-implementor-tier-1.md", - "task-implementor-tier-2.md", -} +def test_model_registry_in_sync_with_manifest(): + """Every model-bearing surface stays in sync with the manifest. + ``.opencode/models/models.yaml`` is the single source of truth for + per-agent model assignment. ``tools/sync_models.py`` regenerates the + derived surfaces from it — the ``.opencode/models/*.txt`` files, the + ``opencode.json`` ``agent`` block, and each non-tier agent's ``.md`` + ``model:`` frontmatter (the "both channels" design: the assignment + is written to every surface OpenCode might read, so it is correct + regardless of which one the running OpenCode version honours). -def test_no_md_agent_has_model_in_frontmatter(): - """No ``.opencode/agents/.md`` file may declare a ``model:`` - line UNLESS it's in ``_MD_FRONTMATTER_MODEL_ALLOWED`` (controller- - managed agents that intentionally pin a specific model). - - The legacy centralization invariant said EVERY model assignment - lives in ``opencode.json`` + ``.opencode/models/.txt``. But - OpenCode caches its config at startup and ignores the dispatcher's - per-session pass-through (see .opencode/models/README.md operational - note), so for agents whose model must be controlled at the source - level, the .md frontmatter IS the right place. The allowlist above - is the audit trail — adding to it requires a comment explaining why. + Running the generator's ``--check`` mode here fails the build on any + drift from ``models.yaml`` — a hand-edited ``opencode.json`` agent + entry, a stale ``.md`` ``model:`` line, an out-of-date ``.txt`` file. + Tier agents (``task-implementor-tier-*``) are covered separately by + ``test_tier_model_registry.py``. """ - import re - - pattern = re.compile(r'^model:\s*"?[^"\n]+"?\s*$', re.MULTILINE) - - offenders: list[str] = [] - for md in sorted((REPO_ROOT / ".opencode/agents").glob("*.md")): - if md.name in _MD_FRONTMATTER_MODEL_ALLOWED: - continue - if pattern.search(md.read_text()): - offenders.append(md.name) - - assert not offenders, ( - f"the following .opencode/agents/*.md files contain a 'model:' " - f"line in their YAML frontmatter but are NOT in the allowlist " - f"_MD_FRONTMATTER_MODEL_ALLOWED: {offenders}.\n" - f"Either:\n" - f" (a) remove the line and configure the model via " - f"opencode.json + .opencode/models/.txt instead, OR\n" - f" (b) add the file to _MD_FRONTMATTER_MODEL_ALLOWED with a " - f"comment explaining why the source-level pin is needed." + result = subprocess.run( + [sys.executable, str(REPO_ROOT / "tools" / "sync_models.py"), "--check"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + "model registry out of sync with .opencode/models/models.yaml — " + "run `python3 tools/sync_models.py` to regenerate.\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" ) diff --git a/tools/sync_models.py b/tools/sync_models.py new file mode 100755 index 000000000..01646f86b --- /dev/null +++ b/tools/sync_models.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Regenerate the model-registry artifacts from the single source of truth. + +``.opencode/models/models.yaml`` is the ONE file humans edit to assign an +LLM model to an agent. This script regenerates every artifact derived from +it so a model assignment can never drift across surfaces: + + - ``.opencode/models/.txt`` — one per non-default agent; read by + the dispatcher's ``_opencode_worker._resolve_role_model``. + - ``.opencode/models/default.txt`` — the fallback model. + - the ``agent`` block of ``opencode.json`` — each agent points at a + ``{file:.../.txt}`` model file. + - the ``model:`` frontmatter line of every managed agent's + ``.opencode/agents/.md``. + +opencode.json and the ``.md`` frontmatter are BOTH written, because the +repo's own history disagrees on which one OpenCode honours at startup — +writing both makes the assignment correct regardless. All four surfaces +are generated from the one manifest, so they cannot disagree. + +A per-agent ``.txt`` whose agent is no longer in the manifest is an +ORPHAN: the dispatcher's resolver reads ``.txt`` before +``default.txt``, so a leftover file would silently pin the old model. +This script deletes such orphans on every run (tier ``.txt`` files +excepted — those belong to ``sync_tier_models.py``). + +Tier-ladder agents (``task-implementor-tier-*``) are governed separately +by ``tiers.yaml`` + ``tools/sync_tier_models.py``; this script passes +their opencode.json entries through untouched and never edits their +``.md`` files. Agents with no opencode.json ``agent`` entry (e.g. +``task-implementor``) deliberately inherit their caller's model and are +left alone. + +Usage: + python3 tools/sync_models.py # regenerate (idempotent) + python3 tools/sync_models.py --check # verify only; exit 1 on drift + +The pipeline launcher runs this with no args at every startup, before +OpenCode caches its config, so the registry is always applied. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +MODELS_DIR = REPO_ROOT / ".opencode" / "models" +AGENTS_DIR = REPO_ROOT / ".opencode" / "agents" +MODELS_YAML = MODELS_DIR / "models.yaml" +OPENCODE_JSON = REPO_ROOT / "opencode.json" + +# Tier variants are owned by tiers.yaml + sync_tier_models.py — their +# opencode.json entries and .md files are left exactly as-is here. +TIER_AGENT_PREFIX = "task-implementor-tier-" + +# Frontmatter of an agent .md: '---\n' ... '\n' '---\n'. Non-greedy so it +# stops at the FIRST closing delimiter (not a later markdown '---' rule). +_FRONTMATTER_RE = re.compile(r"^---\n(.*?\n)---\n", re.DOTALL) +# A top-level `model:` line WITHIN the frontmatter (no leading indent, so +# a nested key or a prose mention in the body is never matched). +_MODEL_LINE_RE = re.compile(r"^model:.*$", re.MULTILINE) + + +class SyncError(RuntimeError): + """A manifest / validation problem that should abort the sync.""" + + +def _load_manifest() -> tuple[str, dict[str, str]]: + """Parse models.yaml → (default_model, {agent: model}).""" + if not MODELS_YAML.exists(): + raise SyncError(f"manifest not found: {MODELS_YAML}") + data = yaml.safe_load(MODELS_YAML.read_text(encoding="utf-8")) or {} + default = data.get("default") + agents = data.get("agents") or {} + if not isinstance(default, str) or "/" not in default: + raise SyncError("models.yaml: `default` must be a 'providerID/modelID' string") + if not isinstance(agents, dict): + raise SyncError("models.yaml: `agents` must be a mapping") + for name, model in agents.items(): + if not isinstance(model, str) or "/" not in model: + raise SyncError( + f"models.yaml: agents.{name} must be a 'providerID/modelID' string" + ) + if name.startswith(TIER_AGENT_PREFIX): + raise SyncError( + f"models.yaml: agents.{name} is a tier agent — assign it in " + "tiers.yaml, not here" + ) + return default, dict(agents) + + +def _validate_providers(default: str, agents: dict[str, str], opencode: dict) -> None: + """Every model's provider half must be declared in opencode.json.""" + providers = set(opencode.get("provider", {})) + for label, model in [("default", default), *agents.items()]: + provider = model.split("/", 1)[0] + if provider not in providers: + raise SyncError( + f"model for {label!r} uses provider {provider!r}, which is not " + "declared in opencode.json's `provider` block" + ) + + +def _expected_txt_files(default: str, agents: dict[str, str]) -> dict[Path, str]: + """The .txt files this registry owns, mapped to their exact content.""" + out = {MODELS_DIR / "default.txt": default + "\n"} + for name, model in agents.items(): + out[MODELS_DIR / f"{name}.txt"] = model + "\n" + return out + + +def _agent_order(current_agents: dict, agents: dict[str, str]) -> list[str]: + """The opencode.json ``agent`` block's key order after the manifest + is applied: existing agents keep their file order; manifest agents + not already present are appended in manifest order. + + Both the rendered ``agent`` block and the set of ``.md`` files to + manage derive from this one ordering, so the two channels can never + end up covering different agents. + """ + keys = list(current_agents.keys()) + for name in agents: + if name not in keys: + keys.append(name) + return keys + + +def _render_agent_block( + keys: list[str], current_agents: dict, agents: dict[str, str] +) -> str: + """Render the opencode.json ``agent`` block for the ordered ``keys``. + + Non-tier agents point at a ``{file:...}`` model file (their own when + overridden, else ``default.txt``); tier agents are passed through + with their current opencode.json value. + """ + resolved: dict[str, str] = {} + for name in keys: + if name.startswith(TIER_AGENT_PREFIX): + resolved[name] = str(current_agents[name].get("model", "")) + elif name in agents: + resolved[name] = f"{{file:./.opencode/models/{name}.txt}}" + else: + resolved[name] = "{file:./.opencode/models/default.txt}" + + width = max(len(f'"{k}":') for k in keys) + lines = [' "agent": {'] + for i, name in enumerate(keys): + comma = "" if i == len(keys) - 1 else "," + label = f'"{name}":'.ljust(width) + lines.append( + " " + label + ' { "model": "' + resolved[name] + '" }' + comma + ) + lines.append(" }") + return "\n".join(lines) + + +def _splice_opencode_json(block: str) -> str: + """Return opencode.json text with its ``agent`` block replaced. + + The ``agent`` key is the last top-level key, so everything from + ``\\n "agent": {`` to EOF is regenerated; the rest of the file is + preserved byte-for-byte. + """ + text = OPENCODE_JSON.read_text(encoding="utf-8") + match = re.search(r'\n "agent": \{', text) + if not match: + raise SyncError('could not locate the "agent" block in opencode.json') + return text[: match.start()] + "\n" + block + "\n}\n" + + +def _md_with_model(text: str, model: str, label: str) -> str: + """Return the agent-.md ``text`` with its frontmatter ``model:`` line + set to ``model`` — replacing an existing line in place, or inserting + one as the first frontmatter key. Everything else is byte-preserved. + """ + match = _FRONTMATTER_RE.match(text) + if not match: + raise SyncError(f"{label}: no YAML frontmatter ('--- ... ---')") + frontmatter = match.group(1) + desired = f"model: {model}" + if _MODEL_LINE_RE.search(frontmatter): + new_fm = _MODEL_LINE_RE.sub(lambda _m: desired, frontmatter, count=1) + else: + new_fm = desired + "\n" + frontmatter + return "---\n" + new_fm + "---\n" + text[match.end() :] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Sync the model registry from .opencode/models/models.yaml" + ) + parser.add_argument( + "--check", + action="store_true", + help="verify the generated artifacts are in sync; write nothing; " + "exit 1 on drift", + ) + args = parser.parse_args() + + try: + default, agents = _load_manifest() + opencode = json.loads(OPENCODE_JSON.read_text(encoding="utf-8")) + _validate_providers(default, agents, opencode) + if not isinstance(opencode.get("agent"), dict): + raise SyncError("opencode.json has no `agent` block") + current_agents = opencode["agent"] + + # Every manifest agent must name a REAL agent — i.e. have an + # `.opencode/agents/.md`. Without this, a typo'd manifest + # key is silently appended to opencode.json as a bogus agent + # (and gets an orphan .txt), while the real agent it was meant + # to name keeps the default model — no error, wrong result. + for name in agents: + if not (AGENTS_DIR / f"{name}.md").exists(): + raise SyncError( + f"models.yaml: agents.{name} has no " + f".opencode/agents/{name}.md — a manifest key must name a " + "real agent (check for a typo)" + ) + + # One ordering drives BOTH OpenCode-facing channels (the + # opencode.json block and the .md frontmatter), so a manifest + # agent appended to opencode.json also gets its .md written. + agent_order = _agent_order(current_agents, agents) + + txt_files = _expected_txt_files(default, agents) + expected_json = _splice_opencode_json( + _render_agent_block(agent_order, current_agents, agents) + ) + # Validate the regenerated JSON BEFORE writing anything, so a + # bad render aborts cleanly instead of leaving opencode.json + # corrupt on disk. + try: + json.loads(expected_json) + except ValueError as exc: + raise SyncError( + f"regenerated opencode.json is not valid JSON ({exc})" + ) from exc + + # Every non-tier agent in the final order gets its .md + # frontmatter `model:` set too (the "both channels" guarantee). + md_files: dict[Path, str] = {} + for name in agent_order: + if name.startswith(TIER_AGENT_PREFIX): + continue + md_path = AGENTS_DIR / f"{name}.md" + if not md_path.exists(): + raise SyncError( + f"agent {name!r} is in opencode.json but has no {md_path}" + ) + model = agents.get(name, default) + md_files[md_path] = _md_with_model( + md_path.read_text(encoding="utf-8"), model, md_path.name + ) + + # Orphan .txt files: a non-tier .txt left behind after + # its override was dropped from models.yaml would still be read + # FIRST by the dispatcher's resolver (.txt before + # default.txt), silently pinning the stale model. Tier .txt + # files belong to sync_tier_models.py and are never touched. + expected_txt = set(txt_files) + orphan_txt = sorted( + p + for p in MODELS_DIR.glob("*.txt") + if p not in expected_txt and not p.stem.startswith(TIER_AGENT_PREFIX) + ) + except (SyncError, ValueError) as exc: + print(f"sync_models: ERROR: {exc}", file=sys.stderr) + return 1 + + rel = lambda p: str(p.relative_to(REPO_ROOT)) # noqa: E731 + drift: list[Path] = [] + for path, content in txt_files.items(): + if not path.exists() or path.read_text(encoding="utf-8") != content: + drift.append(path) + if OPENCODE_JSON.read_text(encoding="utf-8") != expected_json: + drift.append(OPENCODE_JSON) + for path, content in md_files.items(): + if path.read_text(encoding="utf-8") != content: + drift.append(path) + drift.extend(orphan_txt) + + if args.check: + if drift: + print( + f"sync_models: DRIFT — {len(drift)} artifact(s) out of sync with " + "models.yaml:\n " + "\n ".join(sorted(rel(p) for p in drift)), + file=sys.stderr, + ) + return 1 + print("sync_models: registry in sync with models.yaml") + return 0 + + if not drift: + print("sync_models: registry already in sync — no changes") + return 0 + + for path, content in txt_files.items(): + path.write_text(content, encoding="utf-8") + OPENCODE_JSON.write_text(expected_json, encoding="utf-8") + for path, content in md_files.items(): + path.write_text(content, encoding="utf-8") + for path in orphan_txt: + path.unlink() + + orphan_set = set(orphan_txt) + updated = sorted(rel(p) for p in drift if p not in orphan_set) + parts: list[str] = [] + if updated: + md_changed = sum(1 for p in updated if p.endswith(".md")) + listed = [p for p in updated if not p.endswith(".md")] + if md_changed: + listed.append(f"{md_changed} agent .md file(s)") + parts.append("updated " + ", ".join(listed)) + if orphan_txt: + parts.append( + "removed orphan " + + ", ".join(sorted(rel(p) for p in orphan_txt)) + + " (no longer in models.yaml)" + ) + print("sync_models: applied registry — " + "; ".join(parts)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())