Stop, re-run and answer a turn by id from another kernel

A release engineer prepares a dependency upgrade in a Jupyter notebook. The service calls httpx.Client(proxies=...). One agent reads httpx's release notes on GitHub and lists what can break. One pins httpx in requirements.txt to the newest release that still has proxies=, until the code moves to its replacement, once a reviewer approves the edit. One writes the dependency report from PyPI. In a notebook, you stop cells, re-run them, and paste ids from one kernel into another, and each must do what you expect:

  • A stop tells you how to stop the turn that keeps running. The stop button stops the wait, not the turn. Run the cell again and the same turn comes back, and finishes.
  • An approval left open can be answered from elsewhere with its ids. The reviewer approves the edit from their own kernel.
  • A cell run again while its first run still goes does not start the work twice. It steers the running report instead.

Demonstrates: one call, stopping a wait, steer and cancel, approvals answered by id, turns (Busy, idempotency keys), the local runtime.

Run: put the code blocks in one file, notebook.py, and run python notebook.py in a scratch folder: it overwrites any requirements.txt there (setup).

The local runtime keeps its sessions in its event loop, so this program runs both kernels in one process, one after the other. Once you deploy the agents with Studio, self-hosted or Mistral-managed, the kernels can be separate processes: each one uses the ids the other printed. pin then edits the requirements.txt of the machine that runs the agent's tools.

The notebook's agents

The notebook's agents

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],
)

The tools call GitHub's and PyPI's public APIs and edit requirements.txt. GitHub rate-limits calls without a token: if release_notes fails with 403, set GITHUB_TOKEN. pin writes only a package name and a version, and leaves a line with extras, markers or a range to a person. It refuses with agents.ToolError, so the model reads why: it can call pin again with other arguments, or say why in its answer. The upgrader's policy lets it list versions, and makes the edit wait for an approval.

Kernel 1: a stop that does not stop the turn, and a pin from what the reader found

Kernel 1: a stop that does not stop the turn, and a pin from what the reader found

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

Cancelling the call stops only the wait, and the turn keeps reading release notes. The CancelledError carries a note with the ids and the line that stops the turn, for a person to read. agents.list_sessions(host="local", status="running") lists the sessions running in this process: the stopped call's session is one.

The engineer runs the cell again: the same call with the same idempotency_key returns the same turn, not a second one, and waits for it to finish. To stop it instead, the note gives the line: turn.interrupt(). The reader's list says that 0.28.0 removed proxies=, and that proxy= replaces it. So the upgrader gets that list and the service's use of proxies=, and asks to pin the newest release that still has it, until the code moves to proxy=. The engineer leaves the decision to the reviewer: with no on_approval, the call returns Blocked.

Kernel 2: only the pasted ids

Kernel 2: only the pasted ids

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())

The reviewer's kernel binds the upgrader's session with a handler, and turn.result() lets the handler answer the open approval: the edit runs once. This handler allows the request it prints; in a real notebook, the reviewer reads the request first. Then the report cell runs twice: the second start_turn returns Busy, because a session runs one turn at a time, and report_cell steers the running report instead of starting another. main writes the service's pins, runs the two kernels in one process, prints the ids to paste, and prints requirements.txt at the end.

Output

Output

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

The pin follows from the release notes: 0.28.0 removed proxies=, so requirements.txt pins 0.27.2 until the code moves to proxy=, and the report shows it behind 0.28.1. The steer reached the running report: it ends with the number of pins behind.

Next steps

Next steps