diff --git a/examples/README.md b/examples/README.md index 148b338..7e20cbb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,16 +16,22 @@ You need four things: most also hire `runtime="codex"`. Both CLIs must be installed and logged in. `launcher="resident"` additionally needs `tmux` (it spawns the sessions). 4. **A repo to work on** — each score opens `Conductor(".", …)`, so run it from - the repository the agents should modify, with a **clean worktree** (most - scores `preflight(clean_worktree=True)` and fail fast otherwise). + the repository the agents should modify, with a **clean worktree** (the + arena and ensemble scores call `preflight(clean_worktree=True)` and fail + fast otherwise). + +`preflight(live=...)` is intended for the default `"attach"` launcher, where +sessions must already be parked on their inboxes. Do not use that check before +the first turn with `launcher="resident"`: resident sessions are started lazily +when a turn is dispatched. Then run any example as a plain Python script. Three take the task as an optional CLI argument (falling back to a demo task): ```bash -python examples/ensemble_score.py "implement \`h5i pull\` mirroring \`h5i push\`" -python examples/arena_score.py "make \`h5i doctor\` exit non-zero on repair failures" -python examples/review_escalation.py "fix the flaky msg_integration test" +python examples/ensemble_score.py "implement quicksort" +python examples/arena_score.py "implement quicksort" +python examples/review_escalation.py "implement quicksort" ``` The rest are self-contained — the task is written into the score: @@ -39,15 +45,17 @@ python examples/custom_control_flow.py # uses the default "attach" launcher: # park resident sessions yourself first ``` -Model coverage varies: `arena_score.py` and `review_escalation.py` hire -`claude-haiku-4-5`, and `judge_panel_score.py`, `review_escalation.py`, and -`tournament.py` hire `claude-opus-4-8` — edit the `model=` arguments if your -account lacks a model. To resume an interrupted run, just re-run the same -command — each script fixes its run id (`"arena-demo"`, `"pipeline-demo"`, …) -and the journal replays completed steps. Scores that end in an `apply` pause -at a durable human gate: the question is delivered over `h5i msg`, so answer -it from the inbox (`h5i msg inbox`, then `h5i msg ack ` / `h5i msg reply - …`) before the winner touches your worktree. +Every example pins inexpensive models instead of inheriting a potentially +costly CLI default: Claude seats use `claude-haiku-4-5` and Codex seats use +`gpt-5.4-mini`. Edit the `model=` arguments if your account lacks either model. +Changing a model on an existing run does not rewrite a journaled hire, so also +change the run id (for example, `"ensemble-demo-v2"`) when experimenting with +another model. To resume an interrupted run without configuration changes, +just re-run the same command — each script fixes its run id (`"arena-demo"`, +`"pipeline-demo"`, …) and the journal replays completed steps. Scores that end +in an `apply` pause at a durable human gate: the question is delivered over +`h5i msg`, so answer it from the inbox (`h5i msg inbox`, then `h5i msg ack ` +/ `h5i msg reply …`) before the winner touches your worktree. ## One pattern each diff --git a/examples/arena_score.py b/examples/arena_score.py index cc33dd6..9586054 100644 --- a/examples/arena_score.py +++ b/examples/arena_score.py @@ -19,11 +19,13 @@ async def main(task: str) -> None: async with Conductor(".", "arena-demo", launcher="resident") as c: agents = await asyncio.gather( - c.hire("claude", runtime="claude"), - c.hire("codex", runtime="codex"), + c.hire("claude", runtime="claude", model="claude-haiku-4-5"), + c.hire("codex", runtime="codex", model="gpt-5.4-mini"), c.hire("haiku", runtime="claude", model="claude-haiku-4-5"), ) - await c.preflight(live=agents, clean_worktree=True) + # LaunchResident starts each session on its first turn, so there is no + # live session to check yet. Still fail fast on repository hygiene. + await c.preflight(clean_worktree=True) outcome = await patterns.arena( c, diff --git a/examples/custom_control_flow.py b/examples/custom_control_flow.py index 015d63c..9502f6b 100644 --- a/examples/custom_control_flow.py +++ b/examples/custom_control_flow.py @@ -12,8 +12,15 @@ async def main() -> None: async with Conductor(".", "triage-and-fix") as c: - lead = await c.hire("lead", runtime="claude") - crew = [await c.hire(f"fixer{i}", runtime="claude") for i in range(2)] + lead = await c.hire( + "lead", runtime="claude", model="claude-haiku-4-5" + ) + crew = [ + await c.hire( + f"fixer{i}", runtime="claude", model="claude-haiku-4-5" + ) + for i in range(2) + ] # A data turn: the agent replies with JSON, not code. The reply is # journaled — on resume this list comes back without re-asking. diff --git a/examples/debate_then_build.py b/examples/debate_then_build.py index dbca893..2003da5 100644 --- a/examples/debate_then_build.py +++ b/examples/debate_then_build.py @@ -20,9 +20,11 @@ async def main() -> None: async with Conductor(".", "debate-demo", launcher="resident") as c: - pro = await c.hire("pro", runtime="claude") - con = await c.hire("con", runtime="codex") - moderator = await c.hire("moderator", runtime="claude") + pro = await c.hire("pro", runtime="claude", model="claude-haiku-4-5") + con = await c.hire("con", runtime="codex", model="gpt-5.4-mini") + moderator = await c.hire( + "moderator", runtime="claude", model="claude-haiku-4-5" + ) outcome = await patterns.debate( c, QUESTION, [pro, con], moderator=moderator, rounds=2 diff --git a/examples/ensemble_score.py b/examples/ensemble_score.py index 764fd73..8ed67d7 100644 --- a/examples/ensemble_score.py +++ b/examples/ensemble_score.py @@ -17,11 +17,15 @@ async def main(task: str) -> None: async with Conductor(".", "ensemble-demo", launcher="resident") as c: - claude = await c.hire("claude", runtime="claude") - codex = await c.hire("codex", runtime="codex") + claude = await c.hire( + "claude", runtime="claude", model="claude-haiku-4-5" + ) + codex = await c.hire("codex", runtime="codex", model="gpt-5.4-mini") # Fail the predictable ways now, not at minute 30. - await c.preflight(live=[claude, codex], clean_worktree=True) + # LaunchResident starts each session on its first turn, so there is no + # live session to check yet. Still fail fast on repository hygiene. + await c.preflight(clean_worktree=True) # Independent attempts, in parallel — then seal the round. a, b = await asyncio.gather( diff --git a/examples/judge_panel_score.py b/examples/judge_panel_score.py index 6b2eec3..efe44a0 100644 --- a/examples/judge_panel_score.py +++ b/examples/judge_panel_score.py @@ -26,12 +26,12 @@ async def main() -> None: async with Conductor(".", "panel-demo", launcher="resident") as c: workers = await asyncio.gather( - c.hire("claude", runtime="claude"), - c.hire("codex", runtime="codex"), + c.hire("claude", runtime="claude", model="claude-haiku-4-5"), + c.hire("codex", runtime="codex", model="gpt-5.4-mini"), ) judges = await asyncio.gather( - c.hire("judge-a", runtime="claude"), - c.hire("judge-b", runtime="claude", model="claude-opus-4-8"), + c.hire("judge-a", runtime="claude", model="claude-haiku-4-5"), + c.hire("judge-b", runtime="claude", model="claude-haiku-4-5"), ) # Independent attempts → seal → neutral evidence for the panel. diff --git a/examples/pipeline_score.py b/examples/pipeline_score.py index 87d71f3..b0b1843 100644 --- a/examples/pipeline_score.py +++ b/examples/pipeline_score.py @@ -18,9 +18,13 @@ async def main() -> None: async with Conductor(".", "pipeline-demo", launcher="resident") as c: - architect = await c.hire("architect", runtime="claude") - builder = await c.hire("builder", runtime="codex") - hardener = await c.hire("hardener", runtime="claude") + architect = await c.hire( + "architect", runtime="claude", model="claude-haiku-4-5" + ) + builder = await c.hire("builder", runtime="codex", model="gpt-5.4-mini") + hardener = await c.hire( + "hardener", runtime="claude", model="claude-haiku-4-5" + ) design, impl, hardened = await patterns.pipeline( c, diff --git a/examples/review_escalation.py b/examples/review_escalation.py index 9847d8a..0cc39a9 100644 --- a/examples/review_escalation.py +++ b/examples/review_escalation.py @@ -20,7 +20,9 @@ async def main(task: str) -> None: async with Conductor(".", "escalation-demo", launcher="resident") as c: junior = await c.hire("junior", runtime="claude", model="claude-haiku-4-5") - senior = await c.hire("senior", runtime="claude", model="claude-opus-4-8") + senior = await c.hire( + "senior", runtime="claude", model="claude-haiku-4-5" + ) artifact = await junior.work(task) await c.freeze() diff --git a/examples/tournament.py b/examples/tournament.py index 7b43451..fa6db42 100644 --- a/examples/tournament.py +++ b/examples/tournament.py @@ -18,10 +18,10 @@ #: (name, runtime, model) seeds, bracket order. SEEDS = [ - ("claude", "claude", None), - ("codex", "codex", None), + ("claude", "claude", "claude-haiku-4-5"), + ("codex", "codex", "gpt-5.4-mini"), ("haiku", "claude", "claude-haiku-4-5"), - ("opus", "claude", "claude-opus-4-8"), + ("haiku-2", "claude", "claude-haiku-4-5"), ] diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..4e46125 --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,54 @@ +import ast +from pathlib import Path + +import pytest + + +EXAMPLES = Path(__file__).parents[1] / "examples" +CHEAP_MODELS = {"claude-haiku-4-5", "gpt-5.4-mini"} + + +@pytest.mark.parametrize("path", sorted(EXAMPLES.glob("*.py")), ids=lambda p: p.name) +def test_examples_are_valid_python(path: Path): + ast.parse(path.read_text(), filename=str(path)) + + +@pytest.mark.parametrize("name", ["arena_score.py", "ensemble_score.py"]) +def test_resident_examples_do_not_preflight_live_sessions(name: str): + tree = ast.parse((EXAMPLES / name).read_text()) + live_checks = [ + call + for call in ast.walk(tree) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "preflight" + and any(keyword.arg == "live" for keyword in call.keywords) + ] + + assert not live_checks, ( + 'launcher="resident" starts sessions lazily on the first turn; ' + "preflight(live=...) fails before that turn can start them" + ) + + +@pytest.mark.parametrize("path", sorted(EXAMPLES.glob("*.py")), ids=lambda p: p.name) +def test_examples_pin_cheap_models(path: Path): + tree = ast.parse(path.read_text()) + hire_calls = [ + call + for call in ast.walk(tree) + if isinstance(call, ast.Call) + and isinstance(call.func, ast.Attribute) + and call.func.attr == "hire" + ] + + assert all(any(keyword.arg == "model" for keyword in call.keywords) for call in hire_calls) + + explicit_models = { + node.value + for node in ast.walk(tree) + if isinstance(node, ast.Constant) + and isinstance(node.value, str) + and (node.value.startswith("claude-") or node.value.startswith("gpt-")) + } + assert explicit_models <= CHEAP_MODELS