Die interaktive Terminal-Sitzung

Die interaktive Terminal-Sitzung

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

Bis hierher ist der Agent ein One-Shot-Kommando: ein Prompt rein, eine Antwort raus, fertig. Ein Werkzeug, mit dem man arbeitet, ist etwas anderes. Es hält ein Gespräch, behält den Verlauf, lässt nachfragen und korrigieren. Dieser Artikel macht aus dem Kommando eine interaktive Sitzung. Die eigentliche Schwierigkeit dabei ist nicht die Schleife, sondern die Testbarkeit: Eine Terminal-Oberfläche, die direkt liest und schreibt, lässt sich kaum automatisiert prüfen. Der Schlüssel ist eine saubere Trennung. Der Stand ist eingefroren als Tag v0.9.

Eine schlanke REPL, kein Vollbild

Wir bauen eine zeilenorientierte REPL, keine Vollbild-Oberfläche mit eigenem Bildschirm und Cursor-Steuerung. Das hat Gründe. Eine zeilenorientierte Sitzung kommt ohne externe Abhängigkeit aus, läuft robust über Pipes und in Tests, und der Token-Stream des Modells passt natürlich zu zeilenweiser Ausgabe. Eine Vollbild-TUI wäre eindrucksvoller, aber aufwändig und schwer zu testen. Für ein Werkzeug, das vor allem zuverlässig sein soll, ist die schlanke Variante die richtige.

Render-Logik von I/O trennen

Das Prinzip, das die ganze Oberfläche testbar macht, ist simpel: Was angezeigt wird, und wie es angezeigt wird, sind zwei verschiedene Dinge. Die Render-Logik formt Strings und berührt das Terminal nie. Sie ist eine Sammlung purer Funktionen:

public enum Renderer {
    public static let inputPrompt = "› "

    public static func welcome(model: String) -> String {
        """
        apfel-agent — interactive session (model: \(model))
        Type your request. /help for commands, /exit to quit.
        """
    }

    public static func toolCall(name: String, arguments: String) -> String {
        "→ \(name)(\(arguments))"
    }
}

Weil diese Funktionen nur Strings nehmen und Strings zurückgeben, lassen sie sich prüfen, ohne dass je ein Terminal im Spiel ist. Den Diff und die y/n/a-Bestätigung rendern wir hier bewusst nicht neu, das übernimmt der TerminalGate aus Artikel 5.

Die REPL-Schleife

Die Sitzung selbst besitzt den Loop und den Gesprächsverlauf, aber nicht die Ein- und Ausgabe. Das Lesen einer Zeile, das Schreiben und das Erzeugen einer Antwort sind injizierte Closures. Genau das macht die Sitzung testbar: Im Test treiben wir sie mit gescripteten Eingaben und einem Fake-Backend, im CLI hängen wir das echte Terminal und den Agenten ein.

public struct Session: Sendable {
    public typealias ReadLine = @Sendable () async -> String?
    public typealias Write = @Sendable (String) async -> Void
    public typealias Respond = @Sendable (_ history: [ChatMessage], _ emit: @Sendable (String) async -> Void) async throws -> String

    public func run() async {
        await write(Renderer.welcome(model: model))
        var history: [ChatMessage] = []

        while let line = await readLine() {
            let input = line.trimmingCharacters(in: .whitespacesAndNewlines)
            if input.isEmpty { continue }

            switch input {
            case "/exit":  return
            case "/help":  await write(Renderer.help); continue
            case "/reset": history.removeAll(); await write("(history cleared)"); continue
            default:       break
            }

            history.append(ChatMessage(role: "user", content: input))
            history = context.trim(history)
            do {
                let answer = try await respond(history) { chunk in await write(chunk) }
                history.append(ChatMessage(role: "assistant", content: answer))
                await write("\n")
            } catch {
                await write("Error: \(error)")
            }
        }
    }
}

Streaming sauber darstellen

Die Antwort eines Modells kommt Token für Token. Eine Sitzung, die erst die vollständige Antwort abwartet und dann auf einen Schlag ausgibt, fühlt sich tot an. Deshalb reicht die respond-Closure jeden Chunk sofort über emit nach außen, während sie ihn empfängt, und gibt am Ende den vollständigen Text zurück. Den Stream gibt die Sitzung direkt aus, den Rückgabewert braucht sie nur für den Verlauf:

let answer = try await respond(history) { chunk in await write(chunk) }
history.append(ChatMessage(role: "assistant", content: answer))

Im CLI verdrahtet

Die Sitzung kennt nur ihre drei Closures. Erst im CLI bekommen sie eine Bedeutung: readLine schreibt die Eingabezeile und liest von stdin, write schreibt auf stdout, und respond erzeugt die Antwort eines Turns. Dasselbe Session-Objekt, das im Test gegen ein Fake-Backend lief, spricht hier mit dem echten Modell:

let session = Session(
    model: client.model,
    readLine: {
        FileHandle.standardOutput.write(Data(Renderer.inputPrompt.utf8))
        return Swift.readLine()
    },
    write: { text in FileHandle.standardOutput.write(Data(text.utf8)) },
    respond: { history, emit in try await turn(history, emit) }
)
await session.run()

Was in turn passiert, ist der eigentliche Punkt, und es greift die Lehre aus Artikel 7 auf.

Edits routen, nicht raten

Hier lauert eine Falle. Der naheliegende Bau eines Turns ist ein Tool-Round-Trip aus Artikel 4: Das Modell bekommt alle Werkzeuge, auch write_file und edit_file, und wählt frei. Genau dieses freie Wählen hat Artikel 7 aber als beim Editieren unzuverlässig gemessen. Eine Sitzung, die Editieren anbietet und das Modell wieder frei raten lässt, fiele hinter die eigene Reihe zurück.

Deshalb routet jeder Turn. Ist die Eingabe ein Edit-Auftrag, geht sie durch den constrained EditFlow aus Artikel 7; alles andere, Lesen, Listen, Erklären, ein Kommando ausführen, durch einen Tool-Round-Trip mit nur lesenden Werkzeugen:

let files = currentFiles(in: sandbox.root)
if await classifier.isEditRequest(task, files: files) {
    let result = try await editFlow.run(task: task)   // constrained, Artikel 7
    await emit(result)
    return result
}
// sonst: read_file, list_dir, run_shell — hier ist freies Tool-Calling in Ordnung
let result = try await readOnlyRoundTrip.run(history)

Die Klassifikation ist ein kurzer constrained Aufruf, der entscheidet, ob die Anfrage den Inhalt einer Datei ändert. Sie ist bewusst in einen konkreten Kontext eingefaltet, die Dateiliste und die Anfrage. Eine freistehende Meta-Frage „ist das ein Edit?" würde von Apples Guardrails blockiert, derselbe Nebenbefund wie in Artikel 7. Eingefaltet geht sie durch, und in der Messung trifft sie die Trennung von Edit und Nicht-Edit zuverlässig. So bedient die Oberfläche dieselbe Lehre, die wir uns erarbeitet haben, statt sie zu umgehen.

Schreiben umfasst dabei mehr als Ändern. Verlangt die Anfrage eine neue Datei, gibt es keinen alten Inhalt für einen {old, new}-Edit. Der EditFlow prüft deshalb, ob die Zieldatei existiert. Wenn nicht, generiert er den vollständigen Inhalt über ein constrained {content}-Schema und legt die Datei an, mit Diff gegen leer und demselben Gate. Aus Nutzersicht ist „erstelle eine Datei mit X" auch eine Schreib-Anfrage, und der constrained Pfad deckt beide Fälle ab, das Ändern und das Anlegen.

Diff und Gate in der Sitzung

Sobald der Agent in der Sitzung schreiben oder ausführen will, greift dieselbe Absicherung wie im One-Shot-Modus. Der TerminalGate aus Artikel 5 zeigt den Diff und stellt die Frage [y]es / [n]o / [a]lways inline, mitten im Gespräch. Die Sitzung muss dafür nichts Eigenes tun, sie reicht den TerminalGate an die Werkzeuge weiter, und der Mensch entscheidet an derselben Stelle, an der er ohnehin liest und tippt. Die Bestätigung ist kein Bruch im Ablauf, sondern Teil davon.

Verlauf und Kontext-Budget zwischen Turns

Der Unterschied zur One-Shot-Variante ist der gehaltene Zustand. Jeder Turn hängt die Nutzereingabe und die Antwort an den Verlauf an, und der nächste Turn sieht alles, was vorher war. Damit dieser wachsende Verlauf nicht das 4096-Token-Fenster sprengt, läuft er vor jedem Turn durch den ContextManager aus Artikel 8. Ein Test hält fest, dass der Verlauf über Turns hinweg erhalten bleibt:

@Test("history is kept across turns")
func historyKept() async {
    let rec = Recorder()
    await makeSession(["first", "second", "/exit"], rec).run()
    let second = await rec.histories[1]
    #expect(second.count == 3)
    #expect(second.first?.content == "first")
    #expect(second.last?.content == "second")
}

Beim zweiten Turn enthält der Verlauf drei Nachrichten: die erste Eingabe, die erste Antwort, die zweite Eingabe. Die Sitzung erinnert sich.

Sonderbefehle und Abbruch

Drei Befehle steuern die Sitzung selbst, statt an das Modell zu gehen. /help zeigt die Befehle, /exit beendet die Sitzung, und /reset leert den Verlauf, ohne die Sitzung zu verlassen. Letzteres ist nützlich, wenn das Gespräch in eine Sackgasse gelaufen ist oder das Kontextfenster mit Altlasten gefüllt ist. Ein Test prüft, dass nach /reset der nächste Turn nur noch sich selbst sieht:

@Test("/reset clears the history")
func resetClears() async {
    let rec = Recorder()
    await makeSession(["first", "/reset", "second", "/exit"], rec).run()
    let afterReset = await rec.histories.last
    #expect(afterReset?.count == 1)
    #expect(afterReset?.first?.content == "second")
}

Tests ohne Terminal

Der ganze Aufwand mit den injizierten Closures zahlt sich hier aus. Die Render-Funktionen prüfen wir, indem wir ihre Strings festnageln, ganz ohne Terminal. Die Sitzung prüfen wir, indem wir ein ScriptedInput mit vorgegebenen Zeilen und ein Fake-Backend einhängen, das die empfangene History aufzeichnet und eine feste Antwort liefert. So lassen sich Aussagen treffen, die bei direkter Terminal-Ausgabe kaum prüfbar wären: dass die Begrüßung erscheint, dass /exit ohne Modell-Aufruf beendet, dass eine leere Zeile ignoriert wird, dass der Verlauf hält. Keiner dieser Tests öffnet ein Terminal.

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

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

Die Sitzung ausprobieren

Auf den Tag einsteigen:

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

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

  • Sources/AgentCore/TUI/Renderer.swift — pure Render-Logik
  • Sources/AgentCore/TUI/Session.swift — die REPL-Schleife
  • --repl im CLI

Bauen, testen, eine Sitzung gegen ein laufendes apfel starten (eigener Port, weil Ollama den Standard belegt):

swift build
swift test                        # offline, kein apfel nötig
apfel --serve --port 11509 &
swift run apfel-agent --repl --workdir /tmp/work --base-url http://127.0.0.1:11509

Dann tippen, mit /help die Befehle ansehen, mit /exit beenden. Die Unit-Tests prüfen Render-Logik und Sitzungs-Zustand offline gegen ein Fake-Backend, kein laufendes apfel nötig.

Was bleibt

Die Oberfläche steht. Der Agent ist von einem Kommando zu einer Sitzung geworden, die ein Gespräch hält, streamt und ihre Werkzeuge mit Bestätigung einsetzt, alles lokal. Bisher sind diese Werkzeuge selbst geschrieben: read_file, write_file, run_shell und der Edit-Workflow. Im nächsten Schritt öffnen wir den Agenten für Werkzeuge, die wir nicht selbst bauen, über das Model Context Protocol.


Vorheriger Artikel: Die Agent-Schleife mit Done-Prüfung. Nächster Artikel: Werkzeuge, die der Agent nicht schreibt: MCP. Repo-Tag: v0.9.