Agentes sempre ativos precisam observar e reagir a eventos continuamente, vindos de muito mais fontes do que cabem na janela de contexto. O Salix, harness distribuído do Comma, dá aos agentes a capacidade de carregar pequenos programas eBPF no cluster e mantê-los em execução persistente, 24/7, para monitorar eventos, chamar o Jev para decisões rápidas e inteligentes e acordar o loop principal do agente quando algo interessante acontece.
O loop interno não basta
O loop de agente que todos conhecem é simples: envie o contexto a um modelo, execute as chamadas de ferramenta que ele devolver, anexe os resultados e repita. Esse loop é bom em fazer trabalho. É ruim em esperar por trabalho.
Peça a um agente para “me avisar quando o contrato voltar assinado” ou “acompanhar este repositório e sinalizar qualquer coisa que mexa em billing”, e o loop interno fica com duas opções ruins. Ele pode fazer polling: acordar a cada poucos minutos, reler a caixa de entrada e gastar uma chamada inteira de modelo para concluir que nada aconteceu. Ou pode delegar a tarefa a um heartbeat ou a um agendamento cron, que é o mesmo polling com intervalo maior e latência pior. De qualquer forma, a parte cara do sistema, um modelo grande lendo um contexto grande, roda a cada tick, e quase todo tick é um tick vazio.
O que um agente sempre ativo realmente precisa é de um loop externo: algo que fique sobre o fluxo de eventos, faça a filtragem barata e só escale para o modelo quando houver algo que mereça reflexão. E como cada agente observa coisas diferentes de jeitos diferentes, esse loop externo não pode ser um recurso fixo do produto. O próprio agente tem que escrevê-lo.
No Salix, esse loop externo se chama Loop.
Um Loop é um pequeno programa em C
Um Loop é um único arquivo C, escrito pelo agente, compilado para eBPF e executado no cluster ao lado do agente a que pertence. Todo Loop tem a mesma forma:
Ele espera um timer ou um evento, verifica alguma coisa e, raramente, acorda o agente. O agente obtém o header exato do SDK e um guia de programação pela ferramenta loop.sdk, escreve o programa, compila com loop.build e o inicia com loop.create. Não há template nem DSL. Se o agente consegue descrever a observação em C, consegue executá-la.
Por que C e eBPF, e não, digamos, um script Python num contêiner? Por causa dos números em torno dos quais projetamos o Salix. O Salix roda milhões de agentes, com mais de 100 agentes por núcleo de CPU. Um agente não pode ter um sandbox sempre ligado, então suas observações também não podem. Um Loop precisa ser barato o bastante para continuar rodando para sempre, para cada agente, em um nó compartilhado e multi-tenant, executando código que nenhum humano revisou.
O eBPF atende a esses requisitos excepcionalmente bem:
É pequeno. Um Loop em espera ocupa dezenas de kilobytes de memória residente. Não há processo, nem interpretador, nem heap.
É seguro rodar código não confiável. O alvo não tem syscalls, nem ponteiros de função, nem pilha ilimitada. Tudo o que um programa pode fazer é computar sobre a própria memória e chamar as funções de host que entregamos a ele.
Esperar é barato. Um Loop bloqueado em
sf_event_nextousf_sleep_msnão custa nada além da sua memória.
O preço é um dialeto restrito, e o guia do SDK é direto quanto a isso: apenas C com inteiros (sem ponto flutuante, sem divisão com sinal), stack frames de 4 KiB, no máximo 8 frames de profundidade, sem recursão, sem sprintf e no máximo 128 handles vivos. Na prática, os modelos lidam bem com isso. Eles já sabem C, e essas restrições são do tipo que um erro de compilação explica bem.
Spinfoam: o runtime por trás de cada Loop
Os Loops rodam no spinfoam, o nosso runtime de loops eBPF. O spinfoam é construído sobre o async-ebpf, um runtime eBPF em espaço de usuário compatível com async, totalmente preemptivo e com segurança de memória formalmente verificada em seu núcleo.
eBPF em espaço de usuário, agora assíncrono
O spinfoam não é eBPF de kernel. É um processo Rust comum, um por nó do Salix, que não exige suporte a eBPF no kernel nem privilégios, e roda igual no Linux e no macOS. O Salix conversa com ele via JSON-RPC por stdin e stdout. A divisão de responsabilidades é rígida: o spinfoam cuida da execução local, do isolamento entre programas, do cancelamento e da entrega limitada de mensagens. O Salix cuida de tudo o que precisa sobreviver a uma falha: alocação, estado durável, política de reinício, credenciais e rede.
O “async” de async-ebpf é o que faz um Loop parecer C sequencial comum. Cada programa carregado recebe uma única invocação de longa duração, rodando na sua própria corrotina. Quando o programa chama sf_sleep_ms, sf_event_next ou sf_host_call, o helper suspende a corrotina e entrega a espera ao Tokio. A pilha C e as variáveis globais do programa ficam exatamente onde estavam. Quando o timer dispara, o evento chega ou o Salix responde à chamada de host, a corrotina retoma na linha seguinte. O agente escreve for (;;) { wait; check; wake; } e nunca vê um callback.
O “totalmente preemptivo” é o que torna seguro compartilhar. Todo o código guest roda em uma única thread do Tokio, e uma thread de vigilância interrompe qualquer programa que rode por tempo demais sem ceder a vez. Um Loop que gira num for (;;) {} apertado perde sua fatia de tempo como qualquer outro, e pará-lo não depende da cooperação dele. Um Loop com bug não consegue travar os vizinhos.
A maioria dos Loops passa quase todo o tempo esperando, então esse design permite uma densidade alta. Na nossa rodada de qualificação, 10.000 Loops de monitoramento pequenos, todos compilados pelo compilador embutido, rodaram em uma única thread de execução com cerca de 64 KB de memória residente cada. Carregar e iniciar os 10.000 levou 3,07 segundos. Entregar um evento a cada Loop, e responder à chamada de host que cada um fez em resposta, levou 1,56 segundo. As requisições de controle ficaram abaixo de um quarto de milissegundo no p99. O processo inteiro usou quatro threads do sistema operacional durante o carregamento e duas quando ocioso.
Segurança de memória que dá para verificar
Rodar milhares de programas escritos por agentes em um único processo só é razoável se nenhum deles puder tocar em memória que não é sua. No async-ebpf, essa garantia começa no layout de memória e termina em provas verificadas por máquina.
Primeiro, o layout. Os dados de cada programa ficam dentro de uma gaiola de ponteiros: uma região reservada cercada por páginas de guarda aleatorizadas. A pilha do guest é organizada em ilhas de um frame cada, separadas por lacunas inacessíveis mais largas do que qualquer instrução de memória eBPF consegue alcançar, de modo que uma função que sai do próprio frame gera uma falha em vez de ler o frame de quem a chamou. As páginas de código do JIT nunca são graváveis e executáveis ao mesmo tempo.
Depois, as provas. O async-ebpf compila cada função eBPF para código nativo na primeira vez em que ela roda. Seu backend x86_64 é estruturado de modo que a parte que toma decisões possa ser provada:
O validador é correto. Em toda execução de um programa que ele aceita, o programa nunca chega a uma instrução indefinida, nunca salta para o meio de uma instrução ou para fora do programa, e o frame pointer é sempre a base do frame para a profundidade de chamada atual.
As funções são fechadas. O controle nunca sai de uma função a não ser por uma chamada local ou um retorno, e é isso que permite ao JIT compilar uma função de cada vez.
O código gerado é seguro em memória. Se o verificador aceita uma função, toda execução do seu código nativo toca apenas um conjunto permitido de endereços (seu frame, suas próprias regiões de guest, sua própria pilha e seu literal pool) e retorna com o estado do chamador intacto. Funções chamadas compiladas sob demanda se compõem pelo mesmo teorema.
Os bytes dizem o que o modelo diz. Cada instrução emitida pelo assembler é decodificada de volta na instrução sobre a qual o modelo raciocinou.
As provas se conectam ao hardware em dois pontos. Prova-se que um simulador executável é uma execução do modelo de máquina em Lean, e um teste diferencial roda vinte mil sequências aleatórias de instruções tanto no simulador quanto no processador real. Antes de cada invocação, o runtime confere o layout concreto de memória contra a hipótese do teorema e se recusa a rodar se ela não for satisfeita.
Somos explícitos sobre o que não está provado. A base confiável inclui a semântica das instruções x86 (testada contra o hardware, não provada), os trampolins de entrada, o handler de falhas, os mapeamentos de memória, o lado host de cada chamada de helper, e o próprio Charon e o Aeneas. O teorema trata de segurança de memória, não de correção funcional nem de vazamento de informação. E cobre apenas o backend x86_64; o backend arm64 é testado, mas não provado.
As provas mantêm o guest dentro da própria memória. Tudo o que um Loop faz no mundo externo passa por uma chamada de host, e essa fronteira pertence ao Salix: a allowlist de capacidades e as regras de autoridade descritas mais adiante.
O compilador também roda dentro do sandbox
Os agentes compilam os próprios Loops, então o compilador também está em território de entrada não confiável. O spinfoam embute o TinyCC, compilado para eBPF, e o executa no async-ebpf como qualquer outro guest. Cada build recebe uma instância nova do compilador com uma arena de 8 MiB, um sistema de arquivos só em memória contendo apenas os fontes enviados e o header do SDK, um prazo de 15 segundos e nenhum acesso a rede, processos ou arquivos do host. A saída também é tratada como não confiável. Todo objeto passa pela mesma validação ao ser carregado, tenha vindo do compilador ou não.
É por isso que loop.build não precisa de toolchain, contêiner ou privilégio no nó. O código C do agente nunca encosta em um compilador nativo.
Acordar o agente é a parte cara
Um Loop roda sozinho, mas é inútil se não consegue dizer nada ao agente. A única forma de fazer isso é agent.notify:
A notificação é entregue à Session de destino do Loop como uma mensagem comum. Só então o loop principal do agente roda, e só então ele gasta tokens de modelo. Uma hora tranquila não custa nenhum token.
O dedup_key importa mais do que parece. Loops são reiniciados, eventos são reentregues e um guest pode repetir uma chamada sem ter certeza de que ela deu certo. O Salix entrega o despertar como loop:<id>:<dedup_key>, então o mesmo e-mail, issue ou alerta acorda o agente uma única vez.
Como despertar é a parte cara, ele também tem orçamento. Um Loop pode acordar seu agente seis vezes a cada dez minutos. Um Loop que fica nesse limite por uma hora é pausado com o motivo budget, e o agente é avisado. Um Loop com bug ou ansioso demais vira um Loop pausado e visível, não uma fatura.
Decisões rápidas sem um modelo grande
A parte difícil de uma observação geralmente não é “algo mudou?”, e sim “essa mudança importa?”. Este e-mail é algo sobre o qual o dono precisa agir hoje? Este PR mexe nas partes do sistema que nos interessam? Um Loop escrito em C com inteiros não consegue responder isso sozinho, e chamar um modelo de fronteira a cada evento nos levaria de volta ao ponto de partida.
Por isso os Loops ganham mais uma capacidade: decide. Ela envia uma pergunta pequena e tipada a um modelo rápido de decisão (Jev) e recebe de volta uma resposta estruturada. Esta é a pergunta do nosso protótipo de observação de e-mail:
decide oferece choice para escolher um candidato, perguntas sim/não separadas para múltiplas correspondências e score para relevância ordenada. Ela só devolve uma decisão. Não lê fontes, não executa ferramentas nem concede autoridade. O Loop fornece os dados, faz a pergunta e aplica o limiar no próprio código.
Duas escolhas de design fizeram isso funcionar bem:
Incerteza é uma resposta válida. Uma escolha explícita
noneoudefersignifica “não sei dizer”, e isso é um resultado bem-sucedido, não um erro de transporte. O Loop guarda o evento em vez de inventar uma decisão de silêncio.É barato o bastante para rodar em todo evento. Nosso catálogo de preços lista
typesafe/jev-1.13.0a $0.042 por milhão de tokens de entrada, com tokens de saída gratuitos. Isso é barato o suficiente para triar todo e-mail, e não só os que passam por um filtro de palavras-chave.
O resultado é um sistema em duas camadas: um modelo pequeno faz a triagem de todo evento, e o modelo grande só vê os que passam.
Eventos que entram de forma durável
Timers bastam para polling, mas muitas fontes conseguem fazer push. Um Loop tem uma caixa de mensagens, e sistemas externos a alimentam de três formas:
a API do Salix:
POST /v1/agent-groups/:group_id/loops/:loop_id/eventscom uma chave de API do grupo;uma URL secreta de webhook, que o agente ativa, rotaciona ou revoga com
loop.webhook;triggers do Composio vindos dos apps conectados do usuário, que chegam com o ID de evento do provedor e o slug do trigger.
Um 202 de qualquer uma delas significa que o PostgreSQL reteve o evento no seu Loop. Não significa que o Loop o processou. O guest chama loop.ack quando chega a um ponto seguro: uma decisão de silêncio ou uma passagem durável, como um agent.notify bem-sucedido. Até lá, o evento fica pendente, e o Reconciler reprocessa os eventos pendentes depois que o Loop muda de nó ou reinicia, e de qualquer forma uma vez por minuto. Um evento que não recebe ack em 15 minutos faz o Loop falhar de forma visível, com os eventos pendentes preservados para o agente repetir ou descartar.
Uma lição da construção disso: a caixa de mensagens do spinfoam deduplica IDs de evento na admissão, não na conclusão do negócio. Em um protótipo inicial, esperávamos que uma chamada de modelo que falhou fosse repetida pelo provedor reentregando o evento. Não foi; a reentrega foi corretamente descartada como duplicata. Então as novas tentativas cabem ao guest, que guarda o evento e tenta de novo um número limitado de vezes, e a recuperação após uma falha cabe à caixa de entrada durável. Hoje essa regra está escrita no guia do SDK.
Ack, admissão durável na Session, uma mensagem visível e um efeito externo são quatro fatos distintos. Damos a cada um seu próprio dono, exigimos chaves de deduplicação estáveis mais adiante no fluxo e não prometemos efeitos externos exatamente uma vez.
Rodando para sempre em um cluster em movimento
“24/7” é fácil de dizer e difícil de fazer em um cluster onde nós entram e saem. Um Loop roda no nó que detém o lease do seu agente, e acompanha esse lease.
Alocação. Quando o processo servidor de um agente obtém seu lease, ele adota os Loops ativos do agente. Quando o agente é passivado ou isolado por fence, ele os libera. Um Loop ativo mantém seu agente residente, então um agente ocioso com um Loop renova o lease em vez de ser estacionado.
Encarnações. Cada carregamento de um Loop incrementa sua encarnação, um valor de fence na linha do Loop. Uma chamada de host de um objeto que não é a encarnação atual é recusada, assim como um
ackvindo dele. Uma cópia obsoleta de um Loop em um nó que está saindo não consegue agir.Checkpoints.
loop.state.putsalva até 16 KiB, e o carregamento seguinte recebe isso de volta comoconfig.state. Os agentes fazem checkpoint de cursores e dos últimos IDs vistos, não de tudo.Falhas. Uma falha recarrega o Loop a partir do seu checkpoint, no máximo três vezes por hora. Depois disso ele fica
failed, e o agente é avisado uma vez.Órfãos. Uma varredura periódica encontra Loops ativos sem objeto associado, ou associados a um nó que saiu, e reinicia o dono deles.
Os Loops também têm uma fonte de verdade fora do runtime. loop.build grava o ELF compilado no sistema de arquivos do agente, e loop.create registra seu caminho e seu SHA-256. Cada carregamento relê o arquivo e confere o hash, então um Loop roda exatamente o programa com que foi criado, ou não roda.
O que um Loop tem permissão para fazer
Um Loop é código escrito por um modelo, rodando sem supervisão, o tempo todo. A autoridade dele tem que ser menor que a do agente, não igual.
Os Loops chamam uma allowlist fechada de capacidades de host: agent.notify, as chamadas loop.state.*, loop.ack e loop.log, ferramentas do Salix classificadas como somente leitura, ferramentas de ambiente e de dispositivo, ferramentas SSH, leitura de uma conversa, web.http_request, composio.execute para apps conectados e decide. O spinfoam recusa qualquer outro nome com SF_DENIED. Não há etapa de concessão para configurar errado.
Cada chamada passa pelo mesmo despacho de ferramentas e pelas mesmas verificações de fluxo de informação de um turno normal do agente. Um Loop age como seu criador, por meio de um principal delegado schedule|loop:<id>|<creator>, com as regras de divulgação do criador e a origem selada do Loop. Payloads de eventos são dados. Eles não carregam autoridade e não podem ampliar o que o Loop pode fazer, e é por isso que o prompt de decisão acima trata explicitamente o e-mail como entrada não confiável.
Por fim, cotas mantêm o sistema limitado: 20 Loops ativos por agente, 100 por grupo, 32 eventos pendentes de 16 KiB cada por Loop e rate limits na entrada de webhooks.
Scripts: o mesmo runtime, por um turno
Depois que tínhamos um runtime seguro e barato para C escrito por agentes, surgiu um segundo uso. script.run compila e roda um programa em C com inteiros uma única vez, dentro de uma só chamada de ferramenta, com acesso às ferramentas do próprio turno que fez a chamada por meio de salix.call. Um script não tem linha no banco, encarnação, checkpoint nem agent.notify. É uma forma de o agente agrupar muitas chamadas de ferramenta em um único programa determinístico, em vez de muitas idas e voltas ao modelo. Duas das skills que distribuímos são programas C executados assim.
Agentes que programam o próprio sistema de eventos
Juntando tudo, um agente do Comma a quem se pede para ficar de olho faz três coisas que agentes tradicionais não conseguem: escreve a observação como um programa, o cluster roda esse programa pelo tempo que for necessário ao custo de alguns kilobytes, e um modelo pequeno decide, evento a evento, se o modelo grande precisa sequer acordar.
O loop interno continua sendo onde o agente pensa. O loop externo é onde ele presta atenção. O Salix deixa o agente construir os dois.


