Skip to content

Orchestrating several agents

Andrea de Ruvo edited this page Aug 19, 2026 · 5 revisions

Orchestrating several agents

Argus does not ship an orchestrator and is not going to. What it ships is the substrate one is made of — start a thing, see who is there, hand a sentence over, ring a person — a framework that owns the plumbing, and three worked examples that are twenty lines each because of it.

The framework

tools/argus_orchestra.py. One standard-library file, beside the client, importable or copyable.

from argus_orchestra import Orchestra

o = Orchestra("~/work/api")

tries = o.fan_out(["a cache in front of the query", "an index on the join column"],
                  say="Try this approach: {each}",
                  worktree="try/{each}",
                  until="RESULT.md")

o.step(name="judge", until="DECISION.md",
       say=f"Read these and say which to keep:\n{tries.files}")
o.report()

That is a whole orchestration. It used to be two hundred lines, and the two hundred were all plumbing — which is the point: argus_client.py gives you the verbs, and that was enough to write an orchestrator and not enough to stop every orchestrator rewriting the same machinery.

What the framework owns

the contract you write until="RESULT.md" and the prompt gains "when it is written, run argus-say ring --why done --session <its own name>". That sentence has to name the right session, in every prompt, every time, and it is the thing everyone gets wrong.
the waiting one implementation of bell wakes it, file decides it, with the deadline checked inside the stream and a look at the disk every ten seconds — because an agent that writes its result and forgets to ring is explicitly not a failure.
the names slugs, an optional prefix, and every name checked against what is already running before anything starts.
the worktrees one checkout per idea, on its own branch, the prefix reaching the branch too so two runs never fight over one path.
what comes back tries.files, tries.done, tries.lost; Result.says("ALL GREEN"), Result.split_by("backend:", "frontend:").
the rehearsal run=False types every prompt in and leaves the return to you — once, rather than reimplemented per script.

The whole surface — every argument, what each call returns, and the prompt an agent actually receives — is in Writing an orchestrator.

What it deliberately does not own. It is ordinary Python: a while is a while and an if is an if. It is blocking on purpose — fan_out starts N agents and returns when they are done — so a script reads top to bottom and a conditional loop is a for, not a feature request. That is why fullstack.py below can exist at all.

Watching it happen

--watch on any of the three, or watch=True on an Orchestra, does two things: each session appears on the desk of whatever browser has Argus open, and the run posts its shape as it goes, so a window draws it — a node per agent, an arrow per dependency, and the four states in the same colours they wear everywhere else here.

  • green finished · accent working · amber an agent has stopped to ask for a person · red the clock ran out on it, and no arrow from it into whatever came next, because whatever came next was given what came back.

Each node also says its state in a word under the name, which is the half that survives being colour-blind or printed.

The window opens itself the first time a run speaks and does not move you off the page you are reading. Nothing is queued for a browser that is not open — "show me this now" only means something while somebody is looking — and the orchestration runs exactly the same with every browser shut. If the script is killed, the run says lost touch rather than claiming to run for ever: it beats once a minute while it waits, and five minutes of silence is not believed.

On a board of several machines, Panoptes shows the same run as one line on that machine's tile — how far along, and amber if an agent inside it is waiting for a person. The picture stays on the machine you can act on.

The three shapes

orchestra.py N attempts at the same problem, then a judge. One worktree per idea so they never edit one checkout, an agent in each, and one more reading the results.
referee.py N pairs of eyes on the same artefact, then an editor, then a rebuttal. Four referees with four different lenses, an editor who reconciles them, and — with --rebuttal — an author who answers.
fullstack.py Roles that stay alive and hand work to each other in order. A backend, a frontend and a tester in one checkout, with a capped loop: the tester's failures go back to whichever side owns them.
python3 scripts/orchestra.py --repo ~/work/api \
    --try "a cache in front of the query" --try "an index on the join column"

python3 scripts/referee.py --paper ~/work/paper --rebuttal

python3 scripts/fullstack.py --repo ~/work/shop --task "saved baskets, endpoint and page"

Every one takes --no-run, which types each prompt into its agent and leaves the return to you. Run them that way first: you see exactly what each agent was told — the contract included — and nothing starts working until you press Enter. --watch puts each session on your desk as it starts.

Actually running one

Where. On the machine Argus runs on. The scripts call 127.0.0.1 and read ~/.config/argus/config.yaml themselves, so there is no address to pass and no token to handle — and no way to drive another machine's Argus from here, which is deliberate.

One thing to check first. The prompts these scripts write all end "then run: argus-say ring --why done", so argus-say has to be a command the agents can actually run. install.sh puts it on the PATH beside argus; if you are running from a clone, one line:

ln -s ~/argus/tools/argus-say ~/.local/bin/argus-say
argus-say who        # should list your sessions and end with "can start: …"

The last line of argus-say who is the launcher list, and --launcher has to be one of those names exactly — the scripts check it before starting anything and tell you the list if it is wrong.

From a shell. Open a shell session in Argus — or tmux new -s orchestra — and run it there rather than in a terminal on your laptop. It stays in the foreground for as long as --minutes allows, printing each agent as it starts and each result as it lands; run it inside tmux and you can shut the laptop, and watch the whole thing from a phone.

cd ~/argus
python3 scripts/orchestra.py --repo ~/work/api \
    --try "a cache in front of the query" \
    --try "an index on the join column" --no-run

--no-run types every prompt into its agent and leaves the return to you. Do this first. You see exactly what each one was told, nothing has started, and you press Enter in the panes you actually want.

From an agent. It is a shell command, so an agent can run it — but an agent that runs a thirty-minute blocking command is an agent that is gone for thirty minutes. Hand it to a session of its own instead, which is one call:

argus-say start "A shell" --name orchestra --in ~/argus --run \
  --prompt 'python3 scripts/orchestra.py --repo ~/work/api --try "a cache" --try "an index"'

The orchestrator now has a pane in Argus like everything else: you watch it, the agent that started it goes back to its own work, and neither is waiting on the other. Tell your agent that in its CLAUDE.md and it will reach for it.

When the names are taken. Every script uses fixed session names — judge, editor, backend — and a machine that has been worked on for a week already has one of those. The framework checks every name up front and refuses with the list, rather than finding out on the third launch and leaving half an orchestra running. --prefix is the answer, and it reaches the branch names too, so two runs of the same thing live side by side:

python3 scripts/fullstack.py --repo ~/work/shop --task "saved baskets" --prefix shop
#   -> shop-backend, shop-frontend, shop-tests

What you get back. Nothing is closed and nothing is deleted. orchestra.py leaves a worktree per attempt (git -C <repo> worktree list) with its RESULT.md; referee.py leaves <paper>/reviews/<today>/; fullstack.py leaves <repo>/.handover/. Every session is still open, so the useful next move is usually to go and ask one of them something.

Three constraints that shape all of them

These are properties of the substrate, not shortcuts, and anything you write will meet them.

You cannot read what an agent said. Argus types into a session; it cannot read one back. Reading a pane means capture-pane, which is scraping a text user interface and — on at least one machine this was tested against — a way to take the whole tmux server down. So coordination goes through the filesystem: the agents write files, the orchestrator reads files. Every serious pattern lands there, which is why the two-agent recipes have always used a bridge file.

There is no implicit "finished". An agent is done when it says so, and that has to be in the prompt: when you have finished, write X and then run argus-say ring --why done. Writing that contract is the orchestrator's half of the work. The examples wait on the bell stream — an open connection, not polling — and check the file whenever a bell arrives: the bell is the signal, the file is the fact, and an agent that writes its result and forgets to ring is not treated as a failure.

Nothing retries, supervises or recovers. A timeout fires and you are told which agent never finished, with the path to its worktree. A real supervisor is another hundred lines of the same kind, and it should be yours.

What it costs to be wrong

Two brakes, and they are not security — the launcher list is that:

  • launches_a_minute, twelve by default. It was six, and that was wrong: a runaway loop does hundreds, while a deliberate fan-out of four referees plus an editor is five in as many seconds. Two of the three examples tripped it halfway through and left half an orchestra running.
  • relay_a_minute, thirty. A pokes B, B pokes A, and by morning there are nine hundred lines of two robots talking.

Both are config keys, because whoever runs a fan-out of twenty knows that they are.

What one is made of

Four calls, and you have met all of them:

argus.call("POST", "/api/git/worktree", {"path": repo, "branch": "try/cache"})
argus.call("POST", "/api/tmux/launch",  {"launcher": "Claude Code", "name": "cache",
                                         "path": where, "prompt": brief, "run": True})
for bell in argus.bells(since, until=deadline):     # the open connection
    ...
argus.call("POST", "/api/relay", {"to": "frontend", "text": "the contract is written"})

Underneath is tools/argus_client.py, the transport: it reads ~/.config/argus/config.yaml itself and prefers the agent key if there is one, so no token is ever passed around. The framework sits on top of it, and the three bugs below are fixed in there rather than in each script.

The bugs these examples cost, which are worth knowing

They are in the repository because they run, and getting there found three things that would have bitten anybody writing their own:

  • A heartbeat is not a bell. The bell stream sends one every twenty-five seconds and never ends. A deadline checked between yielded bells is therefore never checked at all: an orchestrator asked to wait one minute was still waiting after three. Check the clock inside the reader, on every line.
  • A flat socket timeout overshoots. Forty seconds of patience when ten remain is thirty seconds late. Make it track what is left.
  • A multi-line prompt is one paste. A fake agent reading with readline() got the first line and nothing else. Real agents handle bracketed paste; a shell script you write as a stand-in will not, and you will blame the wrong thing for an hour.

When not to

One agent doing one job needs none of this — start it from the desk, watch it, answer it. These are for the afternoon where three things could be tried at once, or where the thing that is missing is a second opinion rather than more typing.

Clone this wiki locally