0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
386 lines
16 KiB
Python
386 lines
16 KiB
Python
"""Generate per-tier ``task-implementor`` agent variants from the
|
|
single-source-of-truth manifest at ``.opencode/models/tiers.yaml``.
|
|
|
|
Why this exists
|
|
---------------
|
|
The implementer pipeline routes work through a model-tier ladder
|
|
(``escalation_tier`` -1, 0, 1, 2). The controller invokes
|
|
``task-implementor-tier-<slot>`` variants directly — one ``.md``
|
|
agent + one ``.txt`` model file per slot, both generated from the
|
|
single source of truth at ``.opencode/agents/task-implementor.md``
|
|
(body) and ``.opencode/models/tiers.yaml`` (per-slot model assignment).
|
|
|
|
This script (idempotently) regenerates:
|
|
|
|
1. ``.opencode/agents/task-implementor-<slot>.md`` — the
|
|
``task-implementor.md`` body with an HTML-comment generation
|
|
header prepended AND a ``model: providerID/modelID`` line injected
|
|
into the YAML frontmatter (this is what OpenCode actually reads at
|
|
session-create time per the .opencode/models/README.md operational
|
|
note; the dispatcher's runtime model pass-through is recorded in
|
|
session metadata but NOT consumed by the generation path).
|
|
2. ``.opencode/models/task-implementor-<slot>.txt`` — one
|
|
``providerID/modelID`` line per file, with a single trailing
|
|
newline. Kept for dispatcher-side observability + as a backup
|
|
resolution channel (``_opencode_worker._resolve_role_model``
|
|
reads it).
|
|
|
|
Why model injected into BOTH (.md AND .txt)
|
|
-------------------------------------------
|
|
Pre-2026-05-18 the model lived only in the ``.txt`` file with the
|
|
expectation that ``opencode.json``'s ``agent.<name>.model`` block
|
|
would reference it via ``{file:.../tier-N.txt}`` interpolation. But:
|
|
- opencode.json does NOT carry an ``agent`` block (only ``mcp``)
|
|
- Per the README + trial-2 empirical observation, OpenCode reads
|
|
``model:`` from the agent's .md frontmatter at startup; it
|
|
ignores the dispatcher's per-session model pass-through
|
|
|
|
Result before the fix: all 4 tier variants ran on the same default
|
|
model — the escalation ladder picked different agent names but the
|
|
models never actually swapped. The .md injection here is the
|
|
mechanism that makes tier escalation real.
|
|
|
|
Operator workflow
|
|
-----------------
|
|
- To swap a model in a slot: edit ``tiers.yaml`` and run
|
|
``python3 tools/sync_tier_models.py``. Commit the manifest +
|
|
the regenerated .md and .txt files together. Restart OpenCode
|
|
for the new model to take effect (OpenCode caches the .md
|
|
frontmatter at startup).
|
|
- To verify on CI / before commit: run
|
|
``python3 tools/sync_tier_models.py --check``. Exits non-zero on
|
|
any drift between manifest and generated files.
|
|
- The drift-detection test
|
|
``tests/auto_agents/test_tier_model_registry.py`` runs ``--check``
|
|
semantics so PR builds fail loudly if someone hand-edits a
|
|
generated file or forgets to re-run the script.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
MANIFEST_PATH = REPO_ROOT / ".opencode" / "models" / "tiers.yaml"
|
|
MODELS_DIR = REPO_ROOT / ".opencode" / "models"
|
|
AGENTS_DIR = REPO_ROOT / ".opencode" / "agents"
|
|
TASK_IMPLEMENTOR_SOURCE = AGENTS_DIR / "task-implementor.md"
|
|
|
|
# Header prepended to every generated task-implementor variant. Sits BEFORE
|
|
# the frontmatter as an HTML comment so OpenCode's YAML frontmatter parser
|
|
# does not treat it as a key/value line. The marker also lets the generator
|
|
# detect drift even when an operator hand-edits a variant file body.
|
|
TASK_IMPLEMENTOR_VARIANT_HEADER = (
|
|
"<!-- GENERATED BY tools/sync_tier_models.py — DO NOT EDIT.\n"
|
|
" Source of truth: .opencode/agents/task-implementor.md\n"
|
|
" This variant exists to give OpenCode a distinct agent.<name>.model\n"
|
|
" slot for the task-implementor pipeline's escalation-tier ladder\n"
|
|
" (R3 wrapper-chain retirement, 2026-05-17). The body is a byte copy\n"
|
|
" of the source; only the filename + opencode.json agent entry differ.\n"
|
|
" To change the body: edit task-implementor.md and re-run the\n"
|
|
" generator. To swap which model fills this slot: edit\n"
|
|
" .opencode/models/tiers.yaml and re-run the generator. -->\n"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TierEntry:
|
|
"""One slot in the tier ladder.
|
|
|
|
The ``agent`` field is a SLOT NAME (e.g. ``tier-0``,
|
|
``tier-min``) — kept stable across the R3 retirement
|
|
(2026-05-17) so manifests, labels, and operator-facing tier
|
|
integers stay byte-equivalent. The actual OpenCode agent
|
|
invoked at this slot is the generated
|
|
``task-implementor-{slot}`` variant; see
|
|
:attr:`task_implementor_variant_name`.
|
|
"""
|
|
|
|
tier: int
|
|
agent: str # slot name, NOT an OpenCode agent (the variant is)
|
|
model: str
|
|
capability: str
|
|
description: str
|
|
|
|
@property
|
|
def model_file_contents(self) -> str:
|
|
"""Exactly one line, single trailing newline — the resolver
|
|
in ``_opencode_worker._resolve_role_model`` strips whitespace
|
|
but we keep the format canonical for diff cleanliness."""
|
|
return f"{self.model}\n"
|
|
|
|
@property
|
|
def task_implementor_variant_name(self) -> str:
|
|
"""Variant agent name for this tier's task-implementor copy.
|
|
|
|
Names match the pattern OpenCode resolves through its
|
|
``agent.<name>.model`` block; each variant gets its own
|
|
``.opencode/models/<name>.txt`` so the model is enforced at
|
|
session-create time rather than inherited through a wrapper
|
|
agent. Pattern: ``task-implementor-{tier-slot-name}``.
|
|
"""
|
|
return f"task-implementor-{self.agent}"
|
|
|
|
@property
|
|
def task_implementor_md_path(self) -> Path:
|
|
return AGENTS_DIR / f"{self.task_implementor_variant_name}.md"
|
|
|
|
@property
|
|
def task_implementor_txt_path(self) -> Path:
|
|
return MODELS_DIR / f"{self.task_implementor_variant_name}.txt"
|
|
|
|
|
|
def load_manifest(path: Path = MANIFEST_PATH) -> list[TierEntry]:
|
|
"""Parse ``tiers.yaml`` and return an ordered list of TierEntry.
|
|
|
|
Raises:
|
|
FileNotFoundError: manifest is missing.
|
|
ValueError: manifest is malformed (missing keys, duplicate
|
|
tiers, non-list shape, etc.). The contract is strict
|
|
because a silently-mis-parsed manifest would propagate
|
|
wrong models into worker dispatch.
|
|
"""
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"manifest not found: {path}")
|
|
with path.open("r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f)
|
|
if not isinstance(data, dict) or "tiers" not in data:
|
|
raise ValueError(
|
|
f"manifest {path} must be a mapping with a top-level 'tiers:' key"
|
|
)
|
|
raw_tiers = data["tiers"]
|
|
if not isinstance(raw_tiers, list) or not raw_tiers:
|
|
raise ValueError(f"manifest {path} 'tiers' must be a non-empty list")
|
|
|
|
seen_tiers: set[int] = set()
|
|
seen_agents: set[str] = set()
|
|
entries: list[TierEntry] = []
|
|
required_keys = {"tier", "agent", "model", "capability", "description"}
|
|
for idx, raw in enumerate(raw_tiers):
|
|
if not isinstance(raw, dict):
|
|
raise ValueError(
|
|
f"manifest entry [{idx}] must be a mapping, got {type(raw).__name__}"
|
|
)
|
|
missing = required_keys - raw.keys()
|
|
if missing:
|
|
raise ValueError(f"manifest entry [{idx}] missing keys: {sorted(missing)}")
|
|
extra = raw.keys() - required_keys
|
|
if extra:
|
|
raise ValueError(
|
|
f"manifest entry [{idx}] has unknown keys: {sorted(extra)}"
|
|
)
|
|
tier = raw["tier"]
|
|
if not isinstance(tier, int) or isinstance(tier, bool):
|
|
raise ValueError(
|
|
f"manifest entry [{idx}] 'tier' must be an int, got {tier!r}"
|
|
)
|
|
if tier in seen_tiers:
|
|
raise ValueError(f"duplicate tier in manifest: {tier}")
|
|
seen_tiers.add(tier)
|
|
agent = raw["agent"]
|
|
if not isinstance(agent, str) or not agent.strip():
|
|
raise ValueError(
|
|
f"manifest entry [{idx}] 'agent' must be a non-empty string"
|
|
)
|
|
if agent in seen_agents:
|
|
raise ValueError(f"duplicate agent in manifest: {agent}")
|
|
seen_agents.add(agent)
|
|
model = raw["model"]
|
|
if not isinstance(model, str) or "/" not in model:
|
|
raise ValueError(
|
|
f"manifest entry [{idx}] 'model' must be 'providerID/modelID', "
|
|
f"got {model!r}"
|
|
)
|
|
entries.append(
|
|
TierEntry(
|
|
tier=tier,
|
|
agent=agent,
|
|
model=model,
|
|
capability=str(raw["capability"]),
|
|
description=str(raw["description"]),
|
|
)
|
|
)
|
|
# Stable, tier-ascending order — the generated dispatcher table is
|
|
# easier to read with -1 at the top.
|
|
entries.sort(key=lambda e: e.tier)
|
|
return entries
|
|
|
|
|
|
def render_task_implementor_variant(source: str, model: str) -> str:
|
|
"""Return the full file content for one task-implementor-tier-N
|
|
variant: the generation header, then the source's frontmatter with
|
|
a ``model: <providerID/modelID>`` line injected, then the source's
|
|
body unchanged.
|
|
|
|
The model line is injected into the YAML frontmatter (between
|
|
the opening ``---`` and the closing ``---``) immediately after
|
|
``hidden: false`` if present, else immediately after ``mode: …``.
|
|
Both anchor lines exist in task-implementor.md by convention.
|
|
|
|
Why frontmatter not just the .txt file: OpenCode reads ``model:``
|
|
from the .md frontmatter at startup (see module docstring). The
|
|
.txt file is a parallel observability channel + dispatcher
|
|
fallback but not the authoritative resolution path.
|
|
|
|
Idempotency: if the source frontmatter already carries a
|
|
``model:`` line (it MUST NOT, per the source-file invariant —
|
|
the source has no model: because each variant needs a different
|
|
one) we raise — silent re-injection would create two ``model:``
|
|
keys, which YAML parses as the LAST one winning but OpenCode's
|
|
parser may not.
|
|
"""
|
|
if _SOURCE_HAS_MODEL_LINE_RE.search(source):
|
|
raise ValueError(
|
|
f"{TASK_IMPLEMENTOR_SOURCE} carries a 'model:' line in its "
|
|
"frontmatter — the source must NOT pin a model because each "
|
|
"tier variant gets its own model from tiers.yaml. Remove the "
|
|
"model: line from the source and re-run."
|
|
)
|
|
# Inject `model:` between `hidden:` (or `mode:`) and the next line.
|
|
# The frontmatter is the block between the first two `---` lines.
|
|
lines = source.split("\n")
|
|
if lines[0].strip() != "---":
|
|
raise ValueError(
|
|
f"{TASK_IMPLEMENTOR_SOURCE} does not start with YAML frontmatter "
|
|
f"opening '---' (got {lines[0]!r})"
|
|
)
|
|
closing_idx = None
|
|
for i in range(1, len(lines)):
|
|
if lines[i].strip() == "---":
|
|
closing_idx = i
|
|
break
|
|
if closing_idx is None:
|
|
raise ValueError(f"{TASK_IMPLEMENTOR_SOURCE} frontmatter has no closing '---'")
|
|
# Find injection point: after `hidden:` if present, else after `mode:`.
|
|
inject_after: int | None = None
|
|
for i in range(1, closing_idx):
|
|
stripped = lines[i].lstrip()
|
|
if stripped.startswith("hidden:"):
|
|
inject_after = i
|
|
break
|
|
if inject_after is None:
|
|
for i in range(1, closing_idx):
|
|
stripped = lines[i].lstrip()
|
|
if stripped.startswith("mode:"):
|
|
inject_after = i
|
|
break
|
|
if inject_after is None:
|
|
raise ValueError(
|
|
f"{TASK_IMPLEMENTOR_SOURCE} frontmatter has neither 'mode:' nor "
|
|
"'hidden:' anchor for model: injection"
|
|
)
|
|
model_line = (
|
|
"# Per-tier model (regenerated from .opencode/models/tiers.yaml by\n"
|
|
"# tools/sync_tier_models.py — do not hand-edit).\n"
|
|
f"model: {model}"
|
|
)
|
|
new_lines = lines[: inject_after + 1] + [model_line] + lines[inject_after + 1 :]
|
|
return TASK_IMPLEMENTOR_VARIANT_HEADER + "\n".join(new_lines)
|
|
|
|
|
|
# Pattern matches a `model:` key in YAML frontmatter — used to refuse
|
|
# regeneration when the source somehow grew one (which would create a
|
|
# duplicate when we inject).
|
|
import re as _re
|
|
|
|
_SOURCE_HAS_MODEL_LINE_RE = _re.compile(r"(?m)^model:\s+")
|
|
|
|
|
|
def sync(*, check_only: bool = False) -> int:
|
|
"""Generate (or verify) every derived artifact from the manifest.
|
|
|
|
Returns the number of files that would be (or were) written. In
|
|
``--check`` mode, a non-zero return is the drift signal — the
|
|
CLI maps it to exit code 1.
|
|
|
|
Two artifact families per tier slot (R3, 2026-05-17):
|
|
- ``task-implementor-<slot>.md`` — byte copy of the
|
|
task-implementor source + generation header.
|
|
- ``task-implementor-<slot>.txt`` — one-line model file
|
|
consumed by ``opencode.json``'s ``agent.<name>.model``
|
|
``{file:...}`` interpolation.
|
|
"""
|
|
entries = load_manifest()
|
|
|
|
if not TASK_IMPLEMENTOR_SOURCE.exists():
|
|
raise FileNotFoundError(
|
|
f"{TASK_IMPLEMENTOR_SOURCE} does not exist; cannot generate "
|
|
f"task-implementor variants"
|
|
)
|
|
|
|
pending: list[tuple[Path, str]] = []
|
|
stale: list[Path] = []
|
|
source = TASK_IMPLEMENTOR_SOURCE.read_text(encoding="utf-8")
|
|
for e in entries:
|
|
# Each variant gets its own model: line baked into the
|
|
# frontmatter, derived from tiers.yaml.
|
|
desired_variant_md = render_task_implementor_variant(source, e.model)
|
|
md_path = e.task_implementor_md_path
|
|
existing_md = md_path.read_text(encoding="utf-8") if md_path.exists() else None
|
|
if existing_md != desired_variant_md:
|
|
pending.append((md_path, desired_variant_md))
|
|
# The per-tier ``.txt`` files were a pre-2026-05-18 mechanism:
|
|
# the dispatcher read them, stuffed the model into the POST
|
|
# /session body, and expected OpenCode to honor the override.
|
|
# Empirical testing on 2026-05-18 proved OpenCode IGNORES the
|
|
# per-session model field and reads ``model:`` from the agent's
|
|
# .md frontmatter at startup. The .txt files are now dead;
|
|
# remove them if they exist.
|
|
txt_path = e.task_implementor_txt_path
|
|
if txt_path.exists():
|
|
stale.append(txt_path)
|
|
|
|
if check_only:
|
|
for path, _ in pending:
|
|
rel = path.relative_to(REPO_ROOT)
|
|
print(f"DRIFT: {rel}", file=sys.stderr)
|
|
for path in stale:
|
|
rel = path.relative_to(REPO_ROOT)
|
|
print(f"STALE (delete): {rel}", file=sys.stderr)
|
|
return len(pending) + len(stale)
|
|
|
|
for path, content in pending:
|
|
path.write_text(content, encoding="utf-8")
|
|
rel = path.relative_to(REPO_ROOT)
|
|
print(f"wrote {rel}")
|
|
for path in stale:
|
|
path.unlink()
|
|
rel = path.relative_to(REPO_ROOT)
|
|
print(f"removed (stale) {rel}")
|
|
return len(pending) + len(stale)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Regenerate tier model files and dispatcher mapping table "
|
|
"from .opencode/models/tiers.yaml."
|
|
)
|
|
)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="Exit non-zero if any generated file is out of sync with the "
|
|
"manifest. Does not write anything.",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
pending = sync(check_only=args.check)
|
|
if args.check and pending:
|
|
print(
|
|
f"\nERROR: {pending} file(s) out of sync with "
|
|
f"{MANIFEST_PATH.relative_to(REPO_ROOT)}.\n"
|
|
f"Run `python3 tools/sync_tier_models.py` to regenerate.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|