Skip to content

AGENTS缓存与更新边界

从 AgentsMdManager 追踪 AGENTS.md 的缓存键、重载时机、Step 快照和 WorldState 更新边界。

基于rust-v0.150.0
CodexRustContext

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。

rust
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::refresh
  • codex-rs/core/src/environment_selection.rs :: TurnEnvironmentSnapshot::to_selections
rust
#[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 初始化段。

rust
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。

rust
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。

rust
if matches!(previous, PreviousSectionState::Known(previous) if previous == &current) {
    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。

rust
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。

rust
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 还在使用旧规则”,应沿着源码的实际生效边界排查:

  1. 确认修改发生在本次 Step 使用的 environment cwd 到 project root 路径上,而不是另一个 environment。
  2. 比较当前 TurnEnvironmentSnapshot::to_selections() 与上一次 selection;如果完全相同,manager 命中缓存是预期行为。
  3. 检查 StepContext.loaded_agents_md 是否在 capture 后取得新 Arc;WorldState 只消费这个快照,不会再次读磁盘。
  4. 如果新快照确实进入 WorldState,再检查前一份 baseline 是否相同;不同内容应出现 replacement notice,相同内容不会重复注入。
  5. 若问题出现在恢复会话,检查 rollout 中的 world-state patch;恢复使用持久化 snapshot,不以当前文件内容重建过去的模型历史。

可执行的源码导航命令如下:

bash
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。