Apprendre à l'agent à rater tôt

Sur Granit Golem, le vrai coût n'était pas les bugs. C'était d'attendre vingt minutes de CI pour apprendre une broutille. Alors j'ai appris à l'agent à rater tôt : un LSP, puis une cascade de hooks, des oracles déterministes qui signalent l'erreur à la seconde où elle naît.

Projet rattaché Granit Golem Sur Azure, le prix d'un health-check explose : de quelques centimes à plusieurs euros pièce. Aucun outil self-hosted ne coche mes cases, alors je l'écris moi-même. Au passage, je lâche Claude Code à fond pour voir : le projet 'rikiki' vire en plateforme SaaS industrielle, et je passe un mois à éponger le bazar généré.

Le coût d’un bug, c’est quand on le trouve

Une faute de frappe dans une requête SQL ne se paie pas à l’écriture. Elle se paie au moment où je la découvre.

Granit Golem, au départ, c’est mon outil de monitoring. C’est devenu, surtout, mon terrain d’essai pour une usine logicielle. Des agents Claude dépilent ma backlog GitLab tout seuls, jour et nuit. Un programme pioche un ticket, un agent le code et ouvre une merge request, un second agent la relit. Sur cette chaîne, je ne tiens plus qu’un poste, le feu vert final. Près de trois cents tickets en sont sortis fusionnés, la plupart pendant que je dormais.

Toute cette mécanique ne vise qu’à raccourcir la chaîne, de la spec d’un ticket jusqu’à sa mise en production (mes lignes de code à moi, je n’en écris presque plus). Une heure gagnée là se rejoue sur chaque ticket de la file, nuit après nuit. Une heure perdue aussi.

Et sur cette chaîne, le moment où je découvrais la faute tombait au pire endroit : la CI, le poste le plus lent. L’agent écrit le code, je laisse passer, je pousse. Le pipeline broie ses vingt minutes. Ressort rouge. Pour une broutille. Je relis les logs, je redemande un correctif, et c’est reparti pour un tour.

Le même octet mal placé, payé en plusieurs pipelines rouges et quelques milliers de tokens de débogage.

Le problème n’était donc pas « l’agent code mal ». C’était : l’agent, comme moi, découvre ses erreurs trop tard, une fois la facture tombée.

D’où la vraie question : comment lui faire trouver le défaut à la seconde où il l’écrit, et pas vingt minutes plus loin dans la file ? La réponse me pendait au nez depuis le début. Je m’en sers tous les jours sans y penser : le LSP.

C’est quoi un LSP, au juste ?

Vous codez dans VS Code. Une fonction se souligne en rouge avant même que vous ayez sauvegardé. F12 vous emmène droit à sa définition. Vous survolez une variable, son type s’affiche.

Tout ce savoir vient d’un programme à part, qui tourne en arrière-plan, pas de l’éditeur : le serveur de langage. Il répond à l’éditeur dans une langue commune, le Language Server Protocol. rust-analyzer pour Rust, typescript-language-server pour le TypeScript. L’éditeur ne fait que poser les questions ; le serveur, lui, répond la vérité du compilateur.

Un protocole, pas mille intégrations

L’idée derrière est plus maligne qu’elle n’en a l’air.

Avant, il fallait réécrire le support de chaque langage dans chaque éditeur. L’autocomplétion Python ici, la navigation Rust là. Dix éditeurs, vingt langages : deux cents bricolages à maintenir. Un cauchemar.

Microsoft, en bâtissant VS Code, a renversé la table. Un seul serveur par langage, qui parle une langue commune. N’importe quel éditeur qui la parle comprend aussitôt tous les langages. On passe de « vingt fois dix » à « vingt plus dix ».

Le serveur, lui, a fait le gros du travail une fois pour toutes : il a lu et compris tout le projet. Ensuite, il répond à des questions toujours les mêmes. Quel est le type ici ? Où est-ce défini ? Qui appelle cette fonction ? Quelles erreurs sur cette ligne ?

Et si l’agent devenait un client ?

L’éditeur interroge le LSP pour vous. Le harness (le programme qui pilote l’agent) fait pareil, mais pour l’agent. Le serveur, lui, ne voit pas la différence.

Le même serveur, deux clients

VS Code pour vous

  • survol → le type affiché à la volée
  • F12 → la définition
  • soulignés rouges les erreurs en direct, sans CI
rust-analyzer le serveur de langage

Le harness pour l'agent

  • le type d'une fonction rendu, pas deviné
  • tous ses appels la liste complète, d'un coup
  • les mêmes erreurs dès la frappe
rust-analyzer ne sait pas qui l'interroge. Hier votre éditeur posait les questions à votre place ; aujourd'hui le harness les pose pour l'agent. Mêmes réponses, aussi exactes.

Un oracle déterministe

À la même question, un LSP donne toujours la même réponse, et la bonne. Pas une recherche de texte qui devine au passage : la réponse exacte du compilateur. C’est ça, un oracle déterministe.

Pour l’agent, ça change tout. Avant, pour savoir à quoi ressemblait une fonction, il lisait tout le fichier. Des centaines de lignes, autant de tokens, pour une réponse qui tenait sur une seule. Maintenant il la demande au LSP, qui la lui rend déjà prête. Et pour retrouver partout où cette fonction est appelée, fini de fouiller à la main : il en obtient la liste complète d’un coup. Moins de lecture inutile, et surtout des réponses justes.

Compile partielle, compile complète

Une question m’a occupé un moment : comment le LSP répond-il à chaque frappe sans jamais me faire patienter, alors qu’une compilation Rust prend de longues secondes ?

Parce qu’il ne recompile pas tout. cargo check (la commande qui revérifie le projet en entier) repart de zéro. rust-analyzer, non : il garde tout en mémoire et ne recalcule que ce que vous venez de taper. La compilation complète, elle, attendra plus tard, quand quelques secondes de plus ne coûtent rien.

Comment Claude se sert du LSP

Quand je fais coder Claude, le LSP lui parvient par deux canaux opposés, et la différence compte.

Claude et le serveur LSP, pas à pas
Claude l'agent rust-analyzer le serveur LSP il édite un fichier diagnostics poussés <new-diagnostics> · automatique périmé ? un worktree sans node_modules ? il interroge le serveur quel type ? défini où ? réponse à l'instant sur l'état réel · synchrone il décide en connaissance de cause
Deux directions, jamais les mêmes. Le serveur pousse ses diagnostics après chaque édition (ils peuvent être périmés), et Claude le ré-interroge en direct dès qu'un doute le prend.

C’est le canal poussé qui a un angle mort, et il m’a fait douter du LSP un moment. Vous éditez un fichier, et les soulignés rouges montrent encore l’erreur d’avant, déjà corrigée. Ce n’est pas un bug, c’est du différé : le serveur ré-analyse dans son coin, puis remplace toute la liste d’un bloc, à la fin. Entre les deux, c’est l’ancienne qui reste affichée, et rien ne la marque comme périmée. (Son seul signal, « j’indexe », concerne sa base de symboles globale, pas la fraîcheur des soulignés du fichier courant.)

Le point contre-intuitif, c’est que personne ne tranche cette fraîcheur à la place de Claude. Le harness lui pousse les diagnostics bruts, sans la moindre notion de « périmé ». C’est donc lui qui juge : il se dit « cet avertissement vient d’un worktree sans node_modules, c’est un faux », et il repose la question au LSP en direct dès qu’il a un doute. La fraîcheur ne sort pas du serveur, elle sort du raisonnement de l’agent.

À la main, le réflexe est le même : après avoir touché une config ou un en-tête, ne vous fiez pas aux soulignés. Refaites un saut à la définition, ou attendez que l’indicateur d’indexation se taise.

Brancher le LSP : trois pièges

Sur le papier, c’est trivial. En pratique, trois pièges. Tous instructifs.

Le PATH

Le harness lance les serveurs avec un PATH réduit, pas celui de mon shell habituel. Du coup rust-analyzer, installé dans ~/.cargo/bin, reste introuvable : Executable not found in $PATH.

Je connaissais déjà la chanson. Le même oubli frappait l’agent lui-même : faute de trouver cargo, Claude recopiait PATH="$HOME/.cargo/bin:$PATH" devant chacune de ses commandes. À une période, j’en ai compté 44 en deux jours. (La faute aux hooks husky, qui tournent en sh et ne lisent pas mon .zshrc.) Autant de tokens dépensés à réécrire une rustine au lieu de coder.

La parade n’a rien d’élégant : un lien dans /usr/local/bin, le seul dossier fiable sur ce PATH dépouillé. Un gros scotch posé un peu énervé, en attendant de régler ça proprement un jour. Avec, tout de même, un hook SessionStart plus présentable qui injecte le PATH dans chaque session. Le préfixe a disparu par centaines.

Le workspace

Plus sournois. rust-analyzer se lançait bien, mais répondait à côté dès qu’une question touchait plusieurs crates (les briques compilables d’un projet Rust).

La preuve était dans la RAM, et c’est elle qui a tranché : 48 Mo. Pour un programme censé avaler 213 000 lignes de Rust, c’était l’aveu d’un fainéant. Un rust-analyzer qui a vraiment chargé le projet pèse un à trois Go.

À 48 Mo, il tournait en mode « fichier isolé » : il regardait chaque fichier tout seul, sans voir le reste du projet. La cause ? Toujours le PATH : il n’arrivait pas à lancer cargo, et se repliait en silence, sans rien me dire.

cargo rendu visible, le serveur a bondi à 580 Mo. Et d’un coup il a retrouvé les 27 implémentations d’un même trait (l’équivalent Rust d’une interface), jusque-là éparpillées dans le projet. La même question, deux réponses, séparées par un seul binaire sur un PATH.

Un binaire introuvable, et le serveur travaillait les yeux fermés.

Le langage absent du catalogue

Le harness de Claude fournit quelques serveurs LSP prêts à brancher. Pour Rust ou TypeScript, rien à faire. Mais le CSS et le HTML n’y figuraient pas.

Alors je les ai branchés moi-même. Je récupère le serveur, celui-là même qui équipe VS Code. Je le déclare au harness. Et d’un coup l’agent gagne l’autocomplétion et le repérage d’erreurs, sur des fichiers qu’il modifiait jusque-là à l’aveugle. Un oracle de plus, pour le prix d’une ligne de configuration.

La bonne question

Le LSP réglé, l’idée a fait tilt.

Si c’est un oracle fiable pour une classe de problèmes (les types, les références), pourquoi pas pour les autres ? Toutes celles que je paie cher en CI : le formatage, les fuites de données personnelles dans les logs, les règles métier, le code mort, les contrats entre le back et le front.

Deux étages de garde-fous

Concrètement, des hooks, sur deux tempos.

À chaque édition de fichier, du rapide et du bloquant. lint-changed.sh inspecte le fichier en moins d’une seconde. S’il trouve quelque chose, il bloque net et renvoie l’erreur directement à l’agent, qui corrige avant de continuer. Le défaut est attrapé par un script, pas par moi trois échanges plus loin.

À la fin de chaque tour, du lourd et du non-bloquant. quality-gate-stop.sh lance les vérifications lentes en arrière-plan (c’est là que la compilation complète a sa place) et ne réveille l’agent que si ça casse. Son seul vrai risque serait de tourner en boucle. Il s’en garde avec une empreinte des fichiers touchés. Tant qu’elle ne change pas, il ne relance rien.

Le détail de chaque étage, qui passe sur quoi :

À chaque éditionmoins d'une seconde · bloquant
  • Format, lint, fuites de données perso
  • Faute trouvée, l'agent corrige sur-le-champ
À la fin du touren fond · jamais bloquant
  • Compile complète, clippy (le lint Rust), requêtes SQL, code mort
  • Ne réveille l'agent que si quelque chose casse

Tout ça vit dans ma config personnelle, en dehors du projet. Chaque session en hérite, sans rien committer.

ast-grep : un LSP de mes propres invariants

Reste ast-grep, le seul oracle que j’écris à la main.

Le LSP connaît les types du langage. ast-grep, lui, me laisse écrire mes propres règles (ce qui doit toujours rester vrai dans mon code) et les vérifier sur chaque fichier que je modifie.

Le bug qui ne plantait pas

Le cas qui m’a converti, c’est mon pire bug RLS. (Le Row-Level Security, ce verrou que je laisse à PostgreSQL pour qu’un client ne voie jamais les données d’un autre.)

Il s’est manifesté deux fois, par les deux bouts.

La première, bruyante : la base refusait d’insérer la donnée, et l’API renvoyait une erreur 500. Au moins, ça se voyait.

La seconde, sournoise : un job de fond interrogeait la base sans dire pour quel client. Là, pas de 500. Les requêtes ne renvoyaient rien, les e-mails partaient vides, et rien ne plantait.

C’est cette version muette qui m’a coûté le plus cher. Le job silencieux, je ne l’ai débusqué que bien plus tard, dans les logs, en cherchant pourquoi certains clients ne recevaient jamais le moindre courriel.

Le correctif tient en une fonction. Le code paraît touffu, l’idée tient en une ligne : autoriser un traitement système à voir les données de tous les clients.

pub async fn bypass_rls<'e, E>(executor: E) -> Result<(), sqlx::Error>
where
    E: Executor<'e, Database = Postgres>,
{
    sqlx::query("SET LOCAL app.bypass_rls = 'true'")
        .execute(executor)
        .await?;
    Ok(())
}

Du correctif à la règle

Mais un correctif ne protège que du bug d’hier. Pour empêcher qu’il revienne, une dizaine de lignes de YAML transforment ce postmortem en garde-fou permanent, vérifié sur chaque fichier de job que je modifie :

# extrait simplifié de la vraie règle
id: rust-job-tx-without-bypass-rls
language: rust
severity: error
message: 'Job : .begin() sans bypass_rls → requêtes à 0 ligne hors contexte RLS (bug emails vides).'
files:
  - '**/jobs/**/*.rs'
  - '**/scheduler/**/*.rs'
  - '**/executor/**/*.rs'
rule:
  kind: function_item
  all:
    - has: { pattern: $P.begin(), stopBy: end }
    - not: { has: { pattern: bypass_rls($$$), stopBy: end } }

D’où ma règle, depuis :

Un postmortem, une règle.

Chaque rechute ne laisse plus seulement un correctif derrière elle, mais un garde-fou qui guettera la prochaine, en local comme en CI. Le jeu démarre à trois règles : le bypass RLS, le dbg!() oublié (un affichage de débogage), le test resté en .only (qui désactive tous les autres). Il ne fait que grossir.

Trois règles plutôt que dix

Le piège, avec ces oracles, c’est le bruit.

J’ai écarté plus de règles que je n’en ai gardées.

Une règle « pas de todo!() » en signalait huit là où il n’y en avait aucun : ast-grep lit mal certaines macros Rust, et la règle se déclenchait à tort. Une autre, contre les injections SQL, avait raison sur le principe mais se trompait sur un cas précis, en conseillant l’inverse de ce qu’il fallait. knip et cargo-machete, deux chasseurs de code mort (l’un côté front, l’autre côté Rust), en trouvaient du vrai. Mais noyé dans quinze remontées de dette ancienne que je n’avais pas demandées.

La parade n’a pas été de baisser le niveau d’exigence, mais de limiter chaque règle au code qui vient de changer. knip ne déballe plus la dette de tout le projet : il ne regarde que les fichiers que l’agent vient de toucher. Vous ajoutez un export mort, il le signale. Le reste se tait.

Le prompt pour répliquer ça

Envie d’équiper votre propre agent ? Voici, en gros, le prompt que je lui donnerais pour démarrer. À adapter à votre projet.

prompt à coller à votre agent
Mets en place des oracles déterministes sur ce projet, pour attraper mes erreurs au plus tôt.

1. LSP. Pour chaque langage du projet, branche le serveur de langage qui va bien au harness (rust-analyzer, typescript-language-server...). Si un langage n'a pas de serveur fourni, installe-le et déclare-le (CSS, HTML...). Sers-en pour résoudre les types et les références, au lieu de lire des fichiers entiers.

2. Des hooks, sur deux tempos.
 - À chaque édition de fichier : format, lint, détection de secrets et de données perso, en moins d'une seconde. En cas d'échec, bloque et fais remonter l'erreur tout de suite.
 - À la fin de chaque tour : compile complète, lint lourd, code mort, en arrière-plan et sans bloquer. Garde une empreinte des fichiers touchés pour ne relancer que si le périmètre a changé.

3. Une règle par postmortem. À chaque bug qui se reproduit, écris une règle structurelle (ast-grep ou équivalent) qui le détecte, limitée aux fichiers concernés, et vise zéro faux positif. Une règle qui crie au loup finit débranchée.

Un seul continuum

Le système de types, le LSP, ces hooks et la CI tiennent tous sur la même ligne. Tous répondent à la même question, « cette propriété tient-elle ? », à des moments différents.

La même question, du moins cher au plus cher

À la conception

  • Le système de typesle bug devient impossible à écrire · coût nul

Pendant la frappe

  • Le LSPcompile partielle, juste ce qui change · instantané
  • ast-grepmes invariants maison · instantané

À la fin du tour

  • cargo check, clippycompile complète du projet · secondes à minutes

En CI

  • La pipelinetout, compile et tests · ~20 minutes
coût nul~20 minle coût qui grimpe
Une seule question, « cette propriété tient-elle ? », posée à plusieurs moments. Posée plus tard, elle coûte plus cher. Nulle à la conception, le type interdit le bug. Instantanée sous les doigts, avec le LSP. Quelques secondes quand il faut tout recompiler. Vingt minutes une fois en CI. Tout le jeu consiste à la poser le plus tôt possible.

Plus je pousse la réponse vers la gauche, moins le défaut coûte cher. Tout à gauche, je ne vérifie même plus rien : le code faux ne peut tout bonnement plus s’écrire. C’est le système de types qui s’en charge, avant la moindre question.

L’agent n’est pas devenu plus malin, je n’ai jamais cherché ça. Je lui ai tendu les mêmes filets qu’à moi, ceux qui me retiennent à deux heures du matin quand je valide un commit sans le relire. Lui non plus ne se méfie pas. Mais un garde-fou, lui, ne dort jamais et ne se lasse pas.

Mais tout ne se rattrape pas à l’écriture. Le job RLS muet, aucun oracle ne l’a vu venir. C’est un log qui l’a trahi, des jours plus tard. Ce que je cherche à réduire, c’est ce délai : le temps entre l’erreur et le moment où je l’apprends.