Inilah yang diterima Codex CLI saat pertama kali memanggil tool sesi di osFoundry sebelum login:
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 dan tools/list menjawab tanpa token, jadi client bisa melihat ke-59 tool sebelum ada yang login. Dua di antaranya terlihat seperti melakukan hal yang sama:
{"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"]}]}
Keduanya berujung pada sebuah sesi yang mengerjakan sesuatu. Sisa tulisan ini membahas kenapa keduanya dipisah, batas apa saja yang berlaku di masing-masing, dan sebuah rekonstruksi sisi client dengan Python yang mengecek batas-batas itu terhadap schema live.
Apa yang membedakan kedua panggilan itu?
Siapa yang bilang ya, dan kapan.
Handoff meminta persetujuan orang, saat itu juga. Owner menerima notifikasi ("Codex CLI requests a handoff"). Tab Handoffs di sesi menampilkannya di bawah "Outside assistant requests" (permintaan dari asisten luar) beserta tujuannya, bagian lipat "Context and expected output", dan baris "Up to N rounds on your agent runtime". Tidak ada yang masuk antrean sampai seseorang mengklik Accept and run. Setelah itu web client mengirim {decision: "accept"} ke /agent-sessions/{id}/external-handoffs/{handoff_id}/decision, dan toast-nya berbunyi "Handoff accepted and run queued". Baris handoff berpindah dari pending ke accepted, declined, atau cancelled.
Run sudah disetujui sebelumnya. Owner memberi koneksi itu kursi Runner di tab Connected, dan itulah persetujuannya. Teks bantuan di sana: "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." Di halaman login, scope run tertulis "Run sessions where you grant a runner role".
Kalau keduanya digabung jadi satu tool dengan flag needs_approval, pemanggil yang menentukan apakah manusia perlu ditanya. Dengan dua tool, keputusan itu ada di sisi owner, di dua tempat yang tidak bisa disentuh pemanggil: scope OAuth (osfoundry:write dibanding osfoundry:run) dan kursi (Contributor dibanding Runner). Konfigurasi Codex yang tidak mencantumkan osfoundry:run di scopes tidak bisa memulai run, bahkan di kursi Runner.
Annotations-nya mengikuti pemisahan yang sama. propose_session_handoff bernilai openWorldHint: true karena output-nya sampai ke orang di luar panggilan. request_session_run bernilai openWorldHint: false, karena hanya menyentuh antrean sesi yang sudah setuju sejak awal. Keduanya destructiveHint: true, jadi client yang memakai default_tools_approval_mode = "writes" milik Codex akan bertanya dulu sebelum menjalankan salah satunya.
Mengapa handoff mewajibkan ringkasan konteks?
Karena ada orang yang membacanya sebelum apa pun dijalankan. context_summary dan requested_output wajib di handoff dan tidak ada di run. Owner sedang memutuskan apakah runtime sesinya mau dipakai untuk ini, dan "fix the export" saja tidak cukup untuk memutuskan. Runner sudah punya kepercayaan itu, jadi run hanya butuh goal.
Batasnya juga berbeda. goal handoff berhenti di 5,000 karakter, ditambah 5,000 untuk konteks dan 1,000 untuk output yang diminta. goal run bisa sampai 10,000. Dalam praktiknya, goal run-lah yang membawa konteks, karena tidak ada field lain untuk itu.
Apa yang bisa dibaca kembali oleh Codex?
Lebih sedikit dari isi sesinya:
-
read_session_task_result: "the status and attributed runtime result for one task started by this connection". Task id milik koneksi lain tidak ada gunanya. -
read_session_mail: "notes addressed to this connection and notes it posted", 1 sampai 20 per halaman denganbefore_iduntuk yang lebih lama. "Internal agent analysis is excluded." -
read_session_state: status terkini dan task terbaru dari sesi yang sudah diikuti koneksi itu. -
post_session_note: catatan beratribusi yang bisa dibalas agen melalui "the returned external sender slug". Catatan ini "does not trigger an automatic run", jadi tidak bisa dipakai untuk melewati klik owner.
Di sesi tim, catatan-catatan itu muncul di Team mail dengan nama tampilan koneksi dan label "outside assistant".
Rekonstruksinya
Tidak ada binary codex yang terpasang di Mac ini, jadi handoff_sim.py (sekitar 220 baris) berperan sebagai client. Script ini melakukan tiga hal:
- Memanggil server live tanpa token:
initialize,tools/list, dan satutools/call, masing-masing 15 kali, lalu menyimpan daftar tool. - Memvalidasi dua belas kasus argumen terhadap
inputSchemalive denganjsonschema(Draft 2020-12), jadi tidak ada batas yang ditebak-tebak. - Menjalankan model in-memory kecil berisi kursi, scope, keputusan owner, dan idempotensi. Kode server osFoundry tidak publik, jadi bagian ini direkonstruksi dari deskripsi tool dan web client yang menampilkan handoff. Responsnya berasal dari model, bukan dari server.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py
Jaringan
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
Satuannya milidetik, diukur dari Mac ini di Jepang lewat edge KIX milik CloudFront, jadi angkanya lebih banyak bercerita tentang rute daripada server. Yang penting adalah polanya. Discovery gratis dan cukup cepat untuk dijalankan setiap kali Codex start. Panggilan sungguhan pertama memakan satu round trip 401, dan 401 itu membawa resource_metadata, yang membuat Codex bisa menemukan server OAuth tanpa diberi tahu.
Tujuh tool sesi itu memakan 1,152 token dari 10,197 token daftar lengkap (11.3%, cl100k_base, jadi anggap saja perkiraan untuk tokenizer Codex sendiri). Mencantumkan hanya tool-tool itu di enabled_tools membuat prompt tetap kecil.
Batasnya
| Tool | Scope | Wajib | Batas |
|---|---|---|---|
propose_session_handoff |
write | 6 | goal 5,000, context 5,000, output 1,000, max_rounds 1–10 |
cancel_session_handoff |
write | 1 | hanya handoff_id |
request_session_run |
run | 4 | goal 10,000, max_rounds 1–10 |
read_session_state |
read | 2 | tidak ada |
read_session_task_result |
read | 3 | tidak ada |
read_session_mail |
read | 2 | limit 1–20 |
post_session_note |
write | 5 | subject 200, body 10,000 |
Dua belas kasus terhadap schema live:
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
Dua di antaranya layak dilihat lagi. goal yang sama sepanjang 6,000 karakter lolos sebagai run tapi terlalu panjang sebagai handoff. Client yang menyusun satu prompt panjang lalu mengirimnya ke tool mana pun yang tersedia akan kena masalah ini. Lalu additionalProperties: false menolak field model: pemanggil tidak bisa memilih model sesi. Sesi berjalan dengan pengaturannya sendiri.
max_rounds opsional di keduanya. Schema tidak mencantumkan nilai default, jadi kalau angkanya penting buatmu, kirimkan.
Kursi, cancel, dan accept (model)
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'}
Matriks ini adalah teks bantuan tab Connected yang diubah jadi tabel. Aturan yang menarik adalah jendela cancel. cancel_session_handoff dideskripsikan bekerja "before its owner accepts it", jadi siapa yang lebih dulu, dia yang menang. Begitu run sudah masuk antrean, menariknya kembali bukan lagi wewenang pemanggil. Itu juga sebabnya cancel hanya menerima handoff_id: tidak ada state yang perlu dikirim, hanya baris pending yang perlu ditutup.
Respons yang hilang
Setiap write mewajibkan idempotency_key. Untuk melihat manfaatnya, model mengirim 1,000 panggilan request_session_run, membuang 20% respons (seed 7), dan membiarkan client mencoba ulang sampai tiga kali dengan key yang sama:
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
276 run tambahan di runtime milik orang lain, itulah kasus yang ingin dicegah oleh key wajib ini. Client mana pun yang retry setelah timeout akan mengalaminya, dan run yang sudah jalan bukan sesuatu yang ingin terjadi dua kali. Karena key-nya wajib, bukan opsional, tidak ada client yang bisa lupa.
Seluruh script memakan 26.4 s waktu nyata di Apple M1. Hampir semuanya untuk 45 panggilan jaringan, dan waktu CPU user-nya 0.37 s.
Kalau kamu merangkainya sendiri
- Pisahkan "bertanya ke orang" dan "bertindak dengan izin yang sudah ada" menjadi dua tool dengan dua scope, supaya pilihan owner ada di token dan kursi, bukan di argumen yang diatur pemanggil.
- Wajibkan field yang dibutuhkan orang untuk memutuskan (
context_summary,requested_output) hanya di proposal, dan beri batas yang lebih ketat daripadagoalrun. - Izinkan pemanggil membatalkan hanya selama permintaan masih pending. Setelah diterima, menghentikan pekerjaan adalah urusan owner.
- Batasi setiap pembacaan kembali pada apa yang dimulai atau dikirim ke koneksi ini, dan jangan sertakan analisis internal sesi.
- Wajibkan
idempotency_keydi setiap write, dan kembalikan hasil pertama saat ada pengulangan. - Tutup schema dengan
additionalProperties: false, supaya pemanggil tidak bisa menyelipkan model atau daftar tool yang tidak dipilih sesi.
Cara agen-agen osFoundry sendiri saling mengoper pekerjaan antar langkah ada di halaman handoffs. Untuk protokol di baliknya, lihat entri glosarium MCP.
Top comments (0)