DEV Community

Cover image for Come incapsulare Codex CLI in Python come fa osFoundry
BBT for BBT Italiano

Posted on

Come incapsulare Codex CLI in Python come fa osFoundry

Questo è il comando che l'app desktop di osFoundry esegue quando clicchi Verify sign-in per Codex. Lo costruisce build_codex_command in src-tauri/src/providers/agent_cli.rs, la stessa funzione da cui passa ogni vera esecuzione di 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

Vediamolo pezzo per pezzo:

  • exec con il prompt come unico argomento posizionale. Codex non ha un flag per il system prompt, quindi osFoundry costruisce quell'argomento come system + "\n\n" + transcript.
  • --json fa sì che stdout sia un oggetto JSON per riga. Quello stream è l'unico output che osFoundry legge.
  • --skip-git-repo-check permette di avviare l'esecuzione in una cartella che non è un repository.
  • --ignore-user-config salta ~/.codex/config.toml, così i server MCP dell'utente restano fuori dall'esecuzione. L'autenticazione viene comunque caricata da CODEX_HOME.
  • --ignore-rules viene aggiunto solo se codex exec --help lo elenca. osFoundry lo chiede al binario perché le build più vecchie rifiutano il flag, e un flag sconosciuto fa fallire l'intera esecuzione.
  • -s insieme a approval_policy="never" imposta la sandbox e dice a Codex che non ci sarà nessuno ad approvare nulla. Un'esecuzione autonoma riceve -s workspace-write. Una che collega il bridge MCP di osFoundry riceve invece --dangerously-bypass-approvals-and-sandbox, e solo dentro un git worktree.

Prima di lanciare il processo, osFoundry rimuove anche CODEX_API_KEY, OPENAI_API_KEY e OPENAI_BASE_URL dall'ambiente del processo figlio. Se una di queste fosse impostata, l'esecuzione finirebbe su un account API a consumo invece che sull'accesso ChatGPT.

Il resto del post parla dell'altro capo di quella pipe. Hai una CLI di agente il cui formato di stream non è garantito, e devi tirarne fuori la risposta, l'avanzamento e un errore pulito.

Perché leggere gli eventi invece di un file con la risposta finale?

Ci sono due modi per ottenere una risposta da una CLI del genere:

  1. Risposta finale su file. Passi un percorso di output (codex exec -o FILE; le note del modulo di osFoundry lo chiamano l'output su file documentato), aspetti che il processo termini e poi leggi il file. Il contratto è un file con dentro una stringa.
  2. Stream di eventi su stdout. Passi --json, leggi riga per riga e tieni gli item che capisci.

Il builder attuale di osFoundry fa la seconda cosa. Il parser si trova in runtime/session_event.rs (parse_codex_line) e si basa su pochissimo:

Evento Diventa
item.completed + agent_message testo della risposta
item.completed + reasoning riga Thinking: …
item.completed + command_execution Using shell — <command>
item.completed + mcp_tool_call Using <tool>… (Codex fornisce server e tool separatamente)
item.completed + file_change / todo_list Edited <path> / Plan: 1. … 2. …
turn.completed utilizzo (input_tokens, cached_input_tokens, output_tokens)
turn.failed, error errore, marcato come di autenticazione se il messaggio lo sembra

Tutto il resto viene ignorato, così come qualsiasi riga che non sia JSON. È questa tolleranza che rende la cosa praticabile senza uno schema stabile. Un tipo di evento sconosciuto non costa nulla. Solo un campo rinominato in una di quelle sette righe può rompere il parser.

Il prezzo è la granularità. Codex emette item completati, non delta di token. La risposta compare tutta in una volta quando il messaggio è finito, ed è per questo che l'app etichetta Codex come No live stream e lo descrive come run-then-emit. Le righe di avanzamento sono diverse: arrivano man mano che ogni item si completa.

Ricostruire entrambe su un Mac

Sulla macchina che ho usato non c'è nessun binario di Codex installato. Quindi ho scritto fake_codex.py, un sostituto che accetta gli stessi flag, stampa i tipi di evento visti sopra con ritardi fissi, scrive il messaggio finale nel file -o all'uscita e si può configurare per bloccarsi, andare in crash o fallire l'autenticazione. Non è Codex. Quello che permette di misurare è il comportamento del wrapper, non la velocità di Codex.

La timeline del sostituto: un item reasoning a 0.4 s, poi un processo figlio di shell (sleep 0.8), poi l'agent_message circa 1.3 s dopo, poi turn.completed 0.2 s più tardi. Il file della risposta viene scritto all'uscita.

Il wrapper, wrap.py, usa solo la libreria standard su Python 3.9.6. Il cuore della strategia a eventi, accorciato:

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 strategia su file è lo stesso spawn con -o <tmp> al posto di --json, poi p.wait(timeout=…), poi la lettura del file se esiste.

Tempo al primo byte contro tempo alla risposta

Setup del test: Apple M1, macOS 15.8.1, 20 esecuzioni per strategia, mediane, cronometrate con time.monotonic() da subito prima di Popen (bench.py).

eventi (--json) file (-o)
primo byte su stdout 0.034 s nessuno
primo item completato 0.443 s nessuno
risposta disponibile 2.562 s 2.824 s
processo terminato 2.775 s 2.824 s
esecuzioni riuscite 20 / 20 20 / 20

I numeri assoluti vengono dagli sleep del sostituto. Due cose nella tabella no:

  • Con un file, non esce nulla fino all'uscita del processo. Per 2.8 s il chiamante non ha modo di distinguere "sta pensando" da "è bloccato". Con gli eventi, la prima riga di avanzamento arriva a 0.44 s, e i byte iniziano a 0.034 s, più o meno il costo di avvio del processo da parte di Python (un semplice python3 -c pass qui ha fatto in media 19 ms).
  • Il file esiste solo dopo l'ultimo passo della CLI. In questa esecuzione è stato 0.26 s dopo che il messaggio era già comparso su stdout. Con un agente reale, il distacco è quello che la CLI fa tra la fine del messaggio e l'uscita.

Che aspetto ha un errore da ciascun lato?

Stesso wrapper, un'esecuzione per caso:

Caso exit strategia a eventi strategia su file
crash prima di rispondere 101 errore, con la coda di stderr come indizio, 0.44 s errore: nessun file di risposta, 0.51 s
errore di autenticazione (turn.failed 401) 1 401 Unauthorized: please log in, marcato come di autenticazione nessun file di risposta + testo di stderr
ucciso dopo il messaggio (nessun turn.completed) 137 risposta conservata, nessun errore nessun file di risposta
blocco oltre una deadline di 3 s -9 timed out after 3s a 3.004 s timed out after 3s a 3.027 s

Qui contano due righe.

Autenticazione. Lo stream di eventi ti dice in un campo strutturato che si trattava di un problema di login. La strategia su file ha solo il testo di stderr per tirare a indovinare. osFoundry confronta il messaggio con una breve lista (401, unauthorized, please log in, sign in, …) e mostra il proprio prompt di accesso.

Ucciso dopo il messaggio. Le due strategie non sono d'accordo. Leggendo gli eventi, hai un agent_message completo e nessun turn.completed. Leggendo un file, non hai niente. osFoundry decide in base a quello che ha detto lo stream: l'esecuzione fallisce se è arrivato un evento turn.failed/error o se non è arrivato alcun testo, e lo stato di uscita non entra in questa decisione. Se preferisci trattare l'assenza di turn.completed come un risultato parziale, è lì che va aggiunto.

Cosa uccide davvero un timeout?

osFoundry avvolge l'intera esecuzione in tokio::time::timeout: 1,800 s per un'esecuzione autonoma, 300 s per la verifica dell'accesso e per le altre completion senza streaming. Il figlio viene lanciato con kill_on_drop(true), quindi quando il timeout fa il drop del future, il processo viene ucciso.

Una CLI di agente però ha dei figli suoi. Il sostituto avvia sleep 30 come suo "tool di shell" e ci resta bloccato sopra. Ho controllato quel PID 0.3 s dopo il ritorno del wrapper:

Kill ritorno del wrapper figlio del tool dopo
p.kill() (solo il figlio diretto) 3.010 s ancora in esecuzione
start_new_session=True + os.killpg 3.011 s sparito

Stessi tempi, pulizia diversa. Se il figlio del tool tiene aperta la tua pipe di stdout, anche un lettore guidato dall'EOF non vede mai l'EOF, ed è per questo che il loop sopra controlla una deadline invece di aspettare che readline ritorni.

Perché svuotare stderr su un thread a parte?

Queste CLI scrivono gli errori di autenticazione e i panic su stderr, quindi osFoundry lo mette in una pipe e ne tiene la coda (circa 4,000 byte sul percorso dell'agente) per il messaggio di errore. Una pipe però ha un buffer fisso. Su questo Mac un figlio si è bloccato dopo esattamente 65,536 byte scritti su uno stderr non letto (pipecap.py). Poi ho fatto scrivere al sostituto circa 200 KB di trace prima di rispondere:

Gestione di stderr risultato
svuotato su un thread mentre si legge stdout risposta a 2.56 s
letto solo dopo la chiusura di stdout nessuna risposta, timed out after 8s

La CLI si blocca su una scrittura che nessuno legge, stdout resta aperto e l'esecuzione si perde alla deadline. Leggere stderr in parallelo costa un thread.

Se lo implementi tu

  • Scegli lo stream di eventi invece del file di risposta se qualcuno segue l'esecuzione. Basati sui pochi tipi di evento che ti servono, e salta le righe non JSON e i tipi sconosciuti invece di fallire.
  • Metti il system prompt nel testo del prompt quando la CLI non ha un flag apposito, con un separatore semplice, e tieni i turni precedenti come un transcript etichettato.
  • Decidi fin da subito se il successo lo stabilisce il codice di uscita o lo stream, e cosa conta un messaggio senza turn.completed.
  • Dai al figlio un suo process group e uccidi il gruppo al timeout, così i processi dei tool se ne vanno con lui.
  • Leggi stderr in parallelo e tieni una coda di dimensione limitata. Contiene gli errori di autenticazione che vorrai mostrare.
  • Elimina le variabili d'ambiente che dirotterebbero un login con abbonamento su una chiave a consumo, e interroga --help per i flag opzionali invece di mantenere una tabella delle versioni.

La riga di Codex descritta sopra si trova nella scheda External AI della finestra BYOK keys dell'app.

Top comments (0)