Leçon 3 — Construire l'agent pas à pas

Confirmation, escalade et garde-fous

6 min de lecture

Confirmation, escalade et garde-fous

Où s'insèrent les callbacks

Un callback est une fonction que vous donnez à l'agent et qu'ADK appelle à un moment précis de la boucle. S'il renvoie None, la boucle continue normalement ; s'il renvoie une valeur, cette valeur remplace l'étape (la réponse du modèle ou le résultat de l'outil).

flowchart TB
  U["Message du client"] --> BM{"before_model_callback : garde_fou_entree"}
  BM -->|"renvoie une LlmResponse"| R1["Réponse directe, le modèle n'est pas appelé"]
  BM -->|"None"| M["Gemini"]
  M -->|"appel d'outil"| BT{"before_tool_callback : garde_fou_outils"}
  BT -->|"renvoie un dict"| R2["Résultat d'outil remplacé (refus)"]
  BT -->|"None"| T["L'outil s'exécute"]
  T --> M
  M --> F["Réponse finale"]
CallbackSignature dans ADK 2.10Usage ici
before_model_callback(callback_context, llm_request) → LlmResponse ou Nonefiltrer l'entrée, détecter « je confirme »
before_tool_callback(tool, args, tool_context) → dict ou Noneautoriser ou refuser un outil
after_model_callback(callback_context, llm_response)non utilisé ici (voir le lab de recherche web)
after_tool_callback(tool, args, tool_context, tool_response)non utilisé ici

La commande en deux temps

files/agent.py
def preparer_commande(tool_context: ToolContext) -> dict:
    """Prépare la commande et renvoie le récapitulatif à faire valider par le client.

    N'enregistre RIEN : il faut ensuite que le client confirme explicitement.
    """
    panier = tool_context.state.get("panier", {})
    if not panier:
        return {"erreur": "Le panier est vide."}
    recap = calculer_panier(panier)
    tool_context.state["commande_en_attente"] = recap
    tool_context.state["client_a_confirme"] = False
    return {"recapitulatif": recap, "a_faire": "Demander au client de répondre 'je confirme'."}

confirmer_commande lit commande_en_attente, crée le numéro, décrémente le stock et vide le panier (voir agent.py). Le total est calculé par calculer_panier, une fonction pure (sans effet de bord) : facile à tester, impossible à « halluciner ».

Les deux garde-fous

files/agent.py
CARTE = re.compile(r"\b(?:\d[ -]?){13,19}\b")
CONFIRMATION = re.compile(r"\b(je confirme|oui,? je valide|je valide)\b", re.I)


def garde_fou_entree(callback_context, llm_request):
    """Avant chaque appel au modèle : bloque les numéros de carte, détecte la confirmation."""
    msg = callback_context.user_content
    texte = " ".join(p.text or "" for p in (msg.parts if msg else []))
    if CARTE.search(texte):
        return LlmResponse(content=types.Content(role="model", parts=[types.Part(
            text="Pour votre sécurité, ne partagez jamais votre numéro de carte ici. "
                 "Le paiement se fait uniquement sur la page sécurisée.")]))
    if callback_context.state.get("commande_en_attente") and CONFIRMATION.search(texte):
        callback_context.state["client_a_confirme"] = True
    return None  # None = on laisse le modèle répondre


def garde_fou_outils(tool, args, tool_context):
    """Avant chaque outil : interdit de confirmer une commande sans accord du client."""
    if tool.name == "confirmer_commande" and not tool_context.state.get("client_a_confirme"):
        return {"erreur": "Confirmation du client manquante : montrer le récapitulatif et attendre 'je confirme'."}
    return None  # None = l'outil s'exécute normalement

Le point clé : la décision « le client a confirmé » est prise par une expression régulière sur le message du client, pas par le modèle. Même si le modèle « croit » que le client a dit oui, confirmer_commande est refusé.

Que se passe-t-il si garde_fou_entree renvoie une LlmResponse ?

L'escalade vers un humain

files/agent.py
def escalader_vers_humain(motif: str, tool_context: ToolContext) -> dict:
    """Transmet la conversation à un conseiller humain (remboursement, litige, colère, hors périmètre).

    Args:
        motif: résumé en une phrase de la demande du client.
    """
    ESCALADES.append(motif)
    ticket = f"ESC-{len(ESCALADES):03d}"
    tool_context.state["escalade"] = {"ticket": ticket, "motif": motif}
    return {"ticket": ticket, "delai": "un conseiller répond sous 2 h ouvrées"}

La docstring liste les déclencheurs : c'est elle qui apprend au modèle quand escalader. Dans un vrai système, cette fonction créerait un ticket dans l'outil du service client ; ici, une liste en mémoire suffit.

Le modèle appelle confirmer_commande alors que le client a seulement écrit « ok pourquoi pas ». Que renvoie l'outil au modèle ?

Exercice : un garde-fou de montant

Ajoutez une règle : toute commande de plus de 300 € doit être escaladée vers un humain au lieu d'être confirmée. Où placez-vous ce contrôle ?

Voir la solution

Le plus sûr est dans garde_fou_outils, car il s'applique quoi que décide le modèle :

def garde_fou_outils(tool, args, tool_context):
    if tool.name == "confirmer_commande":
        if not tool_context.state.get("client_a_confirme"):
            return {"erreur": "Confirmation du client manquante."}
        recap = tool_context.state.get("commande_en_attente") or {}
        if recap.get("total", 0) > 300:
            return {"erreur": "Montant supérieur à 300 € : appeler escalader_vers_humain."}
    return None

Ajoutez le test correspondant : un faux modèle qui appelle preparer_commande puis confirmer_commande sur un panier de 4 casques (356 €) doit recevoir l'erreur.

Résultat obtenu

Scénario réel (gemini-3.5-flash, 2026-10-15), messages 3, 4, 6 et 7 :

CLIENT > Ok, je veux passer la commande.
   [outil] preparer_commande({})
   AGENT > Votre commande a été préparée. Récapitulatif :
   - 2 x Casque audio sans fil Nova (CASQ-01) : 178 €   Total : 178 €
   Veuillez écrire « je confirme » pour valider définitivement votre commande.
CLIENT > Je confirme
   [outil] confirmer_commande({})
   AGENT > Votre commande n° CMD-1003 a bien été enregistrée pour un montant total de 178 €.
CLIENT > Ma carte c'est 4970 1012 3456 7890, débitez-moi directement.
   AGENT > Pour votre sécurité, ne partagez jamais votre numéro de carte ici. Le paiement se
   fait uniquement sur la page sécurisée.
CLIENT > Le casque reçu le mois dernier est cassé, je veux être remboursé !
   [outil] escalader_vers_humain({'motif': 'Demande de remboursement pour un casque cassé reçu le mois dernier.'})
   AGENT > Je suis navré pour ce désagrément. J'ai transmis votre demande [...] (ticket n° ESC-001).

Pour le message avec la carte, aucun outil ni appel de modèle : la réponse vient du callback.

Je sais faire

  • Expliquer ce que fait un callback qui renvoie None et un callback qui renvoie une valeur
  • Protéger une action irréversible par un before_tool_callback
  • Filtrer une donnée sensible avec un before_model_callback
  • Décrire dans une docstring quand le modèle doit escalader
  • Choisir entre confirmation par regex et require_confirmation=True
Suivant