Das ist das einzige Tool im MCP-Server von osFoundry, das Arbeit startet, ohne dass ein Mensch irgendetwas anklickt. So liefert tools/list es an jeden Client aus, angemeldet oder nicht:
{"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.",
"inputSchema": {"properties": {
"session_type": {"enum": ["agent_session", "team_session", "loop_session"]},
"session_id": {"type": "string"},
"goal": {"type": "string", "maxLength": 10000},
"max_rounds": {"type": "integer", "minimum": 1, "maximum": 10},
"idempotency_key": {"type": "string"}},
"required": ["session_type", "session_id", "goal", "idempotency_key"],
"additionalProperties": false},
"annotations": {"readOnlyHint": false, "openWorldHint": false, "destructiveHint": true},
"securitySchemes": [{"type": "oauth2", "scopes": ["osfoundry:run"]}]}
Zwei Formulierungen in dieser Beschreibung bestimmen alles, was ein Client damit macht. "Invited runner" ist eine Berechtigungsprüfung, die an drei Stellen stattfindet. "When available" heißt, dass der Aufruf zurückkommt, bevor die Arbeit beginnt, der Client also später nach dem Ergebnis fragen muss. Dieser Post geht beide durch, dazu das Postfach, über das Codex danach mit der Session spricht, mit einem Python-Nachbau der Client-Seite, der auf diesem Mac lief.
Was erfährt ein Client vor der Anmeldung?
Ziemlich viel. initialize, tools/list und die Protected-Resource-Metadaten antworten alle ohne Token:
$ uv run --with tiktoken --with jsonschema python3 runner_sim.py
== Part 1: live wire, 10 calls each, ms ==
initialize HTTP 200 median 375.1 p90 726.0
tools/list HTTP 200 median 551.1 p90 755.5
protected-resource HTTP 200 median 325.4 p90 578.8
tools/call (no token) HTTP 401 median 454.2 p90 492.8
scopes_supported: ['osfoundry:read', 'osfoundry:write', 'osfoundry:run', 'osfoundry:models']
Gemessen aus Japan auf einem Apple M1, die Millisekunden sagen also genauso viel über die Route wie über den Server. osfoundry:run ist als eigener Scope veröffentlicht, getrennt von osfoundry:write. Eine Codex-Konfiguration, die ihn in scopes weglässt, bekommt nie einen Token, mit dem sie einen Run einreihen kann, egal welcher Platz ihr später zugewiesen wird.
Die Runner-Schleife nutzt fünf der 59 Tools des Servers: read_session_state, request_session_run, read_session_task_result, post_session_note und read_session_mail. Sie machen 821 der 10,197 Tokens der Liste aus (8.1%, gezählt mit cl100k_base, für den eigenen Tokenizer von Codex also nur eine Näherung). Wer nur diese fünf in enabled_tools einträgt, hält die anderen 54 aus dem Prompt heraus.
Wer entscheidet, dass Codex ein Runner ist?
Drei getrennte Stellen, und ein Aufruf braucht alle drei:
- Der Token.
request_session_rundeklariertosfoundry:run. Die Anmeldeseite zeigt diesen Scope als "Run sessions where you grant a runner role". - Der Grant. Im Web-Client von osFoundry (
ExternalAssistantsPanel.tsx) trägt ein Session-Grant Aktionen mit den Labels View, Reply, Edit und "Start work". Das Rollen-Dropdown eines Grants bietet Contributor nur an, wenn der Grantwriteenthält, und Runner nur, wenn errunenthält. - Der Platz. Der Owner speichert pro Session eine Rolle, die der Client als
POST /external-assistants/{connection}/sessions/{session_type}/{session_id}mit{"role": "observer" | "contributor" | "runner"}sendet. Das Dropdown zeigt sie als Viewer, Contributor und Runner.
Das Skript baut diese Prüfung nach. Die Zuordnung von Rolle zu Tool stammt aus dem Hilfetext des Panels ("Contributor to post notes and propose handoffs, or Runner to also request runs"), der Scope pro Tool aus den Live-Schemas. Der Servercode von osFoundry ist nicht öffentlich, das hier ist also eine Rekonstruktion der Regel, kein Mitschnitt echter Ablehnungen des Servers:
grant actions ['read'] -> roles offered ['observer']
grant actions ['read', 'write'] -> roles offered ['observer', 'contributor']
grant actions ['read', 'write', 'run'] -> roles offered ['observer', 'contributor', 'runner']
tool observer contributor runner
read_session_state yes yes yes
request_session_run - - yes
read_session_task_result yes yes yes
post_session_note - yes yes
read_session_mail yes yes yes
request_session_run seat=contributor scopes=read+run+write -> seat 'contributor' cannot call it
request_session_run seat=runner scopes=read+write -> token lacks osfoundry:run
Für alle, die den Client schreiben, ist die Trennung wichtig. Eine Ablehnung bei request_session_run hat zwei verschiedene Lösungen: sich mit dem Scope neu anmelden (deine Seite) oder den Owner um den Platz bitten (seine Seite). Melde, welcher Fall es ist, statt es erneut zu versuchen.
Was passiert zwischen Aufruf und Ergebnis?
Der Aufruf reiht einen Task ein und kehrt zurück. Die Typen im Web-Client zeigen, was dieser Task ist: ein AgentTask mit task_type: "external_assistant", ein Typ, den die Task-Erstellungsroute der API ablehnt, weil nur der Server ihn scharf schaltet. Sein Status durchläuft pending, running, input_required, completed, failed oder cancelled. Im Trigger-Tab der Session trägt er das Label "Requested by an outside assistant".
Wer ihn abholt, hängt davon ab, wo die Session läuft. Für eine Session in der Desktop-App des Owners legt agent_session_runtime.rs das Timing offen:
-
DEFAULT_POLL_INTERVAL_MSist 15,000. Ein fälliger Task wird beim nächsten Poll mit einem Lease übernommen. - Nach vier Polls ohne Treffer (
IDLE_CYCLES_BEFORE_BACKOFF) steigt das Intervall entlangIDLE_BACKOFF_LADDER_MS: 30 s, 60 s, dann 300 s. Ein Kommentar in der Datei nennt den Preis: Ein vom Server scharf geschalteter Trigger, der "arriving at a fully idle desktop waits up to five minutes" (an einem komplett untätigen Desktop bis zu fünf Minuten wartet). Jede Aktion eines Menschen in der App weckt die Schleife innerhalb von etwa einer Sekunde. - Eine Session, die "already has a run in flight", stellt ihre fälligen Tasks zurück. Runs in einer Session überlappen sich nicht.
- Wenn der Run endet, wird das Ergebnis von
runtimemit einertask_idin die Session-Mail geschrieben. Der Betreff zeigt, ob er sauber fertig wurde ("Task … result") oder an einer Obergrenze stoppte ("goal not fully met", "stopped at your token ceiling"). - Das Goal wird als nicht vertrauenswürdige Eingabe geprüft.
provenance_for_task_typegibt nur fürmanual, also einen Run, den der Owner in der App gestartet hat,Operatorzurück. Alle anderen Typen, dieser eingeschlossen, laufen durch osGuard.
input_required verdient in jedem Client eine eigene Zeile. Es bedeutet, dass der Run einem Menschen in osFoundry eine Frage gestellt hat und auf die Antwort wartet. Er läuft noch. Weiter warten und dem eigenen Nutzer sagen, dass jemand auf der osFoundry-Seite antworten muss.
Wie oft sollte der Client pollen?
Codex kann innerhalb eines Tool-Aufrufs nicht schlafen, und ein MCP-Tool-Aufruf, der minutenlang blockiert, läuft in Client-Timeouts. Realistisch ist also eine Polling-Schleife auf read_session_task_result. Die Frage ist das Intervall.
Teil 4 des Skripts simuliert das auf einer virtuellen Uhr: 2,000 Runs, die Abholverzögerung aus den Intervallen der Runtime selbst, die Laufzeit aus einer Log-Normalverteilung mit Median 90 s. Diese Laufzeit ist eine Annahme, die absoluten Zahlen sind also nur zur Veranschaulichung. Jede Strategie zählt ihre Aufrufe und wie lange nach dem Ende sie das Ergebnis bemerkt:
runtime active pending wait: median 7.5 s p90 13.5 s max 15.0 s
poll every 5 s calls/task median 20 p90 41 noticed late by median 2.5 s p90 4.5 s
poll every 30 s calls/task median 4 p90 7 noticed late by median 15.0 s p90 26.8 s
poll 5 s x1.5, cap 60 s calls/task median 6 p90 8 noticed late by median 21.5 s p90 48.8 s
poll 15 s x2, cap 120 s calls/task median 3 p90 4 noticed late by median 41.8 s p90 107.2 s
poll first at 60 s, then 15 s calls/task median 4 p90 11 noticed late by median 7.9 s p90 14.6 s
runtime idle-300 pending wait: median 152.4 s p90 273.7 s max 300.0 s
poll every 5 s calls/task median 51 p90 78 noticed late by median 2.5 s p90 4.5 s
poll every 30 s calls/task median 9 p90 13 noticed late by median 14.8 s p90 26.6 s
poll 5 s x1.5, cap 60 s calls/task median 9 p90 11 noticed late by median 28.4 s p90 52.9 s
poll 15 s x2, cap 120 s calls/task median 5 p90 6 noticed late by median 59.0 s p90 107.7 s
poll first at 60 s, then 15 s calls/task median 14 p90 23 noticed late by median 7.5 s p90 13.4 s
Daraus ergeben sich drei Dinge.
Exponential Backoff lohnt sich hier nicht. Beim Polling ist das der Reflex, aber diese Runs dauern Minuten, nicht Stunden. "15 s x2, cap 120 s" spart gegenüber festen 30 s ein oder zwei Aufrufe und bemerkt das Ergebnis im Median 40 bis 60 s später.
Eine erste Wartezeit nahe der typischen Laufzeit ist bei einer aktiven Runtime der beste Kompromiss: vier Aufrufe im Median, Ergebnisse innerhalb von 8 s bemerkt. Jeder Aufruf, bevor der Run überhaupt fertig sein kann, ist verschwendet.
Der Zustand der Runtime zählt mehr als die Strategie des Clients. Auf einem Desktop, der in den Leerlauf gegangen ist, steht der mediane Run 152 s auf pending, bevor ihn jemand übernimmt, und daran ändert kein Polling-Intervall etwas. Wer read_session_state beobachtet und den Task nach ein paar Minuten noch als pending sieht, sieht den Idle-Backoff der Runtime, keinen hängenden Run.
Runs, die direkt hintereinander in einer Session eingereiht werden, stellen sich an:
queued run 1: starts at 13.9 s, done at 121.9 s
queued run 2: starts at 135.9 s, done at 346.2 s
queued run 3: starts at 357.7 s, done at 406.6 s
Reiht Codex drei Goals ein und pollt alle drei im 5-s-Takt, liefern zwei davon minutenlang pending, aus keinem anderen Grund als der Warteschlange.
Warum kann eine Notiz keinen Run starten?
post_session_note braucht nur osfoundry:write und einen Contributor-Platz. Könnte eine Notiz die Agents der Session zu einem Run wecken, wäre jeder Contributor ein Runner unter anderem Namen. Die Beschreibung schließt das aus: "Other agents can reply to the returned external sender slug; this does not trigger an automatic run."
Die Notiz ist also eine Nachricht, kein Auslöser. Sie landet mit dem Anzeigenamen der Verbindung in der Session-Mail. Der Web-Client zeigt in Team mail external_sender_name und ein Label "outside assistant". Agents können beim nächsten Run darauf antworten.
Was liefert das Postfach?
read_session_mail wird als "bounded notes addressed to this connection and notes it posted" beschrieben, mit "Internal agent analysis is excluded." Das Schema begrenzt eine Seite auf limit 1 bis 20 und blättert mit before_id zurück.
Das Skript modelliert die Mail einer vielbeschäftigten Team-Session, 409 Zeilen mit den Nachrichtentypen, die der Web-Client kennt (analysis, tool_call, tool_result, agent_note, broadcast), und wendet den beschriebenen Filter an:
session mail rows 409; visible to the connection 22 (addressed to it 13, posted by it 9)
not visible, by type: {'tool_call': 97, 'tool_result': 62, 'agent_note': 25, 'analysis': 176, 'broadcast': 27}
limit 20: 2 calls to walk back through all 22 notes
limit 10: 3 calls to walk back through all 22 notes
limit 5: 5 calls to walk back through all 22 notes
Die Zeilenzahlen stammen aus dem Modell. Entscheidend ist die Form: Die Sicht der Verbindung ist ein kleiner Ausschnitt, limit: 20 holt also meist alles mit ein oder zwei Aufrufen. Das Ergebnis des Runs steckt auch nicht in diesem Ausschnitt. Im Code der Desktop-Runtime wird die Ergebnis-Mail ohne Empfänger gepostet, sie ist also nicht an die Verbindung adressiert. Ergebnisse über read_session_task_result lesen, die Unterhaltung über read_session_mail, und nicht erwarten, dass das eine das andere ersetzt.
Wo stoppen die Schemas einen Client?
Dreizehn Fälle gegen die Live-inputSchemas, validiert mit jsonschema (Draft 2020-12):
request_session_run goal 10,000 chars ok
request_session_run goal 10,001 chars REJECT maxLength: goal
request_session_run max_rounds 10 ok
request_session_run max_rounds 11 REJECT maximum: max_rounds
request_session_run extra field 'context' REJECT additionalProperties
request_session_run session_type 'graph_session' REJECT enum: session_type
read_session_task_result no task_id REJECT required
read_session_mail limit 20 ok
read_session_mail limit 0 REJECT minimum: limit
read_session_mail limit 50 REJECT maximum: limit
post_session_note subject 200, body 10,000 ok
post_session_note body 10,001 REJECT maxLength: body
post_session_note to: '@writer' REJECT additionalProperties
Über zwei davon stolpert man leicht. Ein Run hat kein Feld context, Hintergrund gehört also in goal, und die Grenze von 10,000 Zeichen gilt für beides zusammen. Eine Notiz hat kein to, man kann also keinen einzelnen Agent ansprechen. Jede Notiz geht an die Session, und Agents antworten an den Sender-Slug der Verbindung.
Das ganze Skript brauchte auf dem M1 19.3 s Wall-Clock-Zeit, 40 Netzwerkaufrufe inklusive. Die User-CPU-Zeit lag bei 0.47 s, die Simulation selbst fällt also nicht ins Gewicht.
Wenn du das selbst baust
- Veröffentliche "start work" als eigenen OAuth-Scope und knüpfe die Rolle sowohl an den Grant als auch an den Token, damit weder der Aufrufer noch ein veralteter Grant sich selbst zum Runner machen kann.
- Gib sofort eine Task-ID zurück und biete dem Aufrufer einen billigen Status-Read an. Lass keinen MCP-Tool-Aufruf auf einen Agent-Run warten.
- Lege das tatsächliche Abholverhalten der Runtime (Poll-Intervall, ein Run pro Session) in der Doku oder im Status offen, damit Clients in vernünftigem Takt pollen, statt zu raten.
- Prüfe alles von einem externen Aufrufer als nicht vertrauenswürdige Eingabe, auch wenn der Aufrufer einen Runner-Platz hat.
- Halte Notizen und Runs auseinander: Wer Notizen schreibt, sollte keinen Run wecken können, und das Zurücklesen sollte die interne Analyse der Session ausschließen.
Wie sich Runs pro Session einreihen, steht auf der Seite zur agent task queue, und die anderen Wege, eine Session zu starten, stehen unter agent triggers.
Top comments (0)