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.
| Outil | Erreur visée | Garde |
|---|---|---|
read_file(path, start, limit) | lire un fichier énorme d'un coup | 200 lignes par défaut, numérotées, « relisez avec start=… » |
search(pattern, path) | ouvrir 15 fichiers pour trouver une fonction | regex Python, fichier:ligne:texte, 50 résultats au plus |
edit_file(path, old, new) | réécrire tout un fichier, ou remplacer au mauvais endroit | old doit apparaître exactement une fois ; le diff est renvoyé |
list_files(path) | se perdre dans l'arborescence | 200 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é.
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 candidateL'é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.
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 tauxVé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éationCe 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, caroldn'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
olden boucle. Symptôme dans le journal : troisedit_fileen 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.venvde 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 ?
Une chaîne qui semble identique mais ne correspond pas trahit des caractères invisibles. Changer de modèle ne règle rien, il voit le même texte. La longueur n'empêche pas la correspondance d'un extrait. Un refus de permission produirait un message « refusé », pas « introuvable ».
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 ?
Réécrire un fichier de 2 000 lignes coûte des milliers de tokens de sortie, les plus chers, et un modèle qui recopie un long fichier en perd régulièrement des morceaux. La confirmation humaine ne protège pas : personne ne relit un diff de 2 000 lignes. write_file sait créer un fichier, ce n'est pas l'argument.
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