6178be3aa7
.opencode/models/models.yaml is now the ONE file humans edit to assign a model to an agent. tools/sync_models.py regenerates every derived surface — the .opencode/models/*.txt files, opencode.json's `agent` block, and each non-tier agent's .md `model:` frontmatter — so an assignment cannot drift across surfaces. `--check` verifies with no writes and is enforced in CI by test_model_registry_in_sync_with_manifest. Hardened after adversarial review: - Deletes orphan <agent>.txt files left behind when an override is dropped from the manifest. The dispatcher's resolver reads <agent>.txt before default.txt, so a stale file would silently pin the old model. Tier .txt files are left to sync_tier_models.py. - Rejects a manifest key that does not name a real agent (no matching .opencode/agents/<name>.md) instead of silently appending a bogus opencode.json entry and leaving the real agent on the default model. - Validates the regenerated opencode.json BEFORE writing it, so a bad render aborts cleanly instead of corrupting the file on disk. Tier-ladder agents (task-implementor-tier-*) remain governed separately by tiers.yaml + sync_tier_models.py and are passed through untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
334 lines
13 KiB
Python
Executable File
334 lines
13 KiB
Python
Executable File
#!/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/<agent>.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:.../<agent>.txt}`` model file.
|
|
- the ``model:`` frontmatter line of every managed agent's
|
|
``.opencode/agents/<agent>.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 ``<agent>.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/<name>.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 <agent>.txt left behind after
|
|
# its override was dropped from models.yaml would still be read
|
|
# FIRST by the dispatcher's resolver (<agent>.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())
|