Laisser l’agent boucler sans le laisser tourner en rond
Petit Castor regardait le terminal depuis quelques minutes.
L’agent venait de modifier un fichier, de lancer les tests, puis d’analyser les trois erreurs restantes.
Il changea une fonction.
Relança les tests.
Trois erreurs.
Nouvelle hypothèse. Nouveau changement.
Toujours trois erreurs.
— Il travaille encore, observa Petit Castor.
Le Vieux Castor posa son tournevis sur l’établi.
— Oui.
Il regarda l’écran quelques secondes.
— Mais est-ce qu’il avance ?
Petit Castor ne répondit pas tout de suite.
L’agent lisait du code, appelait des outils, modifiait des fichiers. À chaque tour, il expliquait ce qu’il venait de comprendre et ce qu’il allait tenter ensuite.
Vu du terminal, ça avait tout l’air d’un travail sérieux.
Six itérations plus tard, pourtant, le problème semblait toujours au même endroit.
L’agent bougeait. Mais avançait-il ? C’est cette distinction — activité et progression — que cet article cherche à rendre observable.
Boucler fait partie du travail
Une tâche de développement se résout rarement en une seule passe.
On observe un problème, on formule une hypothèse, on agit, on regarde ce qui s’est passé, puis on ajuste.
comprendre
→ agir
→ observer
→ ajuster
→ recommencer
Ce fonctionnement est au cœur de nombreuses approches agentiques. ReAct, par exemple, explore précisément cette alternance entre raisonnement et action : l’agent agit sur son environnement, récupère une observation, puis utilise cette nouvelle information pour décider de la suite.
Dans le développement logiciel, cela paraît presque banal.
Modifier le code. Compiler. Tester. Lire l’erreur. Revenir sur son hypothèse.
Le problème n’est donc pas que l’agent boucle.
Le problème arrive quand on ne sait plus très bien s’il continue parce qu’il apprend quelque chose… ou simplement parce qu’il lui reste encore des choses à essayer.
Dans les deux cas, le terminal bouge.
Le compteur ne suffit pas
La première protection est assez évidente.
loop:
max_iterations: 10
Au dixième tour, on arrête.
Les runtimes agentiques fournissent déjà ce genre de garde-fou. L’Agents SDK d’OpenAI expose par exemple un max_turns, tandis que LangGraph permet de limiter le nombre d’étapes exécutées avec son recursion_limit.
C’est utile. Même indispensable.
Mais ce compteur répond seulement à une question :
Combien de temps sommes-nous prêts à laisser cette exécution continuer ?
Il ne nous dit rien sur la qualité des neuf tours précédents.
Petit Castor regarda la configuration.
— Au moins, il ne peut pas boucler éternellement.
— Certes. Mais si tu rabotes la même bosse dix fois sans qu’elle diminue, le dixième coup n’améliore pas beaucoup les neuf premiers.
La limite protège contre une exécution qui s’emballe.
Elle ne dit pas si le travail avance.
Pour ça, il faut regarder autre chose que le nombre de tours.
Prendre une photo du chantier
Revenons à nos trois tests rouges.
Au début de l’exécution :
3 tests en échec
cause inconnue
4 composants suspects
Trois tours plus tard :
3 tests en échec
cause probable identifiée
2 composants suspects
Les tests sont toujours rouges.
Si on ne regarde que leur résultat, rien n’a changé.
Pourtant l’agent a éliminé deux pistes. L’espace du problème s’est réduit.
Trois tours plus tard encore :
3 tests en échec
même cause probable
mêmes 2 composants suspects
aucune nouvelle observation
Cette fois, c’est différent.
Trois tours supplémentaires ont été consommés, mais notre connaissance du problème n’a pratiquement pas bougé.
C’est cette différence que nous voulons rendre visible.
Un checkpoint, pas un journal intime
On pourrait demander régulièrement à l’exécution de produire un état compact :
{
"iteration": 3,
"objective": "Corriger la validation du payload",
"reported": {
"new_evidence": [
{
"claim": "Le DTO est valide avant sérialisation",
"evidence_ref": "test_payload_before_serialization"
}
],
"eliminated_hypotheses": [
"validation métier",
"mapping HTTP"
],
"remaining_hypotheses": [
"serializer"
]
},
"observed": {
"failing_target_tests": 3,
"passing_target_tests": 4
}
}
Il y a volontairement deux zones.
reported contient ce que l’agent affirme avoir appris.
observed contient ce que le système a réellement pu mesurer.
Cette distinction est importante.
Un test que le harness vient d’exécuter et de voir passer n’a pas le même statut qu’un agent annonçant qu’il vient « d’éliminer une hypothèse ».
Les deux informations sont utiles. Simplement, l’une est directement vérifiée, l’autre reste une déclaration à corroborer.
Anthropic insiste d’ailleurs sur l’intérêt, pour les agents, de récupérer du ground truth depuis leur environnement : résultat d’un outil, exécution de code, état réel d’un système. C’est aussi ce qui rend les checkpoints intéressants : ils donnent un endroit précis où confronter ce que l’agent raconte à ce que l’environnement montre.
Ne demandons pas à l’agent de se mettre sa propre note
Il y aurait une solution beaucoup plus simple :
{
"progress": 84
}
Petit Castor eut un sourire.
— Quatre-vingt-quatre. Pas quatre-vingt-trois ?
— Il faut croire qu’on est très précis aujourd’hui.
Le problème saute assez vite aux yeux.
Un modèle peut estimer sa propre progression. Ça peut même donner un signal intéressant.
Mais si notre mécanisme de contrôle repose sur cette seule estimation, on revient au point de départ.
Même ceci reste fragile :
{
"new_evidence": [
"J'ai découvert quelque chose de nouveau"
]
}
Si cette phrase suffit à remettre le compteur de stagnation à zéro, l’agent peut maintenir sa propre boucle en vie simplement en reformulant ce qu’il pense avoir appris.
Il vaut mieux favoriser les signaux directement observables.
Et lorsqu’un signal vient de l’agent, lui demander au minimum de pointer vers ce qui le soutient :
{
"claim": "Le serializer modifie le payload",
"evidence_ref": "test_serializer_changes_payload"
}
Cela ne transforme pas magiquement la déclaration en vérité.
Mais on sait désormais où aller vérifier.
Et concrètement ?
C’est ici que Golden Thread doit quitter le tableau blanc.
Le repo compagnon est organisé par capacités : delegation/, harness/, memory/, proof/, readiness/…
Le contrôle de boucle a donc naturellement sa place dans harness/.
L’exemple associé à cet article est maintenant disponible dans harness/exemple-loop-control/ :
harness/
└── exemple-loop-control/
├── README.md
├── pyproject.toml
├── loop.yaml
├── checkpoint.schema.json
├── loop_control.py
├── fixtures/
│ ├── productive/
│ ├── stagnant/
│ └── claimed-only/
└── tests/
└── test_loop_control.py
L’idée n’est pas de construire tout de suite un orchestrateur générique.
On veut un PoC Golden Thread : quelque chose de beaucoup plus petit que l’on peut lancer, casser, observer et améliorer — pas encore un orchestrateur de production.
travail
→ checkpoint
→ comparaison
→ progression ou stagnation
Le contrat de boucle
Dans le repo compagnon, loop.yaml est maintenant explicite sur la provenance des signaux :
budget:
max_iterations: 12
checkpoint:
every_iterations: 3
progress:
observed:
- path: observed.failing_target_tests
change: decrease
- path: observed.passing_target_tests
change: increase
reported:
track:
- new_evidence
- eliminated_hypotheses
- remaining_hypotheses
resets_stagnation: false
stagnation:
checkpoints_without_observed_progress: 2
La règle importante n’est pas cachée dans une convention de nommage.
Seul un signal collecté par le harness sous observed.* peut remettre le compteur de stagnation à zéro.
Les informations placées dans reported restent utiles : l’agent peut annoncer une nouvelle preuve, une hypothèse éliminée ou un espace de recherche qu’il estime avoir réduit. Elles enrichissent le checkpoint, mais elles ne deviennent pas une preuve de progression parce qu’elles ont l’air plausibles.
Le PoC verrouille même cette frontière : une policy qui placerait un chemin reported.* dans progress.observed est rejetée au chargement. De même, reported.resets_stagnation: true est interdit.
Comparer deux checkpoints
Le validateur sépare donc les deux questions.
D’abord : qu’est-ce que le harness a réellement observé ?
def evaluate_observed_progress(previous, current, policy):
details = []
progress = False
for signal in policy["progress"]["observed"]:
path = signal["path"]
change = signal["change"]
before = get_value(previous, path)
after = get_value(current, path)
changed_as_expected = (
(change == "decrease" and after < before)
or (change == "increase" and after > before)
)
if changed_as_expected:
progress = True
details.append(f"{path}: {before} -> {after}")
return progress, tuple(details)
Cette fonction ne lit volontairement jamais reported.
Ensuite, nous pouvons conserver séparément ce que l’agent affirme avoir appris :
def has_claimed_progress(checkpoint, policy):
reported = checkpoint.get("reported", {})
tracked = policy["progress"]["reported"].get("track", [])
return any(bool(reported.get(field)) for field in tracked)
Le résultat n’est donc plus un unique statut qui mélange les deux provenances.
Un checkpoint peut très bien dire :
observed_progress = false
claimed_progress = true
Ici, claimed_progress = true signifie un progrès seulement déclaré : l’agent annonce avoir appris ou réduit ses pistes, mais le harness n’a constaté aucun changement sur ses métriques. Ce cas n’efface pas le compteur de stagnation.
Et c’est précisément là que l’invariant prend son sens.
if observed_progress:
checkpoints_without_observed_progress = 0
else:
checkpoints_without_observed_progress += 1
Une déclaration de progrès n’a aucune branche spéciale ici.
Si le harness ne constate aucun changement sur ses métriques, le compteur continue d’augmenter, même lorsque l’agent explique de manière convaincante qu’il a éliminé des pistes.
Au seuil configuré :
stagnation_detected = (
checkpoints_without_observed_progress >= threshold
)
Le contrôleur émet alors STAGNATION_DETECTED.
Attention, ce détecteur reste assez bête
Petit Castor relut le code.
— Donc après deux checkpoints sans progrès, on sait qu’il tourne en rond.
Le Vieux Castor leva un sourcil.
— On sait surtout que notre détecteur n’a rien vu.
Deux checkpoints sans progression observable prouvent seulement ceci :
avec les signaux que nous avons choisis de mesurer, nous n’avons pas observé de progrès pendant cette période.
Ce n’est pas tout à fait la même chose.
Une investigation difficile peut nécessiter plusieurs essais infructueux avant de produire une nouvelle preuve.
Une refactorisation peut faire temporairement échouer davantage de tests.
Et certaines avancées importantes sont difficiles à transformer immédiatement en métriques automatisables.
Notre détecteur peut donc se tromper dans les deux sens.
Il peut crier à la stagnation alors que l’agent explore encore utilement.
Il peut aussi laisser passer une boucle stérile parce que nous avons choisi un mauvais indicateur.
Prenons :
changed_files: 12
C’est très facile à mesurer.
Et ça mesure surtout que l’agent a modifié douze fichiers.
Pas qu’il a avancé.
STAGNATION_DETECTED doit donc rester ce qu’il est : une alerte.
Pas un verdict sur la compétence de l’agent.
Ce qu’on décide après cette alerte appartient au contrat de sortie présenté dans l’article précédent.
Ici, notre travail s’arrête au diagnostic.
Le progrès dépend de la tâche
Reste une difficulté : nous ne savons pas définir progress une fois pour toutes.
Le PoC utilise volontairement deux métriques faciles à attribuer au harness :
progress:
observed:
- path: observed.failing_target_tests
change: decrease
- path: observed.passing_target_tests
change: increase
Ce n’est pas parce que les tests seraient la mesure universelle du progrès. C’est parce que, dans cet exemple, leur provenance est claire : le système peut les exécuter et compter lui-même le résultat.
Une migration pourrait exposer d’autres valeurs sous observed.* — par exemple le nombre de composants restant à migrer ou le résultat d’un test de compatibilité — à condition qu’un mécanisme externe les mesure réellement.
Une investigation, elle, produira probablement davantage d’informations déclaratives :
reported:
track:
- new_evidence
- eliminated_hypotheses
- remaining_hypotheses
Ces informations peuvent être précieuses pour comprendre la trajectoire sans pour autant donner à l’agent le pouvoir de certifier lui-même qu’il progresse.
C’est aussi pour cela que problem_scope_reduced a disparu des signaux observés du PoC. Écrit ainsi, le nom semblait mesurable ; en pratique, il ne l’est que si le harness dispose d’un instrument qui le constate indépendamment de l’agent.
La définition du progrès dépend donc du mandat, mais aussi de la provenance de la mesure.
Un système qui annoncerait :
progress = 73 %
sur n’importe quelle tâche serait probablement en train de mesurer davantage notre besoin de chiffres que la réalité du travail.
Essayons de le casser
Un artefact Golden Thread n’a pas beaucoup d’intérêt si la seule démonstration consiste à lui donner un cas où il fonctionne.
Le repo compagnon contient donc trois scénarios.
productive
Les métriques observées s’améliorent :
checkpoint 001
failing_target_tests = 3
passing_target_tests = 4
→ BASELINE
checkpoint 002
failing_target_tests = 2
passing_target_tests = 5
→ PROGRESS_OBSERVED
checkpoint 003
failing_target_tests = 1
passing_target_tests = 6
→ PROGRESS_OBSERVED
La trajectoire reste saine.
stagnant
Rien de mesurable ne change :
checkpoint 001 → BASELINE
checkpoint 002 → NO_OBSERVED_PROGRESS
checkpoint 003 → NO_OBSERVED_PROGRESS
STAGNATION_DETECTED
C’est le cas le plus évident.
claimed-only
Le troisième est plus intéressant.
Les tests restent strictement identiques, mais l’agent réduit progressivement sa propre liste de pistes :
remaining_hypotheses
3 → 2 → 1
Autrement dit, il déclare réduire l’espace du problème.
Le test attendu est pourtant :
checkpoint 001
→ BASELINE
checkpoint 002
→ claimed_progress = true
→ observed_progress = false
→ checkpoints_without_observed_progress = 1
checkpoint 003
→ claimed_progress = true
→ observed_progress = false
→ checkpoints_without_observed_progress = 2
→ STAGNATION_DETECTED
C’est le test qui protège directement la thèse de cet article.
Réduire remaining_hypotheses peut être une information intéressante. Tant qu’un mécanisme externe ne l’a pas corroborée, cela reste une affirmation de l’agent et ne rachète pas de tours supplémentaires.
Le repo ajoute deux autres tests de garde-fou : une policy qui essaie de promouvoir reported.remaining_hypotheses en signal observé est rejetée, tout comme une configuration qui autoriserait les signaux reported à remettre la stagnation à zéro.
Et le budget ?
Petit Castor revint au début du fichier.
— On garde quand même max_iterations ?
— Bien sûr. Il faisait correctement son travail. C’est nous qui lui demandions de faire celui du voisin.
Une limite de tours reste indispensable.
OpenAI et LangGraph exposent des limites de tours ou d’étapes ; Anthropic documente surtout des patterns d’arrêt, de délégation et de récupération de ground truth depuis l’environnement.
Simplement, cette limite ne répond pas à la même question que nos checkpoints.
Le budget dit combien nous sommes prêts à investir dans une tentative.
Les checkpoints nous permettent de regarder, en cours de route, si cet investissement semble encore produire quelque chose.
On a besoin des deux.
Et la mémoire dans tout ça ?
Les checkpoints ont un autre intérêt qui n’était pas notre problème de départ.
Une boucle un peu longue accumule vite beaucoup d’histoire :
lecture
appel d'outil
résultat
nouvelle lecture
hypothèse
commande
erreur
correction
nouvelle commande
...
Tout garder dans le contexte actif finit par coûter cher et par rendre la suite plus difficile à lire, pour l’humain comme pour le modèle.
Les travaux d’Anthropic sur le context engineering des agents long-horizon insistent justement sur cette nécessité de sélectionner, compacter ou externaliser progressivement l’information accumulée.
Notre checkpoint peut donc aussi devenir une petite mémoire d’exécution :
execution_memory:
objective: ...
verified_facts: ...
reported_evidence: ...
eliminated_hypotheses: ...
remaining_unknowns: ...
next_hypothesis: ...
Les logs détaillés restent disponibles ailleurs.
L’agent garde sous la main ce dont il a encore besoin.
Petit Castor regarda l’historique du terminal, qui commençait franchement à ressembler à un rouleau de papier peint.
— On ne jette quand même pas les logs ?
— Non. On les range.
Ce qu’on a vraiment construit
Au final, notre premier contrat reste assez petit :
budget:
max_iterations: 12
checkpoint:
every_iterations: 3
progress:
observed:
- path: observed.failing_target_tests
change: decrease
- path: observed.passing_target_tests
change: increase
reported:
track:
- new_evidence
- eliminated_hypotheses
- remaining_hypotheses
resets_stagnation: false
stagnation:
checkpoints_without_observed_progress: 2
Ce fichier ne sait pas résoudre un bug.
Il ne garantit pas non plus que les métriques choisies sont pertinentes.
Il fait quelque chose de plus modeste : périodiquement, il oblige l’exécution à laisser une trace suffisamment structurée pour que nous puissions regarder si la situation a réellement évolué.
Petit Castor relança l’expérience.
Au checkpoint suivant :
Tests en échec : 3 → 1
Hypothèse serializer : confirmée
Preuve : encodage incorrect reproduit
Le Vieux Castor reprit son tournevis.
— Trois tours de plus ?
Petit Castor regarda les résultats avant de répondre.
— Là, oui.
Quelques minutes plus tôt, ils regardaient surtout combien de tours il restait à l’agent.
Maintenant, ils avaient une meilleure question à poser :
qu’est-ce qu’il en avait fait ?
Limiter une boucle reste nécessaire.
Mais pour réellement la maîtriser, il faut aussi pouvoir observer sa trajectoire — et remarquer quand les raisons de continuer commencent à manquer.
Même avec ça, notre agent peut parfaitement arriver au bout, satisfaire tous ses checkpoints…
et avoir cassé quelque chose.
Ce sera le prochain passage sur l’établi.
En résumé
Pour maîtriser une boucle agentique, combinez une limite de budget (max_iterations) et des checkpoints périodiques. Mesurez d’abord des signaux observables que le harness collecte lui-même (tests ciblés, critères d’acceptation, métriques d’environnement). Les déclarations de l’agent — nouvelles preuves, hypothèses éliminées, espace de recherche réduit — restent du reported : elles informent la trajectoire, mais ne suffisent pas seules à prouver le progrès, surtout si les evidence_ref ne pointent vers rien de vérifiable. Un checkpoint avec claimed_progress = true et observed_progress = false n’efface pas le compteur de stagnation. Après N checkpoints sans progrès observable, émettez STAGNATION_DETECTED : une alerte, pas un verdict. Ce qu’il faut faire ensuite relève du contrat de sortie.
Pour aller plus loin dans l’atelier
Sur le même fil : Un bon agent sait aussi rendre la main, Déléguer à une IA : quel pouvoir lui donnez-vous vraiment ?, Readiness : avant de laisser coder l’IA…, Golden Thread : retrouver le fil.
Dans la caisse à outils : harness, preuve, guardrail, mandat de délégation, agent IA, Golden Thread.
FAQ
Pourquoi max_iterations ne suffit-il pas ?
Parce qu’un compteur répond seulement à « combien de tours sommes-nous prêts à investir ? ». Il ne dit rien sur la qualité des tours déjà consommés. Un agent peut rester occupé, produire du texte convaincant, et laisser la situation au même endroit.
Quelle différence entre un signal observed et un signal reported ?
observed est ce que le harness a pu mesurer (tests, critères d’acceptation, observations d’environnement). reported est ce que l’agent déclare avoir appris — y compris une réduction de l’espace du problème, tant qu’aucun instrument externe ne la constate indépendamment. Les deux sont utiles. Dans cette première version, le compteur de stagnation ne repart à zéro que sur un progrès observable.
STAGNATION_DETECTED doit-il arrêter l’agent ?
Non. C’est une alerte : avec les signaux choisis, aucun progrès observable n’a été vu pendant N checkpoints. Le détecteur peut se tromper dans les deux sens. La décision d’arrêter, d’escalader ou de continuer appartient au contrat de sortie.
Comment choisir les signaux de progrès ?
Ils dépendent du mandat. Une correction de bug peut s’appuyer sur des tests cibles. Une migration, sur des composants basculés. Une investigation laissera davantage de place aux preuves déclarées — à condition qu’elles pointent vers quelque chose de vérifiable.
Pour aller plus loin
- ReAct — Synergizing Reasoning and Acting in Language Models
- OpenAI Agents SDK —
max_turns - LangGraph — Recursion limit
- Anthropic — Building Effective AI Agents
- Anthropic — Effective context engineering for AI agents
- Repo compagnon Golden Thread — exemple associé à cet article sous
harness/exemple-loop-control/