Agents die altijd aanstaan moeten continu events in de gaten houden en erop reageren, uit veel meer bronnen dan ze in hun contextvenster kunnen bijhouden. De gedistribueerde harness van Comma, Salix, geeft agents de mogelijkheid om kleine eBPF-programma’s in het cluster te laden en die blijvend te laten draaien, 24/7, om events te monitoren, Jev aan te roepen voor snelle, slimme beslissingen en de hoofdlus van de agent te wekken als er iets interessants gebeurt.
De binnenste lus is niet genoeg
De bekende agentlus is eenvoudig: stuur de context naar een model, voer de tool calls uit die het teruggeeft, voeg de resultaten toe, herhaal. Die lus is goed in werk doen. Hij is slecht in wachten op werk.
Vraag een agent ‘laat het me weten als het contract getekend terugkomt’ of ‘houd deze repo in de gaten en meld alles wat aan billing raakt’, en de binnenste lus heeft twee slechte opties. Hij kan pollen: om de paar minuten wakker worden, de inbox opnieuw lezen en een volledige modelaanroep besteden aan de conclusie dat er niets is gebeurd. Of hij kan de klus overlaten aan een heartbeat of een cron-schema, wat hetzelfde pollen is met een langer interval en een slechtere latency. Hoe dan ook draait het dure deel van het systeem, een groot model dat een grote context leest, bij elke tik, en bijna elke tik is een rustige.
Wat een agent die altijd aanstaat eigenlijk nodig heeft, is een buitenste lus: iets dat op de eventstroom zit, het goedkope filterwerk doet en alleen naar het model escaleert als er iets is om over na te denken. En omdat elke agent andere dingen op andere manieren in de gaten houdt, kan die buitenste lus geen vaste productfunctie zijn. De agent moet hem zelf schrijven.
In Salix heet die buitenste lus een Loop.
Een Loop is een klein C-programma
Een Loop is één C-bestand, geschreven door de agent, gecompileerd naar eBPF en gedraaid op het cluster naast de agent die er eigenaar van is. Elke Loop heeft dezelfde vorm:
Hij wacht op een timer of een event, controleert iets en wekt, zelden, de agent. De agent krijgt de exacte SDK-header en een programmeerhandleiding van een loop.sdk-tool, schrijft het programma, compileert het met loop.build en start het met loop.create. Er is geen template en geen DSL. Als de agent de bewaking in C kan beschrijven, kan hij die ook draaien.
Waarom C en eBPF, en niet bijvoorbeeld een Python-script in een container? Vanwege de getallen waar we Salix omheen hebben ontworpen. Salix draait miljoenen agents, met meer dan 100 agents per CPU-core. Een agent kan geen sandbox hebben die altijd aanstaat, en zijn bewakingen dus ook niet. Een Loop moet goedkoop genoeg zijn om eeuwig te blijven draaien, voor elke agent, op een gedeelde multi-tenant-node, terwijl hij code uitvoert die geen mens heeft gereviewd.
eBPF past ongewoon goed bij die eisen:
Het is klein. Een wachtende Loop neemt enkele tientallen kilobytes resident geheugen in. Er is geen proces, geen interpreter, geen heap.
Het is veilig om onvertrouwde code te draaien. Het eBPF-doelplatform heeft geen syscalls, geen functiepointers en geen onbegrensde stack. Een programma kan alleen rekenen over zijn eigen geheugen en de hostfuncties aanroepen die wij het geven.
Wachten is goedkoop. Een Loop die geblokkeerd is in
sf_event_nextofsf_sleep_mskost niets behalve zijn geheugen.
De prijs is een beperkt dialect, en de SDK-handleiding is daar duidelijk over: alleen integer-C (geen floating point, geen signed division), stackframes van 4 KiB, hoogstens 8 frames diep, geen recursie, geen sprintf en hoogstens 128 actieve handles. Modellen blijken daar prima mee om te gaan. Ze kennen C al, en de beperkingen zijn van het soort dat een compilerfout goed uitlegt.
Spinfoam: de runtime onder elke Loop
Loops draaien op spinfoam, onze eBPF-runtime voor Loops. Spinfoam is gebouwd op async-ebpf, een eBPF-runtime in userspace die async-vriendelijk en volledig preëmptief is, met formeel geverifieerde geheugenveiligheid in de kern.
eBPF in userspace, async gemaakt
Spinfoam is geen kernel-eBPF. Het is een gewoon Rust-proces, één per Salix-node, dat geen eBPF-ondersteuning in de kernel en geen privileges nodig heeft, en op Linux en macOS hetzelfde draait. Salix praat ermee via JSON-RPC op stdin en stdout. De taakverdeling is strikt: spinfoam is eigenaar van lokale uitvoering, isolatie tussen programma’s, annulering en begrensde berichtbezorging. Salix is eigenaar van alles wat een crash moet overleven: plaatsing, duurzame status, herstartbeleid, inloggegevens en het netwerk.
De ‘async’ in async-ebpf is wat een Loop eruit laat zien als gewone sequentiële C. Elk geladen programma krijgt één langlevende aanroep, die op een eigen coroutine draait. Als het programma sf_sleep_ms, sf_event_next of sf_host_call aanroept, schort de helper de coroutine op en geeft het wachten door aan Tokio. De C-stack en de globals van het programma blijven precies waar ze waren. Als de timer afgaat, het event binnenkomt of Salix de host call beantwoordt, gaat de coroutine verder op de volgende regel. De agent schrijft for (;;) { wait; check; wake; } en ziet nooit een callback.
‘Volledig preëmptief’ is wat dit veilig maakt om te delen. Alle guestcode draait op één Tokio-thread, en een watcher-thread onderbreekt elk programma dat te lang draait zonder de beurt af te staan. Een Loop die rondjes draait in een strakke for (;;) {} verliest zijn tijdslot zoals elke andere, en hem stoppen vraagt niet om zijn medewerking. Eén Loop met een bug kan zijn buren niet ophouden.
De meeste Loops zijn bijna al hun tijd aan het wachten, dus dit ontwerp is erg compact. In onze kwalificatierun draaiden 10.000 kleine monitoring-Loops, allemaal gecompileerd door de ingebouwde compiler, op één uitvoeringsthread met ongeveer 64 KB resident geheugen per stuk. Alle 10.000 laden en starten duurde 3,07 seconden. Eén event aan elke Loop bezorgen, en de host call beantwoorden die elk ervan als reactie deed, duurde 1,56 seconden. Besturingsverzoeken bleven op p99 onder een kwart milliseconde. Het hele proces gebruikte vier OS-threads tijdens het laden en twee in rust.
Geheugenveiligheid die je kunt controleren
Duizenden door agents geschreven programma’s in één proces draaien is alleen verantwoord als geen ervan geheugen kan aanraken dat niet van hem is. In async-ebpf begint die garantie bij de geheugenindeling en eindigt ze bij machinaal gecontroleerde bewijzen.
Eerst de indeling. De data van elk programma zit in een pointer cage: een gereserveerd gebied met gerandomiseerde guard pages eromheen. De gueststack is ingedeeld als eilanden van elk één frame, gescheiden door ontoegankelijke gaten die breder zijn dan welke eBPF-geheugeninstructie ook kan bereiken, dus een functie die buiten haar eigen frame loopt, veroorzaakt een fout in plaats van het frame van haar aanroeper te lezen. JIT-codepagina’s zijn nooit tegelijk schrijfbaar en uitvoerbaar.
Dan de bewijzen. async-ebpf compileert elke eBPF-functie naar native code de eerste keer dat ze draait. De x86_64-backend is zo opgebouwd dat het deel dat beslissingen neemt bewezen kan worden:
De validator is correct. Bij elke uitvoering van een programma dat hij accepteert, komt het programma nooit bij een ongedefinieerde instructie, springt het nooit midden in een instructie of buiten het programma, en is de framepointer altijd de framebasis voor de huidige aanroepdiepte.
Functies zijn gesloten. De besturing verlaat een functie nooit, behalve via een lokale aanroep of een return. Daardoor kan de JIT één functie tegelijk compileren.
De gegenereerde code is geheugenveilig. Als de checker een functie accepteert, raakt elke uitvoering van haar native code alleen een toegestane set adressen aan (haar frame, haar eigen guestgebieden, haar eigen stack en literal pool) en keert ze terug met de status van de aanroeper intact. Lazy gecompileerde callees passen in hetzelfde theorema.
De bytes zeggen wat het model zegt. Elke instructie die de assembler uitstuurt, decodeert terug naar de instructie waarover het model redeneerde.
De bewijzen sluiten op twee plekken aan op de hardware. Van een uitvoerbare simulator is bewezen dat hij een run van het Lean-machinemodel is, en een differentiële test stuurt twintigduizend willekeurige instructiereeksen door zowel de simulator als de echte processor. Voor elke aanroep controleert de runtime de concrete geheugenindeling tegen de hypothese van het theorema, en weigert te draaien als die niet klopt.
We zijn expliciet over wat niet bewezen is. De vertrouwde basis omvat de semantiek van de x86-instructies (getest tegen hardware, niet bewezen), de entry trampolines, de fault handler, de geheugenmappings, de hostkant van elke helper call, en Charon en Aeneas zelf. Het theorema gaat over geheugenveiligheid, niet over functionele correctheid of informatielekken. En het dekt alleen de x86_64-backend; de arm64-backend is getest, maar niet bewezen.
De bewijzen houden de guest in zijn eigen geheugen. Alles wat een Loop in de buitenwereld doet, gaat via een host call, en die grens is van Salix: de allowlist van mogelijkheden en de bevoegdheidsregels die hieronder worden beschreven.
Ook de compiler draait in de sandbox
Agents compileren hun eigen Loops, dus ook de compiler krijgt onvertrouwde input. Spinfoam bevat TinyCC, gecompileerd naar eBPF, en draait het op async-ebpf zoals elke andere guest. Elke build krijgt een nieuwe compilerinstantie met een arena van 8 MiB, een bestandssysteem in het geheugen met alleen de ingediende bronbestanden en de SDK-header, een deadline van 15 seconden, en geen toegang tot netwerk, processen of hostbestanden. Ook de output wordt als onvertrouwd behandeld. Elk object doorloopt bij het laden dezelfde validatie, of het nu van de compiler komt of niet.
Daarom heeft loop.build geen toolchain, geen container en geen privileges op de node nodig. De C-broncode van de agent komt nooit bij een native compiler.
De agent wekken is het dure deel
Een Loop draait zelfstandig, maar is nutteloos als hij de agent niets kan vertellen. De enige manier waarop hij dat doet is agent.notify:
De melding wordt als gewoon bericht bezorgd bij de doel-Session van de Loop. Pas dan draait de hoofdlus van de agent, en pas dan besteedt hij modeltokens. Een rustig uur kost helemaal geen tokens.
De dedup_key is belangrijker dan hij lijkt. Loops worden herstart, events worden opnieuw bezorgd, en een guest kan een aanroep opnieuw proberen waarvan hij niet zeker weet of die is gelukt. Salix bezorgt de wekker als loop:<id>:<dedup_key>, zodat dezelfde mail, issue of alert de agent maar één keer wekt.
Omdat wekken het dure deel is, heeft het ook een budget. Een Loop mag zijn agent zes keer per tien minuten wekken. Een Loop die een uur lang op die limiet blijft, wordt gepauzeerd met als reden budget, en de agent krijgt dat te horen. Een Loop met een bug of te veel ijver wordt zo een zichtbare, gepauzeerde Loop, en geen rekening.
Snelle beslissingen zonder groot model
Het lastige van bewaken is meestal niet ‘is er iets veranderd’, maar ‘doet deze verandering ertoe’. Is deze e-mail iets waar de eigenaar vandaag iets mee moet? Raakt deze PR de delen van het systeem die ons interesseren? Een Loop in integer-C kan dat niet zelf beantwoorden, en bij elk event een frontiermodel aanroepen zou ons weer terugbrengen bij af.
Daarom krijgen Loops nog één mogelijkheid: decide. Die stuurt een kleine, getypeerde vraag naar een snel beslismodel (Jev) en krijgt een gestructureerd antwoord terug. Dit is de vraag uit ons prototype voor e-mailbewaking:
decide ondersteunt choice om één kandidaat te kiezen, losse ja/nee-vragen voor meerdere treffers, en score voor geordende relevantie. Het geeft altijd alleen een beslissing terug. Het leest geen bronnen, voert geen tools uit en verleent geen bevoegdheden. De Loop levert de data, stelt de vraag en past de drempel toe in zijn eigen code.
Twee ontwerpkeuzes zorgden ervoor dat dit goed werkt:
Onzekerheid is een geldig antwoord. Een expliciete keuze
noneofdeferbetekent ‘ik kan het niet zeggen’, en dat is een geslaagd resultaat, geen transportfout. De Loop houdt het event vast in plaats van een rustige beslissing te verzinnen.Het is goedkoop genoeg om bij elk event te draaien. Onze prijscatalogus noemt voor
typesafe/jev-1.13.0$0.042 per miljoen inputtokens, met gratis outputtokens. Dat is goedkoop genoeg om elke e-mail te screenen, niet alleen de mails die een trefwoordfilter doorlaat.
Het resultaat is een systeem met twee lagen: een klein model screent elk event, en het grote model ziet alleen de events die erdoor komen.
Events binnen, duurzaam
Timers zijn genoeg om te pollen, maar veel bronnen kunnen zelf pushen. Een Loop heeft een mailbox, en externe systemen vullen die op drie manieren:
de Salix-API:
POST /v1/agent-groups/:group_id/loops/:loop_id/eventsmet een API-sleutel van de groep;een geheime webhook-URL, die de agent inschakelt, roteert of intrekt met
loop.webhook;Composio-triggers uit de gekoppelde apps van de gebruiker, die binnenkomen met het event-ID van de provider en de trigger-slug.
Een 202 van een van deze betekent dat PostgreSQL het event bij zijn Loop heeft vastgelegd. Het betekent niet dat de Loop het heeft verwerkt. De guest roept loop.ack aan als hij een veilig punt heeft bereikt: een rustige beslissing, of een duurzame overdracht zoals een geslaagde agent.notify. Tot dan blijft het event openstaan, en de Reconciler speelt openstaande events opnieuw af nadat de Loop is verplaatst of herstart, en hoe dan ook eens per minuut. Een event dat niet binnen 15 minuten is bevestigd, laat de Loop zichtbaar falen, waarbij de openstaande events bewaard blijven zodat de agent ze opnieuw kan proberen of weggooien.
Eén les uit het bouwen hiervan: de mailbox van spinfoam ontdubbelt event-ID’s bij toelating, niet bij het afronden van het eigenlijke werk. In een vroeg prototype verwachtten we dat een mislukte modelaanroep opnieuw zou worden geprobeerd doordat de provider het event opnieuw bezorgde. Dat gebeurde niet; de nieuwe bezorging werd terecht als duplicaat weggegooid. Opnieuw proberen hoort dus bij de guest, die het event vasthoudt en een begrensd aantal keren opnieuw probeert, en herstel na een crash hoort bij de duurzame inbox. Die regel staat nu in de SDK-handleiding.
Bevestiging, duurzame toelating tot de Session, een zichtbaar bericht en een extern effect zijn vier afzonderlijke feiten. We geven elk een eigen eigenaar, eisen stabiele ontdubbelingssleutels verderop in de keten, en beloven geen externe effecten die precies één keer plaatsvinden.
Eeuwig draaien op een cluster in beweging
‘24/7’ is makkelijk gezegd en moeilijk gedaan op een cluster waar nodes komen en gaan. Een Loop draait op de node die de lease van zijn agent heeft, en volgt die lease.
Plaatsing. Als het serverproces van een agent zijn lease claimt, neemt het de actieve Loops over. Als de agent wordt gepassiveerd of gefenced, laat het ze los. Een actieve Loop houdt zijn agent resident, dus een inactieve agent met een Loop verlengt zijn lease in plaats van te parkeren.
Incarnaties. Elke keer dat een Loop wordt geladen, gaat zijn incarnatie omhoog, een fence-waarde op de rij van de Loop. Een host call van een object dat niet de huidige incarnatie is, wordt geweigerd, en een
ackook. Een verouderde kopie van een Loop op een vertrekkende node kan niets doen.Checkpoints.
loop.state.putslaat tot 16 KiB op, en de volgende keer dat de Loop laadt, krijgt hij het terug alsconfig.state. Agents checkpointen cursors en de laatst geziene ID’s, niet alles.Fouten. Na een fout wordt de Loop opnieuw geladen vanaf zijn checkpoint, hoogstens drie keer per uur. Daarna is hij
failed, en krijgt de agent dat één keer te horen.Gestrande Loops. Een periodieke controle zoekt actieve Loops zonder gekoppeld object, of gekoppeld op een node die is vertrokken, en herstart hun eigenaar.
Loops hebben ook een bron van waarheid buiten de runtime. loop.build schrijft de gecompileerde ELF naar het bestandssysteem van de agent, en loop.create legt het pad en de SHA-256 ervan vast. Bij elke keer laden wordt het bestand opnieuw gelezen en de hash gecontroleerd, dus een Loop draait precies het programma waarmee hij is gemaakt, of helemaal niet.
Wat een Loop mag
Een Loop is code die een model heeft geschreven en die zonder toezicht de klok rond draait. Zijn bevoegdheden moeten kleiner zijn dan die van de agent, niet gelijk.
Loops roepen een gesloten allowlist van hostmogelijkheden aan: agent.notify, de aanroepen loop.state.*, loop.ack en loop.log, Salix-tools die als alleen-lezen zijn geclassificeerd, omgevings- en apparaattools, SSH-tools, het lezen van een gesprek, web.http_request, composio.execute voor gekoppelde apps, en decide. Spinfoam weigert elke andere naam met SF_DENIED. Er is geen toekenningsstap die mis kan gaan.
Elke aanroep doorloopt dezelfde tool dispatch en informatiestroomcontroles als een normale agentbeurt. Een Loop handelt als zijn maker, via een gedelegeerde principal schedule|loop:<id>|<creator>, met de openbaarmakingsregels van de maker en de verzegelde herkomst van de Loop. Event-payloads zijn data. Ze dragen geen bevoegdheid en kunnen niet uitbreiden wat de Loop mag, en daarom behandelt de beslisprompt hierboven de e-mail uitdrukkelijk als onvertrouwde input.
Tot slot houden quota’s het systeem begrensd: 20 actieve Loops per agent, 100 per groep, 32 openstaande events van elk 16 KiB per Loop, en rate limits op binnenkomende webhooks.
Scripts: dezelfde runtime, voor één beurt
Toen we eenmaal een veilige, goedkope runtime hadden voor C die agents schrijven, diende zich een tweede toepassing aan. script.run compileert en draait een integer-C-programma één keer, binnen één tool call, met toegang tot de tools van de aanroepende beurt via salix.call. Een script heeft geen rij, geen incarnatie, geen checkpoint en geen agent.notify. Het is een manier voor een agent om veel tool calls te bundelen in één deterministisch programma, in plaats van veel rondes langs het model. Twee van de skills die we meeleveren zijn C-programma’s die zo draaien.
Agents die hun eigen eventsysteem programmeren
Alles bij elkaar doet een Comma-agent die de wacht moet houden drie dingen die traditionele agents niet kunnen: hij schrijft de bewaking als programma, het cluster draait dat programma zo lang als nodig voor de prijs van een paar kilobytes, en een klein model beslist per event of het grote model überhaupt wakker moet worden.
De binnenste lus is nog steeds waar de agent denkt. De buitenste lus is waar hij oplet. Met Salix bouwt de agent ze allebei.