Zum Inhalt springen

cat posts/agent-im-loop-07-groessere-schleifen.md

Agent im Loop (Teil 7): Größere Schleifen

Das Finale: von der Unit- zur Acceptance-Schleife mit einer echten CLI, wann man den Agenten nicht autonom an die Schleife lässt – und wie sich die Rolle des Menschen zum Spec-Owner und Reviewer verschiebt.

Die Würfel-Engine aus Teil 6 kann inzwischen parsen, keep/drop, explodieren und rerollen. pytest prüft das Verhalten, mypy die Typverträge, Ruff den Code und Hooks sorgen dafür, dass die wichtigsten Checks nicht vergessen werden.

Bisher lief diese Schleife aber fast vollständig innerhalb des Python-Codes. Zum Abschluss zoomen wir eine Ebene heraus: Was passiert, wenn wir nicht mehr roll() aufrufen, sondern das Programm so starten, wie es ein Nutzer tun würde?

Damit wird aus der kleinen Unit-Schleife ein größerer Loop.

Die kleinste Schleife ist nicht die ganze Anwendung

Unsere bisherigen Tests waren absichtlich nah an der Logik:

assert roll(
    "2d6r1",
    SequenceRng([
        (6, 1),
        (6, 4),
        (6, 2),
    ]),
) == 6

Das ist schnell, exakt und bei einem Fehler leicht zu verstehen. Genau deshalb gehören solche Tests weiterhin ins Zentrum der Suite.

Sie beantworten aber nicht jede Frage.

Zum Beispiel:

  • Lässt sich das Programm überhaupt von der Kommandozeile starten?
  • Werden Argumente richtig gelesen?
  • Landet das Ergebnis auf stdout?
  • Landet eine Fehlermeldung auf stderr?
  • Liefert das Programm einen brauchbaren Exit-Code?
  • Funktioniert alles auch dann noch, wenn ein neuer Python-Prozess gestartet wird?

Dafür müssen wir die Grenze verschieben.

Eine kleine CLI

Wir bauen keinen großen Kommandozeilen-Client. Ein dünner Adapter um roll() reicht:

# cli.py
import argparse
import random
import sys

from dice import roll


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="roll")
    parser.add_argument("notation")
    parser.add_argument("--seed", type=int)

    args = parser.parse_args(argv)

    rng = (
        random.Random(args.seed)
        if args.seed is not None
        else None
    )

    try:
        result = roll(args.notation, rng)
    except (ValueError, RuntimeError) as error:
        print(f"Fehler: {error}", file=sys.stderr)
        return 1

    print(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Die CLI hat damit einen kleinen Vertrag:

gültige Notation:
    Ergebnis auf stdout
    Exit-Code 0

fachlich ungültige Notation:
    Fehlermeldung auf stderr
    Exit-Code 1

ungültige CLI-Argumente:
    argparse-Fehler auf stderr
    Exit-Code 2

Der letzte Fall kommt nicht aus unserer Würfel-Engine. argparse selbst behandelt beispielsweise einen nichtnumerischen Wert für --seed als Fehler.

Schon daran sieht man den Unterschied zur bisherigen Schleife: Wir testen nicht mehr nur unsere Würfellogik, sondern das Zusammenspiel mehrerer Teile.

main() testen ist nützlich – aber noch nicht die Prozessgrenze

Wir könnten die CLI weiterhin direkt aus pytest aufrufen:

def test_main_gibt_ergebnis_aus(capsys):
    code = main(["1d6", "--seed", "42"])

    captured = capsys.readouterr()

    assert code == 0
    assert 1 <= int(captured.out) <= 6
    assert captured.err == ""

Das ist ein guter Test. Er prüft Argumentverarbeitung, Ausgabe und unsere Rückgabe aus main().

Aber Python hat dabei keinen neuen Prozess gestartet. sys.argv, der eigentliche Programmeinstieg und die Umwandlung des Rückgabewerts in einen Prozess-Exit-Code werden umgangen.

Für die Acceptance-Schleife gehen wir deshalb noch einen Schritt weiter.

Die CLI wirklich starten

Python bringt mit subprocess.run() alles mit, was wir dafür brauchen.

Ein kleiner Helfer hält die Tests lesbar:

# test_cli.py
import subprocess
import sys


def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        [sys.executable, "-m", "cli", *args],
        capture_output=True,
        text=True,
        timeout=5,
        check=False,
    )

sys.executable startet denselben Python-Interpreter, unter dem auch pytest läuft. Wenn wir die Tests mit

uv run pytest

ausführen, bleibt der subprocess damit in derselben Python-Umgebung.

Der timeout ist ebenfalls Absicht. Seit Teil 5 wissen wir, dass ein Fehler in einer Schleife nicht zwangsläufig rot wird – er kann den Test auch einfach hängen lassen. Auf Prozess-Ebene haben wir jetzt eine äußere Grenze dafür.

Der Happy Path

Der erste Acceptance-Test benutzt das Programm so, wie es später auch ein Nutzer tun würde:

def test_cli_erfolgreicher_wurf():
    result = run_cli(
        "4d6kh3",
        "--seed",
        "42",
    )

    assert result.returncode == 0
    assert result.stderr == ""

    value = int(result.stdout)
    assert 3 <= value <= 18

Der Test kennt die interne Struktur der Engine nicht.

Er weiß nichts von:

Tokenizer
Parser
AST
Evaluator
SequenceRng

Er kennt nur den sichtbaren Vertrag: Der Prozess soll erfolgreich enden und für 4d6kh3 eine mögliche Summe auf stdout schreiben.

Das ist eine andere Perspektive als beim Unit-Test – und genau deshalb lohnt sich beides.

Den Seed nicht zum Golden Value machen

Wir könnten jetzt einmal ausführen:

uv run python -m cli 4d6kh3 --seed 42

und anschließend genau diesen Wert in den Test schreiben.

Das wäre unnötig eng.

Für die exakte Würfellogik haben wir SequenceRng und unsere Unit-Tests. Auf dieser Ebene interessiert uns vor allem, ob --seed seinen versprochenen Zweck erfüllt: denselben Ablauf reproduzierbar zu machen.

Das lässt sich direkt prüfen:

def test_cli_seed_ist_reproduzierbar():
    first = run_cli(
        "4d6kh3",
        "--seed",
        "12345",
    )
    second = run_cli(
        "4d6kh3",
        "--seed",
        "12345",
    )

    assert first.returncode == 0
    assert second.returncode == 0
    assert first.stdout == second.stdout

Wir pinnen damit keinen konkreten Würfelwert fest. Der Test sagt lediglich:

gleiche Version + gleicher Seed + gleiche Eingabe
= gleicher Ablauf

Die genaue Mathematik dahinter gehört weiterhin in die kleinere, präzisere Unit-Schleife.

Auch Fehler gehören zum öffentlichen Vertrag

Unsere CLI behandelt fachliche Fehler selbst.

Eine ungültige Würfelnotation soll deshalb nicht mit einem Python-Traceback beim Nutzer landen:

def test_cli_ungueltige_notation():
    result = run_cli("2x")

    assert result.returncode == 1
    assert result.stdout == ""
    assert "Fehler:" in result.stderr

Damit prüfen wir mehrere Dinge gleichzeitig:

Parser erkennt die ungültige Eingabe
        ↓
roll() meldet einen Fehler
        ↓
CLI fängt ihn ab
        ↓
Meldung landet auf stderr
        ↓
Prozess endet mit 1

Ein Unit-Test des Parsers könnte denselben Fehler wesentlich genauer lokalisieren. Der Acceptance-Test prüft dagegen, ob die komplette Kette am Ende das richtige sichtbare Verhalten ergibt.

CLI-Fehler sind wieder eine andere Schicht

Was passiert bei:

uv run python -m cli 1d6 --seed kartoffel

Hier erreicht die Eingabe unsere Engine gar nicht. argparse scheitert bereits beim Versuch, kartoffel in einen Integer umzuwandeln.

Auch das können wir an der Prozessgrenze festhalten:

def test_cli_ungueltiger_seed():
    result = run_cli(
        "1d6",
        "--seed",
        "kartoffel",
    )

    assert result.returncode == 2
    assert result.stdout == ""
    assert "error:" in result.stderr

Dieser Test schützt keine Würfellogik. Er schützt den Vertrag unserer Anwendung nach außen.

Damit haben wir inzwischen mehrere Schleifen, die jeweils etwas anderes sehen.

Schleifen ineinander

Man kann die Ebenen grob so betrachten:

Unit
  ↓
einzelne Regeln und Funktionen

Komponente
  ↓
Tokenizer + Parser + Evaluator

Integration
  ↓
CLI + Engine + Argumentverarbeitung

Acceptance
  ↓
neuer Prozess + echte Ein-/Ausgabe + Exit-Code

Die Begriffe werden in realen Projekten nicht überall exakt gleich verwendet. Entscheidend ist hier weniger das Etikett als die Grenze des Tests.

Je weiter außen die Schleife sitzt, desto realistischer wird sie – aber meistens auch langsamer und ungenauer bei der Fehlersuche.

Wenn

test_cli_ungueltige_notation

scheitert, wissen wir zunächst nur, dass die Kette nicht mehr funktioniert.

Wenn dagegen

test_tokenize_keep_highest

scheitert, ist die Fehlerstelle ziemlich gut eingegrenzt.

Deshalb ersetzen die größeren Loops die kleinen nicht.

Nicht alles durch die teuerste Schleife schicken

Es wäre möglich, jede Würfelregel nur noch über subprocess zu testen.

Für jeden Fall würden wir dann Python neu starten:

1d6
2d6
4d6kh3
1d6!
2d6r1
...

Das wäre näher an der echten Anwendung, aber trotzdem keine Verbesserung.

Die kleinen Tests sind:

  • schneller,
  • deterministischer steuerbar,
  • präziser bei Fehlern.

Die Prozess-Tests beantworten dafür Fragen, die ein Unit-Test prinzipbedingt nicht sehen kann.

Eine brauchbare Suite hat deshalb typischerweise viele schnelle Tests nahe an der Logik und deutlich weniger Tests an den äußeren Grenzen.

Für den Agenten ist diese Staffelung besonders nützlich. Nach einem kleinen Edit kann er die billige Schleife drehen. Vor dem Abschluss läuft zusätzlich die größere.

Der Agent bekommt jetzt gestuftes Feedback

Aus Teil 6 haben wir bereits schnelle Hooks nach einer Änderung und die vollständige Suite als Stop-Gate.

Mit mehreren Testebenen können wir den Takt weiter unterscheiden:

während der Änderung:
    Ruff
    mypy
    schnelle Unit-Tests

nach einem zusammenhängenden Schritt:
    komplette pytest-Suite

vor dem Abschluss:
    Integration + Acceptance

Nicht jeder Tippfehler muss sofort einen neuen Python-Prozess für jeden Acceptance-Test starten.

Umgekehrt sollte ein Agent nicht allein deshalb „fertig“ melden, weil die Würfellogik isoliert grün ist, während die CLI gar nicht mehr startet.

Der Loop wird größer, aber nicht überall gleichzeitig.

Der Test ist nicht die komplette Spezifikation

Über die Serie hinweg haben wir Tests immer wieder als ausführbare Spezifikation benutzt.

Das bleibt nützlich, braucht aber eine Einschränkung.

Dieser Test:

assert roll(
    "1d6!",
    SequenceRng([
        (6, 6),
        (6, 6),
        (6, 2),
    ]),
) == 14

beschreibt sehr präzise einen Teil der Explosionssemantik.

Er sagt aber nicht automatisch:

  • ob 1d1! erlaubt ist,
  • wie viele Explosionen maximal erlaubt sind,
  • ob 4d6kh3! gültige Syntax sein soll,
  • welche Exception bei einem Fehler erwartet wird.

Deshalb bestand der Prompt in den späteren Teilen aus zwei Dingen:

Prosa:
    Welche Semantik wollen wir?

Tests:
    Welche davon können wir ausführbar prüfen?

Das ist robuster als die Vorstellung, ein paar grüne Tests seien bereits die gesamte Spezifikation.

Wo ein autonomer Loop schlecht funktioniert

Unsere Würfel-Engine eignet sich fast ideal für agentisches Arbeiten:

Eingabe
    ↓
deterministische oder kontrollierbare Regeln
    ↓
klar prüfbares Ergebnis

Nicht jedes Problem sieht so aus.

Schwierig wird ein autonomer Loop vor allem dann, wenn das Feedback selbst schwach, teuer oder missverständlich ist.

Wenn das Orakel unscharf ist

Ob

2d6+3

richtig ausgewertet wurde, lässt sich exakt beantworten.

Ob eine Benutzeroberfläche „schön“ ist oder ein Text „natürlich klingt“, hat kein vergleichbar eindeutiges Orakel. Automatische Checks können trotzdem helfen: Screenshots, Layout-Regeln, Style Checks oder Bewertungen durch weitere Modelle liefern Signale.

Nur sind diese Signale weniger eindeutig.

Dann sollte ein Agent das Ergebnis nicht allein deshalb als fertig betrachten, weil eine Metrik einen Grenzwert erreicht hat.

Wenn ein Fehler teuer ist

Je größer die Folgen eines Fehlers, desto weniger sollte ein einzelner automatischer Loop gleichzeitig Implementierer und Abnahmeinstanz sein.

Das betrifft beispielsweise Code, der:

Daten löscht
Berechtigungen verändert
Produktivsysteme deployed
Geld bewegt
sicherheitsrelevante Entscheidungen umsetzt

Ein Agent kann bei solchen Aufgaben weiterhin Code schreiben, Tests ausführen oder Änderungen vorbereiten.

Die letzte Freigabe braucht aber zusätzliche Grenzen: Reviews, isolierte Testumgebungen, Berechtigungen, Freigabeschritte oder andere unabhängige Kontrollen.

Das Problem ist nicht der Agent. Das Problem wäre eine Schleife, deren einziges Kriterium lautet:

Tests grün -> Produktion

obwohl die Tests nicht stark genug sind, diese Entscheidung allein zu tragen.

Wenn die Anforderung selbst noch offen ist

Ein Agent kann hervorragend dabei helfen, Anforderungen zu erkunden:

Welche Edge Cases gibt es?
Welche Semantiken wären möglich?
Wo widersprechen sich die Anforderungen?

Er kann aber nicht aus einem ungelösten Produktproblem plötzlich eine objektiv richtige Spezifikation machen.

Wenn noch nicht entschieden ist, ob

4d6r2

nur exakte Zweien oder alle Werte bis einschließlich 2 neu würfelt, sollte der Agent nicht anhand seiner eigenen Annahme implementieren und dieselbe Annahme anschließend in Tests festschreiben.

Sonst produziert er gleichzeitig Frage und Antwort – und bestätigt sich danach selbst.

Wenn die Prüfung unverhältnismäßig teuer ist

Manche Änderungen lassen sich nur mit aufwendigen Umgebungen, langsamen externen Systemen oder manueller Kontrolle wirklich beurteilen.

Dann sollte die Lösung nicht darin bestehen, dieselbe teure Prüfung nach jedem Edit auszuführen.

Besser ist eine gestufte Schleife:

billiges lokales Signal
    ↓
größerer Integrationstest
    ↓
teure oder manuelle Abnahme

Damit bleibt der schnelle Loop schnell, ohne so zu tun, als wäre er die letzte Wahrheit.

Der Mensch wird nicht nur Testautor

Zu Beginn der Serie war die Rollenverteilung einfach:

Mensch schreibt roten Test
Agent schreibt grünen Code

Nach sieben Teilen ist sie etwas interessanter.

Spec-Owner

Jemand muss entscheiden, was die Software überhaupt bedeuten soll.

Bei unserer Engine waren das Fragen wie:

Explodiert ein Würfel wiederholt?
Was bedeutet r1 genau?
Ist 4d6kh5 erlaubt?
Wie soll ein ungültiger Ausdruck scheitern?

Der Agent kann Optionen nennen. Die Entscheidung gehört zur Spezifikation.

Testautor und Testreviewer

Der Mensch muss nicht jede Testzeile selbst tippen. Ein Agent kann hervorragend parametrisierte Fälle, Fixtures oder Property-Tests erzeugen.

Aber Tests bestimmen den Maßstab, gegen den der Agent anschließend arbeitet.

Deshalb verdienen Änderungen an ihnen besondere Aufmerksamkeit:

Wurde eine Assertion schwächer?
Ist ein Edge Case verschwunden?
Testet der neue Fall wirklich das behauptete Verhalten?
Hat der Agent seinen eigenen Fehler zur neuen Erwartung erklärt?

Reviewer

Grün ist der Beginn des Reviews, nicht dessen Ersatz.

Der Diff beantwortet Fragen, die kein Test vollständig abdeckt:

Ist die Lösung unnötig kompliziert?
Wurde eine neue Abhängigkeit eingeführt?
Gibt es verdächtige Sonderfälle?
Passt der Code zur Architektur?
Wurde mehr geändert als nötig?

Ein guter Loop verkleinert den Review-Aufwand. Er macht ihn nicht überflüssig.

Gatekeeper

Schließlich entscheidet jemand, welche Schleife für welche Aktion ausreicht.

Ein lokaler Refactor braucht andere Schranken als ein Deployment. Ein Dokumentationsfix braucht andere als eine Migration mit Datenverlustpotenzial.

Diese Grenze ist selbst Teil des Systemdesigns.

Was aus der Würfel-Engine geworden ist

Wir haben mit einem Test begonnen, der lediglich sagte:

1d6 liegt zwischen 1 und 6.

Inzwischen besteht das Beispiel aus deutlich mehr:

Notation:
    NdM
    mehrere Würfelgruppen
    additive und subtraktive Modifikatoren
    keep/drop
    explodierende Würfel
    Reroll

Struktur:
    Tokenizer
    Recursive-Descent-Parser
    AST
    Evaluator
    CLI

Feedback:
    exakte Unit-Tests
    parametrisierte Tests
    Property-Based Tests
    mypy
    Ruff
    Branch Coverage
    Hooks
    Prozess-/Acceptance-Tests

Wichtiger als die Feature-Liste ist allerdings die Reihenfolge, in der sie entstanden ist.

Wir haben den Agenten nie einfach mit

Bau mir eine Würfel-Engine.

losgeschickt.

Stattdessen bekam er immer wieder einen kleinen, beobachtbaren Auftrag. Das Ergebnis floss zurück in die nächste Runde.

Die Schleife ist eine Architekturentscheidung

Ein Coding-Agent ist besonders nützlich, wenn er selbst feststellen kann, ob eine Änderung funktioniert.

Dafür braucht er Feedback, dem wir vertrauen können.

Am Anfang der Serie war das nur:

pytest

Später kamen weitere Signale und größere Grenzen hinzu:

Code ändern
    ↓
Ruff / mypy
    ↓
Unit-Tests
    ↓
Integration
    ↓
Acceptance
    ↓
Diff-Review

Nicht jede Stufe muss nach jeder Änderung laufen. Und keine einzelne Stufe beweist, dass der Code korrekt ist.

Der eigentliche Gewinn entsteht dadurch, dass Fehler möglichst früh in der kleinsten Schleife sichtbar werden, die sie zuverlässig erkennen kann.

Ein Tippfehler soll nicht erst im Acceptance-Test auffallen. Eine kaputte CLI kann ein Unit-Test dagegen niemals sehen. Eine falsche Produktentscheidung lässt sich wiederum nicht dadurch reparieren, dass wir noch mehr pytest laufen lassen.

Das ist die Grenze des Loops – und gleichzeitig sein Nutzen.

Der Agent kann Implementierung sehr schnell durch diese Schleifen treiben. Welche Fragen gestellt werden, welche Antworten als ausreichend gelten und wann ein Ergebnis wirklich fertig ist, bleibt eine menschliche Entscheidung.

Weiterlesen

Sources: pytest – Dokumentation, Python: argparse, Claude Code – Dokumentation.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!