Context变更语义
ContextManager 的历史不是只能增长的消息数组。普通事件通过 record_items 追加;上下文窗口压缩、线程回滚和 rollout 重建则需要删除或整体替换。它们对配对关系、回合边界、历史版本和上下文基线的影响不同,因此不能都抽象成“修改 Vec”。
本文只研究 codex-rs/core/src/context_manager/history.rs 中四条更新路径,并把每条路径连接到真实消费者:
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。
#[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。
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。
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。
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。
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。
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。
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。
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_call | call 在 output 前 | 头删后两项都消失 | function 成对删除 | 重复 call_id |
remove_first_item_removes_matching_call_for_output | output 在 call 前 | 头删后两项都消失 | output 反向删除 | 多个同 ID output |
remove_first_item_handles_local_shell_pair | local shell + function output | 配对同时删除 | shell 协议复用 output | shell 无 call_id |
remove_first_item_handles_custom_tool_pair | custom 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_messages | prefix contextual user + 两 turn | contextual 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 bundle | baseline 置空 | stale diff 防护 | 下一轮 provider 行为 |
可以在本版本源码中执行:
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 没有马上补 output | record_items | 这是 raw history;继续看 for_prompt |
| 删除一个 call 后 output 仍在 | remove_first_item / remove_corresponding_for | call_id 和协议类型 |
| 回滚后环境或插件提示仍残留 | trim_pre_turn_context_updates | contextual developer/user 判定 |
| 回滚后下一轮 diff 不完整 | reference_context_item | 混合 bundle 是否清空 baseline |
| world-state patch 与历史不一致 | world_state_baseline | remove/replace 后是否重新 full snapshot |
| Guardian 不再使用 delta | history_version | 是否发生 replace 或 rollback |
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、回滚或诊断消费者带入错误状态。
