AGENTS缓存与更新边界
上一篇已经说明了 AGENTS.md 如何被发现和读取。本文继续回答一个更容易误判的问题:文件内容改变后,哪一次模型请求会看见新内容?答案不是“下一次调用 refresh 就一定重读”。AgentsMdManager 只把 ready environment 的 selection 列表作为缓存键;selection 不变时直接复用 Arc<LoadedAgentsMd>,文件内容、mtime 和目录字节本身都不会触发失效。
本文只讨论 AGENTS.md 的管理链:Session 初始化、Step 捕获、selection 变化、锁外文件读取,以及 WorldState 对 AGENTS 片段的替换和移除。目录发现算法、候选优先级和字节预算见 AGENTS文件发现与作用域;更高层的上下文 section 拼装见 指令优先级与拼装顺序。
1. 刷新对象
AgentsMdManager 持有两类不同生命周期的数据:构造时固定的 host user_instructions,以及由 environment selection 决定的 LoadedAgentsMd。后者放在 Mutex<AgentsMdCache> 中,缓存同时保存 selection 列表和加载结果,二者必须一起替换。
源码位置:codex-rs/core/src/agents_md_manager.rs :: AgentsMdManager、AgentsMdCache。
pub(crate) struct AgentsMdManager {
user_instructions: Option<UserInstructions>,
cache: Mutex<AgentsMdCache>,
}
#[derive(Default)]
struct AgentsMdCache {
selections: Option<Vec<TurnEnvironmentSelection>>,
active_project_trust_level: Option<TrustLevel>,
loaded: Option<Arc<LoadedAgentsMd>>,
}new 会先过滤空白 host 指令。空字符串不会占据缓存,也不会让后续 LoadedAgentsMd::is_empty 误判为有内容;这与 AGENTS文件发现与作用域 中“空白文档不进入结果”的规则一致。Arc 的作用是让多个 Step 读取同一份不可变加载结果,而不需要复制所有文本和 provenance。
图中的缓存并不保存文件快照的 hash 或时间戳。LoadedAgentsMd 保存的是已经读完的文本和 provenance;因此“缓存命中”意味着复用上一次加载结果,而不是重新确认文件是否仍然存在。
2. 命中条件
刷新入口有两个短路条件:ready environment 的 selection 列表与缓存完全相等,并且 config.active_project.trust_level 也相等。turn_environments() 只遍历 ready environment;starting 环境不会进入 缓存键。相同顺序、相同 ID、cwd、配置和 trust level 才算命中。
相关源码:
codex-rs/core/src/agents_md_manager.rs :: AgentsMdManager::refreshcodex-rs/core/src/environment_selection.rs :: TurnEnvironmentSnapshot::to_selections
#[tracing::instrument(name = "agents_md.refresh", skip_all)]
pub(crate) async fn refresh(
&self,
config: &Config,
environments: &TurnEnvironmentSnapshot,
) -> io::Result<()> {
let selections = environments
.turn_environments()
.map(|environment| environment.selection.clone())
.collect::<Vec<_>>();
let active_project_trust_level = config.active_project.trust_level;
{
let mut cache = self.cache.lock().await;
if cache.selections.as_ref() == Some(&selections)
&& cache.active_project_trust_level == active_project_trust_level
{
return Ok(());
}
cache.selections = None;
cache.active_project_trust_level = None;
cache.loaded = None;
}
let loaded = load_project_instructions(
config,
self.user_instructions.clone(),
environments,
)
.await?
.map(Arc::new);
let mut cache = self.cache.lock().await;
cache.selections = Some(selections);
cache.active_project_trust_level = active_project_trust_level;
cache.loaded = loaded;
Ok(())
}这带来一个必须记住的反例:AGENTS.md 内容从 old 改成 new,但 selection 与 trust level 都没变, refresh 仍然直接返回;下一 Step 会继续拿到旧 Arc。反过来,即使文件内容没变,只要 cwd、environment selection 或项目信任级别改变,就会重新运行发现流程。项目变为 untrusted 时,loader 只保留 host user_instructions,不读取项目 AGENTS 文档。
3. 锁外读取
代码只在比较/清空旧缓存和提交新结果时持有 cache 锁。load_project_instructions 以及底层 metadata/read_file 都发生在锁外。这一设计避免文件 I/O 长时间占有锁,但 miss 后会先把旧 key 和旧 loaded 一起清空。因此加载期间调用 get_loaded 得到的是 None,而不是旧快照;若受 sandbox 约束的读取返回错误, refresh 通过 Result 向上传播,缓存保持空状态,下一次调用会重新尝试。
锁外 I/O 的直接收益是减少锁竞争;它不能被解释为并发刷新协调器。当前源码没有 generation、取消旧加载 或“只提交最新请求”的版本比较。两个不同 key 的并发 refresh 仍可能按完成顺序覆盖缓存,调用方不能从这里 推导出 latest-request-wins。
4. 生效时机
Session 创建时,Session::new 先构造 ThreadEnvironments,得到 ready snapshot,再把 AgentsMdManager::refresh 与 Plugin/Skill warmup、thread name lookup 放进 tokio::join!。因此初始 Session 返回前,manager 已经完成第一次加载尝试。
源码位置:codex-rs/core/src/session/session.rs :: Session::new 初始化段。
let resolved_environments = turn_environments.snapshot().await;
let agents_md_manager = Arc::new(AgentsMdManager::new(user_instructions));
let (agents_md_result, plugin_skill_errors, thread_name) = tokio::join!(
agents_md_manager.refresh(config.as_ref(), &resolved_environments),
plugin_skill_warmup,
thread_name_lookup,
);
agents_md_result?;Session 初始化会检查 agents_md_result?,因此受 sandbox 约束的发现错误可以阻止 Session 完成构造。运行中的 每个请求又在 capture_step_context_with_required_mcp_servers 中刷新一次,并使用 await? 把错误返回给 Turn 调用链;它先调用 refresh_readiness 让已经完成启动的环境晋升为 ready,再把 manager 结果复制进 StepContext。
源码位置:codex-rs/core/src/session/mod.rs :: capture_step_context_with_required_mcp_servers。
let environments = turn_context.environments.refresh_readiness();
self.services
.agents_md_manager
.refresh(&turn_context.config, &environments)
.await?;
let loaded_agents_md = self.services.agents_md_manager.get_loaded().await;
let step_context = StepContext {
turn: Arc::clone(&turn_context),
environments,
loaded_agents_md,
// mcp, tool_router and capability snapshots follow
};4.1 文件变化
下面的时间线区分“selection 变化”和“文件内容变化”:
| 变化 | refresh 行为 | 当前 Step | 后续 Step |
|---|---|---|---|
| AGENTS 内容变化,selection 不变 | cache hit | 已捕获的旧文本 | 仍是旧文本 |
| cwd 改变,形成新 selection | 重新发现 | 当前 Step 仍用旧 snapshot | 新 Step 可见新文本 |
| environment ID 或 workspace selection 改变 | 重新发现 | 当前 Step 不回溯 | 新 Step 可见新组合 |
| 文件在发现后被删除 | 读阶段按 NotFound 跳过 | 已捕获内容不变 | 只有重载后才可能消失 |
| trust level 改变 | cache miss,重新计算是否允许项目文档 | 已捕获内容不变 | 新 Step 使用新信任结果 |
| 受 sandbox 约束的读取错误 | 清空缓存并返回 Err | 已构造 Step 不回溯 | 后续 refresh 可重试 |
| 无 sandbox 的 host 读取错误 | loader 记录错误并继续 | 已捕获内容不变 | 可得到部分结果 |
“下一 turn 可见”只有在 selection 或 active project trust level 发生变化时才成立。当前版本没有 AGENTS 文件 watcher、mtime probe 或显式 invalidate API;单纯编辑文件仍不会使缓存失效。
5. 替换消息
manager 只负责得到 LoadedAgentsMd,真正决定模型如何看见变化的是 AgentsMdState::render_diff。它把当前文本变成 AgentsMdSnapshot { directory, text },并与 ContextManager 保存的上一次 snapshot 比较。
源码位置:codex-rs/core/src/context/world_state/agents_md.rs :: AgentsMdState::render_diff。
if matches!(previous, PreviousSectionState::Known(previous) if previous == ¤t) {
return None;
}
let previous_may_contain_instructions = match previous {
PreviousSectionState::Known(previous) => previous.text.is_some(),
PreviousSectionState::Unknown => true,
PreviousSectionState::Absent => false,
};
let instructions = match (&self.instructions, previous_may_contain_instructions) {
(Some(instructions), true) => UserInstructions {
directory: instructions.directory.clone(),
text: format!(
"{REPLACEMENT_NOTICE}\n\n{}",
instructions.text
),
},
(Some(instructions), false) => instructions.clone(),
(None, true) => UserInstructions {
directory: None,
text: REMOVAL_NOTICE.to_string(),
},
(None, false) => return None,
};
Some(Box::new(instructions))因此“新 AGENTS 生效”不是把新文本原地改写进历史,而是追加一个带替换说明的 contextual user fragment;“AGENTS 消失”则追加 removal notice。只有前后 snapshot 完全相同,才不追加任何消息。
6. 持久化顺序
Session::record_step_world_state_if_changed 用同一对前后 snapshot 同时产生两种结果:一份给模型的 contextual fragment,一份写入 rollout 的 WorldStateItem merge patch。代码先记录实际发出的 fragment,再更新 history baseline,最后持久化 patch。这样下一 Step 比较的是已经对模型生效的状态,而不是尚未发送的候选状态。
源码位置:codex-rs/core/src/session/mod.rs :: record_step_world_state_if_changed。
let previous_snapshot = previous_world_state.snapshot();
let world_state_snapshot = world_state.snapshot();
let world_state_item = world_state_snapshot
.merge_patch_from(&previous_snapshot)
.map(WorldStateItem::patch);
let items = merge_contextual_fragments(
world_state.render_diff(&previous_snapshot),
);
if !items.is_empty() {
self.record_conversation_items(turn_context, &items).await;
}
self.state
.lock()
.await
.history
.set_world_state_baseline(world_state_snapshot);
if let Some(world_state_item) = world_state_item {
self.persist_rollout_items(&[RolloutItem::WorldState(world_state_item)])
.await;
}这里有两个容易混淆的“历史”:模型上下文历史保存替换/移除文本,rollout 历史保存结构化 world-state patch。后续恢复依赖 patch 能还原 section snapshot;它不会重新访问 AGENTS 文件系统,也不会因为文件现在变了就自动改写旧 rollout。
7. 测试边界
context/world_state/agents_md_tests.rs 的 snapshot fixture 覆盖了四种重要输入:从无到有、旧到新、从有到无,以及 previous state 为 Unknown。例如旧文本变新文本时,断言结果包含 These AGENTS.md instructions replace all previously provided AGENTS.md instructions.;从有到空时,断言结果是 The previously provided AGENTS.md instructions no longer apply.。
源码位置:codex-rs/core/src/context/world_state/agents_md_tests.rs :: snapshots。
let empty = AgentsMdState::default();
let project_formatter = LoadedAgentsMd::from_text_for_testing("use the project formatter");
let project_formatter = AgentsMdState::new(Some(&project_formatter));
let old = LoadedAgentsMd::from_text_for_testing("old instructions");
let old = AgentsMdState::new(Some(&old));
let new = LoadedAgentsMd::from_text_for_testing("new instructions");
let new = AgentsMdState::new(Some(&new));
insta::assert_snapshot!(render_section_cases(&[
(Absent, Absent),
(Absent, Known(&empty)),
(Absent, Known(&project_formatter)),
(Known(&project_formatter), Known(&project_formatter)),
(Known(&old), Known(&new)),
(Known(&new), Known(&empty)),
(Unknown, Known(&new)),
(Unknown, Known(&empty)),
]));这些测试证明的是 section 的输入输出和消息文案,不证明文件系统变化会触发 manager reload。agents_md_tests.rs 则覆盖 root/cwd 发现、selection 对应的多 environment 预算和读取容错,但没有一个 manager 测试把 mtime 当作失效信号;这是因为当前实现根本没有该信号。
测试没有证明三件事:文件被编辑后模型一定在下一轮看到新文本;并发调用 refresh 时提交顺序满足某种更强的线性化保证;模型会按 AGENTS 内容执行。前两项需要 manager 层专门的可控 filesystem/concurrency 测试,最后一项属于模型行为而非源码函数的断言。
8. 排查路径
当读者观察到“我已经修改 AGENTS.md,但 Codex 还在使用旧规则”,应沿着源码的实际生效边界排查:
- 确认修改发生在本次 Step 使用的 environment cwd 到 project root 路径上,而不是另一个 environment。
- 比较当前
TurnEnvironmentSnapshot::to_selections()与上一次 selection;如果完全相同,manager 命中缓存是预期行为。 - 检查
StepContext.loaded_agents_md是否在 capture 后取得新Arc;WorldState 只消费这个快照,不会再次读磁盘。 - 如果新快照确实进入 WorldState,再检查前一份 baseline 是否相同;不同内容应出现 replacement notice,相同内容不会重复注入。
- 若问题出现在恢复会话,检查 rollout 中的 world-state patch;恢复使用持久化 snapshot,不以当前文件内容重建过去的模型历史。
可执行的源码导航命令如下:
rg -n "pub\(crate\) async fn refresh|get_loaded|AgentsMdCache" \
codex-rs/core/src/agents_md_manager.rs
rg -n "capture_step_context_with_required_mcp_servers|loaded_agents_md" \
codex-rs/core/src/session/mod.rs codex-rs/core/src/session/step_context.rs
rg -n "REPLACEMENT_NOTICE|REMOVAL_NOTICE|render_diff" \
codex-rs/core/src/context/world_state/agents_md.rs读完本文后,应该能够回答一个具体问题:如果只编辑文件而不改变 environment selection,为什么下一次 Step 仍可能使用旧 Arc<LoadedAgentsMd>;如果 selection 改变,为什么变化最终不是覆盖旧历史,而是由 AgentsMdState 生成 replacement/removal fragment 并更新 rollout baseline。
