Agenter som alltid er på, må hele tiden følge med på og reagere på hendelser fra langt flere kilder enn de kan holde styr på i kontekstvinduet. Salix, den distribuerte harnessen til Comma, gir agenter muligheten til å laste små eBPF-programmer inn i klyngen og kjøre dem vedvarende, 24/7, for å overvåke hendelser, kalle Jev for raske, intelligente avgjørelser og vekke hovedløkken til agenten når noe interessant skjer.
Den indre løkken er ikke nok
Den kjente agentløkken er enkel: send konteksten til en modell, kjør verktøykallene den returnerer, legg til resultatene, gjenta. Den løkken er god til å gjøre arbeid. Den er dårlig til å vente på arbeid.
Be en agent om å «si fra når kontrakten kommer tilbake signert» eller «følg med på dette repoet og flagg alt som berører fakturering», og den indre løkken har to dårlige valg. Den kan polle: våkne med noen minutters mellomrom, lese innboksen på nytt og bruke et helt modellkall på å fastslå at ingenting har skjedd. Eller den kan overlate jobben til en heartbeat eller en cron-plan, som er den samme pollingen med lengre intervall og dårligere forsinkelse. Uansett kjører den dyre delen av systemet, en stor modell som leser en stor kontekst, på hvert tikk, og nesten hvert tikk er stille.
Det en agent som alltid er på, egentlig trenger, er en ytre løkke: noe som sitter på hendelsesstrømmen, gjør den billige filtreringen og bare eskalerer til modellen når det er noe verdt å tenke over. Og fordi hver agent følger med på forskjellige ting på forskjellige måter, kan ikke den ytre løkken være en fast produktfunksjon. Agenten må skrive den selv.
I Salix heter den ytre løkken en Loop.
En Loop er et lite C-program
En Loop er én enkelt C-fil, skrevet av agenten, kompilert til eBPF og kjørt på klyngen ved siden av agenten som eier den. Alle Loops har samme form:
Den venter på en timer eller en hendelse, sjekker noe og vekker en sjelden gang agenten. Agenten får den nøyaktige SDK-headeren og en programmeringsveiledning fra verktøyet loop.sdk, skriver programmet, kompilerer det med loop.build og starter det med loop.create. Det finnes ingen mal og ingen DSL. Kan agenten beskrive overvåkingen i C, kan den kjøre den.
Hvorfor C og eBPF, og ikke for eksempel et Python-skript i en container? På grunn av tallene vi designet Salix rundt. Salix kjører millioner av agenter, med mer enn 100 agenter per CPU-kjerne. En agent kan ikke ha en sandkasse som alltid står på, og dermed kan heller ikke overvåkingene dens det. En Loop må være billig nok til å kjøre for alltid, for hver agent, på en delt node med flere leietakere, mens den kjører kode som ingen mennesker har gått gjennom.
eBPF passer uvanlig godt til det:
Det er lite. En Loop som venter, bruker noen titalls kilobyte residentminne. Det finnes ingen prosess, ingen tolk og ingen heap.
Det er trygt å kjøre kode du ikke stoler på. Målet har ingen systemkall, ingen funksjonspekere og ingen ubegrenset stakk. Alt et program kan gjøre, er å regne på sitt eget minne og kalle vertsfunksjonene vi gir det.
Det er billig å vente. En Loop som er blokkert i
sf_event_nextellersf_sleep_ms, koster ingenting utover minnet sitt.
Prisen er en begrenset dialekt, og SDK-veiledningen er rett på sak om det: bare heltalls-C (ingen flyttall, ingen fortegnsdivisjon), stakkrammer på 4 KiB, høyst 8 rammer dypt, ingen rekursjon, ingen sprintf og høyst 128 levende håndtak. Modellene viser seg å takle dette fint. De kan allerede C, og begrensningene er av typen som en kompilatorfeil forklarer godt.
Spinfoam: kjøremiljøet under hver Loop
Loops kjører på spinfoam, vårt eBPF-kjøremiljø for løkker. Spinfoam er bygget på async-ebpf, et eBPF-kjøremiljø i brukerrommet som fungerer godt med async, er fullt preemptivt og har formelt verifisert minnesikkerhet i kjernen.
eBPF i brukerrommet, gjort asynkront
Spinfoam er ikke kjerne-eBPF. Det er en vanlig Rust-prosess, én per Salix-node, som verken trenger eBPF-støtte i kjernen eller privilegier, og som kjører likt på Linux og macOS. Salix snakker med den over JSON-RPC på stdin og stdout. Ansvarsfordelingen er streng: spinfoam eier lokal kjøring, isolasjon mellom programmer, avbrytelse og begrenset meldingslevering. Salix eier alt som må overleve et krasj: plassering, varig tilstand, omstartspolicy, påloggingsinformasjon og nettverket.
«Async» i async-ebpf er det som får en Loop til å se ut som vanlig sekvensiell C. Hvert lastet program får ett langlivet kall, som kjører på sin egen korutine. Når programmet kaller sf_sleep_ms, sf_event_next eller sf_host_call, suspenderer hjelpefunksjonen korutinen og overlater ventingen til Tokio. C-stakken og de globale variablene til programmet blir liggende nøyaktig der de var. Når timeren går av, hendelsen kommer eller Salix svarer på vertskallet, fortsetter korutinen på neste linje. Agenten skriver for (;;) { wait; check; wake; } og ser aldri en callback.
«Fullt preemptivt» er det som gjør dette trygt å dele. All gjestekode kjører på én Tokio-tråd, og en vakttråd avbryter ethvert program som kjører for lenge uten å gi fra seg kontrollen. En Loop som spinner i en tett for (;;) {}, mister tidsluken sin som alle andre, og det trengs ikke samarbeid fra den for å stoppe den. Én Loop med feil kan ikke stoppe naboene sine.
De fleste Loops bruker nesten all tiden på å vente, så denne designen pakker tett. I kvalifiseringskjøringen vår kjørte 10 000 små overvåkings-Loops, alle kompilert av den innebygde kompilatoren, på én kjøringstråd med omtrent 64 KB residentminne hver. Å laste og starte alle 10 000 tok 3,07 sekunder. Å levere én hendelse til hver Loop, og svare på vertskallet hver av dem gjorde som respons, tok 1,56 sekunder. Kontrollforespørsler holdt seg under et kvart millisekund ved p99. Hele prosessen brukte fire OS-tråder under lasting og to i tomgang.
Minnesikkerhet du kan kontrollere
Å kjøre tusenvis av agentskrevne programmer i én prosess er bare forsvarlig hvis ingen av dem kan røre minne som ikke er deres. I async-ebpf starter den garantien med minneoppsettet og ender med maskinkontrollerte bevis.
Først oppsettet. Dataene til hvert program ligger inne i et pekerbur: et reservert område med randomiserte vaktsider rundt. Gjestestakken er lagt opp som øyer på én ramme hver, adskilt av utilgjengelige mellomrom som er bredere enn noen eBPF-minneinstruksjon kan nå, så en funksjon som går utenfor sin egen ramme, feiler i stedet for å lese rammen til den som kalte den. JIT-kodesider er aldri skrivbare og kjørbare samtidig.
Så bevisene. async-ebpf kompilerer hver eBPF-funksjon til maskinkode første gang den kjører. x86_64-backenden er strukturert slik at delen som tar avgjørelsene, kan bevises:
Validatoren er sunn. Langs hver kjøring av et program den godtar, når programmet aldri en udefinert instruksjon, hopper aldri inn midt i en instruksjon eller ut av programmet, og rammepekeren er alltid rammebasen for gjeldende kalldybde.
Funksjoner er lukkede. Kontrollen forlater aldri en funksjon unntatt gjennom et lokalt kall eller en retur, og det er dette som lar JIT-en kompilere én funksjon om gangen.
Den genererte koden er minnesikker. Hvis kontrollen godtar en funksjon, berører hver kjøring av maskinkoden bare et tillatt sett med adresser (rammen, sine egne gjesteområder, sin egen stakk og literal pool) og returnerer med tilstanden til den som kalte, intakt. Funksjoner som kompileres senere ved behov, settes sammen med det samme teoremet.
Bytene sier det modellen sier. Hver instruksjon assembleren sender ut, dekodes tilbake til instruksjonen modellen resonnerte om.
Bevisene kobles til maskinvaren på to steder. En kjørbar simulator er bevist å være en kjøring av Lean-maskinmodellen, og en differensiell test kjører tjue tusen tilfeldige instruksjonssekvenser gjennom både simulatoren og den ekte prosessoren. Før hvert kall kontrollerer kjøremiljøet det konkrete minneoppsettet mot hypotesen i teoremet, og nekter å kjøre hvis den ikke holder.
Vi er tydelige på hva som ikke er bevist. Den betrodde basen omfatter x86-instruksjonssemantikken (testet mot maskinvare, ikke bevist), inngangstrampolinene, feilhåndtereren, minnemappingene, vertssiden av hvert hjelpekall, og Charon og Aeneas selv. Teoremet handler om minnesikkerhet, ikke funksjonell korrekthet eller informasjonslekkasjer. Og det dekker bare x86_64-backenden; arm64-backenden er testet, men ikke bevist.
Bevisene holder gjesten i sitt eget minne. Alt en Loop gjør i omverdenen, går gjennom et vertskall, og den grensen tilhører Salix: funksjonslisten og myndighetsreglene som beskrives nedenfor.
Kompilatoren kjører også i sandkassen
Agentene kompilerer sine egne Loops, så kompilatoren er også et sted for upålitelig input. Spinfoam bygger inn TinyCC, kompilert til eBPF, og kjører den på async-ebpf som enhver annen gjest. Hvert bygg får en fersk kompilatorinstans med en arena på 8 MiB, et filsystem bare i minnet som kun inneholder de innsendte kildefilene og SDK-headeren, en tidsfrist på 15 sekunder, og ingen tilgang til nettverk, prosesser eller filer på verten. Resultatet behandles også som upålitelig. Hvert objekt går gjennom den samme valideringen når det lastes, enten det kom fra kompilatoren eller ikke.
Derfor trenger loop.build verken verktøykjede, container eller privilegier på noden. C-kildekoden til agenten kommer aldri i nærheten av en native kompilator.
Å vekke agenten er den dyre delen
En Loop kjører på egen hånd, men den er ubrukelig hvis den ikke kan si noe til agenten. Den eneste måten den gjør det på, er agent.notify:
Varselet leveres til mål-Sessionen til Loopen som en vanlig melding. Først da kjører hovedløkken til agenten, og først da bruker den modelltokens. En stille time koster ingen tokens i det hele tatt.
dedup_key betyr mer enn det ser ut til. Loops blir startet på nytt, hendelser blir levert på nytt, og en gjest kan prøve et kall igjen når den ikke er sikker på at det lyktes. Salix leverer vekkingen som loop:<id>:<dedup_key>, så den samme e-posten, saken eller varslingen vekker agenten én gang.
Fordi en vekking er den dyre delen, har den også et budsjett. En Loop kan vekke agenten sin seks ganger per ti minutter. En Loop som ligger på den grensen i en time, settes på pause med årsaken budget, og agenten får beskjed. En Loop med feil eller for mye iver ender som en synlig, pauset Loop, ikke som en regning.
Raske avgjørelser uten en stor modell
Det vanskelige med en overvåking er sjelden «har noe endret seg», men «betyr denne endringen noe». Er denne e-posten noe eieren må handle på i dag? Berører denne PR-en de delene av systemet vi bryr oss om? En Loop skrevet i heltalls-C kan ikke svare på det alene, og å kalle en frontmodell for hver hendelse ville sendt oss rett tilbake til start.
Derfor får Loops én funksjon til: decide. Den sender et lite, typet spørsmål til en rask beslutningsmodell (Jev) og får tilbake et strukturert svar. Her er spørsmålet fra prototypen vår for e-postovervåking:
decide støtter choice for å velge én kandidat, separate ja/nei-spørsmål for flere treff og score for rangert relevans. Den returnerer aldri annet enn en avgjørelse. Den leser ikke kilder, kjører ikke verktøy og gir ikke myndighet. Loopen leverer dataene, stiller spørsmålet og bruker terskelen i sin egen kode.
To designvalg fikk dette til å fungere godt:
Usikkerhet er et gyldig svar. Et eksplisitt
none- ellerdefer-valg betyr «jeg kan ikke avgjøre det», og det er et vellykket resultat, ikke en transportfeil. Loopen beholder hendelsen i stedet for å finne på en stille avgjørelse.Det er billig nok til å kjøre på hver hendelse. Priskatalogen vår oppfører
typesafe/jev-1.13.0til $0.042 per million input-tokens, med gratis output-tokens. Det er billig nok til å sjekke hver e-post, ikke bare dem et nøkkelordfilter slipper gjennom.
Resultatet er et system i to nivåer: en liten modell sjekker hver hendelse, og den store modellen ser bare dem som slipper gjennom.
Hendelser inn, varig lagret
Timere holder for polling, men mange kilder kan pushe. En Loop har en postkasse, og eksterne systemer mater den på tre måter:
Salix-API-et:
POST /v1/agent-groups/:group_id/loops/:loop_id/eventsmed en API-nøkkel for gruppen;en hemmelig webhook-URL, som agenten slår på, roterer eller trekker tilbake med
loop.webhook;Composio-triggere fra appene brukeren har koblet til, som kommer med hendelses-ID-en og trigger-sluggen fra leverandøren.
En 202 fra noen av disse betyr at PostgreSQL har tatt vare på hendelsen hos Loopen. Det betyr ikke at Loopen har behandlet den. Gjesten kaller loop.ack når den har nådd et trygt punkt: en stille avgjørelse, eller en varig overlevering som en vellykket agent.notify. Fram til da forblir hendelsen ventende, og Reconciler spiller av ventende hendelser på nytt etter at Loopen flytter eller starter på nytt, og uansett én gang i minuttet. En hendelse som ikke er bekreftet innen 15 minutter, får Loopen til å feile synlig, og de ventende hendelsene beholdes så agenten kan prøve igjen eller forkaste dem.
Én lærdom fra arbeidet med dette: postkassen i spinfoam fjerner duplikate hendelses-ID-er ved mottak, ikke når forretningslogikken er fullført. I en tidlig prototype forventet vi at et mislykket modellkall ville bli prøvd igjen ved at leverandøren leverte hendelsen på nytt. Det skjedde ikke; den nye leveringen ble helt korrekt forkastet som duplikat. Derfor hører nye forsøk hjemme hos gjesten, som beholder hendelsen og prøver igjen et begrenset antall ganger, mens gjenoppretting etter et krasj hører hjemme i den varige innboksen. Den regelen skriver vi nå inn i SDK-veiledningen.
Bekreftelse, varig mottak i Sessionen, en synlig melding og en ekstern effekt er fire separate fakta. Vi gir hver av dem sin egen eier, krever stabile dedupliseringsnøkler nedstrøms og lover ikke eksterne effekter nøyaktig én gang.
Å kjøre for alltid på en klynge i bevegelse
«24/7» er lett å si og vanskelig å få til på en klynge der noder kommer og går. En Loop kjører på noden som har leasen til agenten, og den følger den leasen.
Plassering. Når serverprosessen til en agent gjør krav på leasen sin, overtar den de aktive Loopene sine. Når agenten passiviseres eller fences, slipper den dem. En aktiv Loop holder agenten sin resident, så en inaktiv agent med en Loop fornyer leasen i stedet for å parkeres.
Inkarnasjoner. Hver lasting av en Loop øker inkarnasjonen, en fence-verdi på raden til Loopen. Et vertskall fra et objekt som ikke er gjeldende inkarnasjon, avvises, og det samme gjør en
ackfra et slikt objekt. En utdatert kopi av en Loop på en node som forsvinner, kan ikke handle.Sjekkpunkter.
loop.state.putlagrer opptil 16 KiB, og neste lasting får det tilbake somconfig.state. Agentene lagrer markører og sist sette ID-er, ikke alt.Feil. En feil laster Loopen på nytt fra sjekkpunktet, høyst tre ganger i timen. Etter det er den
failed, og agenten får beskjed én gang.Strandede Loops. En periodisk gjennomgang finner aktive Loops uten tilknyttet objekt, eller tilknyttet en node som har forsvunnet, og starter eieren deres på nytt.
Loops har også en sannhetskilde utenfor kjøremiljøet. loop.build skriver den kompilerte ELF-filen til filsystemet til agenten, og loop.create registrerer stien og SHA-256-verdien. Hver lasting leser filen på nytt og kontrollerer hashen, så en Loop kjører nøyaktig programmet den ble opprettet med, eller ingenting.
Dette har en Loop lov til å gjøre
En Loop er kode skrevet av en modell, som kjører uten tilsyn døgnet rundt. Myndigheten dens må være mindre enn agentens, ikke lik den.
Loops kaller en lukket tillatelsesliste med vertsfunksjoner: agent.notify, kallene loop.state.*, loop.ack og loop.log, Salix-verktøy som er klassifisert som skrivebeskyttede, miljø- og enhetsverktøy, SSH-verktøy, lesing av en samtale, web.http_request, composio.execute for tilkoblede apper, og decide. Spinfoam avviser alle andre navn med SF_DENIED. Det finnes ikke noe tildelingssteg som kan bli feil.
Hvert kall går gjennom den samme verktøyfordelingen og de samme kontrollene av informasjonsflyt som en vanlig agenttur. En Loop handler som skaperen sin, gjennom en delegert prinsipal schedule|loop:<id>|<creator>, med skaperens regler for deling og Loopens forseglede opprinnelse. Hendelsesinnhold er data. Det har ingen myndighet og kan ikke utvide det Loopen har lov til, og derfor behandler beslutningsprompten ovenfor e-posten eksplisitt som upålitelig input.
Til slutt holder kvoter systemet begrenset: 20 aktive Loops per agent, 100 per gruppe, 32 ventende hendelser på 16 KiB hver per Loop, og hastighetsgrenser på innkommende webhooks.
Skript: det samme kjøremiljøet, for én tur
Da vi hadde et trygt og billig kjøremiljø for agentskrevet C, dukket det opp et nytt bruksområde. script.run kompilerer og kjører et program i heltalls-C én gang, inne i ett enkelt verktøykall, med tilgang til verktøyene i den kallende turen gjennom salix.call. Et skript har ingen rad, ingen inkarnasjon, ikke noe sjekkpunkt og ingen agent.notify. Det er en måte for en agent å samle mange verktøykall i ett deterministisk program i stedet for mange runder med modellen. To av skillene vi leverer, er C-programmer som kjøres på denne måten.
Agenter som programmerer sitt eget hendelsessystem
Til sammen gjør en Comma-agent som blir bedt om å holde vakt, tre ting som tradisjonelle agenter ikke kan: den skriver overvåkingen som et program, klyngen kjører programmet så lenge det trengs, for prisen av noen få kilobyte, og en liten modell avgjør, hendelse for hendelse, om den store modellen i det hele tatt trenger å våkne.
Den indre løkken er fortsatt der agenten tenker. Den ytre løkken er der den følger med. Salix lar agenten bygge begge.