Swarm : 300 agents trient les issues d'un dépôt sur un tableau de messages commun
Un dépôt qui compte des centaines d'issues ouvertes contient des doublons que personne n'a le temps de trouver. Attribuez à chaque issue
son propre agent : 300 agents, un par issue, dans un seul programme. Ils communiquent uniquement via un tableau de messages,
avec deux outils, send_swarm_message et read_message(after=, limit=). Chaque membre publie un
résumé de son issue en une ligne, puis lit tout le tableau et signale l'issue qui décrit le même
problème que la sienne. Un agent éditeur lit le tableau et rédige le rapport de triage. Vous regardez le
tableau se remplir dans un navigateur.

Le tableau pendant que 300 agents trient les issues ouvertes de astral-sh/uv (en accéléré). Les lignes mises en évidence sont les doublons trouvés par les membres.
Ce que montre cet exemple : des outils qui accèdent à votre propre service, des entrées de session qui indiquent à chaque outil quel membre l'appelle, une indication de lecture seule, des centaines de sessions dans un seul processus, un délai maximal par phase, et un seul appel pour le rapport.
Exécuter : démarrez le tableau, puis, depuis le dossier de l'exemple avec MISTRAL_API_KEY défini :
uv run python swarm.py astral-sh/uv 300
Le tableau
Le tableau est une petite application web avec une base de données Postgres. Il conserve les messages de chaque exécution, chacune ayant son propre tableau, dont le nom figure dans l'URL. Les agents publient et lisent via son API ; les utilisateurs accèdent à sa page, qui affiche les nouveaux messages à mesure qu'ils arrivent :
@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
]Le même code s'exécute sur votre machine et sur Mistral Apps. Son fichier
mistral.json déclare l'application et une base de données Postgres gérée : sur Mistral Apps, la plateforme exécute
la base de données et fournit à l'application ses paramètres POSTGRES_*.
Deux outils pour les membres
send_swarm_message publie, read_message lit. Le nom d'un membre provient d'une entrée de session, ce qui empêche le
modèle de publier au nom d'un autre membre :
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 one-line message on the swarm's board, where every member can read it."""
response = await board.post("/messages", json={"author": me.name, "text": text})
response.raise_for_status()
return f"posted as message {response.json()['id']}"
@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 board.get("/messages", params={"after": after, "limit": limit})
response.raise_for_status()
lines = [f"{m['id']} {m['author']}: {m['text']}" for m in response.json()]
return "\n".join(lines) or "(no newer messages)"board est un httpx.AsyncClient qui utilise l'URL du tableau, SWARM_BOARD_URL. Il envoie votre clé API Mistral
dans X-Api-Key, que le tableau vérifie sur Mistral Apps et ignore sur votre machine.
Les membres et l'éditeur
Chaque membre utilise le même agent, avec les deux outils. L'éditeur lit uniquement le tableau et fournit son rapport en réponse :
triager = agents.Agent(
model="mistral-large-4",
name="issue-triager",
instructions=(
"You are one member of a swarm that triages a repository's open issues. Each member owns one "
"issue. The swarm talks only through its board: send_swarm_message posts, "
"read_message(after=, limit=) reads. Keep every message to one line."
),
tools=[send_swarm_message, read_message],
)
editor = agents.Agent(
model="mistral-large-4",
name="triage-editor",
instructions=(
"You write the triage report of a swarm that summarized a repository's open issues on its "
"board. Read the whole board with read_message, paging with after=. Then answer with the report "
"alone, in Markdown: the groups of duplicates, each with its issue numbers and the shared problem "
"in one line; then the five areas with the most issues."
),
tools=[read_message], # it reads the board, and writes only its answer
)Les prompts précisent le nom de l'outil à appeler. Le tableau contient uniquement ce qu'un membre publie, jamais sa réponse :
SUMMARIZE = (
"Your issue is #{number}: {title}\n\n{body}\n\n"
"Call send_swarm_message once with '#{number} [<area>] <the symptom in at most 15 words>'. "
"Then answer 'posted'."
)
FIND_DUPLICATES = (
"Every member has posted. Read the whole board, paging with after= until there are no newer "
"messages. If another issue reports the same problem as yours, call send_swarm_message with "
"'#<yours> duplicates #<other>: <why, in 10 words>'. Otherwise post nothing. "
"Answer with the duplicate's number, or 'none'."
)Deux phases
Le programme démarre une session par issue, puis exécute deux phases. Chaque phase attend tous les membres,
ce qui garantit que tous les résumés figurent sur le tableau avant qu'un membre ne cherche son doublon. Un membre qui
chercherait plus tôt lirait un tableau à moitié vide et risquerait de le manquer. Avec timeout=, une phase attend trois
minutes au maximum : un membre encore en cours d'exécution est alors exclu, mais son tour se poursuit jusqu'à ce que le programme
annule toutes les sessions à la fin :
sessions = await asyncio.gather(
*(
triager.start_session(session_inputs={Member.input_id: Member(name=f"triager-{issue['number']}")})
for issue in issues
)
)
try:
# Two rounds, each gathered: every member has posted before any member looks for duplicates.
# A round waits ROUND_SECONDS at most: a member still running then is left out, so one slow
# turn never holds up the other 299.
summaries = await asyncio.gather(
*(
session.run(
SUMMARIZE.format(number=issue["number"], title=issue["title"], body=(issue["body"] or "")[:1500]),
timeout=ROUND_SECONDS,
)
for session, issue in zip(sessions, issues)
)
)
late = sum(isinstance(state, agents.Running) for state in summaries)
print(f"round 1 after {time.monotonic() - started:.0f} s: {len(issues) - late} members done, {late} left out")
answers = await asyncio.gather(*(session.run(FIND_DUPLICATES, timeout=ROUND_SECONDS) for session in sessions))
found = [state for state in answers if isinstance(state, agents.Completed) and "none" not in state.text.lower()]
print(f"round 2 after {time.monotonic() - started:.0f} s: {len(found)} members found a duplicate")
finally:
await asyncio.gather(*(session.cancel() for session in sessions))
match await editor.run("Write the triage report."):
case agents.Completed(text=report):
print(f"\n{report}")
case other:
print(f"the editor stopped: {other}")Le code de l'essaim
L'exemple comprend deux projets : l'essaim et le tableau par lequel il communique.
| Fichier | Ce qu'il montre |
|---|---|
swarm.py | Les deux outils, les membres et l'éditeur, les deux phases |
board/main.py | L'API du tableau, avec Postgres |
board/page.html | La page qui affiche un tableau en direct |
board/mistral.json | L'application et sa base de données gérée, pour Mistral Apps |
board/Dockerfile | L'image du tableau, construite par Mistral Apps |
Exécuter le tableau sur votre machine
Démarrez Postgres dans Docker, puis le tableau, depuis le dossier board :
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 8000Déployer le tableau sur Mistral Apps
Sur Mistral Apps, la plateforme construit l'image du tableau, exécute sa base de données et rend sa page accessible aux membres de votre espace de travail, qui se connectent avec leur compte Mistral.
Installez la CLI mistral. Elle s'installe dans ~/.mistral/bin ; ajoutez donc ce dossier à votre PATH :
curl -fsSL https://mistral.ai/cli/install.sh | bash
export PATH="$HOME/.mistral/bin:$PATH"Activez ses commandes d'application dans ~/.config/mistral/config.json :
{"flags": {"mistral_cli_apps": true}}Connectez-vous, puis déployez depuis le dossier board :
mistral login
mistral deploy --from-disk --name swarm-boardDepuis un script ou la CI, où la commande ne peut pas demander de saisie, passez aussi --organization <id or name> et
--workspace <id or name>. Le déploiement écrit les identifiants de l'application dans mistral.json, sous deployment :
créez un commit avec ces identifiants, et le déploiement suivant met à jour la même application.
La commande affiche l'URL de l'application, par exemple https://00b31003-….apps.mistral.run. Configurez l'essaim pour l'utiliser :
export SWARM_BOARD_URL=<the URL the deploy printed>Exécuter l'essaim
Depuis le dossier de l'exemple :
uv sync
export MISTRAL_API_KEY=...
export GITHUB_TOKEN=... # optional: GitHub limits the requests it answers without one
uv run python swarm.py astral-sh/uv 300Accédez à l'URL affichée par le programme pour suivre le tableau.
Sortie
requires-python when picking the tool version/interpreter (silent downgrade or confusing build error).platform_release marker range comparisons mis-evaluate on macOS 26, so resolution picks the wrong platform branch.uv.lock/sync despite PEP 592.--prompt value executes command substitution when the activate script is sourced.L'exécution complète a pris 7 minutes sur un ordinateur portable : 83 s pour les 300 résumés, trois minutes supplémentaires pour les doublons, puis le rapport de l'éditeur. Chaque groupe du rapport rassemble des issues ouvertes qu'un mainteneur peut vérifier et fermer comme doublons d'une même issue.
Adapter l'exemple
- Un autre dépôt : passez son
owner/nameet le nombre d'issues ouvertes à trier. - Une autre tâche pour l'essaim : modifiez les deux prompts et les instructions de l'éditeur. Le tableau et les outils restent les mêmes.
- Plus de phases : ajoutez une phase qui attend tous les membres, par exemple une phase où chaque membre lit les doublons et confirme ou rejette celui qui concerne sa propre issue.