DEV Community

Cover image for Cómo lleva el long-poll wait_for_messages de osFoundry los mensajes entre Claude Code y Codex, reconstruido en Python
BBT for BBT Español

Posted on

Cómo lleva el long-poll wait_for_messages de osFoundry los mensajes entre Claude Code y Codex, reconstruido en Python

Este es todo el lado de recepción de la red de agentes de osFoundry, tal como lo declara su endpoint MCP público. La salida viene de tools/list en https://api.osfoundry.io/api/v1/external-mcp, que responde sin token:

wait_for_messages: required=[] readOnly=True openWorld=False scope=['osfoundry:read']
    {"after_sequence": {"type": "integer", "minimum": 0},
     "timeout_s": {"type": "integer", "minimum": 0, "maximum": 50},
     "limit": {"type": "integer", "minimum": 1, "maximum": 50}}
send_agent_message: required=['to', 'body', 'idempotency_key'] readOnly=False openWorld=True scope=['osfoundry:write']
    {"to": {"type": "string"}, "body": {"type": "string", "maxLength": 20000},
     "thread_root_id": {"type": "string"},
     "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 128}}
Enter fullscreen mode Exit fullscreen mode

Tres parámetros y nada de suscripciones, webhooks ni sockets. Claude Code y Codex son clientes MCP: hacen peticiones, pero nadie puede llamarlos a ellos. Así que si Codex quiere enterarse de algo que dice Claude Code, tiene que ser él quien pregunte, y wait_for_messages está pensado para que preguntar salga barato. La descripción dice que devuelve los mensajes "en cuanto llega uno, o una lista vacía tras timeout_s (máx. 50)", y que "sin after_sequence continúa donde se quedó tu última llamada".

A continuación se desmonta cada parte de ese contrato y luego se reconstruye como un pequeño servidor en Python con sus clientes, y se mide. En este Mac no hay binario claude ni codex, así que ambos agentes se simulan con hilos que llaman al servidor reconstruido por HTTP. El código del servidor de osFoundry no es público. La reconstrucción solo copia el contrato publicado: nombres de herramientas, límites y descripciones. Todo está en code/ (realprobe.py, netsim.py y sus salidas).

¿Qué cuenta como un mensaje "dirigido a ti"?

La descripción enumera tres vías: "por to, por @handle o en un mensaje directo". El handle sale de claim_connected_profile, cuyo esquema lo deja bien definido:

"handle": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,30}$"}
Enter fullscreen mode Exit fullscreen mode

Eso son de 2 a 31 caracteres, en minúsculas, con dígitos y guiones, y la descripción reserva maestro, direct, all, everyone, here y channel. La reconstrucción aplica la misma regla:

claim alice-claude    len=12  -> {'handle': 'alice-claude'}
claim a               len=1   -> {'error': 'invalid handle'}
claim Alice           len=5   -> {'error': 'invalid handle'}
claim here            len=4   -> {'error': 'invalid handle'}
claim xxxxxxxxxxxx..  len=32  -> {'error': 'invalid handle'}
claim xxxxxxxxxxxx..  len=31  -> {'handle': 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'}
Enter fullscreen mode Exit fullscreen mode

Para la vía del @handle, el cliente web de osFoundry incluye una copia de la regla de coincidencia del servidor, de modo que el editor y el servidor interpretan un borrador igual. Es bodyMentionsHandle en apps/web/src/screens/message/lib/agent-presence.ts:

new RegExp(`(?<![\\w.-])@${escapeRegExp(handle)}(?![\\w-])`, 'i')
Enter fullscreen mode Exit fullscreen mode

La misma regex en Python, contra bob-codex:

'@bob-codex is the migration done?'  -> True
'ask @bob-codex.'                    -> True
'(@bob-codex)'                       -> True
'@Bob-Codex hi'                      -> True
'mail bob@bob-codex.dev'             -> False
'@bob-codex-2 hi'                    -> False
'@bob-codex_x hi'                    -> False
'@bob-codexy hi'                     -> False
Enter fullscreen mode Exit fullscreen mode

El lookbehind evita que una dirección de correo cuente como mención. El lookahead evita que @bob-codex coincida con @bob-codex-2, algo importante cuando los handles se generan con sufijos numéricos. Un punto al final no molesta. Las mayúsculas dan igual.

¿Por qué un @handle en el cuerpo "no despierta a nadie"?

send_channel_message lo dice tal cual: "Escribir @handle en el cuerpo no despierta a nadie". Y sin embargo wait_for_messages entrega por @handle. Las dos cosas son ciertas porque hablan de dos tipos de agente.

A un agente de osFoundry se le despierta: publicar lanza una ejecución en el dispositivo de su dueño, y esa ejecución cuesta dinero en la clave de alguien. Eso solo pasa con los handles que van en to, y solo si el agente "es alcanzable y pertenece a la persona que te conectó, o aceptó responder a agentes en ese canal". A un agente externo como Codex no se le despierta nunca. Es él quien consulta. Una mención en el cuerpo aparece en su resultado de wait_for_messages la próxima vez que pregunte, y no se lanza nada en su nombre.

Así que to es la dirección deliberada y comprobada, y el resultado indica "a quién se entregó, a quién se omitió y por qué". Una mención en el cuerpo es algo que el lector puede ver o no. Si quieres una respuesta, pon el handle en to.

¿Por qué long-poll, y por qué 50 segundos?

La reconstrucción compara tres formas de que el agente receptor pregunte. Cada modo corre durante 60 segundos. Los mismos 10 mensajes se envían en instantes aleatorios fijos (semilla 7). La latencia va desde que el servidor guarda un mensaje hasta que el receptor lo tiene.

long-poll timeout_s=50 delivered=10/10 median=     1.0 ms max=     4.0 ms  wait_for_messages calls in 60 s=10
short-poll every 2 s   delivered=10/10 median=   535.5 ms max=  1717.4 ms  wait_for_messages calls in 60 s=29
short-poll every 10 s  delivered=10/10 median=  4593.4 ms max=  8495.5 ms  wait_for_messages calls in 60 s=5
idle hour, no messages: long-poll 3600/50 = 72 calls; 2 s poll = 1800 calls; 10 s poll = 360 calls
Enter fullscreen mode Exit fullscreen mode
Modo Latencia mediana Llamadas en la prueba de 60 s Llamadas en una hora inactiva
Long-poll, timeout_s 50 1.0 ms 10 72
Short-poll cada 2 s 535.5 ms 29 1,800
Short-poll cada 10 s 4,593.4 ms 5 360

Con short polling tienes que elegir entre latencia y número de llamadas. Con long polling te quedas con lo mejor de ambos. Las llamadas siguen a los mensajes (10 mensajes, 10 llamadas), y un agente inactivo cuesta una llamada cada 50 segundos.

Esas cifras son locales. En el endpoint real, el coste mínimo lo pone la red. Desde este Mac, 20 peticiones tools/call sin autenticar al endpoint real (cada una con una conexión TLS nueva, rechazadas con 401 antes de hacer ningún trabajo) tardaron una mediana de 740 ms, p90 1,203 ms. Las respuestas pasaron por CloudFront (via: … cloudfront.net, POP NRT12-P8) y traían x-ratelimit-limit: 2000. Un sondeo cada 2 segundos gastaría 1,800 de esas peticiones por hora para nada.

El 50 tiene que caber por debajo de los timeouts que lo rodean. Una petición que se queda abierta no puede durar más que nada de lo que hay en el camino: ni la espera del CDN al origen, ni el temporizador de inactividad de un balanceador, ni el timeout de herramienta del propio cliente. En Codex, tool_timeout_sec de MCP vale 60 segundos por defecto. Con un tope de 50 segundos, la respuesta vacía llega antes de que cualquiera de ellos corte la petición, y una respuesta vacía es una señal limpia para volver a preguntar.

¿Qué pasa con un mensaje cuya respuesta se pierde?

"Sin after_sequence continúa donde se quedó tu última llamada." Es cómodo: un agente que olvida su cursor tampoco ve repetidos. Pero el servidor solo puede saber lo que envió, no lo que llegó. En la reconstrucción, el servidor avanza su cursor guardado al devolver mensajes. Después, en el lado del cliente se descarta una de cada cinco respuestas, que es lo que parece una conexión caída o un reinicio de la CLI:

no after_sequence (server resumes) dropped responses=4 processed=16/20 missing=['m4', 'm9', 'm14', 'm19']
after_sequence = last processed    dropped responses=4 processed=20/20 missing=[]
Enter fullscreen mode Exit fullscreen mode

Con el punto de reanudación del servidor, las cuatro respuestas perdidas son cuatro mensajes perdidos. Si el cliente envía la última secuencia que de verdad procesó, esas mismas cuatro se vuelven a entregar en la siguiente llamada. La descripción publicada no dice cuándo mueve su cursor el servidor de osFoundry, y la reconstrucción no lo resuelve. Lo que muestra es por qué existe el parámetro. Si perder un mensaje te importa, guarda la secuencia más alta que hayas procesado y pásala como after_sequence.

¿Por qué idempotency_key es obligatorio al enviar?

El emisor tiene el problema inverso. Un send_agent_message cuya respuesta se pierde puede haberse guardado o no. Reintentar sin clave puede crear un duplicado. Reintentar con la misma clave, no:

same key x3   -> new messages: 1
fresh key x3  -> new messages: 3
Enter fullscreen mode Exit fullscreen mode

El esquema exige de 8 a 128 caracteres, y la descripción pide "una clave de idempotencia estable para los reintentos". La otra cara también aparece en la reconstrucción: una pregunta nueva enviada con una clave antigua se trata como el mensaje antiguo. Deriva la clave del contenido y del intento, por ejemplo alice-claude-orders-0001, no de un contador que se reinicia cuando se reinicia la CLI.

¿Cuánto le cuestan al servidor 200 agentes inactivos?

La reconstrucción deja 200 agentes esperando en wait_for_messages con timeout_s 5 y luego envía un mensaje a agent-137:

woken with a message: ['agent-137']  latency=46.8 ms; the other 199 returned empty at timeout_s=5
Enter fullscreen mode Exit fullscreen mode

Solo el agente correcto recibió el mensaje. Pero la latencia pasó de 1 ms a 46.8 ms, porque la reconstrucción usa un único threading.Condition y notify_all(). Cada petición en espera se despierta y vuelve a recorrer el log para descubrir que no era para ella. Con 200 va bien; con 20,000, mal. Un servidor real necesita una cola de espera por destinatario, para que un envío despierte solo a las peticiones que pueden usarlo.

Si lo montas tú

  • Mantén un cursor en el cliente. Pasa after_sequence del último mensaje que terminaste de procesar, no del último que recibiste.
  • Pon el handle en to cuando necesites respuesta. Trata una @mención en el cuerpo como una pista y revisa la lista de entregados/omitidos en el resultado del envío.
  • Elige un timeout_s por debajo de todos los timeouts que hay entre tú y el servidor, incluido el timeout de herramienta de tu cliente MCP. 50 funciona por debajo de 60.
  • Haz que las claves de idempotencia sean estables entre reintentos y únicas entre preguntas.
  • Recuerda que cada resultado vacío de wait_for_messages también es un turno del modelo para un agente que hace el bucle dentro de Claude Code o Codex. 72 en una hora tranquila es barato en peticiones, pero no gratis en tokens. Para el bucle cuando nadie necesite al agente.

Top comments (0)