Session 与 TurnContext · 会话状态与每轮配置

Codex 内核把状态分成三层:线程级的 Session、一轮一个的 TurnContext、每次模型请求一个的 StepContext。一轮开始时,设置先在锁内提交成线程默认值,再冻结成本轮只读的 TurnContext;每发一次请求,还要再冻结一次模型、工具与环境。

作者 David更新于 第 12 篇(共 57 篇)

Session 与 TurnContext · 会话状态与每轮配置

上一部分讲到,CodexThread 最重要的两个字段是一个 Arc<Session> 和一个 SessionIo。从这一篇开始进入 codex-core 的内核,先把 Session 打开:它存了哪些状态,这些状态分几层,一轮对话开始时配置怎样被“冻结”下来。

用户看到的样子

在 TUI 里,用 /model 换模型、/permissions 换权限预设、/plan 或 Shift+Tab 切到计划模式、/fast 开关 Fast 档位,都是在改“这个线程接下来怎么跑”(命令说明见斜杠命令)。通过 app-server 编程时,同样的东西可以随 turn/start 一起带上(模型、推理强度、审批策略、协作模式等),也可以单独用实验性的 thread/settings/update 修改(协议见 SDK 与 App Server)。

有一条规则值得先记住:正在跑的那一轮不受影响。改动写进线程的默认设置,从下一轮起生效;本轮已经拿到的是一份冻结的快照。例外是实验性的 turn/settings/update,它只改正在进行的这一轮后续请求所用的设置,后面会讲到它的限制。

三层状态

层类型寿命装什么
线程Session与线程同生共死会话级可变状态 SessionState、长寿服务 SessionServices、当前轮 ActiveTurn、输入队列、事件通道
一轮TurnContext一个任务从开始到结束本轮冻结的 Config、初始设置 initial_settings、环境快照、轮级元数据
一次请求StepContext一次采样请求这次请求用的设置版本、环境、MCP 连接、工具路由、AGENTS.md

一轮(turn)里通常要和模型来回好几次:模型要调工具,就得把结果送回去再问一次。每一次“问”在代码里叫一个 step,由 StepContext 描述。三层的关系是:Session 产出 TurnContext,TurnContext 再在每次请求前产出 StepContext。

Session:一个线程一个

/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
pub(crate) struct Session {
    pub(crate) thread_id: ThreadId,
    pub(crate) installation_id: String,
    pub(super) tx_event: Sender<Event>,
    pub(super) agent_status: watch::Sender<AgentStatus>,
    pub(super) state: Mutex<SessionState>,
    // ...
    pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
    pub(crate) async_hook_results: async_channel::Receiver<HookCompletedEvent>,
    pub(crate) input_queue: InputQueue,
    pub(crate) services: SessionServices,

(codex-rs/core/src/session/session.rs:55)

文档注释里那句“同一时刻至多一个运行中的任务”是整个内核的前提。几个字段各司其职:

  • state: Mutex<SessionState>(codex-rs/core/src/state/session.rs:69)是会话级的可变状态:当前的线程设置 session_configuration、对话历史 history(一个 ContextManager,见上下文与历史)、最近的 token 用量与限额快照、上一轮用的模型 previous_turn_settings、自动压缩窗口 auto_compact_window,以及“下一轮是不是第一轮”这类标记。
  • services: SessionServices(codex-rs/core/src/state/service.rs:48)是长寿的服务句柄,创建后基本不换:会话级的模型客户端 model_client、MCP 运行时、unified exec 进程管理器、hooks、执行策略、模型目录 models_manager、AGENTS.md 管理器、会话持久化句柄 live_thread 等。
  • active_turn: Mutex<Option<ActiveTurn>> 是当前这一轮。ActiveTurn(codex-rs/core/src/state/turn.rs:32)里有运行中的任务 RunningTask 和一份轮级可变状态 TurnState:等待中的审批、权限请求、提问、MCP elicitation 与动态工具回调(各存一个 oneshot::Sender),用户中途追加的输入 pending_input,本轮授予的额外权限,工具调用计数和开轮时的 token 用量。

这样一分,锁的粒度就清楚了:改历史拿 state,登记或唤醒审批等待拿 active_turn 里单独加锁的 turn_state,两类操作不必抢同一把锁。

启动:spawn 与 new

Session::spawn(codex-rs/core/src/session/mod.rs:510)先建两条通道:提交队列 tx_sub 是容量 512 的有界通道(SUBMISSION_CHANNEL_CAPACITY),事件通道不设上限。随后它解析出本线程的模型、基础指令、协作模式与服务档位,拼成一个 SessionConfiguration,校验后交给 Session::new。Session::new 把三件互不依赖的事用 tokio::join! 并行起来:初始化会话持久化(新建或恢复 rollout)、打开状态数据库、解析认证并准备 MCP 配置。之后依次建好 shell 快照、执行环境、AGENTS.md、网络代理、hooks,组装 SessionServices;对外第一个发出的事件是 SessionConfigured,恢复会话时还附带历史消息,好让界面立刻渲染。spawn 收尾时 tokio::spawn 一个 submission_loop,此后所有 Op 都从这个循环进来,按提交顺序逐个处理。

图表加载中…

一轮开始:先提交,再冻结

用户的输入经 turn_input::handle 判定为“开新一轮”后,走到 new_turn_with_sub_id_if:

        let service_tier_for_turn = updates.service_tier_for_turn.clone();
        let commit = match self.update_settings_if(updates, should_start).await {
            Ok(Some(commit)) => commit,
            Ok(None) => return Ok(None),
            // ...
        let mut configuration = commit.configuration;
        // Apply the override only to the turn's copy, after persisting thread settings.
        if let Some(service_tier) = service_tier_for_turn {
            Arc::make_mut(&mut configuration.step_settings).service_tier = Some(service_tier);
        }
        // ...
        let turn_environments = self.activate_turn_environments(&configuration).await;
        let turn_context = self
            .new_turn_from_configuration(sub_id, configuration, turn_environments, options)
            .await;
        Ok(Some((turn_context, commit.snapshot)))

(codex-rs/core/src/session/turn_context.rs:1071)

这段代码把随请求而来的东西分成两类。turn/start 里的模型、推理强度、审批策略、协作模式等被整理成 SessionSettingsUpdate,先由 update_settings_if(codex-rs/core/src/session/mod.rs:1863)在 state 锁里校验、提交成线程的新默认值,以后每一轮都继承;而本轮专用的服务档位、结构化输出的 JSON Schema 只落在这一轮的副本上。update_settings_if 还接收一个 should_start 判定函数,在同一把锁里对比新旧配置,比如“自动发起的轮次不许进出 Plan 模式”就靠它拒绝,被拒时设置一点都不会改。

提交之后,build_per_turn_config(codex-rs/core/src/session/turn_context.rs:843)把会话启动时的原始 Config 整个复制一份,再用线程设置覆盖工作目录、审批策略、推理强度、摘要、服务档位、权限配置档等字段;函数头的注释提醒“不要再往 config 上加被改写的参数,我们正在去掉它”。随后解析模型元数据、加载插件与技能,交给 make_turn_context 组装成 TurnContext。

TurnContext:本轮的只读快照

/// The context needed for a single turn of the thread.
#[derive(Debug)]
pub struct TurnContext {
    pub(crate) sub_id: String,
    // ...
    /// Turn-scoped configuration. Read step-specific settings such as service tier and
    /// approvals reviewer from the corresponding `StepContext` instead.
    pub config: Arc<Config>,
    // ...
    /// Frozen settings used to construct this context. Legacy turn consumers
    /// keep this view even when later steps use different settings.
    pub(crate) initial_settings: Arc<ResolvedStepSettings>,
    // ...
    /// Settings for the next step; environments are owned by `ThreadEnvironments`.
    pub(super) next_step_settings: ArcSwap<ResolvedStepSettings>,

(codex-rs/core/src/session/turn_context.rs:304)

sub_id 就是这一轮的 turn ID。TurnContext 创建后放进 Arc,整轮基本只读(少数计时、告警标记用原子量或内部锁),任务、工具、审批都拿它的引用。设置有两份:initial_settings 是开轮时冻结的版本,大量“旧式”访问器都读它,例如 model_info() 的注释写着“Legacy: returns the frozen initial-turn model metadata”;next_step_settings 是一个 ArcSwap,存“下一次请求”要用的版本。ResolvedStepSettings(codex-rs/core/src/session/step_settings.rs:41)把用户选的 StepSettings(协作模式里的模型与推理强度、摘要、服务档位、审批策略与评审方)和解析好的 ModelInfo 绑在一起,并算出真正要发的摘要与服务档位。

平时这两份是同一个对象。Op::TurnSettings(对应 turn/settings/update)可以在一轮中途换掉 next_step_settings 或执行环境:只换审批评审方或执行环境时直接可用,换模型、推理强度、摘要或服务档位还要打开 step_model_switching 功能开关(开发中,默认关闭)。apply_turn_settings(codex-rs/core/src/session/step_activation.rs:229)先在锁外解析新模型,再回到锁内确认目标任务还是同一个(比较每个任务独有的 done 通知指针,因为 turn ID 可能被后来的任务复用),重新校验托管要求;换模型时,只要新模型会改变审批策略、Guardian 覆盖范围、前缀规则这类仍由开轮快照决定的东西,就直接拒绝。

StepContext:每次请求再冻结一次

/// Request-scoped state that may change between model sampling requests.
pub(crate) struct StepContext {
    pub(crate) turn: Arc<TurnContext>,
    // ...
    /// One immutable settings version captured before request preparation.
    pub(crate) settings: Arc<ResolvedStepSettings>,
    // ...
    pub(crate) environments: TurnEnvironmentSnapshot,
    // ...
    /// The exact MCP connections, configuration, and catalog captured for this step.
    pub(crate) mcp: Arc<McpBinding>,
    /// The finalized tool plan advertised and executed for this exact sampling request.
    pub(crate) tool_router: Arc<ToolRouter>,
    /// The canonical AGENTS.md value observed with this environment snapshot.
    pub(crate) loaded_agents_md: Option<Arc<LoadedAgentsMd>>,
}

(codex-rs/core/src/session/step_context.rs:19)

capture_step_context_inner(codex-rs/core/src/session/mod.rs:3787)在 active_turn 锁里同时读出 next_step_settings 与环境快照,保证“模型”和“环境”来自同一时刻;然后并行做两件事:刷新 AGENTS.md,以及解析能力根、准备 MCP 连接、构建工具路由。这样一次请求里,写进上下文的环境、发给模型的工具清单、随后真正执行工具调用的注册表,全是同一份视图,中途有人改设置也不会让它们对不上。run_turn 开轮时捕获的第一个 step 直接用于第一次请求,之后每次请求前再捕获新的(见下一篇)。

落盘:TurnContextItem

TurnContext::to_turn_context_item(codex-rs/core/src/session/turn_context.rs:750)把本轮的关键设置(turn ID、工作目录、审批与沙箱策略、权限配置档、模型、协作模式、推理强度与摘要等)序列化成一条 TurnContextItem,StepContext 版本还会补上本次请求的实时语音状态与摘要设置。它有两个用途:写进 rollout,恢复会话时据此还原上一轮的设置;同时存进历史管理器的 reference_context_item,作为下一轮计算“上下文差异”的基准——下一轮设置没变,就不必重复告诉模型。这正是上下文与历史要展开的“只追加”机制的起点。

读 codex-core 时会频繁看到 original_config_do_not_use、带 #[deprecated] 的 cwd 与一串“Legacy”注释:状态正从“一个大 Config 贯穿全程”迁往“每个 step 各自捕获的设置与环境”,两套路径暂时并存。判断某个值以哪份为准,看它读的是 TurnContext 还是 StepContext。

和《从 LLM 到 Coding Agent》对照

Agent Loop 一篇在“跨轮状态”一节列出生产循环要携带的状态:当前模型、压缩边界、恢复计数器、本轮 token 花销,并说成熟的循环“本质是一个手写的状态机”。Codex 把这些状态按寿命拆进三层:跨轮的放进 SessionState,只活一轮的放进 TurnState,只对一次请求有效的冻结进 StepContext。教学实现里一个 messages 数组加几个局部变量就够了;到了支持中途改设置、并发工具与审批等待的生产实现,“谁在什么时候拿哪份快照”本身就成了需要精心设计的契约。


上一篇:codex exec 与 SDK · 没有界面的用法 · 下一篇:run_turn 主循环 · 一轮对话是怎么转起来的

本页目录