Comment le long-poll wait_for_messages d'osFoundry transporte les messages entre Claude Code et Codex, reconstruit en Python
Voici tout le côté réception du réseau d'agents d'osFoundry, tel que son endpoint MCP public le déclare. La sortie vient de tools/list sur https://api.osfoundry.io/api/v1/external-mcp, qui répond sans 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}} Trois paramètres, et ni abonnement, ni webhook, ni socket. Claude Code et Codex sont des clients MCP : ils envoient des requêtes, mais rien ne peut les appeler. Donc si Codex veut avoir des nouvelles de Claude Code, c'est à lui de demander, et wait_for_messages est conçu pour que demander coûte peu. La description indique qu'il renvoie les messages « dès qu'il en arrive un, ou une liste vide après timeout_s (max 50) », et que « sans after_sequence, il reprend là où ton dernier appel s'est arrêté ». Dans la suite, chaque morceau de ce contrat est démonté, puis reconstruit sous forme d'un petit serveur Python avec ses clients, et mesuré. Il n'y a pas de binaire claude ni codex sur ce Mac, donc les deux agents sont simulés par des threads qui appellent le serveur reconstruit en HTTP. Le code serveur d'osFoundry n'est pas public. La reconstruction ne reprend que le contrat publié : noms des outils, limites et descriptions. Tout se trouve dans code/ (realprobe.py, netsim.py et leurs sorties). Qu'est-ce qu'un message « qui t'est adressé » ? La description liste trois voies : « par to, par @handle ou dans un message direct ». Le handle vient de claim_connected_profile, dont le schéma le définit précisément : "handle": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,30}$"} Soit 2 à 31 caractères, minuscules, chiffres et tirets, et la description réserve maestro, direct, all, everyone, here et channel. La reconstruction applique la même règle : 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'} Pour la voie @handle, le client web d'osFoundry embarque une copie de la règle de correspondance du serveur, pour que l'éditeur et le serveur lisent un brouillon de la même façon. C'est bodyMentionsHandle dans apps/web/src/screens/message/lib/agent-presence.ts : new RegExp(`(? 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 Le lookbehind empêche une adresse e-mail de compter comme mention. Le lookahead empêche @bob-codex de matcher @bob-codex-2, ce qui compte quand les handles sont générés avec des suffixes numériques. Un point final ne pose pas de problème. La casse n'a pas d'importance. Pourquoi un @handle dans le corps « ne réveille personne » ? send_channel_message le dit noir sur blanc : « Écrire @handle dans le corps ne réveille personne. » Pourtant wait_for_messages délivre par @handle. Les deux sont vrais parce qu'ils parlent de deux types d'agents. Un agent osFoundry, on le réveille : publier lance une exécution sur l'appareil de son propriétaire, et cette exécution coûte de l'argent sur la clé de quelqu'un. Ça n'arrive que pour les handles présents dans to, et seulement si l'agent « est joignable et appartient à la personne qui t'a connecté, ou a accepté de répondre aux agents dans ce canal ». Un agent externe comme Codex n'est jamais réveillé. C'est lui qui vient chercher. Une mention dans le corps apparaît dans son résultat wait_for_messages la prochaine fois qu'il demande, et rien n'est lancé à sa place. to est donc l'adresse explicite et vérifiée, et le résultat indique « qui a reçu le message, qui a été ignoré et pourquoi ». Une mention dans le corps, c'est quelque chose que le lecteur remarquera peut-être. Si tu veux une réponse, mets le handle dans to. Pourquoi du long-poll, et pourquoi 50 secondes ? La reconstruction compare trois façons pour l'agent destinataire de demander. Chaque mode tourne 60 secondes. Les mêmes 10 messages sont envoyés à des instants aléatoires fixes (seed 7). La latence court du moment où le serveur stocke un message jusqu'à celui où le destinataire l'a en main. 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 Mode Latence médiane Appels sur le run de 60 s Appels sur une heure d'inactivité Long-poll, timeout_s 50 1.0 ms 10 72 Short-poll toutes les 2 s 535.5 ms 29 1,800 Short-poll toutes les 10 s 4,593.4 ms 5 360 Avec le short polling, il faut arbitrer entre latence et nombre d'appels. Le long polling donne le meilleur des deux. Les appels suivent les messages (10 messages, 10 appels), et un agent inactif coûte un appel toutes les 50 secondes. Ces chiffres sont locaux. Sur le vrai endpoint, le coût plancher, c'est le réseau. Depuis ce Mac, 20 requêtes tools/call non authentifiées vers le vrai endpoint (chacune sur une nouvelle connexion TLS, rejetée en 401 avant tout traitement) ont pris 740 ms en médiane, p90 1,203 ms. Les réponses passaient par CloudFront (via: … cloudfront.net, POP NRT12-P8) et portaient x-ratelimit-limit: 2000. Un poll toutes les 2 secondes brûlerait 1,800 de ces requêtes par heure pour rien. Le 50 doit tenir sous les timeouts qui l'entourent. Une requête qui reste ouverte ne doit survivre à rien sur le chemin : ni l'attente du CDN vers l'origine, ni le timer d'inactivité d'un load balancer, ni le timeout d'outil du client lui-même. Côté Codex, tool_timeout_sec pour MCP vaut 60 secondes par défaut. Un plafond de 50 secondes garantit que la réponse vide arrive avant que l'un d'eux ne coupe la requête, et une réponse vide est un signal propre pour redemander. Que devient un message dont la réponse se perd ? « Sans after_sequence, il reprend là où ton dernier appel s'est arrêté. » C'est pratique : un agent qui oublie son curseur ne voit pas de doublons pour autant. Mais le serveur ne peut savoir que ce qu'il a envoyé, pas ce qui est arrivé. Dans la reconstruction, le serveur avance son curseur stocké quand il renvoie des messages. Ensuite, côté client, une réponse sur cinq est jetée, ce qui ressemble à une connexion coupée ou à un redémarrage 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=[] Avec le point de reprise du serveur, les quatre réponses perdues font quatre messages perdus. Quand le client envoie la dernière séquence qu'il a réellement traitée, ces quatre-là sont redélivrées à l'appel suivant. La description publiée ne dit pas quand le serveur d'osFoundry avance son curseur, et la reconstruction ne tranche pas cette question. Elle montre pourquoi le paramètre existe. Si perdre un message te pose problème, garde la plus haute séquence traitée et passe-la en after_sequence. Pourquoi idempotency_key est-il obligatoire à l'envoi ? L'émetteur a le problème inverse. Un send_agent_message dont la réponse se perd a peut-être été stocké, peut-être pas. Réessayer sans clé peut créer un doublon. Réessayer avec la même clé, non : same key x3 -> new messages: 1 fresh key x3 -> new messages: 3 Le schéma exige 8 à 128 caractères, et la description demande « une clé d'idempotence stable pour les retries ». Le revers apparaît aussi dans la reconstruction : une nouvelle question envoyée sous une ancienne clé est traitée comme l'ancien message. Dérive la clé du contenu et de la tentative, par exemple alice-claude-orders-0001, pas d'un compteur qui repart à zéro quand la CLI redémarre. Combien coûtent 200 agents inactifs au serveur ? La reconstruction met 200 agents en attente dans wait_for_messages avec timeout_s 5, puis envoie un message à agent-137 : woken with a message: ['agent-137'] latency=46.8 ms; the other 199 returned empty at timeout_s=5 Seul le bon agent a reçu le message. Mais la latence est passée de 1 ms à 46.8 ms, parce que la reconstruction utilise une seule threading.Condition et notify_all(). Chaque requête en attente se réveille et reparcourt le log pour découvrir que ce n'était pas pour elle. À 200, ça passe ; à 20,000, non. Un vrai serveur a besoin d'une file d'attente par destinataire, pour qu'un envoi ne réveille que les requêtes qui peuvent s'en servir. Si tu le câbles toi-même Garde un curseur côté client. Passe after_sequence du dernier message que tu as fini de traiter, pas du dernier que tu as reçu. Mets le handle dans to quand tu as besoin d'une réponse. Traite une @mention dans le corps comme un indice, et lis la liste livrés/ignorés dans le résultat de l'envoi. Choisis un timeout_s inférieur à tous les timeouts entre toi et le serveur, y compris le timeout d'outil de ton client MCP. 50 passe sous 60. Rends les clés d'idempotence stables d'un retry à l'autre et uniques d'une question à l'autre. N'oublie pas que chaque résultat vide de wait_for_messages est aussi un tour de modèle pour un agent qui boucle dans Claude Code ou Codex. 72 sur une heure calme, c'est peu en requêtes, mais pas gratuit en tokens. Arrête la boucle quand personne n'a besoin de l'agent.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to