Die Agent-Schleife mit Done-Prüfung

Die Agent-Schleife mit Done-Prüfung

Artikel 8 · Serie: Ein lokaler Coding-Agent mit apfel

Bisher führt der Agent einzelne Schritte aus: ein Tool-Round-Trip, ein constrained Edit. Ein echter Coding-Agent macht mehr, er arbeitet auf ein Ziel hin, über mehrere Schritte, bis es erreicht ist. Genau dafür braucht es eine Schleife. Der naheliegende Bau dieser Schleife enthält allerdings einen Fehler, den wir in Artikel 6 schon gemessen haben. Dieser Artikel baut die Schleife so, dass sie ihn vermeidet, und macht die zentrale Folgerung aus dem Eval zu Code: Fertig ist eine Prüfung, keine Behauptung. Der Stand ist eingefroren als Tag v0.8.

Warum der naive Loop das Problem nur verschiebt

Die klassische Agent-Schleife heißt plan, act, observe. Das Modell schlägt einen Tool-Aufruf vor, wir führen ihn aus, geben das Ergebnis zurück, und das wiederholt sich, bis das Modell keinen weiteren Tool-Aufruf mehr macht. Genau dieser Abbruch ist das Problem. „Kein Tool-Aufruf mehr" heißt nichts anderes als: das Modell hält sich für fertig.

Artikel 6 hat gemessen, was diese Selbsteinschätzung wert ist. Sie ist eine Behauptung, kein Beweis, und ihre Verlässlichkeit sinkt mit wachsender Modellgröße eher, als dass sie steigt. Ein Loop, der auf das Schweigen des Modells endet, misst dieselbe Selbsteinschätzung erneut und nennt sie Abschluss. Wir behalten diese naive Variante im Repo als Kontrast, bauen den eigentlichen Loop aber anders.

Antrieb und Abbruch trennen

Die Lösung ist eine saubere Trennung von zwei Rollen, die der naive Loop vermischt. Das Modell ist der Antrieb: Es schlägt den nächsten Schritt vor, einen Tool-Aufruf oder eine Textantwort. Aber der Abbruch gehört nicht dem Modell, sondern dem Programm. Es prüft nach jedem Schritt gegen ein explizites Ziel, ob die Aufgabe erfüllt ist.

Diese Prüfung kapseln wir hinter einem schmalen Protokoll. Sie liefert ein deterministisches Urteil und eine Ausgabe, die als Feedback taugt:

public struct VerifyResult: Sendable, Equatable {
    public let passed: Bool
    public let output: String
}

public protocol Verifier: Sendable {
    func verify() async -> VerifyResult
}

Das Ziel als Verify-Kommando

Wie sieht ein „explizites, maschinell prüfbares Ziel" konkret aus? Am ehrlichsten als das, dem ein Entwickler ohnehin vertraut: ein Kommando, das entweder durchläuft oder nicht. Der ShellVerifier führt ein Shell-Kommando aus, Exit-Code 0 bedeutet erfüllt, alles andere bedeutet noch nicht, mit der Ausgabe als Feedback:

public struct ShellVerifier: Verifier {
    let command: String
    let workdir: URL

    public func verify() async -> VerifyResult {
        // ... Process mit /bin/zsh -c command im workdir ...
        let outData = stdout.fileHandleForReading.readDataToEndOfFile()
        let errData = stderr.fileHandleForReading.readDataToEndOfFile()
        process.waitUntilExit()
        // ...
        return VerifyResult(passed: process.terminationStatus == 0, output: combined)
    }
}

Auf der Kommandozeile wird das Ziel damit zum Verify-Kommando. Der Auftrag steht im Prompt, das Erfolgskriterium in --until:

apfel-agent --until "swift test" "Mach den fehlschlagenden Test in CalculatorTests grün."

Der Loop läuft, bis swift test mit Exit 0 durchläuft. Das Modell entscheidet die Schritte, aber ob das Ziel erreicht ist, entscheidet der Compiler und die Test-Suite, nicht das Modell.

Der gescheiterte Verify ist Feedback, kein Abbruch

Ein nicht bestandener Verify beendet den Loop nicht. Seine Ausgabe geht als nächste Nachricht zurück ins Gespräch, damit das Modell darauf reagiert, statt im Dunkeln weiterzuraten. Genau das macht den Kern der Schleife aus:

public func run(_ task: String) async throws -> Result {
    var conversation = [ChatMessage(role: "user", content: task)]

    for iteration in 1...maxIterations {
        let response = try await complete(conversation, nil)
        guard let choice = response.choices.first else {
            return Result(outcome: .exhausted(iterations: iteration), finalContent: nil)
        }

        // Antrieb: den vom Modell vorgeschlagenen Schritt ausführen.
        if let calls = choice.message.toolCalls, !calls.isEmpty {
            conversation.append(ChatMessage(assistantToolCalls: calls))
            for call in calls {
                conversation.append(ChatMessage(toolCallID: call.id, content: await result(for: call)))
            }
        } else if let content = choice.message.content {
            conversation.append(ChatMessage(role: "assistant", content: content))
        }

        // Abbruch: die maschinelle Prüfung, nicht das Schweigen des Modells.
        let verdict = await verifier.verify()
        if verdict.passed {
            return Result(outcome: .done(iterations: iteration), finalContent: choice.message.content)
        }

        // Gescheiterter Verify ist Feedback.
        conversation.append(ChatMessage(
            role: "user",
            content: "Not done yet. The check still fails:\n\(verdict.output)\nKeep going."
        ))
        conversation = context.trim(conversation)
    }

    return Result(outcome: .exhausted(iterations: maxIterations), finalContent: nil)
}

Der Unterschied zum naiven Loop steht in einer einzigen Zeile: Wir prüfen verifier.verify(), nicht, ob das Modell noch Tool-Aufrufe macht. Ein Test hält das fest. Das Modell antwortet mit reinem Text, ohne Tool-Aufruf, was der naive Loop sofort als fertig werten würde. Der Verifier besteht erst beim zweiten Mal, also muss die Schleife eine zweite Runde drehen:

@Test("the model's silence is not 'done' — only the verifier decides")
func silenceIsNotDone() async throws {
    let counter = PassCounter(passAt: 2)
    let loop = makeLoop(Scripted([textResponse("I think I'm finished")]),
                        verifier: ClosureVerifier { VerifyResult(passed: await counter.tick(), output: "still red") })
    let result = try await loop.run("do it")
    #expect(result.outcome == .done(iterations: 2))
}

Zwei Schutzgrenzen

Eine Schleife, die nicht auf das Modell hört, braucht eigene Grenzen, sonst läuft sie ewig oder sprengt das Kontextfenster. Wir ziehen zwei ein.

Die erste ist ein Iterationslimit. Erfüllt der Verify nie, endet der Loop nach einer festen Zahl von Runden mit exhausted statt in einer Endlosschleife. Das ist kein Randfall, sondern der ehrliche Ausgang, wenn das Modell die Aufgabe nicht lösen kann.

Die zweite ist ein Kontext-Budget. Das Foundation Model arbeitet mit 4096 Token (Artikel 6). Ein mehrschrittiger Loop sammelt mit jeder Runde Tool-Ergebnisse an, bis das Fenster überläuft und das Modell den ursprünglichen Auftrag aus dem Blick verliert. Der ContextManager kürzt den Verlauf, bevor das passiert. Er behält den Auftrag und die jüngsten Runden, wirft die ältesten mittleren Nachrichten weg und lässt dabei keine verwaiste Tool-Antwort zurück:

public func trim(_ messages: [ChatMessage]) -> [ChatMessage] {
    guard messages.count > 2, Self.estimate(messages) > maxTokens else { return messages }

    var kept = messages
    while kept.count > 2, Self.estimate(kept) > maxTokens {
        kept.remove(at: 1)
    }
    // Eine tool-Antwort ohne ihren Aufruf wäre ein verwaister Rest.
    while kept.count > 1, kept[1].role == "tool" {
        kept.remove(at: 1)
    }
    return kept
}

Die Token-Schätzung ist bewusst grob, rund vier Zeichen pro Token plus etwas Aufschlag pro Nachricht. Das Ziel ist ein Budget, das den Überlauf verhindert, keine exakte Zählung.

Den Loop beobachtbar machen

Eine Schleife, die selbständig mehrere Schritte macht, muss man sehen können. Der Agent meldet auf stderr, was er tut, der finale Text geht auf stdout. Am Ende steht der Ausgang als eine Zeile, die das Wesen des Loops zusammenfasst:

→ done after 1 iteration(s): verify passed

oder, wenn das Modell scheitert:

→ exhausted after 8 iteration(s): verify still failing

Beide Ausgänge sind Fakten. Der erste ist ein bestandener Verify, der zweite eine erschöpfte Schranke. Keiner ist eine Behauptung des Modells.

Ein Durchlauf am Beispiel

Ein echter Lauf gegen apfel, gegen ein Arbeitsverzeichnis mit einer Datei. Das Ziel ist, das Wort DONE in die Datei zu schreiben, das Verify-Kommando prüft genau das:

apfel-agent --workdir /tmp/work \
  --until "grep -q DONE report.txt" \
  "Write the word DONE into the file report.txt. Use the write_file tool."

Das Modell ruft write_file, der Diff wird am Bestätigungs-Gate aus Artikel 5 gezeigt, nach dem Schreiben läuft grep -q DONE report.txt durch:

Write to report.txt?
- placeholder
+ DONE

→ done after 1 iteration(s): verify passed

Der Loop endete, weil eine Maschine das Ziel als erreicht bestätigt hat, nicht weil das Modell behauptet hätte, fertig zu sein (Eigenmessung v0.8, apfel 1.5.1).

Was der Loop nicht rettet

Die Schleife verschiebt die Grenze nicht, die Artikel 7 gezogen hat. Sie gibt dem Modell mehr Versuche und ein deterministisches Abbruchkriterium, aber sie verbessert nicht das Codier-Urteil des Modells. Wenn die Aufgabe einen Edit verlangt, den das kleine Modell nicht produzieren kann, etwa das mehrdeutige Umstellen auf async, dann erfüllt kein noch so geduldiger Loop den Verify, und er endet ehrlich mit exhausted. Das ist kein Mangel des Loops, sondern seine Aufrichtigkeit: Er behauptet keinen Erfolg, den es nicht gibt.

Demo-Repo: apfel-coding-agent v0.8

Der Stand dieses Artikels ist eingefroren als Tag v0.8: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.8

Den Goal-Loop ausprobieren

Auf den Tag einsteigen:

git clone https://codeberg.org/rotecodefraktion/apfel-coding-agent.git
cd apfel-coding-agent
git checkout v0.8

Neu in v0.8 gegenüber v0.7:

  • Sources/AgentCore/Agent/GoalLoop.swift — der zielgetriebene Loop
  • Sources/AgentCore/Agent/Verifier.swift — die maschinelle Done-Prüfung
  • Sources/AgentCore/Agent/ContextManager.swift — das Kontext-Budget
  • --until im CLI
  • docs/adr/004-done-ist-eine-pruefung.md

Bauen, testen, einen Lauf gegen ein laufendes apfel (eigener Port, weil Ollama den Standard belegt):

swift build
swift test                        # offline, kein apfel nötig
apfel --serve --port 11509 &
mkdir -p /tmp/work && printf 'placeholder\n' > /tmp/work/report.txt
swift run apfel-agent --workdir /tmp/work \
  --base-url http://127.0.0.1:11509 \
  --until "grep -q DONE report.txt" \
  "Write the word DONE into the file report.txt. Use the write_file tool."

Die Unit-Tests prüfen den Loop offline gegen ein gescriptetes Fake-Backend: dass das Schweigen des Modells nicht als fertig gilt, dass das Iterationslimit greift, dass der Verlauf korrekt getrimmt wird.

Was bleibt

Der Agent arbeitet jetzt über mehrere Schritte auf ein Ziel hin und weiß deterministisch, wann er fertig ist. Das Gerüst steht, der Antrieb kommt vom Modell, der Abbruch von einer Prüfung, die Schranken halten ihn im Rahmen. Was fehlt, ist die Oberfläche, die das erlebbar macht. Bisher ist der Agent ein One-Shot-Kommando; im nächsten Schritt wird daraus eine interaktive Sitzung, in der Streaming, Diffs und Bestätigungen im Terminal zusammenkommen.


Vorheriger Artikel: Editieren, das funktioniert: Constrained Output statt Tool-Raten. Nächster Artikel: Die interaktive Terminal-Sitzung. Repo-Tag: v0.8.