Dauerhaft laufende Agenten müssen Events laufend beobachten und darauf reagieren, und zwar aus weit mehr Quellen, als in ihr Kontextfenster passen. Salix, das verteilte Harness von Comma, gibt Agenten die Möglichkeit, winzige eBPF-Programme in den Cluster zu laden und sie dauerhaft, rund um die Uhr, ausführen zu lassen: Sie überwachen Events, rufen Jev für schnelle, intelligente Entscheidungen auf und wecken die Hauptschleife des Agenten, sobald etwas Interessantes passiert.
Die innere Schleife reicht nicht
Die bekannte Agent-Schleife ist einfach: Kontext an ein Modell schicken, die zurückgegebenen Tool-Calls ausführen, die Ergebnisse anhängen, wiederholen. Diese Schleife ist gut darin, Arbeit zu erledigen. Schlecht ist sie darin, auf Arbeit zu warten.
Bittet man einen Agenten, „sag mir Bescheid, wenn der Vertrag unterschrieben zurückkommt“ oder „beobachte dieses Repo und melde alles, was Billing berührt“, hat die innere Schleife zwei schlechte Optionen. Sie kann pollen: alle paar Minuten aufwachen, den Posteingang neu lesen und einen vollen Modellaufruf darauf verwenden, festzustellen, dass nichts passiert ist. Oder sie gibt die Aufgabe an einen Heartbeat oder Cron-Zeitplan ab, was nichts anderes ist als Polling mit längerem Intervall und schlechterer Latenz. So oder so läuft der teure Teil des Systems, ein großes Modell, das einen großen Kontext liest, bei jedem Tick, und fast jeder Tick ist ein ruhiger.
Was ein dauerhaft laufender Agent tatsächlich braucht, ist eine äußere Schleife: etwas, das am Event-Stream sitzt, die günstige Filterung übernimmt und nur dann ans Modell eskaliert, wenn es etwas gibt, worüber sich nachzudenken lohnt. Und weil jeder Agent andere Dinge auf andere Weise beobachtet, kann diese äußere Schleife kein festes Produkt-Feature sein. Der Agent muss sie selbst schreiben.
In Salix heißt diese äußere Schleife Loop.
Ein Loop ist ein kleines C-Programm
Ein Loop ist eine einzelne C-Datei, vom Agenten geschrieben, nach eBPF kompiliert und im Cluster neben dem Agenten ausgeführt, dem er gehört. Jeder Loop hat dieselbe Form:
Er wartet auf einen Timer oder ein Event, prüft etwas und weckt, selten, den Agenten. Der Agent bekommt den exakten SDK-Header und einen Programmierleitfaden vom Tool loop.sdk, schreibt das Programm, kompiliert es mit loop.build und startet es mit loop.create. Es gibt kein Template und keine DSL. Wenn der Agent die Überwachung in C beschreiben kann, kann er sie auch ausführen.
Warum C und eBPF und nicht etwa ein Python-Skript in einem Container? Wegen der Zahlen, um die herum wir Salix entworfen haben. Salix betreibt Millionen von Agenten mit mehr als 100 Agenten pro CPU-Kern. Ein Agent kann keine Sandbox haben, die ständig läuft, also können es auch seine Überwachungen nicht. Ein Loop muss günstig genug sein, um für immer weiterzulaufen, für jeden Agenten, auf einem geteilten, mandantenfähigen Node, und dabei Code ausführen, den kein Mensch geprüft hat.
eBPF passt ungewöhnlich gut zu diesen Anforderungen:
Es ist klein. Ein wartender Loop belegt einige zehn Kilobyte Resident Memory. Es gibt keinen Prozess, keinen Interpreter, keinen Heap.
Es führt nicht vertrauenswürdigen Code sicher aus. Das Target hat keine Syscalls, keine Funktionszeiger und keinen unbegrenzten Stack. Ein Programm kann nur über seinem eigenen Speicher rechnen und die Host-Funktionen aufrufen, die wir ihm geben.
Warten ist günstig. Ein Loop, der in
sf_event_nextodersf_sleep_msblockiert, kostet nichts außer seinem Speicher.
Der Preis ist ein eingeschränkter Dialekt, und der SDK-Leitfaden sagt das ohne Umschweife: nur Integer-C (kein Floating Point, keine vorzeichenbehaftete Division), 4 KiB große Stack-Frames, höchstens 8 Frames tief, keine Rekursion, kein sprintf und höchstens 128 aktive Handles. Modelle kommen damit gut zurecht. Sie können bereits C, und die Einschränkungen sind von der Art, die eine Compiler-Fehlermeldung gut erklärt.
Spinfoam: die Runtime unter jedem Loop
Loops laufen auf spinfoam, unserer eBPF-Loop-Runtime. Spinfoam baut auf async-ebpf auf, einer eBPF-Runtime im Userspace, die async-freundlich und vollständig präemptiv ist und im Kern formal verifizierte Speichersicherheit bietet.
Userspace-eBPF, asynchron gemacht
Spinfoam ist kein Kernel-eBPF. Es ist ein gewöhnlicher Rust-Prozess, einer pro Salix-Node, der weder Kernel-eBPF-Unterstützung noch Privilegien braucht und unter Linux und macOS gleich läuft. Salix spricht über JSON-RPC auf stdin und stdout mit ihm. Die Aufgabenteilung ist strikt: spinfoam verantwortet die lokale Ausführung, die Isolation zwischen Programmen, das Abbrechen und die begrenzte Nachrichtenzustellung. Salix verantwortet alles, was einen Absturz überstehen muss: Platzierung, dauerhaften Zustand, Restart-Policy, Credentials und das Netzwerk.
Das „async“ in async-ebpf sorgt dafür, dass ein Loop wie gewöhnliches sequenzielles C aussieht. Jedes geladene Programm bekommt eine langlebige Invocation, die auf ihrer eigenen Coroutine läuft. Ruft das Programm sf_sleep_ms, sf_event_next oder sf_host_call auf, suspendiert der Helper die Coroutine und übergibt das Warten an Tokio. Der C-Stack und die Globals des Programms bleiben genau dort, wo sie waren. Sobald der Timer feuert, das Event eintrifft oder Salix den Host-Call beantwortet, setzt die Coroutine in der nächsten Zeile fort. Der Agent schreibt for (;;) { wait; check; wake; } und sieht nie einen Callback.
„Vollständig präemptiv“ sorgt dafür, dass sich das sicher teilen lässt. Der gesamte Gast-Code läuft auf einem einzigen Tokio-Thread, und ein Watcher-Thread unterbricht jedes Programm, das zu lange läuft, ohne abzugeben. Ein Loop, der in einem engen for (;;) {} kreist, verliert seine Zeitscheibe wie jeder andere, und um ihn anzuhalten, braucht es nicht seine Mitwirkung. Ein fehlerhafter Loop kann seine Nachbarn nicht blockieren.
Die meisten Loops verbringen fast ihre gesamte Zeit mit Warten, deshalb lässt sich dieses Design sehr dicht packen. In unserem Qualifikationslauf liefen 10.000 kleine Monitoring-Loops, alle vom eingebetteten Compiler kompiliert, auf einem Ausführungs-Thread mit etwa 64 KB Resident Memory pro Loop. Alle 10.000 zu laden und zu starten dauerte 3,07 Sekunden. Ein Event an jeden Loop zuzustellen und den Host-Call zu beantworten, den jeder daraufhin absetzte, dauerte 1,56 Sekunden. Control-Requests blieben bei p99 unter einer Viertelmillisekunde. Der gesamte Prozess nutzte beim Laden vier OS-Threads und im Leerlauf zwei.
Speichersicherheit, die sich prüfen lässt
Tausende von Agenten geschriebene Programme in einem Prozess auszuführen, ist nur vertretbar, wenn keines davon Speicher berühren kann, der ihm nicht gehört. In async-ebpf beginnt diese Garantie beim Speicherlayout und endet bei maschinell geprüften Beweisen.
Zuerst das Layout. Die Daten jedes Programms liegen in einem Pointer Cage: einem reservierten Bereich, umgeben von randomisierten Guard Pages. Der Gast-Stack ist als Inseln aus je einem Frame angelegt, getrennt durch unzugängliche Lücken, die breiter sind, als irgendeine eBPF-Speicherinstruktion reicht. Eine Funktion, die über ihren eigenen Frame hinausläuft, löst also einen Fault aus, statt den Frame ihres Aufrufers zu lesen. JIT-Codeseiten sind nie gleichzeitig beschreibbar und ausführbar.
Dann die Beweise. async-ebpf kompiliert jede eBPF-Funktion beim ersten Aufruf zu nativem Code. Das x86_64-Backend ist so aufgebaut, dass sich der Teil, der Entscheidungen trifft, beweisen lässt:
Diese Pässe liegen in einem Rust-Modul, das die Runtime kompiliert und ausführt. Charon und Aeneas übersetzen genau dieses Modul nach Lean, sodass sich die Beweise auf den Code beziehen, der tatsächlich läuft, und nicht auf ein Modell davon. Rund 40.000 Zeilen handgeschriebenes Lean belegen unter anderem:
Der Validator ist korrekt. In jeder Ausführung eines akzeptierten Programms erreicht das Programm nie eine undefinierte Instruktion, springt nie mitten in eine Instruktion oder aus dem Programm heraus, und der Frame Pointer ist stets die Frame-Basis für die aktuelle Aufruftiefe.
Funktionen sind abgeschlossen. Die Kontrolle verlässt eine Funktion nur über einen lokalen Aufruf oder ein Return. Genau das erlaubt dem JIT, jeweils eine Funktion für sich zu kompilieren.
Der erzeugte Code ist speichersicher. Akzeptiert der Checker eine Funktion, berührt jede Ausführung ihres nativen Codes nur eine erlaubte Menge von Adressen (ihren Frame, ihre eigenen Gast-Regionen, ihren eigenen Stack und Literal-Pool) und kehrt mit unverändertem Zustand des Aufrufers zurück. Lazy kompilierte Callees fügen sich über dasselbe Theorem zusammen.
Die Bytes sagen, was das Modell sagt. Jede Instruktion, die der Assembler ausgibt, dekodiert zurück zu der Instruktion, über die das Modell argumentiert hat.
Die Beweise sind an zwei Stellen mit der Hardware verbunden. Für einen ausführbaren Simulator ist bewiesen, dass er ein Lauf des Lean-Maschinenmodells ist, und ein Differenztest schickt zwanzigtausend zufällige Instruktionsfolgen sowohl durch den Simulator als auch durch den echten Prozessor. Vor jeder Invocation prüft die Runtime das konkrete Speicherlayout gegen die Voraussetzung des Theorems und verweigert die Ausführung, wenn sie nicht erfüllt ist.
Wir sagen ausdrücklich, was nicht bewiesen ist. Zur Trusted Base gehören die Semantik der x86-Instruktionen (gegen Hardware getestet, nicht bewiesen), die Entry-Trampolines, der Fault-Handler, die Memory-Mappings, die Host-Seite jedes Helper-Calls sowie Charon und Aeneas selbst. Das Theorem betrifft Speichersicherheit, nicht funktionale Korrektheit oder Informationslecks. Und es deckt nur das x86_64-Backend ab; das arm64-Backend ist getestet, aber nicht bewiesen.
Die Beweise halten den Gast in seinem eigenen Speicher. Alles, was ein Loop in der Außenwelt tut, läuft über einen Host-Call, und diese Grenze gehört Salix: die Capability-Allowlist und die Berechtigungsregeln, die weiter unten beschrieben sind.
Auch der Compiler läuft in der Sandbox
Agenten kompilieren ihre Loops selbst, also verarbeitet auch der Compiler nicht vertrauenswürdige Eingaben. Spinfoam bettet TinyCC ein, selbst nach eBPF kompiliert, und führt den Compiler wie jeden anderen Gast auf async-ebpf aus. Jeder Build bekommt eine frische Compiler-Instanz mit einer 8-MiB-Arena, einem reinen In-Memory-Dateisystem, das nur die eingereichten Quellen und den SDK-Header enthält, einer Deadline von 15 Sekunden und keinem Zugriff auf Netzwerk, Prozesse oder Host-Dateien. Auch die Ausgabe gilt als unvertrauenswürdig. Jedes Objekt durchläuft beim Laden dieselbe Validierung, egal ob es vom Compiler stammt oder nicht.
Deshalb braucht loop.build auf dem Node keine Toolchain, keinen Container und keine Privilegien. Der C-Quellcode des Agenten kommt nie mit einem nativen Compiler in Berührung.
Den Agenten zu wecken ist der teure Teil
Ein Loop läuft eigenständig, ist aber nutzlos, wenn er dem Agenten nichts mitteilen kann. Der einzige Weg dafür ist agent.notify:
Die Benachrichtigung wird der Ziel-Session des Loops als gewöhnliche Nachricht zugestellt. Erst dann läuft die Hauptschleife des Agenten, und erst dann verbraucht sie Modell-Tokens. Eine ruhige Stunde kostet überhaupt keine Tokens.
Der dedup_key ist wichtiger, als er aussieht. Loops werden neu gestartet, Events werden erneut zugestellt, und ein Gast wiederholt vielleicht einen Aufruf, bei dem er nicht sicher ist, ob er erfolgreich war. Salix stellt das Wecken als loop:<id>:<dedup_key> zu, sodass dieselbe Mail, dasselbe Issue oder derselbe Alert den Agenten nur einmal weckt.
Weil das Wecken der teure Teil ist, hat es auch ein Budget. Ein Loop darf seinen Agenten sechsmal pro zehn Minuten wecken. Ein Loop, der eine Stunde lang an diesem Limit bleibt, wird mit dem Grund budget pausiert, und der Agent wird informiert. Ein fehlerhafter oder übereifriger Loop wird so zu einem sichtbaren, pausierten Loop, nicht zu einer Rechnung.
Schnelle Entscheidungen ohne großes Modell
Der schwierige Teil einer Überwachung ist meist nicht „hat sich etwas geändert“, sondern „ist diese Änderung relevant“. Muss sich der Besitzer heute um diese E-Mail kümmern? Berührt dieser PR die Teile des Systems, die uns wichtig sind? Ein Loop in Integer-C kann das nicht allein beantworten, und bei jedem Event ein Frontier-Modell aufzurufen, würde uns genau dorthin zurückbringen, wo wir angefangen haben.
Deshalb bekommen Loops eine weitere Capability: decide. Sie schickt eine kleine, typisierte Frage an ein schnelles Entscheidungsmodell (Jev) und bekommt eine strukturierte Antwort zurück. Hier ist die Frage aus unserem Prototyp zur E-Mail-Überwachung:
decide unterstützt choice, um einen Kandidaten auszuwählen, separate Ja/Nein-Fragen für mehrere Treffer und score für eine geordnete Relevanz. Es liefert immer nur eine Entscheidung. Es liest keine Quellen, führt keine Tools aus und vergibt keine Berechtigungen. Der Loop liefert die Daten, stellt die Frage und wendet den Schwellenwert in seinem eigenen Code an.
Zwei Designentscheidungen haben dafür gesorgt, dass das gut funktioniert:
Unsicherheit ist eine gültige Antwort. Eine explizite Wahl von
noneoderdeferbedeutet „ich kann es nicht sagen“, und das ist ein erfolgreiches Ergebnis, kein Transportfehler. Der Loop behält das Event, statt eine ruhige Entscheidung zu erfinden.Es ist günstig genug für jedes Event. Unser Preiskatalog führt
typesafe/jev-1.13.0mit $0.042 pro Million Input-Tokens, Output-Tokens sind kostenlos. Das ist günstig genug, um jede E-Mail zu prüfen, nicht nur die, die ein Keyword-Filter durchlässt.
Das Ergebnis ist ein zweistufiges System: Ein kleines Modell prüft jedes Event, und das große Modell sieht nur die, die durchkommen.
Events rein, dauerhaft
Für Polling reichen Timer, aber viele Quellen können pushen. Ein Loop hat eine Mailbox, und externe Systeme beliefern sie auf drei Wegen:
über die Salix-API:
POST /v1/agent-groups/:group_id/loops/:loop_id/eventsmit einem Gruppen-API-Key;über eine geheime Webhook-URL, die der Agent mit
loop.webhookaktiviert, rotiert oder widerruft;über Composio-Trigger aus den verbundenen Apps des Nutzers, die mit der Event-ID des Providers und dem Trigger-Slug eintreffen.
Ein 202 von einem dieser Wege bedeutet, dass PostgreSQL das Event bei seinem Loop gespeichert hat. Es bedeutet nicht, dass der Loop es verarbeitet hat. Der Gast ruft loop.ack auf, sobald er einen sicheren Punkt erreicht hat: eine ruhige Entscheidung oder eine dauerhafte Übergabe wie ein erfolgreiches agent.notify. Bis dahin bleibt das Event ausstehend, und der Reconciler spielt ausstehende Events erneut ab, nachdem der Loop umgezogen ist oder neu gestartet wurde, und in jedem Fall einmal pro Minute. Ein Event, das nicht innerhalb von 15 Minuten bestätigt wird, lässt den Loop sichtbar fehlschlagen; die ausstehenden Events bleiben erhalten, damit der Agent sie erneut versuchen oder verwerfen kann.
Eine Lehre aus dem Bau: Die Mailbox von spinfoam dedupliziert Event-IDs bei der Annahme, nicht beim fachlichen Abschluss. In einem frühen Prototyp erwarteten wir, dass ein fehlgeschlagener Modellaufruf wiederholt wird, indem der Provider das Event erneut zustellt. Das passierte nicht; die erneute Zustellung wurde korrekterweise als Duplikat verworfen. Wiederholungen gehören also dem Gast, der das Event behält und es eine begrenzte Anzahl von Malen erneut versucht, und die Wiederherstellung nach einem Absturz gehört der dauerhaften Inbox. Diese Regel steht inzwischen im SDK-Leitfaden.
Bestätigung, dauerhafte Annahme in die Session, eine sichtbare Nachricht und ein externer Effekt sind vier getrennte Fakten. Wir geben jedem seinen eigenen Owner, verlangen nachgelagert stabile Deduplizierungsschlüssel und versprechen keine Exactly-once-Semantik für externe Effekte.
Für immer laufen auf einem Cluster in Bewegung
„24/7“ ist leicht gesagt und schwer umgesetzt auf einem Cluster, auf dem Nodes kommen und gehen. Ein Loop läuft auf dem Node, der die Lease seines Agenten hält, und er folgt dieser Lease.
Platzierung. Beansprucht der Serverprozess eines Agenten seine Lease, übernimmt er dessen aktive Loops. Wird der Agent passiviert oder gefenct, gibt er sie frei. Ein aktiver Loop hält seinen Agenten resident, sodass ein untätiger Agent mit einem Loop seine Lease erneuert, statt geparkt zu werden.
Inkarnationen. Jedes Laden eines Loops erhöht seine Inkarnation, einen Fence-Wert in der Zeile des Loops. Ein Host-Call von einem Objekt, das nicht die aktuelle Inkarnation ist, wird abgelehnt, ebenso ein
ackvon einem solchen. Eine veraltete Kopie eines Loops auf einem scheidenden Node kann nicht handeln.Checkpoints.
loop.state.putspeichert bis zu 16 KiB, und das nächste Laden bekommt sie alsconfig.statezurück. Agenten sichern Cursor und zuletzt gesehene IDs, nicht alles.Fehler. Ein Fault lädt den Loop aus seinem Checkpoint neu, höchstens dreimal pro Stunde. Danach ist er
failed, und der Agent wird einmal informiert.Verwaiste Loops. Ein periodischer Durchlauf findet aktive Loops ohne angehängtes Objekt oder mit einem Objekt auf einem Node, der den Cluster verlassen hat, und startet ihren Owner neu.
Loops haben außerdem eine Source of Truth außerhalb der Runtime. loop.build schreibt das kompilierte ELF ins Dateisystem des Agenten, und loop.create speichert dessen Pfad und SHA-256. Jedes Laden liest die Datei neu und prüft den Hash, sodass ein Loop genau das Programm ausführt, mit dem er erstellt wurde, oder gar keins.
Was ein Loop darf
Ein Loop ist Code, den ein Modell geschrieben hat und der ohne Aufsicht rund um die Uhr läuft. Seine Berechtigungen müssen kleiner sein als die des Agenten, nicht gleich groß.
Loops rufen eine geschlossene Allowlist von Host-Capabilities auf: agent.notify, die Aufrufe loop.state.*, loop.ack und loop.log, als read-only eingestufte Salix-Tools, Environment- und Device-Tools, SSH-Tools, das Lesen einer Konversation, web.http_request, composio.execute für verbundene Apps und decide. Spinfoam lehnt jeden anderen Namen mit SF_DENIED ab. Es gibt keinen Freigabeschritt, bei dem man etwas falsch machen könnte.
Jeder Aufruf durchläuft dasselbe Tool-Dispatching und dieselben Information-Flow-Checks wie ein normaler Agent-Turn. Ein Loop handelt als sein Ersteller, über einen delegierten Principal schedule|loop:<id>|<creator>, mit den Offenlegungsregeln des Erstellers und dem versiegelten Ursprung des Loops. Event-Payloads sind Daten. Sie tragen keine Berechtigung und können nicht erweitern, was der Loop darf. Deshalb behandelt der Entscheidungs-Prompt oben die E-Mail ausdrücklich als unvertrauenswürdige Eingabe.
Schließlich halten Quotas das System begrenzt: 20 aktive Loops pro Agent, 100 pro Gruppe, 32 ausstehende Events mit je 16 KiB pro Loop sowie Rate-Limits für eingehende Webhooks.
Skripte: dieselbe Runtime, für einen Turn
Als wir erst einmal eine sichere, günstige Runtime für von Agenten geschriebenes C hatten, ergab sich ein zweiter Einsatzzweck. script.run kompiliert ein Integer-C-Programm und führt es einmal aus, innerhalb eines einzigen Tool-Calls, mit Zugriff auf die Tools des aufrufenden Turns über salix.call. Ein Skript hat keine Zeile, keine Inkarnation, keinen Checkpoint und kein agent.notify. Es ist ein Weg für einen Agenten, viele Tool-Calls in einem deterministischen Programm zu bündeln, statt viele Roundtrips zum Modell zu machen. Zwei der Skills, die wir ausliefern, sind C-Programme, die auf diese Weise laufen.
Agenten, die ihr eigenes Event-System programmieren
Zusammengenommen tut ein Comma-Agent, der etwas im Blick behalten soll, drei Dinge, die klassische Agenten nicht können: Er schreibt die Überwachung als Programm, der Cluster führt dieses Programm so lange aus, wie es gebraucht wird, zum Preis von ein paar Kilobyte, und ein kleines Modell entscheidet Event für Event, ob das große Modell überhaupt aufwachen muss.
Die innere Schleife ist weiterhin der Ort, an dem der Agent denkt. Die äußere Schleife ist der Ort, an dem er aufmerksam bleibt. Salix lässt den Agenten beides bauen.


