Let the board choose agent models, per launch intent
BOARD_AGENT_MODEL picks one model for every headless launch;
BOARD_AGENT_MODEL_WORK / _ACT_PR / _REVIEW beat it per intent (review
covers PR reviews and relevance checks). Empty — the default — means
the variable never reaches the adapter and the vendor's own resolution
applies, exactly today's behaviour; a stray AGENT_MODEL in the board's
own environment is stripped rather than inherited silently.
Core carries the name as an opaque string: config resolves intent →
model, _launch passes it as AGENT_MODEL, and each adapter renders it
natively — claude appends --model, opencode sets the generated
config's model key ("provider/model-id", per its docs). The resolved
model (or its absence) is recorded on the agent record, so the
Sessions and Focus views state what a run actually rode instead of
leaving it to whoever's machine the board happens to run on.
Stub-binary tests pin the byte-identical-when-unset guarantee, the
per-intent resolution, the launch-env seam and the record.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,19 @@ BOARD_AGENT_ADAPTER=claude
|
||||
BOARD_CLAUDE_BIN=claude
|
||||
BOARD_OPENCODE_BIN=opencode
|
||||
|
||||
# The model headless agents run on — an opaque vendor-native name the
|
||||
# adapter passes through untranslated (claude: a model name or alias for
|
||||
# --model; opencode: the "provider/model-id" config key). Empty = inherit
|
||||
# the vendor's own default, i.e. whatever the CLI on this machine would
|
||||
# pick anyway. The per-intent settings beat the general one for their
|
||||
# intent only; _REVIEW covers both PR reviews and relevance checks. Work
|
||||
# agents write code; reviews just read and judge — they can ride a
|
||||
# cheaper, faster model.
|
||||
BOARD_AGENT_MODEL=
|
||||
BOARD_AGENT_MODEL_WORK=
|
||||
BOARD_AGENT_MODEL_ACT_PR=
|
||||
BOARD_AGENT_MODEL_REVIEW=
|
||||
|
||||
# Command prefixes headless agents may run in their worktree — the
|
||||
# project's test/check commands, comma-separated, in plain neutral form
|
||||
# (each adapter renders them into its vendor's permission rules; the
|
||||
|
||||
@@ -15,9 +15,9 @@ An adapter is a directory with two executables:
|
||||
|
||||
- env in: `AGENT_PROMPT` (the full prompt), `AGENT_MODE` (the launch
|
||||
intent, below), `AGENT_COMMANDS` (the project's allowed command
|
||||
prefixes, below), `AGENT_CWD`, and the `BOARD_*` passthrough
|
||||
(`BOARD_AGENT_ID`, `BOARD_TASK`, `BOARD_PORT`) which your event bridge
|
||||
must forward with every event.
|
||||
prefixes, below), `AGENT_MODEL` (optional, below), `AGENT_CWD`, and
|
||||
the `BOARD_*` passthrough (`BOARD_AGENT_ID`, `BOARD_TASK`,
|
||||
`BOARD_PORT`) which your event bridge must forward with every event.
|
||||
- stdout is captured by the board as the job log. The prompts instruct the
|
||||
agent to end with marker lines (`NOT READY:`, `RELEVANCE REVIEW:`,
|
||||
`PR REVIEW:`, `ADDRESSED:`) — the board parses them from this output, so
|
||||
@@ -57,6 +57,17 @@ prefix-pattern based, so the translation is mechanical:
|
||||
"allow"}}` in a generated config, wildcard rules, last match wins
|
||||
(`opencode/permission_config.py`)
|
||||
|
||||
### The model (`AGENT_MODEL`) — optional
|
||||
|
||||
Absent = the vendor's own default: launch without any model argument and
|
||||
let your CLI resolve it however it normally would. When set, it is an
|
||||
opaque vendor-native model name — a claude alias, an opencode
|
||||
`provider/model-id` — that core never validates or interprets; pass it
|
||||
through untranslated (claude → `--model "$AGENT_MODEL"`, opencode → the
|
||||
generated config's `model` key). Never send your vendor an empty value:
|
||||
the board only sets the variable when a model is actually configured
|
||||
(`BOARD_AGENT_MODEL` and its per-intent overrides in `local/.env`).
|
||||
|
||||
### `wire` — wire live-session visibility into the host project
|
||||
|
||||
Called by `install.py` with the project root as argv[1] (plus `--dry-run`).
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
# (see core/adapters/README.md)
|
||||
# AGENT_COMMANDS comma-separated neutral command prefixes the
|
||||
# project lets agents run (tests/checks)
|
||||
# AGENT_MODEL optional; a claude model name/alias passed
|
||||
# through as --model. Absent = the CLI's own
|
||||
# resolution (user settings), untouched.
|
||||
# AGENT_CWD working directory (already set as cwd by the board)
|
||||
# BOARD_* passthrough for the event bridge
|
||||
# stdout: captured by the board as the job log; the closing report's
|
||||
@@ -25,12 +28,20 @@ BIN="${BOARD_CLAUDE_BIN:-claude}"
|
||||
MODE="${AGENT_MODE:-work}"
|
||||
SETTINGS="$(python3 "$HERE/hook_settings.py" "$MODE")"
|
||||
|
||||
# The ${arr[@]+...} expansion keeps set -u happy on bash 3.2 when unset.
|
||||
MODEL_ARGS=()
|
||||
if [ -n "${AGENT_MODEL:-}" ]; then
|
||||
MODEL_ARGS=(--model "$AGENT_MODEL")
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "review" ]; then
|
||||
exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \
|
||||
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
|
||||
--permission-mode default \
|
||||
--disallowedTools Edit Write NotebookEdit
|
||||
else
|
||||
# work and act-pr both mutate the worktree; the allowlist differs.
|
||||
exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \
|
||||
${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \
|
||||
--permission-mode acceptEdits
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print the opencode config JSON for one headless launch: the permission
|
||||
rules for the launch's intent.
|
||||
rules for the launch's intent, plus the model when the board configured one.
|
||||
|
||||
Usage: permission_config.py [work|act-pr|review]
|
||||
|
||||
@@ -21,6 +21,10 @@ and never a blanket allow: the worktree is isolated, the shell is not.
|
||||
The project's test/check commands arrive in AGENT_COMMANDS as comma-
|
||||
separated neutral command prefixes (set BOARD_AGENT_COMMANDS in
|
||||
local/.env); here each becomes "<prefix>" and "<prefix> *" allow rules.
|
||||
|
||||
AGENT_MODEL, when set, becomes the config's top-level "model" key —
|
||||
opencode's "provider/model-id" form (opencode.ai/docs/config), passed
|
||||
through untranslated. Absent = no key, opencode's own resolution applies.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -56,17 +60,21 @@ def bash_rules(mode: str, commands: list[str]) -> dict:
|
||||
return rules
|
||||
|
||||
|
||||
def build_config(mode: str, commands: list[str]) -> dict:
|
||||
return {
|
||||
def build_config(mode: str, commands: list[str], model: str = "") -> dict:
|
||||
config = {
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"edit": "deny" if mode == "review" else "allow",
|
||||
"bash": bash_rules(mode, commands),
|
||||
},
|
||||
}
|
||||
if model:
|
||||
config["model"] = model
|
||||
return config
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "work"
|
||||
commands = split_commands(os.environ.get("AGENT_COMMANDS", ""))
|
||||
print(json.dumps(build_config(mode, commands)))
|
||||
model = os.environ.get("AGENT_MODEL", "").strip()
|
||||
print(json.dumps(build_config(mode, commands, model)))
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
# (see core/adapters/README.md)
|
||||
# AGENT_COMMANDS comma-separated neutral command prefixes the
|
||||
# project lets agents run (tests/checks)
|
||||
# AGENT_MODEL optional; opencode's "provider/model-id" form,
|
||||
# set as the generated config's model key.
|
||||
# Absent = opencode's own resolution, untouched.
|
||||
# AGENT_CWD working directory (already set as cwd by the board)
|
||||
# BOARD_* passthrough for the event bridge
|
||||
# stdout: captured by the board as the job log; `opencode run` prints
|
||||
|
||||
+24
-12
@@ -81,6 +81,9 @@ def _agent_public(record: dict) -> dict:
|
||||
("id", "task", "branch", "worktree", "status", "rc", "started", "session")}
|
||||
public["mode"] = record.get("mode", "work")
|
||||
public["name"] = record.get("name")
|
||||
# The model the launch was actually given; None = inherited the
|
||||
# vendor's own default. Honesty for the Sessions/Focus views.
|
||||
public["model"] = record.get("model")
|
||||
return public
|
||||
|
||||
|
||||
@@ -111,9 +114,11 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log
|
||||
|
||||
The adapter contract: `run` gets AGENT_PROMPT, AGENT_MODE (the intent:
|
||||
work = mutate and commit, act-pr = work + push, review = read-only +
|
||||
post PR verdicts) and AGENT_COMMANDS (the project's runnable command
|
||||
prefixes) plus the BOARD_* passthrough for its event bridge; its
|
||||
stdout is the job log; exit 0 = completed.
|
||||
post PR verdicts), AGENT_COMMANDS (the project's runnable command
|
||||
prefixes) and AGENT_MODEL (the configured model, when there is one)
|
||||
plus the BOARD_* passthrough for its event bridge; its stdout is the
|
||||
job log; exit 0 = completed. Returns (proc, log_file, model) with
|
||||
model = '' when the launch inherits the vendor default.
|
||||
"""
|
||||
adapter = config.adapter_dir()
|
||||
if adapter is None:
|
||||
@@ -130,6 +135,13 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log
|
||||
"BOARD_TASK": filename,
|
||||
"BOARD_PORT": str(state.serve_port),
|
||||
})
|
||||
model = config.agent_model(mode)
|
||||
if model:
|
||||
env["AGENT_MODEL"] = model
|
||||
else:
|
||||
# Inherit = the variable is simply absent. Popping also stops a
|
||||
# stray AGENT_MODEL in the board's own environment leaking through.
|
||||
env.pop("AGENT_MODEL", None)
|
||||
log_file = log_path.open("wb")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
@@ -138,7 +150,7 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log
|
||||
except OSError as exc:
|
||||
log_file.close()
|
||||
raise ValueError(f"could not launch adapter {adapter}: {exc}")
|
||||
return proc, log_file
|
||||
return proc, log_file, model
|
||||
|
||||
|
||||
def start_agent(filename: str, stage: str) -> dict:
|
||||
@@ -185,7 +197,7 @@ def start_agent(filename: str, stage: str) -> dict:
|
||||
|
||||
prompt = config.prompt("work.md").format(
|
||||
branch=branch, filename=filename, body=task["body"])
|
||||
proc, log_file = _launch("work", prompt, worktree, agent_id, filename, log_path)
|
||||
proc, log_file, model = _launch("work", prompt, worktree, agent_id, filename, log_path)
|
||||
|
||||
name = _pick_name(stem)
|
||||
record = {
|
||||
@@ -193,7 +205,7 @@ def start_agent(filename: str, stage: str) -> dict:
|
||||
"worktree": str(worktree), "base": base, "status": "running",
|
||||
"rc": None, "started": time.time(), "session": None,
|
||||
"log": str(log_path), "proc": proc, "origin": stage, "mode": "work",
|
||||
"name": name,
|
||||
"name": name, "model": model or None,
|
||||
}
|
||||
with state.LOCK:
|
||||
state.AGENTS[agent_id] = record
|
||||
@@ -219,14 +231,14 @@ def start_review(filename: str, stage: str) -> dict:
|
||||
|
||||
prompt = config.prompt("review.md").format(
|
||||
stage=stage, filename=filename, body=task["body"])
|
||||
proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path)
|
||||
proc, log_file, model = _launch("review", prompt, config.REPO, agent_id, filename, log_path)
|
||||
|
||||
name = _pick_name(filename)
|
||||
record = {
|
||||
"id": agent_id, "task": filename, "branch": None, "worktree": None,
|
||||
"base": None, "status": "running", "rc": None, "started": time.time(),
|
||||
"session": None, "log": str(log_path), "proc": proc,
|
||||
"origin": stage, "mode": "review", "name": name,
|
||||
"origin": stage, "mode": "review", "name": name, "model": model or None,
|
||||
}
|
||||
with state.LOCK:
|
||||
state.AGENTS[agent_id] = record
|
||||
@@ -352,13 +364,13 @@ def start_pr_review(filename: str, stage: str) -> dict:
|
||||
|
||||
prompt = config.prompt("review-pr.md").format(
|
||||
filename=filename, pr=task["pr"], branch=branch, body=task["body"])
|
||||
proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path)
|
||||
proc, log_file, model = _launch("review", prompt, config.REPO, agent_id, filename, log_path)
|
||||
|
||||
record = {
|
||||
"id": agent_id, "task": filename, "branch": branch, "worktree": None,
|
||||
"base": None, "status": "running", "rc": None, "started": time.time(),
|
||||
"session": None, "log": str(log_path), "proc": proc,
|
||||
"origin": stage, "mode": "review", "name": name,
|
||||
"origin": stage, "mode": "review", "name": name, "model": model or None,
|
||||
}
|
||||
with state.LOCK:
|
||||
state.AGENTS[agent_id] = record
|
||||
@@ -403,14 +415,14 @@ def start_pr_fix(filename: str, stage: str) -> dict:
|
||||
prompt = config.prompt("act-pr.md").format(
|
||||
filename=filename, branch=branch, pr=task["pr"], body=task["body"])
|
||||
# act-pr is the one intent allowed to push: the PR must update.
|
||||
proc, log_file = _launch("act-pr", prompt, worktree, agent_id, filename, log_path)
|
||||
proc, log_file, model = _launch("act-pr", prompt, worktree, agent_id, filename, log_path)
|
||||
|
||||
record = {
|
||||
"id": agent_id, "task": filename, "branch": branch,
|
||||
"worktree": str(worktree), "base": None, "status": "running",
|
||||
"rc": None, "started": time.time(), "session": None,
|
||||
"log": str(log_path), "proc": proc, "origin": stage, "mode": "work",
|
||||
"name": name,
|
||||
"name": name, "model": model or None,
|
||||
}
|
||||
with state.LOCK:
|
||||
state.AGENTS[agent_id] = record
|
||||
|
||||
@@ -1300,12 +1300,15 @@ function renderFlight() {
|
||||
const stopBtn = agent && agent.status === 'running'
|
||||
? `<button id="stopagent" class="stopbtn" data-aid="${esc(agent.id)}">Hold</button>` : '';
|
||||
const branch = agent && agent.branch ? ` · <span class="mono">${esc(agent.branch)}</span>` : '';
|
||||
// Honesty about what the run actually rode: the configured model, or
|
||||
// the vendor default it inherited. Interactive sessions say nothing.
|
||||
const model = agent ? ` · <span class="mono">${agent.model ? esc(agent.model) : 'model inherited'}</span>` : '';
|
||||
$('#fsession').innerHTML =
|
||||
`<div><div class="s-title">${esc((meta.label || sid).split(' · ')[0])}` +
|
||||
`<span class="sid">${esc(sid.slice(0, 8))}</span></div>` +
|
||||
`<div class="s-line">${meta.task ? 'on ' + esc(meta.task) + ' · ' : ''}` +
|
||||
`started ${fmtShort(meta.started)} · ${meta.count || 0} events · ` +
|
||||
`${files.size} files edited · ${checks} check runs${branch}</div></div>` +
|
||||
`${files.size} files edited · ${checks} check runs${branch}${model}</div></div>` +
|
||||
stopBtn + spark(events, meta);
|
||||
const stop = $('#stopagent');
|
||||
if (stop) stop.addEventListener('click', () => stopAgent(stop.dataset.aid));
|
||||
@@ -1490,6 +1493,7 @@ function renderFocus() {
|
||||
refBits.push(task.number ? '#' + esc(task.number) : esc(task.file));
|
||||
refBits.push(`<span class="acc">${esc((meta.label || '').split(' · ')[0])}</span>`);
|
||||
if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch));
|
||||
if (agent) refBits.push(agent.model ? esc(agent.model) : 'model inherited');
|
||||
refBits.push(esc(task.stage) + '/' + esc(task.file));
|
||||
} else {
|
||||
refBits.push(esc(sid.slice(0, 8)), 'no task attached');
|
||||
|
||||
@@ -95,6 +95,24 @@ ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude")
|
||||
# the adapter's own knowledge; this list is the project's half.
|
||||
AGENT_COMMANDS = setting("BOARD_AGENT_COMMANDS", "python3 -m unittest")
|
||||
|
||||
# Model per launch intent — an opaque vendor-native name core passes to the
|
||||
# adapter untranslated (what names mean anything is vendor knowledge). Empty
|
||||
# = inherit the vendor's own default, exactly today's behaviour. A per-intent
|
||||
# setting beats the general one; review covers PR reviews and relevance
|
||||
# checks (they share the review intent).
|
||||
AGENT_MODEL = setting("BOARD_AGENT_MODEL", "")
|
||||
AGENT_MODELS = {
|
||||
"work": setting("BOARD_AGENT_MODEL_WORK", ""),
|
||||
"act-pr": setting("BOARD_AGENT_MODEL_ACT_PR", ""),
|
||||
"review": setting("BOARD_AGENT_MODEL_REVIEW", ""),
|
||||
}
|
||||
|
||||
|
||||
def agent_model(mode: str) -> str:
|
||||
"""The model one launch intent rides — '' means inherit."""
|
||||
return AGENT_MODELS.get(mode, "") or AGENT_MODEL
|
||||
|
||||
|
||||
# GitHub plumbing: the gh CLI (stub-able for tests) and the git remote PRs
|
||||
# go to. Empty remote = auto-detect the first remote; no remote = no PRs.
|
||||
GH_BIN = setting("BOARD_GH_BIN", "gh")
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Choosing agent models per launch intent (task 12): the BOARD_AGENT_MODEL
|
||||
settings resolve intent → model in config, travel to the adapter as
|
||||
AGENT_MODEL (absent when empty — never an empty flag value), and each
|
||||
adapter renders the opaque name natively. With nothing set, launches are
|
||||
byte-identical to the inherit-everything behaviour.
|
||||
|
||||
The `run` scripts are exercised end-to-end against stub binaries
|
||||
(BOARD_CLAUDE_BIN / BOARD_OPENCODE_BIN), the same seam a live board uses.
|
||||
Run with: python3 -m unittest discover -s tests
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORE = REPO / "manager" / "core"
|
||||
CLAUDE = CORE / "adapters" / "claude"
|
||||
OPENCODE = CORE / "adapters" / "opencode"
|
||||
|
||||
sys.path.insert(0, str(CORE))
|
||||
|
||||
import agents # noqa: E402
|
||||
import config # noqa: E402
|
||||
|
||||
|
||||
def _load(name, path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
permission_config = _load("permission_config", OPENCODE / "permission_config.py")
|
||||
|
||||
# Neutralize any local/.env or shell leakage: process env beats .env, and
|
||||
# an empty value is exactly "nothing configured".
|
||||
UNSET = {"BOARD_AGENT_MODEL": "", "BOARD_AGENT_MODEL_WORK": "",
|
||||
"BOARD_AGENT_MODEL_ACT_PR": "", "BOARD_AGENT_MODEL_REVIEW": ""}
|
||||
|
||||
|
||||
def _resolve(settings: dict) -> dict:
|
||||
"""config.agent_model per intent, in a fresh interpreter so the given
|
||||
settings are what config reads at import."""
|
||||
env = dict(os.environ)
|
||||
env.update(UNSET)
|
||||
env.update(settings)
|
||||
out = subprocess.check_output(
|
||||
[sys.executable, "-c",
|
||||
"import sys; sys.path.insert(0, sys.argv[1]); import config, json; "
|
||||
"print(json.dumps({m: config.agent_model(m) "
|
||||
"for m in ('work', 'act-pr', 'review')}))",
|
||||
str(CORE)],
|
||||
env=env, text=True)
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
class ModelResolution(unittest.TestCase):
|
||||
def test_nothing_set_means_inherit_for_every_intent(self):
|
||||
self.assertEqual(_resolve({}),
|
||||
{"work": "", "act-pr": "", "review": ""})
|
||||
|
||||
def test_the_general_setting_covers_all_intents(self):
|
||||
self.assertEqual(_resolve({"BOARD_AGENT_MODEL": "vendor-x"}),
|
||||
{"work": "vendor-x", "act-pr": "vendor-x",
|
||||
"review": "vendor-x"})
|
||||
|
||||
def test_a_per_intent_setting_beats_the_general_one_for_that_intent_only(self):
|
||||
resolved = _resolve({"BOARD_AGENT_MODEL": "big",
|
||||
"BOARD_AGENT_MODEL_REVIEW": "cheap"})
|
||||
self.assertEqual(resolved,
|
||||
{"work": "big", "act-pr": "big", "review": "cheap"})
|
||||
|
||||
def test_per_intent_alone_leaves_the_others_inheriting(self):
|
||||
resolved = _resolve({"BOARD_AGENT_MODEL_ACT_PR": "pusher"})
|
||||
self.assertEqual(resolved,
|
||||
{"work": "", "act-pr": "pusher", "review": ""})
|
||||
|
||||
|
||||
class LaunchEnv(unittest.TestCase):
|
||||
"""_launch's half of the contract: AGENT_MODEL set iff a model is
|
||||
configured — absent means absent, even against a leaky environment."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
tmp = Path(self._tmp.name)
|
||||
self.capture = tmp / "env.json"
|
||||
adapter = tmp / "stub-adapter"
|
||||
adapter.mkdir()
|
||||
run = adapter / "run"
|
||||
run.write_text("#!/usr/bin/env python3\n"
|
||||
"import json, os\n"
|
||||
f"open({str(self.capture)!r}, 'w').write(json.dumps(dict(os.environ)))\n")
|
||||
run.chmod(run.stat().st_mode | stat.S_IXUSR)
|
||||
self._saved = (config.AGENT_MODEL, config.AGENT_MODELS,
|
||||
config.adapter_dir, config.child_env)
|
||||
config.adapter_dir = lambda: adapter
|
||||
# A stray AGENT_MODEL inherited by the board process must not leak.
|
||||
config.child_env = lambda: {"PATH": os.environ.get("PATH", ""),
|
||||
"AGENT_MODEL": "stray-from-the-shell"}
|
||||
|
||||
def tearDown(self):
|
||||
(config.AGENT_MODEL, config.AGENT_MODELS,
|
||||
config.adapter_dir, config.child_env) = self._saved
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _launch_env(self, mode: str) -> tuple[dict, str]:
|
||||
log = Path(self._tmp.name) / "job.log"
|
||||
proc, log_file, model = agents._launch(
|
||||
mode, "do the task", Path(self._tmp.name), "id-1", "t.md", log)
|
||||
proc.wait()
|
||||
log_file.close()
|
||||
return json.loads(self.capture.read_text()), model
|
||||
|
||||
def test_no_model_configured_means_no_variable_at_all(self):
|
||||
config.AGENT_MODEL = ""
|
||||
config.AGENT_MODELS = {"work": "", "act-pr": "", "review": ""}
|
||||
env, model = self._launch_env("work")
|
||||
self.assertNotIn("AGENT_MODEL", env)
|
||||
self.assertEqual(model, "")
|
||||
|
||||
def test_the_resolved_model_arrives_as_agent_model(self):
|
||||
config.AGENT_MODEL = "big"
|
||||
config.AGENT_MODELS = {"work": "", "act-pr": "", "review": "cheap"}
|
||||
env, model = self._launch_env("review")
|
||||
self.assertEqual(env["AGENT_MODEL"], "cheap")
|
||||
self.assertEqual(model, "cheap")
|
||||
env, model = self._launch_env("work")
|
||||
self.assertEqual(env["AGENT_MODEL"], "big")
|
||||
self.assertEqual(model, "big")
|
||||
|
||||
|
||||
class AgentRecord(unittest.TestCase):
|
||||
def test_public_record_carries_the_model_none_means_inherited(self):
|
||||
base = {"id": "a", "task": "t.md", "branch": None, "worktree": None,
|
||||
"status": "running", "rc": None, "started": 0.0,
|
||||
"session": None, "mode": "review", "name": "Wren"}
|
||||
self.assertIsNone(agents._agent_public(base)["model"])
|
||||
self.assertEqual(
|
||||
agents._agent_public({**base, "model": "cheap"})["model"], "cheap")
|
||||
|
||||
|
||||
def _write_stub(directory: Path, name: str, script: str) -> Path:
|
||||
stub = directory / name
|
||||
stub.write_text(script, encoding="utf-8")
|
||||
stub.chmod(stub.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return stub
|
||||
|
||||
|
||||
class ClaudeRunModel(unittest.TestCase):
|
||||
"""AGENT_MODEL reaches the claude launch as --model; absent (or empty,
|
||||
which must never happen but costs nothing to survive) = no flag."""
|
||||
|
||||
def _run(self, model: str | None) -> list[str]:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
capture = Path(tmp) / "args.json"
|
||||
stub = _write_stub(Path(tmp), "claude-stub",
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import json, sys\n"
|
||||
f"open({str(capture)!r}, 'w').write(json.dumps(sys.argv[1:]))\n")
|
||||
wrapper = _write_stub(Path(tmp), "bin",
|
||||
f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n")
|
||||
env = dict(os.environ)
|
||||
env.pop("AGENT_MODEL", None)
|
||||
env.update({"BOARD_CLAUDE_BIN": str(wrapper),
|
||||
"AGENT_PROMPT": "do the task", "AGENT_MODE": "work",
|
||||
"AGENT_COMMANDS": "python3 -m unittest"})
|
||||
if model is not None:
|
||||
env["AGENT_MODEL"] = model
|
||||
result = subprocess.run(["bash", str(CLAUDE / "run")], env=env,
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return json.loads(capture.read_text())
|
||||
|
||||
def test_unset_launches_byte_identical_to_today(self):
|
||||
args = self._run(None)
|
||||
self.assertNotIn("--model", args)
|
||||
self.assertEqual(args, self._run("")) # empty behaves like absent
|
||||
|
||||
def test_set_appends_model_and_changes_nothing_else(self):
|
||||
args = self._run("claude-model-x")
|
||||
i = args.index("--model")
|
||||
self.assertEqual(args[i + 1], "claude-model-x")
|
||||
self.assertEqual(args[:i] + args[i + 2:], self._run(None))
|
||||
|
||||
|
||||
class OpencodeRunModel(unittest.TestCase):
|
||||
"""AGENT_MODEL reaches the opencode launch as the generated config's
|
||||
model key; absent = no key, config byte-identical to today."""
|
||||
|
||||
def _run(self, model: str | None) -> dict:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
capture = Path(tmp) / "capture.json"
|
||||
stub = _write_stub(Path(tmp), "opencode-stub",
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import json, os\n"
|
||||
f"open({str(capture)!r}, 'w').write("
|
||||
"json.dumps(json.load(open(os.environ['OPENCODE_CONFIG']))))\n")
|
||||
wrapper = _write_stub(Path(tmp), "bin",
|
||||
f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n")
|
||||
env = dict(os.environ)
|
||||
env.pop("AGENT_MODEL", None)
|
||||
env.update({"BOARD_OPENCODE_BIN": str(wrapper),
|
||||
"AGENT_PROMPT": "do the task", "AGENT_MODE": "review",
|
||||
"AGENT_COMMANDS": "python3 -m unittest"})
|
||||
if model is not None:
|
||||
env["AGENT_MODEL"] = model
|
||||
result = subprocess.run(["bash", str(OPENCODE / "run")], env=env,
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return json.loads(capture.read_text())
|
||||
|
||||
def test_unset_launches_byte_identical_to_today(self):
|
||||
cfg = self._run(None)
|
||||
self.assertNotIn("model", cfg)
|
||||
self.assertEqual(cfg, permission_config.build_config(
|
||||
"review", ["python3 -m unittest"]))
|
||||
|
||||
def test_set_lands_as_the_config_model_key_untranslated(self):
|
||||
cfg = self._run("anthropic/model-x")
|
||||
self.assertEqual(cfg["model"], "anthropic/model-x")
|
||||
del cfg["model"]
|
||||
self.assertEqual(cfg, self._run(None))
|
||||
|
||||
def test_build_config_only_grows_the_key_when_given_a_model(self):
|
||||
commands = ["python3 -m unittest"]
|
||||
self.assertNotIn("model", permission_config.build_config("work", commands))
|
||||
self.assertEqual(
|
||||
permission_config.build_config("work", commands, "p/m")["model"], "p/m")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user