Skip to content
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ brings up tmux sessions itself), or `on_turn=my_callback` (every turn is
delivered to your Python function — script deterministic agents in tests, or
spawn your own runtimes).

**Sandboxing.** `Conductor(..., isolation="supervised")` sets the run's
default sandbox tier: every `hire` creates its env at that tier unless it
passes its own (`isolation="container"`, or `"auto"` to re-enable
auto-picking). Explicit tiers are fail-closed — hire errors if the host
cannot enforce them, never silently downgrades — and
`preflight(min_isolation=...)` verifies the floor across the whole roster,
which also catches a *resumed* run whose envs were created at a weaker tier.

**Watching resident sessions.** With `launcher="resident"` the score also
auto-opens a viewer on each agent's tmux session as it comes up — a window
linked into your current tmux session, a Windows Terminal tab under WSL, or a
new GUI terminal — so you never hunt for `tmux attach -t …` by hand. Headless
environments just get the attach command printed. Tune it with
`watch="wezterm start -- tmux attach -t {session}"` (or `$H5I_TERMINAL`), or
turn it off with `watch=False`.

**Discipline the journal asks of you** (same as the Rust eDSL): steps that run
concurrently need distinct labels (`c.scope(f"item/{i}").step("fetch", …)` in
parallel loops), and one agent's turns run sequentially — one resident
Expand Down
45 changes: 33 additions & 12 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ again — journaled steps replay and the run continues where it stopped. They
assume agent runtimes are available (`launcher="resident"` brings tmux
sessions up itself; drop it if you park resident sessions yourself).

You don't need to `tmux attach` by hand to watch the agents: a resident-launcher
score auto-opens a viewer on each agent session as it comes up — a window
linked into your current tmux session if you run the score inside tmux, a
Windows Terminal tab under WSL, or a new GUI terminal window otherwise. In a
headless shell it prints the exact `tmux attach -t …` command instead. It also
warns when an agent has a turn in flight but its session never appeared or died
mid-turn (with the `h5i env shell … -- true` command to diagnose why). Pass
`watch=False` to `Conductor` to disable it, or set a custom terminal with
`watch="kitty -e tmux attach -t {session}"` / `$H5I_TERMINAL`.

## Running the examples

You need four things:
You need five things:

1. **The SDK** — from the repo root: `pip install -e .` (Python ≥ 3.10).
2. **The engine** — the `h5i` binary on `PATH` (`cargo install --path <h5i repo>`),
Expand All @@ -19,24 +29,28 @@ You need four things:
the repository the agents should modify, with a **clean worktree** (the
arena and ensemble scores call `preflight(clean_worktree=True)` and fail
fast otherwise).
5. **A host that can enforce the `supervised` sandbox tier** — every example
pins `isolation="supervised"` on the `Conductor`, so hired agents run in
supervised envs (seccomp-gated, network-jailed) rather than whatever tier
the host happens to auto-pick. The tier is fail-closed: on a host that
cannot enforce it, hire errors instead of silently downgrading — drop the
`isolation=` argument (or set `"auto"`) to fall back to auto-picking.
Note: a *resumed* run keeps the envs it was created with; changing the
tier (like changing a model) needs a fresh run id.

`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 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:
Then run any example as a plain Python script. Every example takes the task
as an optional CLI argument and falls back to the same demo task,
**`implement quicksort with pytest`** — so a bare invocation always works:

```bash
python examples/ensemble_score.py # demo task
python examples/arena_score.py "implement quicksort with pytest"
python examples/review_escalation.py "fix the flaky msg_integration test"
python examples/pipeline_score.py
python examples/judge_panel_score.py
python examples/debate_then_build.py
Expand All @@ -45,9 +59,16 @@ python examples/custom_control_flow.py # uses the default "attach" launcher:
# park resident sessions yourself first
```

To match the demo task, the scores verify candidates with `pytest -q` — the
quicksort submissions carry their own tests. If you pass a task from another
ecosystem, change the `verify` command in the score too (e.g. back to
`["cargo", "test", "--quiet"]`).

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.
`gpt-5.4-mini` with `effort="medium"` (launched as
`-c model_reasoning_effort=medium`, which wins over your `~/.codex/config.toml`
— so a `high` default there won't slow the demos down). 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,
Expand Down
14 changes: 8 additions & 6 deletions examples/arena_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,33 @@
the smallest green diff. The compare rows are the same arena view
`h5i team compare` renders.

python examples/arena_score.py "make `h5i doctor` exit non-zero on repair failures"
python examples/arena_score.py ["<task>"] # default: implement quicksort with pytest
"""

import asyncio
import sys

from h5i.orchestra import Conductor, patterns

DEMO_TASK = "implement quicksort with pytest"


async def main(task: str) -> None:
async with Conductor(".", "arena-demo", launcher="resident") as c:
async with Conductor(".", "arena-demo", launcher="resident", isolation="supervised") as c:
agents = await asyncio.gather(
c.hire("claude", runtime="claude", model="claude-haiku-4-5"),
c.hire("codex", runtime="codex", model="gpt-5.4-mini"),
c.hire("codex", runtime="codex", model="gpt-5.4-mini", effort="medium"),
c.hire("haiku", runtime="claude", model="claude-haiku-4-5"),
)
# 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)
await c.preflight(clean_worktree=True, min_isolation="supervised")

outcome = await patterns.arena(
c,
task,
agents,
verify=["cargo", "test", "--quiet"],
verify=["pytest", "-q"],
isolation="process",
)

Expand All @@ -54,4 +56,4 @@ async def main(task: str) -> None:


if __name__ == "__main__":
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "demo task"))
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK))
21 changes: 13 additions & 8 deletions examples/custom_control_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@
This score shows the pieces no manifest could express: data turns feeding
`if`, a journaled fan-out over a dynamic work list, a custom (LLM-assisted)
verdict policy, and a mid-run score migration marker.

python examples/custom_control_flow.py ["<task>"] # default: implement quicksort with pytest
"""

import asyncio
import sys

from h5i.orchestra import Conductor, Verdict, patterns

DEMO_TASK = "implement quicksort with pytest"


async def main() -> None:
async with Conductor(".", "triage-and-fix") as c:
async def main(task: str) -> None:
async with Conductor(".", "triage-and-fix", isolation="supervised") as c:
lead = await c.hire(
"lead", runtime="claude", model="claude-haiku-4-5"
)
Expand All @@ -25,26 +30,26 @@ async def main() -> None:
# A data turn: the agent replies with JSON, not code. The reply is
# journaled — on resume this list comes back without re-asking.
hotspots: list = await lead.ask(
"List up to 4 modules with failing or flaky tests as a JSON "
'array: [{"path": "...", "symptom": "..."}]',
f"We want to: {task}. Break it into at most 4 independent "
'subtasks as a JSON array: [{"item": "...", "detail": "..."}]',
parse=lambda v: list(v),
)
if not hotspots:
print("nothing to fix")
print("nothing to do")
return

# Journal any host-side effect exactly-once; distinct labels for
# parallel loops come from scopes.
for i, spot in enumerate(hotspots):
await c.scope(f"triage/{i}").step(
"log", lambda spot=spot: f"queued {spot['path']}"
"log", lambda spot=spot: f"queued {spot['item']}"
)

# Dynamic fan-out: assignments follow the data, round-robin.
outcome = await patterns.map_reduce(
c,
[
(crew[i % len(crew)], f"fix `{s['path']}`: {s['symptom']}")
(crew[i % len(crew)], f"{s['item']}: {s['detail']} (part of: {task})")
for i, s in enumerate(hotspots)
],
reduce=(lead, "merge every fix into one coherent candidate"),
Expand Down Expand Up @@ -82,4 +87,4 @@ def only_if_green(run) -> Verdict:


if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK))
31 changes: 19 additions & 12 deletions examples/debate_then_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,36 @@
Debate is pure `ask` (no artifacts, no freeze), so the winning side can go
straight into a work turn afterwards, in the same run and journal.

python examples/debate_then_build.py
python examples/debate_then_build.py ["<task>"] # default: implement quicksort with pytest
"""

import asyncio
import sys

from h5i.orchestra import Conductor, patterns

QUESTION = (
"Should `h5i msg wait` grow a --push webhook mode, or stay poll-only? "
"pro argues for the webhook; con argues for polling."
)
DEMO_TASK = "implement quicksort with pytest"


async def main() -> None:
async with Conductor(".", "debate-demo", launcher="resident") as c:
def question_for(task: str) -> str:
return (
f"What is the right way to '{task}'? pro argues for the simplest "
"implementation that could work; con argues for the most robust, "
"test-heavy one."
)


async def main(task: str) -> None:
question = question_for(task)
async with Conductor(".", "debate-demo", launcher="resident", isolation="supervised") as c:
pro = await c.hire("pro", runtime="claude", model="claude-haiku-4-5")
con = await c.hire("con", runtime="codex", model="gpt-5.4-mini")
con = await c.hire("con", runtime="codex", model="gpt-5.4-mini", effort="medium")
moderator = await c.hire(
"moderator", runtime="claude", model="claude-haiku-4-5"
)

outcome = await patterns.debate(
c, QUESTION, [pro, con], moderator=moderator, rounds=2
c, question, [pro, con], moderator=moderator, rounds=2
)
for who, argument in outcome.transcript:
print(f"[{who}] {argument[:120]}…")
Expand All @@ -42,8 +49,8 @@ async def main() -> None:

artifact, risks = await asyncio.gather(
winner.work(
f"You won this debate: {QUESTION}\nYour side prevailed with: "
f"{conclusion.rationale}\nImplement your position."
f"You won this debate: {question}\nYour side prevailed with: "
f"{conclusion.rationale}\nNow do the task your way: {task}"
),
loser.ask(
"You lost the debate. List the 3 biggest risks of the winning "
Expand All @@ -57,4 +64,4 @@ async def main() -> None:


if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK))
16 changes: 9 additions & 7 deletions examples/ensemble_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,28 @@
attached (bring them up with `launcher="resident"` to let the score spawn
tmux sessions itself).

python examples/ensemble_score.py "implement `h5i pull` mirroring `h5i push`"
python examples/ensemble_score.py ["<task>"] # default: implement quicksort with pytest
"""

import asyncio
import sys

from h5i.orchestra import Conductor

DEMO_TASK = "implement quicksort with pytest"


async def main(task: str) -> None:
async with Conductor(".", "ensemble-demo", launcher="resident") as c:
async with Conductor(".", "ensemble-demo", launcher="resident", isolation="supervised") as c:
claude = await c.hire(
"claude", runtime="claude", model="claude-haiku-4-5"
)
codex = await c.hire("codex", runtime="codex", model="gpt-5.4-mini")
codex = await c.hire("codex", runtime="codex", model="gpt-5.4-mini", effort="medium")

# Fail the predictable ways now, not at minute 30.
# 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)
await c.preflight(clean_worktree=True, min_isolation="supervised")

# Independent attempts, in parallel — then seal the round.
a, b = await asyncio.gather(
Expand All @@ -45,8 +47,8 @@ async def main(task: str) -> None:

# Neutral verification in fresh sandboxed worktrees (never the
# author's box), then the built-in verdict rule.
await c.verify(a, ["cargo", "test", "--quiet"])
await c.verify(b, ["cargo", "test", "--quiet"])
await c.verify(a, ["pytest", "-q"])
await c.verify(b, ["pytest", "-q"])
verdict = await c.judge()

print(await c.trace())
Expand All @@ -68,4 +70,4 @@ async def main(task: str) -> None:


if __name__ == "__main__":
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "demo task"))
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK))
17 changes: 9 additions & 8 deletions examples/judge_panel_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,26 @@
Judges are read-only seats that must be hired before the round seals —
alongside the workers, exactly like the roster note in patterns.py says.

python examples/judge_panel_score.py
python examples/judge_panel_score.py ["<task>"] # default: implement quicksort with pytest
"""

import asyncio
import sys

from h5i.orchestra import Conductor, patterns

TASK = "reduce `h5i team status` latency without changing its output"
DEMO_TASK = "implement quicksort with pytest"
RUBRIC = (
"prefer the smallest change that demonstrably keeps behavior identical; "
"penalize speculative refactors and untested hot paths"
)


async def main() -> None:
async with Conductor(".", "panel-demo", launcher="resident") as c:
async def main(task: str) -> None:
async with Conductor(".", "panel-demo", launcher="resident", isolation="supervised") as c:
workers = await asyncio.gather(
c.hire("claude", runtime="claude", model="claude-haiku-4-5"),
c.hire("codex", runtime="codex", model="gpt-5.4-mini"),
c.hire("codex", runtime="codex", model="gpt-5.4-mini", effort="medium"),
)
judges = await asyncio.gather(
c.hire("judge-a", runtime="claude", model="claude-haiku-4-5"),
Expand All @@ -36,11 +37,11 @@ async def main() -> None:

# Independent attempts → seal → neutral evidence for the panel.
artifacts = await asyncio.gather(
*(w.work(TASK, expect_independent=True) for w in workers)
*(w.work(task, expect_independent=True) for w in workers)
)
await c.freeze()
for artifact in artifacts:
await c.verify(artifact, ["cargo", "test", "--quiet"])
await c.verify(artifact, ["pytest", "-q"])

outcome = await patterns.judge_panel(c, RUBRIC, judges)

Expand All @@ -56,4 +57,4 @@ async def main() -> None:


if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else DEMO_TASK))
Loading
Loading