Editieren, das funktioniert: Constrained Output statt Tool-Raten

Editieren, das funktioniert: Constrained Output statt Tool-Raten

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

Das Eval aus Artikel 6 hat einen unbequemen Befund hinterlassen. Schon der kleinste Datei-Edit, eine Funktion umbenennen, scheitert mit dem lokalen Modell im Mehrheitsvotum (0 von 9 über drei Lokal-Edit-Aufgaben; Eigenmessung v0.6). Nicht, weil das Modell den Code nicht kann. Es formuliert den richtigen neuen Code als Text problemlos. Es bekommt ihn nur nicht durch das schreibende Werkzeug. Dieser Artikel baut den Agenten nicht um ein stärkeres Modell, sondern um genau diese Schwäche herum. Am Ende editiert dasselbe Modell zuverlässig, und wir können in Zahlen sagen, wie weit das reicht und wo die Grenze des Modells beginnt. Der Stand ist eingefroren als Tag v0.7.

Warum freies Tool-Calling beim Edit scheitert

Ein naiver Edit verlangt vom Modell vier Dinge gleichzeitig. Es muss das richtige Werkzeug wählen (write_file), die richtigen Schlüssel im Argument-Objekt treffen (path, content), den vollständigen neuen Dateiinhalt fehlerfrei reproduzieren und das Ganze als gültiges JSON escapen. Jedes für sich beherrscht das Modell. In Kombination kippt es.

Eine isolierte Messung zeigt das Muster. Wir geben dem Modell den Dateiinhalt direkt im Prompt, simulieren also das vorausgehende read_file, und verlangen nur noch den einen write_file-Aufruf mit dem neuen Inhalt. Selbst in dieser verkürzten Form gelingt es selten (5 von 30 über sechs Edit-Aufgaben, je fünf Läufe; Eigenmessung v0.7). Mal erfindet das Modell einen Schlüssel, den es nicht gibt, mal liefert es den Code als Fließtext statt als Werkzeug-Aufruf, mal ist das JSON kaputt. Das Werkzeug zu bedienen und gleichzeitig korrekt zu formulieren, ist die eigentliche Hürde, nicht das Formulieren allein.

Warum der iterative Loop nicht hilft

Der naheliegende Reflex ist ein Agent-Loop. Statt eines einzigen Versuchs lässt man das Modell planen, handeln, das Ergebnis beobachten und nachbessern. Wenn ein Tool-Aufruf einen Fehler zurückgibt, sieht das Modell ihn im nächsten Schritt und korrigiert.

Wir haben diesen Loop gebaut, plan, act, observe, mit Rückführung der Tool-Ergebnisse. Er hebt die Edit-Rate nicht. Das Modell korrigiert sich nach dem Fehler-Feedback nicht systematisch, es wiederholt denselben fehlerhaften Aufruf oder verfällt in eine andere, ebenso falsche Form. Mehr Runden heißt nicht mehr Treffer, wenn jede einzelne Runde an derselben Gleichzeitigkeit scheitert. Der Loop bleibt im Repo als dokumentierte Sackgasse. Die Lösung liegt nicht in der Wiederholung des Versuchs, sondern darin, den Versuch leichter zu machen.

Constrained Output als Hebel

Der Serve-Modus von apfel unterstützt response_format mit einem JSON-Schema. Das ändert die Spielregeln. Statt zu hoffen, dass das Modell die richtigen Schlüssel trifft, schreiben wir das Schema vor, und der Server zwingt die Antwort hinein. Erfundene Schlüssel verschwinden, weil sie nicht ins Schema passen. Mit exaktem Kontext im Prompt verschwinden auch die Platzhalter, weil das Schema einen konkreten Wert verlangt.

Im Client ist das ein zusätzlicher Typ und ein zusätzliches Feld am Request:

public struct ResponseFormat: Codable, Sendable, Equatable {
    public let type: String          // immer "json_schema"
    public let jsonSchema: NamedSchema

    public struct NamedSchema: Codable, Sendable, Equatable {
        public let name: String
        public let schema: JSONSchema
    }
}

Damit lässt sich die schwierige Edit-Entscheidung in zwei kleine, erzwungene Schritte zerlegen, von denen jeder einzelne dem Modell leichtfällt. Genau das ist EditFlow.

EditFlow, Stufe 1: welche Datei, welche Änderung

Die erste Stufe klärt, was zu tun ist, ohne den Dateiinhalt anzufassen. Das Modell bekommt die Aufgabe und die echte Liste der Dateien im Arbeitsverzeichnis und füllt drei erzwungene Felder: den Pfad, die Anweisung in eigenen Worten und die Art der Operation.

let prompt = """
Task: \(task)
Files in the working directory: \(listing.joined(separator: ", "))
Name the file (path), restate the change (instruction). Also set operation: \
use insert ONLY when a brand-new standalone line is added (a comment line, an \
import line). Use replace for everything that changes existing code.
"""
guard let json = try await structuredComplete(
        [ChatMessage(role: "user", content: prompt)], Self.pickFormat),
      let pick = try? JSONDecoder().decode(Pick.self, from: Data(json.utf8)) else {
    return "Error: could not determine which file to edit."
}
let resolved = Self.resolvePath(pick.path, in: listing) ?? pick.path

Die Anweisung trifft das Modell verlässlich. Den Pfad nicht immer. Es schmückt ihn gern mit erfundenen Verzeichnissen, /home/example/greet.swift, obwohl die Datei schlicht greet.swift heißt. Diesem Pfad folgen wir nicht blind. Wir kennen die echte Dateiliste und mappen den Modell-Pfad über seinen Basisnamen zurück auf eine reale Datei:

static func resolvePath(_ modelPath: String, in listing: [String]) -> String? {
    if listing.contains(modelPath) { return modelPath }
    let base = (modelPath as NSString).lastPathComponent
    return listing.first { ($0 as NSString).lastPathComponent == base }
}

Der erfundene Verzeichnis-Präfix fällt weg, der Basisname bleibt, und damit landet die Operation auf der richtigen Datei. Diese Auflösung ist reiner, deterministischer Programm-Code und ist mit eigenen Tests abgesichert.

Stufe 2 für Substitutionen: old_string, new_string

Steht die Datei fest, liest das Programm ihren Inhalt und stellt die zweite erzwungene Frage. Für eine Substitution, also das Ersetzen von vorhandenem Text, ist das Schema {old_string, new_string}. Der Prompt reicht den exakten Dateiinhalt als gewöhnlichen Fließtext ein, nicht als Code-Block, weil Apples Sicherheitsfilter Code-Blöcke und nummerierte Zeilen in Chat-Prompts blockieren, schlichten Text dazwischen aber durchlassen.

let prompt = """
The file currently contains these lines:
\(content)
Apply this change: \(arg.instruction). \
Give old_string (the exact text to find in the file) and new_string (the replacement).
"""
guard let json = try await structuredComplete(
        [ChatMessage(role: "user", content: prompt)], Self.editFormat),
      let spec = try? JSONDecoder().decode(EditSpec.self, from: Data(json.utf8)) else {
    return "Error: could not derive a concrete edit for \(arg.path)"
}
guard !spec.oldString.isEmpty, content.contains(spec.oldString) else {
    return "Error: the text to change was not found. Try a more specific instruction."
}
let updated = content.replacingOccurrences(of: spec.oldString, with: spec.newString)

Bevor irgendetwas geschrieben wird, prüft das Programm, dass old_string nicht leer ist und tatsächlich im Dateiinhalt vorkommt. Trifft das nicht zu, wird kein Schreiben versucht, und die Fehlermeldung taugt zugleich als Hinweis für einen erneuten Versuch. Bei Erfolg geht die Änderung durch Diff und Bestätigungs-Gate aus Artikel 5, bevor sie auf der Platte landet.

Für Substitutionen funktioniert das durchgreifend. Eine Funktion umbenennen, eine Zahl ändern, einen String austauschen, alle drei gelingen über je fünf Läufe vollständig (15 von 15; Eigenmessung v0.7). Aus 0 von 9 naiv ist eine verlässliche Operation geworden, ohne dass das Modell ein einziges Mal selbst ein Werkzeug bedient hat. Es hat nur Slots gefüllt.

Die Insertion-Wand und das anker-erhaltende Primitiv

Eine Klasse von Änderungen bleibt zunächst hartnäckig. Eine neue Zeile über einer Funktion einfügen, ein import an den Dateianfang setzen, beides scheitert vollständig (0 von 6; Eigenmessung v0.7). Der Grund liegt im Primitiv, also in der elementaren Editier-Operation selbst, mit der wir bisher arbeiten. Um eine Zeile voranzustellen, müsste das Modell im new_string den Anker-Text wiederholen, also die bestehende Zeile plus die neue davor. Genau das gelingt ihm nicht. Es lässt den Anker fallen und produziert func /// Doku, die Funktion ist zerstört. Das ist kein Fehler im Anwenden der Änderung. Der Apply-Schritt tut exakt, was im {old, new} steht. Das Modell wählt nur das Falsche.

Die Lösung ist ein eigenes, anker-erhaltendes Primitiv. Anker-erhaltend heißt, dass die bestehende Ankerzeile nicht mehr verlorengehen kann, weil das Programm sie behält und vom Modell nur den neuen Text verlangt. Beim Einfügen nennt das Modell also nur einen vorhandenen Anker und den neuen Text, und das Programm setzt den Text als eigene Zeile davor oder danach:

public static func apply(content: String, anchor: String,
                         position: Position, text: String) -> String? {
    let trimmedAnchor = anchor.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmedAnchor.isEmpty else { return nil }

    var lines = content.components(separatedBy: "\n")
    let hadTrailingNewline = content.hasSuffix("\n")
    if hadTrailingNewline, lines.last == "" { lines.removeLast() }

    guard let idx = lines.firstIndex(where: { $0.contains(trimmedAnchor) }) else { return nil }
    let insertAt = position == .before ? idx : idx + 1
    lines.insert(text, at: insertAt)

    return lines.joined(separator: "\n") + (hadTrailingNewline ? "\n" : "")
}

Findet das Programm den Anker nicht, gibt es nil zurück und lehnt die Änderung ab, statt die Datei stillschweigend zu verstümmeln. Welches der beiden Stufe-2-Schemata greift, entscheidet die operation aus Stufe 1:

if pick.operation?.lowercased() == "insert" {
    return try await InsertFileTool(sandbox: sandbox, gate: gate,
                                    structuredComplete: structuredComplete).call(argsData)
}
return try await EditFileTool(sandbox: sandbox, gate: gate,
                              structuredComplete: structuredComplete).call(argsData)

Mit dem anker-erhaltenden Primitiv gelingen dieselben Einfügungen vollständig (10 von 10; Eigenmessung v0.7), ohne dass die sauberen Substitutionen darunter leiden. Dasselbe Prinzip wird hier zum zweiten Mal sichtbar: Wo das Modell „Anker erhalten und etwas hinzufügen" leisten müsste, scheitert es; wo das Programm den Anker erzwingt, gelingt es.

Die Grenze des Modells

Eine Operation widersteht auch diesem Aufbau. Eine Funktionssignatur auf async umstellen, also aus func load() -> Int ein func load() async -> Int machen, gelingt nur in einem von fünf Läufen (Eigenmessung v0.7). Der Grund ist diesmal nicht das Tooling. Das Modell liefert für make load async den Edit old_string: "load", new_string: "async" und benennt damit die Funktion in async um, statt das Schlüsselwort einzufügen. Es verwechselt „die Funktion load asynchron machen" mit „load in async umbenennen". Auch die explizite Anweisung „behalte den Namen load" ändert daran nichts.

Das ist die Decke des Modells selbst, sein Codier-Urteil, und kein Agent-Gerüst hebt sie. Der Beweis steht in der Cloud-Gegenprobe aus Artikel 6: Dasselbe async, das dem lokalen Modell misslingt, gelingt einem Frontier-Modell problemlos (Cloud-Stichprobe, Sonnet 4.6). Damit trennen sich zwei Fehlerquellen sauber. Die Mechanik des Werkzeug-Gebrauchs haben wir mit Constrained Output gelöst. Das Codier-Urteil ist eine Eigenschaft des Modells, die ein größeres Modell hat und ein kleineres nicht. Ein ehrlicher Agent verschweigt diese Grenze nicht, er macht sie sichtbar.

Ein Nebenbefund am Rande, der für die ganze Reihe wichtig ist: Wir haben versucht, die Operation in einer eigenen Frage klassifizieren zu lassen, losgelöst von der Aufgabe. Diese Metafrage wird von Apples Sicherheitsfiltern hart blockiert. Erst eingefaltet in die konkrete Aufgabe der Stufe 1 geht dieselbe Klassifikation durch. Die Guardrails reagieren nicht auf den Inhalt, sondern auf die Form der Anfrage, ein Thema, auf das wir später zurückkommen.

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

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

Den Edit-Workflow ausprobieren

Auf den Tag einsteigen:

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

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

  • Sources/AgentCore/Agent/EditFlow.swift — der zweistufige Workflow, resolvePath
  • Sources/AgentCore/Agent/Insertion.swift — anker-erhaltendes Einfügen
  • Sources/AgentCore/Tools/EditFileTool.swift — constrained replace-Stufe
  • Sources/AgentCore/Tools/InsertFileTool.swift — constrained insert-Stufe
  • Sources/AgentCore/Client/ResponseFormat und complete(…, responseFormat:)
  • --edit im CLI

Bauen, testen, einen Edit laufen lassen (ein laufender apfel-Serve auf einem eigenen Port, weil Ollama den Standard belegt):

swift build
swift test                        # offline, kein apfel nötig
apfel --serve --port 11509 &
printf 'func processItem(_ x: Int) -> Int { x * 2 }\n' > /tmp/work/sample.swift
swift run apfel-agent --edit --workdir /tmp/work \
  --base-url http://127.0.0.1:11509 \
  "In sample.swift, rename the function processItem to handleItem everywhere."

Die Unit-Tests prüfen die deterministischen Teile offline: die Basisnamen-Auflösung und das anker-erhaltende Einfügen. Der --edit-Lauf zeigt den Workflow am echten Modell.

Modell füllt Slots, Programm macht den Rest

Der Agent editiert jetzt zuverlässig, und an keiner Stelle bedient das Modell ein Werkzeug oder liefert einen Pfad, dem das Programm blind folgt. Es füllt erzwungene Slots, das Programm macht den Rest: die Dateiliste, die Pfad-Auflösung, das Anwenden der Änderung, der Diff, das Gate. Constrained Output verwandelt eine Aufgabe, an der die Gleichzeitigkeit das Modell überfordert, in zwei kleine Entscheidungen, die es einzeln beherrscht.

Das ist die Lehre, die über den Edit hinausreicht. Ein lokaler Agent wird nicht dadurch gut, dass man dem Modell mehr zutraut, sondern dadurch, dass man die Arbeit so verteilt, dass das Modell nur noch die Teile übernimmt, die es kann. Was darüber hinausgeht, das Codier-Urteil bei mehrdeutigen Aufgaben, bleibt die Grenze des Modells, und die zu kennen ist Teil des Bauplans, kein Makel. Im nächsten Schritt der Reihe nehmen wir diese Bausteine und setzen sie zu längeren Abläufen zusammen.


Vorheriger Artikel: Der lokale Coding-Agent im Eval. Nächster Artikel: längere Abläufe aus den Edit-Bausteinen (Platzhalter, Link wird mit Publish von Artikel 8 final). Repo-Tag: v0.7.