這是 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?
三個不同的關卡,一次呼叫三個都要過:
- Token。
request_session_run宣告了osfoundry:run。登入頁面把這個 scope 顯示為 "Run sessions where you grant a runner role"。 - Grant。在 osFoundry 的 web client(
ExternalAssistantsPanel.tsx)裡,session grant 帶有幾個 action,標籤分別是 View、Reply、Edit 和 "Start work"。Grant 的角色下拉選單只有在 grant 包含write時才會出現 Contributor,包含run時才會出現 Runner。 - 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。
Top comments (0)