« Le calcul de TVA est faux pour les taux réduits — corrige-le. » Pour un humain : ouvrir le dépôt, trouver le bon fichier, comprendre, modifier, lancer les tests, ajuster. Un agent de code fait exactement cela — à condition de lui donner les bons outils, une boucle qui exploite le retour des tests, et des garde-fous sérieux : il s’apprête à modifier du code et à exécuter des commandes.
Ce cas est le plus exigeant de la série : il combine tool calling avancé, boucle évaluateur-optimiseur (les tests jouent l’évaluateur) et toutes les protections du deep-dive sécurité.
L’architecture
Le cœur est une boucle édition-test : l’agent propose un correctif, les tests le jugent, et leur sortie nourrit la tentative suivante. C’est le pattern évaluateur-optimiseur, où la suite de tests tient le rôle d’évaluateur objectif — pas un LLM-juge, du vert ou du rouge.
Le parcours complet : explorer/localiser → planifier le correctif → boucle (éditer → tester) → validation humaine → appliquer.
L’état
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
class Etat(TypedDict):
issue: str # la demande en langage naturel
fichiers_cibles: list[str] # localisés à l'exploration
diff: str # le correctif proposé (patch unifié)
tests_ok: bool # résultat de la dernière exécution
sortie_tests: str # logs de tests (nourrissent l'itération)
tentatives: int # garde-fou anti-boucle
messages: Annotated[list, add_messages]
Construire l’agent, étape par étape
Les outils de lecture (sans danger)
L’exploration ne doit rien modifier. On expose des outils en lecture seule : lister, lire, chercher. Le périmètre minimal du deep-dive sécurité commence ici.
from langchain_core.tools import tool
from pathlib import Path
RACINE = Path("/sandbox/repo") # le dépôt, dans un environnement isolé
@tool
def lister(chemin: str = ".") -> str:
"""Liste les fichiers et dossiers sous un chemin du dépôt."""
base = (RACINE / chemin).resolve()
if RACINE not in base.parents and base != RACINE: # anti path-traversal
return "Chemin hors du dépôt."
return "\n".join(p.name for p in base.iterdir())
@tool
def lire(fichier: str) -> str:
"""Lit le contenu d'un fichier du dépôt (numéroté)."""
f = (RACINE / fichier).resolve()
if RACINE not in f.parents:
return "Chemin hors du dépôt."
return "\n".join(f"{i+1}: {l}" for i, l in enumerate(f.read_text().splitlines()))
@tool
def chercher(motif: str) -> str:
"""Recherche un motif (texte) dans le code du dépôt et renvoie les emplacements."""
return grep_dans_repo(RACINE, motif) # ripgrep côté implémentationL’outil de test (l’évaluateur objectif)
Lancer les tests est une exécution de commande — donc à isoler. On exécute dans le sandbox, avec un timeout, et on renvoie la sortie brute (utile à l’agent).
import subprocess
@tool
def lancer_tests() -> str:
"""Lance la suite de tests du dépôt et renvoie le résultat (succès/échecs + logs)."""
proc = subprocess.run(
["pytest", "-q"], cwd=RACINE, capture_output=True, text=True, timeout=120,
)
statut = "SUCCÈS" if proc.returncode == 0 else "ÉCHEC"
return f"{statut}\n{proc.stdout[-4000:]}\n{proc.stderr[-2000:]}"L’outil d’écriture (derrière garde-fou)
L’écriture ne propose qu’un diff ; elle ne l’applique pas directement. C’est ce diff qui passera devant l’humain avant d’être committé.
@tool
def proposer_diff(diff_unifie: str) -> str:
"""Propose un correctif sous forme de patch unifié (git diff). N'écrit rien encore."""
if not diff_unifie.lstrip().startswith(("diff --git", "---")):
return "Format invalide : fournis un patch unifié."
return "Diff enregistré pour validation." # stocké dans l'état par le nœud appelantNœud d’exploration : localiser le code
Un agent ReAct outillé en lecture seule explore le dépôt et identifie les fichiers en cause. On le borne (il ne doit pas explorer indéfiniment).
from langgraph.prebuilt import create_react_agent
from langchain.chat_models import init_chat_model
modele = init_chat_model("anthropic:claude-sonnet-4-6")
explorateur = create_react_agent(modele, [lister, lire, chercher])
def explorer(etat: Etat) -> dict:
res = explorateur.invoke({"messages": [{"role": "user", "content":
f"Localise le(s) fichier(s) concerné(s) par cette issue. Liste-les précisément.\n"
f"Issue : {etat['issue']}"}]},
config={"recursion_limit": 20})
return {"fichiers_cibles": extraire_chemins(res["messages"][-1].content)}Nœud d’édition : proposer un correctif
L’agent lit les fichiers cibles, comprend, et produit un diff. S’il itère après un échec de tests, on lui injecte la sortie des tests précédente — c’est ce retour qui rend la boucle efficace.
def editer(etat: Etat) -> dict:
contexte = "\n\n".join(lire.invoke(f) for f in etat["fichiers_cibles"])
retour = f"\n\nLes tests ont échoué :\n{etat['sortie_tests']}" if etat.get("sortie_tests") else ""
rep = modele.invoke(
f"Issue : {etat['issue']}\n\nCode actuel :\n{contexte}{retour}\n\n"
f"Produis un patch unifié (git diff) qui corrige le problème, sans rien casser d'autre."
)
return {"diff": extraire_diff(rep.content), "tentatives": etat["tentatives"] + 1}Nœud de test : appliquer dans le sandbox et juger
On applique le diff dans le sandbox (jamais sur le vrai dépôt), on lance les tests, on enregistre le verdict.
def tester(etat: Etat) -> dict:
appliquer_diff_sandbox(RACINE, etat["diff"]) # patch temporaire, isolé
sortie = lancer_tests.invoke({})
return {"tests_ok": sortie.startswith("SUCCÈS"), "sortie_tests": sortie}L’aiguillage : vert, on valide ; rouge, on réitère
from typing import Literal
from langgraph.graph import END
def apres_tests(etat: Etat) -> Literal["valider", "editer", "abandonner"]:
if etat["tests_ok"]:
return "valider"
if etat["tentatives"] >= 4: # garde-fou anti-boucle
return "abandonner"
return "editer" # ré-essaie avec la sortie des tests en contexteValidation humaine avant d’écrire
Les tests passent — mais un humain valide le diff avant qu’il ne touche le vrai dépôt. Dernier garde-fou, le plus important.
from langgraph.types import interrupt, Command
def valider(etat: Etat) -> Command:
decision = interrupt({
"diff": etat["diff"],
"tests": "verts",
"demande": "Appliquer ce correctif au dépôt ?",
})
if decision == "approuver":
appliquer_diff_reel(etat["diff"]) # le seul endroit qui écrit vraiment
return Command(goto=END, update={"messages": [{"role": "assistant",
"content": "Correctif appliqué, tests au vert."}]})
return Command(goto=END, update={"messages": [{"role": "assistant",
"content": "Correctif rejeté, aucune modification appliquée."}]})Câbler le graphe
from langgraph.graph import StateGraph, START
from langgraph.checkpoint.memory import InMemorySaver
def abandonner(etat: Etat) -> dict:
return {"messages": [{"role": "assistant",
"content": "Je n'ai pas réussi à faire passer les tests en 4 tentatives. "
"Voici ma meilleure proposition et les logs pour reprise humaine."}]}
g = StateGraph(Etat)
g.add_node("explorer", explorer)
g.add_node("editer", editer)
g.add_node("tester", tester)
g.add_node("valider", valider)
g.add_node("abandonner", abandonner)
g.add_edge(START, "explorer")
g.add_edge("explorer", "editer")
g.add_edge("editer", "tester")
g.add_conditional_edges("tester", apres_tests,
{"valider": "valider", "editer": "editer", "abandonner": "abandonner"})
g.add_edge("abandonner", END)
agent_code = g.compile(checkpointer=InMemorySaver()) # HITL ⇒ checkpointer L’exécuter
from langfuse.langchain import CallbackHandler
handler = CallbackHandler()
config = {"callbacks": [handler], "configurable": {"thread_id": "issue-142"},
"recursion_limit": 30, "metadata": {"langfuse_tags": ["agent-code"]}}
res = agent_code.invoke({"issue": "Le calcul de TVA est faux pour les taux réduits.",
"tentatives": 0}, config)
# le graphe se gèle sur la validation : on présente res["__interrupt__"], puis :
agent_code.invoke(Command(resume="approuver"), config)
Lire la trace : voir l’agent réparer
La trace LangFuse raconte la réparation : l’exploration (lectures, grep), les tentatives d’édition, et surtout les exécutions de tests entre chaque. Sur un cas qui converge en deux essais, on voit clairement le premier diff échouer, la sortie de tests réinjectée, puis le second diff passer.
Bonnes pratiques & pièges
- Exécutez tout dans un sandbox isolé ; n'écrivez sur le vrai dépôt qu'après validation.
- Outils de fichier en lecture seule pour l'exploration ; résolvez et vérifiez chaque chemin.
- Faites des tests l'évaluateur objectif : leur sortie nourrit chaque nouvelle tentative.
- Bornez la boucle édition-test (compteur) avec un abandon qui rend la main à l'humain.
- HITL obligatoire avant d'appliquer un diff : des tests verts ne prouvent pas la justesse.
- Tracez : la trace montre quel diff a échoué et pourquoi, et chiffre le coût des itérations.
- Laisser l'agent écrire directement sur le dépôt : une régression part en production.
- Outils de fichier sans contrôle de chemin : path-traversal et lecture de secrets.
- Exécuter des commandes sans sandbox ni timeout : exécution de code arbitraire.
- Boucle non bornée : un bug irréparable fait tourner l'agent (et la facture) sans fin.
- Faire confiance aux tests verts seuls : une suite incomplète laisse passer des faux correctifs.
Ce qu’il faut retenir
- Un agent de code = outils (lecture, test, écriture proposée) + boucle édition-test + garde-fous lourds.
- Les tests sont l’évaluateur idéal : objectif, et leur sortie réinjectée fait converger l’agent.
- Tout passe par un sandbox ; le vrai dépôt n’est touché qu’après validation humaine.
- Chaque outil de fichier vérifie son chemin ; chaque exécution a un timeout ; la boucle est bornée.
- La trace rend la réparation lisible et en chiffre le coût.
Trois cas, trois architectures : investigation large, réponse fiable, action sous contrôle. Ils puisent tous au même socle — celui du parcours deep-dive. La théorie était la promesse ; ces cas en sont la preuve.