1.508 Zeilen, kein einziger Lauf
Ein Build unseres Coding-Agenten schrieb in 25 Minuten 1.508 Zeilen in vierzehn Dateien, rief die Shell genau einmal auf, bevor irgendein Code existierte, und meldete sich fertig. Zwei Module importierten relativ über das Paket hinaus, deshalb ließen sich vier der acht Hauptmodule nicht laden, und der Einstiegspunkt des Programms war tot. Ein einziger Importbefehl hätte das jederzeit gezeigt.
Die Schleife bemerkte davon nichts. Der Agent hatte eine Shell, der Auftrag verlangte eine Meldung, sobald die Tests bestehen, und die Runde endete ohne Fehler. Ein Sprachmodell schreibt den Satz „fertig, alles läuft“ so flüssig wie jeden anderen. Ob er stimmt, muss deshalb etwas anderes als das Modell entscheiden.
Die Regel: geschriebener Code muss gelaufen sein
Die erste Fassung der Regel verlangt mit Absicht wenig. Wer Code schreibt, setzt einen Merker, und wer danach irgendetwas ausführt, löscht ihn. Will eine Runde enden, während der Merker gesetzt ist, schickt das Gerüst sie einmal zurück, damit sie ausführt, was sie geschrieben hat. Die Regel verlangt weder gute noch bestandene Tests, nur dass der Code mindestens einmal gelaufen ist. Beim toten Build hätte das genügt, denn schon der erste Import wäre gescheitert.
Hinzu kam eine Obergrenze für das Schreiben am Stück. Ein anderer Build schrieb zehn Dateien hintereinander, das ganze Produkt samt Tests, bevor eine Zeile lief, und 26 von 39 Schreibvorgängen lagen in Serien von mehr als drei. Seitdem verweigert das Gerüst nach drei geschriebenen Dateien ohne Lauf weitere Schreibzugriffe, bis der Agent etwas ausgeführt hat.
Zwei Löcher, beide gemessen
Diese Regel hatte zwei Löcher, und wir fanden beide erst in den Protokollen der Läufe.
Das erste öffnete jeder Shell-Aufruf, denn auch ls -la löschte den Merker. Zehn nie ausgeführte Dateien galten als geprüft, weil der Agent sie sich angesehen hatte. Seitdem zählt nur ein Befehl, der Code ausführt.
Das zweite ging tiefer. Das Gerüst las das Ergebnis des Laufs nicht, sodass ein roter Lauf den Merker löschte wie ein grüner. Eine Eigenheit der Shell verdeckte den Fehler: In einem der Builds standen alle elf Testaufrufe in der Form pytest -q 2>&1 | tail -N, und ohne pipefail meldet eine solche Zeile den Erfolg von tail, nicht den von pytest. An der einzigen Stelle, an der die Schleife hinsah, ließ sich eine rote Testsuite nicht von einer grünen unterscheiden. Seitdem liest das Gerüst das Ergebnis jedes Laufs und führt Befehlszeilen mit pipefail aus, sodass der Status von pytest die Pipe übersteht.
Als dritte Verschärfung zählt seitdem der letzte Lauf und nicht der beste. Behebt ein Agent einen Fehler, sieht „15 bestanden“, ändert danach noch etwas und erhält „1 fehlgeschlagen“, dann hat er nichts mehr geprüft. Ein roter Lauf nach einem grünen setzt den Merker wieder, und der Hinweis beim Beenden zitiert die rote Ausgabe.
Bei langen Builds wird der Plan zum Zustand
Für einen Build über viele Stunden reicht das nicht. Ein längerer Build erreichte mit einem lokalen Modell in 236 Aufrufen und rund sechs Stunden den dritten von dreizehn Schritten. Dazwischen wurde der Verlauf fünfzehnmal verdichtet, und nach jeder Verdichtung leitete der Agent aus einer Zusammenfassung neu ab, wo er stand, las den Plan noch einmal und wiederholte seine Erkundung. Die Schleife hielt nirgends fest, dass der Agent bei Schritt 3 stand und welcher Befehl diesen Schritt prüft.
Seitdem führt das Gerüst den Plan als Zustand, während der Agent ihn vorher nur als Text las. Jeder Schritt steht als Abschnitt in einer Datei, und darunter stehen als Codeblock die Befehle, die ihn beweisen. So sieht ein solcher Schritt aus:
## Step 3 — Schreibprotokoll (WAL) Jeder Schreibzugriff landet zuerst im Protokoll; nach einem Absturz stellt der Start den letzten Stand daraus wieder her. ```bash python -m unittest tests.test_wal -v python -m kv.cli selftest --crash-recovery ```
Das Gerüst liest daraus die Schritte und ihre Prüfbefehle und hält in einer eigenen Datei fest, welche erledigt sind, wann und bei welchem Commit. Der Agent erhält dafür ein Werkzeug mit drei Aktionen:
milestone(action="status")
aktueller Schritt, sein Umfang, seine Prüfbefehle
milestone(action="done", step=N)
nur angenommen, wenn jeder Prüfbefehl von Schritt N in dieser
Runde nach dem letzten Schreiben grün lief
milestone(action="reset", step=N)
Schritt wieder öffnen, etwa weil ein späterer ihn brach
Die mittlere Aktion schließt einen Schritt ab. Das Gerüst nimmt sie nur an, wenn es in seinem eigenen Protokoll jeden Prüfbefehl grün findet, und zwar nach dem letzten Schreibzugriff. Die Schleife prüft den Abschluss damit als Behauptung nach und muss ihn nicht mehr als Satz glauben. Nach jeder Verdichtung des Verlaufs speist das Gerüst den aktuellen Schritt samt Prüfbefehlen wieder ein, sodass der Agent nicht neu herleiten muss, wo er steht.
Unser schwererer Benchmark enthält dafür eine eigene Aufgabe, einen Schlüssel-Wert-Speicher in fünf Planschritten. Der Agent bestand sie mit dem lokalen Modell im ersten Lauf nach 38 Modellaufrufen und 509 Sekunden; das ist ein einzelner Lauf.
Grenzen
Die Regel prüft, dass etwas lief und was es ergab. Ob die Tests etwas taugen, prüft sie nicht, denn ein Agent, der einen Test schreibt, der immer besteht, erfüllt sie. Die Prüfbefehle eines Schritts schreibt ein Mensch oder der Agent selbst in den Plan, und ein zu schwacher Befehl macht den Schritt billig. Ein Prüfbefehl gilt als gelaufen, wenn er im Protokoll als Teil einer Befehlszeile vorkommt; wer ihn in eine längere Zeile einbettet, die einen Fehler schluckt, etwa mit einem angehängten || true, überlistet den Abgleich. Was die Regel bei einem fremden Projekt ohne Tests wert ist, hängt allein am Plan.