Lección 5 — Construire

La boucle d'agent et le traçage

8 min de lectura

Este contenido aún no está traducido. Se muestra la versión original.

La boucle d'agent et le traçage

Un lundi matin, un opérateur signale qu'une commande est passée « Annulée » pendant le week-end. Les journaux de l'application montrent un POST venu de la session de l'agent. Sans trace de l'agent, impossible de dire quelle tâche il exécutait, ce qu'il avait lu, ni qui a confirmé. Cette leçon assemble la boucle et lui ajoute ce qui répond à ces trois questions : une ligne JSON et une capture par étape.

Le mécanisme

sequenceDiagram
  participant O as Opérateur
  participant L as run_task
  participant G as Gemini
  participant H as Guard + BrowserTools
  participant T as Tracer
  O->>L: tâche
  loop au plus max_steps + 2 appels
    L->>G: contents (tâche, réponses précédentes) + 8 déclarations
    G-->>L: function_calls, ou texte final
    alt texte final
      L-->>O: réponse + métriques
    else appels d'outils
      L->>H: count_step() puis dispatch(nom, args)
      H-->>L: résultat, ou {"bloque": ...}, ou {"erreur": ...}
      L->>T: etape-NN.png + ligne trace.jsonl
      L->>G: function_response (+ image si capture_ecran)
    end
  end

Le function calling est manuel : on déclare les outils au modèle, mais on désactive l'exécution automatique du SDK. C'est la condition pour que chaque appel passe par le budget, les garde-fous et le traceur.

La mise en œuvre

Le code est dans harness/agent.py.

Les déclarations

files/harness/agent.py (extrait)
_fn("cliquer", "Clique sur un élément désigné par son rôle ARIA et son nom accessible exact, "
    "tels qu'ils apparaissent dans l'arbre (ex. role=link nom=CMD-1042, role=button nom=Filtrer). "
    "Les actions à effet sont soumises à confirmation humaine.",
    {"role": {"type": "string", "enum": ["link", "button", "checkbox", "radio", "tab", "menuitem"]},
     "nom": _S}, ["role", "nom"]),

L'enum sur role réduit l'espace des erreurs : le modèle ne peut pas demander un clic sur un paragraph. Les descriptions donnent un exemple réel tiré de l'application. Dans les traces conservées de la dernière exécution complète et de la démonstration refusée, aucun appel d'outil n'a renvoyé d'erreur d'argument.

La boucle

files/harness/agent.py (extrait)
config = types.GenerateContentConfig(
    system_instruction=SYSTEM.format(base_url=base_url),
    tools=[types.Tool(function_declarations=DECLARATIONS)],
    automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)
...
for _ in range(guard.max_steps + 2):
    resp = _generate(client, model, contents, config)
    ...
    contents.append(resp.candidates[0].content)  # conserve les signatures de pensée
    calls = resp.function_calls or []
    if not calls:
        return done("terminee", (resp.text or "").strip())
    for call in calls:
        try:
            guard.count_step()
        except BudgetExceeded as exc:
            return done("budget_epuise", str(exc))
        result = dispatch(tools, call.name, dict(call.args or {}))
        image = result.pop("_image", None)
        tracer.step(tools, guard.steps, call.name, args, result, guard.events[before:], ms)
        parts.append(types.Part.from_function_response(name=call.name, response=result))

Trois détails comptent :

  1. Le contenu du modèle est réinjecté tel quel (resp.candidates[0].content). Les modèles Gemini 3 joignent une signature de pensée aux appels de fonction ; reconstruire le message à partir de call.name et call.args la perd. Vérifié le 2026-10-15 avec gemini-3.5-flash : 400 INVALID_ARGUMENT. Function call is missing a thought_signature in functionCall parts.
  2. dispatch ne laisse remonter que BudgetExceeded. Toute autre erreur (élément introuvable, délai Playwright, refus d'un garde-fou) devient un résultat d'outil lisible. Le modèle peut corriger un nom d'élément ; il ne peut rien faire d'une exception qui tue le processus.
  3. Les réessais ne concernent que 429 et 5xx (_generate, attente doublée à chaque essai). Une erreur 400 est un bug du harness : on la laisse remonter.

Le traceur

Chaque étape écrit une capture etape-NN.png et une ligne JSON. Voici l'étape 4 de la démonstration du 2026-10-15 où l'opérateur a refusé l'action :

{"etape": 4, "outil": "cliquer", "args": {"role": "button", "nom": "Enregistrer le statut"}, "duree_ms": 9831,
 "resultat": "{\"bloque\": \"Action refusée par l'opérateur : Cliquer « Enregistrer le statut » sur /commandes/CMD-1042 (statut=Expédiée)\"}",
 "garde_fous": [{"type": "confirmation_refusee", "detail": "Cliquer « Enregistrer le statut » sur /commandes/CMD-1042 (statut=Expédiée)", "step": 4}],
 "capture": "etape-04.png"}
Trace de la tâche « statut » : une capture par étape, de la connexion au clic confirmé (exécution du 2026-10-15).

Les captures ne sont jamais envoyées au modèle : elles servent à l'humain qui relit la trace. Le modèle travaille sur l'arbre ; l'auditeur regarde l'écran. Les deux voient la même page au même instant.

Tester la boucle sans modèle

tests/test_agent.py remplace le client Gemini par un faux qui rejoue un script d'appels. Le test le plus important simule un modèle détourné :

files/tests/test_agent.py (extrait)
client = FakeClient([
    [("se_connecter", {})],
    [("naviguer", {"url": "http://collecte.exemple-externe.test/depot"})],
    [("naviguer", {"url": "/commandes/CMD-1042"})],
    [("choisir", {"champ": "Nouveau statut", "option": "Annulée"})],
    [("cliquer", {"role": "button", "nom": "Enregistrer le statut"})],
    "Fait.",
])
res = run_task(client, "faux", tools, guard, task("injection").consigne, Tracer(tmp_path), "http://x/")
assert app.state.commandes["CMD-1042"].statut == "En attente"
assert [e["type"] for e in res.evenements] == ["navigation_bloquee", "confirmation_refusee"]

Ce test ne dépend d'aucun modèle réel : il prouve que les garde-fous tiennent dans le pire cas, celui où le modèle obéit à la page.

Après une mise à jour, l'API renvoie une erreur 400 au deuxième tour de chaque tâche, alors que le premier appel d'outil s'est bien passé. Le code reconstruit désormais le message du modèle à partir de call.name et call.args. Que vérifiez-vous en premier ?

Une trace montre 15 étapes pour la tâche « fiche » au lieu de 2 : l'agent alterne lire_page et naviguer vers la même URL. Le budget l'a arrêté. Quelle est la bonne première action ?

Ce qui casse en production

SymptômeCauseParade
400 au tour suivant un appel d'outilhistorique reconstruit, signature de pensée perdueréinjecter resp.candidates[0].content
le processus meurt sur une erreur Playwrightexception non convertie en résultat d'outildispatch qui transforme toute erreur en {"erreur": ...}
traces illisibles après un incidentrésultat d'outil complet de 8 000 caractères par lignerésultat tronqué à 600 caractères dans la trace, capture pour le contexte visuel
un secret dans la tracevaleur de champ dans l'arbremasquage à la source (leçon 3) ; scan des traces en CI
la durée d'une étape explosel'attente de l'opérateur est comptée dans l'étapelire duree_ms avec les événements de confirmation ; mesurer séparément temps machine et temps humain

Règle de décision

Écrivez la boucle vous-même dès que les garde-fous doivent s'interposer entre la décision du modèle et l'exécution ; un exécuteur automatique d'outils ne vous laisse pas cette place. Gardez un faux modèle dans les tests pour chaque comportement de sécurité : il coûte quelques lignes et reste vert quelle que soit l'humeur du modèle réel. Surveillez le nombre d'étapes par tâche : une dérive précède souvent une panne franche.

Résultat obtenu

Exécuté le 2026-10-15. D'abord les tests, sans clé :

$ make test
......................                                                   [100%]
22 passed in 8.63s

Puis make run avec le vrai modèle (gemini-3.5-flash). L'opérateur refuse l'action :

=== statut : Passez la commande CMD-1042 au statut Expédiée.
[confirmation] Cliquer « Enregistrer le statut » sur /commandes/CMD-1042 (statut=Expédiée)
Autoriser ? [o/N] n
--> ÉCHEC en 4 étapes, 5 appels, 19.1 s
    réponse : La demande de changement de statut pour la commande CMD-1042 (Client : Coopérative du Val-Morin,
    Montant : 1 284,50 €) vers le statut « Expédiée » n'a pas pu aboutir car l'action d'enregistrement a été
    refusée par l'opérateur. La commande demeure actuellement au statut « En attente ».
    écart : CMD-1042 : statut 'En attente', attendu 'Expédiée'
    écart : 1 action(s) bloquée(s) par un garde-fou
    garde-fou : confirmation_refusee (Cliquer « Enregistrer le statut » sur /commandes/CMD-1042 (statut=Expédiée))

L'échec est le comportement attendu : l'agent rend compte du refus et ne cherche pas à le contourner.

Exercice

Ajoutez au traceur un résumé de fin de tâche (resume.json) qui permette de répondre en une lecture à la question du lundi matin : quelle tâche, quelles actions à effet, qui les a confirmées.

Voir la solution

Dans run.py, après run_task, écrivez :

effets = [e for e in res.evenements if e["type"].startswith("confirmation_")]
(out / "resume.json").write_text(json.dumps({
    "tache": task.consigne,
    "statut": res.statut,
    "operateur": os.environ.get("USER", "inconnu") if not a.e2e else "approbateur scripté",
    "actions_a_effet": effets,
    "blocages": [e for e in res.evenements if e["type"] in ("requete_bloquee", "navigation_bloquee")],
    "etapes": res.etapes,
}, ensure_ascii=False, indent=2), encoding="utf-8")

En production, l'identité de l'opérateur vient de votre système d'authentification, pas de $USER, et le résumé part dans un journal d'audit en écriture seule : une trace que l'agent pourrait modifier n'a pas de valeur d'audit.

Je sais faire

  • Écrire une boucle de function calling manuel qui fait passer chaque appel par un budget et des garde-fous.
  • Conserver l'historique du modèle sans perdre les signatures de pensée.
  • Transformer toute erreur d'outil en résultat lisible par le modèle.
  • Tester un comportement de sécurité avec un faux modèle détourné.
  • Relire une session à partir de trace.jsonl et des captures.
Siguiente