Zum Inhalt springen

cat posts/agent-im-loop-02-erster-gruener-balken.md

Agent im Loop (Teil 2): Der erste grüne Balken

Aus dem Akzeptanzkriterium wird ein Prompt: Wir verallgemeinern auf NdM, schreiben mit einer kontrollierten Zufallsquelle den ersten exakten Test und lassen den Agenten die Schleife selbst drehen.

In Teil 1 stand die Schleife: Ein Test beschreibt das gewünschte Verhalten, Claude Code ändert den Code und pytest liefert das Feedback. Unsere Würfel-Engine konnte allerdings nur 1dM.

Jetzt soll daraus NdM werden. Also nicht mehr nur 1d6, sondern auch 2d6, 3d8 oder 10d10. Und diesmal reicht uns nicht nur ein Range-Test. Wir wollen exakt vorgeben können, welche Würfe die Engine bekommt und welches Ergebnis daraus entstehen muss.

Das Akzeptanzkriterium wird Teil des Prompts

Einem Coding-Agenten kannst Du natürlich ausführlich erklären, wie er NdM implementieren soll. Dann beschreibst Du allerdings schnell schon die Lösung statt des gewünschten Verhaltens.

Test-First dreht das um. Wir schreiben zuerst, was gelten muss, und geben Claude Code anschließend einen knappen Auftrag:

Unterstütze die Würfelnotation NdM und bring die vorhandenen Tests zum Laufen.
Ändere die Tests nicht und implementiere noch keine Modifikatoren oder weitere
Notation. Prüfe das Ergebnis mit uv run pytest.

Die Tests ersetzen den Prompt nicht. Sie nehmen ihm aber einen Teil der Interpretationsarbeit ab: Das beobachtbare Verhalten ist ausführbar formuliert und pytest entscheidet anschließend, ob es erfüllt wird.

Das macht einen Test nicht automatisch zu einer perfekten Spezifikation. Ein Test kann falsch oder unvollständig sein. Genau deshalb gehört weiterhin ein Mensch in den Loop.

Erst rot: von 1dM zu NdM

Aus Teil 1 haben wir noch diese Implementierung:

# dice.py
import random


def roll(notation, rng=None):
    if rng is None:
        rng = random

    sides = int(notation.removeprefix("1d"))
    return rng.randint(1, sides)

Für 1d6 reicht das. Bei 3d6 versucht Python dagegen, "3d6" in einen Integer umzuwandeln.

Also schreiben wir zuerst den neuen Test:

@pytest.mark.parametrize("notation,low,high", [
    ("1d6", 1, 6),
    ("2d6", 2, 12),
    ("3d4", 3, 12),
    ("4d10", 4, 40),
])
def test_summe_liegt_zwischen_min_und_max(notation, low, high):
    for _ in range(1000):
        assert low <= roll(notation) <= high

Für NdM ergeben sich die Grenzen direkt aus der Notation. Der kleinste mögliche Wurf ist count, weil jeder Würfel mindestens eine 1 zeigt. Der größte ist count * sides.

Mit der bisherigen Implementierung wird der Test rot, zum Beispiel bei 2d6:

E   ValueError: invalid literal for int() with base 10: '2d6'

Das ist unser neues Akzeptanzkriterium. Erst jetzt bekommt der Agent seinen Auftrag.

Ein Range-Test reicht wieder nicht

Der neue Test prüft zwar mehr als vorher, aber er hat dieselbe Schwäche wie der erste Range-Test aus Teil 1. Eine Implementierung könnte für 3d4 beispielsweise immer 6 zurückgeben und läge damit trotzdem zuverlässig zwischen 3 und 12.

Wir brauchen also zusätzlich einen Test, bei dem wir die einzelnen Würfe kennen.

Ein geseedetes random.Random wäre reproduzierbar:

random.Random(42)

Für einen exakten Unit-Test ist das trotzdem unnötig undurchsichtig. Ein Test wie

assert roll("3d6", random.Random(42)) == 8

verrät beim Lesen nicht, aus welchen drei Würfen die 8 entstanden ist. Noch problematischer wäre es, den erwarteten Wert einfach einmal auszuführen und aus der Ausgabe zu übernehmen. Dann könnte ein Fehler zum erwarteten Ergebnis werden.

Stattdessen kontrollieren wir die Zufallsquelle selbst.

Der erste exakte Test

Unsere roll-Funktion akzeptiert bereits eine RNG von außen. Im Produktivbetrieb ist das das random-Modul. Im Test darf es ein kleines Testdouble sein:

class SequenceRng:
    """Liefert vorbereitete Würfe in der angegebenen Reihenfolge."""

    def __init__(self, sides, values):
        self._sides = sides
        self._values = list(values)

    def randint(self, start, end):
        assert (start, end) == (1, self._sides)
        return self._values.pop(0)

Damit wird aus Zufall eine kontrollierte Folge:

def test_addiert_alle_wuerfel():
    rng = SequenceRng(6, [4, 1, 6])

    assert roll("3d6", rng) == 11

Der Test lässt sich ohne Wissen über Pseudozufallszahlengeneratoren lesen: Drei W6 liefern 4, 1 und 6. Das Ergebnis muss 11 sein.

SequenceRng kontrolliert außerdem, mit welchen Grenzen randint() aufgerufen wird. Wenn die Implementierung versehentlich randint(0, 6) verwendet, scheitert der Test sofort. Werden mehr als drei Zufallswerte angefordert, läuft die vorbereitete Liste leer und der Test wird ebenfalls rot.

Das ist deutlich präziser als tausend echte Zufallswürfe.

Natürlich beweist auch dieser Test nicht mathematisch, dass jede denkbare Implementierung korrekt ist. Ein Agent könnte theoretisch genau diesen einen Fall hart codieren. Gute Tests machen falsche Implementierungen schwieriger, nicht unmöglich. Deshalb werden wir das Netz im Verlauf der Serie weiter verdichten.

Jetzt darf der Agent implementieren

Claude Code bekommt die roten Tests und den knappen Auftrag von oben. Eine passende Implementierung ist klein:

# dice.py
import random


def roll(notation, rng=None):
    if rng is None:
        rng = random

    count_str, _, sides_str = notation.partition("d")
    count = int(count_str)
    sides = int(sides_str)

    return sum(rng.randint(1, sides) for _ in range(count))

partition("d") zerlegt beispielsweise "3d6" in "3", "d" und "6". Daraus werden count = 3 und sides = 6, anschließend wird dreimal gewürfelt und summiert.

1d6 ist dabei kein Sonderfall mehr. count ist einfach 1, und die bestehenden Tests aus Teil 1 bleiben grün.

Der Agent kann jetzt selbst prüfen:

uv run pytest

Sind noch Tests rot, hat er direkt verwertbares Feedback und kann die Implementierung korrigieren. Erst wenn die Suite grün ist, schauen wir uns den Diff an.

Wer besitzt den Loop?

Der Agent darf Code schreiben und Tests ausführen. Das heißt nicht, dass er die Kontrolle über die Spezifikation bekommt.

Für diese Serie gilt deshalb eine einfache Trennung:

  • Wir schreiben oder genehmigen die Tests.
  • Claude Code ändert den Produktivcode.
  • pytest entscheidet, ob die getesteten Erwartungen erfüllt sind.
  • Wir prüfen anschließend den Diff.

Besonders wichtig ist die Anweisung, vorhandene Tests nicht einfach passend zu machen. Wenn der Agent einen roten Test dadurch „repariert“, dass er dessen Erwartung ändert, ist der Balken zwar grün, aber die Feedback-Schleife wertlos.

Der Test ist also keine Leine, die den Agenten magisch korrekt hält. Er ist ein messbarer Teil des Vertrags.

Seed oder kontrollierte RNG?

Beide Varianten lösen unterschiedliche Probleme.

Ein Seed ist praktisch, wenn ein zufälliger Ablauf reproduzierbar werden soll:

rng = random.Random(42)

Mit demselben Seed lässt sich dieselbe Pseudozufallsfolge erneut erzeugen. Das ist hilfreich bei reproduzierbaren Fehlern oder bei Tests, die bewusst größere zufällige Abläufe erzeugen.

Für einen kleinen Unit-Test wollen wir meistens etwas anderes: Wir möchten exakt festlegen, welche Werte eine Abhängigkeit liefert. Dafür ist SequenceRng besser geeignet. Der erwartete Wert lässt sich direkt aus [4, 1, 6] herleiten.

Die Unterscheidung wird später noch wichtiger. Dasselbe Prinzip funktioniert nämlich auch bei Uhrzeit, Netzwerkzugriffen oder Dateisystem-I/O: Externe Einflüsse werden kontrollierbar, wenn der getestete Code sie nicht heimlich selbst erzeugt.

Mehr dazu steht im Begleitartikel Determinismus testengeplant.

Stolperfallen

  • Grün heißt nicht automatisch korrekt. Tests prüfen nur die Fälle und Eigenschaften, die wir tatsächlich formuliert haben.
  • Golden Values nicht blind übernehmen. Ein Wert wird nicht dadurch richtig, dass die aktuelle Implementierung ihn einmal ausgegeben hat.
  • Zufall gezielt kontrollieren. Für exakte Unit-Tests ist eine vorgegebene Sequenz meist verständlicher als ein Seed.
  • Tests nicht vom Agenten passend machen lassen. Wenn sich während der Implementierung herausstellt, dass ein Test falsch ist, ändern wir bewusst die Spezifikation – nicht heimlich den Maßstab.
  • partition("d") ist noch kein Parser. Bei 2d6+3, 2d6+1d4 oder ungültiger Eingabe reicht diese Lösung nicht mehr. Das ist Absicht.

Der Loop wird enger

Nach Teil 1 konnte der Agent einen einzelnen Würfel werfen. Jetzt verarbeitet die Engine beliebig viele Würfel derselben Sorte, und wir können ihre Berechnung mit einer kontrollierten RNG exakt prüfen.

Wichtiger ist aber, was sich am Entwicklungsprozess geändert hat: Der Agent bekommt nicht einfach eine Beschreibung und liefert irgendwann Code zurück. Er bekommt rote Tests, arbeitet gegen ein messbares Ziel und führt die Prüfung selbst aus. Danach bleibt der Diff beim Menschen.

In Teil 3 wird die Notation unangenehmer: 3d6+2, mehrere Würfelgruppen und die ersten Fälle, bei denen ein simples partition("d") nicht mehr reicht. Dann verschiebt sich das Problem vom Implementieren zum interessanteren Teil: Welche Edge Cases müssen wir überhaupt aufschreiben, bevor der Agent sie übersehen kann?

Weiterlesen

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

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!