Jatkuvasti päällä olevien agenttien täytyy valvoa tapahtumia ja reagoida niihin keskeytyksettä, ja lähteitä on paljon enemmän kuin agentti pystyy seuraamaan konteksti-ikkunassaan. Comman hajautettu agenttikehys Salix antaa agenteille mahdollisuuden ladata klusteriin pieniä eBPF-ohjelmia ja ajaa niitä pysyvästi, 24/7: ne valvovat tapahtumia, kysyvät Jeviltä nopeita ja älykkäitä päätöksiä ja herättävät agentin pääsilmukan, kun jotain kiinnostavaa tapahtuu.
Sisäsilmukka ei riitä
Tuttu agenttisilmukka on yksinkertainen: lähetä konteksti mallille, aja sen palauttamat työkalukutsut, liitä tulokset mukaan ja toista. Tämä silmukka on hyvä tekemään työtä. Työn odottamisessa se on huono.
Pyydä agenttia ”kerro, kun sopimus palaa allekirjoitettuna” tai ”valvo tätä repositoriota ja ilmoita kaikesta, mikä koskee laskutusta”, niin sisäsilmukalla on kaksi huonoa vaihtoehtoa. Se voi kysellä: herätä muutaman minuutin välein, lukea saapuneet viestit uudelleen ja käyttää kokonaisen mallikutsun päätelläkseen, ettei mitään tapahtunut. Tai se voi antaa tehtävän heartbeat- tai cron-ajastukselle, mikä on samaa kyselyä pidemmällä välillä ja huonommalla viiveellä. Kummassakin tapauksessa järjestelmän kallein osa, suuri malli lukemassa suurta kontekstia, ajetaan joka tikillä, ja lähes jokainen tikki on hiljainen.
Jatkuvasti päällä oleva agentti tarvitsee oikeasti ulkosilmukan: jotain, joka istuu tapahtumavirran päällä, tekee halvan suodatuksen ja vie asian mallille vain, kun on jotain ajateltavaa. Ja koska jokainen agentti valvoo eri asioita eri tavoin, ulkosilmukka ei voi olla kiinteä tuoteominaisuus. Agentin on kirjoitettava se itse.
Salixissa tätä ulkosilmukkaa kutsutaan nimellä Loop.
Loop on pieni C-ohjelma
Loop on yksi C-tiedosto, jonka agentti kirjoittaa, joka käännetään eBPF:ksi ja jota ajetaan klusterissa sen omistavan agentin vieressä. Jokaisella Loopilla on sama muoto:
Se odottaa ajastinta tai tapahtumaa, tarkistaa jotain ja herättää agentin harvoin. Agentti saa tarkan SDK-otsakkeen ja ohjelmointioppaan loop.sdk-työkalulta, kirjoittaa ohjelman, kääntää sen loop.build-kutsulla ja käynnistää sen loop.create-kutsulla. Mallipohjaa tai DSL:ää ei ole. Jos agentti osaa kuvata valvonnan C:llä, se voi ajaa sen.
Miksi C ja eBPF eikä vaikkapa Python-skripti kontissa? Lukujen takia, joiden ympärille Salix suunniteltiin. Salix ajaa miljoonia agentteja, yli 100 agenttia prosessoriydintä kohden. Agentilla ei voi olla jatkuvasti päällä olevaa hiekkalaatikkoa, joten ei myöskään sen valvonnoilla. Loopin on oltava niin halpa, että se voi olla käynnissä ikuisesti, jokaiselle agentille, jaetulla monivuokralaisella solmulla, samalla kun se ajaa koodia, jota kukaan ihminen ei ole katselmoinut.
eBPF sopii tähän poikkeuksellisen hyvin:
Se on pieni. Odottava Loop vie kymmeniä kilotavuja muistia. Prosessia, tulkkia tai keosta ei ole.
Epäluotettavan koodin ajaminen on turvallista. Kohdealustalla ei ole järjestelmäkutsuja, funktio-osoittimia eikä rajoittamatonta pinoa. Ohjelma voi vain laskea omassa muistissaan ja kutsua isäntäfunktioita, jotka annamme sille.
Odottaminen on halpaa. Loop, joka on pysähtynyt kutsuun
sf_event_nexttaisf_sleep_ms, ei maksa mitään muistinsa lisäksi.
Hintana on rajoitettu murre, ja SDK-opas sanoo sen suoraan: vain kokonaislukuja käyttävää C:tä (ei liukulukuja eikä etumerkillistä jakolaskua), 4 KiB:n pinokehyksiä, enintään 8 kehystä syvyyttä, ei rekursiota, ei sprintf-funktiota ja enintään 128 elävää kahvaa. Mallit pärjäävät tämän kanssa hyvin. Ne osaavat jo C:tä, ja rajoitukset ovat sellaisia, jotka kääntäjän virheilmoitus selittää hyvin.
Spinfoam: ajoympäristö jokaisen Loopin alla
Loopit toimivat spinfoamilla, eBPF-silmukoiden ajoympäristöllämme. Spinfoam on rakennettu async-ebpf:n päälle. Se on käyttäjätilan eBPF-ajoympäristö, joka sopii asynkroniseen koodiin, on täysin keskeyttävä ja jonka ytimen muistiturvallisuus on verifioitu formaalisti.
Käyttäjätilan eBPF, asynkronisena
Spinfoam ei ole ytimen eBPF:ää. Se on tavallinen Rust-prosessi, yksi kutakin Salix-solmua kohden, joka ei tarvitse ytimen eBPF-tukea eikä erityisoikeuksia ja toimii samoin Linuxissa ja macOS:ssä. Salix keskustelee sen kanssa JSON-RPC:llä stdinin ja stdoutin kautta. Vastuunjako on tiukka: spinfoam vastaa paikallisesta suorituksesta, ohjelmien eristyksestä toisistaan, peruuttamisesta ja rajatusta viestien toimituksesta. Salix vastaa kaikesta, minkä täytyy säilyä kaatumisen yli: sijoittelusta, pysyvästä tilasta, uudelleenkäynnistyskäytännöstä, tunnistetiedoista ja verkosta.
Async-ebpf:n ”async” saa Loopin näyttämään tavalliselta peräkkäiseltä C-koodilta. Jokainen ladattu ohjelma saa yhden pitkäikäisen kutsun, joka toimii omassa korutiinissaan. Kun ohjelma kutsuu sf_sleep_ms-, sf_event_next- tai sf_host_call-funktiota, apufunktio keskeyttää korutiinin ja antaa odotuksen Tokiolle. C-pino ja ohjelman globaalit muuttujat pysyvät täsmälleen paikallaan. Kun ajastin laukeaa, tapahtuma saapuu tai Salix vastaa isäntäkutsuun, korutiini jatkaa seuraavalta riviltä. Agentti kirjoittaa for (;;) { wait; check; wake; } eikä koskaan näe takaisinkutsua.
”Täysin keskeyttävä” tekee jakamisesta turvallista. Kaikki vieraskoodi toimii yhdessä Tokio-säikeessä, ja valvontasäie keskeyttää minkä tahansa ohjelman, joka toimii liian kauan luovuttamatta vuoroaan. Loop, joka pyörii tiukassa for (;;) {}-silmukassa, menettää aikaviipaleensa kuten mikä tahansa muu, eikä sen pysäyttäminen vaadi sen yhteistyötä. Yksi virheellinen Loop ei voi jumittaa naapureitaan.
Useimmat Loopit käyttävät lähes kaiken aikansa odottamiseen, joten tämä rakenne pakkaa ne tiiviisti. Hyväksyntäajossamme 10 000 pientä valvovaa Looppia, jotka kaikki oli käännetty sulautetulla kääntäjällä, toimi yhdessä suoritussäikeessä noin 64 kt:n muistinkäytöllä kukin. Kaikkien 10 000:n lataaminen ja käynnistäminen kesti 3,07 sekuntia. Yhden tapahtuman toimittaminen jokaiselle Loopille ja vastaaminen isäntäkutsuun, jonka kukin teki vastauksena, kesti 1,56 sekuntia. Ohjauspyyntöjen p99-viive pysyi alle neljänneksen millisekunnista. Koko prosessi käytti latauksen aikana neljää käyttöjärjestelmän säiettä ja joutilaana kahta.
Muistiturvallisuus, jonka voi tarkistaa
Tuhansien agenttien kirjoittamien ohjelmien ajaminen yhdessä prosessissa on järkevää vain, jos mikään niistä ei voi koskea muistiin, joka ei ole sen omaa. Async-ebpf:ssä tämä takuu alkaa muistin asettelusta ja päättyy koneellisesti tarkistettuihin todistuksiin.
Ensin asettelu. Jokaisen ohjelman data on osoitinhäkissä: varatulla alueella, jota ympäröivät satunnaistetut suojasivut. Vieraan pino on aseteltu yhden kehyksen saarekkeiksi, joita erottavat saavuttamattomat välit, jotka ovat leveämpiä kuin minkään eBPF:n muistikäskyn ulottuma, joten oman kehyksensä yli kulkeva funktio kaatuu eikä lue kutsujansa kehystä. JIT-koodisivut eivät koskaan ole yhtä aikaa kirjoitettavia ja suoritettavia.
Sitten todistukset. Async-ebpf kääntää jokaisen eBPF-funktion konekoodiksi, kun se ajetaan ensimmäisen kerran. Sen x86_64-taustaosa on jäsennetty niin, että päätöksiä tekevä osa voidaan todistaa:
Validoija on pätevä. Jokaisessa sen hyväksymän ohjelman suorituksessa ohjelma ei koskaan päädy määrittelemättömään käskyyn, ei hyppää käskyn keskelle tai ohjelman ulkopuolelle, ja kehysosoitin on aina nykyisen kutsusyvyyden kehyksen alku.
Funktiot ovat suljettuja. Ohjaus ei koskaan poistu funktiosta muuten kuin paikallisen kutsun tai paluun kautta, minkä ansiosta JIT voi kääntää funktion kerrallaan.
Tuotettu koodi on muistiturvallista. Jos tarkistin hyväksyy funktion, sen konekoodin jokainen suoritus koskee vain sallittuja osoitteita (sen kehystä, sen omia vierasalueita, sen omaa pinoa ja literaalivarastoa) ja palaa jättäen kutsujan tilan koskemattomaksi. Laiskasti käännetyt kutsuttavat funktiot yhdistyvät saman lauseen avulla.
Tavut vastaavat mallia. Jokainen assemblerin tuottama käsky purkautuu takaisin samaksi käskyksi, jota malli käsitteli.
Todistukset kytkeytyvät laitteistoon kahdessa kohdassa. Suoritettavan simulaattorin on todistettu olevan Lean-konemallin ajo, ja differentiaalitesti ajaa kaksikymmentätuhatta satunnaista käskyjonoa sekä simulaattorin että oikean prosessorin läpi. Ennen jokaista kutsua ajoympäristö tarkistaa konkreettisen muistiasettelun lauseen oletusta vasten ja kieltäytyy ajamasta, jos oletus ei päde.
Kerromme suoraan, mitä ei ole todistettu. Luotettuun perustaan kuuluvat x86-käskyjen semantiikka (testattu laitteistoa vasten, ei todistettu), sisääntulotrampoliinit, vikakäsittelijä, muistikartoitukset, jokaisen apukutsun isäntäpuoli sekä Charon ja Aeneas itse. Lause koskee muistiturvallisuutta, ei toiminnallista oikeellisuutta tai tietovuotoja. Ja se kattaa vain x86_64-taustaosan; arm64-taustaosa on testattu, mutta ei todistettu.
Todistukset pitävät vieraan sen omassa muistissa. Kaikki, mitä Loop tekee ulkomaailmassa, kulkee isäntäkutsun kautta, ja tämä raja kuuluu Salixille: sallittujen kyvykkyyksien lista ja valtuussäännöt, joita kuvataan alempana.
Myös kääntäjä toimii hiekkalaatikossa
Agentit kääntävät omat Looppinsa, joten myös kääntäjä käsittelee epäluotettavaa syötettä. Spinfoam sulauttaa TinyCC:n, joka on käännetty eBPF:ksi, ja ajaa sitä async-ebpf:llä kuten mitä tahansa muuta vierasta. Jokainen build saa uuden kääntäjäinstanssin, jolla on 8 MiB:n areena, pelkästään muistissa oleva tiedostojärjestelmä, jossa on vain lähetetyt lähdetiedostot ja SDK-otsake, 15 sekunnin aikaraja eikä pääsyä verkkoon, prosesseihin tai isännän tiedostoihin. Myös tuotosta pidetään epäluotettavana. Jokainen objekti käy läpi saman validoinnin latautuessaan, tuli se kääntäjältä tai ei.
Siksi loop.build ei tarvitse solmulla työkaluketjua, konttia eikä erityisoikeuksia. Agentin C-lähdekoodi ei koskaan koske natiiviin kääntäjään.
Agentin herättäminen on kallis osa
Loop toimii itsenäisesti, mutta se on hyödytön, jos se ei voi kertoa agentille mitään. Ainoa tapa siihen on agent.notify:
Ilmoitus toimitetaan Loopin kohde-Sessionille tavallisena viestinä. Vasta silloin agentin pääsilmukka käynnistyy, ja vasta silloin se kuluttaa mallin tokeneita. Hiljainen tunti ei maksa tokeneita lainkaan.
dedup_key on tärkeämpi kuin miltä näyttää. Looppeja käynnistetään uudelleen, tapahtumia toimitetaan uudelleen, ja vieras voi yrittää uudelleen kutsua, jonka onnistumisesta se ei ole varma. Salix toimittaa herätyksen muodossa loop:<id>:<dedup_key>, joten sama sähköposti, tiketti tai hälytys herättää agentin kerran.
Koska herätys on kallis osa, sille on myös budjetti. Loop saa herättää agenttinsa kuusi kertaa kymmenessä minuutissa. Loop, joka pysyy tällä rajalla tunnin ajan, keskeytetään syyllä budget, ja agentille kerrotaan siitä. Virheellisestä tai liian innokkaasta Loopista tulee näkyvä, keskeytetty Loop eikä lasku.
Nopeita päätöksiä ilman suurta mallia
Valvonnan vaikein osa ei yleensä ole ”muuttuiko jokin” vaan ”onko tällä muutoksella väliä”. Onko tämä sähköposti sellainen, johon omistajan on reagoitava tänään? Koskeeko tämä PR järjestelmän osia, joista välitämme? Kokonaisluku-C:llä kirjoitettu Loop ei pysty vastaamaan tähän itse, ja huippumallin kutsuminen jokaisesta tapahtumasta veisi meidät takaisin lähtöpisteeseen.
Siksi Loopit saavat vielä yhden kyvykkyyden: decide. Se lähettää pienen, tyypitetyn kysymyksen nopealle päätösmallille (Jev) ja saa takaisin jäsennellyn vastauksen. Tässä kysymys sähköpostinvalvonnan prototyypistämme:
decide tukee choice-tyyppiä yhden vaihtoehdon valitsemiseen, erillisiä kyllä/ei-kysymyksiä useille osumille ja score-tyyppiä järjestettyyn relevanssiin. Se palauttaa aina vain päätöksen. Se ei lue lähteitä, aja työkaluja eikä myönnä valtuuksia. Loop antaa datan, esittää kysymyksen ja soveltaa kynnysarvoa omassa koodissaan.
Kaksi suunnitteluratkaisua sai tämän toimimaan hyvin:
Epävarmuus on kelvollinen vastaus. Eksplisiittinen
none- taidefer-valinta tarkoittaa ”en osaa sanoa”, ja se on onnistunut tulos, ei siirtovirhe. Loop säilyttää tapahtuman sen sijaan, että keksisi hiljaisen päätöksen.Se on niin halpa, että sen voi ajaa jokaisesta tapahtumasta. Hinnastossamme
typesafe/jev-1.13.0maksaa $0.042 miljoonaa syötetokenia kohden, ja tulostokenit ovat ilmaisia. Se on niin halpa, että sillä voi seuloa jokaisen sähköpostin eikä vain niitä, jotka avainsanasuodatin päästää läpi.
Tuloksena on kaksitasoinen järjestelmä: pieni malli seuloo jokaisen tapahtuman, ja suuri malli näkee vain ne, jotka pääsevät läpi.
Tapahtumat sisään, pysyvästi
Ajastimet riittävät kyselyyn, mutta monet lähteet osaavat lähettää itse. Loopilla on postilaatikko, ja ulkoiset järjestelmät syöttävät sitä kolmella tavalla:
Salix-API:
POST /v1/agent-groups/:group_id/loops/:loop_id/eventsryhmän API-avaimella;salainen webhook-osoite, jonka agentti ottaa käyttöön, vaihtaa tai peruu
loop.webhook-kutsulla;Composio-triggerit käyttäjän yhdistetyistä sovelluksista; ne saapuvat palveluntarjoajan tapahtumatunnuksen ja trigger-slugin kanssa.
Kun mikä tahansa näistä palauttaa 202, PostgreSQL on säilyttänyt tapahtuman sen Loopille. Se ei tarkoita, että Loop olisi käsitellyt sen. Vieras kutsuu loop.ack-kutsua, kun se on päässyt turvalliseen kohtaan: hiljaiseen päätökseen tai pysyvään luovutukseen, kuten onnistuneeseen agent.notify-kutsuun. Siihen asti tapahtuma odottaa, ja Reconciler toistaa odottavat tapahtumat, kun Loop siirtyy tai käynnistyy uudelleen, ja joka tapauksessa kerran minuutissa. Tapahtuma, jota ei kuitata 15 minuutin kuluessa, kaataa Loopin näkyvästi, ja odottavat tapahtumat säilytetään, jotta agentti voi yrittää uudelleen tai hylätä ne.
Yksi opetus tätä rakentaessa: spinfoamin postilaatikko poistaa tapahtumatunnusten kaksoiskappaleet vastaanotossa, ei liiketoiminnan valmistuessa. Varhaisessa prototyypissä odotimme, että epäonnistunut mallikutsu yritetään uudelleen, kun palveluntarjoaja toimittaa tapahtuman uudelleen. Niin ei käynyt; uudelleentoimitus pudotettiin oikein kaksoiskappaleena. Siksi uudelleenyritykset kuuluvat vieraalle, joka säilyttää tapahtuman ja yrittää uudelleen rajatun määrän kertoja, ja kaatumisen jälkeinen palautuminen kuuluu pysyvälle postilaatikolle. Kirjoitamme tämän säännön nyt SDK-oppaaseen.
Kuittaus, pysyvä vastaanotto Sessioniin, näkyvä viesti ja ulkoinen vaikutus ovat neljä erillistä tosiasiaa. Annamme jokaiselle oman omistajan, vaadimme alavirrassa vakaat deduplikointiavaimet emmekä lupaa ulkoisille vaikutuksille täsmälleen kerran -takuuta.
Ikuisesti käynnissä liikkuvassa klusterissa
”24/7” on helppo sanoa ja vaikea toteuttaa klusterissa, jossa solmut tulevat ja menevät. Loop toimii solmulla, jolla on sen agentin vuokrasopimus, ja se seuraa tätä vuokrasopimusta.
Sijoittelu. Kun agentin palvelinprosessi ottaa vuokrasopimuksensa, se ottaa hoitaakseen aktiiviset Looppinsa. Kun agentti passivoidaan tai eristetään, se vapauttaa ne. Aktiivinen Loop pitää agenttinsa muistissa, joten joutilas agentti, jolla on Loop, uusii vuokrasopimuksensa sen sijaan, että siirtyisi lepoon.
Inkarnaatiot. Jokainen Loopin lataus kasvattaa sen inkarnaatiota, joka toimii Loopin rivillä fencing-tunnisteena. Isäntäkutsu objektilta, joka ei ole nykyinen inkarnaatio, hylätään, samoin sen
ack. Poistuvalla solmulla oleva vanhentunut Loopin kopio ei voi toimia.Tarkistuspisteet.
loop.state.puttallentaa enintään 16 KiB, ja seuraava lataus saa sen takaisin muodossaconfig.state. Agentit tallentavat kursorit ja viimeksi nähdyt tunnukset, eivät kaikkea.Viat. Vika lataa Loopin uudelleen sen tarkistuspisteestä enintään kolme kertaa tunnissa. Sen jälkeen se on
failed, ja agentille kerrotaan siitä kerran.Orvot Loopit. Säännöllinen läpikäynti etsii aktiiviset Loopit, joihin ei ole liitetty objektia tai jotka on liitetty poistuneelle solmulle, ja käynnistää niiden omistajan uudelleen.
Loopeilla on myös totuuden lähde ajoympäristön ulkopuolella. loop.build kirjoittaa käännetyn ELF-tiedoston agentin tiedostojärjestelmään, ja loop.create tallentaa sen polun ja SHA-256-tiivisteen. Jokainen lataus lukee tiedoston uudelleen ja tarkistaa tiivisteen, joten Loop ajaa täsmälleen sen ohjelman, jolla se luotiin, tai ei mitään.
Mitä Loop saa tehdä
Loop on mallin kirjoittamaa koodia, joka toimii valvomatta ympäri vuorokauden. Sen valtuuksien on oltava agentin valtuuksia pienemmät, ei yhtä suuret.
Loopit kutsuvat suljettua sallittujen isäntäkyvykkyyksien listaa: agent.notify, kutsut loop.state.*, loop.ack ja loop.log, vain luku -työkaluiksi luokitellut Salix-työkalut, ympäristö- ja laitetyökalut, SSH-työkalut, keskustelun lukeminen, web.http_request, composio.execute yhdistetyille sovelluksille sekä decide. Spinfoam hylkää kaikki muut nimet koodilla SF_DENIED. Erillistä myöntövaihetta, jonka voisi tehdä väärin, ei ole.
Jokainen kutsu kulkee saman työkalujen välityksen ja tietovirtatarkistusten läpi kuin tavallinen agentin vuoro. Loop toimii luojanaan delegoidun päämiehen schedule|loop:<id>|<creator> kautta, luojan paljastussäännöillä ja Loopin sinetöidyllä alkuperällä. Tapahtumien sisältö on dataa. Se ei kanna valtuuksia eikä voi laajentaa sitä, mitä Loop saa tehdä, minkä vuoksi yllä oleva päätöskehote käsittelee sähköpostia nimenomaisesti epäluotettavana syötteenä.
Lopuksi kiintiöt pitävät järjestelmän rajattuna: 20 aktiivista Looppia agenttia kohden, 100 ryhmää kohden, 32 odottavaa 16 KiB:n tapahtumaa Looppia kohden sekä nopeusrajoitukset webhook-liikenteelle.
Skriptit: sama ajoympäristö yhdelle vuorolle
Kun meillä oli turvallinen ja halpa ajoympäristö agenttien kirjoittamalle C:lle, sille löytyi toinen käyttö. script.run kääntää ja ajaa kokonaisluku-C-ohjelman kerran, yhden työkalukutsun sisällä, ja ohjelmalla on pääsy kutsuvan vuoron omiin työkaluihin salix.call-kutsun kautta. Skriptillä ei ole riviä, inkarnaatiota, tarkistuspistettä eikä agent.notify-kutsua. Sen avulla agentti voi koota monta työkalukutsua yhdeksi deterministiseksi ohjelmaksi monen mallikierroksen sijaan. Kaksi toimittamistamme skilleistä on tällä tavalla ajettavia C-ohjelmia.
Agentit, jotka ohjelmoivat oman tapahtumajärjestelmänsä
Kokonaisuutena valvomaan pyydetty Comma-agentti tekee kolme asiaa, joihin perinteiset agentit eivät pysty: se kirjoittaa valvonnan ohjelmaksi, klusteri ajaa ohjelmaa niin kauan kuin sitä tarvitaan muutaman kilotavun hinnalla, ja pieni malli päättää tapahtuma kerrallaan, tarvitseeko suurta mallia herättää lainkaan.
Sisäsilmukka on yhä paikka, jossa agentti ajattelee. Ulkosilmukka on paikka, jossa se kiinnittää huomiota. Salixin avulla agentti rakentaa molemmat.