Arrêter, relancer et répondre à un tour par son identifiant depuis un autre noyau

Un ingénieur chargé des versions prépare une mise à jour de dépendance dans un notebook Jupyter. Le service appelle httpx.Client(proxies=...). Un agent lit les notes de version de httpx sur GitHub et liste les risques de rupture. Un autre fixe la version de httpx dans requirements.txt à la dernière version qui prend encore en charge proxies=, jusqu'à ce que le code utilise son remplacement, après approbation de la modification par un relecteur. Un troisième rédige le rapport sur la dépendance à partir de PyPI. Dans un notebook, vous arrêtez des cellules, les relancez et collez des identifiants d'un noyau dans un autre. Chaque action doit produire le résultat attendu :

  • Un arrêt vous indique comment interrompre le tour qui reste en cours. Le bouton d'arrêt interrompt l'attente, pas le tour. Relancez la cellule : vous retrouvez le même tour, qui se termine.
  • Une demande d'approbation en attente peut être traitée ailleurs avec ses identifiants. Le relecteur approuve la modification depuis son propre noyau.
  • Relancer une cellule encore en cours d'exécution ne démarre pas le travail deux fois. Cette action oriente plutôt le rapport en cours.

Démonstration : un appel, l'arrêt d'une attente, l'orientation et l'annulation, les approbations traitées par identifiant, les tours (Busy, clés d'idempotence), l'environnement d'exécution local.

Exécution : placez les blocs de code dans un seul fichier, notebook.py, et exécutez python notebook.py dans un dossier de test : le programme écrase tout fichier requirements.txt qui s'y trouve (configuration).

L'environnement d'exécution local conserve ses sessions dans sa boucle d'événements. Ce programme exécute donc les deux noyaux dans un même processus, l'un après l'autre. Une fois les agents déployés avec Studio, Auto-hébergés ou gérés par Mistral, les noyaux peuvent s'exécuter dans des processus distincts : chacun utilise les identifiants affichés par l'autre. pin modifie alors le fichier requirements.txt de la machine qui exécute les outils de l'agent.

Les agents du notebook

Les agents du notebook

import asyncio
import os
import re
from pathlib import Path

import httpx

from mistralai import agents

REQUIREMENTS = Path("requirements.txt")
PACKAGE = re.compile(r"[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?")  # a package name, as PEP 508 spells it
VERSION = re.compile(r"[0-9]+(\.[0-9]+)*")

@agents.tool
async def release_notes(repository: str) -> str:
    """The notes of a GitHub repository's last 6 releases, such as encode/httpx."""
    # GitHub rate-limits calls without a token: set GITHUB_TOKEN to raise the limit.
    token = os.environ.get("GITHUB_TOKEN")
    async with httpx.AsyncClient(timeout=10, headers={"Authorization": f"Bearer {token}"} if token else {}) as client:
        response = await client.get(f"https://api.github.com/repos/{repository}/releases", params={"per_page": 6})
        response.raise_for_status()
    return "\n\n".join(f"## {release['tag_name']}\n{release['body']}" for release in response.json())

@agents.tool
async def latest_release(package: str) -> str:
    """A package's latest version on PyPI."""
    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.get(f"https://pypi.org/pypi/{package}/json")
        response.raise_for_status()
    return f"{package} {response.json()['info']['version']}"

@agents.tool
async def versions(package: str) -> str:
    """A package's last 10 final releases on PyPI (such as 1.2.3), oldest first, without yanked ones."""
    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.get(f"https://pypi.org/pypi/{package}/json")
        response.raise_for_status()
    final = [
        version
        for version, files in response.json()["releases"].items()
        if version.replace(".", "").isdigit() and files and not all(file["yanked"] for file in files)
    ]
    return ", ".join(sorted(final, key=lambda version: tuple(map(int, version.split("."))))[-10:])

def name(line: str) -> str:
    """The package a requirements.txt line names, as pip compares names: httpx for HTTPX[http2]>=0.27."""
    return re.sub(r"[-_.]+", "-", re.split(r"[^A-Za-z0-9._-]", line.strip(), maxsplit=1)[0]).lower()

@agents.tool
def pin(package: str, version: str) -> str:
    """Pin a package to a version in requirements.txt."""
    # The model's text goes into a file that pip reads: a package name and a version, nothing else.
    if not PACKAGE.fullmatch(package) or not VERSION.fullmatch(version):
        raise agents.ToolError(f"Not a package name and a version: {package!r} {version!r}.")
    lines = REQUIREMENTS.read_text().splitlines()
    mine = [line for line in lines if name(line) == name(package)]
    # Extras, markers, ranges and comments on the package's line are a person's to rewrite.
    if any(not re.fullmatch(r"[A-Za-z0-9._-]+==[0-9.]+", line) for line in mine):
        raise agents.ToolError(f"requirements.txt has more than a version for {package}: edit its line by hand.")
    new = f"{package}=={version}"
    lines = [new if line in mine else line for line in lines] if mine else [*lines, new]
    REQUIREMENTS.write_text("".join(f"{line}\n" for line in lines))
    return f"requirements.txt pins {new}"

reader = agents.Agent(
    model="mistral-large-4",
    name="release-notes-reader",
    instructions=(
        "You review dependency upgrades for a Python service. Read the package's release notes with "
        "release_notes, and list each change that can break code that calls the package, one line "
        "each, with the version that made it and what to use instead. Say only what the notes say. At "
        "most 6 lines, no preamble."
    ),
    tools=[release_notes],
)
upgrader = agents.Agent(
    model="mistral-large-4",
    name="upgrader",
    instructions=(
        "You pin the dependencies of a Python service. Find the versions with versions, pin the one "
        "the task asks for with pin. Then answer in one line, without markdown: the new pin, the "
        "one change that makes it the newest one that works, and what the code should move to."
    ),
    tools=[versions, pin],
    # Looking a version up runs; editing requirements.txt waits for an approval.
    permission_policy=agents.PermissionPolicy(
        default="allow",
        rules=(agents.PermissionRule(tools=("client.pin",), decision="ask"),),
    ),
)
reporter = agents.Agent(
    model="mistral-large-4",
    name="dependency-report",
    instructions=(
        "You write the dependency report of a Python service. Look up each pinned package with "
        "latest_release, then write one line per package: the pin, the latest version, and whether "
        "the pin is behind. No preamble."
    ),
    tools=[latest_release],
)

Les outils appellent les API publiques de GitHub et de PyPI et modifient requirements.txt. GitHub limite la fréquence des appels sans token : si release_notes échoue avec une erreur 403, définissez GITHUB_TOKEN. pin écrit uniquement un nom de paquet et une version, et laisse à une personne le soin de traiter les lignes contenant des extras, des marqueurs ou une plage de versions. Il refuse ces modifications avec agents.ToolError, pour que le modèle en lise la raison : il peut rappeler pin avec d'autres arguments ou expliquer ce refus dans sa réponse. La politique de l'agent de mise à jour l'autorise à lister les versions et impose une approbation avant toute modification.

Noyau 1 : un arrêt qui n'interrompt pas le tour, et une version fixée d'après les résultats de l'agent de lecture

Noyau 1 : un arrêt qui n'interrompt pas le tour, et une version fixée d'après les résultats de l'agent de lecture

async def kernel_1() -> tuple[str, str]:
    question = "What in httpx's last releases can break our client code? The repository is encode/httpx."
    call = asyncio.create_task(reader.run(question, idempotency_key="httpx-notes-1"))
    await asyncio.sleep(1)
    call.cancel()  # the stop button, after 1 s
    try:
        await call
    except asyncio.CancelledError as error:
        print(f"CancelledError, note: {error.__notes__[0]}")  # the ids, and how to stop the turn

    # The sessions running in this process: the stopped call's session is still one of them.
    print([info.id async for info in agents.list_sessions(host="local", status="running")])
    # The cell again, with the same key: the same turn comes back, not a second one, and finishes.
    state = await reader.run(question, idempotency_key="httpx-notes-1")
    print(f"same key: {type(state).__name__}, turn {state.turn_id}:\n{state.text}")
    await reader.session(state.session_id).cancel()

    task = (
        "Our client code calls httpx.Client(proxies=...). Pin httpx to the newest release that still "
        "has that argument, until we move to its replacement. The breaking changes in httpx's last "
        f"releases:\n{state.text}"
    )
    state = await upgrader.run(task, idempotency_key="pin-httpx-1")
    print(f"pin: {type(state).__name__}")  # Blocked: no on_approval
    return state.session_id, state.turn_id

L'annulation de l'appel arrête uniquement l'attente. Le tour continue à lire les notes de version. CancelledError contient une note avec les identifiants et la ligne qui arrête le tour, destinée à être lue par une personne. agents.list_sessions(host="local", status="running") liste les sessions en cours dans ce processus : la session de l'appel arrêté en fait partie.

L'ingénieur relance la cellule : le même appel avec la même idempotency_key renvoie le même tour, pas un deuxième, et attend qu'il se termine. Pour l'arrêter à la place, la note fournit la ligne suivante : turn.interrupt(). La liste de l'agent de lecture indique que la version 0.28.0 a supprimé proxies= et que proxy= le remplace. L'agent de mise à jour reçoit donc cette liste et l'information que le service utilise proxies=, puis demande à fixer la dernière version qui le prend encore en charge, jusqu'à ce que le code utilise proxy=. L'ingénieur laisse la décision au relecteur : sans on_approval, l'appel renvoie Blocked.

Noyau 2 : uniquement les identifiants collés

Noyau 2 : uniquement les identifiants collés

async def report_cell(session: agents.Session) -> agents.Turn:
    """A notebook cell: start the dependency report, or steer the report that still runs."""
    match await session.start_turn(f"Write the dependency report of these pins:\n{REQUIREMENTS.read_text()}"):
        case agents.Busy(turn=running):  # a session runs one turn at a time
            print("again: Busy")
            await running.steer("End with the number of pins that are behind.")
            return running
        case turn:
            return turn

async def kernel_2(session_id: str, turn_id: str) -> None:
    """Only the pasted ids carry over."""

    def review(ctx: agents.ApprovalCallbackContext, request: agents.ApprovalRequest) -> agents.AllowApproval:
        print(f"approve: {request.tool.id} {request.input}")
        return agents.AllowApproval()

    session = upgrader.session(session_id, on_approval=review)
    state = await session.turn(turn_id).result()
    print(f"pin: {type(state).__name__}: {state.text}")  # Completed: the edit ran once
    await session.cancel()

    session = await reporter.start_session()
    first = await report_cell(session)
    await report_cell(session)  # the cell ran twice
    state = await first.result()
    print(f"report: {type(state).__name__}:\n{state.text}")
    await session.cancel()

async def main() -> None:
    REQUIREMENTS.write_text("httpx==0.27.0\npydantic==2.9.2\nrich==13.9.4\n")  # the service's pins
    session_id, turn_id = await kernel_1()
    print(f"ids to paste: {session_id} {turn_id}")
    await kernel_2(session_id, turn_id)
    print(f"requirements.txt:\n{REQUIREMENTS.read_text()}", end="")

if __name__ == "__main__":
    asyncio.run(main())

Le noyau du relecteur associe un gestionnaire à la session de l'agent de mise à jour. turn.result() permet au gestionnaire de répondre à la demande d'approbation en attente : la modification s'exécute une seule fois. Ce gestionnaire autorise la demande qu'il affiche ; dans un vrai notebook, le relecteur lit d'abord la demande. La cellule de rapport est ensuite exécutée deux fois : le deuxième start_turn renvoie Busy, car une session n'exécute qu'un tour à la fois. report_cell oriente donc le rapport en cours au lieu d'en démarrer un autre. main écrit les versions fixées pour le service, exécute les deux noyaux dans un même processus, affiche les identifiants à coller et affiche requirements.txt à la fin.

Sortie

Sortie

Output
CancelledError, note: Stopped waiting for turn a463f88e-9ebc-4e62-a1d9-99079d00fbc6 of session d1a8c10b-fd92-4e7c-b5ff-80f4817b56d4. The cancel does not stop the turn. To stop it: await agent.session('d1a8c10b-fd92-4e7c-b5ff-80f4817b56d4').turn('a463f88e-9ebc-4e62-a1d9-99079d00fbc6').interrupt()
['d1a8c10b-fd92-4e7c-b5ff-80f4817b56d4']
same key: Completed, turn a463f88e-9ebc-4e62-a1d9-99079d00fbc6:
• 0.28.0 — proxies= argument removed (hard error); use proxy= or mounts= instead.
• 0.28.0 — app= argument removed (hard error); use transport=httpx.ASGITransport(app=...) / httpx.WSGITransport(app=...) instead.
• 0.28.0 — verify as a string (CA-bundle path) is deprecated and raises warnings; pass an ssl.SSLContext (or verify=True/False) instead.
• 0.28.0 — cert= argument is deprecated and raises warnings; load client certificates into an ssl.SSLContext passed via verify=.
• 0.28.0 — URL percent-escape sets reworked per WHATWG spec; URLs with special characters are now encoded differently on the wire.
• 0.28.0 — params={} now strictly replaces an existing query string instead of merging with it, and JSON request bodies are sent compact (no whitespace), changing request bytes.
pin: Blocked
ids to paste: 40d0c9b7-4978-4663-b393-ec3c62a12841 19e9bb91-0749-4221-9ea2-f27d440af9fa
approve: client.pin {'package': 'httpx', 'version': '0.27.2'}
pin: Completed: httpx==0.27.2 — the newest release that still accepts proxies= because 0.28.0 is the release that removed it with a hard error, so 0.27.2 is the last one before that breaking change — and the client code should move from httpx.Client(proxies=...) to httpx.Client(proxy=...) (or mounts= for multiple/per-scheme proxies) when unpinning to 0.28+.
again: Busy
report: Completed:
httpx==0.27.2 — latest 0.28.1 — behind
pydantic==2.9.2 — latest 2.14.2 — behind
rich==13.9.4 — latest 15.0.0 — behind
3 pins behind
requirements.txt:
httpx==0.27.2
pydantic==2.9.2
rich==13.9.4

La version fixée découle des notes de version : la version 0.28.0 a supprimé proxies=, donc requirements.txt fixe la version à 0.27.2 jusqu'à ce que le code utilise proxy=. Le rapport indique qu'elle est antérieure à la version 0.28.1. L'instruction d'orientation a atteint le rapport en cours : celui-ci se termine par le nombre de dépendances dont la version fixée est en retard.

Aller plus loin

Aller plus loin