상시 실행 에이전트는 컨텍스트 윈도우로는 다 추적할 수 없을 만큼 많은 소스에서 들어오는 이벤트를 끊임없이 지켜보고 대응해야 합니다. Comma의 분산 하네스인 Salix는 에이전트가 작은 eBPF 프로그램을 클러스터에 올려 24시간 내내 상시 실행할 수 있게 합니다. 이 프로그램은 이벤트를 모니터링하고, 빠르고 지능적인 판단이 필요할 때 Jev를 호출하며, 흥미로운 일이 생기면 메인 에이전트 루프를 깨웁니다.
내부 루프만으로는 부족하다
익숙한 에이전트 루프는 단순합니다. 컨텍스트를 모델에 보내고, 모델이 돌려준 도구 호출을 실행하고, 결과를 덧붙이고, 이를 반복합니다. 이 루프는 일을 하는 데는 능숙하지만, 일을 기다리는 데는 서툽니다.
에이전트에게 "계약서가 서명되어 돌아오면 알려 줘"나 "이 저장소를 지켜보다가 결제와 관련된 변경이 있으면 표시해 줘"라고 요청하면, 내부 루프에는 나쁜 선택지 두 개만 남습니다. 하나는 폴링입니다. 몇 분마다 깨어나 받은편지함을 다시 읽고, 아무 일도 없었다는 결론을 내리는 데 모델 호출 한 번을 통째로 씁니다. 다른 하나는 이 일을 하트비트나 cron 스케줄에 넘기는 것인데, 이는 간격만 길고 지연 시간은 더 나쁜 폴링일 뿐입니다. 어느 쪽이든 시스템에서 가장 비싼 부분, 즉 큰 컨텍스트를 읽는 대형 모델이 매 틱마다 실행되고, 그 틱의 거의 전부는 아무 일도 없는 틱입니다.
상시 실행 에이전트에게 실제로 필요한 것은 외부 루프, 즉 이벤트 스트림 위에 자리 잡고 값싼 필터링을 처리하다가 생각해 볼 가치가 있는 일이 생겼을 때만 모델로 넘기는 장치입니다. 그리고 에이전트마다 지켜보는 대상도, 지켜보는 방식도 다르기 때문에 이 외부 루프는 고정된 제품 기능이 될 수 없습니다. 에이전트가 직접 작성해야 합니다.
Salix에서는 이 외부 루프를 Loop라고 부릅니다.
Loop는 작은 C 프로그램이다
Loop는 에이전트가 작성하고, eBPF로 컴파일되어, 클러스터에서 자신을 소유한 에이전트 곁에서 실행되는 단일 C 파일입니다. 모든 Loop는 같은 모양을 가집니다.
Loop는 타이머나 이벤트를 기다렸다가 무언가를 확인하고, 드물게 에이전트를 깨웁니다. 에이전트는 loop.sdk 도구에서 정확한 SDK 헤더와 프로그래밍 가이드를 받아 프로그램을 작성하고, loop.build로 컴파일한 다음 loop.create로 시작합니다. 템플릿도 DSL도 없습니다. 감시 로직을 C로 표현할 수 있다면 그대로 실행할 수 있습니다.
컨테이너 안의 Python 스크립트 같은 방식이 아니라 왜 C와 eBPF일까요? Salix를 설계할 때 기준으로 삼은 숫자 때문입니다. Salix는 CPU 코어 하나당 100개가 넘는 에이전트를 실행하며, 전체로는 수백만 개의 에이전트를 운영합니다. 에이전트에게 항상 켜져 있는 샌드박스를 줄 수 없으니, 에이전트의 감시 작업도 그럴 수 없습니다. Loop는 사람이 검토하지 않은 코드를 공유 멀티테넌트 노드에서 실행하면서도, 모든 에이전트에 대해 영원히 돌려도 될 만큼 저렴해야 합니다.
eBPF는 이 요구 사항에 유난히 잘 맞습니다.
작습니다. 대기 중인 Loop가 차지하는 상주 메모리는 수십 킬로바이트입니다. 프로세스도, 인터프리터도, 힙도 없습니다.
신뢰할 수 없는 코드를 안전하게 실행할 수 있습니다. 대상 환경에는 시스템 콜도, 함수 포인터도, 무제한 스택도 없습니다. 프로그램이 할 수 있는 일은 자기 메모리 위에서 계산하고, 우리가 건네준 호스트 함수를 호출하는 것뿐입니다.
기다리는 비용이 낮습니다.
sf_event_next나sf_sleep_ms에서 블로킹된 Loop는 메모리 외에는 아무 비용도 들지 않습니다.
대가는 제한된 방언입니다. SDK 가이드도 이 점을 숨기지 않습니다. 정수 C만 쓸 수 있고(부동소수점 없음, 부호 있는 나눗셈 없음), 스택 프레임은 4 KiB, 깊이는 최대 8프레임이며, 재귀도 sprintf도 없고, 살아 있는 핸들은 최대 128개입니다. 막상 해 보니 모델은 이런 제약을 잘 다룹니다. 이미 C를 알고 있고, 이 제약들은 컴파일러 오류 메시지로 잘 설명되는 종류이기 때문입니다.
Spinfoam: 모든 Loop 아래의 런타임
Loop는 우리의 eBPF 루프 런타임인 spinfoam 위에서 실행됩니다. Spinfoam은 async-ebpf를 기반으로 만들어졌습니다. async-ebpf는 비동기 친화적이고 완전 선점형이며, 핵심부의 메모리 안전성이 형식 검증된 유저스페이스 eBPF 런타임입니다.
비동기로 만든 유저스페이스 eBPF
Spinfoam은 커널 eBPF가 아닙니다. Salix 노드마다 하나씩 뜨는 평범한 Rust 프로세스로, 커널 eBPF 지원도 권한도 필요 없고 Linux와 macOS에서 똑같이 동작합니다. Salix는 stdin과 stdout 위의 JSON-RPC로 spinfoam과 통신합니다. 책임 분리는 엄격합니다. spinfoam은 로컬 실행, 프로그램 간 격리, 취소, 그리고 제한된 메시지 전달을 맡습니다. Salix는 크래시 이후에도 살아남아야 하는 모든 것, 즉 배치, 영속 상태, 재시작 정책, 자격 증명, 네트워크를 맡습니다.
async-ebpf의 "async"는 Loop를 평범한 순차 C 코드처럼 보이게 해 주는 요소입니다. 로드된 각 프로그램은 자체 코루틴에서 실행되는 하나의 장수명 호출을 받습니다. 프로그램이 sf_sleep_ms, sf_event_next, sf_host_call을 호출하면 헬퍼가 코루틴을 일시 중단하고 대기를 Tokio에 넘깁니다. C 스택과 프로그램의 전역 변수는 정확히 그 자리에 그대로 남습니다. 타이머가 울리거나, 이벤트가 도착하거나, Salix가 호스트 호출에 응답하면 코루틴은 다음 줄에서 재개됩니다. 에이전트는 for (;;) { wait; check; wake; }라고 쓰기만 하면 되고, 콜백은 한 번도 보지 않습니다.
"완전 선점형"은 이 구조를 안전하게 공유할 수 있게 해 주는 요소입니다. 모든 게스트 코드는 단일 Tokio 스레드에서 실행되고, 감시 스레드가 양보 없이 너무 오래 실행되는 프로그램을 중단시킵니다. 촘촘한 for (;;) {}에서 빙빙 도는 Loop도 다른 프로그램과 마찬가지로 타임 슬라이스를 잃으며, 이를 멈추는 데 그 Loop의 협조는 필요 없습니다. 버그가 있는 Loop 하나가 이웃을 멈춰 세울 수 없습니다.
대부분의 Loop는 거의 모든 시간을 대기하며 보내므로, 이 설계는 매우 높은 밀도로 패킹됩니다. 검증 실행에서는 모두 내장 컴파일러로 컴파일한 작은 모니터링 Loop 10,000개가 실행 스레드 하나에서, 개당 약 64 KB의 상주 메모리로 실행되었습니다. 10,000개 전체를 로드하고 시작하는 데 3.07초가 걸렸습니다. 모든 Loop에 이벤트를 하나씩 전달하고, 각 Loop가 그에 대한 응답으로 보낸 호스트 호출에 답하는 데 1.56초가 걸렸습니다. 제어 요청은 p99 기준 0.25밀리초 미만을 유지했습니다. 프로세스 전체가 사용한 OS 스레드는 로드 중 4개, 유휴 상태에서 2개였습니다.
검증할 수 있는 메모리 안전성
에이전트가 작성한 수천 개의 프로그램을 한 프로세스에서 실행하는 것은, 그중 어느 것도 자기 것이 아닌 메모리에 손댈 수 없을 때에만 합리적입니다. async-ebpf에서 이 보장은 메모리 레이아웃에서 시작해 기계 검증된 증명으로 끝납니다.
먼저 레이아웃입니다. 각 프로그램의 데이터는 포인터 케이지 안에 놓입니다. 포인터 케이지는 주변에 무작위 가드 페이지를 둔 예약 영역입니다. 게스트 스택은 프레임 하나씩으로 된 섬들로 배치되고, 섬 사이에는 어떤 eBPF 메모리 명령도 닿을 수 없을 만큼 넓은 접근 불가 간격이 있습니다. 그래서 자기 프레임 밖으로 벗어난 함수는 호출자의 프레임을 읽는 대신 폴트를 일으킵니다. JIT 코드 페이지는 쓰기 가능과 실행 가능 상태가 절대 동시에 되지 않습니다.
다음은 증명입니다. async-ebpf는 각 eBPF 함수를 처음 실행될 때 네이티브 코드로 컴파일합니다. x86_64 백엔드는 결정을 내리는 부분을 증명할 수 있도록 구조화되어 있습니다.
검증기는 건전합니다. 검증기가 승인한 프로그램은 어떤 실행 경로에서도 정의되지 않은 명령에 도달하지 않고, 명령 중간이나 프로그램 밖으로 점프하지 않으며, 프레임 포인터는 항상 현재 호출 깊이의 프레임 베이스를 가리킵니다.
함수는 닫혀 있습니다. 제어 흐름은 로컬 호출이나 반환을 통해서만 함수를 벗어납니다. 덕분에 JIT가 함수를 하나씩 따로 컴파일할 수 있습니다.
생성된 코드는 메모리 안전합니다. 검사기가 어떤 함수를 승인하면, 그 네이티브 코드의 모든 실행은 허용된 주소 집합(자기 프레임, 자기 게스트 영역, 자기 스택과 리터럴 풀)에만 접근하며, 호출자의 상태를 온전히 둔 채 반환합니다. 지연 컴파일되는 피호출 함수도 같은 정리로 합성됩니다.
바이트는 모델이 말하는 그대로입니다. 어셈블러가 내보내는 모든 명령은 디코딩하면 모델이 추론한 바로 그 명령으로 되돌아갑니다.
증명은 두 지점에서 하드웨어와 연결됩니다. 실행 가능한 시뮬레이터가 Lean 머신 모델의 실행임이 증명되어 있고, 차분 테스트가 무작위 명령 시퀀스 2만 개를 시뮬레이터와 실제 프로세서 양쪽에서 실행합니다. 런타임은 매 호출 전에 실제 메모리 레이아웃이 정리의 가정을 만족하는지 확인하고, 만족하지 않으면 실행을 거부합니다.
증명되지 않은 부분도 분명히 밝힙니다. 신뢰 기반에는 x86 명령 의미론(증명이 아니라 하드웨어 대조 테스트), 진입 트램펄린, 폴트 핸들러, 메모리 매핑, 모든 헬퍼 호출의 호스트 측, 그리고 Charon과 Aeneas 자체가 포함됩니다. 정리는 메모리 안전성에 관한 것이며 기능적 정확성이나 정보 유출에 관한 것이 아닙니다. 또한 x86_64 백엔드만 다룹니다. arm64 백엔드는 테스트되었지만 증명되지는 않았습니다.
증명은 게스트를 자기 메모리 안에 가둡니다. Loop가 바깥 세계에서 하는 모든 일은 호스트 호출을 거치며, 그 경계는 Salix의 몫입니다. 아래에서 설명할 기능 허용 목록과 권한 규칙이 그것입니다.
컴파일러도 샌드박스 안에서 실행된다
에이전트가 자기 Loop를 직접 컴파일하므로, 컴파일러 역시 신뢰할 수 없는 입력을 다루는 영역입니다. Spinfoam은 eBPF 자체로 컴파일한 TinyCC를 내장하고, 다른 게스트와 똑같이 async-ebpf 위에서 실행합니다. 빌드마다 8 MiB 아레나를 가진 새 컴파일러 인스턴스, 제출된 소스와 SDK 헤더만 담긴 메모리 전용 파일 시스템, 15초 마감 시한이 주어지며, 네트워크, 프로세스, 호스트 파일에는 접근할 수 없습니다. 출력물 역시 신뢰하지 않습니다. 모든 오브젝트는 컴파일러에서 나왔든 아니든 로드될 때 같은 검증을 거칩니다.
그래서 loop.build는 노드에 툴체인도, 컨테이너도, 권한도 필요로 하지 않습니다. 에이전트의 C 소스는 네이티브 컴파일러에 전혀 닿지 않습니다.
비싼 것은 에이전트를 깨우는 일이다
Loop는 스스로 실행되지만, 에이전트에게 아무것도 전할 수 없다면 쓸모가 없습니다. 그 유일한 방법이 agent.notify입니다.
알림은 Loop의 대상 Session에 평범한 메시지로 전달됩니다. 그제야 메인 에이전트 루프가 실행되고, 그제야 모델 토큰을 씁니다. 조용한 한 시간은 토큰을 전혀 쓰지 않습니다.
dedup_key는 보기보다 중요합니다. Loop는 재시작되고, 이벤트는 재전달되며, 게스트는 성공했는지 확신할 수 없는 호출을 다시 시도할 수 있습니다. Salix는 깨우기를 loop:<id>:<dedup_key>로 전달하므로, 같은 메일, 이슈, 알림은 에이전트를 한 번만 깨웁니다.
깨우기는 비싼 일이므로 예산도 정해져 있습니다. Loop는 10분에 6번까지 에이전트를 깨울 수 있습니다. 한 시간 동안 이 한도에 머무는 Loop는 budget 사유로 일시 중지되고, 에이전트에게 그 사실이 전달됩니다. 버그가 있거나 지나치게 열성적인 Loop는 청구서가 아니라, 눈에 보이는 일시 중지 상태의 Loop로 귀결됩니다.
대형 모델 없이 빠르게 판단하기
감시에서 어려운 부분은 대개 "무언가 바뀌었는가"가 아니라 "이 변화가 중요한가"입니다. 이 이메일은 사용자가 오늘 처리해야 하는 일인가? 이 PR은 우리가 신경 쓰는 시스템 영역을 건드리는가? 정수 C로 작성된 Loop는 혼자서 이런 질문에 답할 수 없고, 매 이벤트마다 프런티어 모델을 호출하면 처음 출발한 자리로 돌아가게 됩니다.
그래서 Loop에는 기능이 하나 더 있습니다. 바로 decide입니다. 작고 타입이 지정된 질문을 빠른 판단 모델(Jev)에 보내고 구조화된 답을 받습니다. 다음은 이메일 감시 프로토타입에서 쓴 질문입니다.
decide는 후보 하나를 고르는 choice, 여러 항목이 해당될 수 있을 때 쓰는 개별 예/아니요 질문, 그리고 관련도 순위를 매기는 score를 지원합니다. 돌려주는 것은 오직 판단뿐입니다. 소스를 읽거나, 도구를 실행하거나, 권한을 부여하지 않습니다. 데이터를 제공하고, 질문을 던지고, 임곗값을 적용하는 일은 Loop가 자기 코드에서 합니다.
두 가지 설계 선택이 이 방식을 잘 작동하게 만들었습니다.
불확실함도 유효한 답입니다. 명시적인
none이나defer선택지는 "판단할 수 없다"는 뜻이며, 이는 전송 오류가 아니라 성공한 결과입니다. Loop는 조용히 넘어가도 된다는 판단을 지어내는 대신 이벤트를 보관합니다.모든 이벤트에 실행해도 될 만큼 저렴합니다. 우리 가격 카탈로그에서
typesafe/jev-1.13.0은 입력 토큰 100만 개당 $0.042이고 출력 토큰은 무료입니다. 키워드 필터를 통과한 이메일만이 아니라 모든 이메일을 걸러 볼 수 있을 만큼 저렴합니다.
결과는 2단 구조입니다. 작은 모델이 모든 이벤트를 걸러 내고, 대형 모델은 그 관문을 통과한 이벤트만 봅니다.
이벤트를 영속적으로 받아들이기
폴링에는 타이머로 충분하지만, 많은 소스는 푸시를 할 수 있습니다. Loop에는 메일박스가 있고, 외부 시스템은 세 가지 방법으로 여기에 이벤트를 넣습니다.
Salix API: 그룹 API 키로
POST /v1/agent-groups/:group_id/loops/:loop_id/events호출비밀 웹훅 URL: 에이전트가
loop.webhook으로 활성화, 교체, 폐기사용자가 연결한 앱의 Composio 트리거: 제공업체의 이벤트 ID와 트리거 슬러그를 담아 도착
이 중 어느 경로에서든 202를 받았다면 PostgreSQL이 해당 Loop에 이벤트를 보관했다는 뜻입니다. Loop가 그 이벤트를 처리했다는 뜻은 아닙니다. 게스트는 안전한 지점, 즉 조용히 넘어가기로 판단했거나 성공한 agent.notify 같은 영속적인 인계가 끝난 지점에 이르렀을 때 loop.ack를 호출합니다. 그 전까지 이벤트는 대기 상태로 남고, Reconciler는 Loop가 이동하거나 재시작된 뒤에, 그리고 어떤 경우든 1분에 한 번씩 대기 중인 이벤트를 재생합니다. 15분 안에 확인되지 않은 이벤트는 Loop를 눈에 보이게 실패시키며, 대기 중인 이벤트는 에이전트가 재시도하거나 폐기할 수 있도록 보관됩니다.
이를 만들면서 얻은 교훈이 하나 있습니다. spinfoam의 메일박스는 이벤트 ID를 비즈니스 처리 완료 시점이 아니라 수용 시점에 중복 제거합니다. 초기 프로토타입에서 우리는 실패한 모델 호출이 제공업체의 이벤트 재전달로 재시도될 것이라고 기대했습니다. 그렇지 않았습니다. 재전달은 중복으로 올바르게 버려졌습니다. 따라서 재시도는 이벤트를 보관하고 정해진 횟수만큼 다시 시도하는 게스트의 몫이고, 크래시 이후의 복구는 영속 인박스의 몫입니다. 이제 이 규칙을 SDK 가이드에 명시하고 있습니다.
확인(ack), 영속적인 Session 수용, 눈에 보이는 메시지, 외부 효과는 서로 별개인 네 가지 사실입니다. 우리는 각 사실에 고유한 소유자를 두고, 다운스트림에 안정적인 중복 제거 키를 요구하며, 외부 효과의 정확히 한 번(exactly-once) 실행은 약속하지 않습니다.
움직이는 클러스터 위에서 영원히 실행하기
"24/7"은 말하기는 쉽지만, 노드가 들고 나는 클러스터에서 실현하기는 어렵습니다. Loop는 자기 에이전트의 리스를 가진 노드에서 실행되며, 그 리스를 따라 움직입니다.
배치. 에이전트의 서버 프로세스가 리스를 획득하면 활성 Loop를 넘겨받습니다. 에이전트가 패시베이션되거나 펜싱되면 Loop를 놓아 줍니다. 활성 Loop는 에이전트를 상주 상태로 유지하므로, Loop가 있는 유휴 에이전트는 주차되는 대신 리스를 갱신합니다.
인카네이션. Loop를 로드할 때마다 인카네이션, 즉 Loop 행의 펜스 값이 증가합니다. 현재 인카네이션이 아닌 오브젝트의 호스트 호출은 거부되며, 그런 오브젝트의
ack도 마찬가지입니다. 떠나는 노드에 남은 Loop의 오래된 사본은 아무것도 할 수 없습니다.체크포인트.
loop.state.put은 최대 16 KiB를 저장하고, 다음 로드 때config.state로 돌려줍니다. 에이전트는 모든 것을 저장하지 않고 커서와 마지막으로 본 ID만 체크포인트합니다.폴트. 폴트가 발생하면 Loop를 체크포인트에서 다시 로드하며, 이는 한 시간에 최대 세 번까지입니다. 그 뒤에는
failed상태가 되고 에이전트에게 한 번 알립니다.고립. 주기적인 점검이 오브젝트가 붙어 있지 않거나 이미 떠난 노드에 붙어 있는 활성 Loop를 찾아, 그 소유자를 재시작합니다.
Loop에는 런타임 바깥의 원천 데이터도 있습니다. loop.build는 컴파일된 ELF를 에이전트의 파일 시스템에 쓰고, loop.create는 그 경로와 SHA-256을 기록합니다. 매 로드마다 파일을 다시 읽고 해시를 확인하므로, Loop는 생성될 때의 프로그램을 정확히 그대로 실행하거나 아예 실행되지 않습니다.
Loop가 할 수 있는 일
Loop는 모델이 작성하고, 감독 없이, 24시간 내내 실행되는 코드입니다. 그 권한은 에이전트와 같아서는 안 되고, 에이전트보다 작아야 합니다.
Loop는 닫힌 허용 목록에 있는 호스트 기능만 호출할 수 있습니다. agent.notify, loop.state.*, loop.ack, loop.log 호출, 읽기 전용으로 분류된 Salix 도구, 환경 및 디바이스 도구, SSH 도구, 대화 읽기, web.http_request, 연결된 앱을 위한 composio.execute, 그리고 decide입니다. Spinfoam은 그 밖의 모든 이름을 SF_DENIED로 거부합니다. 잘못 설정할 수 있는 권한 부여 단계 자체가 없습니다.
각 호출은 일반적인 에이전트 턴과 같은 도구 디스패치와 정보 흐름 검사를 거칩니다. Loop는 위임된 주체 schedule|loop:<id>|<creator>를 통해 생성자로서 행동하며, 생성자의 공개 규칙과 Loop의 봉인된 출처를 따릅니다. 이벤트 페이로드는 데이터입니다. 아무런 권한도 담고 있지 않으며 Loop가 할 수 있는 일을 넓힐 수 없습니다. 앞서 본 판단 프롬프트가 이메일을 신뢰할 수 없는 입력으로 명시적으로 취급하는 이유가 이것입니다.
마지막으로, 할당량이 시스템을 일정한 범위 안에 묶어 둡니다. 에이전트당 활성 Loop 20개, 그룹당 100개, Loop당 16 KiB 크기의 대기 이벤트 32개, 그리고 웹훅 수신에 대한 속도 제한이 있습니다.
스크립트: 같은 런타임을 한 턴 동안
에이전트가 작성한 C를 위한 안전하고 저렴한 런타임이 생기자, 두 번째 쓰임새가 나타났습니다. script.run은 정수 C 프로그램을 단일 도구 호출 안에서 한 번 컴파일하고 실행하며, salix.call을 통해 호출한 턴의 도구에 접근할 수 있습니다. 스크립트에는 행도, 인카네이션도, 체크포인트도, agent.notify도 없습니다. 에이전트가 여러 번의 모델 왕복 대신, 많은 도구 호출을 하나의 결정적인 프로그램으로 묶어 처리하는 방법입니다. 우리가 제공하는 스킬 중 두 개가 이런 방식으로 실행되는 C 프로그램입니다.
자기 이벤트 시스템을 프로그래밍하는 에이전트
정리하면, 감시를 맡은 Comma 에이전트는 기존 에이전트가 할 수 없는 세 가지 일을 합니다. 감시 로직을 프로그램으로 작성하고, 클러스터는 그 프로그램을 필요한 기간 동안 몇 킬로바이트의 비용으로 실행하며, 작은 모델은 이벤트마다 대형 모델을 깨울 필요가 있는지를 판단합니다.
에이전트가 생각하는 곳은 여전히 내부 루프입니다. 주의를 기울이는 곳은 외부 루프입니다. Salix는 에이전트가 둘 다 만들 수 있게 합니다.


