DEV Community

Cover image for Wie der wait_for_messages-Long-Poll von osFoundry Nachrichten zwischen Claude Code und Codex transportiert, in Python nachgebaut
BBT for BBT Deutsch

Posted on

Wie der wait_for_messages-Long-Poll von osFoundry Nachrichten zwischen Claude Code und Codex transportiert, in Python nachgebaut

Das hier ist die komplette Empfangsseite des Agent-Netzwerks von osFoundry, so wie der öffentliche MCP-Endpoint sie deklariert. Die Ausgabe stammt von tools/list auf https://api.osfoundry.io/api/v1/external-mcp, das ohne Token antwortet:

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

Drei Parameter, keine Subscription, kein Webhook, kein Socket. Claude Code und Codex sind MCP-Clients. Sie schicken Requests, aber niemand kann sie von außen aufrufen. Wenn Codex also etwas von Claude Code hören will, muss Codex selbst fragen, und wait_for_messages ist darauf ausgelegt, dass dieses Fragen billig ist. Laut Beschreibung liefert das Tool Nachrichten zurück, „sobald eine eintrifft, oder eine leere Liste nach timeout_s (max. 50)“, und „ohne after_sequence macht es dort weiter, wo dein letzter Aufruf aufgehört hat“.

Im Folgenden wird jeder Teil dieses Vertrags auseinandergenommen, dann als kleiner Python-Server mit Clients nachgebaut und gemessen. Auf diesem Mac gibt es kein claude- oder codex-Binary, deshalb werden beide Agents durch Threads simuliert, die den nachgebauten Server per HTTP aufrufen. Der Servercode von osFoundry ist nicht öffentlich. Der Nachbau übernimmt nur den veröffentlichten Vertrag: Tool-Namen, Limits und Beschreibungen. Alles liegt in code/ (realprobe.py, netsim.py und deren Ausgaben).

Was ist eine Nachricht, die „an dich adressiert“ ist?

Die Beschreibung nennt drei Wege: „per to, per @handle oder in einer Direktnachricht“. Das Handle kommt von claim_connected_profile, und dessen Schema legt es genau fest:

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

Das sind 2 bis 31 Zeichen, Kleinbuchstaben, Ziffern und Bindestriche, und die Beschreibung reserviert maestro, direct, all, everyone, here und channel. Der Nachbau setzt dieselbe Regel durch:

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

Für den @handle-Weg liefert der Web-Client von osFoundry eine Kopie der Matching-Regel des Servers mit, damit Composer und Server einen Entwurf gleich lesen. Es ist bodyMentionsHandle in apps/web/src/screens/message/lib/agent-presence.ts:

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

Dieselbe Regex in Python, gegen 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

Der Lookbehind sorgt dafür, dass eine E-Mail-Adresse nicht als Erwähnung zählt. Der Lookahead verhindert, dass @bob-codex auf @bob-codex-2 matcht, was wichtig wird, sobald Handles mit Zahlensuffixen vergeben werden. Ein Punkt am Ende stört nicht. Groß- und Kleinschreibung spielt keine Rolle.

Warum „weckt“ ein @handle im Body „niemanden“?

send_channel_message sagt es ganz direkt: „Ein @handle im Body weckt niemanden.“ Trotzdem stellt wait_for_messages per @handle zu. Beides stimmt, weil es um zwei Arten von Agents geht.

Ein osFoundry-Agent wird geweckt: Ein Post startet einen Run auf dem Gerät seines Besitzers, und dieser Run kostet Geld auf irgendjemandes Key. Das passiert nur für Handles in to, und nur wenn der Agent „erreichbar ist und der Person gehört, die dich verbunden hat, oder sich bereit erklärt hat, Agents in diesem Channel zu antworten“. Ein externer Agent wie Codex wird nie geweckt. Er holt sich die Nachrichten selbst. Eine Erwähnung im Body landet beim nächsten Fragen in seinem wait_for_messages-Ergebnis, und in seinem Namen wird nichts gestartet.

to ist also die bewusste, geprüfte Adresse, und das Ergebnis sagt, „an wen zugestellt wurde, wer übersprungen wurde und warum“. Eine Erwähnung im Body ist etwas, das der Leser bemerken kann. Wenn du eine Antwort willst, gehört das Handle in to.

Warum Long-Polling, und warum 50 Sekunden?

Der Nachbau vergleicht drei Arten, wie der empfangende Agent fragen kann. Jeder Modus läuft 60 Sekunden. Dieselben 10 Nachrichten werden zu festen Zufallszeitpunkten gesendet (Seed 7). Die Latenz läuft vom Speichern der Nachricht auf dem Server bis dahin, dass der Empfänger sie hat.

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
Modus Median-Latenz Aufrufe im 60-s-Lauf Aufrufe in einer Leerlaufstunde
Long-Poll, timeout_s 50 1.0 ms 10 72
Short-Poll alle 2 s 535.5 ms 29 1,800
Short-Poll alle 10 s 4,593.4 ms 5 360

Beim Short-Polling tauschst du Latenz gegen Aufrufe. Long-Polling holt das Beste aus beidem raus. Die Aufrufe folgen den Nachrichten (10 Nachrichten, 10 Aufrufe), und ein Agent im Leerlauf kostet einen Aufruf alle 50 Sekunden.

Diese Zahlen sind lokal. Beim echten Endpoint ist das Netzwerk die Untergrenze. Von diesem Mac aus brauchten 20 nicht authentifizierte tools/call-Requests an den echten Endpoint (jeder mit frischer TLS-Verbindung, abgelehnt mit 401, bevor irgendwas passiert) im Median 740 ms, p90 1,203 ms. Die Antworten liefen über CloudFront (via: … cloudfront.net, POP NRT12-P8) und enthielten x-ratelimit-limit: 2000. Ein 2-Sekunden-Poll würde 1,800 dieser Requests pro Stunde für nichts verbrauchen.

Die 50 müssen unter die Timeouts drumherum passen. Ein offener Request darf nichts auf dem Weg überdauern: nicht das Warten des CDN auf den Origin, nicht den Idle-Timer eines Load Balancers, nicht das eigene Tool-Timeout des Clients. Bei Codex steht tool_timeout_sec für MCP standardmäßig auf 60 Sekunden. Mit einer Obergrenze von 50 Sekunden kommt die leere Antwort an, bevor einer davon den Request abschneidet, und eine leere Antwort ist ein sauberes Signal, erneut zu fragen.

Was passiert mit einer Nachricht, deren Antwort verloren geht?

„Ohne after_sequence macht es dort weiter, wo dein letzter Aufruf aufgehört hat.“ Das ist bequem: Ein Agent, der seinen Cursor vergisst, sieht trotzdem keine Wiederholungen. Aber der Server weiß nur, was er gesendet hat, nicht was angekommen ist. Im Nachbau setzt der Server seinen gespeicherten Cursor weiter, sobald er Nachrichten zurückgibt. Dann wird auf Client-Seite jede fünfte Antwort verworfen, und genau so sieht eine abgebrochene Verbindung oder ein CLI-Neustart aus:

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

Mit dem Fortsetzungspunkt des Servers sind die vier verlorenen Antworten vier verlorene Nachrichten. Schickt der Client die letzte Sequenz, die er tatsächlich verarbeitet hat, werden dieselben vier beim nächsten Aufruf erneut zugestellt. Die veröffentlichte Beschreibung sagt nicht, wann der Server von osFoundry seinen Cursor weitersetzt, und der Nachbau beantwortet das auch nicht. Er zeigt, warum es den Parameter gibt. Wenn dir eine verlorene Nachricht wehtut, merk dir die höchste verarbeitete Sequenz und übergib sie als after_sequence.

Warum ist idempotency_key beim Senden Pflicht?

Der Sender hat das spiegelbildliche Problem. Ein send_agent_message, dessen Antwort verloren geht, wurde vielleicht gespeichert, vielleicht nicht. Ein Retry ohne Key kann ein Duplikat erzeugen. Ein Retry mit demselben Key nicht:

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

Das Schema verlangt 8 bis 128 Zeichen, und die Beschreibung bittet um „einen stabilen Idempotency-Key für Retries“. Die Kehrseite zeigt sich im Nachbau ebenfalls: Eine neue Frage unter einem alten Key wird als die alte Nachricht behandelt. Leite den Key aus Inhalt und Versuch ab, etwa alice-claude-orders-0001, und nicht aus einem Zähler, der beim Neustart der CLI wieder bei null anfängt.

Was kosten 200 Agents im Leerlauf den Server?

Der Nachbau parkt 200 Agents in wait_for_messages mit timeout_s 5 und schickt dann eine Nachricht an 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

Nur der richtige Agent hat die Nachricht bekommen. Aber die Latenz stieg von 1 ms auf 46.8 ms, weil der Nachbau eine einzige threading.Condition mit notify_all() verwendet. Jeder geparkte Request wacht auf und scannt das Log erneut, nur um festzustellen, dass er nicht gemeint war. Bei 200 ist das okay, bei 20,000 nicht mehr. Ein echter Server braucht eine Warteschlange pro Empfänger, damit ein Send nur die Requests weckt, die damit etwas anfangen können.

Wenn du das selbst verdrahtest

  • Halte einen Cursor auf Client-Seite. Übergib after_sequence von der letzten Nachricht, die du fertig verarbeitet hast, nicht von der letzten, die du empfangen hast.
  • Setz das Handle in to, wenn du eine Antwort brauchst. Behandle eine @erwähnung im Body als Hinweis und lies die Liste der zugestellten/übersprungenen Empfänger im Send-Ergebnis.
  • Wähle ein timeout_s unter jedem Timeout zwischen dir und dem Server, inklusive des Tool-Timeouts deines MCP-Clients. 50 passt unter 60.
  • Mach Idempotency-Keys stabil über Retries hinweg und eindeutig pro Frage.
  • Denk daran, dass jedes leere wait_for_messages-Ergebnis für einen Agent, der in Claude Code oder Codex in einer Schleife läuft, auch ein Modell-Turn ist. 72 in einer ruhigen Stunde sind billig, was Requests angeht, aber nicht gratis, was Tokens angeht. Beende die Schleife, wenn niemand den Agent braucht.

Top comments (0)