DEV Community

Cover image for Perché osFoundry dà a Codex un tool per proporre un handoff e un altro per avviare un run?
BBT for BBT Italiano

Posted on

Perché osFoundry dà a Codex un tool per proporre un handoff e un altro per avviare un run?

Ecco cosa riceve Codex CLI la prima volta che chiama un tool di sessione su osFoundry senza aver fatto l'accesso:

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"}], …}}
Enter fullscreen mode Exit fullscreen mode

initialize e tools/list rispondono senza token, quindi un client vede tutti i 59 tool prima che qualcuno faccia l'accesso. Due di questi sembrano fare la stessa cosa:

{"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"]}]}
Enter fullscreen mode Exit fullscreen mode

In entrambi i casi, alla fine una sessione lavora. Il resto del post spiega perché sono tenuti separati, quali limiti valgono per ciascuno, e mostra una ricostruzione in Python del lato client che verifica quei limiti contro gli schemi live.

Cosa distingue le due chiamate?

Chi dice di sì, e quando.

Un handoff chiede a una persona, adesso. L'owner riceve una notifica ("Codex CLI requests a handoff"). La scheda Handoffs della sessione lo elenca sotto "Outside assistant requests" (richieste da assistenti esterni) con l'obiettivo, un pannello espandibile "Context and expected output" e la riga "Up to N rounds on your agent runtime". Non viene accodato nulla finché qualcuno non clicca Accept and run. A quel punto il client web invia {decision: "accept"} a /agent-sessions/{id}/external-handoffs/{handoff_id}/decision e il toast dice "Handoff accepted and run queued". La riga dell'handoff passa da pending a accepted, declined o cancelled.

Un run invece è stato approvato prima. L'owner ha dato alla connessione un posto da Runner nella scheda Connected, e quello è il sì. Il testo di aiuto lì dice: "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." Nella pagina di accesso, lo scope run si legge "Run sessions where you grant a runner role".

Se le unissi in un unico tool con un flag needs_approval, sarebbe il chiamante a decidere se chiedere a un umano. Con due tool quella decisione resta dalla parte dell'owner, in due punti che il chiamante non può toccare: lo scope OAuth (osfoundry:write contro osfoundry:run) e il posto (Contributor contro Runner). Una config di Codex che non mette osfoundry:run in scopes non può avviare un run nemmeno con un posto da Runner.

Le annotations seguono la stessa divisione. propose_session_handoff è openWorldHint: true perché il suo output raggiunge una persona fuori dalla chiamata. request_session_run è openWorldHint: false, perché tocca solo la coda di una sessione che ha già dato il consenso. Entrambi sono destructiveHint: true, quindi un client con il default_tools_approval_mode = "writes" di Codex chiede conferma prima di ognuno dei due.

Perché l'handoff pretende un riassunto del contesto?

Perché una persona lo legge prima che parta qualsiasi cosa. context_summary e requested_output sono obbligatori nell'handoff e assenti nel run. L'owner sta decidendo se spendere il runtime della propria sessione su questa richiesta, e "fix the export" non basta per decidere. Un Runner ha già quella fiducia, quindi il run prende solo un goal.

Anche i limiti cambiano. Il goal di un handoff si ferma a 5,000 caratteri, più 5,000 di contesto e 1,000 di output atteso. Il goal di un run arriva a 10,000. In pratica è il goal del run a portarsi dietro il contesto, perché non c'è un altro campo per farlo.

Cosa può rileggere Codex?

Meno di quanto contiene la sessione:

  • read_session_task_result: "the status and attributed runtime result for one task started by this connection". L'id di un task di un'altra connessione non serve a niente.
  • read_session_mail: "notes addressed to this connection and notes it posted", da 1 a 20 per pagina, con before_id per quelle più vecchie. "Internal agent analysis is excluded."
  • read_session_state: stato attuale e task recenti di una sessione a cui la connessione si è unita.
  • post_session_note: una nota attribuita a cui gli agenti possono rispondere tramite "the returned external sender slug". "does not trigger an automatic run", quindi una nota non permette di aggirare il clic dell'owner.

In una sessione di team, queste note compaiono in Team mail con il nome visualizzato della connessione e l'etichetta "outside assistant".

La ricostruzione

Su questo Mac non è installato nessun binario codex, quindi è handoff_sim.py (circa 220 righe) a fare la parte del client. Fa tre cose:

  1. Chiama il server live senza token: initialize, tools/list e un tools/call, 15 volte ciascuno, e salva la lista dei tool.
  2. Valida dodici casi di argomenti contro gli inputSchema live con jsonschema (Draft 2020-12), così sui limiti non si tira a indovinare.
  3. Esegue un piccolo modello in memoria di posti, scope, decisioni dell'owner e idempotenza. Il codice server di osFoundry non è pubblico, quindi questa parte è ricostruita a partire dalle descrizioni dei tool e dal client web che mostra gli handoff. Le sue risposte sono del modello, non del server.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py
Enter fullscreen mode Exit fullscreen mode

La rete

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
Enter fullscreen mode Exit fullscreen mode

Millisecondi, misurati da questo Mac in Giappone attraverso l'edge KIX di CloudFront, quindi i numeri dicono più sul percorso che sul server. Quello che conta è la forma. Il discovery è gratuito e abbastanza veloce da farlo a ogni avvio di Codex. La prima chiamata vera costa un round trip con 401, e quel 401 contiene resource_metadata, che è il modo in cui Codex trova il server OAuth senza che glielo si dica.

I sette tool di sessione valgono 1,152 token sui 10,197 della lista completa (11.3%, cl100k_base, quindi da considerare un'approssimazione per il tokenizer di Codex). Mettere solo quelli in enabled_tools tiene piccolo il prompt.

I limiti

Tool Scope Obbligatori Limiti
propose_session_handoff write 6 goal 5,000, context 5,000, output 1,000, max_rounds 1–10
cancel_session_handoff write 1 solo handoff_id
request_session_run run 4 goal 10,000, max_rounds 1–10
read_session_state read 2 nessuno
read_session_task_result read 3 nessuno
read_session_mail read 2 limit 1–20
post_session_note write 5 subject 200, body 10,000

Dodici casi contro gli schemi 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
Enter fullscreen mode Exit fullscreen mode

Due meritano una seconda occhiata. Lo stesso goal di 6,000 caratteri va bene come run ed è troppo lungo come handoff. Un client che costruisce un unico prompt lungo e lo manda al tool che ha a disposizione ci finirà dentro. E additionalProperties: false respinge un campo model: il chiamante non può scegliere il modello della sessione. La sessione gira con le proprie impostazioni.

max_rounds è opzionale in entrambi. Gli schemi non pubblicano un default, quindi se il numero ti interessa, mandalo.

Posti, annullamento e accettazione (modello)

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'}
Enter fullscreen mode Exit fullscreen mode

La matrice è il testo di aiuto della scheda Connected trasformato in tabella. La regola interessante è la finestra di annullamento. Secondo la descrizione, cancel_session_handoff funziona "before its owner accepts it", quindi vince chi arriva prima. Una volta che il run è in coda, ritirarlo non spetta più al chiamante. È anche per questo che cancel prende solo un handoff_id: non c'è stato da passare, solo una riga in sospeso da chiudere.

Risposte perse

Ogni scrittura richiede un idempotency_key obbligatorio. Per vedere cosa ci si guadagna, il modello invia 1,000 chiamate request_session_run, perde il 20% delle risposte (seed 7) e lascia che il client ritenti fino a tre volte con la stessa chiave:

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
Enter fullscreen mode Exit fullscreen mode

276 run in più sul runtime di qualcun altro: è proprio il caso per cui esiste la chiave obbligatoria. Qualsiasi client che ritenta dopo un timeout ci finirà, e un run già partito non è una cosa che vuoi due volte. Rendere la chiave obbligatoria e non opzionale significa che nessun client può dimenticarla.

L'intero script ha impiegato 26.4 s di tempo reale su un Apple M1. Quasi tutto se ne va in 45 chiamate di rete, e il tempo CPU utente è stato di 0.37 s.

Se lo colleghi da solo

  • Separa "chiedere a una persona" e "agire con un permesso già dato" in due tool con due scope, così la scelta dell'owner sta nel token e nel posto, non in un argomento impostato dal chiamante.
  • Rendi obbligatori i campi che servono a una persona per decidere (context_summary, requested_output) solo nella proposta, e mettigli un limite più stretto del goal del run.
  • Lascia annullare al chiamante solo finché la richiesta è in sospeso. Dopo l'accettazione, fermare il lavoro è compito dell'owner.
  • Limita ogni lettura a ciò che questa connessione ha avviato o ricevuto, e lascia fuori l'analisi interna della sessione.
  • Rendi idempotency_key obbligatorio su ogni scrittura e restituisci il primo risultato in caso di ripetizione.
  • Chiudi lo schema con additionalProperties: false, così un chiamante non può infilare un modello o una lista di tool che la sessione non ha scelto.

Come gli agenti di osFoundry si passano il lavoro tra un passo e l'altro è spiegato nella pagina sugli handoff. Per il protocollo sottostante, vedi la voce MCP del glossario.

Top comments (0)