Les outils Playwright de haut niveau
Première version d'un agent navigateur, vue sur de nombreux prototypes : un seul outil executer_playwright(code) qui laisse le modèle écrire du Python. C'est rapide à brancher et impossible à contrôler : le harness ne sait pas ce que fait le code avant de l'exécuter. Ce projet expose à la place huit outils étroits, chacun lisible par le harness avant exécution.
Le mécanisme
sequenceDiagram
participant M as Modèle
participant T as BrowserTools
participant G as Guard
participant P as Page Playwright
M->>T: cliquer(role="button", nom="Enregistrer le statut")
T->>P: get_by_role("button", name=..., exact=True).count()
P-->>T: 1
T->>G: is_effect("button", "Enregistrer le statut") ?
G-->>T: oui, confirmation demandée puis 1 POST armé
T->>P: click() puis wait_for_load_state()
P-->>T: nouvelle page
T-->>M: url, titre, arbre simplifié étiqueté non fiable
| Outil | Arguments | Ce qu'il renvoie |
|---|---|---|
se_connecter | aucun | état de la page d'accueil ; le mot de passe reste dans le harness |
naviguer | url (relative acceptée) | arbre de la nouvelle page, après contrôle de la liste blanche |
lire_page | aucun | URL, titre, arbre simplifié |
cliquer | role, nom | arbre après le clic ; confirmation si l'action a un effet |
remplir, choisir | champ (libellé), valeur ou option | accusé de réception, sans arbre (économie de jetons) |
extraire_tableau | nom du tableau | lignes en texte, étiquetées non fiables |
capture_ecran | aucun | image jointe au tour suivant : repli coûteux |
La mise en œuvre
Le code est dans harness/browser.py. Trois points méritent d'être lus de près.
1. L'arbre simplifié
page.locator("body").aria_snapshot() renvoie l'arbre ARIA au format YAML. Sur la liste de 24 commandes, il pèse 7 752 caractères et 3 357 jetons, car chaque ligne de tableau répète ses cellules. La fonction simplify garde la ligne nommée et supprime ses enfants, ainsi que les libellés déjà portés par les champs :
def simplify(snapshot: str) -> str:
out: list[str] = []
skip_deeper_than: int | None = None
for line in snapshot.splitlines():
indent = len(line) - len(line.lstrip())
if skip_deeper_than is not None:
if indent > skip_deeper_than:
continue
skip_deeper_than = None
item = line.strip()
if item.startswith(("- text:", "- caption:")):
continue
if item.startswith('- row "') and item.endswith(":"):
out.append(line[:-1])
skip_deeper_than = indent
continue
out.append(line)
return "\n".join(out)Mesure du 2026-10-15 avec count_tokens de gemini-3.5-flash : 3 357 jetons pour l'arbre brut, 1 149 pour l'arbre simplifié, et la même réponse exacte du modèle sur les deux. Sur l'exécution complète des 5 tâches, les jetons d'entrée passent de 76 000 à 91 000 (arbre brut, trois exécutions) à 46 000 ou 47 000 (arbre simplifié, deux exécutions).
2. Le clic par rôle et nom, sans ambiguïté
def cliquer(self, role: str, nom: str) -> dict:
loc = self.page.get_by_role(role, name=nom, exact=True)
n = loc.count()
if n == 0:
raise Blocked(f"Aucun élément de rôle {role!r} nommé {nom!r} : relisez la page (lire_page)")
if n > 1:
raise Blocked(f"{n} éléments {role!r} nommés {nom!r} : la cible est ambiguë")
if role == "link":
self.guard.check_navigation(urljoin(self.page.url, loc.get_attribute("href") or ""))
effect = self.guard.is_effect(role, nom)
if effect:
values = ", ".join(loc.evaluate(_FORM_VALUES_JS))
self.guard.confirm(Action("cliquer", f"Cliquer « {nom} » sur {path} ({values})"))
...exact=True évite que « Filtrer » corresponde aussi à « Filtrer par date ». Le refus des cibles multiples est volontaire : cliquer sur la première d'entre elles est la cause classique de l'action sur la mauvaise ligne. La description soumise à l'opérateur inclut les valeurs du formulaire (statut=Expédiée) : l'humain confirme ce qui sera envoyé, pas seulement le nom du bouton.
3. La connexion hors du modèle
def se_connecter(self) -> dict:
self.page.goto(urljoin(self.base_url, "login"))
user, password = self._credentials
self.page.get_by_label("Identifiant", exact=True).fill(user)
self.page.get_by_label("Mot de passe", exact=True).fill(password)
self.guard.arm(1) # le POST de connexion est une action du harness, pas du modèle
...Ce n'est pas suffisant. En écrivant le test, on constate que aria_snapshot() expose la valeur d'un champ type=password rempli :
- textbox "Identifiant": operateur
- textbox "Mot de passe": secret123Si l'agent appelait lire_page sur un formulaire de connexion rempli (connexion lente, double soumission), le secret partirait dans le contexte, puis dans les traces. Le harness masque donc le secret dans tout arbre renvoyé, et _FORM_VALUES_JS exclut les champs mot de passe des descriptions soumises à l'opérateur :
snap = simplify(self.page.locator("body").aria_snapshot())
# L'arbre ARIA expose la valeur des champs, y compris type=password : on masque le secret.
snap = snap.replace(self._credentials[1], "[masqué]")Le test test_mot_de_passe_masque_meme_si_la_page_l_affiche vérifie les deux faits : le secret est dans l'arbre brut, il n'est pas dans ce que reçoit le modèle.
Sur une page de fiche, l'agent appelle cliquer(role="button", nom="Enregistrer") et reçoit « 2 éléments button nommés Enregistrer : la cible est ambiguë ». Que faites-vous côté harness ?
Cliquer sur le premier élément est exactement l'erreur qui modifie la mauvaise ligne sans erreur visible. Restreindre par région (get_by_role('region', name=...).get_by_role('button', ...)) garde une cible lisible et contrôlable. Les coordonnées et la capture font perdre au harness la lecture de l'action.
La page Commandes affiche 2 000 lignes. lire_page renvoie un arbre tronqué à 8 000 caractères. L'agent doit trouver les commandes d'un client précis. Quelle conception d'outil privilégiez-vous ?
L'application sait déjà filtrer côté serveur : c'est moins cher et plus juste que de faire lire 2 000 lignes au modèle. Le message de troncature (« utilisez extraire_tableau ou filtrez ») oriente le modèle. Sans troncature, une seule page peut consommer une large part du contexte. Paginer multiplie les étapes. Une capture pleine page de 2 000 lignes serait illisible.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
TimeoutError après un clic | la page charge en JavaScript après load | attendre un état métier (get_by_role("status")) plutôt qu'un délai fixe |
| export cliqué, aucun fichier | le téléchargement arrive après le retour de l'outil | attente après les clics à effet ; page.expect_download() si l'export est lent (marqué ponytail: dans le code) |
| le modèle invente un nom d'élément | il paraphrase le libellé (« Sauvegarder ») | message d'erreur qui renvoie vers lire_page ; exact=True conservé |
| arbre vide sur une application | composants sans rôles ARIA | repli capture_ecran, et alerte dans la trace |
un secret apparaît dans trace.jsonl | l'arbre ARIA contient la valeur d'un champ mot de passe | masquage dans _state ; recherche du secret dans les traces en CI |
Règle de décision
Exposez des outils étroits dont chaque appel est lisible par une règle : rôle et nom, libellé de champ, chemin relatif. N'exposez jamais d'outil d'exécution de code ou de sélecteur CSS libre à un agent qui manipule des données réelles. Surveillez dans les traces le ratio d'erreurs « aucun élément » : au-delà de quelques pour cent, les libellés de l'application ont changé.
Résultat obtenu
Exécuté le 2026-10-15, sur Chromium headless et le back-office local, sans réseau externe :
$ .venv/bin/python -m pytest -v tests/test_browser.py
tests/test_browser.py::test_connexion_sans_fuite_du_mot_de_passe PASSED
tests/test_browser.py::test_recherche_et_tableau PASSED
tests/test_browser.py::test_modification_refusee_puis_accordee PASSED
tests/test_browser.py::test_filtre_reseau_coupe_un_post_non_confirme PASSED
tests/test_browser.py::test_navigation_externe_bloquee PASSED
tests/test_browser.py::test_export_telecharge PASSED
tests/test_browser.py::test_arbre_simplifie_garde_les_cibles PASSED
tests/test_browser.py::test_mot_de_passe_masque_meme_si_la_page_l_affiche PASSED
tests/test_browser.py::test_redirection_vers_un_domaine_tiers_coupee PASSED
9 passed in 6.62sExercice
Ajoutez un argument facultatif dans à cliquer qui restreint la recherche à une région ou à une ligne de tableau nommée, par exemple cliquer(role="link", nom="Voir", dans="CMD-1042").
Voir la solution
def cliquer(self, role: str, nom: str, dans: str = "") -> dict:
scope = self.page
if dans:
scope = self.page.get_by_role("row", name=dans).or_(self.page.get_by_role("region", name=dans))
if scope.count() != 1:
raise Blocked(f"Zone {dans!r} introuvable ou ambiguë")
loc = scope.get_by_role(role, name=nom, exact=True)
...Mettez à jour la déclaration de l'outil dans harness/agent.py ("dans": {"type": "string"}, non requis) et ajoutez la zone à la description soumise à confirmation : l'opérateur doit voir sur quelle ligne porte l'action. Le nom d'une ligne (row "CMD-1042 Coopérative du Val-Morin ...") contient la référence, d'où l'usage d'une correspondance partielle ici.
Je sais faire
- Concevoir un outil de navigateur dont chaque appel est vérifiable par une règle.
- Réduire l'arbre ARIA sans perdre de cible cliquable, et mesurer le gain en jetons.
- Refuser une cible ambiguë au lieu de cliquer sur la première.
- Garder les secrets de connexion hors du contexte du modèle.