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.
Protocolhä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
PostToolUsezuverlä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.jsonist 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
- Agent im Loop (Teil 5): Wenn der Loop lügt
- Type Hints in der Praxis: mypy, Pyright und Co.
- Ruff: Linter und Formatter
- Mutation Testing: Sind Deine Tests gut?geplant – misst, ob Deine Tests wirklich prüfen.
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!
Anmelden um einen Kommentar zu hinterlassen.