Agenter, der altid er aktive, skal hele tiden holde øje med og reagere på hændelser fra langt flere kilder, end de kan holde styr på i deres kontekstvindue. Commas distribuerede harness, Salix, giver agenter mulighed for at indlæse små eBPF-programmer i klyngen og lade dem køre vedvarende, døgnet rundt, så de kan overvåge hændelser, kalde Jev for hurtige, intelligente beslutninger og vække agentens hovedløkke, når der sker noget interessant.
Den indre løkke er ikke nok
Den velkendte agentløkke er enkel: send konteksten til en model, kør de værktøjskald, den returnerer, tilføj resultaterne, og gentag. Den løkke er god til at udføre arbejde. Den er dårlig til at vente på arbejde.
Beder du en agent om »sig til, når kontrakten kommer retur underskrevet« eller »hold øje med dette repository, og markér alt, der rører ved billing«, har den indre løkke to dårlige muligheder. Den kan polle: vågne med få minutters mellemrum, læse indbakken igen og bruge et helt modelkald på at konstatere, at der ikke er sket noget. Eller den kan overlade opgaven til et heartbeat eller en cron-plan, som er den samme polling med et længere interval og dårligere latenstid. Uanset hvad kører den dyre del af systemet, en stor model, der læser en stor kontekst, ved hvert tick, og i næsten hvert tick sker der ingenting.
Det, en agent, der altid er aktiv, faktisk har brug for, er en ydre løkke: noget, der lytter på hændelsesstrømmen, klarer den billige filtrering og kun sender videre til modellen, når der er noget værd at tænke over. Og fordi hver agent holder øje med forskellige ting på forskellige måder, kan den ydre løkke ikke være en fast produktfunktion. Agenten skal selv skrive den.
I Salix hedder den ydre løkke et Loop.
Et Loop er et lille C-program
Et Loop er én C-fil, skrevet af agenten, kompileret til eBPF og kørt i klyngen ved siden af den agent, der ejer det. Alle Loops har samme form:
Det venter på en timer eller en hændelse, tjekker noget og vækker en sjælden gang agenten. Agenten får den præcise SDK-header og en programmeringsvejledning fra værktøjet loop.sdk, skriver programmet, kompilerer det med loop.build og starter det med loop.create. Der er ingen skabelon og intet DSL. Kan agenten beskrive overvågningen i C, kan den køre den.
Hvorfor C og eBPF og ikke fx et Python-script i en container? På grund af de tal, vi har designet Salix ud fra. Salix kører millioner af agenter med mere end 100 agenter pr. CPU-kerne. En agent kan ikke have en sandkasse, der altid kører, og det kan dens overvågninger heller ikke. Et Loop skal være billigt nok til at køre for evigt, for hver agent, på en delt multi-tenant-node, mens det kører kode, som intet menneske har gennemgået.
eBPF passer usædvanligt godt til den opgave:
Det er lille. Et ventende Loop fylder et par dusin kilobyte resident hukommelse. Der er ingen proces, ingen fortolker og ingen heap.
Det er sikkert at køre uden tillid. Målplatformen har ingen systemkald, ingen funktionspointere og ingen ubegrænset stak. Et program kan kun regne på sin egen hukommelse og kalde de værtsfunktioner, vi giver det.
Det er billigt at vente. Et Loop, der er blokeret i
sf_event_nextellersf_sleep_ms, koster kun sin hukommelse.
Prisen er en begrænset dialekt, og SDK-vejledningen siger det ligeud: kun heltals-C (ingen flydende kommatal, ingen division med fortegn), stakrammer på 4 KiB, højst 8 rammer i dybden, ingen rekursion, ingen sprintf og højst 128 aktive handles. Det viser sig, at modeller klarer det fint. De kan allerede C, og begrænsningerne er af den slags, som en compilerfejl forklarer godt.
Spinfoam: runtimen under hvert Loop
Loops kører på spinfoam, vores eBPF-runtime til løkker. Spinfoam er bygget på async-ebpf, en eBPF-runtime i brugerrummet, der er async-venlig, fuldt præemptiv og har formelt verificeret hukommelsessikkerhed i sin kerne.
eBPF i brugerrummet, gjort async
Spinfoam er ikke kerne-eBPF. Det er en almindelig Rust-proces, én pr. Salix-node, som hverken kræver eBPF-understøttelse i kernen eller særlige rettigheder og kører ens på Linux og macOS. Salix taler med den via JSON-RPC på stdin og stdout. Ansvarsfordelingen er skarp: spinfoam ejer lokal afvikling, isolation mellem programmer, annullering og begrænset levering af beskeder. Salix ejer alt, der skal overleve et nedbrud: placering, varig tilstand, genstartspolitik, loginoplysninger og netværket.
Det er »async« i async-ebpf, der får et Loop til at ligne almindelig sekventiel C. Hvert indlæst program får ét langlivet kald, der kører på sin egen coroutine. Når programmet kalder sf_sleep_ms, sf_event_next eller sf_host_call, suspenderer hjælpefunktionen coroutinen og overlader ventetiden til Tokio. C-stakken og programmets globale variabler bliver præcis, hvor de var. Når timeren udløber, hændelsen ankommer, eller Salix besvarer værtskaldet, fortsætter coroutinen på næste linje. Agenten skriver for (;;) { wait; check; wake; } og ser aldrig et callback.
Det er »fuldt præemptiv«, der gør det sikkert at dele. Al gæstekode kører på én Tokio-tråd, og en overvågningstråd afbryder ethvert program, der kører for længe uden at afgive kontrollen. Et Loop, der spinner i en stram for (;;) {}, mister sin tidsskive som alle andre, og det kræver ikke dets samarbejde at stoppe det. Ét fejlbehæftet Loop kan ikke få naboerne til at gå i stå.
De fleste Loops bruger næsten al deres tid på at vente, så de kan pakkes tæt sammen. I vores kvalifikationskørsel kørte 10.000 små overvågnings-Loops, alle kompileret af den indbyggede compiler, på én afviklingstråd med omkring 64 KB resident hukommelse hver. Det tog 3,07 sekunder at indlæse og starte alle 10.000. Det tog 1,56 sekunder at levere én hændelse til hvert Loop og besvare det værtskald, hvert af dem foretog som svar. Kontrolforespørgsler holdt sig under et kvart millisekund ved p99. Hele processen brugte fire OS-tråde under indlæsning og to i tomgang.
Hukommelsessikkerhed, du kan tjekke
Det er kun rimeligt at køre tusindvis af agentskrevne programmer i én proces, hvis ingen af dem kan røre hukommelse, der ikke er deres. I async-ebpf begynder den garanti med hukommelseslayoutet og slutter med maskinkontrollerede beviser.
Først layoutet. Hvert programs data ligger i et pointerbur: et reserveret område med randomiserede guard pages omkring sig. Gæstestakken er lagt ud som øer på én ramme hver, adskilt af utilgængelige huller, der er bredere end nogen eBPF-hukommelsesinstruktion kan nå, så en funktion, der går ud over sin egen ramme, fejler i stedet for at læse kalderens. JIT-kodesider er aldrig skrivbare og eksekverbare på samme tid.
Så beviserne. async-ebpf kompilerer hver eBPF-funktion til native kode første gang, den kører. Dens x86_64-backend er struktureret, så den del, der træffer beslutningerne, kan bevises:
Validatoren er sund. Langs enhver afvikling af et program, den accepterer, når programmet aldrig en udefineret instruktion, hopper aldrig ind midt i en instruktion eller ud af programmet, og rammepointeren er altid rammebasen for den aktuelle kaldsdybde.
Funktioner er lukkede. Kontrollen forlader aldrig en funktion undtagen via et lokalt kald eller en returnering, og det er det, der lader JIT’en kompilere én funktion ad gangen.
Den genererede kode er hukommelsessikker. Hvis checkeren accepterer en funktion, rører hver afvikling af dens native kode kun et tilladt sæt adresser (dens ramme, dens egne gæsteområder, dens egen stak og literal pool) og returnerer med kalderens tilstand intakt. Kaldte funktioner, der først kompileres ved behov, er dækket af samme sætning.
De udsendte bytes siger det samme som modellen. Hver instruktion, assembleren udsender, dekodes tilbage til den instruktion, modellen ræsonnerede om.
Beviserne forbindes med hardwaren to steder. Det er bevist, at en eksekverbar simulator følger Lean-maskinmodellen, og en differentiel test kører tyve tusind tilfældige instruktionssekvenser gennem både simulatoren og den rigtige processor. Før hvert kald tjekker runtimen det konkrete hukommelseslayout mod sætningens forudsætning og nægter at køre, hvis den ikke holder.
Vi siger tydeligt, hvad der ikke er bevist. Den betroede base omfatter x86-instruktionssemantikken (testet mod hardware, ikke bevist), indgangstrampolinerne, fejlhåndteringen, hukommelsesmappingerne, værtssiden af hvert hjælpekald samt Charon og Aeneas selv. Sætningen handler om hukommelsessikkerhed, ikke om funktionel korrekthed eller informationslæk. Og den dækker kun x86_64-backenden; arm64-backenden er testet, men ikke bevist.
Beviserne holder gæsten i sin egen hukommelse. Alt, hvad et Loop gør i omverdenen, går gennem et værtskald, og den grænse hører til Salix: listen over tilladte funktioner og de rettighedsregler, der er beskrevet nedenfor.
Compileren kører også i sandkassen
Agenter kompilerer deres egne Loops, så compileren skal også tåle upålideligt input. Spinfoam indlejrer TinyCC, kompileret til eBPF, og kører den på async-ebpf som enhver anden gæst. Hvert build får en frisk compilerinstans med en arena på 8 MiB, et rent hukommelsesbaseret filsystem med kun de indsendte kildefiler og SDK-headeren, en frist på 15 sekunder og ingen adgang til netværk, processer eller værtens filer. Outputtet behandles også som upålideligt. Hvert objekt gennemgår samme validering, når det indlæses, uanset om det kom fra compileren eller ej.
Derfor kræver loop.build hverken toolchain, container eller særlige rettigheder på noden. Agentens C-kildekode rører aldrig en native compiler.
Det er dyrt at vække agenten
Et Loop kører af sig selv, men det er ubrugeligt, hvis det ikke kan fortælle agenten noget. Den eneste måde, det gør det på, er agent.notify:
Beskeden leveres til Loopets mål-Session som en almindelig besked. Først da kører agentens hovedløkke, og først da bruger den modeltokens. En stille time koster slet ingen tokens.
dedup_key betyder mere, end det ser ud til. Loops bliver genstartet, hændelser bliver leveret igen, og en gæst kan prøve et kald igen, som den ikke er sikker på lykkedes. Salix leverer vækningen som loop:<id>:<dedup_key>, så den samme mail, det samme issue eller den samme alarm kun vækker agenten én gang.
Fordi en vækning er den dyre del, har den også et budget. Et Loop må vække sin agent seks gange pr. ti minutter. Et Loop, der ligger på den grænse i en time, sættes på pause med årsagen budget, og agenten får besked. Et fejlbehæftet eller overivrigt Loop ender som et synligt Loop på pause, ikke som en regning.
Hurtige beslutninger uden en stor model
Det svære ved en overvågning er sjældent »har noget ændret sig«, men »betyder ændringen noget«. Er denne e-mail noget, ejeren skal handle på i dag? Rører denne PR ved de dele af systemet, vi går op i? Et Loop skrevet i heltals-C kan ikke svare på det alene, og at kalde en frontiermodel ved hver hændelse ville sende os lige tilbage til start.
Derfor får Loops én funktion mere: decide. Den sender et lille, typet spørgsmål til en hurtig beslutningsmodel (Jev) og får et struktureret svar tilbage. Her er spørgsmålet fra vores prototype til e-mailovervågning:
decide understøtter choice til at vælge én kandidat, separate ja/nej-spørgsmål til flere match og score til ordnet relevans. Den returnerer altid kun en beslutning. Den læser ikke kilder, kører ikke værktøjer og giver ikke rettigheder. Loopet leverer dataene, stiller spørgsmålet og anvender tærsklen i sin egen kode.
To designvalg fik det til at fungere godt:
Usikkerhed er et gyldigt svar. Et eksplicit
none- ellerdefer-valg betyder »jeg kan ikke afgøre det«, og det er et vellykket resultat, ikke en transportfejl. Loopet beholder hændelsen i stedet for at opdigte en stille beslutning.Det er billigt nok til at køre ved hver hændelse. Vores priskatalog angiver
typesafe/jev-1.13.0til $0.042 pr. million inputtokens, med gratis outputtokens. Det er billigt nok til at screene hver e-mail, ikke kun dem, et nøgleordsfilter lukker igennem.
Resultatet er et system i to lag: en lille model screener hver hændelse, og den store model ser kun dem, der slipper igennem.
Hændelser, der bliver gemt
Timere er nok til polling, men mange kilder kan selv skubbe data. Et Loop har en postkasse, og eksterne systemer fodrer den på tre måder:
Salix-API’et:
POST /v1/agent-groups/:group_id/loops/:loop_id/eventsmed en API-nøgle til gruppen;en hemmelig webhook-URL, som agenten aktiverer, roterer eller tilbagekalder med
loop.webhook;Composio-triggere fra brugerens forbundne apps, som ankommer med udbyderens hændelses-ID og trigger-slug.
En 202 fra en af dem betyder, at PostgreSQL har gemt hændelsen på dens Loop. Det betyder ikke, at Loopet har behandlet den. Gæsten kalder loop.ack, når den har nået et sikkert punkt: en stille beslutning eller en varig overdragelse som et vellykket agent.notify. Indtil da forbliver hændelsen afventende, og Reconciler afspiller afventende hændelser igen, efter at Loopet er flyttet eller genstartet, og under alle omstændigheder én gang i minuttet. En hændelse, der ikke er kvitteret inden for 15 minutter, får Loopet til at fejle synligt, og de afventende hændelser gemmes, så agenten kan prøve igen eller kassere dem.
En lektie fra udviklingen: spinfoams postkasse deduplikerer hændelses-ID’er ved modtagelsen, ikke når forretningslogikken er færdig. I en tidlig prototype forventede vi, at et mislykket modelkald ville blive prøvet igen, når udbyderen leverede hændelsen igen. Det skete ikke; den nye levering blev korrekt kasseret som en dublet. Derfor hører genforsøg til gæsten, som beholder hændelsen og prøver igen et begrænset antal gange, og gendannelse efter et nedbrud hører til den varige indbakke. Den regel skriver vi nu ind i SDK-vejledningen.
Kvittering, varig modtagelse i en Session, en synlig besked og en ekstern effekt er fire separate fakta. Vi giver hver af dem sin egen ejer, kræver stabile dedupliceringsnøgler længere nede i kæden og lover ikke eksterne effekter præcis én gang.
Døgnet rundt i en klynge, der hele tiden ændrer sig
»Døgnet rundt« er let at sige og svært at gøre i en klynge, hvor noder kommer og går. Et Loop kører på den node, der har dets agents lease, og det følger den lease.
Placering. Når en agents serverproces gør krav på sin lease, overtager den sine aktive Loops. Når agenten passiveres eller fences, frigiver den dem. Et aktivt Loop holder sin agent resident, så en inaktiv agent med et Loop fornyer sin lease i stedet for at blive parkeret.
Inkarnationer. Hver indlæsning af et Loop tæller dets inkarnation op, en fence-værdi på Loopets række. Et værtskald fra et objekt, der ikke er den aktuelle inkarnation, afvises, og det samme gør et
ackfra et sådant. En forældet kopi af et Loop på en node, der er på vej væk, kan ikke handle.Checkpoints.
loop.state.putgemmer op til 16 KiB, og næste indlæsning får det tilbage somconfig.state. Agenter checkpointer cursorer og senest sete ID’er, ikke alt.Fejl. En fejl genindlæser Loopet fra dets checkpoint, højst tre gange i timen. Derefter er det
failed, og agenten får besked én gang.Strandede Loops. En periodisk gennemgang finder aktive Loops uden tilknyttet objekt, eller tilknyttet en node, der er forsvundet, og genstarter deres ejer.
Loops har også en sandhedskilde uden for runtimen. loop.build skriver den kompilerede ELF til agentens filsystem, og loop.create registrerer dens sti og SHA-256. Hver indlæsning læser filen igen og tjekker hashen, så et Loop kører præcis det program, det blev oprettet med, eller slet ikke.
Hvad et Loop har lov til
Et Loop er kode skrevet af en model, der kører uden opsyn, døgnet rundt. Dets rettigheder skal være mindre end agentens, ikke lig med dem.
Loops kalder en lukket liste over tilladte værtsfunktioner: agent.notify, kaldene loop.state.*, loop.ack og loop.log, Salix-værktøjer klassificeret som skrivebeskyttede, miljø- og enhedsværktøjer, SSH-værktøjer, læsning af en samtale, web.http_request, composio.execute til forbundne apps og decide. Spinfoam afviser alle andre navne med SF_DENIED. Der er intet tildelingstrin, man kan gøre forkert.
Hvert kald går gennem den samme værktøjsfordeling og de samme informationsflowtjek som en normal agenttur. Et Loop handler som sin opretter via en delegeret principal schedule|loop:<id>|<creator>, med opretterens regler for videregivelse og Loopets forseglede oprindelse. Hændelsesdata er data. De bærer ingen rettigheder og kan ikke udvide, hvad Loopet må, og derfor behandler beslutningsprompten ovenfor udtrykkeligt e-mailen som upålideligt input.
Endelig holder kvoter systemet begrænset: 20 aktive Loops pr. agent, 100 pr. gruppe, 32 afventende hændelser på 16 KiB hver pr. Loop og hastighedsgrænser på indgående webhooks.
Scripts: den samme runtime til én tur
Da vi havde en sikker, billig runtime til C skrevet af agenter, dukkede en anden anvendelse op. script.run kompilerer og kører et heltals-C-program én gang inden for et enkelt værktøjskald, med adgang til den kaldende turs egne værktøjer via salix.call. Et script har ingen række, ingen inkarnation, intet checkpoint og intet agent.notify. Det er en måde for en agent at samle mange værktøjskald i ét deterministisk program i stedet for mange runder frem og tilbage med modellen. To af de skills, vi leverer, er C-programmer, der køres på den måde.
Agenter, der programmerer deres eget hændelsessystem
Samlet set gør en Comma-agent, der bliver bedt om at holde øje, tre ting, som traditionelle agenter ikke kan: den skriver overvågningen som et program, klyngen kører programmet, så længe der er brug for det, for prisen af nogle få kilobyte, og en lille model afgør, hændelse for hændelse, om den store model overhovedet skal vækkes.
Den indre løkke er stadig der, hvor agenten tænker. Den ydre løkke er der, hvor den er opmærksom. Salix lader agenten bygge begge.