Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <n>` / `h5i msg reply
<n> …`) 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 <n>`
/ `h5i msg reply <n> …`) before the winner touches your worktree.

## One pattern each

Expand Down
8 changes: 5 additions & 3 deletions examples/arena_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions examples/custom_control_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions examples/debate_then_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions examples/ensemble_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions examples/judge_panel_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions examples/pipeline_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion examples/review_escalation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions examples/tournament.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]


Expand Down
54 changes: 54 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
@@ -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
Loading