Startseite / Artikel

Harness Engineering: Die Laufzeitumgebung, die einen LLM in einen Agenten verwandelt

Was tatsächlich einen Programmier-Agenten zum Funktionieren bringt: die Schleife, die Tool-Verwaltung, die schlechten Aufrufen standhält, sowie die Funktionen zum Erteilen, Fordern und Verweigern von Berechtigungen – dargestellt anhand von etwa 600 Zeilen reinen Python-Code.

Verlag: REACTAPP.TOP13 Min. Lesezeit2950 Wörter

Quellen: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 1/3)

Wenn ein Kodierungsagent versagt, wird in der Regel das Modell dafür verantwortlich gemacht. Oft liegt der Fehler jedoch im Harness, dem Programm, das um das Modell herumgebaut ist.

Jeder, der bereits eine Chat-Completion-API genutzt hat, kennt den einfachen Teil: Nachrichten senden, eine Antwort erhalten, gegebenenfalls Funktionen aufrufen. Der schwierige Teil ist der Weg von dort bis zu einem Agenten, der in einem Repository untergebracht werden kann, Dateien bearbeiten, Tests ausführen und nach einem Fehler wieder funktionstüchtig werden kann. Der Harness überbrückt genau diesen Abstand. Er ist die Laufzeitumgebung um das Modell herum: Tools, Berechtigungen, Sandboxing, Kontextverarbeitung, Speichermanagement sowie Fehlerbehebung. Alles, was das Modell tut, ist Text einlesen und Text ausgeben. Der Harness hingegen ist das Programm, das diesen Zyklus wiederholt, Tools ausführt, alles sicher hält und mit Ihnen über die Terminal-Schnittstelle kommuniziert. Wenn das Modell der Motor ist, dann ist der Harness das Auto.

Ein Sprachmodell allein kann weder Ihre Dateien öffnen, pytest ausführen, Code bearbeiten, das Gespräch vom vorigen Dienstag abrufen noch verhindern, dass es rm -rf / eintippt. Alles, was es tun kann, ist die nächste Nachricht auszusenden. Alles Weitere erfordert zusätzliche Mechanismen:

  • Loop: Das Modell immer wieder aufrufen, bis es aufhört, nach Tools zu fragen.
  • Tools: Die Mittel zum Handeln (Shell, Dateien, Bearbeitungen).
  • Permissions: Vor riskanten Aktionen fragen und die schlimmsten blockieren.
  • Sandbox: Gefährliche Aktionen auf Betriebssystemebene scheitern lassen.
  • Context: Das Gespräch so klein halten, dass es übertragen werden kann.
  • Memory: Fähigkeiten, eine To-Do-Liste, eine Sitzungsdatei, die wieder aufgenommen werden kann.
  • Subagents: Eine Codebasis untersuchen, ohne den Hauptgesprächsverlauf zu überladen.

Dieser Artikel behandelt die ersten drei Aspekte, die einen Chatbot in einen Agenten verwandeln. Die übrigen Aspekte – Sandboxing, Kontextverwaltung, Speicherung sowie Subagenten – sind weitere Schichten auf derselben Grundstruktur.

Chatbot versus Agent

Ein Chat-Assistent führt nur eine einzige Aktion aus: eine Frage eingeben und eine Antwort erhalten. Tools wie Claude Code oder der Agent-Modus von Cursor funktionieren anders. Wenn man beispielsweise „Refaktorieren Sie diesen Modul“ sagt, lesen sie Dateien, führen Tests durch, ändern den Code, führen die Tests erneut aus und beheben alle Fehler. Der Grund dafür liegt nicht in einem intelligenteren Modell, sondern in einem Programm, das um das Modell herum entwickelt wurde und weiß, wie man Schleifen verwendet, Tools aufruft und bei Fehlern weitermacht.

Menschen haben begonnen, dies als „Harness Engineering“ zu bezeichnen – ein eher prunkvoller Begriff für eine einfache Idee: Claude Code, Cursor und Codex sind nicht nur bessere Modelle, sondern auch bessere „Harnesses“.

Ein Beispiel hilft mehr als Definitionen. Man gibt ein: „Beheben Sie den fehlerhaften Test in test_parser.py.“ Folgendes zeigt sich dann:

Turn 1:  LLM reads the test file         → bash("cat test_parser.py")
Turn 2:  LLM runs the test to see error  → bash("pytest test_parser.py")
Turn 3:  LLM reads the source file       → read_file("parser.py")
Turn 4:  LLM edits the bug               → str_replace("parser.py", old, new)
Turn 5:  LLM re-runs the test            → bash("pytest test_parser.py")
Turn 6:  Tests pass → LLM says "Done! Fixed the off-by-one error in line 42."

Sechs Runden und sechs Entscheidungen – und niemand hat nach der ersten Aufforderung etwas geklickt. Das zeigt, dass das System seine Aufgabe erfüllt.

Wortschatz

Mehrere Begriffe tauchen immer wieder auf.

  • LLM: eine Funktion, die eine Nachrichtenliste in eine weitere Nachricht umwandelt. Es hat kein Gedächtnis für frühere API-Aufrufe. Wenn Sie den Eindruck erwecken möchten, dass es sich erinnert, senden Sie die Historie erneut.
  • Token: eine Texteinheit, die vom Modell gezählt wird; etwa drei Viertel eines englischen Wortes. „Hello world“ umfasst etwa zwei Token, während eine 100 Zeilen lange Python-Datei rund 500 Token hat.
  • Context window: die Anzahl der Tokens, die das Modell gleichzeitig sieht. Werte von etwa 128K für Modelle der GPT-4-Klasse und rund 200K für Claude erscheinen enorm – bis ein Agent 40 Mal die Ausgabe von grep -r in das Chat-Fenster kopiert hat.
  • Toolaufruf: Das Modell führt Ihren Code nicht aus. Es erstellt eine Anfrage wie {"name": "bash", "arguments": {"command": "ls"}}, anschließend führt Ihr Python-Code ls aus und gibt die Ausgabe zurück.
  • Stateless: Jeder call_llm(messages) steht für sich, und die messages-Liste dient als Speicher.
  • Es ist kein Hintergrundwissen in maschinellem Lernen erforderlich. Grundlegende Kenntnisse in Python (Funktionen, Schleifen, Dictionaries, try/except), etwas Erfahrung mit einer API im Stil von OpenAI sowie die gängigsten Shell-Befehle (ls, grep, cat) reichen aus.

    Die Referenzimplementierung

    Das Beispiel-Agent enthält etwa 600 Zeilen Python ohne Framework und verwendet vier Bibliotheken: openai, rich, prompt-toolkit sowie pyyaml. Die untenstehenden Auszüge sind der eigentliche Code, kein Pseudocode – am Ende sollten Sie die gleichen Bestandteile auch in Claude Code oder Cursor wiedererkennen. Um mitzumachen, klonen Sie eine Kopie des Projekts und installieren Sie es im bearbeitbaren Modus:

    git clone https://github.com/shrinidhi-mahishi/coding-harness.git
    cd coding-harness
    pip install -e .
    

    Jeder mit OpenAI kompatible Endpunkt funktioniert: OpenRouter, ein lokaler Server, OpenAI selbst. Legen Sie die Einstellungen in ~/.agents/env ab:

    BASE_URL=https://openrouter.ai/api/v1
    API_KEY=sk-or-...
    MODEL=deepseek/deepseek-v4-flash
    

    Echte Umgebungsvariablen überschreiben diese Datei. Der Befehl coding-harness, der in pyproject.toml als Eingangspunkt zu coding_harness.agent:main deklariert ist, startet eine Terminal-Sitzung. Sie geben Eingaben ein, und die innere Schleife läuft weiter, bis der Agent keine weiteren Tool-Aufrufe mehr vornehmen kann.

    Das gesamte Design setzt sich aus sieben Elementen zusammen: der Agent-Schleife (der „Herzschlag“), den Tools (den „Händen“), den Berechtigungen (wer was tun darf), einer Sandbox (Sicherheit durch den Kernel gewährleistet), dem Kontext-Engineering (ein sauberes Arbeitsfeld), den Fähigkeiten, den To-Do-Listen und Sitzungen (das „Gedächtnis“) sowie Subagenten (der Kontext wird an einem verwertbaren Ort gespeichert). Hier werden nur die ersten drei behandelt.

    Die wichtigen Dateien:

    coding_harness/agent.py         # two nested loops
    coding_harness/llm.py           # one API call + the system prompt
    coding_harness/tools.py         # tool functions + defensive execute()
    coding_harness/permissions.py   # allow / ask / deny
    coding_harness/config.py        # BASE_URL, API_KEY, MODEL
    

    Es gibt weder eine Datenbank noch einen Webserver oder eine Orchestrierungsbibliothek. Das „Framework“ besteht aus zwei while True-Schleifen sowie einem Wörterbuch mit Funktionen für Tools. Bei der ersten Durchsicht sollten Sie in dieser Reihenfolge config.py, llm.py, agent.py, tools.py und permissions.py durchgehen.

    1. Die Agentenschleife

    Claude Code, Cursor und Codex teilen sich alle eine Schleife:

    1. Senden Sie das Gespräch an das Modell.
    2. Falls das Modell nach einem Tool fragt, führen Sie dieses aus.
    3. Fügen Sie das Ergebnis dem Gespräch hinzu und kehren Sie zum Schritt 1 zurück.
    4. Sobald keine weiteren Toolaufrufe mehr möglich sind, ist die Arbeit abgeschlossen und die Antwort wird angezeigt.

    Ein Chatbot führt nur eine einzige Hin- und Rückreise durch, während ein Agent weiterarbeitet, bis die Aufgabe erledigt ist. Das ist der gesamte Unterschied – der größte Teil der verbleibenden Ingenieurarbeit dient dazu, zu verhindern, dass die Schleife ins Stocken gerät.

    Ein Punkt verwirrt Anfänger: Modelle sind zustandslos. Sie erinnern sich nicht an den vorherigen Schritt, weshalb jede API-Aufruf die gesamte Konversation enthält. Die Nachrichten sehen so aus:

    messages = [
        {"role": "system",    "content": "You are a coding agent..."},
        {"role": "user",      "content": "Fix the failing test"},
        {"role": "assistant", "content": "I'll start by reading the test file."},
        {"role": "tool",      "content": "... contents of test file ..."},
    ]
    

    Es gibt vier Rollen. Die Systemrolle enthält dauerhafte Anweisungen, der Benutzer sind Sie, der Assistent ist das Modell und das Tool übernimmt die Ausgabe, die ein Tool zurückgibt. Das Modell nimmt die Liste entgegen und erzeugt den nächsten Eintrag – diese Liste ist seine einzige „Erinnerung“. Es existiert kein versteckter zweiter Speicher; daher ist der Agent fehlerhaft, wenn die Liste falsch ist.

    Zwei Schleifen sind beteiligt. Die innere umfasst einen einzelnen Benutzerzugriff sowie so viele Modellzugriffe, wie nötig sind. Die äußere wartet einfach auf Eingaben.

    Das ist die innere Schleife in vereinfachter Form. Die eigentliche Datei entfernt außerdem alte Tool-Ausgaben, fügt am Ende jeder Anfrage einen neuen Hinweis hinzu, löscht temporäre Dateien und kann die Historie komprimieren.

    while True:
        message, usage = call_llm(messages)
    
        messages.append(message.model_dump(exclude_none=True))
            if message.content:
                ui.agent(message.content)
            # No tool calls? The agent is done thinking.
            if not message.tool_calls:
                break
            # Execute each tool and feed the result back
            for tool_call in message.tool_calls:
                args, result = execute(tool_call)
                ui.tool(tool_call.function.name, args, result)
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result,
                })
    

    Der Code befindet sich in agent.py. call_llm(messages) sendet die gesamte Historie und das Modell gibt Text, Toolaufrufe oder beides zurück. model_dump() wandelt lediglich das API-Objekt in ein Dictionary um, das gespeichert werden kann. Ohne Toolaufrufe bricht der Loop ab. Andernfalls werden diese ausgeführt, angezeigt, die Ergebnisse hinzugefügt und der Prozess wiederholt, sodass das Modell erkennen kann, was passiert ist, und entscheiden kann, ob es fortfahren soll.

    Der äußere Loop ist noch kleiner:

    while True:
        user_input = ui.ask()
        if not user_input:
            break
    
        messages.append({"role": "user", "content": user_input})
            # ... inner agent loop runs here ...
    

    Der Text wird der Liste hinzugefügt, die innere Schleife läuft, bis der Agent fertig ist, danach wartet das Programm erneut. Es gibt keine Zustandsmaschine, nur zwei verschachtelte while True-Anweisungen, die dieselbe Struktur wie bei Claude Code und Cursor aufweisen. Das Modell spricht oder bittet um Tools, die Ergebnisse der Tools fließen zurück in die messages, und der Kreislauf wiederholt sich.

    2. Tools: Hände für das Modell

    Ohne Tools ist die Schleife lediglich ein Chatbot mit zusätzlichen Schritten. Tools sind das Mittel, mit dem das Modell Aufgaben erledigt: Dateien lesen, Befehle ausführen, Code schreiben, in einem Repository suchen.

    Stellen Sie sich vor, Sie bitten eine brillante Person, einen Fehler zu beheben, während sie sich in einem abgeschlossenen Raum ohne Computer befindet. Sie kann denken, aber nichts anfassen. Tools sind die Tür.

    Viele Tutorials übergehen eine Tatsache: Die Tools werden niemals direkt vom Modell ausgeführt. Was das Modell erzeugt, ist eine Anfrage im Sinne von „führe diesen Befehl aus“, und der dazugehörige Python-Code erledigt die Arbeit und sendet das Ergebnis zurück. Das Modell erhält weder eine Shell noch etwas anderes als eine Formularstruktur.

    Ein Tool setzt sich aus vier Elementen zusammen: einem Namen (bash, read_file), einer Beschreibung, die dem Modell mitteilt, wann es verwendet werden soll, einem JSON Schema für die Argumente mit erforderlichen Feldern und Typen sowie einer Python-Funktion, die die eigentliche Arbeit ausführt.

    bash ist sowohl das mächtigste Tool als auch das besorgniserregendste. Hier ist es aus tools.py:

    def bash(command: str) -> str:
        """Run a shell command and return its combined stdout and stderr."""
        try:
            result = sandbox.run(command)
        except subprocess.TimeoutExpired as expired:
            return (
                f"Timed out after {expired.timeout}s and was killed. "
                "Narrow it down - search inside the working directory rather than /."
            )
        return (result.stdout + result.stderr) or "(no output)"
    

    Was das Modell sieht, ist das Schema:

    {
        "type": "function",
        "function": {
            "name": "bash",
            "description": "Run a shell command and return its combined stdout and stderr.",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "The shell command to run",
                    }
                },
                "required": ["command"],
            },
        },
    }
    

    Wenn das Modell eine Verzeichnisliste wünscht, gibt es {"name": "bash", "arguments": {"command": "ls -la src/"}} aus. Der Code führt dies aus, gibt die Ausgabe zurück, und das Modell wählt seinen nächsten Schritt aus.

    Der Agent verfügt über sieben Werkzeuge, von denen jedes aus einer Funktion sowie einem Schema besteht:

    • bash führt einen Shell-Befehl über das Sandbox-Umfeld aus.
    • read_file liest eine Datei ein.
    • write_file erstellt eine Datei oder überschreibt eine bestehende.
    • str_replace vornimmt eine präzise Änderung, indem ein String durch einen anderen ersetzt wird.
    • read_skill lädt ein SKILL.md-Handbuch herunter.
    • write_todos ersetzt die gesamte To-Do-Liste.
    • task startet einen Unteragenten und gibt nur seine endgültige Antwort zurück.

    Die bash-Funktion ist eine einfache Umhüllung: Sie ruft sandbox.run mit einer 60-sekündigen Zeitbeschränkung auf und gibt stdout sowie stderr zurück – oder eine Zeitüberschreitungsmitteilung, die dazu auffordert, innerhalb des Projektverzeichnisses zu suchen und nicht im gesamten /-Verzeichnisstrang.

    Behandeln Sie Toolaufrufe als unzuverlässige Eingaben

    Viele Beiträge zum Thema „Erstellen Sie einen Agenten in 20 Zeilen“ überspringen diesen Teil. Ein Toolaufruf ist Text, den das Modell erstellt hat, wodurch das Bedrohungsmodell dem eines Webforms entspricht. Der Name mag nicht existieren, die Argumente mögen keine gültige JSON-Struktur sein und die Typen könnten falsch sein. All das kann die Sitzung beenden, wenn der Code nicht entsprechend vorbereitet ist.

    Modelle erfinden Dinge – sie erfinden Funktionsnamen und generieren fehlerhafte JSON-Strukturen. Ein Absturz zwingt den Benutzer dazu, von vorn anzufangen. Wenn Sie das Problem erkennen und einen verständlichen Fehler zurückgeben, versucht das Modell in der Regel erneut – und das überraschend gut, sofern Sie ihm den Fehler und nicht den Stacktrace übergeben.

    execute(), ein defensiver Dispatcher, der sich ebenfalls in tools.py befindet, arbeitet nach folgender Reihenfolge:

    1. Das JSON wird analysiert; falls es fehlerhaft ist, wird ein Fehler mit der Meldung zurückgegeben, dass die Argumente kein gültiges JSON sind.
    2. Der Name der Tool wird abgefragt; falls er unbekannt ist, werden die tatsächlichen Tools aufgelistet.
    3. Bei einer Ablehnung, einer Anfrage oder einer Genehmigung wird permissions.check() konsultiert.
    4. Die Python-Funktion wird aufgerufen.
    5. TypeError sowie alle anderen Ausnahmen werden gefangen, und es wird eine Zeichenkette „Error: ...“ zurückgegeben – niemals ein Stacktrace.

    Kurz gesagt: Er leitet einen Tool-Aufruf an eine Funktion weiter, ohne die Sitzung jemals abstürzen zu lassen.

    def execute(tool_call):
        name = tool_call.function.name
    
    # The model might send broken JSON
        try:
            args = json.loads(tool_call.function.arguments)
        except json.JSONDecodeError as broken:
            return {}, f"Error: arguments were not valid JSON ({broken})."
        # The model might hallucinate a tool name
        if name not in TOOLS:
            return args, f"Error: no tool named '{name}'. Available: {', '.join(TOOLS)}."
        # Run through the permission layer, then call the function
        try:
            action, reason = check(name, args)
            if action == "deny":
                return args, f"Blocked by policy: {reason}"
            if action == "ask" and not ui.approve(reason):
                return args, "The user denied this tool call."
            return args, TOOLS[name](**args)
        except TypeError as mismatch:
            return args, f"Error: wrong arguments for {name} ({mismatch})."
        except Exception as failure:
            return args, f"Error: {name} failed - {type(failure).__name__}: {failure}"
    

    Nehmen wir an, das Modell erfindet ein Werkzeug mit dem Namen search_code. Die JSON-Struktur ist in Ordnung, doch der Name befindet sich nicht in TOOLS; daher antwortet der Dispatcher, dass es kein solches Werkzeug gibt, und listet die verfügbaren auf: bash, read_file, write_file, str_replace, read_skill, write_todos und task. Im nächsten Schritt verwendet das Modell bash zusammen mit grep -r „auth“ .. Es kommt zu keinem Absturz – genau das ist der Sinn: Ein fehlerhafter Werkzeugaufruf darf die Sitzung auf keinen Fall zum Abbruch bringen. Fehler sind lediglich weitere Eingaben.

    3. Berechtigungen: Wer darf was tun?

    Wenn bash verfügbar ist, ist der Agent nützlich – doch dadurch kann es auch passieren, dass rm -rf / ausgeführt wird oder Geheimnisse an einen unbekannten Ort gesendet werden. Wenn das Modell alles ausführen kann, kann es auch Dateien löschen, unnötigen Inhalt hochladen oder eine Verbindung zum Server herstellen. Wenn man jede Anweisung manuell bestätigen muss, verbringt man den Nachmittag damit, ls zu genehmigen. Es ist daher ein Mittelweg erforderlich.

    Die Antwort sind drei Ebenen, ähnlich wie eine Verkehrsampel:

    Ebene Bedeutung Beispiele
    allow läuft stumm ls, grep, cat, git status, pytest
    ask pausiert auf Ihre Aktion mkdir, python script.py, npm install
    deny wird blockiert, selbst wenn Sie zustimmen rm, sudo, curl, wget, git push

    Die Regeln sind Glob-Muster, wobei * auf alles passt, und die letzte passende Regel gilt. Aus permissions.py:

    BASH_RULES = {
        "*":                   "ask",     # default: ask for everything
        # read-only: let them through
        "ls*":                 "allow",
        "cat *":               "allow",
        "grep *":              "allow",
        "git status*":         "allow",
        "git diff*":           "allow",
        "pytest*":             "allow",
        # risky: never, even if the user says yes
        "rm *":                "deny",
        "sudo *":              "deny",
        "curl *":              "deny",
        "git push*":           "deny",
    }
    

    Der Eintrag * setzt den Standardwert auf „fragen“. Nur Lese-Only-Befehle werden erlaubt, gefährliche Befehle werden direkt blockiert, und alles andere, wie zum Beispiel python script.py oder npm install, bleibt bei der Anfrage.

    Das Modell wird versuchen, listige Tricks anzuwenden. Ein Befehl wie grep foo | rm -rf / beginnt harmlos, endet aber katastrophal. Deshalb teilt der Prüfer den Befehl anhand von |, ; und && auf, berücksichtigt dabei Anführungszeichen und wendet das strengste Urteil aus jedem Teil an:

    def decide(command):
        """Rate every part of a compound command; the strictest verdict wins."""
        verdicts = []
        for part in split_command(command):
            action = "ask"
            for pattern, rule in BASH_RULES.items():
                if fnmatch(part, pattern):
                    action = rule
            verdicts.append(action)
    
        for strictest in ("deny", "ask"):
                if strictest in verdicts:
                    return strictest
            return "allow"
    

    In der Praxis wird grep foo | rm -rf / abgelehnt, weil rm nicht erlaubt ist. ls && mkdir test wird angefragt, da mkdir nicht in der Erlaubnisliste steht. cat file.py | grep TODO ist erlaubt, weil beide Kommandoteile nur zum Lesen bestimmt sind.

    Rechte und Sandboxing erfüllen unterschiedliche Aufgaben. Rechte betreffen das Benutzererlebnis: Lohnt es sich, den Benutzer dafür zu stören? Sie stellen keine Sicherheit dar, denn ein geschicktes Kommando kann wie ls aussehen, aber etwas anderes sein. Sandboxing hingegen ist eine Sicherheitsmaßnahme, da es feststellt, ob eine Aktion überhaupt physisch zulässig ist. Ein echter Agent benötigt beides.

    Was bleibt übrig

    Eine Schleife, defensive Tools und Rechte bilden bereits die Grundlage für einen funktionsfähigen Programmieragenten. Er kann ein Repository lesen, Tests ausführen, Dateien bearbeiten, vor dem Ausführen unsinniger Aktionen nachfragen und sich von fehlerhaften Aufrufen erholen.

    Es ist noch nicht sicher, denn ein entschlossenes Modell kann immer noch Schaden anrichten. Es ist auch nicht effizient, da das Kontextfenster schnell voll wird. Zudem hat es kein Gedächtnis, wodurch jede Sitzung von vorne beginnen muss. Ein Sandbox-Modell auf Betriebssystemebene, bei dem zerstörerische Aktionen im Kernel abgewiesen werden, sowie eine schichtweise Kontextverwaltung, die lange Sitzungen zusammenhält, sind die logischen nächsten Schritte. Das sind dieselben Grundmechanismen, die Produktionsagenten verwenden – nur ohne das Rätselhafte.