-
Notifications
You must be signed in to change notification settings - Fork 0
Writing an orchestrator
The reference for tools/argus_orchestra.py. If you have not seen one run yet, start with
Orchestrating several agents — this page is the surface, not
the tour.
| The API | HTTP, documented in The API and live at Settings → Go to → The API, live. Everything else here is a client of it. Use it directly from a language that is not Python, or from curl. |
tools/argus_client.py |
the transport: who, launch, relay, ring, worktree, prefs, prompts, bells. It reads the config, prefers the agent key, and knows the four awkward things about the bell stream. One call in, one answer out. |
tools/argus_orchestra.py |
the framework: several agents, in an order, each waiting for something. It owns the contract in each prompt, the waiting, the names, the worktrees and the report. |
Reach for the client when what you are writing is one call — a hook that rings, a cron job that starts a thing. Reach for the framework the moment there is a second agent and a wait between them, because that is where the two hundred lines of plumbing used to go.
Both are standard library only, one file each, importable or copyable. Neither is on PyPI on purpose: this project says its API can change between commits, and a versioned client would be promising a surface it cannot hold still yet.
Everything argus_client.py offers. Every method is one HTTP call unless it says otherwise,
and every one of them raises ArgusError on a refusal rather than returning something empty.
from argus_client import Argus, ArgusError, TooFast, credentials, config_path
a = Argus() # reads the config, prefers the agent key
a = Argus(base="http://…", token="…") # or say so yourselfLooking
a.who() |
sessions, who is in each, in which folder, and which are waiting for a person — the four questions the browser asks separately, in one answer. The first call an agent should make |
a.sessions() |
the raw session list, when who() is more than you need |
a.launchers(versions=False) |
what this machine can start, and whether each is really installed. versions=True also asks each one what version it is, which costs about two seconds |
a.prefs() |
what the browser remembers, as the machine has it: the desks, the windows, and — the reason a script wants it — the prompt library and the placeholder sets |
a.prompts() |
the prompt library alone, or [] if no browser has synced yet |
a.worktrees(path) |
the repository that path is in, and every checkout of it |
Doing
a.launch(launcher, name, where=".", prompt="", run=False, worktree=None, wait=True, wait_seconds=None, desk=False) |
start something from the launcher list. run=False types the prompt in and leaves the return to a person; worktree="branch" cuts a checkout first and starts in it; desk=True puts a window for it on whoever has Argus open. The answer says whether it settled before the prompt went in, which is the failure this is designed around |
a.relay(to, text, run=False) |
a sentence into a session that is already running — what dragging a prompt onto a terminal does, offered to a program |
a.ring(text="", why="asking", session="") |
call the person. asking waits for one; done and failed only report. session defaults to $ARGUS_SESSION
|
a.worktree(repo, branch, to=None) |
a second checkout on its own branch. Refuses a path that already exists, and never removes one |
Waiting
a.bells(until, since=0) |
bells as they ring, over one open connection, until until (a time.monotonic() value). A generator |
a.wait_for(paths, until, on_bell=None) |
wait for files to appear, woken by bells. Returns (arrived, missing)
|
bells takes a deadline rather than letting you check the clock between yields, and that is the
whole reason it is worth a library: the stream sends a heartbeat every twenty-five seconds and
never ends, so a for loop that checks the time between bells never gets the chance. Measured,
before it was fixed: an orchestrator asked to wait ninety seconds was still waiting after five
minutes. The socket timeout tracks what is left, too — forty seconds of patience when ten remain
is thirty seconds late.
The awkward corners
a.call(method, path, body=None, timeout=60) |
any route at all, with the token attached and the errors unwrapped. Use it for anything the methods above do not cover — nothing is hidden behind them |
credentials() |
(base, token) read out of ~/.config/argus/config.yaml. Prefers the agents: key over the master one, and $ARGUS_TOKEN over both |
config_path() |
where it looked. $ARGUS_CONFIG overrides it, which is how you point a script at a second Argus on the same machine |
ArgusError |
something Argus refused, carrying .status and the server's own sentence |
TooFast |
a subclass, for 429 only, so a script can back off rather than die. The message names the config key to raise |
main() is the command-line front door, and
tools/argus-say is three lines over it.
Both tables above are checked.
scripts/surface.pyenumerates every public name intools/from the code and the test suite fails if one has no docstring — or, with a checkout of this wiki, if one is named nowhere on it. CI clones the wiki so that second check is real. It exists because this page was once eight methods out of fourteen.
from argus_orchestra import Orchestra
o = Orchestra("~/work/api", launcher="Claude Code", prefix="", minutes=30,
run=True, watch=False, on_lost="continue", name="")where |
the folder everything defaults to, and the repository worktrees are cut from |
launcher |
a name from argus-say who. Checked before anything starts; a wrong one exits with the list |
prefix |
goes in front of every session name and on the last segment of every branch, so two runs of the same thing live side by side |
minutes |
the default patience for every wait; each call can override it |
run |
False types every prompt in and leaves the return to a person. Do this first
|
watch |
puts each session on the desk as it starts, and posts the run's shape so a browser can draw it live |
on_lost |
"continue" (default) carries on with what came back; "stop" refuses to go past a step somebody did not finish |
name |
what the run is called on the board. Defaults to the script's own filename |
argus |
an Argus instance, if you have a reason not to let it build its own |
group = o.fan_out(items, say, until=None, worktree=None, minutes=None, wait=True)
agent = o.step(say, name="step", until=None, where=None, worktree=None, minutes=None)
agent = o.start(name, say="", until=None, where=None, worktree=None)fan_out — the same job, N ways, one agent each. items is a list of strings, or a dict
when each one needs a short name and a long description: the key fills {each} and the value
fills {value}, which is what makes ref-{each} and "you are reading it for {value}" two
halves of one line. Blocking: it returns when they have all finished or the clock runs out.
wait=False returns as soon as they are started.
step — one agent, and wait for it. The ordinary case: a judge, an editor, a summariser.
start — one agent, left to it. Use this when the agent should be alive for the whole run
and told things later, which is what fullstack.py does with its three roles.
The placeholders, in say, until and worktree: {each}, {value}, {i}, {n}.
o.wait(*agents, minutes=None) # until each writes its `until`
result = o.wait_for(path, minutes=None, fresh=False, where=None)
o.tell(who, text, run=True) # a sentence into a running session
for turn in o.rounds(3): ... # a loop that says which round it is
o.report() # what happened, and where to lookwait_for takes a path rather than an agent. fresh=True waits for it to change rather
than to exist, which is the round-trip case: a tester rewrites the same FAILURES.md every
round and a stale one would end the loop early with the wrong answer.
tell accepts an Agent or a bare name, and applies the prefix either way — a session name is
an address, and frontend in another run is somebody else's agent.
rounds is bounded on purpose. An orchestration that can go round for ever will, on the night
you are not watching; reaching the cap prints that it is a cap and not a verdict.
group.done # the agents that wrote their file
group.lost # the ones that did not
group.paths # their files, as Paths
group.files # "- a cache: /path/RESULT.md\n- an index: …" — for the next prompt
agent.done # did it write it
agent.file # what it was asked to write
agent.where # the folder or worktree it is in
agent.text() # what it wrote
result.says("ALL GREEN")
result.split_by("backend:", "frontend:") # {"backend:": [...], "frontend:": [...], "": [...]}fan_out gives you a Group, start/step give you an Agent, and wait_for gives
you a Result — or None when nothing wrote the file.
for agent in group: ... # a Group iterates, and has a len()
len(group)
agent.name # the tmux session, prefix and all — the address `tell` writes to
agent.label # what you called it: the idea, the lens, the role. What the diagram shows
agent.branch # the worktree's branch, or None
agent.state # "waiting" · "working" · "asking" · "done" · "lost" — what colours the diagram
agent.arrived() # is the file there *now*, regardless of what was decided earlier
result.path # where it was
bool(result) # false when the file is empty, so `if said:` means what it looks likegroup.files is the method that earns its class. Every orchestrator ends up interpolating
"here is what the others produced" into the next agent's instructions, and doing it by hand is
a loop, a join and a formatting decision in the middle of the interesting part.
split_by keeps the lines that matched nothing under "" — a failure nobody owns is still a
failure, and dropping it is how it never gets fixed.
Small, and here because a reference that lists twelve of twenty-one methods is a reference you stop trusting.
o.say(line="") |
print through the framework, so your own lines sit in the same stream as its report rather than racing it |
o.named(name) |
the session name this run would use for name — the prefix rule, in case you need to build one yourself |
o.free(names) |
refuse now if any of those names is already running. Called for you by start and fan_out; call it yourself before doing something expensive |
slug(text, n=24) |
the sentence-to-name rule, trimmed at both ends and after the cut |
o.argus |
the client underneath. Nothing is hidden behind the framework: when it does not cover something, o.argus.call(...) is right there, holding the same token |
o.started |
every Agent this run has started, in order |
o.stages |
the shape the diagram is drawn from: [{"name": …, "agents": [Agent, …]}, …]
|
o.here |
the answer to who() from when the run began — the machine, its sessions, its launchers |
o.id, o.state, o.name
|
how the run appears on the noticeboard |
Orchestra.LOOK_EVERY |
10 seconds — how often the waiting loop looks at the disk regardless of bells |
Orchestra.BEAT_EVERY |
60 seconds — how often a watched run says "still here", so five minutes of silence can mean something |
Both constants are class attributes: change them on the class, or on one instance, if you have a reason. The defaults are chosen so that a file appearing with no bell costs you ten seconds and a killed script is noticed in five minutes.
Yours, plus two additions, both printed by a dry run because a framework that quietly edits what you told an agent is a framework you cannot debug.
Try this approach: a cache in front of the query.
Work only in /home/you/work/api-try-a-cache. ← only with a worktree
When you are finished, write /home/you/…/RESULT.md. ← only with `until`
When it is written, run: argus-say ring --why done --session a-cache
That last line is the reason the framework exists more than any other: it has to name the right session, in every prompt, every time, and it is the thing everybody gets wrong.
It also means argus-say must be on the PATH
on the machine the agents run on. install.sh puts it there; from a clone it is one symlink.
Properties of the substrate, not shortcuts, and anything you write will run into 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: agents write files, the framework reads files.
There is no implicit "finished". An agent is done when it says so, which is why until and
the contract exist. An agent that writes its file and forgets to ring is explicitly not a
failure: the bell is the signal, the file is the fact, and the loop checks the disk every ten
seconds regardless.
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 — the framework is blocking Python precisely so that while,
if and try still mean what they normally mean.
Two brakes, and they are not security — the launcher list and the agent key are that.
-
launches_a_minute, twelve. A deliberate fan-out of four referees and an editor is five in as many seconds; a runaway loop is hundreds. -
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, and the 429 names the one to raise. ArgusError carries the status and
the server's own sentence; TooFast is its own class so a script can back off rather than die.