這是 osFoundry MCP server 裡唯一一個不需要任何人點擊就會開始工作的工具。不論 client 有沒有登入,tools/list 回傳的就是這樣:

{"name": "request_session_run", "description": "Queue a bounded goal for an agent, team, or loop session where this assistant is an invited runner. The osFoundry runtime executes the task when available.", "inputSchema": {"properties": { "session_type": {"enum": ["agent_session", "team_session", "loop_session"]}, "session_id": {"type": "string"}, "goal": {"type": "string", "maxLength": 10000}, "max_rounds": {"type": "integer", "minimum": 1, "maximum": 10}, "idempotency_key": {"type": "string"}}, "required": ["session_type", "session_id", "goal", "idempotency_key"], "additionalProperties": false}, "annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]} 

這段 description 裡有兩個詞決定了 client 要怎麼用它。"Invited runner" 是一個會在三個地方檢查的權限。"When available" 代表呼叫會在工作開始前就返回,所以 client 得回頭來拿結果。這篇文章會逐一說明這兩點,以及 Codex 之後用來和 session 溝通的信箱,並附上在這台 Mac 上跑的 Python 版 client 端重建。

登入前 client 能知道什麼?

其實不少。initialize、tools/list 和 protected-resource metadata 都不需要 token 就會回應:

$ uv run --with tiktoken --with jsonschema python3 runner_sim.py == Part 1: live wire, 10 calls each, ms == initialize HTTP 200 median 375.1 p90 726.0 tools/list HTTP 200 median 551.1 p90 755.5 protected-resource HTTP 200 median 325.4 p90 578.8 tools/call (no token) HTTP 401 median 454.2 p90 492.8 scopes_supported: ['osfoundry:read', 'osfoundry:write', 'osfoundry:run', 'osfoundry:models'] 

這是在日本用 Apple M1 量的,所以這些毫秒數反映的不只是 server,也包含網路路徑。osfoundry:run 是獨立公開的 scope,和 osfoundry:write 分開。Codex 設定如果沒有把它放進 scopes,就永遠拿不到能排入 run 的 token,之後被指派什麼 seat 都一樣。

Runner 迴圈只用到 server 59 個工具中的五個:read_session_state、request_session_run、read_session_task_result、post_session_note 和 read_session_mail。它們佔整個清單 10,197 個 token 中的 821 個(8.1%,用 cl100k_base 計算,對 Codex 自己的 tokenizer 來說只是近似值)。在 enabled_tools 只列出這五個,就能讓另外 54 個工具不進 prompt。

誰決定 Codex 是 runner?

三個不同的關卡,一次呼叫三個都要過:

  1. Token。request_session_run 宣告了 osfoundry:run。登入頁面把這個 scope 顯示為 "Run sessions where you grant a runner role"。
  2. Grant。在 osFoundry 的 web client(ExternalAssistantsPanel.tsx)裡,session grant 帶有幾個 action,標籤分別是 View、Reply、Edit 和 "Start work"。Grant 的角色下拉選單只有在 grant 包含 write 時才會出現 Contributor,包含 run 時才會出現 Runner。
  3. Seat。Owner 會為每個 session 儲存一個角色,client 以 POST /external-assistants/{connection}/sessions/{session_type}/{session_id} 搭配 {"role": "observer" | "contributor" | "runner"} 送出。下拉選單上顯示為 Viewer、Contributor 和 Runner。

腳本重建了這個檢查。角色和工具的對應來自面板的說明文字("Contributor to post notes and propose handoffs, or Runner to also request runs"),每個工具的 scope 則來自 live schema。osFoundry 的 server 程式碼沒有公開,所以這是規則的重建,不是實際抓到的 server 拒絕回應:

grant actions ['read'] -> roles offered ['observer'] grant actions ['read', 'write'] -> roles offered ['observer', 'contributor'] grant actions ['read', 'write', 'run'] -> roles offered ['observer', 'contributor', 'runner'] tool observer contributor runner read_session_state yes yes yes request_session_run - - yes read_session_task_result yes yes yes post_session_note - yes yes read_session_mail yes yes yes request_session_run seat=contributor scopes=read+run+write -> seat 'contributor' cannot call it request_session_run seat=runner scopes=read+write -> token lacks osfoundry:run 

對寫 client 的人來說,這個區分很重要。request_session_run 被拒絕有兩種不同的解法:帶著 scope 重新登入(你這邊),或請 owner 給 seat(對方那邊)。要回報是哪一種,而不是一直重試。

從呼叫到拿到結果之間發生了什麼?

呼叫會排入一個 task 然後返回。Web client 的型別說明了這個 task 是什麼:一個 task_type: "external_assistant" 的 AgentTask,API 的 task 建立路由會拒絕這個型別,因為只有 server 能啟用它。它的狀態會經過 pending、running、input_required、completed、failed 或 cancelled。在 session 的 Trigger 分頁裡,它的標籤是 "Requested by an outside assistant"。

誰來接手取決於 session 跑在哪裡。如果 session 在 owner 的桌面 app 上,agent_session_runtime.rs 把時間寫得很清楚:

  • DEFAULT_POLL_INTERVAL_MS 是 15,000。到期的 task 會在下一次 poll 時以 lease 認領。
  • 連續四次 poll 都沒找到東西後(IDLE_CYCLES_BEFORE_BACKOFF),間隔會沿著 IDLE_BACKOFF_LADDER_MS 拉長:30 s、60 s,然後 300 s。檔案裡有一段註解直接講明代價:server 啟用的 trigger "arriving at a fully idle desktop waits up to five minutes"(到達完全閒置的桌面時最多要等五分鐘)。只要有人在 app 裡做任何操作,迴圈大約一秒內就會被喚醒。
  • 已經 "already has a run in flight" 的 session 會延後它到期的 task。同一個 session 的 run 不會重疊。
  • Run 結束時,結果會由 runtime 帶著 task_id 寫進 session mail。主旨會說明它是正常完成("Task … result"),還是停在某個上限("goal not fully met"、"stopped at your token ceiling")。
  • Goal 會被當成不可信的輸入來檢查。provenance_for_task_type 只有對 manual,也就是 owner 在 app 裡自己啟動的 run,才會回傳 Operator。其他所有型別,包括這一個,都要經過 osGuard。

任何 client 都該特別處理 input_required。它代表 run 向 osFoundry 裡的某個人提出了問題,正卡在等回答。它仍然在執行中。繼續等,並告訴你的使用者 osFoundry 那邊需要有人回答。

Client 應該多久 poll 一次?

Codex 沒辦法在工具呼叫裡 sleep,而一個卡住好幾分鐘的 MCP 工具呼叫會撞上 client 的 timeout。所以實際可行的做法是對 read_session_task_result 跑 polling 迴圈。問題在於間隔要設多少。

腳本的第 4 部分用虛擬時鐘模擬:2,000 次 run,接手延遲取自 runtime 本身的間隔,run 的長度取自中位數 90 s 的對數常態分布。Run 長度是假設值,所以絕對數字只當參考。每種策略都會計算它發出的呼叫次數,以及 run 完成後多久才發現:

runtime active pending wait: median 7.5 s p90 13.5 s max 15.0 s poll every 5 s calls/task median 20 p90 41 noticed late by median 2.5 s p90 4.5 s poll every 30 s calls/task median 4 p90 7 noticed late by median 15.0 s p90 26.8 s poll 5 s x1.5, cap 60 s calls/task median 6 p90 8 noticed late by median 21.5 s p90 48.8 s poll 15 s x2, cap 120 s calls/task median 3 p90 4 noticed late by median 41.8 s p90 107.2 s poll first at 60 s, then 15 s calls/task median 4 p90 11 noticed late by median 7.9 s p90 14.6 s runtime idle-300 pending wait: median 152.4 s p90 273.7 s max 300.0 s poll every 5 s calls/task median 51 p90 78 noticed late by median 2.5 s p90 4.5 s poll every 30 s calls/task median 9 p90 13 noticed late by median 14.8 s p90 26.6 s poll 5 s x1.5, cap 60 s calls/task median 9 p90 11 noticed late by median 28.4 s p90 52.9 s poll 15 s x2, cap 120 s calls/task median 5 p90 6 noticed late by median 59.0 s p90 107.7 s poll first at 60 s, then 15 s calls/task median 14 p90 23 noticed late by median 7.5 s p90 13.4 s 

從中可以看出三件事。

Exponential backoff 在這裡不划算。它是 polling 的直覺做法,但這些 run 只跑幾分鐘,不是幾小時。"15 s x2, cap 120 s" 比固定 30 s 省一兩次呼叫,但中位數上晚 40 到 60 s 才發現結果。

在活躍的 runtime 上,最好的取捨是讓第一次等待接近 run 的典型長度:中位數四次呼叫,8 s 內就發現結果。Run 不可能完成之前的每一次呼叫都是浪費。

Runtime 本身的狀態比 client 的策略更重要。在已經閒置的桌面上,中位數的 run 會在 pending 停留 152 s 才被認領,任何 polling 間隔都改變不了。如果你盯著 read_session_state,發現幾分鐘後 task 還是 pending,那是 runtime 的閒置 backoff,不是 run 卡住。

在同一個 session 上連續排入的 run 會依序排隊:

 queued run 1: starts at 13.9 s, done at 121.9 s queued run 2: starts at 135.9 s, done at 346.2 s queued run 3: starts at 357.7 s, done at 406.6 s 

如果 Codex 排入三個 goal,再用 5 s 的計時器同時 poll 三個,其中兩個會花好幾分鐘一直回傳 pending,原因只是在排隊。

為什麼 note 不能啟動 run?

post_session_note 只需要 osfoundry:write 和 Contributor seat。如果 note 能喚醒 session 的 agent 去跑 run,那每個 Contributor 其實都是換了名字的 Runner。Description 明確排除了這點:"Other agents can reply to the returned external sender slug; this does not trigger an automatic run."

所以 note 是訊息,不是觸發器。它會帶著 connection 的顯示名稱進到 session mail。Web client 會在 Team mail 裡顯示 external_sender_name 和 "outside assistant" 標籤。Agent 下次執行時可以回覆它。

信箱會回傳什麼?

read_session_mail 的說明是 "bounded notes addressed to this connection and notes it posted",另外還有 "Internal agent analysis is excluded."。Schema 把每頁限制在 limit 1 到 20,並用 before_id 往前翻頁。

腳本模擬了一個忙碌 team session 的 mail,共 409 列,涵蓋 web client 認得的訊息類型(analysis、tool_call、tool_result、agent_note、broadcast),並套用說明中的過濾規則:

session mail rows 409; visible to the connection 22 (addressed to it 13, posted by it 9) not visible, by type: {'tool_call': 97, 'tool_result': 62, 'agent_note': 25, 'analysis': 176, 'broadcast': 27} limit 20: 2 calls to walk back through all 22 notes limit 10: 3 calls to walk back through all 22 notes limit 5: 5 calls to walk back through all 22 notes 

列數是模型給的。重點在形狀:connection 看得到的只是一小塊,所以 limit: 20 通常一兩次呼叫就能全部拿到。Run 的結果也不在這一小塊裡。在桌面 runtime 的程式碼中,結果 mail 發出時沒有指定收件者,所以不是寄給 connection 的。結果用 read_session_task_result 讀,對話用 read_session_mail 讀,不要期待其中一個能取代另一個。

Schema 會在哪裡擋下 client?

針對 live inputSchema 測了十三個案例,用 jsonschema(Draft 2020-12)驗證:

request_session_run goal 10,000 chars ok request_session_run goal 10,001 chars REJECT maxLength: goal request_session_run max_rounds 10 ok request_session_run max_rounds 11 REJECT maximum: max_rounds request_session_run extra field 'context' REJECT additionalProperties request_session_run session_type 'graph_session' REJECT enum: session_type read_session_task_result no task_id REJECT required read_session_mail limit 20 ok read_session_mail limit 0 REJECT minimum: limit read_session_mail limit 50 REJECT maximum: limit post_session_note subject 200, body 10,000 ok post_session_note body 10,001 REJECT maxLength: body post_session_note to: '@writer' REJECT additionalProperties 

其中兩個很容易踩到。Run 沒有 context 欄位,所以背景資訊要放進 goal,10,000 字元的上限是兩者合計。Note 沒有 to,所以沒辦法指定給某一個 agent。每則 note 都是發給 session,agent 再回覆到 connection 的 sender slug。

整個腳本在 M1 上的 wall-clock 時間是 19.3 s,包含 40 次網路呼叫。User CPU 時間是 0.47 s,所以模擬本身的成本可以忽略。

如果你要自己串接

  • 把 "start work" 公開成獨立的 OAuth scope,並且讓角色同時受 grant 和 token 把關,這樣不論是呼叫端還是過期的 grant,都沒辦法自己變成 runner。
  • 立即回傳 task id,並給呼叫端一個便宜的狀態查詢。不要讓 MCP 工具呼叫去等 agent run 跑完。
  • 在文件或狀態裡公開 runtime 實際的接手行為(poll 間隔、每個 session 一次一個 run),讓 client 用合理的頻率 poll,而不是用猜的。
  • 外部呼叫端送來的任何東西都當成不可信的輸入來檢查,即使呼叫端握有 runner seat。
  • 把 note 和 run 分開:寫 note 的人不應該能喚醒 run,讀回的內容也應該排除 session 的內部分析。

每個 session 的 run 怎麼排隊,請看 agent task queue 頁面,session 的其他啟動方式則在 agent triggers。