Trier les tickets Ouverts d'un dépôt avec un agent par ticket

Un dépôt comptant des centaines de tickets Ouverts contient des doublons que personne n'a le temps de repérer. Attribuez à chaque ticket son propre agent : 300 agents, un par ticket, 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 ticket en une ligne, puis lit l'ensemble du tableau et signale le ticket qui décrit le même problème que le sien. Un agent éditeur lit le tableau et rédige le rapport de tri. Vous observez 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 tri, puis les doublons repérés, mis en évidence

Le tableau pendant que 300 agents trient les tickets Ouverts de astral-sh/uv (en accéléré). Les lignes mises en évidence sont les doublons repérés par les membres.

Démonstration : 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 d'expiration par tour, et un seul appel pour le rapport.

Exécution : démarrez le tableau, puis, depuis le dossier de l'exemple avec MISTRAL_API_KEY défini : uv run python issue_triage.py astral-sh/uv 300

Les membres communiquent via le tableau de l'essaim de questions-réponses : une petite application web utilisant Postgres qui réserve un tableau distinct à chaque exécution. Exécutez-la sur votre machine ou déployez-la sur Mistral Apps, et définissez SWARM_BOARD_URL avec son URL.

Deux outils pour les membres

Deux outils pour les membres

send_swarm_message publie les messages, read_message les lit. Le nom d'un membre provient d'une entrée de session : le modèle ne peut donc pas 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 configuré avec l'URL du tableau, SWARM_BOARD_URL. Il envoie votre clé API Mistral dans X-Api-Key. Un tableau hébergé sur Mistral Apps la vérifie, tandis qu'un tableau sur votre machine l'ignore.

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 guise de 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 indiquent le nom de l'outil à appeler. Le tableau contient uniquement les messages publiés par un membre, 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 tours

Deux tours

Le programme démarre une session par ticket, puis exécute deux tours. Chaque tour attend que tous les membres aient terminé. Tous les résumés figurent donc 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=, un tour attend trois minutes au maximum : un membre encore en cours d'exécution est alors exclu de l'attente, et 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}")
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 issue_triage.py astral-sh/uv 300

Ouverts 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 300 résumés, trois minutes supplémentaires pour les doublons, puis le rapport de l'éditeur. Chaque groupe du rapport rassemble des tickets Ouverts qu'un responsable du dépôt peut vérifier et fermer comme doublons d'un même ticket.

Adapter l'exemple

Adapter l'exemple

  • Un autre dépôt : indiquez son owner/name et le nombre de tickets Ouverts à 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 tours : ajoutez un tour qui attend tous les membres, par exemple pour que chacun lise les doublons et confirme ou rejette celui qui concerne son propre ticket.