Accueil / Articles

Comment un agent d’IA pour la correction de documents s’est trompé lui-même : faux positifs dans un environnement sandbox Docker

Un agent de correction de documents de LangGraph a fourni une correction inutile, puis a causé des problèmes avec les paquets src-layout. La faille liée au sandbox Docker en est la cause, et la véritable correction a été trouvée dans click.

Éditeur : REACTAPP.TOP7 min de lecture1666 mots

Sources: **I built an AI Doc-Fixing Agent. Here’s every way it fooled itself first.**

Un agent d’IA conçu pour réparer des exemples de code endommagés dans la documentation a produit un résultat étrange dans son premier dépôt réel. Il a « réparé » un bug qui n’existait pas, en prenant une ligne parfaite, sans y apporter aucun changement, et en publiant le même texte en tant que correction vérifiée.

- print(tqdm.__version__, sys.version, sys.platform)
+ print(tqdm.__version__, sys.version, sys.platform)

Les deux lignes correspondent. L’agent s’était convaincu de corriger quelque chose qui n’était jamais défectueux, et ce n’était que la première des trois manières dont il s’est trompé lui-même avant que le véritable responsable ne soit identifié.

Qu’est-ce que DocuPatch fait

DocuPatch est un agent géré par LangGraph. Il parcourt les fichiers Markdown d’un dépôt GitHub, extrait les blocs de code Python et les exécute dans un conteneur Docker isolé. Les échantillons qui échouent réellement sont envoyés à Gemini pour correction ; la solution est ensuite vérifiée à nouveau dans le même environnement isolé, et seul un résultat confirmé devient une branche ou une demande de fusion.

Structure du dépôt :

  • agent/ingest.py clone le dépôt et localise les fichiers Markdown.
  • agent/graph.py lance un pipeline LangGraph pour chaque fichier.
  • agent/parser.py extrait les blocs de code Python.
  • agent/tester.py exécute chaque extrait à l’intérieur d’un conteneur Docker.
  • agent/context_collector.py rassemble les fichiers Python et le code pertinents pour un extrait.
  • agent/fixer.py génère l’extrait corrigé.
  • agent/github_client.py crée une branche forkée, enregistre la correction et ouvre des demandes de fusion.
  • tests/ contient des tests pour chaque composant.
  • Le principe est simple : le code de documentation se détériore. Les API évoluent, les fonctions sont renommées, les exemples cessent de fonctionner, et personne ne s’en aperçoit tant que quelqu’un ne colle pas un extrait inutilisable issu d’un README. L’agent automatise cette détection. Il trouve un extrait suspect, prouve qu’il est défectueux en le exécutant, demande à un LLM de proposer une correction, vérifie que la correction fonctionne en l’exécutant à nouveau, et n’ouvre une demande de fusion qu’alors.

    Le pipeline comprend une étape de clonage et de parsing qui alimente un cycle contrôlé par des preuves ; une correction n’est acceptée qu’après deux vérifications : l’original échoue et le remplaçant fonctionne correctement.

    La règle directrice est le contrôle par les preuves. Rien n’atteint le LLM à moins qu’un essai en environnement isolé ne montre qu’il y a un problème, et rien n’est publié à moins qu’un nouvel essai en environnement isolé ne prouve que la correction fonctionne. Ce principe a survécu. L’environnement isolé lui-même, pendant longtemps, ne l’a pas fait.

    Premier test : tqdm

    Le premier essai dans des conditions réelles visait tqdm/tqdm :

    Found 9 Markdown file(s). Starting audit...
    
    Auditing: .github\ISSUE_TEMPLATE\bug.md
      Generating repair for snippet #1
      Verifying generated repair for snippet #1
      Publishing verified repair(s)...
      VERIFIED REPAIR: Branch published
    

    Deux fichiers ont été « corrigés » et publiés, ce qui semblait être une victoire en théorie. Puis on a examiné la différence réelle :

    - print(tqdm.__version__, sys.version, sys.platform)
    + print(tqdm.__version__, sys.version, sys.platform)
    

    Encore une fois, les deux lignes sont identiques. Gemini n’a rien changé et a régénéré le code caractère par caractère. Le système a testé un extrait de code, l’a déclaré défectueux, l’a « réparé » en renvoyant le même code, l’a de nouveau testé, a constaté qu’il fonctionnait et a soumis un commit inactif sur le fork.

    Ce n’était pas une erreur de correction. Il s’agissait d’un faux positif au niveau du test, car l’extrait de code original n’était jamais défectueux. Une ligne important tqdm et sys ainsi que des informations de version est tout à fait ordinaire pour l’utilisation de tqdm, alors pourquoi le sandbox a-t-il signalé une erreur ? La question a été classée comme suspecte, et un autre répertoire a été testé afin d’obtenir un deuxième élément de données avant de tirer des conclusions.

    Deuxième test : pallets/click

    Sur pallets/click, le schéma était impossible à manquer :

    Auditing: docs\advanced.md
      Generating repair for snippet #2
      Verifying generated repair for snippet #2
      [Reflection Loop 2/3] Revising repair for snippet #2
      ...
      [Reflection Loop 3/3] Revising repair for snippet #3
      NOT PUBLISHED: Generated repair failed verification:
        ModuleNotFoundError: No module named 'click'
    

    Une erreur à chaque fois. Les six fichiers ont tous été soumis à trois boucles de réflexion complètes, et chaque échec était identique : ModuleNotFoundError: No module named 'click'.

    Cette répétition est le signe révélateur. Lorsque six fichiers et six tentatives distinctes avec un LLM échouent tous à cause d’une même erreur d’import, le facteur commun n’est pas le code, mais l’environnement sous-jacent. Réécrire un fragment de code ne peut pas résoudre un module manquant si le sandbox ne parvient pas à voir le paquet, quel que soit le texte écrit.

    En ouvrant tester.py, on a découvert le mécanisme :

    command.extend(["-v", f"{project_dir}:/workspace", "-w", "/workspace", "-e", "PYTHONPATH=/workspace"])
    

    Dans le conteneur, le répertoire cloné est monté et PYTHONPATH est défini au niveau le plus élevé du répertoire. Cela fonctionne bien lorsque le dossier du paquet se trouve directement là, mais cela ne fonctionne plus lorsque le paquet est niché un niveau en dessous. Le projet Click est un exemple : son pyproject.toml conserve le code réel sous src/click/ plutôt que dans un dossier click/ à la racine, comme le prévoit le schéma src utilisé par de nombreux projets modernes. Avec PYTHONPATH=/workspace, l’interpréteur cherchait dans un dossier qui ne contenait absolument pas de dossier click.

    Une vérification manuelle en dehors du pipeline l’a confirmé :

    docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace python:3.10-slim python -c "import click"
    # ModuleNotFoundError: No module named 'click'
    
    docker run --rm -v "C:\temp\click_test:/workspace" -w /workspace -e PYTHONPATH=/workspace/src python:3.10-slim python -c "import click; print(click.__version__)"
    # import works... but:
    # importlib.metadata.PackageNotFoundError: No package metadata was found for click
    

    Le deuxième résultat a été le plus instructif. En pointant PYTHONPATH vers le bon dossier, le problème d’import a été résolu, mais click.__version__ continuait de planter, car les versions actuelles de click génèrent __version__ à l’aide de importlib.metadata, qui lit les métadonnées des paquets installés et non les fichiers sources. Ces métadonnées n’apparaissent qu’après un véritable pip install, jamais après une manipulation de chemin.

    Ainsi, deux bugs se superposaient : PYTHONPATH était orienté vers le mauvais dossier pour les répertoires de type src-layout. Même s’il était correctement orienté, un simple truc avec PYTHONPATH ne permet pas d’obtenir un paquet correctement installé, alors que de plus en plus de paquets exigent d’être installés réellement et non simplement importables.

    Un détour mérite d’être mentionné, car c’était le tronçon le plus instructif. Beaucoup de temps a été consacré à une intuition concernant une particularité de montage Docker réservée à Windows, avec des expériences sur les guillemets, les styles de chemins et les listes d’arguments par rapport aux chaînes de shell. L’explication réelle était banale : le dossier de test était vide parce qu’un git clone antérieur avait échoué en silence sans le remplir, et personne ne l’a vérifié. Un résultat vide de dir C:\temp\click_test n’était pas un « pas de réponse pour l’instant », mais bien la réponse elle-même, et elle a été ignorée à trois reprises avant que quelqu’un ne s’arrête pour la lire.

    La solution : installer, ne pas deviner

    Le véritable remède n’est pas de deviner plus intelligemment le chemin, mais de ne pas deviner du tout. pip install -e . lit le pyproject.toml ou le setup.cfg du projet lui-même, détermine l’emplacement correct du paquet quel que soit son organisation, et écrit des métadonnées d’installation authentiques, de sorte que les appels à importlib.metadata se comportent comme pour tout utilisateur réel de la bibliothèque.

    La nouvelle logique de tester.py s’exécute dans cet ordre :

    • Si un pyproject.toml, un setup.py ou un setup.cfg existe, le sandbox crée une image Docker et y exécute pip install -e .. C’est le « mode d’installation », où une erreur d’import peut être prise en compte.
    • En l’absence de métadonnées de packaging mais en présence d’un requirements.txt, on recourt à l’approche PYTHONPATH précédente, le « mode pythonpath ».
  • Dans le mode pythonpath, si un extrait échoue en raison d’une importation non résolue d’un paquet du projet lui-même, marquez-le comme ENV_ERROR, c’est-à-dire « impossible à déterminer », plutôt que FAIL.
  • Après ce changement, l’ensemble des tests existants a montré 26 sur 27 tests réussis sans modification. L’échec unique était justifié, car il testait l’ancienne signature de fonction qui avait été modifiée intentionnellement. Les attentes ont été mises à jour et trois nouveaux tests ont couvert ce cas limite, portant le total des tests réussis à 74.

    Une véritable erreur dans la documentation de click

    Avec le sandbox qui installe désormais le projet sans plus deviner, click a été exécuté à nouveau. Les six fichiers qui avaient échoué auparavant ont maintenant eu une chance équitable, et l’un d’eux a révélé une véritable erreur non découverte dans la documentation même de click.

    docs/commands.md décrit une chaîne de pipelines CLI, et l’une des fonctions d’exemple indique :

    @cli.result_callback()
    def process_pipeline(processors, fin):
    - iterator = (x.rstrip("\r\n") for x in input)
    + iterator = (x.rstrip("\r\n") for x in fin)
    

    La fonction reçoit en réalité fin, le handle de fichier fourni par click. Les documents, eux, itèrent sur input, ce qui renvoie à la fonction intégrée de Python input() et non au fichier. Itérer sur cette dernière est invalide et provoque une erreur dès l’exécution du code, mais un lecteur ne s’en apercevrait pas car le nom semble plausible.

    DocuPatch l’a détecté, a confirmé une véritable erreur dans le sandbox, a généré la correction, a vérifié que la version corrigée fonctionnait correctement du début à la fin et l’a publiée. C’était le premier résultat de toute l’enquête qui constituait à la fois un véritable bug de documentation et une véritable correction, sans aucun bruit provenant du sandbox ni faux positif. C’est précisément à cela que le projet est destiné.

    Leçons tirées du débogage

    • Vérifiez le verdict avant de faire confiance au résultat. Des étiquettes telles que « Passé » ou « Publié » ne sont fiables que dans la mesure où le test qui les accompagne l’est également. Le problème lié à tqdm n’a été découvert que parce que le diff a été ouvert et que la coche verte n’a pas été prise pour acquise.
    • Des échecs identiques dans de nombreux cas indiquent une cause commune. Six fichiers, six tentatives indépendantes avec un LLM et un seul message d’erreur constituent un signal au niveau du système.
    • Observez avant de théoriser. Des heures ont été perdues à spéculer sur Docker et les chemins Windows avant que quiconque ne vérifie que le dossier de test contenait bien des fichiers.
    • Un test échoué peut confirmer sans endommager. Lorsque la modification de la signature de run_snippet a provoqué l’échec d’un test, la bonne question était de savoir ce que ce test affirmait réellement, et non ce qui avait été endommagé.

    Désormais, le bug du sandbox a été corrigé, couvert par des tests et confirmé dans un dépôt qui posait auparavant des problèmes. La documentation de Click contenait un véritable défaut : un nom de variable qui provoquait des erreurs dès que quelqu’un exécutait l’exemple, et l’agent l’a détecté, corrigé correctement puis vérifié la correction avant de la publier. C’est l’idée centrale du projet, prouvée dans un cas réel et non synthétique.