Un agente siempre activo necesita vigilar eventos y responder a ellos de forma continua, y esos eventos llegan de muchas más fuentes de las que puede seguir dentro de su ventana de contexto. Salix, el arnés distribuido de Comma, permite a los agentes cargar diminutos programas eBPF en el clúster y ejecutarlos de forma persistente, 24/7, para vigilar eventos, llamar a Jev cuando necesitan decisiones rápidas e inteligentes y despertar el bucle principal del agente cuando ocurre algo interesante.
El bucle interior no basta
El bucle de agente de siempre es sencillo: enviar el contexto a un modelo, ejecutar las llamadas a herramientas que devuelve, agregar los resultados y repetir. Ese bucle es bueno para trabajar. Es malo para esperar trabajo.
Pídele a un agente “avísame cuando el contrato vuelva firmado” o “vigila este repositorio y señala cualquier cosa que toque la facturación”, y el bucle interior tiene dos malas opciones. Puede hacer sondeo: despertar cada pocos minutos, releer la bandeja de entrada y gastar una llamada completa al modelo para concluir que no pasó nada. O puede delegar la tarea en un heartbeat o en una programación cron, que es el mismo sondeo con un intervalo más largo y peor latencia. En ambos casos, la parte cara del sistema, un modelo grande leyendo un contexto grande, se ejecuta en cada tic, y casi todos los tics son tranquilos.
Lo que de verdad necesita un agente siempre activo es un bucle exterior: algo que se sitúe sobre el flujo de eventos, haga el filtrado barato y solo escale al modelo cuando haya algo en lo que valga la pena pensar. Y como cada agente vigila cosas distintas de maneras distintas, ese bucle exterior no puede ser una función fija del producto. Tiene que escribirlo el propio agente.
En Salix, ese bucle exterior se llama Loop.
Un Loop es un pequeño programa en C
Un Loop es un único archivo C que escribe el agente, se compila a eBPF y se ejecuta en el clúster junto al agente al que pertenece. Todos los Loops tienen la misma forma:
Espera a un temporizador o a un evento, revisa algo y, en contadas ocasiones, despierta al agente. El agente obtiene la cabecera exacta del SDK y una guía de programación mediante la herramienta loop.sdk, escribe el programa, lo compila con loop.build y lo arranca con loop.create. No hay plantilla ni DSL. Si el agente puede describir la vigilancia en C, puede ejecutarla.
¿Por qué C y eBPF, y no, por ejemplo, un script de Python en un contenedor? Por las cifras en torno a las que diseñamos Salix. Salix ejecuta millones de agentes, a más de 100 agentes por núcleo de CPU. Un agente no puede tener un sandbox siempre encendido, así que sus vigilancias tampoco. Un Loop tiene que ser lo bastante barato como para seguir ejecutándose indefinidamente, para cada agente, en un nodo compartido y multiinquilino, mientras ejecuta código que ningún humano ha revisado.
eBPF encaja con estos requisitos especialmente bien:
Es pequeño. Un Loop en espera ocupa decenas de kilobytes de memoria residente. No hay proceso, ni intérprete, ni heap.
Es seguro ejecutarlo sin confiar en él. El objetivo no tiene syscalls, ni punteros a función, ni una pila sin límite. Lo único que puede hacer un programa es calcular sobre su propia memoria y llamar a las funciones del host que le proporcionamos.
Esperar es barato. Un Loop bloqueado en
sf_event_nextosf_sleep_msno cuesta nada más que su memoria.
El precio es un dialecto restringido, y la guía del SDK lo dice sin rodeos: solo C de enteros (sin punto flotante ni división con signo), marcos de pila de 4 KiB, como máximo 8 marcos de profundidad, sin recursión, sin sprintf y como máximo 128 handles vivos. Resulta que a los modelos esto no les representa ningún problema. Ya conocen C, y son restricciones que un error del compilador explica bien.
Spinfoam: el runtime bajo cada Loop
Los Loops se ejecutan sobre spinfoam, nuestro runtime de bucles eBPF. Spinfoam se basa en async-ebpf, un runtime de eBPF en espacio de usuario compatible con código asíncrono, totalmente apropiativo y con seguridad de memoria verificada formalmente en su núcleo.
eBPF en espacio de usuario, hecho asíncrono
Spinfoam no es eBPF del kernel. Es un proceso de Rust común, uno por nodo de Salix, que no necesita soporte de eBPF en el kernel ni privilegios, y funciona igual en Linux y macOS. Salix se comunica con él mediante JSON-RPC por stdin y stdout. El reparto de responsabilidades es estricto: spinfoam se encarga de la ejecución local, el aislamiento entre programas, la cancelación y la entrega acotada de mensajes. Salix se encarga de todo lo que tiene que sobrevivir a una caída: la ubicación, el estado duradero, la política de reinicio, las credenciales y la red.
El “async” de async-ebpf es lo que hace que un Loop parezca C secuencial normal. Cada programa cargado recibe una única invocación de larga duración, que se ejecuta en su propia corrutina. Cuando el programa llama a sf_sleep_ms, sf_event_next o sf_host_call, el helper suspende la corrutina y delega la espera en Tokio. La pila de C y las variables globales del programa se quedan exactamente donde estaban. Cuando vence el temporizador, llega el evento o Salix responde a la llamada al host, la corrutina se reanuda en la línea siguiente. El agente escribe for (;;) { wait; check; wake; } y nunca ve un callback.
“Totalmente apropiativo” es lo que hace que sea seguro compartirlo. Todo el código invitado se ejecuta en un único hilo de Tokio, y un hilo vigilante interrumpe cualquier programa que se ejecute demasiado tiempo sin ceder el control. Un Loop que gira en un for (;;) {} cerrado pierde su porción de tiempo como cualquier otro, y detenerlo no requiere su cooperación. Un Loop con errores no puede bloquear a sus vecinos.
La mayoría de los Loops pasan casi todo su tiempo esperando, así que este diseño permite una densidad muy alta. En nuestra prueba de cualificación, 10,000 pequeños Loops de supervisión, todos compilados con el compilador integrado, se ejecutaron en un solo hilo de ejecución con unos 64 KB de memoria residente cada uno. Cargar y arrancar los 10,000 llevó 3.07 segundos. Entregar un evento a cada Loop, y responder a la llamada al host que cada uno hizo en respuesta, llevó 1.56 segundos. Las solicitudes de control se mantuvieron por debajo de un cuarto de milisegundo en el p99. El proceso completo usó cuatro hilos del sistema operativo durante la carga y dos en reposo.
Seguridad de memoria que se puede verificar
Ejecutar miles de programas escritos por agentes en un solo proceso solo es razonable si ninguno de ellos puede tocar memoria que no le pertenece. En async-ebpf, esa garantía empieza en la disposición de la memoria y termina en demostraciones verificadas por máquina.
Primero, la disposición. Los datos de cada programa se encuentran dentro de una jaula de punteros: una región reservada rodeada de páginas de guarda aleatorizadas. La pila del invitado se organiza en islas de un marco cada una, separadas por huecos inaccesibles más anchos que el alcance de cualquier instrucción de memoria de eBPF, de modo que una función que se sale de su propio marco provoca un fallo en lugar de leer el de quien la llamó. Las páginas de código JIT nunca son escribibles y ejecutables a la vez.
Después, las demostraciones. async-ebpf compila cada función eBPF a código nativo la primera vez que se ejecuta. Su backend x86_64 está estructurado para que la parte que toma decisiones se pueda demostrar:
El validador es correcto. En cada ejecución de un programa que acepta, el programa nunca llega a una instrucción indefinida, nunca salta a mitad de una instrucción ni fuera del programa, y el puntero de marco siempre es la base del marco para la profundidad de llamada actual.
Las funciones son cerradas. El control nunca sale de una función salvo mediante una llamada local o un retorno, y eso es lo que permite al JIT compilar las funciones de una en una.
El código generado es seguro en memoria. Si el verificador acepta una función, cada ejecución de su código nativo solo toca un conjunto permitido de direcciones (su marco, sus propias regiones del invitado, su propia pila y su pool de literales) y retorna con el estado de quien la llamó intacto. Las funciones llamadas que se compilan de forma diferida se componen con el mismo teorema.
Los bytes dicen lo que dice el modelo. Cada instrucción que emite el ensamblador se decodifica de vuelta a la instrucción sobre la que razonó el modelo.
Las demostraciones se conectan con el hardware en dos puntos. Se demuestra que un simulador ejecutable es una ejecución del modelo de máquina de Lean, y una prueba diferencial pasa veinte mil secuencias de instrucciones aleatorias tanto por el simulador como por el procesador real. Antes de cada invocación, el runtime verifica la disposición concreta de la memoria frente a la hipótesis del teorema y se niega a ejecutar si no se cumple.
Somos explícitos sobre lo que no está demostrado. La base de confianza incluye la semántica de las instrucciones x86 (probada contra el hardware, no demostrada), los trampolines de entrada, el manejador de fallos, los mapeos de memoria, el lado del host de cada llamada a un helper, y los propios Charon y Aeneas. El teorema trata sobre la seguridad de memoria, no sobre la corrección funcional ni las fugas de información. Y solo cubre el backend x86_64; el backend arm64 está probado, pero no demostrado.
Las demostraciones mantienen al invitado dentro de su propia memoria. Todo lo que un Loop hace en el mundo exterior pasa por una llamada al host, y esa frontera pertenece a Salix: la lista de capacidades permitidas y las reglas de autoridad que se describen más abajo.
El compilador también se ejecuta dentro del sandbox
Los agentes compilan sus propios Loops, así que el compilador también recibe entradas no confiables. Spinfoam integra TinyCC, compilado a eBPF, y lo ejecuta sobre async-ebpf como a cualquier otro invitado. Cada compilación recibe una instancia nueva del compilador con una arena de 8 MiB, un sistema de archivos solo en memoria que contiene únicamente los archivos fuente enviados y la cabecera del SDK, un plazo de 15 segundos y ningún acceso a la red, a procesos ni a archivos del host. La salida también se trata como no confiable. Todos los objetos pasan por la misma validación al cargarse, vengan o no del compilador.
Por eso loop.build no necesita ninguna toolchain, ni contenedor, ni privilegios en el nodo. El código fuente C del agente nunca pasa por un compilador nativo.
Despertar al agente es la parte cara
Un Loop se ejecuta por su cuenta, pero no sirve de nada si no puede decirle nada al agente. La única forma de hacerlo es agent.notify:
La notificación se entrega a la Session de destino del Loop como un mensaje normal. Solo entonces se ejecuta el bucle principal del agente, y solo entonces gasta tokens del modelo. Una hora tranquila no cuesta ningún token.
La dedup_key importa más de lo que parece. Los Loops se reinician, los eventos se vuelven a entregar y un invitado puede reintentar una llamada si no está seguro de que haya funcionado. Salix entrega el aviso como loop:<id>:<dedup_key>, de modo que el mismo correo, issue o alerta despierta al agente una sola vez.
Como despertar al agente es la parte cara, también tiene un presupuesto. Un Loop puede despertar a su agente seis veces cada diez minutos. Un Loop que se mantiene en ese límite durante una hora se pausa con el motivo budget, y se avisa al agente. Un Loop con errores o demasiado entusiasta termina convertido en un Loop pausado y visible, no en una factura.
Decisiones rápidas sin un modelo grande
Lo difícil de una vigilancia no suele ser saber “si algo cambió”, sino “si ese cambio importa”. ¿Es este correo algo sobre lo que el propietario tiene que actuar hoy? ¿Toca esta PR las partes del sistema que nos interesan? Un Loop escrito en C de enteros no puede responder a eso por sí solo, y llamar a un modelo de frontera con cada evento nos devolvería al punto de partida.
Por eso los Loops reciben una capacidad más: decide. Envía una pregunta pequeña y tipada a un modelo de decisión rápido (Jev) y recibe una respuesta estructurada. Esta es la pregunta de nuestro prototipo de vigilancia de correo:
decide admite choice para elegir un candidato, preguntas de sí/no independientes para varias coincidencias y score para una relevancia ordenada. Solo devuelve una decisión. No lee fuentes, no ejecuta herramientas ni concede autoridad. El Loop aporta los datos, hace la pregunta y aplica el umbral en su propio código.
Dos decisiones de diseño hicieron que esto funcionara bien:
La incertidumbre es una respuesta válida. Una opción explícita
noneodefersignifica “no lo sé”, y es un resultado correcto, no un error de transporte. El Loop conserva el evento en lugar de inventarse una decisión tranquila.Es lo bastante barato para ejecutarlo con cada evento. Nuestro catálogo de precios incluye
typesafe/jev-1.13.0a $0.042 por millón de tokens de entrada, con tokens de salida gratuitos. Es lo bastante barato para filtrar cada correo, no solo los que deja pasar un filtro por palabras clave.
El resultado es un sistema de dos niveles: un modelo pequeño filtra cada evento y el modelo grande solo ve los que pasan.
Eventos que entran de forma duradera
Los temporizadores bastan para hacer sondeo, pero muchas fuentes pueden enviar eventos por sí mismas. Un Loop tiene un buzón, y los sistemas externos lo alimentan de tres maneras:
la API de Salix:
POST /v1/agent-groups/:group_id/loops/:loop_id/eventscon una clave de API del grupo;una URL de webhook secreta, que el agente activa, rota o revoca con
loop.webhook;los triggers de Composio de las aplicaciones conectadas del usuario, que llegan con el ID de evento del proveedor y el slug del trigger.
Un 202 de cualquiera de ellos significa que PostgreSQL retuvo el evento en su Loop. No significa que el Loop ya lo haya procesado. El invitado llama a loop.ack cuando llega a un punto seguro: una decisión tranquila o una entrega duradera, como un agent.notify que tuvo éxito. Hasta entonces, el evento queda pendiente, y el Reconciler vuelve a reproducir los eventos pendientes después de que el Loop se mueva o se reinicie, y una vez por minuto en cualquier caso. Un evento que no se confirma en 15 minutos hace fallar el Loop de forma visible, y los eventos pendientes se conservan para que el agente los reintente o los descarte.
Una lección que aprendimos al construir esto: el buzón de spinfoam deduplica los ID de evento en la admisión, no al completarse la lógica de negocio. En un prototipo temprano, esperábamos que una llamada fallida al modelo se reintentara cuando el proveedor volviera a entregar el evento. No fue así; la nueva entrega se descartó, correctamente, como duplicado. Así que los reintentos son responsabilidad del invitado, que conserva el evento y lo reintenta un número acotado de veces, y la recuperación tras una caída es responsabilidad de la bandeja de entrada duradera. Ahora dejamos escrita esa regla en la guía del SDK.
La confirmación, la admisión duradera en la Session, un mensaje visible y un efecto externo son cuatro hechos distintos. Damos a cada uno su propio responsable, exigimos claves de deduplicación estables aguas abajo y no prometemos efectos externos exactamente una vez.
Ejecutarse para siempre en un clúster en movimiento
“24/7” es fácil de decir y difícil de conseguir en un clúster donde los nodos van y vienen. Un Loop se ejecuta en el nodo que tiene la concesión (lease) de su agente, y sigue a esa concesión.
Ubicación. Cuando el proceso de servidor de un agente reclama su concesión, adopta sus Loops activos. Cuando el agente se pasiva o queda aislado por fencing, los libera. Un Loop activo mantiene a su agente residente, así que un agente inactivo con un Loop renueva su concesión en lugar de suspenderse.
Encarnaciones. Cada carga de un Loop incrementa su encarnación, un valor de fencing en la fila del Loop. Se rechaza una llamada al host desde un objeto que no es la encarnación actual, y también un
ackque venga de él. Una copia obsoleta de un Loop en un nodo que se está retirando no puede actuar.Checkpoints.
loop.state.putguarda hasta 16 KiB, y la siguiente carga lo recupera comoconfig.state. Los agentes guardan en el checkpoint cursores y los últimos ID vistos, no todo.Fallos. Un fallo recarga el Loop desde su checkpoint, como máximo tres veces por hora. A partir de ahí queda en
failed, y se avisa al agente una sola vez.Loops varados. Un barrido periódico encuentra Loops activos sin ningún objeto asociado, o asociados en un nodo que ya no existe, y reinicia a su propietario.
Los Loops también tienen una fuente de verdad fuera del runtime. loop.build escribe el ELF compilado en el sistema de archivos del agente, y loop.create registra su ruta y su SHA-256. Cada carga vuelve a leer el archivo y verifica el hash, de modo que un Loop ejecuta exactamente el programa con el que se creó, o no se ejecuta.
Qué puede hacer un Loop
Un Loop es código escrito por un modelo que se ejecuta sin supervisión a todas horas. Su autoridad tiene que ser menor que la del agente, no igual.
Los Loops llaman a una lista cerrada de capacidades del host: agent.notify, las llamadas loop.state.*, loop.ack y loop.log, las herramientas de Salix clasificadas como de solo lectura, las herramientas de entorno y de dispositivo, las herramientas SSH, la lectura de una conversación, web.http_request, composio.execute para las aplicaciones conectadas y decide. Spinfoam rechaza cualquier otro nombre con SF_DENIED. No hay ningún paso de concesión de permisos que se pueda configurar mal.
Cada llamada pasa por el mismo despacho de herramientas y las mismas verificaciones de flujo de información que un turno normal del agente. Un Loop actúa como su creador, mediante un principal delegado schedule|loop:<id>|<creator>, con las reglas de divulgación del creador y el origen sellado del Loop. Los payloads de los eventos son datos. No llevan autoridad y no pueden ampliar lo que el Loop puede hacer, y por eso el prompt de decisión anterior trata explícitamente el correo como una entrada no confiable.
Por último, las cuotas mantienen el sistema acotado: 20 Loops activos por agente, 100 por grupo, 32 eventos pendientes de 16 KiB cada uno por Loop y límites de tasa en la entrada de webhooks.
Scripts: el mismo runtime, para un solo turno
Una vez que tuvimos un runtime seguro y barato para C escrito por agentes, apareció un segundo uso. script.run compila y ejecuta una sola vez un programa en C de enteros, dentro de una única llamada a herramienta, con acceso a las propias herramientas del turno que lo llama mediante salix.call. Un script no tiene fila, ni encarnación, ni checkpoint, ni agent.notify. Es una forma de que un agente agrupe muchas llamadas a herramientas en un único programa determinista en lugar de hacer muchos viajes de ida y vuelta al modelo. Dos de las skills que incluimos son programas en C que se ejecutan de esta forma.
Agentes que programan su propio sistema de eventos
En conjunto, un agente de Comma al que se le pide vigilar algo hace tres cosas que los agentes tradicionales no pueden: escribe la vigilancia como un programa, el clúster ejecuta ese programa durante todo el tiempo necesario a cambio de unos pocos kilobytes, y un modelo pequeño decide, evento a evento, si el modelo grande necesita despertarse.
El bucle interior sigue siendo donde el agente piensa. El bucle exterior es donde presta atención. Salix permite al agente construir ambos.