feat(operator): runner registry — spawn any agent CLI, not just Claude (P3)
Generalize the three Claude-hardcoded spots in bootstrap (launch argv,
install plan, creds/config-dir) behind a Runner registry so a nested
operator can be spawned with any agent CLI. The Claude path stays the
default and byte-identical.
- bootstrap.Runner + RUNNERS{claude,codex,gemini,cmd} + get_runner/runner_present
- build_run_argv / install_plan / creds_source / plan_creds / child_env all
take an optional `runner` (default claude → unchanged behaviour)
- claude validated end-to-end; codex/gemini are best-effort defaults
overridable via $HH_<RUNNER>_INSTALL etc.; generic `cmd` runner reads a
full launch template from $HH_OPERATOR_CMD ({directive} placeholder)
- bridge _op_spawn resolves req["runner"], rejects unknown; plan now carries
runner/runner_present (was claude_present)
- CLI: `spawn --runner {claude|codex|gemini|cmd}`
- 7 new unit tests; full suite 132 passed
Note: compose_directive still emits the Claude-flavoured ("hh-operator
skill") directive — the portable Layer-1 prompt is P4.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ Each test builds the bridge *inside* its own ``asyncio.run`` so the bridge's
|
||||
import sys
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
@@ -335,6 +336,95 @@ def test_build_run_argv_flags():
|
||||
assert "--dangerously-skip-permissions" not in argv
|
||||
|
||||
|
||||
# ── runner registry (P3): model-agnostic spawn ───────────────────────────────
|
||||
def test_get_runner_default_and_unknown():
|
||||
assert boot.get_runner().name == "claude"
|
||||
assert boot.get_runner("codex").name == "codex"
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
boot.get_runner("bogus")
|
||||
|
||||
|
||||
def test_build_run_argv_per_runner():
|
||||
# claude default unchanged
|
||||
assert boot.build_run_argv("do x")[:3] == ["claude", "-p", "do x"]
|
||||
# codex: own subcommand + unattended flag
|
||||
argv = boot.build_run_argv("do x", skip_permissions=True, runner="codex")
|
||||
assert argv[:3] == ["codex", "exec", "do x"]
|
||||
assert "--dangerously-bypass-approvals-and-sandbox" in argv
|
||||
# gemini
|
||||
assert boot.build_run_argv("do x", runner="gemini")[:3] == ["gemini", "-p", "do x"]
|
||||
# back-compat: claude_bin override still honoured for the default runner
|
||||
assert boot.build_run_argv("do x", claude_bin="/opt/claude")[0] == "/opt/claude"
|
||||
|
||||
|
||||
def test_cmd_runner_template():
|
||||
import os
|
||||
os.environ["HH_OPERATOR_CMD"] = "mycli --go {directive}"
|
||||
try:
|
||||
assert boot.build_run_argv("do x", runner="cmd") == ["mycli", "--go", "do x"]
|
||||
finally:
|
||||
del os.environ["HH_OPERATOR_CMD"]
|
||||
# no {directive} placeholder → directive appended
|
||||
os.environ["HH_OPERATOR_CMD"] = "mycli run"
|
||||
try:
|
||||
assert boot.build_run_argv("do x", runner="cmd") == ["mycli", "run", "do x"]
|
||||
finally:
|
||||
del os.environ["HH_OPERATOR_CMD"]
|
||||
# missing template env → clear error
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
boot.build_run_argv("do x", runner="cmd")
|
||||
|
||||
|
||||
def test_install_plan_per_runner():
|
||||
assert boot.install_plan({"has_npm": True}, runner="codex")[0] == \
|
||||
["npm", "install", "-g", "@openai/codex"]
|
||||
assert boot.install_plan({"has_npm": True}, runner="gemini")[0] == \
|
||||
["npm", "install", "-g", "@google/gemini-cli"]
|
||||
# generic cmd runner has no npm package → manual install
|
||||
assert boot.install_plan({"has_npm": True}, runner="cmd") == []
|
||||
# per-runner override env wins
|
||||
import os
|
||||
os.environ["HH_CODEX_INSTALL"] = "echo codex-install"
|
||||
try:
|
||||
assert boot.install_plan({"has_npm": True}, runner="codex") == \
|
||||
[["sh", "-c", "echo codex-install"]]
|
||||
finally:
|
||||
del os.environ["HH_CODEX_INSTALL"]
|
||||
|
||||
|
||||
def test_child_env_uses_runner_config_var():
|
||||
assert boot.child_env("/tmp/cd")["CLAUDE_CONFIG_DIR"] == "/tmp/cd"
|
||||
assert boot.child_env("/tmp/cd", runner="codex")["CODEX_HOME"] == "/tmp/cd"
|
||||
# claude's config var is not set under a different runner
|
||||
assert boot.child_env("/tmp/cd", runner="codex").get("CLAUDE_CONFIG_DIR") == \
|
||||
os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
|
||||
def test_spawn_dry_run_honours_runner():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
b.budget = boot.Budget(depth=1, fanout=1, cost_usd=2.0)
|
||||
r = await b._op_spawn({"objective": "scout", "runner": "codex",
|
||||
"room": {"host": "h", "port": 7, "name": "kid"},
|
||||
"dry_run": True})
|
||||
assert r["ok"] and r["plan"]["runner"] == "codex"
|
||||
assert r["plan"]["argv"][0] == "codex"
|
||||
assert "runner_present" in r["plan"]
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_spawn_rejects_unknown_runner():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
b.budget = boot.Budget(depth=1, fanout=1, cost_usd=2.0)
|
||||
r = await b._op_spawn({"objective": "x", "runner": "bogus",
|
||||
"room": {"host": "h", "port": 7}})
|
||||
assert r["ok"] is False and "unknown runner" in r["error"]
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
# ── spawn op: gating + dry-run ────────────────────────────────────────────
|
||||
def test_spawn_dry_run_returns_plan_without_launch():
|
||||
async def go():
|
||||
|
||||
Reference in New Issue
Block a user