Triage a repository's open issues with one agent per issue

A repository with hundreds of open issues has duplicates nobody has time to find. Give each issue its own agent: 300 agents, one per issue, in one program. They talk only through a message board, with two tools, send_swarm_message and read_message(after=, limit=). Each member posts a one-line summary of its issue, then reads the whole board and flags the issue that reports the same problem as its own. An editor agent reads the board and writes the triage report. You watch the board fill up in a browser.

The swarm's board during a run: one-line summaries posted by triager agents, then the duplicates they found, highlighted

The board while 300 agents triaged the open issues of astral-sh/uv (sped up). The highlighted lines are the duplicates the members found.

Demonstrates: tools that reach your own service, session inputs that tell each tool which member calls it, a read-only hint, hundreds of sessions in one process, a timeout per round, and one call for the report.

Run: start the board, then, from the example's folder with MISTRAL_API_KEY set: uv run python issue_triage.py astral-sh/uv 300

The members talk through the board of the QA swarm: a small web app on Postgres that keeps each run on its own board. Run it on your machine or deploy it on Mistral Apps, and set SWARM_BOARD_URL to its URL.

Two tools for the members

Two tools for the members

send_swarm_message posts, read_message reads. 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 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 is an httpx.AsyncClient on the board's URL, SWARM_BOARD_URL. It sends your Mistral API key in X-Api-Key, which a board on Mistral Apps checks and a board on your machine ignores.

The members and the editor

The members and the editor

Every member is the same Agent, with both tools. The editor only reads the board, and writes its report as its answer:

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
)

The prompts name the tool to call. The board holds only what a member posts, never its answer:

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'."
)
Two rounds

Two rounds

The program starts one session per issue, then runs two rounds. Each round waits for every member, so all the summaries are on the board before any member looks for its duplicate. A member that looked earlier would read a half-empty board and miss it. With timeout=, a round waits three minutes at most: a member still running then is left out, and its turn keeps running until the program cancels every session at the end:

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}")
Run the swarm

Run the swarm

From the example's folder:

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

Open the URL the program prints to watch the board.

Output of a 300-issue run

Output of a 300-issue run

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, [...]

The whole run took 7 minutes on a laptop: 83 s for 300 summaries, three more minutes for the duplicates, then the editor's report. Each group in the report is a set of open issues for a maintainer to check and close as duplicates of one.

Adapt it

Adapt it

  • Another repository: pass its owner/name and how many open issues to triage.
  • Another job for the swarm: change the two prompts and the editor's instructions. The board and the tools stay the same.
  • More rounds: add a gathered round, for example one where each member reads the duplicates and confirms or rejects the one about its own issue.