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

Fonctionnement

MONITORING PROCESSMr Le Chonk€9,800 transferto a new accounton_alert("4711")4711 again: same sessionCASE MANAGEMENT APPon_analyst_button( session_id, turn_id, callback_id, approve=True)ApproveDenyids storedSESSIONkey alert:4711TURNRunning01messagereview alert 471102tool callget_alert03tool callget_account_history04tool callget_kyc_profile05tool callscreen_sanctions06tool callsearch_adverse_media07approvalopen_case waits08approvalallowed by the analyst09tool resultCASE-471110agent messagereview done
Step 1 of 5. The monitoring process calls on_alert("4711"). It starts a session with the key alert:4711, and a review turn with the key alert:4711:review.

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.

FichierContenu
payment_review.pyLes systèmes simulés, la politique, l'agent et les deux points d'entrée
run_review.pyUne exécution sur l'alerte 4711 : l'examen, puis la décision de l'analyste
ask_about.pyUne conversation de l'analyste sur l'alerte, en plusieurs tours (section 6)
test_payment_review.pyLes tests, avec un modèle scripté
1. Simuler les systèmes de la banque

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

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

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

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 reads

turn.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

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 over

agents.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é) :

Output
Waiting for an analyst: Allow tools.client.open_case?
summary: EUR 9,800.00 transfer (2026-10-29) from account FR76 3000 4000 0312 3456 7890 143 (Mr Le Chonk, Lyon) to new beneficiary Northwind Trading FZE (AE07 0331 2345 6789 0123 456, UAE), reference 'INV 2210 consulting'. [...]
Review: Completed: [...]
✅ Case file opened: `CASE-4711` with the full evidence package (alert details, account history anomaly, KYC inconsistency, sanctions match, adverse media). [...]
Case files: ['CASE-4711']

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 :

Output
Waiting for an analyst: Allow tools.client.open_case?
summary: Alert 4711: 9,800.00 EUR transfer on 2026-10-29 from Mr Le Chonk (FR76 3000 4000 0312 3456 7890 143) to a NEW beneficiary, Northwind Trading FZE (AE07 0331 2345 6789 0123 456, UAE - high-risk country), reference "INV 2210 consulting". [...]
Review: Completed: The case-file submission was denied at analyst approval, so per procedure I will not re-open it. Here is my final review. [...]
Case files: []
6. Poser des questions complémentaires sur l'alerte

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 :

Output
Here's the summary of alert 4711 (no case opened, per your instruction):
## Who paid whom
• Payer: Mr Le Chonk, Lyon — French account FR76 3000 4000 0312 3456 7890 143 (opened 2014, KYC: individual, salaried, low risk rating, last reviewed 2025-11-14)
• Beneficiary: Northwind Trading FZE — UAE account AE07 0331 2345 6789 0123 456
• Payment: 9,800.00 EUR on 2026-10-29, reference "INV 2210 consulting"
[...]
[approval: Allow tools.client.get_account_history?]
[turn ended: Completed]
The account history only returns aggregate stats for the last 12 months (no per-transaction listing is available), so here's the comparison of the flagged payment against that baseline:
[...]
Still holding off on opening a case, as requested.

La 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

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 out

use_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.py
Problèmes courants

Problèmes courants

L'examen n'attend jamais l'analyste

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

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

Aller plus loin

  • Approbations : gestionnaires, KeepCallbackOpen() et réponse depuis un autre client.