DEV Community

Cover image for Comment piloter Codex CLI depuis Python comme le fait osFoundry
BBT for BBT Français

Posted on

Comment piloter Codex CLI depuis Python comme le fait osFoundry

Voici la commande que l'application de bureau osFoundry lance quand vous cliquez sur Verify sign-in pour Codex. Elle est construite par build_codex_command dans src-tauri/src/providers/agent_cli.rs, la même fonction par laquelle passe chaque vrai lancement cli:codex:agent :

codex exec "Reply with the single word: ok" \
  --json \
  --skip-git-repo-check \
  --ignore-user-config \
  --ignore-rules \
  -s read-only \
  -c 'approval_policy="never"'
Enter fullscreen mode Exit fullscreen mode

Reprenons-la morceau par morceau :

  • exec avec le prompt comme unique argument positionnel. Codex n'a pas d'option pour un prompt système, donc osFoundry construit cet argument sous la forme system + "\n\n" + transcript.
  • --json fait sortir sur stdout un objet JSON par ligne. Ce flux est la seule sortie que lit osFoundry.
  • --skip-git-repo-check permet de démarrer dans un dossier qui n'est pas un dépôt.
  • --ignore-user-config ignore ~/.codex/config.toml, ce qui laisse les serveurs MCP de l'utilisateur en dehors du lancement. L'authentification est quand même chargée depuis CODEX_HOME.
  • --ignore-rules n'est ajouté que si codex exec --help le liste. osFoundry interroge le binaire, car les anciennes versions refusent cette option, et une option inconnue fait échouer tout le lancement.
  • -s associé à approval_policy="never" définit la sandbox et indique à Codex que personne ne sera là pour valider quoi que ce soit. Un lancement autonome reçoit -s workspace-write. Un lancement qui branche le pont MCP d'osFoundry reçoit à la place --dangerously-bypass-approvals-and-sandbox, et uniquement dans un worktree git.

Avant de lancer le processus, osFoundry retire aussi CODEX_API_KEY, OPENAI_API_KEY et OPENAI_BASE_URL de l'environnement du processus enfant. Si l'une d'elles était définie, le lancement passerait par un compte API facturé à l'usage au lieu de la connexion ChatGPT.

La suite de cet article porte sur l'autre bout de ce pipe. Vous avez une CLI d'agent dont le format de flux n'est pas garanti, et vous devez en tirer sa réponse, sa progression et un échec propre.

Pourquoi lire des événements plutôt qu'un fichier de réponse finale ?

Il y a deux façons de récupérer une réponse depuis une CLI de ce genre :

  1. La réponse finale dans un fichier. Vous passez un chemin de sortie (codex exec -o FILE ; les notes du module d'osFoundry parlent de la sortie fichier documentée), vous attendez la fin du processus, puis vous lisez le fichier. Le contrat, c'est un fichier contenant une chaîne.
  2. Un flux d'événements sur stdout. Vous passez --json, vous lisez ligne par ligne et vous gardez les éléments que vous comprenez.

Le builder actuel d'osFoundry fait la seconde. Le parseur se trouve dans runtime/session_event.rs (parse_codex_line) et s'appuie sur très peu de choses :

Événement Devient
item.completed + agent_message texte de la réponse
item.completed + reasoning ligne Thinking: …
item.completed + command_execution Using shell — <command>
item.completed + mcp_tool_call Using <tool>… (Codex fournit server et tool séparément)
item.completed + file_change / todo_list Edited <path> / Plan: 1. … 2. …
turn.completed consommation (input_tokens, cached_input_tokens, output_tokens)
turn.failed, error échec, marqué comme problème d'authentification si le message y ressemble

Tout le reste est ignoré, de même que toute ligne qui n'est pas du JSON. C'est cette tolérance qui rend l'approche viable sans schéma stable. Un type d'événement inconnu ne coûte rien. Seul un champ renommé dans l'une de ces sept lignes peut casser le parseur.

Le prix à payer, c'est la granularité. Codex émet des éléments terminés, pas des deltas de tokens. La réponse apparaît d'un coup, une fois le message fini, ce qui explique pourquoi l'application affiche No live stream pour Codex et le décrit comme run-then-emit. Les lignes de progression, elles, arrivent au fur et à mesure que chaque élément se termine.

Reconstruire les deux sur un Mac

Aucun binaire Codex n'est installé sur la machine que j'ai utilisée. J'ai donc écrit fake_codex.py, un substitut qui accepte les mêmes options, affiche les types d'événements ci-dessus avec des délais fixes, écrit le message final dans le fichier -o en sortant, et peut être réglé pour se bloquer, planter ou échouer à l'authentification. Ce n'est pas Codex. Ce qu'il permet de mesurer, c'est le comportement du wrapper, pas la vitesse de Codex.

La chronologie du substitut : un élément reasoning à 0.4 s, puis un processus shell enfant (sleep 0.8), puis l'agent_message environ 1.3 s plus tard, puis turn.completed 0.2 s après. Le fichier de réponse est écrit à la sortie.

Le wrapper, wrap.py, n'utilise que la bibliothèque standard de Python 3.9.6. Le cœur de la stratégie par événements, en version abrégée :

def run_events(argv, timeout, env=None, group=True, drain_stderr=True):
    t0 = time.monotonic()
    p = subprocess.Popen(argv + ["--json"], stdin=subprocess.DEVNULL,
                         stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                         env=env, start_new_session=group)
    err = []
    if drain_stderr:
        threading.Thread(target=_drain, args=(p.stderr, err), daemon=True).start()
    lines = queue.Queue()
    threading.Thread(target=pump, args=(p.stdout, lines), daemon=True).start()

    deadline = t0 + timeout
    while True:
        left = deadline - time.monotonic()
        if left <= 0:
            os.killpg(p.pid, signal.SIGKILL) if group else p.kill()
            r["error"] = "timed out after %ss" % timeout
            break
        try:
            at, raw = lines.get(timeout=left)
        except queue.Empty:
            continue
        if raw is None:                        # stdout closed
            break
        try:
            v = json.loads(raw)
        except ValueError:
            continue                           # log line on stdout
        t = v.get("type")
        if t == "item.completed":
            item = v.get("item", {})
            if item.get("type") == "agent_message":
                r["text"] += item.get("text", "")
            else:
                r["steps"].append(item.get("type"))
        elif t == "turn.completed":
            r["usage"] = v.get("usage")
        elif t in ("turn.failed", "error"):
            r["error"] = (v.get("error") or {}).get("message") or v.get("message")
    p.wait()
    if not r["error"] and not r["text"].strip():
        r["error"] = "returned no output (%s)" % tail(err)[-200:]
Enter fullscreen mode Exit fullscreen mode

La stratégie par fichier, c'est le même lancement avec -o <tmp> au lieu de --json, puis p.wait(timeout=…), puis la lecture du fichier s'il existe.

Temps jusqu'au premier octet contre temps jusqu'à la réponse

Configuration du test : Apple M1, macOS 15.8.1, 20 lancements par stratégie, médianes, chronométrées avec time.monotonic() depuis juste avant Popen (bench.py).

événements (--json) fichier (-o)
premier octet sur stdout 0.034 s aucun
premier élément terminé 0.443 s aucun
réponse disponible 2.562 s 2.824 s
processus terminé 2.775 s 2.824 s
lancements réussis 20 / 20 20 / 20

Les valeurs absolues viennent des pauses du substitut. Deux éléments du tableau, en revanche, n'en dépendent pas :

  • Avec un fichier, rien ne sort avant la fin du processus. Pendant 2.8 s, l'appelant n'a aucun moyen de distinguer « en train de réfléchir » de « bloqué ». Avec les événements, la première ligne de progression arrive à 0.44 s, et les premiers octets dès 0.034 s, soit à peu près le coût de lancement de Python lui-même (un simple python3 -c pass prenait ici 19 ms en moyenne).
  • Le fichier n'existe qu'après la dernière étape de la CLI. Dans ce test, c'était 0.26 s après que le message était déjà apparu sur stdout. Avec un vrai agent, l'écart correspond à tout ce que fait la CLI entre la fin de son message et sa sortie.

À quoi ressemble un échec de chaque côté ?

Même wrapper, un lancement par cas :

Cas code de sortie stratégie par événements stratégie par fichier
plantage avant la réponse 101 erreur, avec la fin de stderr comme indice, 0.44 s erreur : pas de fichier de réponse, 0.51 s
échec d'authentification (turn.failed 401) 1 401 Unauthorized: please log in, marqué comme problème d'authentification pas de fichier de réponse + texte de stderr
tué après le message (pas de turn.completed) 137 réponse conservée, pas d'erreur pas de fichier de réponse
bloqué au-delà d'une échéance de 3 s -9 timed out after 3s à 3.004 s timed out after 3s à 3.027 s

Deux lignes comptent ici.

L'authentification. Le flux d'événements vous indique dans un champ structuré qu'il s'agissait d'un problème de connexion. La stratégie par fichier n'a que la prose de stderr pour deviner. osFoundry compare le message à une courte liste (401, unauthorized, please log in, sign in, …) et affiche sa propre invite de connexion.

Tué après le message. Là, les deux stratégies divergent. En lisant les événements, vous avez une agent_message complète et pas de turn.completed. En lisant un fichier, vous n'avez rien. osFoundry tranche d'après ce que le flux a dit : le lancement échoue si un événement turn.failed/error est arrivé ou si aucun texte n'est arrivé, et le code de sortie n'entre pas dans cette décision. Si vous préférez traiter l'absence de turn.completed comme un résultat partiel, c'est à cet endroit qu'il faut l'ajouter.

Qu'est-ce qu'un timeout tue réellement ?

osFoundry enveloppe tout le lancement dans tokio::time::timeout : 1,800 s pour un lancement autonome, 300 s pour la vérification de connexion et les autres complétions sans streaming. Le processus enfant est lancé avec kill_on_drop(true), donc quand le timeout abandonne la future, le processus est tué.

Mais une CLI d'agent a ses propres processus enfants. Le substitut lance sleep 30 comme « outil shell » et reste bloqué dessus. J'ai vérifié ce PID 0.3 s après le retour du wrapper :

Kill retour du wrapper processus enfant de l'outil ensuite
p.kill() (enfant direct uniquement) 3.010 s toujours en cours
start_new_session=True + os.killpg 3.011 s disparu

Même timing, nettoyage différent. Si le processus enfant de l'outil garde votre pipe stdout ouvert, un lecteur qui attend EOF ne le verra jamais non plus. C'est pour cela que la boucle ci-dessus vérifie une échéance au lieu d'attendre que readline rende la main.

Pourquoi vider stderr dans son propre thread ?

Ces CLI écrivent les échecs d'authentification et les panics sur stderr. osFoundry le redirige donc dans un pipe et en garde la fin (environ 4,000 octets côté agent) pour le message d'erreur. Mais un pipe a un tampon de taille fixe. Sur ce Mac, un processus enfant s'est bloqué après exactement 65,536 octets écrits dans un stderr non lu (pipecap.py). J'ai ensuite fait écrire au substitut environ 200 KB de traces avant la réponse :

gestion de stderr résultat
vidé dans un thread pendant la lecture de stdout réponse à 2.56 s
lu seulement après la fermeture de stdout pas de réponse, timed out after 8s

La CLI se bloque sur une écriture que personne ne lit, stdout reste ouvert, et le lancement finit perdu à l'échéance. Lire stderr en parallèle coûte un thread.

Si vous câblez cela vous-même

  • Choisissez le flux d'événements plutôt que le fichier de réponse si quelqu'un suit le lancement. Appuyez-vous sur les quelques types d'événements dont vous avez besoin, et ignorez les lignes non JSON et les types inconnus au lieu d'échouer dessus.
  • Mettez le prompt système dans le texte du prompt quand la CLI n'a pas d'option pour ça, avec un séparateur simple, et conservez les tours précédents sous forme de transcript annoté.
  • Décidez dès le départ si c'est le code de sortie ou le flux qui détermine le succès, et ce que vaut un message sans turn.completed.
  • Donnez au processus enfant son propre groupe de processus et tuez le groupe entier au timeout, pour que les processus des outils partent avec.
  • Lisez stderr en parallèle et gardez-en une fin de taille bornée. C'est là que se trouvent les erreurs d'authentification que vous voudrez afficher.
  • Retirez les variables d'environnement qui redirigeraient une connexion par abonnement vers une clé facturée à l'usage, et testez les options facultatives via --help au lieu de maintenir une table de versions.

La ligne Codex décrite plus haut se trouve dans l'onglet External AI de la boîte de dialogue des clés BYOK de l'application.

Top comments (0)