Teams of coding agents¶
Run one coding agent, a coder + reviewer loop, or several agents with a judge on the same task, each worker in its own isolated git worktree, and get back a patch plus a full account of what it cost.
Concepts¶
| Piece | What it is |
|---|---|
Workspace |
An isolated checkout at a base commit: exec, diff, apply, fork, close. LocalWorktree is the local implementation; the benchmark provides remote sandboxes. |
Harness |
One non-interactive coding-agent invocation: claude (Claude Code CLI), codex (Codex CLI) or sdk (built-in agent on the OpenAI Agents SDK, any model via LiteLLM). Harnesses reach the repository only through Workspace.exec. |
Team |
A topology that drives harnesses over a workspace and returns a TeamResult: the patch, every HarnessRun (roles worker, coder, reviewer, judge, planner, integrator), duration and meta. |
Contracts are in types.py. The loops are the agent.workflows
patterns: review is a RefineLoop, best is a BestOfN with one generator per worker.
Team specs¶
solo:worker=H
review:coder=H,reviewer=H[,rounds=N] # rounds defaults to 3
best:workers=H+H[+H...][,judge=H] # judge defaults to the first worker's harness
plan:planner=H,workers=H[+H...][,integrator=H][,max_tasks=N][,replans=N][,reviewer=H][,rounds=N]
# integrator defaults to the first worker's harness; max_tasks to 4;
# replans to 1 (0 = never re-plan failed subtasks);
# reviewer= reviews every subtask (not `check`); rounds= (default 3) needs it
H := harness[@model] # claude, codex, sdk
# reviewer= and judge= also accept `check`, see Checks (not a plan's reviewer=)
The kind is split off at the first :; model ids may contain /, :, . and -
(not , or +). Examples: solo:worker=claude@haiku, solo:worker=codex@gpt-6-luna,
best:workers=claude@sonnet+codex+sdk@litellm/openrouter/some-model. Team.name is the canonical
spec with every default spelled out (rounds=3, judge=...), so build_team(team.name).name ==
team.name. build_team(spec) and build_harness(spec) raise SpecError with a message naming the
bad part and the allowed values.
sdk reaches any OpenAI-compatible endpoint (a local proxy, vLLM, a gateway): set OPENAI_BASE_URL
and OPENAI_API_KEY. sdk@<model> then uses the Responses API, sdk@litellm/openai/<model> Chat
Completions. Cost is still the list price of the model name (see Cost semantics), not what the
endpoint charges; names LiteLLM does not know are unpriced.
| Team | Behaviour |
|---|---|
solo |
The worker edits the given workspace. |
review |
The coder edits the workspace. After each attempt the reviewer judges the patch in a fork with the patch applied (it cannot touch the coder's workspace) and returns a verdict (score 0-10, passed, feedback). The coder resumes its session (where the harness supports it) with the feedback. Stops on passed, after rounds attempts, or when the coder stops changing anything. Returns the best-scoring patch (earliest on ties), not the last. |
best |
Every worker runs concurrently in its own fork, possibly with different harnesses. The judge scores each candidate patch independently (0-10) in another fork. The highest score wins, ties go to the earliest worker. A worker that crashes without producing a change is dropped; the run fails (empty patch) only if all do. |
plan |
A planner (it runs in a fork that is discarded, so anything it writes is thrown away) splits the task into 1..max_tasks independent subtasks {title, instruction, paths?}, returned as JSON (native schema flag where the CLI has one, else the last JSON object in the reply). Workers solve the subtasks concurrently, each in its own fork (subtask i goes to workers[i % len(workers)]; all at once unless PlanTeam(max_concurrency=N) bounds it; the prompt has the whole task, the subtask and the titles of the others, and tells the worker to stay out of their scope). The patches are merged into the team's workspace in subtask order with git apply --3way and left as uncommitted, unstaged changes like any other team's; a patch that conflicts or does not apply is rolled back (the clean ones stay). Re-planning: a subtask whose worker failed (or changed nothing) is recorded in failures and handed back to the planner, at most replans times (default 1). The planner runs in a throwaway fork with the merged changes applied, is told which subtasks are merged and which failed and why, and returns 0..max_tasks new subtasks for the remaining work only (0 = nothing is left). They are solved from the base commit like the first wave, with the worker rotated by the round (workers[(i + round) % len(workers)], so a failed piece goes to another harness when there is one), and merged the same way; the replaced subtasks get status replanned. A replan planner that fails or returns an invalid plan is recorded in failures (role planner) and the failed subtasks stay missing. Conflicts are not re-planned. Then the integrator runs in the workspace with the task, all patches and what went wrong (conflicts, subtasks still missing after the last replan) to produce the combined change; it does not run when everything merged. With a --check that fails on the integrated patch, the integrator gets one fix pass with the check output and the check runs again. Per-subtask review: with reviewer=H every subtask is solved by a review loop (the subtask's worker as coder, the reviewer judging each attempt in a fork of the worker's fork, at most rounds attempts) instead of one worker run, and the best-scoring patch is what gets merged; the reviewer never changes the worker's state. If the planner fails or returns an invalid plan (recorded in failures), or the plan has one subtask, the team behaves like solo with the first worker, or like a review loop with it when a reviewer is set (meta["fallback"]). |
A reviewer/judge that fails or answers with something that is not a verdict is recorded
(meta["failures"], the run is kept) and scores 0; it never crashes the team. Verdicts use the CLI's
native schema flag where there is one (claude --json-schema, codex exec --output-schema) and
otherwise the last JSON object in the reply.
TeamResult.meta holds the canonical team, and per topology: review → scores, rounds,
accepted, best_round, round_log; best → scores (null for dropped workers), chosen
(1-based), candidates (harness, score and indices into runs). Failures go to failures.
plan → subtasks (title, instruction, paths, round (0 = the initial plan), worker, status of applied/conflict/failed/empty/replanned, with a reviewer also review (scores, accepted, best_round), indices into runs; numbered 1-based in creation order across rounds),
replans (one {round, failed, subtasks, error?} per replan round: the subtask numbers handed back and the new ones),
integration (applied/conflicts/missing subtask numbers, integrator_runs), check_before_fix
(the failing check result, when a fix pass ran) and fallback. Its runs are in this order: planner,
the first wave's worker runs (coder/reviewer runs with a reviewer), then per replan round the
planner and its workers, then integrator. In failures, a subtask's entries carry its subtask
number, and with a reviewer the review loop's attempt; a failed replan carries its round.
Checks¶
--check CMD (build_team(spec, check=CMD)) is an objective verdict from a shell command: tests, a
build, a linter. After the team finishes, sh -c CMD runs in a fresh fork with the final patch
applied. It gets your environment (including PATH) but not your working tree: the fork starts at
the base commit, so untracked dependencies (node_modules, a virtualenv) are missing, and a
virtualenv on your PATH still points at your original checkout: an editable install
(pip install -e .) imports that tree, not the patched fork, and the check would silently judge the
wrong code. Make the command self-contained and install into the fork, e.g.
--check 'python -m venv .venv && .venv/bin/pip install -q -e . pytest && .venv/bin/pytest -q'.
The command is bounded by the team's --timeout (a timeout is a failed check); without
--timeout it is unbounded. The result is recorded in TeamResult.meta["check"] and as check
in record.json: {command, passed, exit_code, output_tail, duration_s} (exit_code is null if
the command did not finish or there was no patch to check; output_tail is the end of stdout then
stderr). The CLI summary prints pass/fail; the check does not change the exit code of
multi-agents run. (It can change which patch a team returns, see below.)
The check runs on what the agents left behind, including edits to the tests. An agent that
rewrites or deletes the failing test, conftest.py or the Makefile makes the check pass, and the
prompts only ask it not to. The fork's HEAD is the base commit, so restore the files the agents
must not touch before running: --check 'git checkout HEAD -- tests && python3 -m unittest -q'
(this does not restore files the agents added, e.g. a new conftest.py; add git clean -fdq tests).
Read the patch before you apply it either way.
It also changes how teams decide:
| Use | Behaviour |
|---|---|
reviewer=check |
A test-driven review loop with no LLM reviewer: exit 0 scores 10 and passes (the loop stops), anything else scores 0 and the coder gets the tail of the output as feedback. |
judge=check |
best keeps the worker whose patch passes the check (ties go to the earliest worker). |
LLM reviewer + --check |
The reviewer's prompt includes the check result for the patch under review. |
LLM judge + --check |
Every candidate is checked as well. Candidates that fail rank below every candidate that passes; the judge's scores order within each group. meta["candidates"][i]["check"] has each result; meta["scores"] stays the judge's raw scores. |
check as reviewer=/judge= implies --check with the same command and raises SpecError
without one; as worker, coder, planner, integrator, in workers or as a plan team's reviewer it is rejected. The check reviewer/judge
and the final recorded check share one memoized run per patch (the command is not run twice on the
same patch). A check is not a harness run: it consumes no tokens. As reviewer=check or
judge=check it appears in runs with role reviewer/judge, harness check and cost exactly $0.00.
With reviewer=check all failing attempts score 0, so the team returns the last attempt (the one
with the most feedback) rather than the first.
CLI¶
multi-agents run --team review:coder=codex,reviewer=claude@haiku,rounds=2 \
--repo path/to/repo "Fix the failing test in tests/test_ops.py"
multi-agents is the installed console script; from a checkout, python -m agent is the same.
| Flag | Meaning |
|---|---|
--team SPEC |
required, see above |
--repo PATH |
repository (default: current directory); --base REF base ref (default HEAD) |
--timeout S |
finite positive seconds allowed per harness run (not for the whole team) |
--permissions safe\|bypass |
see Safety (default safe) |
--runs-dir DIR |
where records go (default $XDG_CACHE_HOME/multi-agents/runs/<repo>-<hash>/, i.e. never inside your repository; the path is printed) |
--keep-worktrees |
keep the main worktree for inspection (forks made by teams are always removed) |
--apply |
apply the patch to your tree; refused up front (before any agent runs) if the tree is not clean |
--check CMD |
shell command run against the final patch in a fork; see Checks |
--max-cost USD |
hard stop on spend: no new harness run starts once this much has been spent; see Budget |
--allow-unpriced |
with --max-cost: accept harnesses that report no cost and count their runs as $0 |
The run creates <runs-dir>/<utc-timestamp>_<team>/record.json (team, task, repo, base commit,
patch, every run with harness/role/model/tokens/cost/seconds/session/log, usage, total cost, duration,
meta) and final.patch, prints a Rich summary and the exact command to apply it:
git -C <repo> apply <runs-dir>/<run>/final.patch
<team> is the canonical spec with characters outside A-Za-z0-9_- replaced by -; a spec too
long for a 255-byte directory name is cut and suffixed with a hash of the full spec, which
record.json keeps.
Your working tree is never modified unless you pass --apply (and then only if git status is
clean). The base is a commit: uncommitted changes in your tree are not visible to the workers.
Exit code: 0 patch produced, 1 empty patch, 2 usage error, 3 --apply refused or failed (or --pr delivery failed), 4 the team crashed, 130/143/129 cancelled by SIGINT/SIGTERM/SIGHUP.
SIGINT, SIGTERM and SIGHUP all take the same path: the team is cancelled, every worker's process
group is killed and the worktrees are removed (so a caller's tool timeout, which sends SIGTERM, does
not leave agents running and billing); a second signal force-quits.
Where worktrees live¶
LocalWorktree creates detached git worktrees under $MULTI_AGENTS_WORKTREE_DIR, else
$XDG_CACHE_HOME/multi-agents/worktrees, else ~/.cache/multi-agents/worktrees, grouped per repo.
Not inside your working tree (editors and git status never see them) and not inside .git (the
agent CLIs protect paths containing a .git segment). Creating one adds only metadata under the
repository's worktrees/ directory; your index, HEAD and branches are untouched. diff() uses a
throwaway index, so it leaves the worktree's own index alone, includes new files and commits the
agent made, honours .gitignore, leaves out new Python bytecode (__pycache__/, *.pyc) even
where the repository does not ignore it, and is binary-safe.
Safety: permission modes¶
The adapters run real agents that edit files and run commands. Pick the mode deliberately.
safe(default).claude:--permission-mode acceptEditsplus Claude Code's OS sandbox (sandbox.enabled,autoAllowBashIfSandboxed): edits and Bash (tests) are allowed automatically but Bash is confined to the workspace and has no network; anything that would still need approval is denied rather than waiting. If the sandbox cannot start on the host, Bash is denied (the agent can edit but not run tests).codex:-s workspace-writewithapproval_policy="never".bypass.claude --dangerously-skip-permissions,codex --dangerously-bypass-approvals-and-sandbox. Use only where the workspace itself is the sandbox (the benchmark's containers). Claude Code refuses it as root unlessIS_SANDBOX=1is in its environment: passenv={"IS_SANDBOX": "1"}tobuild_team/build_harness(or set it in the sandbox).sdkhas no permission system. Itsrun_shelltool runs with the workspace's own privileges, unconfined. Locally that means your user account; the CLI prints a warning. Model calls are made from the host process (keys from the host environment), tool commands in the workspace.
Your own CLI configuration still applies to claude and codex (user settings, CLAUDE.md/AGENTS.md,
hooks, default model).
Cost semantics¶
Usage.cost_usd is None when a run is unpriced, and any sum containing an unpriced run is None
(TeamResult.usage), so totals never under-report silently. The CLI then shows the priced part plus
"N unpriced run(s)". input_tokens includes cached tokens; cached_input_tokens is the cache-read part.
| Harness | Tokens | Cost |
|---|---|---|
claude |
from modelUsage (input + cache read + cache creation, output); on resume the CLI's totals are cumulative over the session, so the previous total is subtracted |
the CLI's own total_cost_usd (same delta); None if absent or if the session's earlier total was not seen by this harness object |
codex |
turn.completed usage, minus the previous total when resuming (the CLI reports a cumulative thread total) |
pricing.py: list price × tokens, short-context rates, for models in the table. The stream does not name the model: with no @model it is read from the session's rollout file; if that fails, or the model is not listed, None |
sdk |
summed over all requests of the run | pricing.py for OpenAI models; litellm.cost_per_token for litellm/... models when litellm knows the model; else None |
Prices are never guessed or borrowed from a similar model. pricing.py records its source URL and date;
batch/flex/fast tiers and data-residency uplifts are not applied. is_priced("codex@model") tells a
caller up front whether a harness spec will report a cost.
Budget¶
--max-cost USD (build_team(spec, budget=Budget(USD))) is a hard stop on new work. Every harness of
the team except check (a shell command, exactly $0) shares one Budget: before a run starts it is
admitted only while the spend so far is below the limit, and when it returns its usage.cost_usd is
added (also for a non-zero exit, which still cost money). Once the limit is reached every further run
is refused with BudgetExceeded; the topologies record that in meta["failures"] like any failed run,
so a refused run never appears in TeamResult.runs and the team finishes with the best patch it has.
- The limit can be exceeded. A harness run cannot be stopped midway without losing its work, and
its cost is only known when it ends. Runs already admitted finish, so the total can overshoot by
their cost: the run that crossed the limit plus the runs in flight beside it (the workers of
bestorplanrun concurrently). - Unpriced usage blocks new work. A run with
cost_usd=Nonecannot be counted, so after one the budget can no longer be enforced and further runs are refused. The CLI therefore rejects (exit 2, before any agent runs) a team with a harness that is notis_priced(...), naming it, unless--allow-unpriced: then unpriced runs count as $0 and the limit only covers the priced part of the spend.--allow-unpricedwithout--max-costis a usage error. - A yellow warning is printed when 80% of the limit is used (
Budget.warn_at,on_warn).
record.json gets budget (max_cost_usd, spent_usd, unpriced_runs, warned, exhausted,
refused: the {role, harness, reason} of each refused run) and the summary prints one line such as
budget: $0.0412 of $0.0500 used, 2 run(s) refused. Exit codes are unchanged.
Python API¶
from agent.teams import CodingTask, LocalWorktree, build_team
team = build_team("best:workers=claude@haiku+codex", permissions="safe", timeout=900)
async with await LocalWorktree.create("path/to/repo") as ws:
result = await team.solve(CodingTask("t1", "Fix the failing test"), ws)
print(result.patch, result.usage.cost_usd, [(r.role, r.harness) for r in result.runs])
Issue to pull request¶
multi-agents run --repo . --team "solo:worker=claude@haiku" --issue 42 --pr
multi-agents run --team "review:coder=codex,reviewer=claude" --issue https://github.com/OWNER/REPO/issues/42 \
--pr --ready "keep the public API unchanged"
Both need the GitHub CLI (gh), logged in.
Issue text is untrusted input. Whoever can open an issue on the repository writes part of the
agent's prompt. The text is passed to the agent as a delimited problem description it is told not to
obey as instructions, which reduces but does not remove prompt-injection risk. Do not combine --issue
on a repository where strangers can open issues with --permissions bypass or the sdk harness (both
run commands with your privileges), and read the patch and the draft PR before trusting them.
--issue N|URLreads the issue (gh issue view, run in the target repo, so a bare number resolves against its default remote) and uses its title and body (capped at 8000 characters) as the task. A positional instruction is added as extra guidance. The issue is stored inrecord.jsonasissue: {number, title, url}. A missinggh, no login, or an unknown issue stops the run with exit code 2 before any agent starts.--prpublishes a non-empty patch: a branchmulti-agents/<issue-number-or-slug>-<run-id>is committed at the base commit with a temporary index (your checkout, index and current branch are never touched), as your configured git identity (unsigned, even withcommit.gpgsign: sign it afterwards if a ruleset requires signatures), pushed toorigin(never forced; if the name exists locally or onorigin, a-2,-3... suffix is used), and opened withgh pr create --draftagainst the branch the base commit came from iforiginhas it, else the repository's default branch.--readyopens a regular PR instead. The push runs your repository'spre-pushhook, if any, like anygit push.- Before the team runs,
--prchecks (exit code 2 otherwise) thatghis logged in, thatoriginis a repositoryghcan use, and that the base commit is already on that base branch onorigin. The pushed branch carries every commit below it, so unpushed local commits would otherwise be published and show up in the PR: push them, or pass--base origin/<branch>. - The PR body has
Closes #N(Closes OWNER/REPO#Nfor an issue in another repository), the team spec, the check result when the run record has one, a per-role cost/tokens/time table with the total, and the files changed. It is also saved aspr-body.mdnext torecord.json. The PR URL and branch are stored aspull_request: {url, branch}and printed. - An empty patch opens nothing (exit code 1). If committing, pushing or
gh pr createfails (exit code 3, the same code as a failed--apply), the local branch is kept where there is one and the exact commands to finish by hand are printed; the patch is always saved asfinal.patchnext to the record.
gh picks the repository for pr create from your remotes (gh repo set-default to change it). Fork
workflows where origin is not that repository are not handled specially.