# Comment créer une boucle d’IA avec une vraie barrière?

Date: 2026-10-06 · Author: Robert Scales · https://guidetoartificialintelligence.com/fr/apprendre/creer-boucle-ia-vraie-barriere/

**Answer:** Écrivez la boucle pour que chaque morceau de travail passe par des vérifications capables de le refuser sans que personne surveille, comme des tests qui doivent se terminer avec le code 0, une ligne source pour chaque affirmation et une limite sur les fichiers modifiables. Une unité refusée retourne à l’exécutant avec les preuves, un plafond de tentatives confie les unités récalcitrantes à une personne, et rien n’est appliqué avant qu’une personne approuve le résultat.

Une boucle qui se contente de demander au modèle « est-ce bon? » entend presque toujours « oui ». Anthropic l’a dit clairement en mars 2026 : quand des agents notent leur propre travail, ils ont tendance à en faire l’éloge, même quand une personne voit bien qu’il est médiocre. La solution n’est pas une question plus sévère. C’est une **barrière** (*gate*) : une vérification écrite en code qui rend elle-même un verdict de réussite ou d’échec, de sorte qu’un mauvais résultat est arrêté, que quelqu’un regarde ou non.

Cet article en construit une. Le script ci-dessous compte environ 170 lignes de Python ordinaire, n’a besoin que de la bibliothèque standard et fonctionne tel quel. Il utilise un substitut (*stub*) à la place d’un modèle : vous pouvez donc l’exécuter, voir chaque barrière échouer puis réussir, et seulement ensuite brancher un vrai modèle. La sortie présentée plus bas provient d’une exécution avec Python 3.14 sur un Mac.

## Les pièces, en mots simples

Une **unité** est un morceau de travail assez petit pour être vérifié seul : ici, une correction de bogue avec sa note de changement. Un **exécutant** (*worker*) produit une ébauche de l’unité. Dans un vrai système, l’exécutant est un appel à un modèle; ici, c’est un substitut qui rejoue des ébauches fixes pour que l’exécution soit reproductible. Une **barrière** examine l’ébauche et renvoie une liste d’échecs; une liste vide veut dire réussite.

Le script a trois barrières, et chacune peut refuser le travail sans intervention humaine :

| Barrière | Ce qu’elle vérifie | Comment elle échoue |
|---|---|---|
| Tests | Les tests propres à l’unité, exécutés dans un dossier neuf | `unittest` se termine avec un code autre que 0, ou dépasse 60 secondes |
| Sources | Chaque ligne de la note de changement se termine par `(source: fichier:ligne)` et pointe vers une ligne qui existe | Une affirmation n’a pas de source, ou pointe vers une ligne absente |
| Portée | Seuls les fichiers inscrits au plan ont été modifiés | Tout autre fichier apparaît dans l’ébauche, y compris le fichier de tests |

Trois règles entourent les barrières. Une unité refusée retourne à l’exécutant avec les messages d’échec comme preuves, et seule cette unité retourne; les autres gardent leur résultat. Chaque unité a droit à trois tentatives au plus, après quoi elle est confiée à une personne au lieu d’être reprise sans fin. Et rien n’est copié dans le dossier `approved/` avant qu’une personne lise les différences et tape `o`.

Ces choix suivent des pratiques publiées. Le guide d’Anthropic sur les agents dit que ceux-ci ont besoin d’une « vérité terrain » (*ground truth*) tirée de leur environnement après chaque étape (le résultat de l’exécution du code, par exemple) et qu’un nombre maximal d’itérations est une règle d’arrêt courante. Dans son texte sur les agents de programmation de longue durée, Anthropic a dit à l’agent qu’il était inacceptable de supprimer ou de modifier des tests, puisque cela pouvait mener à des fonctions manquantes ou boguées. Une boucle a besoin de la même protection, parce qu’un agent poussé à réussir peut rendre les tests plus faciles au lieu de rendre le code meilleur. La barrière de portée transforme cette consigne en vérification : une ébauche qui touche au fichier de tests échoue, si bon que soit le reste.

## Le script

Enregistrez-le sous `gate_loop.py` dans un dossier vide et lancez `python3 gate_loop.py`. Il écrit `loop_log.jsonl` (une ligne par tentative) et, si vous approuvez, un dossier `approved/`.

```python
"""Une petite boucle avec de vraies barrières. Bibliothèque standard seulement (Python 3.10+).

Chaque unité de travail est produite par un exécutant, puis vérifiée par trois barrières qui peuvent la refuser :
  1. tests :   les tests de l'unité doivent se terminer avec le code 0
  2. sources : chaque affirmation de la note de changement doit citer une ligne source qui existe
  3. portée :  le changement ne peut toucher que les fichiers prévus au plan
Une unité refusée retourne à l'exécutant avec les preuves. Après MAX_ATTEMPTS, elle va
plutôt à une personne. Rien n'est écrit dans approved/ avant qu'une personne tape « o ».
"""
import difflib, json, pathlib, re, subprocess, sys, tempfile

MAX_ATTEMPTS = 3
LOG = pathlib.Path("loop_log.jsonl")

# ---------- Le travail, déjà découpé en unités (le résultat du découpeur) ----------
UNITS = [
    {"id": "slugify-accents",
     "task": "Faire en sorte que slugify() remplace les lettres accentuées par des lettres simples, comme le demande issue.txt.",
     "allowed": {"slugify.py", "CHANGES.md"},
     "files": {
        "slugify.py": 'import re\n\ndef slugify(text):\n    text = text.lower()\n'
                      '    text = re.sub(r"[^a-z0-9]+", "-", text)\n    return text.strip("-")\n',
        "test_slugify.py": 'import unittest\nfrom slugify import slugify\n\nclass T(unittest.TestCase):\n'
                           '    def test_plain(self):\n        self.assertEqual(slugify("Hello World"), "hello-world")\n'
                           '    def test_accents(self):\n'
                           '        self.assertEqual(slugify("Crème brûlée à Montréal"), "creme-brulee-a-montreal")\n',
        "issue.txt": 'Billet 12 : les slugs cassent sur les lettres accentuées.\n'
                     '"Crème brûlée" devient "cr-me-br-l-e".\n'
                     'Attendu : retirer les accents d\'abord, puis construire le slug.\n'}},
    {"id": "reading-time-minimum",
     "task": "Faire en sorte que reading_time() n'affiche jamais 0 minute, comme le demande issue.txt.",
     "allowed": {"reading_time.py", "CHANGES.md"},
     "files": {
        "reading_time.py": 'def reading_time(text):\n    return len(text.split()) // 200\n',
        "test_reading_time.py": 'import unittest\nfrom reading_time import reading_time\n\nclass T(unittest.TestCase):\n'
                                '    def test_short(self):\n        self.assertEqual(reading_time("word " * 50), 1)\n'
                                '    def test_rounds_up(self):\n        self.assertEqual(reading_time("word " * 401), 3)\n',
        "issue.txt": 'Billet 15 : un texte de 50 mots affiche "0 min de lecture".\n'
                     'Attendu : au moins 1 minute, arrondie vers le haut.\n'}},
    {"id": "french-dates",
     "task": "Faire en sorte que format_date() renvoie des dates en français, comme '4 novembre 2026'.",
     "allowed": {"dates.py", "CHANGES.md"},
     "files": {
        "dates.py": 'def format_date(d):\n    return d.strftime("%B %-d, %Y")\n',
        "test_dates.py": 'import unittest, datetime\nfrom dates import format_date\n\nclass T(unittest.TestCase):\n'
                         '    def test_fr(self):\n'
                         '        self.assertEqual(format_date(datetime.date(2026, 11, 4)), "4 novembre 2026")\n',
        "issue.txt": 'Billet 18 : les pages en français affichent des dates en anglais.\n'}},
]

# ---------- L'exécutant ----------
# Un substitut qui rejoue des ébauches fixes : l'exécution est reproductible et ne coûte rien.
# Un vrai exécutant enverrait build_prompt(unit, feedback) à un modèle et lirait le JSON renvoyé.
DRAFTS = {
    "slugify-accents": [
        {"slugify.py": 'import re\n\ndef slugify(text):\n    text = text.lower().replace("é", "e")\n'
                       '    text = re.sub(r"[^a-z0-9]+", "-", text)\n    return text.strip("-")\n',
         "CHANGES.md": "- Les slugs gèrent maintenant les lettres accentuées.\n"},
        {"slugify.py": 'import re, unicodedata\n\ndef slugify(text):\n'
                       '    text = unicodedata.normalize("NFKD", text)\n'
                       '    text = "".join(c for c in text if not unicodedata.combining(c)).lower()\n'
                       '    text = re.sub(r"[^a-z0-9]+", "-", text)\n    return text.strip("-")\n',
         "test_slugify.py": 'import unittest\nfrom slugify import slugify\n\nclass T(unittest.TestCase):\n'
                            '    def test_plain(self):\n        self.assertEqual(slugify("Hello World"), "hello-world")\n',
         "CHANGES.md": "- Les slugs retirent les accents avant d'être construits (source: issue.txt:3)\n"},
        {"slugify.py": 'import re, unicodedata\n\ndef slugify(text):\n'
                       '    text = unicodedata.normalize("NFKD", text)\n'
                       '    text = "".join(c for c in text if not unicodedata.combining(c)).lower()\n'
                       '    text = re.sub(r"[^a-z0-9]+", "-", text)\n    return text.strip("-")\n',
         "CHANGES.md": "- Les slugs retirent les accents avant d'être construits (source: issue.txt:3)\n"}],
    "reading-time-minimum": [
        {"reading_time.py": 'import math\n\ndef reading_time(text):\n'
                            '    return max(1, math.ceil(len(text.split()) / 200))\n',
         "CHANGES.md": "- Le temps de lecture est d'au moins 1 minute, arrondi vers le haut (source: issue.txt:2)\n"}],
    "french-dates": [
        {"dates.py": 'def format_date(d):\n    return d.strftime("%-d %B %Y")\n',
         "CHANGES.md": "- Les dates utilisent les noms de mois en français (source: issue.txt:1)\n"}] * 3,
}

def build_prompt(unit, feedback):
    files = "\n\n".join(f"--- {p}\n{c}" for p, c in unit["files"].items())
    prompt = (f"Tâche : {unit['task']}\nTu peux modifier seulement : {sorted(unit['allowed'])}.\n"
              "Dans CHANGES.md, termine chaque ligne par (source: fichier:ligne).\n"
              f"Renvoie du JSON : {{chemin: nouveau contenu du fichier}}.\n\nFichiers :\n{files}")
    if feedback:
        prompt += "\n\nTa dernière tentative a échoué à ces vérifications. Corrige seulement celles-ci :\n" + "\n".join(feedback)
    return prompt

def worker(unit, attempt, feedback):
    prompt = build_prompt(unit, feedback)   # ce qu'un modèle recevrait
    # Vrai modèle : return json.loads(call_your_model(prompt))
    return DRAFTS[unit["id"]][attempt - 1]

# ---------- Les barrières : chacune renvoie une liste d'échecs (vide = réussite) ----------
def gate_tests(workdir):
    try:
        r = subprocess.run([sys.executable, "-m", "unittest", "-q"], cwd=workdir,
                           capture_output=True, text=True, timeout=60)
    except subprocess.TimeoutExpired:
        return ["tests : toujours en cours après 60 secondes"]
    if r.returncode == 0:
        return []
    lines = [l for l in r.stderr.splitlines() if l.startswith(("FAIL:", "ERROR:", "AssertionError"))]
    return [f"tests : code de sortie {r.returncode} : " + " | ".join(lines[:4])]

def gate_sources(unit, change):
    problems = []
    for claim in change.get("CHANGES.md", "").splitlines():
        if not claim.strip():
            continue
        m = re.search(r"\(source: ([\w.]+):(\d+)\)\s*$", claim)
        if not m:
            problems.append(f"sources : aucune ligne source pour l'affirmation {claim!r}")
            continue
        lines = unit["files"].get(m.group(1), "").splitlines()
        if not 1 <= int(m.group(2)) <= len(lines):
            problems.append(f"sources : {m.group(1)}:{m.group(2)} n'existe pas")
    if "CHANGES.md" not in change:
        problems.append("sources : aucune note de changement")
    return problems

def gate_scope(unit, change):
    outside = sorted(set(change) - unit["allowed"])
    return [f"portée : fichiers modifiés hors du plan : {outside}"] if outside else []

# ---------- La boucle pour une unité ----------
def run_unit(unit):
    feedback = []
    for attempt in range(1, MAX_ATTEMPTS + 1):
        change = worker(unit, attempt, feedback)
        with tempfile.TemporaryDirectory() as tmp:
            for path, text in {**unit["files"], **change}.items():
                pathlib.Path(tmp, path).write_text(text, encoding="utf-8")
            failures = gate_tests(tmp) + gate_sources(unit, change) + gate_scope(unit, change)
        with LOG.open("a", encoding="utf-8") as f:
            f.write(json.dumps({"unit": unit["id"], "attempt": attempt, "failures": failures}) + "\n")
        print(f"[{unit['id']}] tentative {attempt} : " + ("RÉUSSITE" if not failures else "ÉCHEC"))
        for line in failures:
            print("    " + line)
        if not failures:
            return {"status": "réussie", "change": change, "attempts": attempt}
        feedback = failures          # seule cette unité retourne, avec les preuves
    return {"status": "à confier à une personne", "last_failures": feedback, "attempts": MAX_ATTEMPTS}

# ---------- L'exécution : les unités sont indépendantes, un échec reste avec son unité ----------
results = {u["id"]: run_unit(u) for u in UNITS}
units = {u["id"]: u for u in UNITS}
print("\n=== Résumé ===")
for uid, r in results.items():
    print(f"{uid} : {r['status']} après {r['attempts']} tentative(s)")

passed = {uid: r for uid, r in results.items() if r["status"] == "réussie"}
print("\n=== À approuver ===")
for uid, r in passed.items():
    for path, new in r["change"].items():
        old = units[uid]["files"].get(path, "")
        sys.stdout.writelines(difflib.unified_diff(old.splitlines(True), new.splitlines(True),
                                                   f"{uid}/{path}", f"{uid}/{path} (nouveau)"))
answer = input(f"\nAppliquer {len(passed)} unité(s) réussie(s) dans approved/? [o/N] ").strip().lower()
if answer == "o":
    for uid, r in passed.items():
        for path, text in r["change"].items():
            out = pathlib.Path("approved", uid, path)
            out.parent.mkdir(parents=True, exist_ok=True)
            out.write_text(text, encoding="utf-8")
    print("Écrit dans approved/. Les unités à confier à une personne sont indiquées plus haut.")
else:
    print("Rien n'a été écrit.")
```

La barrière des tests utilise `subprocess.run` avec `capture_output=True` et un délai maximal (*timeout*). La documentation de Python décrit `returncode` comme le code de sortie du processus enfant, où 0 signifie habituellement une réussite; quand le délai expire, l’enfant est arrêté et `TimeoutExpired` est levée, ce que la barrière transforme en échec plutôt qu’en plantage. C’est le lanceur de tests, et non l’exécutant, qui décide si les tests ont réussi.

## Que se passe-t-il quand on l’exécute?

Lancez-le et répondez `o` à la question (ou fournissez la réponse avec `printf 'o\n' | python3 gate_loop.py`). Voici la sortie complète :

```text
[slugify-accents] tentative 1 : ÉCHEC
    tests : code de sortie 1 : FAIL: test_accents (test_slugify.T.test_accents) | AssertionError: 'cr-me-br-lee-montreal' != 'creme-brulee-a-montreal'
    sources : aucune ligne source pour l'affirmation '- Les slugs gèrent maintenant les lettres accentuées.'
[slugify-accents] tentative 2 : ÉCHEC
    portée : fichiers modifiés hors du plan : ['test_slugify.py']
[slugify-accents] tentative 3 : RÉUSSITE
[reading-time-minimum] tentative 1 : RÉUSSITE
[french-dates] tentative 1 : ÉCHEC
    tests : code de sortie 1 : FAIL: test_fr (test_dates.T.test_fr) | AssertionError: '4 November 2026' != '4 novembre 2026'
[french-dates] tentative 2 : ÉCHEC
    tests : code de sortie 1 : FAIL: test_fr (test_dates.T.test_fr) | AssertionError: '4 November 2026' != '4 novembre 2026'
[french-dates] tentative 3 : ÉCHEC
    tests : code de sortie 1 : FAIL: test_fr (test_dates.T.test_fr) | AssertionError: '4 November 2026' != '4 novembre 2026'

=== Résumé ===
slugify-accents : réussie après 3 tentative(s)
reading-time-minimum : réussie après 1 tentative(s)
french-dates : à confier à une personne après 3 tentative(s)

=== À approuver ===
--- slugify-accents/slugify.py
+++ slugify-accents/slugify.py (nouveau)
@@ -1,6 +1,7 @@
-import re
+import re, unicodedata
 
 def slugify(text):
-    text = text.lower()
+    text = unicodedata.normalize("NFKD", text)
+    text = "".join(c for c in text if not unicodedata.combining(c)).lower()
     text = re.sub(r"[^a-z0-9]+", "-", text)
     return text.strip("-")
--- slugify-accents/CHANGES.md
+++ slugify-accents/CHANGES.md (nouveau)
@@ -0,0 +1 @@
+- Les slugs retirent les accents avant d'être construits (source: issue.txt:3)
--- reading-time-minimum/reading_time.py
+++ reading-time-minimum/reading_time.py (nouveau)
@@ -1,2 +1,4 @@
+import math
+
 def reading_time(text):
-    return len(text.split()) // 200
+    return max(1, math.ceil(len(text.split()) / 200))
--- reading-time-minimum/CHANGES.md
+++ reading-time-minimum/CHANGES.md (nouveau)
@@ -0,0 +1 @@
+- Le temps de lecture est d'au moins 1 minute, arrondi vers le haut (source: issue.txt:2)

Appliquer 2 unité(s) réussie(s) dans approved/? [o/N] o
Écrit dans approved/. Les unités à confier à une personne sont indiquées plus haut.
```

Lisez-la unité par unité.

**`slugify-accents` a besoin de trois tentatives.** La première ébauche ne remplace que le « é » : le test des accents échoue en affichant exactement la chaîne obtenue et la chaîne attendue, et la note de changement n’a pas de source. Les deux messages retournent à l’exécutant. La deuxième ébauche corrige bien le code, mais supprime aussi le test des accents dans `test_slugify.py`. Les tests réussissent alors, et c’est pourquoi une barrière de tests seule ne suffit pas : la barrière de portée refuse l’ébauche, car le fichier de tests est hors du plan. La troisième ébauche corrige le code, laisse les tests intacts et cite la ligne 3 du billet. Elle réussit.

**`reading-time-minimum` réussit du premier coup.** Elle ne retourne jamais, et les reprises des autres unités ne la touchent pas. C’est tout l’intérêt de renvoyer une seule unité refusée plutôt que de relancer tout le lot.

**`french-dates` ne réussit jamais.** Le substitut renvoie toujours des noms de mois en anglais, le test échoue chaque fois, et après trois tentatives l’unité est marquée « à confier à une personne ». Un vrai modèle peut rester coincé de la même façon, par exemple quand la correction exige une information qu’il n’a pas. Le plafond de tentatives transforme une boucle sans fin en une courte note pour un humain.

**L’étape d’approbation ne montre que ce qui a réussi,** sous forme de différences avec les fichiers d’origine. Rien n’arrive dans `approved/` avant la réponse de la personne. Répondez autre chose que `o` et le script affiche « Rien n’a été écrit. »

> **À essayer :** exécutez le script une fois tel quel, puis modifiez la troisième ébauche de `slugify-accents` pour que sa note de changement cite `issue.txt:9`. Relancez-le et regardez la barrière des sources attraper une citation vers une ligne qui n’existe pas.

## Qu’est-ce que ces barrières ne vérifient pas?

Chaque barrière vérifie une chose précise, et il vaut la peine de dire ce qui passe à travers.

- **Les tests ne vérifient que ce qu’ils testent.** Si les tests sont minces, une mauvaise correction peut passer. C’est pourquoi la barrière de portée protège le fichier de tests et pourquoi une personne lit les différences à la fin. Anthropic note que les tests automatisés aident à vérifier le code, mais qu’une révision humaine reste nécessaire pour s’assurer qu’une solution respecte les exigences plus larges.
- **La barrière des sources vérifie qu’une citation existe, pas qu’elle appuie l’affirmation.** La note `- Les slugs retirent les accents (source: issue.txt:1)` passerait même si la ligne 1 ne parle pas du tout de retirer les accents. Pour aller plus loin, vous pouvez exiger que les mots clés de l’affirmation figurent sur la ligne citée, ou ajouter un deuxième appel à un modèle comme réviseur, ce qui est une vérification plus faible, car un modèle peut se tromper de la même façon que l’exécutant.
- **La barrière de portée vérifie des noms de fichiers, pas des intentions.** Un exécutant peut encore faire un changement nuisible dans un fichier permis.

La boucle diffère aussi de **Self-Refine** (2023), où un même modèle écrit, critique son propre résultat et le révise. Self-Refine a amélioré les résultats sur les tâches que ses auteurs ont testées, mais sa rétroaction est l’opinion du modèle lui-même. Ici, la rétroaction est la sortie du lanceur de tests, une citation manquante ou une liste de fichiers : des preuves que l’exécutant ne peut pas contester.

## Comment remplacer le substitut par un vrai modèle?

Remplacez le corps de `worker()`. La fonction `build_prompt()` assemble déjà ce dont un modèle a besoin : la tâche, les fichiers qu’il peut modifier, la règle pour les notes de changement, les fichiers actuels et, après un échec, les messages d’échec exacts. Après une première tentative ratée sur `slugify-accents`, ce prompt se lit ainsi :

```text
Tâche : Faire en sorte que slugify() remplace les lettres accentuées par des lettres simples, comme le demande issue.txt.
Tu peux modifier seulement : ['CHANGES.md', 'slugify.py'].
Dans CHANGES.md, termine chaque ligne par (source: fichier:ligne).
Renvoie du JSON : {chemin: nouveau contenu du fichier}.

[fichiers actuels ici]

Ta dernière tentative a échoué à ces vérifications. Corrige seulement celles-ci :
tests : code de sortie 1 : FAIL: test_accents (test_slugify.T.test_accents) | AssertionError: 'cr-me-br-lee-montreal' != 'creme-brulee-a-montreal'
sources : aucune ligne source pour l'affirmation '- Les slugs gèrent maintenant les lettres accentuées.'
```

Envoyez ce prompt au modèle de votre choix, local ou hébergé, et renvoyez `json.loads()` de sa réponse. Ajoutez une protection pour les réponses qui ne sont pas du JSON valide : traitez-les comme une tentative ratée avec le message « la réponse n’était pas du JSON valide », pour qu’elles comptent dans le plafond comme tout autre échec. Gardez les barrières, le plafond et l’étape d’approbation exactement tels quels : ce sont eux qui rendent la boucle digne de confiance, et aucun ne dépend du modèle qui écrit les ébauches.

Deux réglages méritent un second regard une fois un vrai modèle en place. Trois tentatives, c’est un point de départ : si la plupart des unités qui réussissent le font au premier ou au deuxième essai, un plafond plus élevé achète surtout des coûts supplémentaires sur des unités qui ne réussiront pas. Et le délai de 60 secondes devrait convenir à votre suite de tests réelle la plus lente, avec une marge, pour qu’un test bloqué fasse échouer la tentative au lieu de figer l’exécution.

## Sources

- Building effective agents: https://www.anthropic.com/engineering/building-effective-agents
- Effective harnesses for long-running agents: https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents
- Harness design for long-running application development: https://www.anthropic.com/research/harness-design-long-running-apps
- Self-Refine: Iterative Refinement with Self-Feedback: https://arxiv.org/abs/2303.17651v1
- subprocess: Subprocess management: https://docs.python.org/3/library/subprocess.html

---

Tiré du Guide de l’intelligence artificielle, par Robert Scales, publié par Holistic Hybrid inc. Partage et adaptation permis selon la licence CC BY-NC-SA 4.0 (https://creativecommons.org/licenses/by-nc-sa/4.0/) : citez le guide, mettez un lien vers https://guidetoartificialintelligence.com/fr/apprendre/creer-boucle-ia-vraie-barriere/, pas d’usage commercial sans permission, et gardez la même licence.
