环境上下文生成与差分
模型看到的 <environment_context> 不是把进程环境变量或操作系统信息全部转储出来。当前版本由 EnvironmentsState 选择性投影 Step 中的 environment cwd、启动状态、shell、主环境权限、workspace roots、日期、时区、网络域名规则和子 agent 摘要,再通过 WorldState 只发送变化部分。
这与旧版单一 EnvironmentContext 已有明显差异:现在一个 Turn 可以选择多个 environment,也可以在远端环境尚未 ready 时先记录 starting;环境移除后发送 unavailable,而不是用新的完整 XML 覆盖历史。本文沿真实源码从 StepContext 追到最终 user-role fragment,并解释哪些字段没有进入该结构。
阅读前可先看 StepContext模型请求,理解为什么一次请求要冻结 environment snapshot;WorldState 的通用差分机制见 Context变更语义。
1. 当前数据模型
旧实现常把环境上下文描述成一个含 cwd、shell、current_date 的扁平结构。当前实现中负责模型可见状态的是 EnvironmentsState:环境自身保存在按 ID 排序的 BTreeMap,Turn 级事实保存在旁边。
源码位置:codex-rs/core/src/context/world_state/environment.rs :: EnvironmentsState。
#[derive(Clone, Debug, Default)]
pub(crate) struct EnvironmentsState {
environments: BTreeMap<String, EnvironmentState>,
current_date: Option<String>,
timezone: Option<String>,
network: Option<NetworkContext>,
filesystem: Option<FileSystemContext>,
subagents: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct EnvironmentState {
cwd: PathUri,
status: EnvironmentStatus,
shell: Option<String>,
is_primary: bool,
}BTreeMap 让 snapshot 和 XML 按 environment ID 稳定输出,而“谁是 primary”仍由原 selection 顺序计算并存入值中。当前结构没有 os、platform、CPU 架构或进程环境变量字段;模型若知道这些信息,来源也不是本 section。
2. 输入所有权
环境上下文不是从一个对象整体克隆出来。from_turn_context_with_environments 同时消费 TurnContext 和本 Step 的 TurnEnvironmentSnapshot:cwd、shell、ready/starting 来自 Step;timezone 和网络规则来自 Turn;文件系统只取 primary environment 的权限与 workspace roots。
源码位置:codex-rs/core/src/context/world_state/environment.rs :: from_turn_context_with_environments。
pub(crate) fn from_turn_context_with_environments(
turn_context: &TurnContext,
environments: &TurnEnvironmentSnapshot,
current_date: Option<String>,
) -> Self {
Self {
environments: environment_states(environments),
current_date,
timezone: turn_context.timezone.clone(),
network: network_from_turn_context(turn_context),
filesystem: environments.primary().map(|environment| {
FileSystemContext::from_permission_profile(
environment.permission_profile(),
environment.workspace_roots(),
)
}),
subagents: None,
}
}下表给出所有者、消费者和捕获时机,避免把相似字段混成“当前机器信息”。
| 字段 | 真实来源 | 生效时机 | 关键边界 |
|---|---|---|---|
| environment ID | selection | Step snapshot | XML 顺序按 ID,不按 selection 顺序 |
| cwd | ready/starting environment | Step capture | 使用 PathUri,支持外部 Windows 路径 |
| shell | ready environment | readiness 完成后 | starting 时为 None |
| primary | ready environment 顺序 | Step capture | 多环境时才渲染属性 |
| current_date | TimeProvider | 构造 WorldState 时 | 不是直接使用 Turn 中旧日期 |
| timezone | TurnContext | Turn 构造时 | IANA 获取失败回退 Etc/UTC |
| network | requirements config | Turn 配置生效后 | 仅允许/拒绝域名列表 |
| filesystem | primary environment | Step capture | 不合并所有 environment 权限 |
| subagents | AgentControl | 构造 WorldState 时 | 空字符串不注入 |
3. 状态归并
environment_states 先遍历 ready environment,并把第一个 ready 项标为 primary;随后补入 starting 项。相同 ID 如果已经 ready,entry(...).or_insert_with(...) 不会让 starting 状态覆盖它。
源码位置:codex-rs/core/src/context/world_state/environment.rs :: environment_states。
fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap<String, EnvironmentState> {
let mut environments = snapshot
.turn_environments()
.enumerate()
.map(|(index, environment)| {
(
environment.environment_id.clone(),
EnvironmentState {
cwd: environment.cwd().clone(),
status: EnvironmentStatus::Available,
shell: environment
.shell
.as_ref()
.map(|shell| shell.name().to_string()),
is_primary: index == 0,
},
)
})
.collect::<BTreeMap<_, _>>();
for environment in snapshot.starting() {
environments
.entry(environment.selection.environment_id.clone())
.or_insert_with(|| EnvironmentState {
cwd: environment.selection.cwd.clone(),
status: EnvironmentStatus::Starting,
shell: None,
is_primary: false,
});
}
environments
}这段代码建立了三个不变量:只有 ready environment 能成为 primary;starting environment 没有 shell;同一 ID 在一次 snapshot 中只输出一条状态。selection 顺序只决定 primary,最终 XML 列举顺序则由 ID 排序。
Unavailable 不是持久状态结构里的枚举值,而是 diff renderer 为“上一份 snapshot 有、当前没有”的 ID 生成的更新标记。下一份完整 snapshot 不会继续保存这个幽灵条目。
4. 两种 XML 布局
只有一个且已 ready 的 environment 时,代码保持旧版兼容布局,直接输出 <cwd> 和 <shell>;多环境或存在 starting 环境时,才引入 <environments> 容器、ID 和 primary 属性。
源码位置:codex-rs/core/src/context/world_state/environment.rs :: RenderedEnvironments::body、is_legacy_single。
fn is_legacy_single(environments: &BTreeMap<String, EnvironmentState>) -> bool {
environments.len() == 1
&& environments
.values()
.all(|environment| environment.status == EnvironmentStatus::Available)
}
if self.legacy_single {
if let Some(EnvironmentUpdate::Current(environment)) = self.updates.values().next() {
push_environment_values(&mut rendered, environment, " ");
}
} else if !self.updates.is_empty() {
rendered.push_str(" <environments>\n");
for (id, update) in &self.updates {
match update {
EnvironmentUpdate::Current(environment) => {
rendered.push_str(" <environment id=\"");
push_xml_escaped_text(&mut rendered, id);
rendered.push('"');
if self.include_primary {
rendered.push_str(if environment.is_primary {
" primary=\"true\""
} else {
" primary=\"false\""
});
}
rendered.push_str(">\n");
push_environment_values(&mut rendered, environment, " ");
rendered.push_str(" </environment>\n");
}
EnvironmentUpdate::Unavailable => {
rendered.push_str(" <environment id=\"");
push_xml_escaped_text(&mut rendered, id);
rendered.push_str("\" status=\"unavailable\" />\n");
}
}
}
rendered.push_str(" </environments>\n");
}单环境完整输出形如:
<environment_context>
<cwd>/repo</cwd>
<shell>bash</shell>
<current_date>2026-02-26</current_date>
<timezone>America/Los_Angeles</timezone>
</environment_context>多环境完整输出则显式标出边界:
<environment_context>
<environments>
<environment id="local" primary="true">
<cwd>/repo/local</cwd>
<shell>bash</shell>
</environment>
<environment id="remote" primary="false">
<cwd>/repo/remote</cwd>
<status>starting</status>
</environment>
</environments>
</environment_context>从单环境跨到多环境时,即使 local 本身没有改变,也必须重述所有当前 environment。原因是模型需要从“隐含唯一环境”切换到“显式 ID + primary”语义;只追加 remote 无法补齐 local 的 ID 和 primary 属性。
5. 差分算法
EnvironmentsState::snapshot 把网络和文件系统先渲染成稳定字符串,再与上一份 typed snapshot 比较。日期、时区、网络和文件系统任何一项变化都会设置 turn_context_values_changed;environment 则逐 ID 比较 cwd、status、primary 和 shell。
源码位置:codex-rs/core/src/context/world_state/environment.rs :: snapshot、render_diff。
let turn_context_values_changed = current.current_date != previous.current_date
|| current.timezone != previous.timezone
|| current.network != previous.network
|| current.filesystem != previous.filesystem;
let multiple_environments = self.environments.len() > 1;
let previous_multiple_environments = previous.environments.len() > 1;
let mut updates = self
.environments
.iter()
.filter(|(id, _)| {
let environment = ¤t.environments[*id];
previous.environments.get(*id).is_none_or(|previous| {
multiple_environments != previous_multiple_environments
|| !environment.has_same_diff_value(previous)
})
})
.map(|(id, environment)| (id.clone(), EnvironmentUpdate::Current(environment.clone())))
.collect::<BTreeMap<_, _>>();
updates.extend(
previous
.environments
.keys()
.filter(|id| !self.environments.contains_key(*id))
.map(|id| (id.clone(), EnvironmentUpdate::Unavailable)),
);5.1 Shell 特例
shell 比较有一个刻意的非对称规则:只有前后两边都有 shell 时才比较字符串;一边缺失时视为没有差异。
源码位置:codex-rs/core/src/context/world_state/environment.rs :: EnvironmentSnapshot::has_same_diff_value。
fn has_same_diff_value(&self, other: &Self) -> bool {
self.cwd == other.cwd
&& self.status == other.status
&& self.is_primary == other.is_primary
&& self
.shell
.as_ref()
.zip(other.shell.as_ref())
.is_none_or(|(current, previous)| current == previous)
}因此 ready environment 的 shell 从未知变成 zsh,如果 cwd、status 和 primary 都不变,不会单独产生环境更新;但 starting 变 available 会因 status 改变而发出完整当前项,同时带上已知 shell。这个规则避免仅因旧 snapshot 缺少 shell 字段而反复补发兼容信息。
6. 权限投影
环境上下文中的 <filesystem> 不是执行器权限对象的原样序列化。FileSystemContext::from_permission_profile 先用 primary environment 的 workspace roots 展开 :workspace_roots,再把权限降维为模型需要理解的 managed/disabled/external 和 restricted/unrestricted 结构。
源码位置:codex-rs/core/src/context/environment_context.rs :: FileSystemContext::from_permission_profile。
let materialized_workspace_roots = workspace_roots
.iter()
.filter_map(|workspace_root| workspace_root.to_abs_path().ok())
.collect::<Vec<_>>();
let permission_profile = permission_profile
.clone()
.materialize_project_roots_with_workspace_roots(&materialized_workspace_roots);
let workspace_roots = workspace_roots
.iter()
.map(PathUri::inferred_native_path_string)
.collect();
let permission_profile = match permission_profile {
PermissionProfile::Managed { file_system, .. } => {
FileSystemPermissionProfileContext::Managed(
ManagedFileSystemContext::from(file_system),
)
}
PermissionProfile::Disabled => FileSystemPermissionProfileContext::Disabled,
PermissionProfile::External { .. } => FileSystemPermissionProfileContext::External,
};restricted entries 在渲染前去重;deny entry 固定写出 escalatable="false"。这只是模型可见说明,不是操作系统强制本身,真正的授权与沙箱执行链属于 权限与Sandbox指令。
7. 时间与开关
TurnContext::new 通过 local_time_context 捕获日期和 IANA timezone,失败时同时回退 UTC 日期与 Etc/UTC。不过构造模型可见 EnvironmentsState 时,日期会再次从 Session 的 TimeProvider 读取并格式化;timezone 仍使用 Turn 快照。
相关源码:
codex-rs/core/src/session/turn_context.rs :: local_time_contextcodex-rs/core/src/session/world_state.rs :: build_world_state_for_step
fn local_time_context() -> (String, String) {
match iana_time_zone::get_timezone() {
Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone),
Err(_) => (
Utc::now().format("%Y-%m-%d").to_string(),
"Etc/UTC".to_string(),
),
}
}
let current_date = self
.services
.time_provider
.current_time(self.thread_id())
.await
.map_err(|err| CodexErr::Fatal(format!("failed to read current time: {err:#}")))?
.with_timezone(&chrono::Local)
.format("%Y-%m-%d")
.to_string();这使跨午夜的后续 Step 可以更新 <current_date>,即使 Turn 构造时保存的 current_date 没有改变。测试也明确断言 timezone 改变时,环境更新中使用调用时的本地日期。
include_environment_context 默认值是 true。关闭后,Session 不创建 EnvironmentsState,也不查询 subagent 文本;相关 cwd 或 timezone 变化不会产生 <environment_context>。这不是发送“环境已移除”的 XML,而是该 section 根本不参与当前 WorldState。
环境选择在 Step 内保持固定:capture_step_context_with_required_mcp_servers 对 TurnContext 中的 snapshot 调用 refresh_readiness(),只晋升已经完成启动的环境,不采纳更新后的 thread selection。随后 build_world_state_for_step 使用同一份 StepContext.environments 构造 EnvironmentsState。因此一次请求内 不会出现 cwd 来自旧 selection、权限来自新 selection 的混合快照。
源码位置:codex-rs/core/src/session/mod.rs :: capture_step_context_with_required_mcp_servers
let environments = turn_context.environments.refresh_readiness();
let step_context = StepContext {
turn: Arc::clone(&turn_context),
environments,
// mcp、capability 和 tool router 也从本次快照继续构造
..
};8. 转义边界
cwd、shell、environment ID、日期和时区通过 push_xml_escaped_text 转义五个 XML 特殊字符。路径中的 & 或 ID 中的引号不会破坏标签结构。
源码位置:codex-rs/core/src/context/environment_context.rs :: push_xml_escaped_text。
pub(crate) fn push_xml_escaped_text(rendered: &mut String, value: &str) {
for ch in value.chars() {
match ch {
'&' => rendered.push_str("&"),
'<' => rendered.push_str("<"),
'>' => rendered.push_str(">"),
'"' => rendered.push_str("""),
'\'' => rendered.push_str("'"),
_ => rendered.push(ch),
}
}
}网络域名当前由 domains.join(",") 直接写入 <allowed> 和 <denied>,没有再次调用 XML escape helper。requirements 层通常约束其为域名模式,但从这个 renderer 本身不能外推“任意字符串都安全转义”;这是字段输入契约与通用 XML renderer 的区别。
9. 渲染测试
environment_render_tests.rs 以完整字符串断言单环境、多环境、Windows 外部路径、网络、workspace roots、managed 权限和子 agent 布局。下面的多环境测试输入两个 cwd,断言 local 为 primary、remote 为非 primary,并验证每个 shell 位于自己的 environment 内。
源码位置:codex-rs/core/src/context/world_state/environment_render_tests.rs :: serialize_environment_context_with_multiple_selected_environments。
let local_cwd = test_path_buf("/repo/local");
let remote_cwd = test_path_buf("/repo/remote");
let context = environment_state(
[
environment("local", PathUri::from_abs_path(&local_cwd.abs()), "bash"),
environment("remote", PathUri::from_abs_path(&remote_cwd.abs()), "bash"),
],
Some("2026-02-26".to_string()),
Some("America/Los_Angeles".to_string()),
/*network*/ None,
/*subagents*/ None,
);
let expected = format!(
r#"<environment_context>
<environments>
<environment id="local" primary="true">
<cwd>{}</cwd>
<shell>bash</shell>
</environment>
<environment id="remote" primary="false">
<cwd>{}</cwd>
<shell>bash</shell>
</environment>
</environments>
<current_date>2026-02-26</current_date>
<timezone>America/Los_Angeles</timezone>
</environment_context>"#,
local_cwd.display(),
remote_cwd.display()
);
assert_eq!(
context.render(),
expected
);changing_primary_environment_updates_model_context_and_persisted_state 进一步证明 primary 变化同时影响两处:模型 fragment 重述两个 environment,WorldState merge patch 则把旧 primary 的 is_primary 删除、给新 primary 写入 true。
源码位置:codex-rs/core/src/context/world_state/environment_tests.rs :: changing_primary_environment_updates_model_context_and_persisted_state。
let previous = before.snapshot();
let rendered = after
.render_diff(PreviousSectionState::Known(&previous))
.expect("primary change should update the model")
.render();
assert_eq!(
rendered,
format!(
"<environment_context>\n <environments>\n <environment id=\"local\" primary=\"false\">\n <cwd>{}</cwd>\n <shell>bash</shell>\n </environment>\n <environment id=\"remote\" primary=\"true\">\n <cwd>{}</cwd>\n <shell>zsh</shell>\n </environment>\n </environments>\n</environment_context>",
PathUri::parse("file:///local")?.inferred_native_path_string(),
PathUri::parse("file:///remote")?.inferred_native_path_string(),
)
);
let mut previous_world_state = super::super::WorldState::default();
previous_world_state.add_section(before);
let mut current_world_state = super::super::WorldState::default();
current_world_state.add_section(after);
assert_eq!(
current_world_state
.snapshot()
.merge_patch_from(&previous_world_state.snapshot()),
Some(json!({
"environments": {
"environments": {
"local": { "is_primary": null },
"remote": { "is_primary": true },
},
},
}))
);这些测试证明 XML 布局、差分选择和持久化 patch,不证明 remote environment 的连接一定成功,也不证明模型正确理解 primary 或权限说明。record_context_updates_omits_environment_item_when_disabled 则证明开关关闭后不发送 fragment,但没有证明历史中旧环境文本会被物理删除。
10. 定位顺序
遇到模型使用错误 cwd、把 remote 当 local,或环境状态长期停留 starting 时,可以按所有权边界排查:
- 在
capture_step_context检查本 Step 的TurnEnvironmentSnapshot,不要先看 Session 的 deprecated cwd。 - 在
environment_states检查 ready/starting 分类和第一个 ready environment;primary 不是按字典序选取。 - 在
EnvironmentsState::snapshot检查模型比较值,确认单环境/多环境边界是否变化。 - 在
render_diff检查目标 ID 是 Current 还是 Unavailable,以及 turn metadata 是否真的变化。 - 若问题是 workspace roots 或权限,检查 primary environment 的
permission_profile;其他 environment 的权限不会合并进<filesystem>。 - 若完全没有环境 fragment,先检查
include_environment_context,再检查 WorldState baseline 是否判定无变化。
可执行的源码导航命令:
rg -n "struct EnvironmentsState|fn environment_states|fn render_diff" \
codex-rs/core/src/context/world_state/environment.rs
rg -n "FileSystemContext|NetworkContext|push_xml_escaped_text" \
codex-rs/core/src/context/environment_context.rs
rg -n "include_environment_context|current_date|from_turn_context_with_environments" \
codex-rs/core/src/session/world_state.rs沿这条链可以得到一个可验证结论:<environment_context> 是请求级 environment snapshot 与 Turn/Session 元数据的模型投影,不是宿主机环境转储;它用完整 snapshot 持久化比较基线,用增量 XML 告诉模型本次真正变化的 environment 和运行约束。
