درس 2 — La boucle

Lire, chercher, éditer sans casser

7 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Lire, chercher, éditer sans casser

Donnez à un modèle un outil write_file(path, content) et demandez-lui de changer une ligne dans un fichier de 900 lignes : il réécrit les 900, en perd parfois une vingtaine au passage, et le diff de la revue de code devient illisible. Donnez-lui edit_file(path, old, new) sans exiger l'unicité de old : il remplace Decimal partout au lieu d'une seule occurrence. Les travaux sur SWE-agent l'ont mesuré : la forme des outils, que les auteurs appellent l'interface agent-ordinateur, change le taux de réussite autant qu'un changement de modèle.

Le mécanisme

Chaque outil répond à une erreur que les modèles font réellement.

OutilErreur viséeGarde
read_file(path, start, limit)lire un fichier énorme d'un coup200 lignes par défaut, numérotées, « relisez avec start=… »
search(pattern, path)ouvrir 15 fichiers pour trouver une fonctionregex Python, fichier:ligne:texte, 50 résultats au plus
edit_file(path, old, new)réécrire tout un fichier, ou remplacer au mauvais endroitold doit apparaître exactement une fois ; le diff est renvoyé
list_files(path)se perdre dans l'arborescence200 entrées au plus, .git et .env invisibles

Le confinement est commun à tous : chaque chemin passe par Workspace.resolve(), qui résout les liens symboliques puis vérifie que le résultat reste sous la racine du dépôt.

flowchart TD
  A["chemin demandé par le modèle"] --> B["(racine / chemin).resolve()"]
  B --> C{"sous la racine ?"}
  C -->|"non"| R["PathError : renvoyée au modèle"]
  C -->|"oui"| D{".git, .env, .venv ?"}
  D -->|"oui"| R
  D -->|"non"| E["outil exécuté"]

La mise en œuvre

Le confinement tient en quelques lignes (workspace.py). resolve() suit les liens : un lien config -> /etc placé dans le dépôt est rattrapé.

files/harness/workspace.py
def resolve(self, rel: str) -> Path:
    candidate = (self.root / rel).resolve()
    if candidate != self.root and self.root not in candidate.parents:
        raise PathError(f"chemin hors du dépôt refusé : {rel}")
    parts = set(candidate.relative_to(self.root).parts)
    if parts & DENIED_PARTS or any(p.startswith(".env") for p in parts):
        raise PathError(f"chemin protégé : {rel}")
    return candidate

L'édition est séparée en deux temps : plan_edit() calcule le nouveau contenu et le diff sans écrire, ce qui permet à la couche de permissions (leçon 4) de montrer le diff à un humain avant toute écriture.

files/harness/tools.py
def plan_edit(self, path: str, old: str, new: str) -> tuple[Path, str, str]:
    target = self.ws.resolve(path)
    ...
    count = before.count(old)
    if count == 0:
        raise ToolError("old introuvable : relisez le fichier, l'indentation et les espaces doivent être exacts")
    if count > 1:
        raise ToolError(f"old apparaît {count} fois : ajoutez du contexte pour le rendre unique")
    after = before.replace(old, new, 1)
    return target, after, unified_diff(path, before, after)

Les messages d'erreur sont écrits pour le modèle : ils disent quoi faire ensuite. Une ToolError n'arrête pas la session ; elle revient dans la function_response sous la clé error, et le modèle corrige son appel. Sur le run réel du 2026-10-15, Gemini a choisi de lui-même un old de trois lignes pour garantir l'unicité :

tour 10 · edit_file  old = "    for seuil, taux in PALIERS:\n        if quantite > seuil:\n            return taux"
--- a/facturation/remises.py
+++ b/facturation/remises.py
@@ -6,6 +6,6 @@
 def taux_remise(quantite: int) -> Decimal:
     for seuil, taux in PALIERS:
-        if quantite > seuil:
+        if quantite >= seuil:
             return taux
Le rapport de trajectoire : appel, permission, point de reprise et diff de chaque édition

Vérification :

.venv/bin/python -m pytest -q tests/test_tools.py
# 6 passed : chemins hors dépôt et liens refusés, pagination, recherche, édition ambiguë refusée, création

Ce qui casse en production

  • Lecture périmée. L'agent lit facture.py, un humain (ou un formateur lancé par un test) le modifie, l'agent édite à partir de sa copie mentale. Le remplacement exact limite les dégâts, car old n'est plus trouvé, mais une suppression peut encore passer. Parade : mémoriser l'empreinte du fichier à la lecture et refuser l'édition si elle a changé (exercice ci-dessous). Claude Code applique cette règle : son outil d'édition refuse un fichier non lu dans la session.
  • Fins de ligne et tabulations. Un fichier en CRLF ou indenté par tabulations fait échouer old en boucle. Symptôme dans le journal : trois edit_file en erreur « introuvable » d'affilée. Parade : normaliser à la lecture et à l'écriture, ou renvoyer un extrait avec les caractères invisibles rendus visibles.
  • Recherche dans les dépendances. Sans exclusion, search("def ") parcourt un .venv de 30 000 fichiers et sature le contexte. Parade : DENIED_PARTS, la borne de 50 résultats, et à terme le respect de .gitignore (ripgrep le fait nativement).
  • Fichiers générés modifiés. L'agent corrige un fichier produit par un générateur ; la correction disparaît au prochain build. Parade : une liste de chemins en lecture seule dans la configuration.

La règle de décision

Préférez le remplacement exact et unique pour les modifications ciblées : il échoue bruyamment au lieu de corrompre. Ajoutez un outil de patch multi-fichiers (format diff unifié, comme l'apply_patch de Codex) quand vos tâches touchent régulièrement plus de trois fichiers. Ne donnez write_file que pour la création. À surveiller : le taux d'erreurs edit_file par session, un bon indicateur de la qualité de vos descriptions d'outils.

Pratique

Dans le journal, vous voyez quatre edit_file consécutifs qui échouent avec « old introuvable » sur le même fichier, alors que le texte semble identique. Quelle est l'hypothèse la plus probable ?

Un développeur veut remplacer edit_file par write_file(path, content) « parce que le modèle se trompe moins ». Sur un dépôt de fichiers de 500 à 2 000 lignes, que répondez-vous ?

Exercice. Refusez une édition si le fichier a changé depuis la dernière lecture par l'agent, ou s'il n'a jamais été lu.

Voir la solution
import hashlib

class Toolbox:
    def __init__(self, workspace, command_timeout_s=60):
        ...
        self.read_hashes: dict[Path, str] = {}

    def _digest(self, p: Path) -> str:
        return hashlib.sha256(p.read_bytes()).hexdigest()

    def read_file(self, path, start=1, limit=200):
        target = self.ws.resolve(path)
        ...
        self.read_hashes[target] = self._digest(target)
        return body

    def plan_edit(self, path, old, new):
        target = self.ws.resolve(path)
        if old and self.read_hashes.get(target) != self._digest(target):
            raise ToolError("fichier non lu ou modifié depuis la lecture : relisez-le avant d'éditer")
        ...

Après une édition réussie, mettez à jour read_hashes[target] : sinon la deuxième édition du même fichier est refusée. Testez les trois cas : jamais lu, lu puis modifié à côté, lu puis édité deux fois.

  • Je confine chaque chemin, liens symboliques compris
  • J'écris des messages d'erreur d'outil qui disent au modèle quoi faire
  • Je sépare le calcul d'une édition de son écriture, pour l'approbation
  • Je sais lire dans un journal la signature d'un problème de caractères invisibles
التالي