Plusieurs clients pour une même session : vues en direct et autorisations
Affichez une même session dans plusieurs clients à la fois, par exemple une application de bureau et un onglet de navigateur. Chaque client suit les événements de la session à partir de son propre curseur et affiche les boutons d'autorisation et les réponses. N'importe quel client peut répondre à une demande d'autorisation ; si deux clients cliquent en même temps, une seule réponse est retenue et les deux savent laquelle. Pour une application complète, avec un véritable terminal et une vue dans le navigateur, consultez Mini Vibe.
Cet exemple montre comment : suivre les événements d'une session avec un curseur, répondre à une demande d'autorisation depuis n'importe quel client, envoyer un message qui démarre un tour ou oriente le tour en cours.
Exécution : placez les blocs de code intitulés several_clients.py dans un même fichier, dans l'ordre, puis exécutez python several_clients.py
(configuration).
Le code
L'agent peut publier un rapport, et permission_mode="ask" met cet appel en attente d'un clic. Chaque
vue est une tâche qui lit session.iter_events(after=screen.cursor) et actualise son Screen :
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field
from mistralai import agents
@agents.tool
def publish_report(title: str) -> str:
"""Publish a report to the team's wiki."""
return f"Published {title!r}."
agent = agents.Agent(
model="mistral-large-4",
name="several-clients",
instructions="You help a team publish reports. Be brief.",
tools=[publish_report],
permission_mode="ask", # publish_report waits for a click
)
@dataclass
class Screen:
"""What one view shows. The app keeps it, so a reload can continue from the cursor."""
name: str
cursor: str | None = None
buttons: dict[str, agents.OpenCallback] = field(default_factory=dict)
lines: list[str] = field(default_factory=list)
shown: str = ""
def update(self) -> None:
"""Redraw, here as one printed line when something changed."""
now = f"buttons: {[b.title for b in self.buttons.values()]}, answers: {len(self.lines)}"
if now != self.shown:
print(f" [{self.name}] {now}")
self.shown = now
async def view(session: agents.Session, screen: Screen) -> None:
"""One view (desktop or web). A reload continues from screen.cursor."""
async for event in session.iter_events(after=screen.cursor):
match event:
case agents.CallbackOpened(callback=callback):
screen.buttons[callback.id] = callback
case agents.CallbackResolved(callback_id=callback_id):
screen.buttons.pop(callback_id, None)
case agents.TurnEnded(state=state):
screen.lines.append(state.text)
screen.cursor = event.id
screen.update()
async def click(
session: agents.Session, callback: agents.OpenCallback, mine: agents.AllowApproval | agents.DenyApproval
) -> str:
match await session.turn(callback.turn_id).answer(callback.id, mine):
case agents.Answered(answer=applied) if applied != mine:
return "the other view answered first"
case agents.Answered():
return "done"
case agents.AlreadySettled():
return "someone else answered"
case agents.NotFound():
return "no such approval"
async def on_typed(session: agents.Session, text: str) -> None:
await session.send_message(text) # starts a turn, or steers the running one
async def until(check: Callable[[], bool], seconds: float = 120) -> None:
async with asyncio.timeout(seconds): # a wait that never ends is a bug: fail instead
while not check():
await asyncio.sleep(0.05)view ne répond jamais ; cette fonction se contente d'afficher. click répond via le tour, et le résultat indique
à la vue ce qui s'est passé : Answered avec sa propre réponse, Answered avec la réponse de l'autre vue
si celle-ci est arrivée en premier, AlreadySettled ou NotFound. on_typed envoie le texte saisi par l'utilisateur : cette fonction démarre un
tour ou oriente le tour en cours.
main simule l'utilisateur. Cette fonction saisit une requête, fait cliquer les deux vues en même temps (la vue de bureau autorise, la
vue web refuse), puis recharge la vue web et saisit un nouveau message. Si le refus est retenu, le modèle peut
renouveler sa demande ; main autorise donc chaque nouvelle demande depuis la vue de bureau. Chaque attente a une durée limite : une
demande d'autorisation manquée provoque un échec plutôt qu'un blocage :
async def main() -> tuple[Screen, Screen]:
session = await agent.start_session()
desktop, web = Screen("desktop"), Screen("web")
views = {screen.name: asyncio.create_task(view(session, screen)) for screen in (desktop, web)}
try:
print("> Publish the weekly report, titled 'Week 44'.")
await on_typed(session, "Publish the weekly report, titled 'Week 44'.")
await until(lambda: bool(desktop.buttons) and bool(web.buttons))
# Both views click at once: the desktop allows, the web view denies.
callback = next(iter(desktop.buttons.values()))
allow, deny = await asyncio.gather(
click(session, callback, agents.AllowApproval()),
click(session, callback, agents.DenyApproval()),
)
print(f"desktop clicked Allow: {allow}; web clicked Deny: {deny}")
# When the Deny counts, the model may ask again: the desktop allows each new approval.
answered = {callback.id}
async def allow_new_approvals() -> None:
for again in [b for b in desktop.buttons.values() if b.id not in answered]:
answered.add(again.id)
print(f"desktop clicked Allow again: {await click(session, again, agents.AllowApproval())}")
async with asyncio.timeout(120):
while not (len(desktop.lines) == 1 and len(web.lines) == 1):
await allow_new_approvals()
await asyncio.sleep(0.05)
# The web view reloads: a new stream from its cursor, so no answer shows twice.
views["web"].cancel()
views["web"] = asyncio.create_task(view(session, web))
print("> Thanks!")
await on_typed(session, "Thanks!")
await until(lambda: len(desktop.lines) == 2 and len(web.lines) == 2)
for line in web.lines:
print(f"web: {line}")
return desktop, web
finally:
for task in views.values():
task.cancel()
await session.cancel()
if __name__ == "__main__":
asyncio.run(main())Sortie
Chaque vue affiche une ligne lorsque son écran change. Les deux vues ont affiché le bouton, puis l'ont
retiré une fois la demande d'autorisation traitée. Dans cette exécution, l'autorisation de la vue de bureau a été retenue ; le refus de la vue web
a reçu Answered avec la réponse de la vue de bureau, ce que la vue web a indiqué. Pour un agent accessible via une API, les deux clics correspondent à
deux requêtes ; le refus de la vue web peut donc être retenu à la place : la vue de bureau l'indique alors, le modèle renouvelle sa
demande et la vue de bureau l'autorise. Après le rechargement, la vue web a repris la lecture à partir de son
curseur et affiché chaque réponse une seule fois.
Tester
Le test exécute la même fonction main avec un modèle aux réponses prédéfinies et ne nécessite donc aucune clé API. Il vérifie que les deux
vues affichent finalement les deux mêmes réponses et qu'une seule réponse est retenue lors des clics simultanés. Un second test fait en sorte que le
refus soit retenu et que le modèle renouvelle sa demande, puis vérifie que la vue de bureau autorise cette nouvelle demande :
"""The test of the several clients cookbook: a scripted model, no API key.
Run it beside several_clients.py: pytest -o asyncio_mode=auto test_several_clients.py
"""
import asyncio
import pytest
import several_clients
from mistralai import agents
async def test_one_click_counts_and_a_reload_shows_no_repeats(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
model = agents.testing.ScriptedModel(
[
# The model calls the tool from code, as a real model does.
agents.testing.run_typescript('return await tools.client.publish_report({"title": "Week 44"});'),
agents.testing.reply("Published 'Week 44'."),
agents.testing.reply("You're welcome."),
]
)
monkeypatch.setattr(several_clients, "agent", several_clients.agent.override(model=model))
desktop, web = await several_clients.main()
model.assert_consumed()
assert desktop.lines == web.lines == ["Published 'Week 44'.", "You're welcome."]
assert "desktop clicked Allow: done; web clicked Deny: the other view answered first" in capsys.readouterr().out
async def test_when_the_deny_counts_the_desktop_allows_the_next_approval(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
model = agents.testing.ScriptedModel(
[
agents.testing.run_typescript('return await tools.client.publish_report({"title": "Week 44"});'),
# The web view's Deny counted, so the model asks again.
agents.testing.run_typescript('return await tools.client.publish_report({"title": "Week 44"});'),
agents.testing.reply("Published 'Week 44'."),
agents.testing.reply("You're welcome."),
]
)
monkeypatch.setattr(several_clients, "agent", several_clients.agent.override(model=model))
click, denied = several_clients.click, asyncio.Event()
async def deny_first(session, callback, mine): # the Allow waits until the Deny has counted
if isinstance(mine, agents.AllowApproval) and not denied.is_set():
await denied.wait()
result = await click(session, callback, mine)
if isinstance(mine, agents.DenyApproval):
denied.set()
return result
monkeypatch.setattr(several_clients, "click", deny_first)
desktop, web = await several_clients.main()
model.assert_consumed()
assert desktop.lines == web.lines == ["Published 'Week 44'.", "You're welcome."]
out = capsys.readouterr().out
assert "desktop clicked Allow: someone else answered; web clicked Deny: done" in out
assert "desktop clicked Allow again: done" in outL'API de test répertorie les fonctions utilitaires du modèle aux réponses prédéfinies.
Aller plus loin
- Événements : tous les événements enregistrés par une session et les curseurs.
- Autorisations : répondre depuis n'importe quel client.
- Lorsqu'un tour est déjà en cours : l'effet d'un message envoyé pendant un tour.