Examiner un paiement suspect avec un agent et un analyste
Le système de surveillance des transactions d'une banque déclenche une alerte sur un paiement. Un agent l'examine : il consulte l'alerte, l'historique du compte, le profil KYC du client, les listes de sanctions et les informations défavorables dans les médias, puis détermine si le paiement est suspect. Si c'est le cas, il ouvre un dossier, après avoir obtenu l'approbation d'un analyste.
Le programme comprend les outils, une politique qui traite les demandes d'approbation que le code peut trancher, et deux points d'entrée : le processus de surveillance qui lance l'examen et le bouton de l'analyste qui permet de répondre aux autres demandes.
Cet exemple présente : des outils avec des annotations, des approbations traitées par une politique et par une personne, des clés d'idempotence, la réponse à un tour depuis un autre client, l'environnement d'exécution local et des tests avec un modèle scripté.
Exécution : depuis le dossier de l'exemple, exécutez python run_review.py, ou python run_review.py --deny pour
simuler un analyste qui refuse l'ouverture du dossier (configuration).
Fonctionnement
Votre code répond aux demandes qu'il peut trancher. Les lectures sont sans risque, donc la politique les autorise. Contacter le
client risquerait de l'alerter, donc la politique le refuse. L'ouverture d'un dossier nécessite une décision humaine : la politique
renvoie agents.KeepCallbackOpen(), la demande d'approbation reste ouverte et le tour renvoie Blocked avec cette demande.
L'analyste y répond plus tard, avec les seuls identifiants.
Les systèmes de la banque sont simulés par des outils qui renvoient des données fixes. L'exemple s'exécute donc avec une simple clé API Mistral. En production, chaque outil appelle le système réel de la banque.
| Fichier | Contenu |
|---|---|
payment_review.py | Les systèmes simulés, la politique, l'agent et les deux points d'entrée |
run_review.py | Une exécution sur l'alerte 4711 : l'examen, puis la décision de l'analyste |
ask_about.py | Une conversation de l'analyste sur l'alerte, en plusieurs tours (section 6) |
test_payment_review.py | Les tests, avec un modèle scripté |
1. Simuler les systèmes de la banque
Chaque système est un outil. Les opérations de lecture sont marquées comme étant en lecture seule, ce qui permet à la politique de les autoriser sans liste de noms :
READ_ONLY = agents.ToolAnnotations(read_only_hint=True, destructive_hint=False)@agents.tool(annotations=READ_ONLY)
def get_alert(alert_id: str) -> dict[str, object]:
"""Transaction monitoring: the alert and the payment that raised it."""
if alert_id not in _ALERTS:
raise agents.ToolError(f"No alert {alert_id}.")
return _ALERTS[alert_id]get_account_history, get_kyc_profile, screen_sanctions et search_adverse_media sont
également en lecture seule, avec la même annotation. L'outil de messagerie client effectue des écritures et n'a donc aucune annotation :
@agents.tool
def message_customer(account: str, text: str) -> str:
"""Customer messaging: send a message to the account holder."""
MESSAGES.append(text)
return "sent"2. Répondre aux demandes d'approbation que votre code peut trancher
open_case enregistre un dossier. review_policy répond à chaque demande d'approbation de l'agent :
@agents.tool
async def open_case(alert_id: str, summary: str, evidence: list[str]) -> str:
"""Open a case file on a suspicious payment, with the evidence. Returns the case id."""
case_id = f"CASE-{alert_id}"
CASES[case_id] = {"alert_id": alert_id, "summary": summary, "evidence": evidence}
return case_id
def review_policy(
ctx: agents.ApprovalCallbackContext, request: agents.ApprovalRequest
) -> agents.AllowApproval | agents.DenyApproval | agents.KeepCallbackOpen:
if request.tool.annotations.read_only_hint: # request.tool: every approval has one
return agents.AllowApproval()
if request.tool.id == "client.open_case":
return agents.KeepCallbackOpen() # not mine: the approval stays open for an analyst
return agents.DenyApproval()La politique se fonde sur l'outil, jamais sur le texte de ses arguments. request.tool.annotations et
request.tool.id indiquent quel outil est à l'origine de la demande ; vos propres outils ont des identifiants de la forme client.<name>.
3. Définir l'agent
payment_reviewer = agents.Agent(
model="mistral-large-4",
name="payment-reviewer",
instructions=(
"Review the alert. Collect the context: the alert, the account history, the KYC profile, "
"sanctions screening and adverse media for the beneficiary. Do not contact the customer. "
"If the payment is suspicious, open a case file with the evidence. An analyst approves each "
"case file: if one is denied, do not open it again; end with your review."
),
tools=[
get_alert,
get_account_history,
get_kyc_profile,
screen_sanctions,
search_adverse_media,
message_customer,
open_case,
],
permission_mode="ask", # the default "auto" asks nothing, so no handler runs
)permission_mode="ask" impose une demande d'approbation pour chaque appel d'outil, afin que review_policy les traite tous.
4. Écrire les deux points d'entrée
Le processus de surveillance appelle on_alert une fois par alerte. Les deux clés d'idempotence proviennent de l'identifiant
de l'alerte : une alerte reçue deux fois n'ouvre donc qu'une session et ne lance qu'un examen. L'application de gestion des dossiers appelle
on_analyst_button lorsque l'analyste clique sur le bouton, avec les identifiants qu'elle a enregistrés :
async def on_alert(alert_id: str) -> agents.TurnState:
"""The transaction monitoring process: one review per alert."""
session = await payment_reviewer.start_session(on_approval=review_policy, idempotency_key=f"alert:{alert_id}")
return await session.run(
f"Review transaction monitoring alert {alert_id}.", idempotency_key=f"alert:{alert_id}:review"
)
# Blocked when open_case waits: the case management app lists state.callbacks
async def on_analyst_button(session_id: str, turn_id: str, callback_id: str, approve: bool) -> agents.TurnState:
"""The case management app, another service: an analyst allows or denies the case file."""
turn = payment_reviewer.session(session_id, on_approval=review_policy).turn(turn_id)
answer = agents.AllowApproval() if approve else agents.DenyApproval()
await turn.answer(callback_id, answer) # Answered | AlreadySettled | NotFound
return await turn.result() # drives the rest; review_policy answers the readsturn.answer applique la décision de l'analyste ; il renvoie Answered, AlreadySettled si quelqu'un
a déjà répondu, ou NotFound. turn.result() attend que le tour se termine ou soit à nouveau bloqué. Le paramètre
on_approval=review_policy du handle de session répond aux demandes de lecture de l'agent après le traitement du dossier.
5. Exécuter un examen
run_review.py simule les deux parties dans un seul programme. La décision de l'analyste est fournie en ligne de commande :
async def main(alert_id: str, approve: bool) -> agents.TurnState:
async with agents.local_runtime(): # at exit: every local session of the event loop stops
state = await on_alert(alert_id)
try:
while isinstance(state, agents.Blocked):
# The case management app shows the open approval to an analyst.
callback = state.callbacks[0]
print(f"Waiting for an analyst: {callback.title}")
if isinstance(callback, agents.OpenApproval) and isinstance(callback.request.input, dict):
print(f" summary: {callback.request.input['summary']}")
# The analyst clicks a button; the app passes on the ids it stored.
state = await on_analyst_button(state.session_id, state.turn_id, callback.id, approve)
print(f"Review: {type(state).__name__}: {state.text}")
print(f"Case files: {list(CASES)}")
return state
finally:
await payment_reviewer.session(state.session_id).cancel() # the review is overagents.local_runtime() exécute l'agent dans ce processus ; à la sortie, toutes les sessions locales de la boucle
d'événements s'arrêtent. Ici, les deux parties s'exécutent dans un même processus. Pour répondre depuis un autre service, déployez l'agent avec
Studio, via des déploiements Auto-hébergés ou
gérés par Mistral ; le code du bouton reste identique.
Le programme affiche le résultat suivant (le texte du modèle est abrégé) :
L'agent a consulté les cinq systèmes sans demander d'intervention et n'a attendu qu'une fois : pour le dossier. Avec
--deny, l'analyste refuse l'ouverture du dossier et l'agent termine en fournissant son analyse, sans créer de dossier :
6. Poser des questions complémentaires sur l'alerte
Un analyste interroge l'agent chargé de l'examen sur une alerte, puis pose une question complémentaire. La deuxième question s'appuie sur
la première réponse : les deux tours s'exécutent donc dans une même session, qui conserve la conversation. Le programme met fin
à la session lorsqu'il a terminé : le retour d'une fonction ne suffit pas à la terminer. Une session hébergée par Mistral
pour un agent démarré avec agents serve ou agents deploy se termine aussi automatiquement lorsqu'elle atteint sa
limite d'inactivité.
Exécutez python ask_about.py depuis le dossier de l'exemple :
import asyncio
from payment_review import payment_reviewer, review_policy
from mistralai import agents
def show(event: agents.Event) -> None:
match event:
case agents.CallbackOpened(callback=callback):
print(f" [approval: {callback.title}]")
case agents.TurnEnded(state=state):
print(f" [turn ended: {type(state).__name__}]")
async def ask_about(alert_id: str) -> None:
session = await payment_reviewer.start_session(on_approval=review_policy)
try:
state = await session.run(
f"Summarise alert {alert_id}: who paid whom, and why it was flagged. Don't open a case yet."
)
print(state.text)
state = await session.run(
"Compare it with the customer's payments of the last 12 months.",
on_event=show, # each event as it comes
)
print(state.text)
finally:
await session.cancel() # returning does not end the session
if __name__ == "__main__":
asyncio.run(ask_about("4711"))La première question indique à l'agent de ne pas encore ouvrir de dossier : cette conversation sert uniquement à poser des questions.
review_policy répond à chaque demande de lecture de l'agent, et show affiche les événements du deuxième tour :
l'approbation de la lecture nécessaire et la fin du tour. Les réponses du modèle sont abrégées :
FR76 3000 4000 0312 3456 7890 143 (opened 2014, KYC: individual, salaried, low risk rating, last reviewed 2025-11-14)AE07 0331 2345 6789 0123 456La deuxième réponse compare le paiement à l'historique des 12 derniers mois obtenu dans la même session. Le fichier de test ci-dessous vérifie cette conversation avec un modèle scripté.
7. Tester sans modèle
Un modèle scripté remplace le modèle réel : les tests n'ont donc pas besoin de clé API et suivent les mêmes étapes à chaque
exécution. Chaque appel d'outil scripté est une ligne de code dans run_typescript, comme l'écrirait un modèle réel
(testing.run_typescript écrit cette ligne). Le premier test exécute le même main et vérifie
que l'examen a attendu la décision de l'analyste et qu'aucun dossier refusé n'a été enregistré :
"""The tests of the suspicious payment review: a scripted model, no API key.
Run them beside payment_review.py: pytest -o asyncio_mode=auto test_payment_review.py
"""
import ask_about
import payment_review
import pytest
import run_review
from mistralai import agents
def use_model(monkeypatch: pytest.MonkeyPatch, model: agents.testing.ScriptedModel) -> None:
"""Give the agent the scripted model, in each module that imported it."""
agent = payment_review.payment_reviewer.override(model=model)
for module in (payment_review, run_review, ask_about):
monkeypatch.setattr(module, "payment_reviewer", agent)
@pytest.mark.parametrize("approve", [True, False])
async def test_the_review_waits_for_the_analyst(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], approve: bool
) -> None:
payment_review.CASES.clear()
model = agents.testing.ScriptedModel(
[
# The model calls each tool from code, as a real model does.
agents.testing.run_typescript('return await tools.client.get_alert({"alert_id": "4711"});'),
agents.testing.run_typescript(
'return await tools.client.screen_sanctions({"name": "Northwind Trading FZE"});'
),
agents.testing.run_typescript(
'return await tools.client.open_case({"alert_id": "4711", '
'"summary": "9,800 EUR to a beneficiary with a 0.82 sanctions match.", '
'"evidence": ["sanctions match 0.82"]});'
),
agents.testing.reply("Case CASE-4711 opened." if approve else "The analyst denied the case file."),
]
)
use_model(monkeypatch, model)
state = await run_review.main("4711", approve=approve)
model.assert_consumed()
assert isinstance(state, agents.Completed)
assert list(payment_review.CASES) == (["CASE-4711"] if approve else []) # a denied case is never written
out = capsys.readouterr().out
assert "Waiting for an analyst: " in out
assert "summary: 9,800 EUR to a beneficiary" in out
async def test_the_policy_allows_reads_and_denies_the_customer_message(monkeypatch: pytest.MonkeyPatch) -> None:
payment_review.MESSAGES.clear()
model = agents.testing.ScriptedModel(
[
agents.testing.run_typescript('return await tools.client.get_kyc_profile({"account": "FR76"});'),
agents.testing.run_typescript(
'return await tools.client.message_customer({"account": "FR76", "text": "Please explain."});'
),
agents.testing.reply("Reviewed."),
]
)
use_model(monkeypatch, model)
state = await payment_review.on_alert("4711")
assert isinstance(state, agents.Completed)
assert [c.name for c in state.tool_calls] == ["client.get_kyc_profile", "client.message_customer"]
assert payment_review.MESSAGES == [] # the policy denied it, so the tool never ran
async def test_the_analysts_conversation_takes_two_turns(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
model = agents.testing.ScriptedModel(
[
agents.testing.reply("Mr Le Chonk paid 9,800 EUR to Northwind Trading FZE."),
agents.testing.run_typescript('return await tools.client.get_account_history({"account": "FR76"});'),
agents.testing.reply("Its largest payment in 12 months was 1,150 EUR."),
]
)
use_model(monkeypatch, model)
await ask_about.ask_about("4711")
model.assert_consumed()
out = capsys.readouterr().out
assert "Mr Le Chonk paid 9,800 EUR" in out
assert "[approval: " in out # the read asked, and the policy answered
assert "[turn ended: Completed]" in outuse_model remplace l'agent dans chaque module qui l'a importé. Une fois pytest et pytest-asyncio
installés, exécutez les tests depuis le dossier de l'exemple :
pytest -o asyncio_mode=auto test_payment_review.pyProblèmes courants
L'examen n'attend jamais l'analyste
Vérifiez que l'agent utilise permission_mode="ask". Avec la valeur par défaut, "auto", l'agent ne demande aucune approbation :
open_case s'exécute donc immédiatement et aucun gestionnaire n'est appelé.
Une lecture est refusée
review_policy refuse tous les outils sans read_only_hint=True, à l'exception de open_case. Marquez chaque
outil en lecture seule avec READ_ONLY, ou autorisez-le par son identifiant.
Aller plus loin
- Approbations : gestionnaires,
KeepCallbackOpen()et réponse depuis un autre client.