這是 Codex CLI 在還沒登入的情況下,第一次呼叫 osFoundry 的 session 工具時拿到的回應:

POST /api/v1/external-mcp {"method":"tools/call","params":{"name":"propose_session_handoff","arguments":{}}} HTTP/2 401 www-authenticate: Bearer resource_metadata="https://api.osfoundry.io/.well-known/oauth-protected-resource/api/v1/external-mcp",  error="invalid_token", error_description="Connect osFoundry to continue" {"jsonrpc":"2.0","id":3,"result":{"isError":true, "content":[{"type":"text","text":"Authentication required"}], …}} 

initialize 和 tools/list 不需要 token 就會回應,所以在任何人登入之前,client 就能看到全部 59 個工具。其中有兩個看起來做的是同一件事:

{"name": "propose_session_handoff", "description": "Send a scoped task and context summary to the owner of an invited session. The owner must accept before a run is queued.", "required": ["session_type","session_id","goal","context_summary","requested_output","idempotency_key"], "annotations": {"readOnlyHint": false, "openWorldHint": true, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:write"]}]} {"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.", "required": ["session_type","session_id","goal","idempotency_key"], "annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true}, "securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]} 

兩者最後都是讓某個 session 去做事。本文接下來會談為什麼要把它們分開、各自有哪些限制,以及一個用 Python 重建的 client 端,拿這些限制去對照線上的 schema 做驗證。

這兩個呼叫差在哪裡?

差在誰說好,以及什麼時候說。

Handoff 是當下去問一個人。Owner 會收到通知("Codex CLI requests a handoff")。Session 的 Handoffs 分頁會把它列在 "Outside assistant requests"(外部助理的請求)底下,附上目標、可展開的 "Context and expected output",以及一行 "Up to N rounds on your agent runtime"。在有人按下 Accept and run 之前,什麼都不會排進佇列。按下之後,web client 會把 {decision: "accept"} 送到 /agent-sessions/{id}/external-handoffs/{handoff_id}/decision,toast 會顯示 "Handoff accepted and run queued"。Handoff 這筆資料的狀態會從 pending 變成 accepted、declined 或 cancelled。

Run 則是事先就核准過了。Owner 在 Connected 分頁給這個連線一個 Runner 席位,這就是同意。那裡的說明文字是:"Invite it to a session as Contributor to post notes and propose handoffs, or Runner to also request runs. A handoff still needs the session owner's approval." 在登入頁面上,run scope 寫的是 "Run sessions where you grant a runner role"。

如果把兩者合成一個帶 needs_approval flag 的工具,要不要問人就變成由呼叫端決定。拆成兩個工具,這個決定就留在 owner 這邊,而且放在呼叫端碰不到的兩個地方:OAuth scope(osfoundry:write 對上 osfoundry:run)和席位(Contributor 對上 Runner)。Codex 設定裡的 scopes 如果沒有 osfoundry:run,就算坐在 Runner 席位上也啟動不了 run。

Annotations 也照同樣的方式切分。propose_session_handoff 是 openWorldHint: true,因為它的輸出會送到呼叫之外的某個人手上。request_session_run 是 openWorldHint: false,因為它只會碰到事先已經同意的 session 的佇列。兩者都是 destructiveHint: true,所以用 Codex default_tools_approval_mode = "writes" 的 client,在呼叫任何一個之前都會先詢問。

為什麼 handoff 一定要附上下文摘要?

因為在任何東西執行之前,會有人讀它。context_summary 和 requested_output 在 handoff 是必填,在 run 則根本沒有。Owner 要決定值不值得把自己 session 的 runtime 花在這件事上,光一句 "fix the export" 不夠讓人判斷。Runner 已經有這份信任了,所以 run 只需要一個 goal。

上限也不一樣。Handoff 的 goal 最多 5,000 字元,另外加上 5,000 的上下文和 1,000 的預期輸出。Run 的 goal 可以到 10,000。實務上 run 的 goal 會把上下文一起帶進去,因為沒有別的欄位可以放。

Codex 能讀回什麼?

比 session 裡實際有的還少:

  • read_session_task_result:"the status and attributed runtime result for one task started by this connection"。拿別的連線的 task id 來查也沒用。
  • read_session_mail:"notes addressed to this connection and notes it posted",每頁 1 到 20 筆,更舊的用 before_id 往回翻。"Internal agent analysis is excluded."
  • read_session_state:這個連線加入的 session 目前的狀態和最近的 task。
  • post_session_note:一則標明來源的 note,agent 可以透過 "the returned external sender slug" 回覆。它 "does not trigger an automatic run",所以沒辦法拿 note 來繞過 owner 的點擊。

在 team session 裡,這些 note 會出現在 Team mail,顯示連線的名稱和 "outside assistant" 標籤。

重建

這台 Mac 沒有裝 codex binary,所以由 handoff_sim.py(大約 220 行)來扮演 client。它做三件事:

  1. 不帶 token 呼叫線上 server:initialize、tools/list 和一次 tools/call,各 15 次,並把工具清單存下來。
  2. 用 jsonschema(Draft 2020-12)拿十二組參數對照線上的 inputSchema 驗證,所以關於限制的部分沒有任何猜測。
  3. 跑一個小型的 in-memory 模型,模擬席位、scope、owner 的決定和冪等性。osFoundry 的 server 程式碼沒有公開,所以這部分是根據工具說明和呈現 handoff 的 web client 重建的。它的回應來自模型,不是 server。
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py 

網路

initialize HTTP 200 median 566.3 p90 799.0 tools/list HTTP 200 median 559.7 p90 937.7 tools/call (no token) HTTP 401 median 482.6 p90 677.0 

單位是毫秒,從這台位於日本的 Mac 經由 CloudFront 的 KIX edge 量測,所以這些數字反映的比較是路線而不是 server。重點在形狀。Discovery 不花成本,也快到可以在每次啟動 Codex 時跑。第一個真正的呼叫要付出一次 401 的 round trip,而這個 401 帶著 resource_metadata,Codex 就是靠它在沒人告訴它的情況下找到 OAuth server。

七個 session 工具在完整清單的 10,197 個 token 中佔了 1,152 個(11.3%,用的是 cl100k_base,對 Codex 自己的 tokenizer 來說只是近似值)。在 enabled_tools 裡只列這幾個,prompt 就能保持精簡。

限制

工具 Scope 必填 上限
propose_session_handoff write 6 goal 5,000, context 5,000, output 1,000, max_rounds 1–10
cancel_session_handoff write 1 只有 handoff_id
request_session_run run 4 goal 10,000, max_rounds 1–10
read_session_state read 2 無
read_session_task_result read 3 無
read_session_mail read 2 limit 1–20
post_session_note write 5 subject 200, body 10,000

對照線上 schema 的十二個案例:

propose_session_handoff 6,000-char goal REJECT maxLength: goal request_session_run 6,000-char goal ok propose_session_handoff no context_summary REJECT required propose_session_handoff max_rounds omitted ok request_session_run max_rounds 0 REJECT minimum: max_rounds request_session_run max_rounds 11 REJECT maximum: max_rounds request_session_run no idempotency_key REJECT required request_session_run session_type 'room' REJECT enum: session_type request_session_run extra field 'model' REJECT additionalProperties read_session_mail limit 21 REJECT maximum: limit post_session_note 201-char subject REJECT maxLength: subject cancel_session_handoff only handoff_id ok 

其中兩個值得多看一眼。同樣 6,000 字元的 goal,當 run 沒問題,當 handoff 就太長。如果 client 組好一個長 prompt,然後丟給手邊剛好有的工具,就會踩到這個坑。另外 additionalProperties: false 會擋掉 model 欄位:呼叫端沒辦法指定 session 的模型。Session 會照它自己的設定跑。

max_rounds 在兩者都是選填。Schema 沒有公開預設值,所以如果你在意這個數字,就自己帶上。

席位、取消與接受(模型)

tool observer contributor runner propose_session_handoff - yes yes cancel_session_handoff - yes yes request_session_run - - yes read_session_state yes yes yes read_session_task_result yes yes yes read_session_mail yes yes yes post_session_note - yes yes runner seat, token without osfoundry:run -> {'error': 'token lacks osfoundry:run'} cancel while pending -> {'status': 'cancelled'} owner clicks Accept after cancel -> cancelled owner accepts -> accepted | cancel after -> {'error': 'already accepted'} another connection reads that task -> {'error': 'not a task started by this connection'} 

這張矩陣就是把 Connected 分頁的說明文字整理成表格。有意思的規則是取消的時間窗。cancel_session_handoff 的說明是在 "before its owner accepts it" 時有效,所以誰先發生就算誰的。Run 一旦排進佇列,要不要撤回就不是呼叫端說了算。這也是 cancel 只吃一個 handoff_id 的原因:沒有狀態要傳,只是把一筆 pending 的資料關掉而已。

遺失的回應

每個寫入操作都必須帶 idempotency_key。為了看看它的效果,模型送出 1,000 次 request_session_run 呼叫,丟掉 20% 的回應(seed 7),並讓 client 用同一個 key 最多重試三次:

server honours idempotency_key=True calls sent 1276 tasks queued 1000 duplicates 0 server honours idempotency_key=False calls sent 1276 tasks queued 1276 duplicates 276 

在別人的 runtime 上多跑了 276 次 run,這正是必填 key 要防的情況。任何在 timeout 後會重試的 client 都會碰到,而已經開始的 run 不會是你想跑兩次的東西。把 key 設成必填而不是選填,就沒有 client 會忘記帶。

整個 script 在 Apple M1 上的實際耗時是 26.4 s。幾乎全部花在 45 次網路呼叫上,user CPU 時間是 0.37 s。

如果你要自己串接

  • 把「問一個人」和「憑既有權限行事」拆成兩個工具、兩個 scope,讓 owner 的選擇存在 token 和席位裡,而不是呼叫端設定的參數裡。
  • 讓人做決定所需的欄位(context_summary、requested_output)只在提議時必填,並且上限設得比 run 的 goal 更緊。
  • 只允許呼叫端在請求還是 pending 時取消。接受之後,要不要停下工作是 owner 的事。
  • 每一種讀回都只限於這個連線啟動的或收到的內容,session 的內部分析不要放進去。
  • 每個寫入都把 idempotency_key 設成必填,重複時回傳第一次的結果。
  • 用 additionalProperties: false 把 schema 封起來,讓呼叫端沒辦法偷塞 session 沒選的模型或工具清單。

osFoundry 自己的 agent 如何在步驟之間交接工作,可以看 handoffs 頁面。底層的協定請參考 MCP 詞彙說明。