osFoundry 에이전트 네트워크에서 받는 쪽이 쓰는 도구는 이게 전부다. 토큰 없이 https://api.osfoundry.io/api/v1/external-mcp에 tools/list를 보내면 이렇게 나온다.
wait_for_messages: required=[] readOnly=True openWorld=False scope=['osfoundry:read']
{"after_sequence": {"type": "integer", "minimum": 0},
"timeout_s": {"type": "integer", "minimum": 0, "maximum": 50},
"limit": {"type": "integer", "minimum": 1, "maximum": 50}}
send_agent_message: required=['to', 'body', 'idempotency_key'] readOnly=False openWorld=True scope=['osfoundry:write']
{"to": {"type": "string"}, "body": {"type": "string", "maxLength": 20000},
"thread_root_id": {"type": "string"},
"idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}
파라미터 세 개가 끝이다. 구독도 웹훅도 소켓도 없다. Claude Code와 Codex는 둘 다 MCP 클라이언트라서 요청은 보낼 수 있지만, 밖에서 이쪽을 호출할 방법은 없다. 그러니까 Codex가 Claude Code 말을 들으려면 Codex가 먼저 물어봐야 한다. 그 물어보는 비용을 싸게 만든 게 wait_for_messages다. 설명에는 "도착하는 즉시 돌려주고, 안 오면 timeout_s(최대 50) 뒤에 빈 리스트", "after_sequence가 없으면 지난번 호출이 멈춘 데서 이어간다"고 되어 있다.
여기서는 이 계약을 하나씩 뜯어보고, Python으로 최소 서버와 클라이언트를 만들어서 직접 재 봤다. 이 맥에는 claude도 codex도 깔려 있지 않아서, 두 에이전트는 HTTP로 서버를 부르는 스레드로 대신했다. osFoundry 서버 코드는 공개돼 있지 않다. 그래서 다시 만든 건 공개된 계약(도구 이름, 상한값, 설명)까지다. 코드와 출력은 code/(realprobe.py, netsim.py, 각 .out)에 있다.
"나한테 온 메시지"는 정확히 뭔가
설명에 나오는 경로는 세 가지다. to, @handle, DM. 핸들은 claim_connected_profile로 잡는데, 스키마에 형식이 박혀 있다.
"handle": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,30}$"}
2~31자에 소문자, 숫자, 하이픈만 쓸 수 있다. 설명에는 maestro, direct, all, everyone, here, channel이 예약어로 나와 있다. 재구현도 같은 규칙으로 막았다.
claim alice-claude len=12 -> {'handle': 'alice-claude'}
claim a len=1 -> {'error': 'invalid handle'}
claim Alice len=5 -> {'error': 'invalid handle'}
claim here len=4 -> {'error': 'invalid handle'}
claim xxxxxxxxxxxx.. len=32 -> {'error': 'invalid handle'}
claim xxxxxxxxxxxx.. len=31 -> {'handle': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'}
@handle 판정은 웹 클라이언트에 서버 규칙 복사본이 들어 있다. 입력창과 서버가 같은 판정을 내리게 하려고 둔 거다. apps/web/src/screens/message/lib/agent-presence.ts의 bodyMentionsHandle이 그거다.
new RegExp(`(?<![\\w.-])@${escapeRegExp(handle)}(?![\\w-])`, 'i')
같은 정규식을 Python으로 옮겨서 bob-codex로 돌려 봤다.
'@bob-codex is the migration done?' -> True
'ask @bob-codex.' -> True
'(@bob-codex)' -> True
'@Bob-Codex hi' -> True
'mail bob@bob-codex.dev' -> False
'@bob-codex-2 hi' -> False
'@bob-codex_x hi' -> False
'@bob-codexy hi' -> False
후방 탐색 덕분에 이메일 주소는 멘션으로 안 잡힌다. 전방 탐색 덕분에 @bob-codex가 @bob-codex-2에 걸리지도 않는다. 핸들 뒤에 번호를 붙여 쓰는 경우 이 부분이 중요하다. 끝에 마침표가 붙는 건 괜찮고, 대소문자는 안 가린다.
본문 @handle은 "아무도 안 깨운다"는데 왜 도착하나
send_channel_message 설명에는 "본문에 @handle을 써도 아무도 안 깨운다"고 대놓고 적혀 있다. 그런데 wait_for_messages는 @handle로도 메시지를 준다. 모순 같지만 둘은 서로 다른 종류의 에이전트 얘기다.
osFoundry 에이전트는 깨워지는 쪽이다. 메시지가 올라오면 주인 기기에서 run이 시작되고, 누군가의 키로 돈이 나간다. 그래서 조건이 빡빡하다. to에 이름이 있어야 하고, 응답 가능한 상태여야 하고, 나를 연결한 사람이 주인이거나 그 채널에서 다른 에이전트 요청에 답하도록 켜 둔 경우여야 한다. Codex 같은 외부 에이전트는 깨워지지 않는다. 직접 가져간다. 본문에서 멘션된 메시지는 다음번 wait_for_messages 결과에 들어갈 뿐이고, 그 때문에 따로 실행되는 건 없다.
결국 to는 일부러 지정하고 검사도 받는 주소다. 보낸 결과에는 누구한테 갔고 누가 왜 빠졌는지가 돌아온다. 본문 멘션은 받는 쪽이 보면 다행인 정도다. 답이 필요하면 to에 넣자.
왜 롱 폴링이고 왜 50초인가
받는 쪽이 물어보는 방식을 세 가지로 비교했다. 모드마다 60초씩 돌리고, 같은 메시지 10개를 고정된 랜덤 시각(seed 7)에 보냈다. 지연은 서버가 메시지를 저장한 시점부터 받는 쪽이 손에 쥘 때까지다.
long-poll timeout_s=50 delivered=10/10 median= 1.0 ms max= 4.0 ms wait_for_messages calls in 60 s=10
short-poll every 2 s delivered=10/10 median= 535.5 ms max= 1717.4 ms wait_for_messages calls in 60 s=29
short-poll every 10 s delivered=10/10 median= 4593.4 ms max= 8495.5 ms wait_for_messages calls in 60 s=5
idle hour, no messages: long-poll 3600/50 = 72 calls; 2 s poll = 1800 calls; 10 s poll = 360 calls
| 방식 | 지연 중앙값 | 60초 동안 호출 | 조용한 1시간 호출 |
|---|---|---|---|
롱 폴링(timeout_s 50) |
1.0 ms | 10 | 72 |
| 2초 간격 숏 폴링 | 535.5 ms | 29 | 1,800 |
| 10초 간격 숏 폴링 | 4,593.4 ms | 5 | 360 |
숏 폴링은 지연이랑 호출 수 중에 하나를 포기해야 한다. 롱 폴링은 둘 다 챙긴다. 호출 수는 메시지 수를 따라가고(10개에 10번), 아무것도 안 올 때는 50초에 한 번이면 된다.
여기까지는 로컬 숫자다. 실서버에서는 한 번 호출할 때 드는 최저 비용을 네트워크가 정한다. 이 맥에서 실제 엔드포인트로 인증 없는 tools/call을 20번 보내 봤다. 매번 새 TLS 연결이고, 처리 전에 401로 막히는 요청이다. 중앙값은 740 ms, p90은 1,203 ms였다. 응답은 CloudFront를 거쳐 왔고(via: … cloudfront.net, POP NRT12-P8), x-ratelimit-limit: 2000이 붙어 있었다. 2초 폴링이면 아무 일도 없는 한 시간 동안 이 중 1,800번을 날리는 셈이다.
50이라는 값은 경로에 있는 타임아웃들보다 짧아야 한다. 열어 둔 요청은 CDN이 오리진을 기다리는 시간, 로드밸런서의 유휴 타이머, 클라이언트 자신의 도구 타임아웃 중 어느 것에도 먼저 끊기면 안 된다. Codex의 MCP tool_timeout_sec 기본값은 60초다. 상한을 50초로 두면 어딘가에서 끊기기 전에 빈 응답이 먼저 온다. 빈 응답은 "다시 물어봐"라는 깔끔한 신호다.
응답이 중간에 날아가면 그 메시지는 어떻게 되나
"after_sequence가 없으면 지난번 호출이 멈춘 데서 이어간다." 커서를 안 들고 있는 에이전트도 중복을 안 보니까 편하다. 문제는 서버가 아는 건 자기가 뭘 보냈는지지, 뭐가 도착했는지가 아니라는 점이다. 재구현에서는 메시지를 돌려주는 순간 서버 쪽 커서를 옮겼다. 그리고 응답 다섯 번 중 한 번을 클라이언트에서 버렸다. 연결이 끊기거나 CLI가 재시작되면 딱 이렇게 보인다.
no after_sequence (server resumes) dropped responses=4 processed=16/20 missing=['m4', 'm9', 'm14', 'm19']
after_sequence = last processed dropped responses=4 processed=20/20 missing=[]
서버가 재개 위치를 들고 있으면, 날아간 응답 네 개가 그대로 메시지 네 개 손실이 됐다. 클라이언트가 실제로 처리한 마지막 번호를 넘기면, 같은 네 개가 다음 호출에서 다시 왔다. osFoundry 서버가 커서를 언제 옮기는지는 공개된 설명에 없고, 재구현으로도 알 수 없다. 이 실험이 보여 주는 건 그 파라미터가 왜 있느냐다. 메시지를 놓치면 안 되는 상황이면, 처리한 가장 큰 sequence를 직접 들고 있다가 after_sequence로 넘기자.
보내는 쪽에 idempotency_key가 왜 필수인가
보내는 쪽은 같은 문제를 반대 방향으로 겪는다. 응답이 날아간 send_agent_message는 저장됐는지 안 됐는지 모른다. 키 없이 재시도하면 중복이 생길 수 있다. 같은 키로 재시도하면 안 생긴다.
same key x3 -> new messages: 1
fresh key x3 -> new messages: 3
스키마상 8~128자 필수이고, 설명도 재시도용으로 안정적인 키를 쓰라고 한다. 재구현에서 반대쪽 삽질 포인트도 보였다. 예전 키로 새 질문을 보내면 이전 메시지랑 같은 걸로 취급된다. 키는 내용과 시도를 기준으로 만들자(예: alice-claude-orders-0001). CLI를 재시작하면 초기화되는 카운터로 만들면 안 된다.
에이전트 200개가 대기 중이면 서버는 어떻게 되나
에이전트 200개를 timeout_s 5로 대기시켜 놓고 agent-137한테만 하나 보냈다.
woken with a message: ['agent-137'] latency=46.8 ms; the other 199 returned empty at timeout_s=5
받는 쪽은 맞게 골랐다. 그런데 지연이 1 ms에서 46.8 ms로 늘었다. 재구현은 threading.Condition 하나에 notify_all()을 써서, 대기 중인 요청이 전부 깨어나 로그를 다시 훑은 뒤에야 자기 게 아니라는 걸 안다. 200개면 괜찮지만 2만 개면 곤란하다. 실서버라면 수신자별로 대기열을 나눠서, 보낼 때 그 메시지를 쓸 수 있는 요청만 깨우는 게 맞다.
직접 붙인다면 정해 둘 것
- 커서는 클라이언트가 들고 있자.
after_sequence에는 받은 마지막 번호가 아니라 처리를 끝낸 마지막 번호를 넘긴다. - 답이 필요하면
to에 넣는다. 본문 @멘션은 힌트 정도로 보고, 보낸 결과의 delivered/skipped를 확인한다. -
timeout_s는 MCP 클라이언트의 도구 타임아웃까지 포함해서 경로 위 어떤 타임아웃보다도 짧게 잡는다. 60 아래 50이면 들어간다. - idempotency key는 재시도끼리는 같게, 질문마다는 다르게 만든다.
- Claude Code나 Codex 안에서 루프를 돌리면, 빈
wait_for_messages도 매번 모델 한 턴이다. 조용한 한 시간에 72번이면 요청 수로는 싸지만, 토큰까지 공짜는 아니다. 할 일이 없으면 루프를 멈추자.
Top comments (0)