QA swarm: 60 agents test jq 1.6 and check each other's bugs
One tester cannot cover all of a tool, and a bug report is worth more once someone else has
reproduced it. Here 60 agents split jq 1.6, the command-line JSON processor
that Debian 12 ships, between them and reproduce each other's findings. Each one works in its own
Docker sandbox with no network. They work in rounds and
talk only through a message board. A member that finds a bug posts a FINDING line with the command
that shows it. In a later round, another member runs the same command and posts REPRODUCED or
NOT REPRODUCED. After each round, a coordinator agent reads the board and gives each member its
next task: reproduce a finding, test a part of jq nobody has covered, or dig where bugs cluster. Once
every finding is checked and every part of jq is covered, the coordinator ends the run with its bug
report.

The board of our 60-member run, replayed and sped up: the coordinator's plan and tasks, then the findings (orange) and the reproductions (green).
A 4-member run ended on its own after six rounds, in 50 minutes, with 31 findings, each reproduced by a second member. Our 60-member run had two hours: when we stopped it, in round 6, the coordinator counted 244 findings on its board, and its report lists 217 of them as reproduced by a second member. The Mission example builds jq 1.6 in Python from its manual; this swarm tests jq 1.6 itself.
Demonstrates: a Docker sandbox per member, with no network, tools that reach your own service, session inputs that tell each tool which member calls it, a read-only hint, a session per member and one for the coordinator, in one process, a timeout per round, and interrupting the turns still running at its end.
Run: build the sandbox image and start the board, then, from the example's folder with
MISTRAL_API_KEY set: uv run python qa_swarm.py 60
The message board
The board is a small web app with a Postgres database. It keeps the messages of every run, each run on its own board, named in the URL. Agents post and read through its API; people open its page, which shows new messages as they arrive:
@app.post("/api/boards/{board}/messages")
def send(board: str, message: NewMessage) -> dict:
with pool.connection() as db:
# One writer at a time, so ids commit in order: a reader paging with after= never skips one.
db.execute("LOCK TABLE messages IN SHARE ROW EXCLUSIVE MODE")
(id,) = db.execute(
"INSERT INTO messages (board, author, text) VALUES (%s, %s, %s) RETURNING id",
(board, message.author, message.text[:2000]),
).fetchone()
return {"id": id}
@app.get("/api/boards/{board}/messages")
def read(board: str, after: int = 0, limit: int = Query(50, le=200)) -> list[dict]:
"""The board's messages with an id greater than `after`, oldest first."""
with pool.connection() as db:
rows = db.execute(
"SELECT id, author, text, at FROM messages WHERE board = %s AND id > %s ORDER BY id LIMIT %s",
(board, after, limit),
).fetchall()
return [
{"id": id, "author": author, "text": text, "at": at.isoformat(timespec="seconds")}
for id, author, text, at in rows
]Run the board on your machine or on Mistral Apps. Its
mistral.json declares the app and a managed Postgres database: on Mistral Apps, the platform runs
the database and gives the app its POSTGRES_* settings.
Tools for the members and the coordinator
Members post with send_swarm_message and read with read_message. A member's name comes from a
session input, so the model cannot post as another member:
class Member(agents.SessionInput):
"""Who this session is on the board."""
input_id = "swarm.member"
name: str@agents.tool
async def send_swarm_message(text: str, me: Annotated[Member, agents.Depends()]) -> str:
"""Post a message on the swarm's board, where every member and the coordinator can read it."""
return f"posted as message {await post(me.name, text)}"
@agents.tool(annotations=agents.ToolAnnotations(read_only_hint=True, destructive_hint=False))
async def read_message(after: int = 0, limit: int = 50) -> str:
"""Read the board's messages with an id greater than `after`, oldest first, at most `limit` (200 at most)."""
response = await call_board("GET", "/messages", params={"after": after, "limit": max(1, min(limit, 200))})
lines = [f"{m['id']} {m['author']}: {m['text']}" for m in response.json()]
return "\n".join(lines) or "(no newer messages)"post sends the message to the board through call_board, which makes the request with an
httpx.AsyncClient on SWARM_BOARD_URL. It tries a read again on a dropped connection or a server
error, and a post only when it could not connect, so no message is posted twice. The client sends
your Mistral API key in X-Api-Key, which a board on Mistral Apps checks and a board on your machine ignores.
The coordinator has two more tools. They hand their arguments to the program: assign keeps a
member's task for the next round and posts it on the board, and finish keeps the reasons and the
report, which ends the run. assign refuses a name that is not a member:
@agents.tool
async def assign(member: str, task: str) -> str:
"""Give one member its task for the next round. The task is posted on the board."""
if member not in sessions:
return f"no member {member}: the members are {', '.join(sessions)}"
assignments[member] = task
await post("coordinator", f"-> {member}: {task}")
return f"{member} assigned"
@agents.tool
async def finish(reasons: list[str], report: str) -> str:
"""End the run.
`reasons`: one line per slice, why its coverage is good enough. `report`: the bug report in Markdown.
"""
finished.update(reasons=reasons, report=report)
return "run ended"The members and the coordinator
Every member is the same agent. Its instructions set the board's protocol: one line per message, a
FINDING with the command that shows it, and a REPRODUCED or NOT REPRODUCED that names the
finding's id. Its sandbox is a container of the swarm-jq16 image, with no network:
member = agents.Agent(
model="mistral-large-4",
name="qa-member",
instructions=(
"You are one member of a QA swarm testing the jq command-line JSON processor installed in your "
"sandbox (run `jq` with your shell tool; there is no network). A bug is wrong output, a crash, a "
"hang or an error on valid input, judged against the jq manual. The swarm talks only through its "
"board: send_swarm_message posts, read_message(after=, limit=) reads. Post one line per message:\n"
"- 'FINDING [<slice>] `<one shell command>` prints <got>; expected <expected>'\n"
"- 'REPRODUCED #<id>: <what you saw>' or 'NOT REPRODUCED #<id>: <why>'\n"
"REPRODUCED means two things: you saw the same output, and the jq 1.6 manual says otherwise. "
"If jq 1.6 behaves as its manual documents (even if a later jq changed it), post NOT REPRODUCED "
"with the manual's words.\n"
"Run every command before you post it, post each finding as soon as you have it, and never post "
"a finding already on the board. Keep each command small: no huge outputs, no endless loops.\n"
"Each round is short: run at most 8 shell commands (one command may test several cases), post, "
"then answer with one line: what you covered.\n"
"Each jq run is limited to 128 MB of memory and 20 seconds. 'cannot allocate memory', exit 124 or "
"an abort on a small input is a finding: name the input and its size."
),
tools=[send_swarm_message, read_message],
environment=agents.LocalDockerSandbox(image="swarm-jq16", network=agents.Network.none()),
)The image is Python with Debian's jq 1.6. A wrapper caps each jq run at 128 MB of virtual memory and 20 seconds, so a runaway jq stops in its own sandbox:
FROM python:3.12-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends jq && rm -rf /var/lib/apt/lists/*
# Each jq run gets 128 MB of virtual memory and 20 seconds, so a runaway jq dies in its own sandbox
# (exit 124 on the time limit, "cannot allocate memory" or exit 5/134 on the memory limit)
# instead of filling the Docker VM. The members see the limits in their instructions.
# The wrapper comes first in PATH; jq itself stays in /usr/bin.
RUN printf '#!/bin/sh\nulimit -v 131072\nexec timeout 20 /usr/bin/jq "$@"\n' > /usr/local/bin/jq && chmod +x /usr/local/bin/jqThe coordinator reads the board and steers the members with assign. Its instructions say when to
stop:
coordinator = agents.Agent(
model="mistral-large-4",
name="qa-coordinator",
instructions=(
"You coordinate a QA swarm that tests jq 1.6. Members post FINDING, REPRODUCED and NOT REPRODUCED "
"lines on the board. After each round, read the new messages with read_message(after=...). "
"Then either call assign once per member with its next task, or call finish. When you are asked, "
"the round is over and every member has stopped: never wait for more messages.\n"
"A task is one of: reproduce given #ids (never by the member that found them), probe a gap in a "
"slice, or dig deeper on an area where bugs cluster. Each task names its slice.\n"
"Call finish only when every finding has been reproduced or refuted by a second member and each "
"slice is covered well enough. In finish, give one line of reasons per slice, and a report listing "
"each reproduced bug: its message id, the command, what jq printed and what it should print."
),
tools=[read_message, send_swarm_message, assign, finish],
)Rounds until the coordinator ends the run
In round 0, the coordinator splits jq into slices, such as paths, regex or dates, and gives each
member one. In each round, every member runs its task in its own sandbox, 20 at a time
(QA_AT_ONCE); the others wait for their turn. Once it starts, a turn gets 10 minutes: a turn still
running then is interrupted, so the next round starts clean. With 60 members, 20 at a time, a round
takes about half an hour at most. The coordinator then reads the round's messages and calls assign
for each member, or finish. The coordinator's turns get 10 minutes too. MAX_ROUNDS is a safety
net: after 8 rounds, the program asks the coordinator to finish.
async def timed_turn(session: agents.Session, prompt: str, seconds: float = ROUND_SECONDS):
"""One turn of at most `seconds`. A turn still running then is interrupted, and has ended when this returns."""
state = await session.run(prompt, timeout=seconds)
if isinstance(state, agents.Busy):
raise RuntimeError("the session still runs an earlier turn")
if isinstance(state, agents.Failed):
raise RuntimeError(f"the turn failed: {state.error}")
if isinstance(state, agents.UnknownOutcome):
raise RuntimeError(f"the turn ended with outcome {state.outcome}")
if isinstance(state, agents.Running):
turn = session.turn(state.turn_id)
await turn.interrupt()
if isinstance(await turn.result(timeout=60), agents.Running):
raise RuntimeError("the turn did not stop after its interrupt")
return stateafter = 0
for round_number in range(1, MAX_ROUNDS + 1):
if finished: # the coordinator ended the run in round 0
break
tasks = [(name, task) for name, task in assignments.items() if name in sessions]
idle = [name for name in sessions if name not in assignments]
assignments.clear()
round_started = time.monotonic()
states = await asyncio.gather(
*(run_member(name, f"Round {round_number}. Your task: {task}") for name, task in tasks)
)
late = sum(isinstance(state, agents.Running) for state in states)
messages = await read_round(after)
kinds = {
k: sum(m["text"].startswith(k) for m in messages) for k in ("FINDING", "REPRODUCED", "NOT REPRODUCED")
}
seconds = time.monotonic() - round_started
print(
f"round {round_number}: {len(tasks)} members, {late} late, {seconds:.0f} s; "
f"board +{len(messages)} {kinds}; crashes so far {len(crashes)}"
)
fresh = f" {', '.join(restarted)} failed this round, so their task is not done." if restarted else ""
fresh += f" {', '.join(idle)} had no task this round." if idle else ""
restarted.clear()
await ask(
f"Round {round_number} is over and every member has stopped.{fresh} Read the board after "
f"message {after}, paging with after= until there are no newer messages. Then assign each "
"member its next task, or finish.",
)
after = messages[-1]["id"] if messages else after
if finished:
print(f"the coordinator ended the run after round {round_number}")
break
else:
print(f"stopped by the safety cap of {MAX_ROUNDS} rounds")
await ask(FINAL_ASK.format(after=after))A member whose turn fails gets a fresh session for the next round. The coordinator's next prompt names it, along with any member it gave no task:
async def restart(name: str, error: Exception) -> None:
"""Give a member whose turn failed a fresh session for the next round, or drop it when none starts."""
crashes.append(f"{name}: {type(error).__name__}: {str(error)[:200]}")
print(f"crash: {crashes[-1]}", file=sys.stderr, flush=True)
await asyncio.gather(sessions[name].cancel(), return_exceptions=True)
try:
sessions[name] = await member.start_session(session_inputs={Member.input_id: Member(name=name)})
restarted.append(name)
except Exception as start_error:
del sessions[name]
print(f"dropped {name}: {start_error}", file=sys.stderr, flush=True)
async def run_member(name: str, prompt: str):
"""One member's turn: its state, or None when it failed."""
try:
async with at_once:
return await timed_turn(sessions[name], prompt)
except Exception as error:
await restart(name, error)
return NoneWhen a coordinator turn fails, a fresh coordinator session takes over. Everything the coordinator knows is on the board, so the new session reads the whole board, then gets the same prompt once more:
async def start_coordinator() -> agents.Session:
return await coordinator.start_session(session_inputs={Member.input_id: Member(name="coordinator")})
async def ask(prompt: str, seconds: float = ROUND_SECONDS) -> None:
"""One coordinator turn. When it fails, a fresh coordinator reads the whole board, then gets the same
prompt once more."""
global lead
try:
await timed_turn(lead, prompt, seconds)
except Exception as error:
crashes.append(f"coordinator: {type(error).__name__}: {str(error)[:200]}")
print(f"crash: {crashes[-1]}", file=sys.stderr, flush=True)
await asyncio.gather(lead.cancel(), return_exceptions=True)
lead = await start_coordinator()
catch_up = "You take over from a coordinator that failed: first read the whole board from message 0, "
await timed_turn(lead, catch_up + "paging with after= until there are no newer messages. " + prompt, seconds)The example's files
| File | What it shows |
|---|---|
qa_swarm.py | The tools, the members and the coordinator, the rounds |
sandbox/Dockerfile | The members' sandbox: jq 1.6 with its memory and time caps |
board/main.py | The board's API, on Postgres |
board/page.html | The page that shows a board live |
board/mistral.json | The app and its managed database, for Mistral Apps |
board/Dockerfile | The board's image, which Mistral Apps builds |
The same folder holds issue_triage.py, a swarm of 300 agents that triage a repository's open
issues on the same board: see the issue triage swarm cookbook.
Run the board on your machine
Start Postgres in Docker, then the board, from the board folder:
docker run -d --name swarm-board-db -p 5432:5432 \
-e POSTGRES_USER=board -e POSTGRES_PASSWORD=board -e POSTGRES_DB=board postgres:17
uv sync
POSTGRES_HOST=127.0.0.1 POSTGRES_USER=board POSTGRES_PASSWORD=board POSTGRES_DB=board \
uv run uvicorn main:app --port 8000Deploy the board on Mistral Apps
On Mistral Apps, the platform builds the board's image, runs its database, and serves its page to the people of your workspace, who sign in with their Mistral account.
Install the mistral CLI. It goes to ~/.mistral/bin, so add that folder to your PATH:
curl -fsSL https://mistral.ai/cli/install.sh | bash
export PATH="$HOME/.mistral/bin:$PATH"Turn on its app commands in ~/.config/mistral/config.json:
{"flags": {"mistral_cli_apps": true}}Sign in, then deploy from the board folder:
mistral login
mistral deploy --from-disk --name swarm-boardFrom a script or CI, where the command cannot ask, also pass --organization <id or name> and
--workspace <id or name>. The deploy writes the app's ids into mistral.json, under deployment:
commit them, and the next deploy updates the same app.
The command prints the app's URL, such as https://00b31003-….apps.mistral.run. Point the swarm at it:
export SWARM_BOARD_URL=<the URL the deploy printed>Run the swarm
From the example's folder, build the members' image, then start 60 members:
docker build -t swarm-jq16 sandbox
uv sync
export MISTRAL_API_KEY=...
uv run python qa_swarm.py 60Open the URL the program prints to watch the board. A run that stops before the coordinator ends it still has its board: get the coordinator's report on it, with the board's name from the URL:
uv run python qa_swarm.py --report qa-20261105-045621Output of a 4-member and a 60-member run
A run of 4 members, on a board on Mistral Apps. The coordinator ended it after six rounds:
From round 2 on, many of the new lines are reproductions: the coordinator gives each finding to a second member. A late member is one whose turn was interrupted at its 10 minutes. A crash is a turn that failed. A member then starts the next round on a fresh session, or is left out if none starts; a coordinator turn is asked again once.
A run of 60 members
Our run of 60 members, rounds 0 to 5:
We stopped it in round 6. --report then asked the coordinator for its report on
the whole board:
The report lists 217 findings over 19 slices that a second member reproduced. They are leads for a maintainer to confirm. We ran five of them in the members' image and in the official jq 1.7.1 image: all five fail on jq 1.6. jq 1.7.1 changed four of them; #343 still crashes there.
| Bug | Command | jq 1.6 | jq 1.7.1 |
|---|---|---|---|
| #307 | printf '"abc"' | jq 'index("")' | cannot allocate memory, exit 134 | null |
| #310 | echo '"aaaaa"' | jq 'rindex("aa")' | 2 | 3 |
| #318 | echo '"abc"' | jq 'gsub(""; "X")' | exit 134 | "XaXbXcX" |
| #328 | echo '[1,2,3,4,5]' | jq -c 'limit(0; .[1:4])' | [2,3,4] | nothing |
| #343 | jq -n --argjson d 175000 'reduce range($d) as $i (null; [$i,.]) | tojson | length' | segmentation fault, exit 139 | segmentation fault |
Adapt it
- Another command-line tool: install it in
sandbox/Dockerfilein place of jq, and name it in the instructions and the round-0 prompt. - More members: pass a larger count.
QA_AT_ONCEsets how many members run a turn at once: raise it with your Docker VM's memory. - Another job for the swarm: the issue triage swarm keeps the board and the two member tools, and changes the prompts.