這是 osFoundry 代理網路的完整接收端,內容和它公開的 MCP endpoint 所宣告的一模一樣。輸出來自 https://api.osfoundry.io/api/v1/external-mcp 上的 tools/list,這個呼叫不需要 token 就會回應:

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}} 

只有三個參數,沒有 subscription、webhook,也沒有 socket。Claude Code 和 Codex 都是 MCP client,它們只會發 request,沒有東西能反過來呼叫它們。所以 Codex 想知道 Claude Code 說了什麼,就只能自己去問,而 wait_for_messages 的設計就是讓「問」這件事很便宜。說明裡寫著它會「一有訊息到達就立刻回傳,或在 timeout_s(最多 50)之後回傳空清單」,而且「沒帶 after_sequence 時,會從你上一次呼叫停下的地方接著回傳」。

接下來會把這份契約逐項拆開,再用 Python 重做成一個小型 server 加上 client,實際量測。這台 Mac 上沒有 claude 或 codex 執行檔,所以兩個代理都是用執行緒模擬,透過 HTTP 呼叫重做的 server。osFoundry 的 server 程式碼沒有公開,這次重做只照抄公開的契約:工具名稱、限制和說明。所有東西都在 code/(realprobe.py、netsim.py 和它們的輸出)。

什麼樣的訊息算是「發給你的」?

說明列了三條路徑:「透過 to、透過 @handle,或在私訊中」。handle 來自 claim_connected_profile,它的 schema 把規則訂得很清楚:

"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 這條路徑上,osFoundry 的 web client 內建了一份和 server 相同的比對規則,讓輸入框和 server 解讀草稿的方式一致。它是 apps/web/src/screens/message/lib/agent-presence.ts 裡的 bodyMentionsHandle:

new RegExp(`(?<![\\w.-])@${escapeRegExp(handle)}(?![\\w-])`, 'i') 

把同一個 regex 搬到 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 

lookbehind 讓 email 地址不會被當成 mention。lookahead 讓 @bob-codex 不會比對到 @bob-codex-2,當 handle 會加上數字後綴時這點很重要。結尾有句點沒關係,大小寫也不影響。

為什麼在內文寫 @handle「不會叫醒任何人」?

send_channel_message 講得很直白:「在內文寫 @handle 不會叫醒任何人。」可是 wait_for_messages 又會依 @handle 投遞訊息。兩句話都對,因為它們講的是兩種不同的代理。

osFoundry 的代理是會被叫醒的:發文會在擁有者的裝置上啟動一次執行,而這次執行會花掉某個人 key 上的錢。這只會發生在 to 裡的 handle 上,而且前提是該代理「可以連線,且屬於連接你的那個人,或已選擇在該頻道回應其他代理」。像 Codex 這種外部代理永遠不會被叫醒,它是自己去拉。內文裡的 mention 會在它下次詢問時出現在 wait_for_messages 的結果中,不會替它啟動任何東西。

所以 to 是刻意指定、經過檢查的地址,結果還會告訴你「投遞給了誰、略過了誰,以及原因」。內文 mention 則是讀者可能會注意到的東西。想要對方回覆,就把 handle 放進 to。

為什麼用長輪詢?為什麼是 50 秒?

重做的版本比較了接收端代理的三種詢問方式。每種模式跑 60 秒,同樣的 10 則訊息在固定的隨機時間點送出(seed 7)。延遲是從 server 存下訊息算到接收端拿到訊息為止。

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 s 測試中的呼叫數 閒置一小時的呼叫數
長輪詢,timeout_s 50 1.0 ms 10 72
每 2 s 短輪詢 535.5 ms 29 1,800
每 10 s 短輪詢 4,593.4 ms 5 360

短輪詢得在延遲和呼叫次數之間取捨,長輪詢則兩邊都顧得到。呼叫次數跟著訊息走(10 則訊息、10 次呼叫),閒置的代理每 50 秒只花一次呼叫。

這些數字是在本機量的。真正的 endpoint 上,最低成本是網路。從這台 Mac 對真正的 endpoint 發 20 個未認證的 tools/call request(每個都是新的 TLS 連線,在做任何事之前就被 401 擋掉),中位數 740 ms,p90 1,203 ms。回應經過 CloudFront(via: … cloudfront.net,POP NRT12-P8),並帶有 x-ratelimit-limit: 2000。每 2 秒輪詢一次,一小時就會白白花掉 1,800 個這樣的 request。

50 必須落在周圍所有 timeout 之下。一個掛著不放的 request,不能撐得比路徑上任何一層還久:CDN 等 origin 的時間、load balancer 的閒置計時器、client 自己的工具 timeout 都一樣。Codex 的 MCP tool_timeout_sec 預設是 60 秒。上限設 50 秒,空回應就能在任何一層切斷 request 之前送達,而空回應正是「再問一次」的乾淨訊號。

回應遺失的訊息會怎樣?

「沒帶 after_sequence 時,會從你上一次呼叫停下的地方接著回傳。」這很方便:就算代理忘了自己的 cursor,也不會看到重複訊息。但 server 只知道自己送了什麼,不知道對方收到了什麼。在重做的版本中,server 回傳訊息時就會推進它存的 cursor。接著在 client 端每五個回應丟掉一個,這就是連線中斷或 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=[] 

用 server 的續傳點,四個遺失的回應就是四則遺失的訊息。改成由 client 送出自己實際處理完的最後一個 sequence,同樣那四則會在下一次呼叫時重新投遞。公開的說明沒有寫 osFoundry 的 server 什麼時候推進 cursor,這次重做也回答不了這個問題,但它說明了這個參數為什麼存在。如果遺失訊息對你來說是個問題,就記住你處理過的最大 sequence,並把它當作 after_sequence 傳進去。

為什麼送訊息時一定要帶 idempotency_key?

傳送端遇到的是反過來的問題。一個回應遺失的 send_agent_message,可能已經存下來了,也可能沒有。不帶 key 重試可能產生重複訊息,帶同一個 key 重試就不會:

same key x3 -> new messages: 1 fresh key x3 -> new messages: 3 

schema 要求 8 到 128 個字元,說明也要求「重試時使用穩定的 idempotency key」。反面的狀況在重做的版本裡也看得到:用舊 key 送出的新問題,會被當成舊訊息處理。key 要從內容和嘗試本身推導,例如 alice-claude-orders-0001,而不是用一個 CLI 重啟就歸零的計數器。

200 個閒置代理會讓 server 付出多少代價?

重做的版本讓 200 個代理用 timeout_s 5 掛在 wait_for_messages 上,然後送一則訊息給 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()。每個掛著的 request 都會被叫醒,重新掃一遍 log,才發現訊息不是給自己的。200 個還沒問題,20,000 個就很糟。真正的 server 需要為每個收件者各開一條等待佇列,讓一次傳送只叫醒用得到它的 request。

如果你要自己串接

  • 在 client 端自己保存 cursor。after_sequence 要傳你最後處理完的那則訊息,而不是最後收到的那則。
  • 需要回覆時,把 handle 放進 to。內文的 @mention 只當作提示,並檢查傳送結果中的已投遞/已略過清單。
  • timeout_s 要比你和 server 之間的每一層 timeout 都小,包括 MCP client 的工具 timeout。60 之下用 50 沒問題。
  • idempotency key 在重試之間要穩定,在不同問題之間要唯一。
  • 別忘了,對一個在 Claude Code 或 Codex 裡跑迴圈的代理來說,每次空的 wait_for_messages 結果也是一次模型回合。安靜的一小時 72 次,以 request 來說很便宜,但以 token 來說不是免費的。沒人需要這個代理時,就把迴圈停掉。