Skip to content

Context变更语义

追踪 ContextManager 的追加、成对删除、按回合回滚和整体替换,解释历史版本、上下文基线与写时复制如何共同维护不变量。

基于rust-v0.150.0
CodexRustContext

Context变更语义 ​

ContextManager 的历史不是只能增长的消息数组。普通事件通过 record_items 追加;上下文窗口压缩、线程回滚和 rollout 重建则需要删除或整体替换。它们对配对关系、回合边界、历史版本和上下文基线的影响不同,因此不能都抽象成“修改 Vec”。

本文只研究 codex-rs/core/src/context_manager/history.rs 中四条更新路径,并把每条路径连接到真实消费者:

text
record_items      → 追加可发送 item,并按策略截断工具输出
remove_first_item → 删除最老 item,同时删除对应 call/output
drop_last_n_user_turns → 从 instruction 边界回滚,并清除被回滚的上下文更新
replace           → 建立新的 history lineage,并失效 world-state baseline

读者需要先了解 ResponseItem 的 call/output 类型和 ContextManager 的 raw/prompt 区别。建议先读 ContextHistory读写 与 Context归一化算法。本文不讨论 prompt 归一化的媒体替换,也不讨论文章生产流程。

1. 更新对象 ​

1.1 三种状态 ​

ContextManager 同时携带三类状态:历史 item、最近一次 token usage,以及用于后续上下文差异计算的两个 baseline。源码位置:codex-rs/core/src/context_manager/history.rs :: ContextManager。

rust
#[derive(Debug, Clone, Default)]
pub(crate) struct ContextManager {
    /// The oldest items are at the beginning of the vector.
    items: Arc<Vec<ResponseItemEnvelope>>,
    /// Bumped whenever history is rewritten, such as compaction or rollback.
    history_version: u64,
    token_info: Option<TokenUsageInfo>,
    /// Reference context snapshot used for diffing and producing model-visible
    /// settings update items.
    reference_context_item: Option<TurnContextItem>,
    /// World state most recently appended to model-visible history.
    world_state_baseline: Option<WorldStateSnapshot>,
}

这五个字段的生效范围不同:items 是模型历史本体;history_version 给 Guardian 等增量消费者判断 lineage 是否连续;token_info 记录服务端用量;reference_context_item 只用于设置差异;world_state_baseline 只用于生成 world-state patch。删除或替换 item 时,后两个 baseline 不能机械地保留。

1.2 更新分类 ​

操作变化范围是否建立新 history version关键副作用
record_items追加多个 API item否对工具输出执行写入时截断
remove_first_item删除最老 item 及其配对项否清空 world-state baseline
drop_last_n_user_turns删除尾部 instruction turns是删除回滚边界上方的 contextual updates,必要时清空 reference baseline
replace用新 Vec 替换全部历史是清空 world-state baseline

这里的“是否建立新 history version”必须以当前源码为准:普通追加和 remove_first_item 不递增 history_version,而 replace 递增;回滚通过 replace 间接递增。这种差异决定了 Guardian 能否把当前 transcript 当作上一次 transcript 的 delta。

2. 追加路径 ​

2.1 record_items ​

追加入口先过滤非 API item,再调用 process_item,最后通过 Arc::make_mut 写入共享 vector。源码位置:codex-rs/core/src/context_manager/history.rs :: record_items。

rust
pub(crate) fn record_items<I>(&mut self, items: I, policy: TruncationPolicy)
where
    I: IntoIterator,
    I::Item: std::ops::Deref<Target = ResponseItem>,
{
    for item in items {
        let item_ref = item.deref();
        if !is_api_message(item_ref) {
            continue;
        }

        let processed = Self::process_item(item_ref, policy);
        Arc::make_mut(&mut self.items).push(processed);
    }
}

record_items 不调用 normalize_history,也不递增 history_version。因此它允许 raw history 暂时包含未配对 call,配对补全仍然留给 for_prompt;但它会在写入时处理可能过大的工具输出,避免把无界 payload 原样放入历史。

源码位置:codex-rs/core/src/context_manager/history.rs :: process_item。

rust
fn process_item(item: &ResponseItem, policy: TruncationPolicy) -> ResponseItem {
    let policy_with_serialization_budget = policy * 1.2;
    match item {
        ResponseItem::FunctionCallOutput {
            id,
            call_id,
            output,
            internal_chat_message_metadata_passthrough: metadata,
        } => ResponseItem::FunctionCallOutput {
            id: id.clone(),
            call_id: call_id.clone(),
            output: truncate_function_output_payload(output, policy_with_serialization_budget),
            internal_chat_message_metadata_passthrough: metadata.clone(),
        },
        ResponseItem::CustomToolCallOutput {
            id,
            call_id,
            name,
            output,
            internal_chat_message_metadata_passthrough: metadata,
        } => ResponseItem::CustomToolCallOutput {
            id: id.clone(),
            call_id: call_id.clone(),
            name: name.clone(),
            output: truncate_function_output_payload(output, policy_with_serialization_budget),
            internal_chat_message_metadata_passthrough: metadata.clone(),
        },
        _ => item.clone(),
    }
}

注意这里的 1.2 是序列化余量,不是模型 token 的精确比例。truncate_function_output_payload 仍然按文本或 content items 分支处理;这是写入历史的大小控制,和 for_prompt 的协议归一化是两层不同的防线。

2.2 写时复制 ​

items 使用 Arc<Vec<_>>,历史快照通常只读共享;record_items 需要写入时才复制 vector。追加不会触碰其他快照,也不会因为一次新 item 就深复制每个已有 ResponseItem。

3. 成对删除 ​

3.1 最老 item ​

remove_first_item 的语义不是简单 remove(0)。它先删除最老 item,再调用 remove_corresponding_for 清除配对项,并把 world-state baseline 置空。源码位置:codex-rs/core/src/context_manager/history.rs :: remove_first_item。

rust
pub(crate) fn remove_first_item(&mut self) {
    if !self.items.is_empty() {
        let items = Arc::make_mut(&mut self.items);
        let removed = items.remove(0);
        normalize::remove_corresponding_for(items, &removed.item);
        self.world_state_baseline = None;
    }
}

无论被删除的是 call 还是 output,配对查找都使用 call_id。FunctionCallOutput 先尝试匹配 FunctionCall,找不到再尝试 LocalShellCall;这是因为 local shell 协议复用了 function output。源码位置:codex-rs/core/src/context_manager/normalize.rs :: remove_corresponding_for。

rust
ResponseItem::FunctionCallOutput { call_id, .. } => {
    if let Some(pos) = items.iter().position(|i| {
        matches!(i, ResponseItem::FunctionCall { call_id: existing, .. }
            if existing == call_id)
    }) {
        items.remove(pos);
    } else if let Some(pos) = items.iter().position(|i| {
        matches!(i, ResponseItem::LocalShellCall {
            call_id: Some(existing), ..
        } if existing == call_id)
    }) {
        items.remove(pos);
    }
}

对应的测试分别从 call 在前、output 在前和 local-shell 在前三种排列验证结果为空;这证明的是单次头删后的配对不变量,不证明任意位置删除或重复 call_id 的业务合法性。

3.2 World baseline ​

world state 的下一次历史更新可能是 full snapshot,也可能是相对旧 snapshot 的 patch。头删会改变模型历史中可见的 world-state 起点,继续使用旧 baseline 会让 patch 的基准脱离 surviving history。因此头删和整体替换都必须让后续 update_world_state 回到 full snapshot 路径。

4. 回滚路径 ​

4.1 指令边界识别 ​

drop_last_n_user_turns 不把所有 role == user 的消息都当作真实回合。is_user_turn_boundary 排除 contextual user message,并把 inter-agent instruction assistant message 也视为边界。源码位置:codex-rs/core/src/context_manager/history.rs :: is_user_turn_boundary。

rust
pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
    if matches!(item, ResponseItem::AgentMessage { .. }) {
        return true;
    }
    let ResponseItem::Message { role, content, .. } = item else {
        return false;
    };

    (role == "user" && !is_contextual_user_message_content(content))
        || (role == "assistant" && is_inter_agent_instruction_content(content))
}

回滚先在不可变 snapshot 上收集边界,再决定切片位置。num_turns == 0 是 no-op;没有真实 user turn 时也不删除 session prefix;当数量超过已有回合数时,只保留第一个 instruction boundary 之前的 prefix。

4.2 清理边界上方更新 ​

初步 cut 位置确定后,trim_pre_turn_context_updates 从 cut 向前扫描连续的 contextual developer/user item,但绝不越过首个真实回合。这样回滚不会留下只服务于已删除回合的权限、插件、环境或协作模式差异。源码位置:codex-rs/core/src/context_manager/history.rs :: trim_pre_turn_context_updates。

rust
fn trim_pre_turn_context_updates(
    &mut self,
    snapshot: &[ResponseItem],
    first_instruction_turn_idx: usize,
    mut cut_idx: usize,
) -> usize {
    while cut_idx > first_instruction_turn_idx {
        match &snapshot[cut_idx - 1] {
            ResponseItem::Message { role, content, .. }
                if role == "developer" && is_contextual_dev_message_content(content) =>
            {
                if has_non_contextual_dev_message_content(content) {
                    self.reference_context_item = None;
                }
                cut_idx -= 1;
            }
            ResponseItem::Message { role, content, .. }
                if role == "user" && is_contextual_user_message_content(content) =>
            {
                cut_idx -= 1;
            }
            _ => break,
        }
    }
    cut_idx
}

混合 developer bundle 是关键边界:它同时包含可回滚的 contextual fragment 和持久 developer text。删除 bundle 后,旧 reference_context_item 已无法代表 surviving history,所以必须置为 None;下一轮重新注入完整 context,而不是根据一个不存在的基线生成差异。

5. 整体替换 ​

5.1 replace ​

整体替换是 compaction、rollout reconstruction 和回滚共用的 lineage 边界。源码位置:codex-rs/core/src/context_manager/history.rs :: replace。

rust
pub(crate) fn replace(&mut self, items: Vec<ResponseItem>) {
    self.items = Arc::new(items);
    self.history_version = self.history_version.saturating_add(1);
    self.world_state_baseline = None;
}

它不自动清空 token_info,也不自动清空 reference_context_item。这两个状态由调用者或更细粒度的回滚逻辑负责;只有 world_state_baseline 是 replace 的固定副作用。把 replace 理解成“重置所有 ContextManager 字段”会错误地推断 token 和设置差异行为。

5.2 Guardian消费边界 ​

Guardian transcript cursor 保存父 history version。当前版本相同且 item 数量单调增加时,它可以使用 delta;replace 或回滚造成版本变化时,消费者必须回退 full transcript。这里的 version 是 lineage 信号,不是每一次 append 的计数器。

6. 测试与边界 ​

相关测试把操作分成可观察的不变量,而不是只检查最终 vector:

测试输入断言覆盖范围未覆盖边界
remove_first_item_removes_matching_output_for_function_callcall 在 output 前头删后两项都消失function 成对删除重复 call_id
remove_first_item_removes_matching_call_for_outputoutput 在 call 前头删后两项都消失output 反向删除多个同 ID output
remove_first_item_handles_local_shell_pairlocal shell + function output配对同时删除shell 协议复用 outputshell 无 call_id
remove_first_item_handles_custom_tool_paircustom call + output配对同时删除custom pair非头部删除
drop_last_n_user_turns_preserves_prefix两个真实 user turn删除尾 turn,保留 prefix回滚切片compact 摘要重建
drop_last_n_user_turns_ignores_session_prefix_user_messagesprefix contextual user + 两 turncontextual prefix 不计入回合边界识别所有 fragment 类型
drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn回合间多类 context update更新全部移除回滚清理外部持久化同步
drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles混合 developer bundlebaseline 置空stale diff 防护下一轮 provider 行为

可以在本版本源码中执行:

bash
just test -p codex-core remove_first_item_removes_matching_output_for_function_call
just test -p codex-core remove_first_item_removes_matching_call_for_output
just test -p codex-core remove_first_item_handles_local_shell_pair
just test -p codex-core remove_first_item_handles_custom_tool_pair
just test -p codex-core drop_last_n_user_turns_preserves_prefix
just test -p codex-core drop_last_n_user_turns_ignores_session_prefix_user_messages
just test -p codex-core drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn
just test -p codex-core drop_last_n_user_turns_clears_reference_context_for_mixed_developer_context_bundles

这些测试证明历史容器在选定输入下保持配对、边界和 baseline 不变量;不证明压缩服务的网络失败恢复、所有 rollout reconstruction 分支,或 token 估算与 provider tokenizer 的数值相等。

7. 源码导航 ​

遇到以下现象时,按 owner 而不是按 UI 表象定位:

现象入口检查点
新 item 没有马上补 outputrecord_items这是 raw history;继续看 for_prompt
删除一个 call 后 output 仍在remove_first_item / remove_corresponding_forcall_id 和协议类型
回滚后环境或插件提示仍残留trim_pre_turn_context_updatescontextual developer/user 判定
回滚后下一轮 diff 不完整reference_context_item混合 bundle 是否清空 baseline
world-state patch 与历史不一致world_state_baselineremove/replace 后是否重新 full snapshot
Guardian 不再使用 deltahistory_version是否发生 replace 或 rollback
bash
rg -n "record_items|remove_first_item|drop_last_n_user_turns|replace" codex-rs/core/src/context_manager/history.rs
rg -n "remove_corresponding_for|is_user_turn_boundary|trim_pre_turn_context_updates" codex-rs/core/src/context_manager
rg -n "history_version|GuardianTranscriptCursor|world_state_baseline" codex-rs/core/src

读完本文后,修改 ContextManager 前应先回答三个问题:这次变化是追加还是重写?call/output 配对是否仍成立?任何 diff 或 patch 的 baseline 是否仍然存在于 surviving history?只有这三个问题同时有源码证据,才可以判断更新操作不会把下一次 prompt、回滚或诊断消费者带入错误状态。