80d61de942
Eliminates the remaining LLM wrapper chain (``tier-dispatcher`` +
``tier-{min,0,1,2}`` selectors) between the Python dispatcher and the
``task-implementor`` worker. Follows the R2 implementation-worker
retirement (6e63073ad, 2026-05-16); both wrappers were pure routing
agents with no per-cycle judgment that could not be moved to Python.
Architecture
------------
Before (R2 baseline):
dispatch_implementer.py
→ tier-dispatcher (LLM)
→ estimator-implementation (LLM, judgment)
→ tier-N selector (LLM, pure pass-through)
→ task-implementor (LLM, the actual work, via `task` hop)
After (R3):
dispatch_implementer.py
→ estimator-implementation (LLM, judgment — invoked top-level)
→ task-implementor-tier-N (LLM, the actual work, NO `task` hops)
Two LLM hops eliminated per cycle. The ``task`` tool hop between the
tier-N selector and task-implementor is gone too, so the dispatcher's
prefetched ``## Pre-fetched …`` sections survive intact in the
worker's prompt — closing the structural cause of the ~30-80
per-session ``implementer_pr_context.py read --pr N`` round-trips
the worker burned to recover summarised-away context.
Cost savings (4-day measurement window, $-figures based on
local-claude pricing with caching):
- Eliminating tier-dispatcher sessions (32/day): ~$5-15/day
- Eliminating tier-N selector sessions (15/day): ~$2-5/day
- Eliminating prefetch round-trips (229/4d → expected near 0): ~$20-40/day
Aggregate at current traffic: roughly $30-60/day, $900-1,800/month.
What changed
------------
1. **New ``sync_tier_models.py`` scope** — generates per-tier
``task-implementor-{slot}.md`` + matching
``.opencode/models/task-implementor-{slot}.txt`` files from
``task-implementor.md`` (the byte source). Dropped: the bare
``tier-N.txt`` model files (no consumer) and the
tier-dispatcher.md mapping-table generation (no file).
2. **New ``_call_python_estimator``** in dispatch_implementer.py
invokes ``estimator-implementation`` as a top-level OpenCode
session, parses ``{is_confident, recommended_tier}``, returns the
tier integer or None. Includes a heartbeat-refresh on_poll so a
30-180 s estimator call cannot trigger the launcher's hung-
process watchdog. Estimator switched from ``mode: subagent`` to
``mode: all`` so the dispatcher can spawn it directly.
3. **New ``_resolve_task_implementor_for_tier(tier)`` helper** maps
manifest tier integers to the matching ``task-implementor-{slot}``
variant. Used by both the initial dispatch (in the prompt
factory) and the in-cycle escalation respawn.
4. **WorkGroup contract extended** with
``requires_worker_agent_override: bool`` (default False, opt-in
per group). The implementer's three WorkGroups set True;
``_resolve_effective_worker_agent`` raises a clear RuntimeError
if the prompt_factory failed to populate the override (a code
bug that would otherwise silently run every cycle at the static
fallback tier).
5. **``_implementation_prompt_dispatch`` refactored** to:
- Resolve the tier in Python (label-driven hint → estimator →
default 0), honouring both the in-cycle escalation flag and the
estimator-enabled flag.
- Stash the resolved ``task-implementor-tier-<slot>`` agent name
on the item context under
``WORKER_AGENT_OVERRIDE_ITEM_KEY`` (single source of truth in
``_dispatch_runtime``; imported into the higher layer).
- Emit the worker body with ``escalation_tier: \`N\``` directly —
no more ``escalation_tier_hint``, ``task_prompt:`` fence, or
``task_agent:``/``estimator_agent:`` outer parameters (all
consumed by the retired tier-dispatcher).
- Skip the estimator call on ``--dry-run`` so the operator-
visible no-I/O contract holds.
6. **Retired agent files DELETED**:
- ``.opencode/agents/tier-dispatcher.md``
- ``.opencode/agents/tier-{min,0,1,2}.md``
- ``.opencode/models/tier-{min,0,1,2}.txt``
- Matching entries in ``opencode.json``'s agent block.
7. **Prose updates** to ``task-implementor.md`` (the byte-source for
variants), ``estimator-implementation.md``, and production
docstrings (``_block_store.py``, ``_pr_context_sentinel.py``,
``implementer_workspace.py``, ``_review_post.py``,
``_review_finalize.py``) reflecting the post-R3 chain. The
filesystem handoff scripts (``implementer_pr_context.py``,
``implementer_workspace.py``) remain in place as the canonical
read path — defensive against any future regression that re-
introduces summarisation.
Tests
-----
2262 auto_agents passing (was 2268 pre-R3; net -6 from
removing tests pinning the retired wrapper-chain contract,
offset by +14 new tests pinning the post-R3 contract):
- ``TestEstimatorEnabledFlag`` rewritten to assert
``escalation_tier`` + agent-override semantics.
- New ``TestEstimatorPromptShape`` (5 tests) pins the body shape
the Python estimator helper passes to the agent and the
call shape into ``run_session_blocking``.
- New ``TestResolveEffectiveWorkerAgent`` (8 tests) directly
covers the override priority chain — override present, empty,
whitespace, non-string, whitespace-stripped, required-but-missing
(loud fail), required-and-present.
- ``test_dry_run_never_calls_estimator`` pins the dry-run no-I/O
contract via an exploding-stub guard on the estimator helper.
- ``TestDirectTierDispatch`` replaces the retired
``TestTierDispatcherShortCircuit`` suite in
``test_worker_permissions.py``.
- ``TestTaskImplementorVariantsAreByteIdentical`` ensures the
four per-tier variants never hand-diverge from each other.
- ``test_no_legacy_tier_agents_in_opencode_agent_block`` fails
loudly if any of the retired tier-* entries are re-introduced
to ``opencode.json``.
Operator notes
--------------
- The C3 footgun (model swaps need OpenCode restart) still applies
to the generated variants — edit ``tiers.yaml``, re-run
``python3 tools/sync_tier_models.py``, then restart OpenCode.
- The estimator now runs as a top-level OpenCode session; an
operator grepping the session archive will see
``[AUTO-IMP-PR-N-estimator] estimator-implementation`` entries
alongside the worker sessions.
- Roll-back: revert this commit + the R3 prep commit (b8c1e4903).
Both wrappers + the static-fallback ``worker_agent`` come back;
no schema migration needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
12 KiB
Python
303 lines
12 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). Post-R3 retirement
|
|
(2026-05-17) the dispatcher 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`` — a byte copy of
|
|
``task-implementor.md`` with an HTML-comment generation header
|
|
prepended. The ``.md`` provides the slot's distinct
|
|
``agent.<name>.model`` identity for OpenCode's startup-cached
|
|
resolver (which is how the model is enforced at session-create
|
|
time).
|
|
2. ``.opencode/models/task-implementor-<slot>.txt`` — one
|
|
``providerID/modelID`` line per file, with a single trailing
|
|
newline. Referenced by ``opencode.json``'s agent block via
|
|
``{file:.../task-implementor-<slot>.txt}`` interpolation.
|
|
|
|
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 ``.txt`` files together.
|
|
- 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.
|
|
|
|
Scope
|
|
-----
|
|
This script does NOT touch ``opencode.json``. The provider block there
|
|
(HTTP endpoints, auth headers, npm packages) is unrelated to the tier
|
|
ladder. The ``agent`` block in ``opencode.json`` is also untouched —
|
|
that block enumerates which agents exist, which is a separate concern
|
|
from which model each tier currently uses. Adding/removing tiers
|
|
needs a corresponding ``opencode.json`` agent-block edit, but it's a
|
|
one-time change at refactor time.
|
|
"""
|
|
|
|
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) -> str:
|
|
"""Return the full file content for one task-implementor-tier-N
|
|
variant: the generation header followed by a byte copy of the
|
|
source ``task-implementor.md`` content.
|
|
|
|
Same body for every tier — the only thing that distinguishes
|
|
the variants is (a) the filename, and (b) the matching
|
|
``.opencode/models/<name>.txt`` whose model OpenCode resolves
|
|
via the agent block in ``opencode.json``. The model lives in
|
|
its own file rather than the frontmatter so the variants
|
|
remain byte-identical bodies of a single source-of-truth
|
|
(matching the design decision in the May 10 model-registry
|
|
centralisation that stripped 39 ``model:`` lines from agent
|
|
frontmatter).
|
|
"""
|
|
return TASK_IMPLEMENTOR_VARIANT_HEADER + source
|
|
|
|
|
|
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]] = []
|
|
source = TASK_IMPLEMENTOR_SOURCE.read_text(encoding="utf-8")
|
|
desired_variant_md = render_task_implementor_variant(source)
|
|
for e in entries:
|
|
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))
|
|
txt_path = e.task_implementor_txt_path
|
|
desired_txt = e.model_file_contents
|
|
existing_txt = txt_path.read_text(encoding="utf-8") if txt_path.exists() else None
|
|
if existing_txt != desired_txt:
|
|
pending.append((txt_path, desired_txt))
|
|
|
|
if check_only:
|
|
for path, _ in pending:
|
|
rel = path.relative_to(REPO_ROOT)
|
|
print(f"DRIFT: {rel}", file=sys.stderr)
|
|
return len(pending)
|
|
|
|
for path, content in pending:
|
|
path.write_text(content, encoding="utf-8")
|
|
rel = path.relative_to(REPO_ROOT)
|
|
print(f"wrote {rel}")
|
|
return len(pending)
|
|
|
|
|
|
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())
|