Skip to content

Thread de session et historique des messages

源码版本rust-v0.145.0

CodexThread est le handle de session que codex expose à l'amont (UI, app-server, extension). Un thread correspond à un historique de conversation continu, à une session loop, à un fichier rollout, ainsi qu'à un ensemble de répertoires de travail et de configurations de permissions liés. Il ne fait pas tourner lui-même la boucle modèle ; il ne fait qu'envoyer les Op au Session sous-jacent et remonter le flux d'événements à l'appelant. C'est Session et le LiveThread qu'elle détient qui stocke l'historique, fait la compaction et tourne la boucle agent.

Responsabilités

  1. Recevoir les Op externes (messages utilisateur, steer, cancel, shutdown), et les pousser via submit / submit_with_trace dans la mailbox de Session (codex-rs/core/src/codex_thread.rs:205-275).
  2. Exposer le flux d'événements : next_event récupère un Event ; agent_status replie les événements en AgentStatus pour que l'UI affiche idle/running/completed (codex-rs/core/src/codex_thread.rs:414-422).
  3. Injecter des items visibles du modèle : inject_response_items pousse des ResponseItem dans l'historique sans lancer de nouveau turn ; inject_if_running intervient en plein turn (codex-rs/core/src/codex_thread.rs:306-483).
  4. Gérer le snapshot de config du thread : config_snapshot / preview_thread_settings_overrides packagent les runtime config sandbox, approval, environment, model pour l'amont (codex-rs/core/src/codex_thread.rs:355-361).
  5. Déléguer la persistance : toutes les opérations rollout/thread-store passent par live_thread ; load_history / read_thread / append_rollout_items / update_thread_metadata traversent toutes LiveThread (codex-rs/core/src/codex_thread.rs:504-558).

Motivations de conception

Au début, codex couplait « conversation » et « boucle de session » ; plus tard, CodexThread a été extrait comme facade, principalement parce que extension et app-server ont besoin d'un handle stable pour travailler — ils ne doivent pas toucher directement aux channels internes de Session. CodexThread encapsule SessionIo (mpsc sous-jacent) et n'expose que les deux directions submit et next_event ; c'est ce que la doc appelle « bidirectional stream of messages that compose a thread ».

La persistance de l'historique est extraite dans le crate thread-store parce que l'implémentation peut changer : fichier rollout local, service distant, mock en mémoire. Le trait ThreadStore ne fixe que les méthodes de cycle de vie (create_thread / resume_thread / append_items / persist_thread / flush_thread / shutdown_thread / discard_thread), le code cœur se fiche de l'emplacement du backend. LiveThread est le handle détenu par session, chargé de traduire la décision cœur « écrire quand il faut » en appels de store.

Le crate message-history est sur une autre dimension : ce n'est pas le rollout stocké par thread, mais le fichier JSONL global cross-thread ~/.codex/history.jsonl, avec un entry {session_id, ts, text} par ligne. Il sert à la TUI pour retrouver les commandes historiques, pas à restaurer une conversation. Il est donc indépendant de thread-store, gère son propre advisory lock et son trim.

Le design de TryStartTurnIfIdleError mérite mention : quand une extension veut « démarrer automatiquement un tour quand le thread est idle », celui-ci peut être occupé, en Plan mode, ou avoir déjà un turn utilisateur en file. L'erreur retournée embarque reason et les items d'origine intacts, pour que l'appelant décide : jeter, retry ou tracer la cause — pas avaler l'entrée silencieusement.

Fichiers clés

codex-rs/core/src/codex_thread.rs:162-203 — définition de la structure CodexThread et constructeur new, porte Arc<Session>, SessionIo, SessionConfiguredEvent.codex-rs/core/src/codex_thread.rs:86-124TryStartTurnIfIdleRejectionReason et TryStartTurnIfIdleError, les trois raisons de refus d'un turn idle automatique.codex-rs/core/src/codex_thread.rs:446-483inject_user_message_without_turn et inject_response_items, poussent des choses dans l'historique sans lancer de turn.codex-rs/core/src/agent/status.rs:6-21agent_status_from_event, replie un EventMsg en AgentStatus.codex-rs/thread-store/src/store.rs:36-94 — le trait ThreadStore, contrat de tous les backends de persistance thread.codex-rs/thread-store/src/live_thread.rs:35-108 — structure LiveThread et create, handle de store détenu par session.codex-rs/thread-store/src/live_thread.rs:48-90LiveThreadInitGuard, jette proprement le live writer si l'init de session échoue.codex-rs/message-history/src/lib.rs:104-189append_entry, ajout atomique au history.jsonl global.codex-rs/message-history/src/lib.rs:61-83HistoryEntry / HistoryConfig, structures de données de l'historique global.

CodexThread::submit paraît se résumer à self.io.submit(op).await, mais derrière io: SessionIo il y a tokio mpsc + attribution de turn id + injection de trace context. L'amont ne voit que « j'ai poussé un Op et récupéré un sub_id » ; mailbox, file de turn, signaux de shutdown sont encapsulés :

rust
// codex_thread.rs:205-207 — Op 的统一入口
pub async fn submit(&self, op: Op) -> CodexResult<String> {
    self.io.submit(op).await
}

L'état de l'agent n'est pas maintenu par CodexThread lui-même, il dérive d'un EventMsg. TurnStarted -> Running, TurnComplete -> Completed, TurnAborted devient Interrupted ou Errored selon la raison, ShutdownComplete -> Shutdown. is_final considère PendingInit / Running / Interrupted comme « pas encore fini » ; tous les autres états sont terminaux :

rust
// agent/status.rs:6-21 — 状态机完全由事件驱动
pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option<AgentStatus> {
    match msg {
        EventMsg::TurnStarted(_) => Some(AgentStatus::Running),
        EventMsg::TurnComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())),
        EventMsg::TurnAborted(ev) => match ev.reason {
            codex_protocol::protocol::TurnAbortReason::Interrupted
            | codex_protocol::protocol::TurnAbortReason::BudgetLimited => {
                Some(AgentStatus::Interrupted)
            }
            _ => Some(AgentStatus::Errored(format!("{:?}", ev.reason))),
        },
        EventMsg::Error(ev) => Some(AgentStatus::Errored(ev.message.clone())),
        EventMsg::ShutdownComplete => Some(AgentStatus::Shutdown),
        _ => None,
    }
}

Un détail intéressant sur l'écriture du history.jsonl global : il faut tenir le lock et écrire toute la ligne JSON + \n en un seul write_all, car POSIX garantit l'atomicité des écritures jusqu'à PIPE_BUF octets. Plusieurs processus qui écrivent en parallèle ne s'entrelacent pas. Le code utilise délibérément spawn_blocking pour sortir cet IO synchrone du runtime async :

rust
// message-history/src/lib.rs:160-172 — 持锁、定位到末尾、一次写完
tokio::task::spawn_blocking(move || -> Result<()> {
    for _ in 0..MAX_RETRIES {
        match history_file.try_lock() {
            Ok(()) => {
                history_file.seek(SeekFrom::End(0))?;
                history_file.write_all(line.as_bytes())?;
                history_file.flush()?;
                enforce_history_limit(&mut history_file, history_max_bytes)?;
                return Ok(());
            }
            Err(std::fs::TryLockError::WouldBlock) => {
                std::thread::sleep(RETRY_SLEEP);
            }
            Err(e) => return Err(e.into()),
        }
    }
    // ...
})

Flux de données

Limites et échecs

  • Plan mode refuse les turns automatiques : try_start_turn_if_idle retourne PlanMode directement en Plan mode, car ce mode interdit tout démarrage automatique de turn modèle ; seul l'utilisateur peut le déclencher explicitement (codex-rs/core/src/codex_thread.rs:313-331).
  • Échec d'init doit jeter le writer : LiveThreadInitGuard appelle discard via Drop en cas d'échec à mi-chemin de l'init de session, sans forcer le flush de la file in-memory vers durable ; si aucun runtime tokio n'est disponible, le spawn échoue et dégrade en warn (codex-rs/thread-store/src/live_thread.rs:75-90).
  • inject_response_items refuse les items vides : retourne directement InvalidRequest, pour éviter une écriture rollout vide silencieuse (codex-rs/core/src/codex_thread.rs:460-483).
  • history.jsonl ne persiste pas de contenu sensible : avec HistoryPersistence::None, append_entry renvoie return Ok(()) directement ; un TODO dans le code rappelle de vérifier le mode sensible (codex-rs/message-history/src/lib.rs:109-117).
  • Trim par soft cap : HISTORY_SOFT_CAP_RATIO = 0.8 ; quand le fichier dépasse max_bytes, on ne tailore pas exactement à la limite mais à 80 %, pour réduire la fréquence des retrims (codex-rs/message-history/src/lib.rs:55-59).

Récapitulatif

La valeur centrale de CodexThread est d'abstraire « une session » en un handle stable — que le backend soit un rollout local ou un service distant, qu'il y ait ou non des sub-agent, le code amont ne voit que submit / next_event / config_snapshot. La vraie logique de boucle est dans Boucle principale de l'Agent, et le détail de persistance est dans les implémentations du trait thread-store. Pour voir comment l'historique est compacté au sein d'un turn, enchaînez sur Évolution de la compaction du contexte.