C'est le seul outil du serveur MCP d'osFoundry qui lance du travail sans que personne ne clique sur quoi que ce soit, tel que tools/list le renvoie à n'importe quel client, connecté ou non :

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

Deux expressions de cette description dictent tout ce qu'un client en fait. "Invited runner" désigne un contrôle de permissions qui a lieu à trois endroits. "When available" signifie que l'appel rend la main avant que le travail ne commence, donc le client doit revenir chercher le résultat. Cet article passe en revue les deux, ainsi que la boîte aux lettres que Codex utilise ensuite pour parler à la session, avec une reconstruction en Python du côté client exécutée sur ce Mac.

Que peut apprendre un client avant de se connecter ?

Pas mal de choses. initialize, tools/list et les métadonnées de la protected resource répondent sans 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'] 

Mesuré depuis le Japon sur un Apple M1 : les millisecondes décrivent donc autant la route que le serveur. osfoundry:run est publié comme un scope à part entière, distinct de osfoundry:write. Une config Codex qui l'omet dans scopes n'obtient jamais de token capable de mettre un run en file, quel que soit le siège qu'on lui attribue ensuite.

La boucle du runner utilise cinq des 59 outils du serveur : read_session_state, request_session_run, read_session_task_result, post_session_note et read_session_mail. Ils pèsent 821 des 10,197 tokens de la liste (8.1 %, comptés avec cl100k_base, donc approximatif pour le tokenizer de Codex). Ne lister que ceux-là dans enabled_tools garde les 54 autres hors du prompt.

Qui décide que Codex est un runner ?

Trois acteurs distincts, et un appel a besoin des trois :

  1. Le token. request_session_run déclare osfoundry:run. La page de connexion affiche ce scope sous la forme "Run sessions where you grant a runner role".
  2. Le grant. Dans le client web d'osFoundry (ExternalAssistantsPanel.tsx), un grant de session porte des actions, libellées View, Reply, Edit et "Start work". Le menu déroulant de rôle d'un grant ne propose Contributor que si le grant inclut write, et Runner que s'il inclut run.
  3. Le siège. Le owner enregistre un rôle par session, que le client envoie sous la forme POST /external-assistants/{connection}/sessions/{session_type}/{session_id} avec {"role": "observer" | "contributor" | "runner"}. Le menu les affiche comme Viewer, Contributor et Runner.

Le script reconstruit ce contrôle. La correspondance rôle-outil vient du texte d'aide du panneau ("Contributor to post notes and propose handoffs, or Runner to also request runs"), et le scope de chaque outil, des schémas en direct. Le code serveur d'osFoundry n'est pas public, il s'agit donc d'une reconstruction de la règle, pas d'une capture des refus du serveur :

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 

Cette séparation compte pour qui écrit le client. Un refus sur request_session_run a deux corrections différentes : se reconnecter avec le scope (de votre côté) ou demander le siège au owner (du sien). Indiquez lequel des deux au lieu de réessayer.

Que se passe-t-il entre l'appel et le résultat ?

L'appel met une tâche en file et rend la main. Les types du client web montrent ce qu'est cette tâche : un AgentTask avec task_type: "external_assistant", un type que la route de création de tâches de l'API refuse, car seul le serveur l'arme. Son statut passe par pending, running, input_required, completed, failed ou cancelled. Dans l'onglet Trigger de la session, elle est libellée "Requested by an outside assistant".

Qui la récupère dépend de l'endroit où tourne la session. Pour une session dans l'app desktop du owner, agent_session_runtime.rs détaille le timing :

  • DEFAULT_POLL_INTERVAL_MS vaut 15,000. Une tâche due est réclamée au poll suivant avec un lease.
  • Après quatre polls sans rien trouver (IDLE_CYCLES_BEFORE_BACKOFF), l'intervalle grimpe le long de IDLE_BACKOFF_LADDER_MS : 30 s, 60 s, puis 300 s. Un commentaire du fichier en donne le coût : un trigger armé par le serveur "arriving at a fully idle desktop waits up to five minutes" (qui arrive sur un desktop totalement inactif attend jusqu'à cinq minutes). La moindre action d'une personne dans l'app réveille la boucle en une seconde environ.
  • Une session qui "already has a run in flight" reporte ses tâches dues. Les runs d'une même session ne se chevauchent pas.
  • À la fin du run, le résultat est écrit dans le mail de la session depuis runtime avec un task_id. L'objet indique s'il s'est terminé proprement ("Task … result") ou s'il s'est arrêté sur un plafond ("goal not fully met", "stopped at your token ceiling").
  • Le goal est filtré comme une entrée non fiable. provenance_for_task_type ne renvoie Operator que pour manual, un run lancé par le owner dans l'app. Tous les autres types, celui-ci compris, passent par osGuard.

input_required mérite une ligne dans n'importe quel client. Cela veut dire que le run a posé une question à une personne dans osFoundry et qu'il attend la réponse. Il tourne toujours. Continuez d'attendre, et dites à votre utilisateur que quelqu'un côté osFoundry doit répondre.

À quelle fréquence le client doit-il poller ?

Codex ne peut pas dormir à l'intérieur d'un appel d'outil, et un appel d'outil MCP qui bloque pendant des minutes se heurte aux timeouts du client. La forme réaliste est donc une boucle de polling sur read_session_task_result. Reste la question de l'intervalle.

La partie 4 du script le simule sur une horloge virtuelle : 2,000 runs, un délai de prise en charge tiré des intervalles du runtime lui-même, et une durée de run tirée d'une loi log-normale de médiane 90 s. Cette durée est une hypothèse, donc les chiffres absolus sont à prendre comme illustratifs. Chaque stratégie compte ses appels et le temps qu'elle met à remarquer la fin du run :

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 

Trois enseignements en ressortent.

Le backoff exponentiel n'est pas rentable ici. C'est le réflexe classique pour le polling, mais ces runs durent des minutes, pas des heures. "15 s x2, cap 120 s" économise un ou deux appels par rapport à un intervalle fixe de 30 s et remarque le résultat 40 à 60 s plus tard en médiane.

Une première attente proche de la durée typique d'un run est le meilleur compromis sur un runtime actif : quatre appels en médiane, résultats remarqués en moins de 8 s. Chaque appel passé avant que le run ait pu se terminer est perdu.

L'état du runtime compte davantage que la stratégie du client. Sur un desktop passé en inactivité, le run médian reste pending 152 s avant que quelqu'un le réclame, et aucun intervalle de polling n'y change rien. Si vous surveillez read_session_state et voyez la tâche toujours pending au bout de quelques minutes, c'est le backoff d'inactivité du runtime, pas un run bloqué.

Les runs mis en file à la suite sur une même session attendent leur tour :

 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 

Si Codex met trois goals en file et les interroge tous les trois toutes les 5 s, deux d'entre eux passent des minutes à renvoyer pending sans autre raison que la file.

Pourquoi une note ne peut-elle pas lancer un run ?

post_session_note n'a besoin que de osfoundry:write et d'un siège Contributor. Si une note pouvait réveiller les agents de la session pour un run, chaque Contributor serait un Runner sous un autre nom. La description l'exclut : "Other agents can reply to the returned external sender slug; this does not trigger an automatic run."

La note est donc un message, pas un déclencheur. Elle arrive dans le mail de la session avec le nom affiché de la connexion. Le client web affiche external_sender_name et un libellé "outside assistant" dans Team mail. Les agents peuvent y répondre lors de leur prochain run.

Que renvoie la boîte aux lettres ?

read_session_mail est décrit comme "bounded notes addressed to this connection and notes it posted", avec "Internal agent analysis is excluded." Le schéma borne une page à un limit de 1 à 20 et remonte avec before_id.

Le script modélise le mail d'une session d'équipe chargée, 409 lignes des types de messages que connaît le client web (analysis, tool_call, tool_result, agent_note, broadcast), et applique le filtre décrit :

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 

Les nombres de lignes viennent du modèle. Ce qui compte, c'est la forme : la vue de la connexion est une petite tranche, donc limit: 20 récupère généralement tout en un ou deux appels. Le résultat du run n'est pas non plus dans cette tranche. Dans le code du runtime desktop, le mail de résultat est posté sans destinataire, il n'est donc pas adressé à la connexion. Lisez les résultats via read_session_task_result et la conversation via read_session_mail, et ne comptez pas sur l'un pour remplacer l'autre.

Où les schémas arrêtent-ils un client ?

Treize cas contre les inputSchema en direct, validés avec 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 

Deux sont faciles à rater. Un run n'a pas de champ context, donc le contexte va dans goal, et la limite de 10,000 caractères couvre les deux. Une note n'a pas de to, donc impossible de l'adresser à un agent précis. Chaque note va à la session, et les agents répondent au sender slug de la connexion.

Le script complet a pris 19.3 s en temps réel sur le M1, 40 appels réseau compris. Le temps CPU utilisateur était de 0.47 s, la simulation elle-même est donc négligeable.

Si vous le mettez en place vous-même

  • Publiez "start work" comme un scope OAuth à part, et conditionnez le rôle au grant autant qu'au token, pour que ni l'appelant ni un grant périmé ne puisse se faire runner tout seul.
  • Renvoyez immédiatement un id de tâche et donnez à l'appelant une lecture de statut peu coûteuse. Ne faites pas attendre un appel d'outil MCP la fin d'un run d'agent.
  • Exposez le vrai comportement de prise en charge du runtime (intervalle de poll, un run par session) dans la doc ou dans le statut, pour que les clients pollent à un rythme raisonnable au lieu de deviner.
  • Filtrez tout ce qui vient d'un appelant externe comme une entrée non fiable, même quand il détient un siège de runner.
  • Séparez notes et runs : celui qui écrit des notes ne doit pas pouvoir réveiller un run, et la relecture doit exclure l'analyse interne de la session.

La façon dont les runs s'enchaînent par session est décrite sur la page agent task queue, et les autres façons de démarrer une session sont dans agent triggers.