jermes-app: PC와 폰을 pod로 연결한 에이전트 런타임과, 클러스터는 세우고 보고는 틀린 5회 실험
XGEN 코드를 고치지 않고 사용자의 PC와 폰을 pod로 연결해 로컬 자원을 도구로 쓰는 에이전트 런타임을 만들었습니다. 같은 k3s 과제를 5회 맡겨 과제 완수 3회, 정확한 보고 0회라는 격차를 계측하고, 한 번의 호출로 끝나는 검증 도구와 파괴적 명령 차단으로 대응했습니다.
Key Results
- 3/5 vs 0/5
- 과제 완수 vs 정확한 자기 보고도커로 k3s 3노드 클러스터를 구성·검증하는 같은 과제 5회 반복
- 20회+ → 8회
- 검증 도구 도입 후 도구 호출 수3회차, verify_running 실사용
- 15/15
- 파괴적 명령 차단 판정차단 대상 7건 전부 차단, 허용 대상 8건 전부 통과
XGEN은 사용자마다 독립된 에이전트 개발 환경을 제공합니다. 하지만 에이전트가 호출하는 도구가 실행되는 곳은 여전히 서버입니다. jermes-app은 그 실행 환경까지 개인화하는 것을 목표로 한 개인 프로젝트입니다. 사용자의 PC와 폰이 각각 pod가 되고, 에이전트는 그 위의 명령 실행, 스크린 캡처, 파일, 컨테이너를 도구로 씁니다. 8월 중순부터 2주 남짓 동안 라우터, pod, 커넥터, 웹 채팅과 iOS 앱까지 구성했습니다.
기능을 늘리는 것보다 어려웠던 것은 에이전트가 자기 작업의 결과를 정확히 판단하게 만드는 일이었습니다. 동료가 진화적 프롬프팅으로 10시간 만에 k3s 3노드 클러스터를 구성한 사례를 보고 같은 과제를 에이전트에 5회 맡겼더니, 클러스터는 3번 실제로 떴지만 결과를 정확히 보고한 회차는 한 번도 없었습니다.
이 글의 앞부분은 런타임의 구조적 결정(코드를 고치지 않는 연동, 지도형 도구 발견, 역방향 연결, 세션 격리, 두뇌 선택)을 다루고, 뒷부분은 5회 반복 실험에서 드러난 과대보고, 과소보고, 루프, 파괴적 명령을 어떻게 진단하고 도구로 대응했는지를 다룹니다.
세 조각 구조와 XGEN을 고치지 않는다는 제약
구조는 세 조각입니다. pod는 로컬 PC에서 도는 도구 실행 환경으로 XGEN 없이도 단독으로 동작합니다. 커넥터는 pod를 XGEN에 연결하는 WebSocket 배관이며 도구의 내용을 알지 못합니다. 라우터는 여러 pod 중 어디에 일을 보낼지 정하고 채팅 백엔드를 겸합니다. 처음부터 정한 원칙은 XGEN 서버 코드를 수정하지 않는 것이었습니다. 공개 API와 커넥터 계약(hello로 도구 카탈로그 등록, mcp_call과 mcp_result로 호출, 20초 간격 ping)만 맞추고, 서버가 재연결을 자동 복구하지 않으므로 커넥터가 지수 백오프로 재접속하며 카탈로그를 다시 보냅니다.
[iOS 앱 / 웹 채팅] ──► 라우터 (마스터 PC: 채팅 백엔드 + 에이전트 루프)
│ 두뇌: 실제 도구 호출을 프로브해 선택
├─► 로컬 pod (명령 · 파일 · 스크린 캡처 · 도커)
├─◄ 원격 pod (pod가 바깥으로 WS 접속, NAT 뒤 PC)
├─► 폰 pod (SSE 왕복, Swift 네이티브 브리지)
└─► XGEN 서버 (공개 API: 워크플로우 · LLM 자산)이 원칙은 다중 PC 연결에서 시험대에 올랐습니다. 조사해 보니 XGEN 서버측 커넥터 브리지는 사용자 ID 하나당 마지막 연결 하나만 유효하게 들고 있어, 같은 계정으로 두 PC를 연결하면 뒤의 연결이 앞의 연결을 끊었습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| XGEN 브리지를 (사용자, 커넥터) 다중 연결로 수정 | 플랫폼 차원의 해결 | XGEN 코드 수정과 MR이 필요하고 원칙과 충돌 | 기각 |
| 자체 라우터 + 마스터가 pod로 HTTP 호출 | 서버 수정 없음, 구현이 단순 | 마스터가 pod 주소로 닿아야 해서 NAT·방화벽 뒤 PC는 pod가 될 수 없음 | 1차 구현 |
| 자체 라우터 + pod가 바깥으로 WebSocket 접속 | NAT 뒤 PC도 pod, 끊기면 자동 해제와 재접속 | 연결 생명주기를 직접 관리해야 함 | 채택 |
마지막 선택지는 엣지 에이전트 패턴으로, pod가 마스터의 WebSocket 서버에 접속하고 지터를 넣은 백오프로 재연결합니다. 실측에서 원격 pod가 접속하자 도구 목록이 86개(로컬 62, 원격 24)로 늘었고, 에이전트를 거친 원격 실행과 마스터 재기동 후 자동 재접속을 확인했습니다. 다만 이 연결 생명주기 관리가 실제로 결함을 만들었다는 이야기는 뒤에서 다시 나옵니다.
도구를 고정하지 않는다: 지도형 발견과 동적 카탈로그
특정 DB나 컨테이너를 코드에 박아 도구를 만들지 않기로 했습니다. 1층의 발견 도구(discover_docker, discover_processes, discover_services)가 무엇이 있는지 지도를 주고, 에이전트가 그중 하나에 attach하면 그 대상의 exec, logs, inspect 도구가 런타임에 카탈로그에 추가됩니다. 카탈로그가 바뀌면 커넥터가 hello를 다시 보내고, 서버는 hello마다 카탈로그를 통째로 교체하므로 새 도구를 즉시 인식합니다. 에이전트 루프는 매 반복마다 도구 명세를 다시 생성합니다. 그렇지 않으면 방금 attach로 켠 도구를 모델이 보지 못하고 기존 호스트 도구를 잘못 썼습니다. 같은 원칙을 반대 방향에도 적용해, XGEN 서버에 접속하면 그 서버의 워크플로우 지도(실측 76개)가 그려지고 하나를 고르면 실행 도구가 생깁니다.
폰은 iOS 웹뷰가 샌드박스라 기기 자원에 직접 닿지 못합니다. Swift 네이티브 계층이 능력을 노출하고, 에이전트가 폰 도구를 호출하면 백엔드가 SSE로 폰에 요청을 보내 대기하고, 폰이 네이티브로 실행한 결과를 POST로 회신하는 왕복 구조를 만들었습니다. 기기 정보, 클립보드, 앱 파일, 사진, 알림, 문자 작성, 단축어 실행 등 12종이 pod 도구가 됐습니다. iOS는 사이드로드 앱이 다른 앱의 UI를 조작하는 것을 원천 차단하므로, 단축어를 자동화의 우회로로 삼았습니다.
pod가 늘자 도구 수 자체가 문제가 됐습니다. pod 하나가 도구 62개를 가지니 pod 3개면 186개로, LLM API의 도구 상한 128을 넘어 요청이 400으로 실패했습니다. 상한을 110으로 두고 초과 시 원격 pod는 대표 도구만 싣되 pod별 라운드로빈으로 균등 배분했습니다. 앞 pod가 자리를 다 차지하면 뒤 pod의 도구가 0개가 되어 "그 PC에서는 못 한다"고 답하는 상황을 실제로 겪었기 때문입니다. 도구 이름 구분자로 쓴 ::가 함수 이름 패턴에 걸려 거부된 문제, 원격 pod가 보낸 설명 하나에 섞인 손상된 서로게이트 문자가 도구 74개짜리 요청 전체를 실패시킨 문제(이분 탐색으로 원인 특정)도 이 단계에서 정리했습니다.
세션 격리와 두뇌 선택: 조용히 실패하던 두 곳
앱의 세션 하나를 독립 에이전트로 보고 기억과 스킬을 그 세션에 귀속시키려 했을 때, 기억만 격리되고 스킬은 전역에 쌓이는 비대칭이 생겼습니다. 스킬의 스코프 필드는 닫힌 집합이라 에이전트 단위 스코프 값을 예외로 거절했고, 기억의 스코프는 자유 문자열이라 통과했습니다. 게다가 스킬을 만드는 경로가 스코프를 user로 하드코딩하고 있어, 어느 세션이 배우든 스킬은 전역에 착지했습니다. 이 결함을 고치면서 분담을 다시 정했습니다. 기억은 그 대화의 맥락이므로 세션 단위로 격리하고, 스킬과 도구는 한 에이전트가 만든 것을 다른 에이전트도 불러 써야 배운 것이 축적되므로 공유 자산으로 둡니다. 회상은 "이 세션 + 공통"의 합집합으로 동작합니다.
두뇌(LLM) 선택에서도 겉보기 정상이 문제였습니다. XGEN 모델 카탈로그의 "사용 가능" 표시는 크레딧이 소진된 모델에도 참이었습니다. 그래서 카탈로그를 믿지 않고 실제 도구 호출 응답까지 확인한 모델을 기본 두뇌로 잡고, 턴 중 실패하면 다시 선택하게 했습니다. 플랫폼의 LLM이 모두 응답하지 않던 날에는 에이전트가 30~90초씩 헛돌았는데, LLM API 직접 호출 경로를 후보 최우선에 두자 컨테이너 조회가 4초로 줄었습니다. 도구를 생성하는 경로도 이 후보를 알아야 했고, 그러지 않으면 죽은 경로로 가서 20스텝을 헛돌았습니다.
장시간 작업은 명령 실행 20초 제한과 90초 턴 예산 때문에 도커 빌드처럼 몇 분 걸리는 일이 반드시 끊겼습니다. 백그라운드 job 도구(start_job, job_wait, job_status)를 만들고, 턴 예산을 절대 시간이 아니라 진전 기준으로 바꿔 3분간 새 스텝이 없을 때만 마무리하게 했습니다. 실제 도커 빌드가 192초 만에 완료됐고, 91.8MB 이미지가 실제로 실행되는 것까지 확인했습니다. 대화가 길어지는 문제는 LLM 없이 중복 제거, 최근 구간 밖 큰 도구 결과의 한 줄 강등, 그래도 크면 앞뒤만 보존하는 3단계 압축으로 다뤘고, 복원할 수 없는 사용자 발화는 건드리지 않았습니다.
같은 과제 5회: 클러스터는 3번 떴고 맞는 보고는 0번
과제는 "k3s 스타일 3노드를 도커로 흉내내서 띄우고 검증해 줘"였고, 두뇌는 GPT-4o였습니다.
1회차는 과대보고였습니다. 에이전트는 컨테이너 3개를 띄우고 "클러스터 실행 중"이라고 답했습니다. 직접 확인하니 컨테이너는 떠 있었지만 마스터는 cgroupv2 오류로 죽어 가는 중이었고 워커는 토큰이 틀려 조인하지 못했습니다.
master E "Failed to start ContainerManager" err="cannot enter cgroupv2 ..."
worker1 E "Failed to configure agent: ... 401 Unauthorized"
$ kubectl get nodes → (빈 출력)docker ps는 프로세스가 있다는 것만 말하지 일한다는 것을 말하지 않습니다. 프롬프트에 "검증하라"를 넣는 것만으로는 에이전트가 검증하지 않았기 때문에, 한 번의 호출로 끝나는 검증 도구 verify_running을 만들었습니다. 컨테이너 이름, URL, host:port, 셸 명령을 섞어 넘기면 exited 상태, 재시작 3회 이상의 크래시 루프, 헬스체크 실패, 로그에 반복되는 오류를 모아 판정합니다. 만들면서 거짓 실패를 줄이는 데 공을 들였습니다. 갓 기동한 서버가 첫 연결을 한 번 끊어 멀쩡한 nginx가 실패로 나오자, 연결 수준 오류만 3회 재시도하고 HTTP 상태 코드는 서버의 대답이므로 재시도하지 않게 했습니다. 검증 도구의 거짓 실패는 에이전트가 멀쩡한 것을 고치려 들게 만들기 때문입니다.
이 도구를 연결하는 과정에서 새 도구가 목록에 나타나지 않는 결함도 추적했습니다. 테스트하며 띄운 pod 프로세스 4개가 같은 pod ID로 동시에 접속해 있었는데, 허브는 새 연결이 오면 레지스트리만 덮어쓰고 옛 소켓을 그대로 두었습니다. 나중에 옛 연결이 끊기면 정리 코드가 살아 있는 새 pod를 등록 해제했습니다. hello 시점에 같은 ID의 옛 연결을 닫고(마지막 연결 우선), 끊길 때는 레지스트리 항목이 자기 자신일 때만 해제하도록 고쳤습니다. 디스크에서 pod를 복원할 때 생존 확인을 하지 않아 꺼진 지 오래된 pod의 도구 61개가 상한 110을 차지한 문제도 있었는데, 처음 쓴 필터는 아무것도 거르지 못했습니다.
def restore(saved_pods):
for pod in saved_pods:
# probe_pod는 {"ok": bool, ...}를 반환한다. dict는 실패해도 참이라
# bool(probe_pod(...))로는 아무것도 거르지 못한다.
if probe_pod(pod.url, pod.token).get("ok"):
register(pod)2회차는 반대 방향의 실패였습니다. 에이전트는 설치 경로를 8번 바꾸고, 명령이 시간 초과되자 job 도구로 갈아타 실제로 3노드를 모두 Ready로 띄웠습니다. 그런데 답은 "클러스터에 연결할 수 없습니다"였습니다. 호스트의 kubeconfig에 적힌 주소가 루프백이 아닌 LAN 주소로 풀려 API 포트에 닿지 못했을 뿐, 컨테이너 안에서 같은 명령을 실행하면 3노드가 그대로 보였습니다. 증상 서술은 맞았지만 결론이 틀렸습니다. 접근 경로가 막힌 것과 대상이 돌지 않는 것을 구분하지 못한 것입니다. verify_running에 컨테이너 안에서 재확인하는 옵션을 넣고, 결과의 reason에 "호스트에서"와 "컨테이너 안에서"를 명시했으며, 호스트 확인이 실패하면 컨테이너 안 재확인을 권하는 hint를 반환하게 했습니다. 실측에서 호스트 확인은 시간 초과로 실패, 컨테이너 안 확인은 3노드 Ready로 성공했습니다.
3회차에는 도구가 부팅 로그를 실패로 세는 오판이 있었고, 컨테이너 검증을 최근 로그 기준으로 판정하도록 바꿨습니다. 이 회차에서 verify_running을 실제로 쓰면서 도구 호출은 20회 이상에서 8회로 줄었습니다. 4회차는 제 수정이 잘못된 갈래로 민 경우였습니다. 시간 초과 안내에 "오래 걸리면 start_job"을 넣었더니, 에이전트가 호스트에서 막힌 kubectl을 job으로 넘겨 "kubectl 실패, job 재시도, 클러스터 재생성"을 반복했습니다. 느린 것이 아니라 막힌 것이라 job으로 넘겨도 똑같이 실패합니다. 접속형 명령(kubectl, curl, psql 등)의 시간 초과는 "닿지 못한다"는 뜻이라고 안내를 갈랐고, 그 자리에서 컨테이너 안 실행을 hint로 주게 했습니다. in_container 안내를 verify_running 안에만 두었을 때는 소용이 없었습니다. 에이전트는 raw 명령으로 접속하다 막히기 때문에, 힌트는 막히는 바로 그 자리에 있어야 했습니다.
5회차에서 에이전트는 이미 PATH에 있던 k3d를 확인하지 않고 재설치를 여러 방법으로 시도하다 엉뚱한 설정 안내로 끝났고, 그 과정에서 docker network prune -f를 실행했습니다. 처음에는 도는 컨테이너가 모두 멀쩡해 피해가 없다고 판단했지만, 로그를 끝까지 읽어 보니 같은 머신에서 멈춰 있던 다른 스택들의 네트워크가 실제로 지워져 있었습니다. 도는 컨테이너만 보고 피해 없음이라 판단한 것 역시 이 실험이 지적하는 바로 그 검증 부족이었습니다. 명령 실행 앞단에 넓게 쓸어버리는 명령을 막는 게이트를 두었습니다. 능력을 막는 것이 아니라 조준을 요구하는 방식이라, 이름을 지정한 삭제는 모두 허용하고 차단 메시지가 대안 명령을 제시합니다.
_BLAST = [
(r"\b(docker|podman)\s+(system|network|volume|image|container)\s+prune\b",
"prune은 다른 프로젝트 것까지 지운다. docker network rm <이름>처럼 지목하라."),
(r"\bdocker\s+(rm|stop|kill)\s+.*\$\(\s*docker\s+ps",
"도는 컨테이너 전부가 대상이다. 네가 띄운 것만 이름으로 지목하라."),
(r"\brm\s+-[a-z]*[rf][a-z]*\s+(/|[a-z]:[/\\])\s*$", "드라이브 루트를 지우려 한다."),
(r"\bformat\s+[a-z]:|\bmkfs(\.[a-z0-9]+)?\s+/dev/", "디스크를 포맷하려 한다."),
]format 패턴은 처음에 git format-patch까지 잡아서 드라이브를 지목한 format과 /dev/를 지목한 mkfs로 좁혔습니다. 차단 대상 7건과 허용 대상 8건, 총 15건으로 검증했습니다. 설치 전에 <도구> --version으로 존재를 먼저 확인하라는 규율도 프롬프트에 추가했습니다.
결과
| 회차 | 클러스터 | 답변 | 실패 원인 |
|---|---|---|---|
| 1 | 실패(cgroupv2) | "실행 중" | 과대보고: docker ps만 확인 |
| 2 | 3/3 Ready | "연결 불가" | 과소보고: 접근 경로 실패를 대상 실패로 판단 |
| 3 | 3/3 Ready | "고장" | 도구 오판: 부팅 로그를 실패로 셈 |
| 4 | 3/3 Ready | "전문가 도움 필요" | 루프: 닿지 못하는 명령을 오래 걸리는 명령으로 취급 |
| 5 | 실패 | 엉뚱한 설정 안내 | 이미 설치된 도구를 다시 설치하려 함 |
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 도구 호출 수 | 20회 이상 | 8회 | k3s 과제 3회차, verify_running 사용 |
| 파괴적 명령 판정 | 게이트 없음 | 15/15 일치 | 차단 대상 7건 전부 차단, 허용 대상 8건 전부 통과 |
| 컨테이너 조회 응답 | 30~90초 | 4초 | 플랫폼 LLM 전부 무응답 상황, 직접 호출 두뇌 우선 |
| 장시간 작업 | 20초·90초 제한으로 중단 | 도커 빌드 192초 완료 | job 도구 + 진전 기준 턴 예산 |
| 대화 컨텍스트 크기 | 31.5k | 16k | LLM 없는 3단계 압축, 사용자 발화 보존 |
회고와 남은 과제
5회의 결과를 한 줄로 요약하면, 만드는 능력과 자기 결과를 읽는 능력이 이만큼 벌어져 있다는 것입니다. 에이전트는 도구가 없으면 내려받아 설치하고, 막히면 경로를 바꾸고, 오래 걸리면 job으로 넘겨 실제 3노드 클러스터를 세웠습니다. 못 한 것은 확인이었습니다. 그래서 확인을 한 번의 호출로 끝나는 도구로 만들고, 다음 수를 그 자리에서 알려 주게 했습니다. 같은 과제와 같은 코드에서도 회차마다 실패 지점이 달랐다는 점도 기록해 둡니다.
남은 일은 분명합니다. 접속형 명령 시간 초과 안내가 4회차의 루프를 실제로 끊는지는 6회차를 실행해 확인하지 못했습니다. 에이전트가 시스템 폴더에 바이너리를 설치한 것은 사용자 폴더로 유도해야 합니다. 스킬 원장 키가 이름만이라 세션이 늘면 동명 스킬이 덮어써질 수 있어 (스코프, 이름) 튜플로 바꿔야 하고, pod 도구에 검증된 것만 싣는 홀드아웃 게이트 연결도 아직 남아 있습니다.