DEV Community

Cover image for ¿Por qué osFoundry le da a Codex una herramienta para proponer un handoff y otra para lanzar un run?
BBT for BBT Español

Posted on

¿Por qué osFoundry le da a Codex una herramienta para proponer un handoff y otra para lanzar un run?

Esto es lo que recibe Codex CLI la primera vez que llama a una herramienta de sesión en osFoundry sin haber iniciado sesión:

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 y tools/list responden sin token, así que un cliente puede ver las 59 herramientas antes de que nadie inicie sesión. Dos de ellas parecen hacer lo mismo:

{"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

Las dos terminan con una sesión haciendo trabajo. El resto del post trata de por qué están separadas, qué límites tiene cada una y de una reconstrucción en Python del lado del cliente que comprueba esos límites contra los esquemas en vivo.

¿Qué diferencia a las dos llamadas?

Quién dice que sí, y cuándo.

Un handoff le pregunta a una persona, en ese momento. El owner recibe una notificación ("Codex CLI requests a handoff"). La pestaña Handoffs de la sesión lo muestra en "Outside assistant requests" (peticiones de asistentes externos) con el objetivo, un desplegable "Context and expected output" y la línea "Up to N rounds on your agent runtime". No se encola nada hasta que alguien pulsa Accept and run. Entonces el cliente web envía {decision: "accept"} a /agent-sessions/{id}/external-handoffs/{handoff_id}/decision y el toast dice "Handoff accepted and run queued". La fila del handoff pasa de pending a accepted, declined o cancelled.

Un run ya se aprobó antes. El owner le dio a la conexión un puesto de Runner en la pestaña Connected, y ese es el sí. El texto de ayuda de esa pestaña: "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." En la página de inicio de sesión, el scope de run dice "Run sessions where you grant a runner role".

Si juntaras las dos en una sola herramienta con un flag needs_approval, sería quien llama el que decidiría si se le pregunta a una persona. Con dos herramientas, esa decisión queda del lado del owner, en dos sitios que quien llama no puede tocar: el scope de OAuth (osfoundry:write frente a osfoundry:run) y el puesto (Contributor frente a Runner). Una configuración de Codex que no incluya osfoundry:run en scopes no puede lanzar un run ni siquiera con un puesto de Runner.

Las annotations siguen la misma división. propose_session_handoff es openWorldHint: true porque su resultado llega a una persona fuera de la llamada. request_session_run es openWorldHint: false, porque solo toca la cola de una sesión que ya dio su permiso. Las dos son destructiveHint: true, así que un cliente con el default_tools_approval_mode = "writes" de Codex pregunta antes de cualquiera de ellas.

¿Por qué el handoff exige un resumen de contexto?

Porque una persona lo lee antes de que se ejecute nada. context_summary y requested_output son obligatorios en el handoff y no existen en el run. El owner está decidiendo si gasta el runtime de su sesión en esto, y con "fix the export" no basta para decidir. Un Runner ya tiene esa confianza, así que el run solo pide un goal.

Los límites también cambian. El goal de un handoff llega hasta 5,000 caracteres, más 5,000 de contexto y 1,000 de salida esperada. El goal de un run llega a 10,000. En la práctica, el goal del run lleva el contexto, porque no hay otro campo para eso.

¿Qué puede leer Codex de vuelta?

Menos de lo que guarda la sesión:

  • read_session_task_result: "the status and attributed runtime result for one task started by this connection". El id de tarea de otra conexión no sirve de nada.
  • read_session_mail: "notes addressed to this connection and notes it posted", de 1 a 20 por página, con before_id para las más antiguas. "Internal agent analysis is excluded."
  • read_session_state: el estado actual y las tareas recientes de una sesión a la que se unió la conexión.
  • post_session_note: una nota atribuida a la que los agentes pueden responder a través de "the returned external sender slug". "does not trigger an automatic run", así que una nota no sirve para saltarse el clic del owner.

En una sesión de equipo, esas notas aparecen en Team mail con el nombre visible de la conexión y la etiqueta "outside assistant".

La reconstrucción

En este Mac no hay ningún binario codex instalado, así que handoff_sim.py (unas 220 líneas) hace de cliente. Hace tres cosas:

  1. Llama al servidor en vivo sin token: initialize, tools/list y un tools/call, 15 veces cada uno, y guarda la lista de herramientas.
  2. Valida doce casos de argumentos contra los inputSchema en vivo con jsonschema (Draft 2020-12), para no adivinar nada sobre los límites.
  3. Ejecuta un pequeño modelo en memoria de puestos, scopes, decisiones del owner e idempotencia. El código del servidor de osFoundry no es público, así que esta parte está reconstruida a partir de las descripciones de las herramientas y del cliente web que muestra los handoffs. Sus respuestas son del modelo, no del servidor.
$ uv run --with tiktoken --with jsonschema python3 handoff_sim.py
Enter fullscreen mode Exit fullscreen mode

La red

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

Milisegundos, medidos desde este Mac en Japón a través del edge KIX de CloudFront, así que los números dicen más de la ruta que del servidor. Lo que importa es la forma. El discovery es gratis y lo bastante rápido como para hacerlo en cada arranque de Codex. La primera llamada real cuesta un round trip con 401, y ese 401 trae resource_metadata, que es como Codex encuentra el servidor OAuth sin que nadie se lo diga.

Las siete herramientas de sesión son 1,152 tokens de los 10,197 de la lista completa (11.3%, cl100k_base, así que tómalo como aproximado para el tokenizer propio de Codex). Poner solo esas en enabled_tools mantiene el prompt pequeño.

Los límites

Herramienta Scope Obligatorios Límites
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 ninguno
read_session_task_result read 3 ninguno
read_session_mail read 2 limit 1–20
post_session_note write 5 subject 200, body 10,000

Doce casos contra los esquemas en vivo:

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

Dos merecen una segunda mirada. El mismo goal de 6,000 caracteres vale como run y es demasiado largo como handoff. Un cliente que arma un prompt largo y lo manda a la herramienta que tenga a mano se va a topar con esto. Y additionalProperties: false rechaza un campo model: quien llama no puede elegir el modelo de la sesión. La sesión se ejecuta con su propia configuración.

max_rounds es opcional en las dos. Los esquemas no publican un valor por defecto, así que si el número te importa, envíalo.

Puestos, cancelar y aceptar (modelo)

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 matriz es el texto de ayuda de la pestaña Connected convertido en tabla. La regla interesante es la ventana de cancelación. Según su descripción, cancel_session_handoff funciona "before its owner accepts it", así que gana lo que pase primero. Una vez encolado el run, retirarlo ya no le corresponde a quien llama. Por eso cancel solo recibe un handoff_id: no hay estado que pasar, solo una fila pendiente que cerrar.

Respuestas perdidas

Toda escritura lleva un idempotency_key obligatorio. Para ver qué aporta, el modelo envía 1,000 llamadas a request_session_run, descarta el 20% de las respuestas (seed 7) y deja que el cliente reintente hasta tres veces con la misma 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
Enter fullscreen mode Exit fullscreen mode

276 runs de más en el runtime de otra persona: ese es el caso para el que existe la key obligatoria. Cualquier cliente que reintenta tras un timeout se lo va a encontrar, y un run que ya arrancó no es algo que quieras dos veces. Al ser obligatoria y no opcional, ningún cliente puede olvidarla.

El script completo tardó 26.4 s de tiempo real en un Apple M1. Casi todo se va en 45 llamadas de red; el tiempo de CPU de usuario fue de 0.37 s.

Si lo montas tú

  • Separa "preguntar a una persona" y "actuar con un permiso ya dado" en dos herramientas con dos scopes, para que la decisión del owner viva en el token y en el puesto, no en un argumento que fija quien llama.
  • Haz obligatorios los campos que una persona necesita para decidir (context_summary, requested_output) solo en la propuesta, y ponles un límite más ajustado que al goal del run.
  • Deja que quien llama cancele solo mientras la petición está pendiente. Una vez aceptada, parar el trabajo es cosa del owner.
  • Limita toda lectura a lo que esta conexión inició o recibió, y deja fuera el análisis interno de la sesión.
  • Haz idempotency_key obligatorio en toda escritura y devuelve el primer resultado cuando se repita.
  • Cierra el esquema con additionalProperties: false, para que nadie cuele un modelo o una lista de herramientas que la sesión no eligió.

Cómo se pasan trabajo entre pasos los propios agentes de osFoundry está en la página de handoffs. Para el protocolo que hay debajo, mira la entrada del glosario sobre MCP.

Top comments (0)