Zum Inhalt springen

cat posts/agent-im-loop-06-den-loop-haerten.md

Agent im Loop (Teil 6): Den Loop härten

Mehr Signale in die Schleife: Type Hints mit mypy, Ruff, Coverage und Claude-Code-Hooks. Schnelle Checks laufen nach Änderungen, die volle Suite vor dem Stopp – nebenbei kommt der Reroll dazu.

Nach Teil 5 wissen wir, dass ein grüner Test nur eine bestimmte Frage beantwortet: Besteht der Code genau diese Tests?

Bessere Tests sind eine Möglichkeit, die Schleife belastbarer zu machen. Eine andere ist, ihr mehr Signale zu geben. Ein Type Checker entdeckt andere Fehler als pytest, ein Linter wiederum andere, und Coverage zeigt Stellen, die unsere Tests überhaupt nicht erreichen.

Der Agent bekommt dadurch nicht bloß ein einziges Rot oder Grün. Er bekommt mehrere Möglichkeiten, seine eigene Änderung zu prüfen.

Nebenbei erweitern wir unsere Würfelsprache um r1: 4d6r1 würfelt jede geworfene 1 genau einmal neu.

Erst die Werkzeuge

mypy, Ruff und pytest-cov kommen als Entwicklungs-Abhängigkeiten ins Projekt:

uv add --dev mypy ruff pytest-cov

Danach stehen uns vier unterschiedliche Prüfungen zur Verfügung:

uv run pytest
uv run mypy dice.py
uv run ruff check .
uv run ruff format --check .

Später automatisieren wir sie. Erst einmal lohnt sich aber ein Blick darauf, was jedes Werkzeug überhaupt beiträgt.

Type Hints: nicht die konkrete RNG typisieren

Bisher konnte unsere öffentliche Funktion sowohl einen echten Zufallszahlengenerator als auch das SequenceRng aus den Tests bekommen.

Zur Erinnerung: Für roll() ist völlig egal, welche Klasse dahintersteckt. Die Funktion braucht nur etwas mit dieser Operation:

rng.randint(1, sides)

Eine Annotation wie

def roll(
    notation: str,
    rng: random.Random | None = None,
) -> int:

wäre deshalb unnötig eng. SequenceRng ist kein random.Random, obwohl es genau die Methode anbietet, die wir benötigen.

Das lässt sich mit einem Protocol ausdrücken:

from typing import Protocol


class Rng(Protocol):
    def randint(self, start: int, end: int) -> int: ...

Rng beschreibt kein konkretes Objekt. Es beschreibt eine Schnittstelle: Alles, was eine kompatible randint()-Methode besitzt, kann dort verwendet werden.

Damit werden die Signaturen unserer Engine präziser, ohne die Testbarkeit zu verschlechtern:

Node = Num | Dice | BinOp


def _evaluate(node: Node, rng: Rng) -> int:
    if isinstance(node, Num):
        return node.value

    if isinstance(node, BinOp):
        left = _evaluate(node.left, rng)
        right = _evaluate(node.right, rng)

        if node.op == "+":
            return left + right

        return left - right

    rolls = [
        _roll_die(
            rng,
            node.sides,
            node.explode,
            node.reroll,
        )
        for _ in range(node.count)
    ]

    selected = _apply_selector(rolls, node.selector)
    return sum(selected)


def roll(notation: str, rng: Rng | None = None) -> int:
    if rng is None:
        rng = random.Random()

    tokens = tokenize(notation)
    tree = Parser(tokens).parse()

    return _evaluate(tree, rng)

Ein echtes random.Random() passt zum Protocol. Unser SequenceRng ebenfalls. Die Produktivimplementierung und das Testdouble müssen dafür weder voneinander erben noch voneinander wissen.

Das ist statisches Duck Typing: Entscheidend ist nicht, was das Objekt ist, sondern welche Operationen es anbietet.

Was mypy dabei tatsächlich prüft

Jetzt kann mypy Inkonsistenzen finden, bevor der betreffende Code überhaupt ausgeführt wird.

Zum Beispiel:

def _roll_die(
    rng: Rng,
    sides: int,
    explode: bool,
    reroll: int | None,
) -> int: ...

Ein Aufruf wie

_roll_die(rng, None, False, None)

passt nicht zur Signatur. Dasselbe gilt, wenn eine Funktion laut Annotation einen int zurückgeben soll, auf einem Pfad aber None liefert.

mypy beweist damit keine korrekte Würfellogik. Es weiß nicht, dass 4d6kh3 vier Würfel werfen soll. Es kann aber prüfen, ob die Schnittstellen, die wir definiert haben, zusammenpassen.

uv run mypy dice.py

Tests prüfen Verhalten zur Laufzeit. mypy prüft einen Teil der strukturellen Verträge, ohne die Anwendung auszuführen.

Mehr dazu steht in Type Hints in der Praxis.

Reroll: das nächste kleine Feature

Mit den zusätzlichen Checks bauen wir wieder eine kleine Erweiterung ein.

Die gewünschte Semantik lautet:

4d6r1

würfelt vier W6. Zeigt einer davon beim ersten Wurf eine 1, wird dieser eine Wurf genau einmal wiederholt. Das zweite Ergebnis gilt, auch wenn es erneut eine 1 ist.

Aus

1 -> 4
2

wird bei 2d6r1 also:

4 + 2 = 6

Wir erweitern zunächst die Grammatik aus Teil 5:

dice     := [number] "d" number [modifier]
modifier := selector | "!" | reroll
selector := ("kh" | "kl" | "dh" | "dl") number
reroll   := "r" number

Der Tokenizer muss dafür r erkennen:

TOKEN_RE = re.compile(r"kh|kl|dh|dl|d|r|!|[+-]|\d+")

Auch der AST bekommt das neue Merkmal:

class Dice:
    def __init__(
        self,
        count: int,
        sides: int,
        selector: tuple[str, int] | None = None,
        explode: bool = False,
        reroll: int | None = None,
    ) -> None:
        self.count = count
        self.sides = sides
        self.selector = selector
        self.explode = explode
        self.reroll = reroll

Wie in Teil 5 erlauben wir pro Würfelgruppe weiterhin nur einen solchen Modifier. 4d6r1 ist definiert. Was Kombinationen wie 4d6r1kh3 bedeuten sollen, ist noch nicht Teil unserer Sprache.

Rot zuerst

Das SequenceRng aus Teil 3 und 4 kontrolliert weiterhin sowohl Würfelgröße als auch konkretes Ergebnis:

@pytest.mark.parametrize(
    "notation,wuerfe,erwartet",
    [
        (
            "2d6r1",
            [(6, 1), (6, 4), (6, 2)],
            6,
        ),
        (
            "2d6r1",
            [(6, 3), (6, 5)],
            8,
        ),
        (
            "1d6r1",
            [(6, 1), (6, 1)],
            1,
        ),
    ],
    ids=[
        "eins-wird-neu-gewuerfelt",
        "kein-reroll",
        "nur-einmal-rerollen",
    ],
)
def test_reroll(notation, wuerfe, erwartet):
    rng = SequenceRng(wuerfe)

    assert roll(notation, rng) == erwartet

    rng.assert_exhausted()

Der dritte Test ist der interessante. Ohne ihn könnte der Agent r1 versehentlich so implementieren, dass jede weitere 1 ebenfalls neu geworfen wird. Das wäre eher eine Art Explosion als unser gewünschter einmaliger Reroll.

Wie schon in Teil 5 entscheidet also wieder die Auswahl des Testfalls darüber, welche Semantik tatsächlich festgenagelt wird.

Parser und Evaluator erweitern

Im Parser kommt neben Explosion und Selektor ein weiterer möglicher Modifier hinzu:

def _dice(self, count: int) -> Dice:
    self._expect("d")
    sides = self._number("Seitenzahl nach 'd' erwartet")

    selector = None
    explode = False
    reroll = None

    if self._peek() == "!":
        self._next()
        explode = True

    elif self._peek() == "r":
        self._next()
        reroll = self._number("Wert nach 'r' erwartet")

    elif self._peek() in ("kh", "kl", "dh", "dl"):
        kind = self._next()
        amount = self._number(f"Anzahl nach Selektor {kind!r} erwartet")
        selector = (kind, amount)

    return Dice(
        count,
        sides,
        selector=selector,
        explode=explode,
        reroll=reroll,
    )

Die eigentliche Reroll-Logik gehört wieder dorthin, wo ein einzelner Würfel ausgewertet wird:

def _roll_die(
    rng: Rng,
    sides: int,
    explode: bool,
    reroll: int | None,
) -> int:
    if explode and sides == 1:
        raise ValueError("Ein explodierender W1 terminiert nicht")

    value = rng.randint(1, sides)

    if reroll is not None and value == reroll:
        value = rng.randint(1, sides)

    if not explode:
        return value

    total = value
    explosions = 0

    while value == sides:
        if explosions >= MAX_EXPLOSIONS:
            raise RuntimeError("Explosionsgrenze erreicht")

        value = rng.randint(1, sides)
        total += value
        explosions += 1

    return total

Für die aktuell erlaubte Grammatik können explode und reroll nicht gleichzeitig gesetzt sein. Die Funktion bekommt trotzdem beide Informationen aus dem AST und bleibt damit der eine Ort, an dem ein einzelner Würfel ausgewertet wird.

Nach der Änderung laufen mindestens zwei Prüfungen:

uv run pytest
uv run mypy dice.py

Ein Fehler kann jetzt also aus unterschiedlichen Richtungen sichtbar werden: Der Code kann fachlich den falschen Wert liefern oder strukturell nicht mehr zu seinen Typverträgen passen.

Ruff: Fehler finden, bevor pytest startet

Ruff ergänzt eine weitere Klasse von Feedback:

uv run ruff check .

Schon mit seinem normalen Regelsatz findet Ruff unter anderem typische Python-Fehler wie unbenutzte Imports oder undefinierte Namen. Welche zusätzlichen Regeln gelten, lässt sich im pyproject.toml konfigurieren.

Der Formatter lässt sich ebenfalls prüfen:

uv run ruff format --check .

Damit verändert der Check noch nichts. Er meldet lediglich, wenn eine Datei nicht dem erwarteten Format entspricht.

Das ist für die Agentenschleife angenehm: Beide Befehle sind schnell genug, dass man sie sehr häufig ausführen kann.

Mehr zu den Regeln und zur Konfiguration steht in Ruff: Linter und Formatter.

Coverage: Wo waren die Tests überhaupt?

Tests können grün sein und trotzdem große Teile des Codes nie berühren.

Mit pytest-cov sehen wir zunächst die Statement Coverage:

uv run pytest --cov=dice --cov-report=term-missing

term-missing zeigt neben der Quote auch die Zeilen, die während des Testlaufs nicht ausgeführt wurden.

Für unsere Engine ist Branch Coverage noch interessanter:

uv run pytest \
    --cov=dice \
    --cov-branch \
    --cov-report=term-missing

Statement Coverage beantwortet grob:

Wurde diese Anweisung ausgeführt?

Branch Coverage schaut zusätzlich auf Verzweigungen. Bei

if kind == "kh":
    ...
elif kind == "kl":
    ...

kann dieselbe Codezeile zwar grundsätzlich erreicht worden sein, ohne dass jeder mögliche Pfad genommen wurde. Branch Coverage macht solche teilweise getesteten Verzweigungen besser sichtbar.

Auch das ist noch kein Qualitätsbeweis.

Diese Funktion kann vollständige Coverage haben:

def add(a, b):
    return a - b

wenn der Test sie lediglich aufruft und nichts Sinnvolles über das Ergebnis behauptet.

Coverage verrät also, wo Tests Code ausgeführt haben. Sie sagt nicht, ob die Assertions gut waren.

Deshalb ist sie eine Landkarte, keine Zielzahl.

Vier Signale, vier unterschiedliche Fragen

Wir haben inzwischen vier Prüfungen:

pytest      -> Tut der Code in den getesteten Fällen das Erwartete?
mypy        -> Passen die statischen Typverträge zusammen?
ruff        -> Verstößt der Code gegen gewählte Regeln oder enthält typische Fehler?
coverage    -> Welche Statements und Branches wurden beim Testlauf erreicht?

Keine davon ersetzt die andere.

Besonders deutlich wird das bei einer Zeile wie:

return left - right

Sie kann perfekt typisiert, sauber formatiert, von Ruff akzeptiert und vollständig durch Tests ausgeführt worden sein. Wenn dort fachlich ein + stehen müsste und kein Test den Unterschied sichtbar macht, bleiben alle Signale grün.

Der Loop ist jetzt besser instrumentiert. Unfehlbar ist er nicht.

Hooks: Feedback ohne Erinnerung

Bislang muss entweder der Mensch oder der Agent daran denken, die Checks auszuführen.

Claude Code kann solche Prüfungen über Hooks automatisch an bestimmte Ereignisse hängen. Für projektweite Hooks eignet sich:

.claude/settings.json

Ein PostToolUse-Hook mit dem Matcher Edit|Write läuft, nachdem Claude Code eine Datei mit einem dieser beiden Tools geändert hat.

Für die schnellen Prüfungen legen wir zunächst ein kleines Script an:

#!/usr/bin/env sh
# .claude/hooks/check-fast.sh

if ! uv run ruff check . >&2; then
    exit 2
fi

if ! uv run ruff format --check . >&2; then
    exit 2
fi

if ! uv run mypy dice.py >&2; then
    exit 2
fi

Unter Linux und macOS muss es ausführbar sein:

chmod +x .claude/hooks/check-fast.sh

Dann kommt der Hook in .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-fast.sh"
          }
        ]
      }
    ]
  }
}

Nach jedem erfolgreichen Edit oder Write laufen damit Ruff und mypy.

Der explizite Exitcode 2 im Script ist dabei nicht nur Unix-Dekoration. Bei einem PostToolUse-Hook kann die Änderung selbst nicht mehr verhindert werden – sie ist bereits passiert. Exitcode 2 sorgt aber dafür, dass Claude Code den Fehler aus stderr als Feedback bekommt und darauf reagieren kann.

Der Hook rollt eine falsche Änderung also nicht zurück. Er macht sie unmittelbar sichtbar.

Warum nicht nach jedem Edit die komplette Suite?

Man könnte auch einfach Folgendes in den Hook schreiben:

uv run pytest

Bei zehn schnellen Unit-Tests ist das völlig in Ordnung. Bei einer größeren Suite wird daraus irgendwann:

Edit
Tests
Edit
Tests
Edit
Tests

Dann wird aus der engen Feedback-Schleife eine Warteschleife.

Schnelle Checks eignen sich gut für jeden kleinen Schritt. Teurere Prüfungen brauchen einen anderen Takt.

Claude Code bietet dafür mehrere Möglichkeiten. Längere Hooks können beispielsweise asynchron laufen, sodass der Agent weiterarbeitet, während die Tests laufen. Das hat aber einen Preis: Das Ergebnis kommt später zurück, und mehrere Änderungen können mehrere parallele Testläufe starten.

Für unser kleines Projekt wählen wir deshalb einen einfacheren Weg: Die komplette Suite wird zum Gate, bevor der Agent seine Arbeit beendet.

Die volle Prüfung vor dem Stopp

Dafür legen wir ein zweites Script an:

#!/usr/bin/env sh
# .claude/hooks/check-all.sh

if ! uv run ruff check . >&2; then
    exit 2
fi

if ! uv run ruff format --check . >&2; then
    exit 2
fi

if ! uv run mypy dice.py >&2; then
    exit 2
fi

if ! uv run pytest >&2; then
    exit 2
fi

Auch dieses Script braucht das Executable-Bit:

chmod +x .claude/hooks/check-all.sh

Den bestehenden Settings fügen wir einen Stop-Hook hinzu:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-fast.sh"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-all.sh"
          }
        ]
      }
    ]
  }
}

Der Unterschied ist wichtig.

PostToolUse sagt:

Du hast gerade etwas geändert. Prüfe sofort die billigen Signale.

Stop sagt:

Bevor Du behauptest, fertig zu sein, muss die gesamte Prüfung durchlaufen.

Schlägt der Stop-Hook mit Exitcode 2 fehl, verhindert Claude Code den Stopp und gibt das Problem an den Agenten zurück. Er bekommt also noch eine Gelegenheit, den Fehler zu beheben, statt eine vermeintlich fertige Änderung abzuliefern.

Das ist näher an dem Loop, den wir eigentlich haben wollen: Nicht „denk bitte daran, am Ende die Tests zu starten“, sondern eine technische Schranke.

Ein Haken bei Edit|Write

Der Matcher reagiert auf die Claude-Code-Tools Edit und Write. Ändert Claude eine Datei indirekt über einen Shell-Befehl, ist das kein Edit- oder Write-Aufruf.

Zum Beispiel:

python irgendein-script.py

könnte Dateien verändern, ohne dass unser PostToolUse-Matcher davon etwas merkt.

Gerade deshalb ist das abschließende Stop-Gate nützlich. Es prüft den Zustand des Projekts unabhängig davon, wie die Dateien verändert wurden.

Für spezielle Fälle bietet Claude Code außerdem einen FileChanged-Hook mit Filesystem-Watcher. Für unsere kleine Engine wäre das mehr Mechanik als Nutzen.

Coverage gehört nicht in jeden Edit-Hook

Coverage fehlt absichtlich in check-fast.sh.

Nicht weil sie unwichtig wäre, sondern weil sie eine andere Aufgabe hat. Ruff und mypy können nach praktisch jedem Edit hilfreiches Feedback liefern. Eine Coverage-Messung ist erst interessant, wenn Tests laufen und wir wissen wollen, welche Pfade diese Suite erreicht.

Deshalb bleibt sie ein gezielter Analyse-Befehl:

uv run pytest \
    --cov=dice \
    --cov-branch \
    --cov-report=term-missing

Man könnte zusätzlich eine Mindestquote mit --cov-fail-under erzwingen. Für diese Serie tun wir das bewusst nicht.

Eine Zielzahl verführt schnell dazu, Coverage zu maximieren, statt bessere Tests zu schreiben. Eine ungetestete Verzweigung ist ein guter Anlass hinzuschauen. Eine beliebige Prozentzahl ist noch keine Spezifikation.

Auch die Checks selbst brauchen Grenzen

Mehr Automatik bedeutet auch mehr Möglichkeiten, sich selbst im Weg zu stehen.

Ein Hook, der bei jeder winzigen Änderung eine lange Suite startet, macht den Loop träge. Ein Formatter, der automatisch Dateien umschreibt, kann weitere Änderungen erzeugen. Ein fehlerhafter Stop-Hook kann den Agenten in einer Schleife halten, wenn seine Bedingung niemals erfüllbar ist.

Hooks sind deshalb keine magische Sicherheitsstufe. Sie sind ausführbarer Projekt-Workflow.

Genau wie beim Produktivcode lohnt sich die Frage:

Welches Signal brauche ich?
Wann brauche ich es?
Wie teuer ist es?
Was passiert bei einem Fehler?

Für unsere Engine lautet die Antwort momentan:

nach Edit/Write:
    Ruff
    Format-Check
    mypy

vor dem Stopp:
    Ruff
    Format-Check
    mypy
    pytest

gezielt:
    pytest + Branch Coverage

Das ist klein genug, um schnell zu bleiben, und streng genug, dass der Agent einen offensichtlich kaputten Zwischenstand nicht einfach als fertig abgeben kann.

Stolperfallen

  • Eine konkrete Klasse ist oft der falsche Typ für eine injizierte Abhängigkeit. Protocol hält die Schnittstelle klein und lässt Testdoubles weiterhin zu.
  • mypy prüft Typen, keine Würfelregeln. Vollständig typisierter Code kann fachlich komplett falsch sein.
  • Ruff ersetzt keine Tests. Ein sauberer, formatierter Bug bleibt ein Bug.
  • Coverage misst Ausführung, nicht Qualität der Assertion. Branch Coverage findet zusätzliche Lücken, beweist aber ebenfalls keine Korrektheit.
  • Ein PostToolUse-Hook kann eine bereits ausgeführte Änderung nicht zurückrollen. Er liefert Feedback danach.
  • Bei Command-Hooks ist der Exitcode relevant. Soll ein Fehler bei PostToolUse zuverlässig an Claude zurückgemeldet werden, muss das Hook-Script ihn entsprechend signalisieren.
  • Langsame Checks gehören nicht zwangsläufig hinter jedes Edit. Nutze schnelle Signale häufig und teure Prüfungen an sinnvollen Gates.
  • Projekt-Hooks führen Shell-Befehle mit Deinen Benutzerrechten aus. Eine eingecheckte .claude/settings.json ist deshalb ausführbare Projektkonfiguration und sollte genauso bewusst reviewed werden wie andere Automatisierung.

Der Loop hat jetzt mehrere Sensoren

Unsere Würfel-Engine kann inzwischen NdM, Modifikatoren, keep/drop, Explosionen und einen einmaligen Reroll. Für die Serie ist aber etwas anderes wichtiger: Die Feedback-Schleife besteht nicht mehr nur aus pytest.

Typen prüfen Schnittstellen. Ruff prüft statisch eine weitere Fehlerklasse. Tests prüfen Verhalten. Coverage zeigt blinde Flecken. Hooks sorgen dafür, dass die Checks nicht bloß gute Vorsätze bleiben.

Keines dieser Signale kann allein sagen:

Der Code ist richtig.

Zusammen machen sie falsche Änderungen aber schneller sichtbar und geben dem Agenten konkreteres Feedback darüber, was nicht stimmt.

In Teil 7 verlassen wir die enge Unit-Schleife. Die Würfel-Engine bekommt eine echte CLI, und damit kommen Prozesse, Ein- und Ausgabe und Integrationstests ins Spiel. Dann stellt sich auch die andere Seite der Agentenfrage: Welche Teile eines Systems sollten wir überhaupt automatisch in den Loop geben – und wo ist eine bewusst gesetzte Grenze die bessere Lösung?

Weiterlesen

Sources: Claude Code: Hooks, mypy: Protocols and structural subtyping, Ruff: The Ruff Linter, pytest-cov, coverage.py: Branch coverage.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!