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 de l'essaim pendant une exécution : les résumés en une ligne publiés par les agents de triage, puis les doublons qu'ils ont trouvés, mis en évidence

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

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

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

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

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

Le code de l'essaim

L'exemple comprend deux projets : l'essaim et le tableau par lequel il communique.

FichierCe qu'il montre
swarm.pyLes deux outils, les membres et l'éditeur, les deux phases
board/main.pyL'API du tableau, avec Postgres
board/page.htmlLa page qui affiche un tableau en direct
board/mistral.jsonL'application et sa base de données gérée, pour Mistral Apps
board/DockerfileL'image du tableau, construite par Mistral Apps
Exécuter le tableau sur votre machine

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 8000
Déployer le tableau sur Mistral Apps

Dé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-board

Depuis 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

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 300

Accédez à l'URL affichée par le programme pour suivre le tableau.

Sortie

Sortie

Output
300 open issues of astral-sh/uv. Watch the board: http://127.0.0.1:8000/boards/triage-20261105-001449
round 1 after 83 s: 300 members done, 0 left out
round 2 after 269 s: 45 members found a duplicate
# Triage Report — Open Issues Board
Board scope: 345 messages — 300 open-issue summaries and 45 duplicate declarations, which collapse into 31 duplicate groups below, followed by the five areas with the most issues.
## Duplicate groups
• #19079, #19676 — uvx mishandles requires-python when picking the tool version/interpreter (silent downgrade or confusing build error).
• #19105, #19308, #22259 — platform_release marker range comparisons mis-evaluate on macOS 26, so resolution picks the wrong platform branch.
• #19112, #21430 — yanked versions are silently kept in uv.lock/sync despite PEP 592.
[...]
• #20759, #22913, #23037, #23038 — a dependency with no wheel for one matrix environment fails the entire universal lock resolution.
[...]
• #23058, #23059, #23060 — unescaped --prompt value executes command substitution when the activate script is sourced.
## Five areas with the most issues
1. Resolver & dependency metadata — 34 issues: #19048, #19105, #19110, #19112, #19223, [...]
2. Lockfile — 24 issues: #19081, #19090, #19160, #19222, #19375, [...]
3. Python management — 21 issues: #19329, #19393, #19416, #19568, #19616, [...]
4. Venv & environments — 20 issues: #19126, #19181, #19275, #19540, #19563, [...]
5. Index, auth & network — 19 issues: #19136, #19153, #19280, #19440, #19758, [...]

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

Adapter l'exemple

  • Un autre dépôt : passez son owner/name et 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.