Skip to content

Commit f2f80ef

Browse files
authored
core: make AGENTS.md react to environment changes (#29810)
## Why With deferred executors, a turn can begin before a remote environment attaches. AGENTS.md discovery previously ran only during session setup, so instructions from a later environment never reached the model or the session instruction sources. WorldState persistence has now landed, so this uses the durable model-visible baseline directly instead of carrying a temporary resume/fork compatibility path. ## What - Add an `AgentsMdManager` in `SessionServices` to own host instructions, loaded state, and refresh caching. - When `DeferredExecutor` is enabled, refresh AGENTS.md when attached environment selections change and freeze the result in the corresponding `StepContext`. - Represent AGENTS.md as a persisted WorldState section for every session, with bounded initial, replacement, and removal updates. - Remove duplicate AGENTS.md state and rendering from `SessionConfiguration` and `TurnContext`. - Build initial context, per-request updates, and compaction context from the same step-scoped value. - On resume and fork, compare current instructions with the restored WorldState baseline and inject a replacement exactly once when they differ. Builds on #29833, #29835, and #29837. ## Tests - Covers a remote environment becoming ready mid-turn, with AGENTS.md appearing on the next request exactly once and updating canonical instruction sources. - Covers full, unchanged, replaced, and removed AGENTS.md WorldState rendering. - Covers changed instructions across cold resume and fork without duplicate reinjection. - Covers remote-v2 compaction retaining creation-time instructions in the live session and cold resume appending one replacement when the source changed. - Ran focused `codex-core` AGENTS.md, WorldState, and context-update test suites.
1 parent 51864b0 commit f2f80ef

30 files changed

Lines changed: 639 additions & 219 deletions

codex-rs/core/src/agents_md.rs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
//! 3. We do **not** walk past the project root.
1717
1818
use crate::config::Config;
19-
use crate::context::ContextualUserFragment;
2019
use crate::context::UserInstructions as ContextUserInstructions;
2120
use crate::environment_selection::TurnEnvironmentSnapshot;
2221
use codex_config::ConfigLayerSource;
@@ -378,8 +377,7 @@ impl LoadedAgentsMd {
378377
output
379378
}
380379

381-
/// Returns the complete model-visible contextual user fragment.
382-
pub(crate) fn render(&self) -> String {
380+
pub(crate) fn contextual_user_fragment(&self) -> ContextUserInstructions {
383381
// One contributing project environment retains the legacy cwd wrapper. With two or more,
384382
// the body labels every contributing environment itself, so the outer cwd is omitted.
385383
let directory = if self.has_multiple_project_environments() {
@@ -392,12 +390,6 @@ impl LoadedAgentsMd {
392390
directory,
393391
text: self.text(),
394392
}
395-
.render()
396-
}
397-
398-
/// Returns the host-provided user instructions.
399-
pub(crate) fn user_instructions(&self) -> Option<&UserInstructions> {
400-
self.user_instructions.as_ref()
401393
}
402394

403395
/// Returns the AGENTS.md files that supplied instruction entries.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use crate::agents_md::LoadedAgentsMd;
2+
use crate::agents_md::load_project_instructions;
3+
use crate::config::Config;
4+
use crate::environment_selection::TurnEnvironmentSnapshot;
5+
use codex_extension_api::UserInstructions;
6+
use codex_protocol::protocol::TurnEnvironmentSelection;
7+
use std::sync::Arc;
8+
use tokio::sync::Mutex;
9+
10+
/// Owns the inputs and cached result of AGENTS.md discovery for a session.
11+
pub(crate) struct AgentsMdManager {
12+
user_instructions: Option<UserInstructions>,
13+
cache: Mutex<AgentsMdCache>,
14+
}
15+
16+
#[derive(Default)]
17+
struct AgentsMdCache {
18+
selections: Option<Vec<TurnEnvironmentSelection>>,
19+
loaded: Option<Arc<LoadedAgentsMd>>,
20+
}
21+
22+
impl AgentsMdManager {
23+
pub(crate) fn new(user_instructions: Option<UserInstructions>) -> Self {
24+
Self {
25+
user_instructions: user_instructions
26+
.filter(|instructions| !instructions.text.trim().is_empty()),
27+
cache: Mutex::new(AgentsMdCache::default()),
28+
}
29+
}
30+
31+
pub(crate) async fn refresh(&self, config: &Config, environments: &TurnEnvironmentSnapshot) {
32+
let selections = environments.to_selections();
33+
if self.cache.lock().await.selections.as_ref() == Some(&selections) {
34+
return;
35+
}
36+
37+
let loaded =
38+
load_project_instructions(config, self.user_instructions.clone(), environments)
39+
.await
40+
.map(Arc::new);
41+
let mut cache = self.cache.lock().await;
42+
cache.selections = Some(selections);
43+
cache.loaded = loaded;
44+
}
45+
46+
pub(crate) async fn get_loaded(&self) -> Option<Arc<LoadedAgentsMd>> {
47+
self.cache.lock().await.loaded.clone()
48+
}
49+
50+
pub(crate) fn user_instructions(&self) -> Option<UserInstructions> {
51+
self.user_instructions.clone()
52+
}
53+
}

codex-rs/core/src/agents_md_tests.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use super::*;
22
use crate::config::ConfigBuilder;
3+
use crate::context::ContextualUserFragment;
34
use crate::environment_selection::TurnEnvironmentSnapshot;
45
use crate::session::turn_context::TurnEnvironment;
56
use codex_config::ConfigLayerEntry;
@@ -345,7 +346,7 @@ fn foreign_agents_md_uses_environment_native_paths() {
345346
};
346347

347348
assert_eq!(
348-
loaded.render(),
349+
loaded.contextual_user_fragment().render(),
349350
format!(
350351
"# AGENTS.md instructions for {rendered_cwd}
351352
@@ -388,7 +389,7 @@ fn multi_environment_agents_md_renders_mixed_path_conventions() {
388389
};
389390

390391
assert_eq!(
391-
loaded.render(),
392+
loaded.contextual_user_fragment().render(),
392393
r#"# AGENTS.md instructions
393394
394395
<INSTRUCTIONS>
@@ -931,7 +932,10 @@ secondary doc"#,
931932
{inner}
932933
</INSTRUCTIONS>"#
933934
);
934-
assert_eq!(loaded.render(), expected_fragment);
935+
assert_eq!(
936+
loaded.contextual_user_fragment().render(),
937+
expected_fragment
938+
);
935939
assert_eq!(
936940
loaded.sources().collect::<Vec<_>>(),
937941
vec![
@@ -972,7 +976,10 @@ async fn secondary_only_project_doc_uses_single_contributor_layout() {
972976
"# AGENTS.md instructions for {}\n\n<INSTRUCTIONS>\n{inner}\n</INSTRUCTIONS>",
973977
secondary.path().display()
974978
);
975-
assert_eq!(loaded.render(), expected_fragment);
979+
assert_eq!(
980+
loaded.contextual_user_fragment().render(),
981+
expected_fragment
982+
);
976983
}
977984

978985
#[tokio::test]
@@ -998,7 +1005,10 @@ async fn primary_only_project_doc_preserves_legacy_layout_with_multiple_bound_en
9981005
"# AGENTS.md instructions for {}\n\n<INSTRUCTIONS>\n{inner}\n</INSTRUCTIONS>",
9991006
primary.path().display()
10001007
);
1001-
assert_eq!(loaded.render(), expected_fragment);
1008+
assert_eq!(
1009+
loaded.contextual_user_fragment().render(),
1010+
expected_fragment
1011+
);
10021012
}
10031013

10041014
#[tokio::test]
@@ -1276,7 +1286,6 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
12761286
}],
12771287
};
12781288
assert_eq!(loaded, expected);
1279-
assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref());
12801289
assert_eq!(
12811290
loaded.sources().collect::<Vec<_>>(),
12821291
vec![

codex-rs/core/src/codex_thread.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,9 +466,15 @@ impl CodexThread {
466466

467467
let turn_context = self.codex.session.new_default_turn().await;
468468
if self.codex.session.reference_context_item().await.is_none() {
469+
// This history-only API runs without run_turn, so it owns its initial step.
470+
let step_context = self
471+
.codex
472+
.session
473+
.capture_step_context(Arc::clone(&turn_context))
474+
.await;
469475
self.codex
470476
.session
471-
.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
477+
.record_context_updates_and_set_reference_context_item(step_context.as_ref())
472478
.await;
473479
}
474480
self.codex

codex-rs/core/src/compact_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ async fn process_compacted_history_with_test_session(
1212
previous_turn_settings: Option<&PreviousTurnSettings>,
1313
) -> (Vec<ResponseItem>, Vec<ResponseItem>) {
1414
let (session, turn_context) = crate::session::tests::make_session_and_context().await;
15+
let turn_context = Arc::new(turn_context);
1516
session
1617
.set_previous_turn_settings(previous_turn_settings.cloned())
1718
.await;

codex-rs/core/src/compact_token_budget.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::hook_runtime::PreCompactHookOutcome;
77
use crate::hook_runtime::run_post_compact_hooks;
88
use crate::hook_runtime::run_pre_compact_hooks;
99
use crate::session::session::Session;
10+
use crate::session::step_context::StepContext;
1011
use crate::session::turn_context::TurnContext;
1112
use codex_analytics::CompactionTrigger;
1213
use codex_protocol::error::CodexErr;
@@ -34,10 +35,9 @@ pub(crate) async fn run_manual_compact_task(
3435
});
3536
sess.send_event(&turn_context, start_event).await;
3637

37-
let world_state = Arc::new(
38-
sess.build_world_state_for_environments(&turn_context, &turn_context.environments)
39-
.await,
40-
);
38+
// Manual compaction runs outside run_turn, so it captures its own current step.
39+
let step_context = sess.capture_step_context(Arc::clone(&turn_context)).await;
40+
let world_state = Arc::new(sess.build_world_state_for_step(&step_context).await);
4141
run_compact_task_inner(&sess, &turn_context, world_state, CompactionTrigger::Manual).await
4242
}
4343

@@ -48,17 +48,17 @@ pub(crate) async fn run_manual_compact_task(
4848
/// observe the same lifecycle as local or remote compaction.
4949
pub(crate) async fn run_inline_auto_compact_task(
5050
sess: Arc<Session>,
51-
turn_context: Arc<TurnContext>,
51+
step_context: Arc<StepContext>,
5252
initial_context_injection: InitialContextInjection,
5353
) -> CodexResult<()> {
54+
let turn_context = &step_context.turn;
5455
let world_state = match initial_context_injection {
5556
InitialContextInjection::BeforeLastUserMessage(world_state) => world_state,
56-
InitialContextInjection::DoNotInject => Arc::new(
57-
sess.build_world_state_for_environments(&turn_context, &turn_context.environments)
58-
.await,
59-
),
57+
InitialContextInjection::DoNotInject => {
58+
Arc::new(sess.build_world_state_for_step(&step_context).await)
59+
}
6060
};
61-
run_compact_task_inner(&sess, &turn_context, world_state, CompactionTrigger::Auto).await
61+
run_compact_task_inner(&sess, turn_context, world_state, CompactionTrigger::Auto).await
6262
}
6363

6464
async fn run_compact_task_inner(
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use super::WorldStateSection;
2+
use crate::agents_md::LoadedAgentsMd;
3+
use crate::context::ContextualUserFragment;
4+
use crate::context::UserInstructions;
5+
use serde::Deserialize;
6+
use serde::Serialize;
7+
8+
const REPLACEMENT_NOTICE: &str =
9+
"These AGENTS.md instructions replace all previously provided AGENTS.md instructions.";
10+
const REMOVAL_NOTICE: &str = "The previously provided AGENTS.md instructions no longer apply.";
11+
12+
/// The AGENTS.md instructions currently visible to the model.
13+
#[derive(Clone, Debug, Default)]
14+
pub(crate) struct AgentsMdState {
15+
instructions: Option<UserInstructions>,
16+
}
17+
18+
/// Persisted model-visible AGENTS.md state, without filesystem provenance.
19+
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
20+
pub(crate) struct AgentsMdSnapshot {
21+
directory: Option<String>,
22+
text: Option<String>,
23+
}
24+
25+
impl AgentsMdState {
26+
pub(crate) fn new(loaded: Option<&LoadedAgentsMd>) -> Self {
27+
Self {
28+
instructions: loaded.map(LoadedAgentsMd::contextual_user_fragment),
29+
}
30+
}
31+
}
32+
33+
impl WorldStateSection for AgentsMdState {
34+
const ID: &'static str = "agents_md";
35+
type Snapshot = AgentsMdSnapshot;
36+
37+
fn snapshot(&self) -> Self::Snapshot {
38+
match &self.instructions {
39+
Some(instructions) => AgentsMdSnapshot {
40+
directory: instructions.directory.clone(),
41+
text: Some(instructions.text.clone()),
42+
},
43+
None => AgentsMdSnapshot::default(),
44+
}
45+
}
46+
47+
fn render_diff(
48+
&self,
49+
previous: Option<&Self::Snapshot>,
50+
) -> Option<Box<dyn ContextualUserFragment>> {
51+
let current = self.snapshot();
52+
if previous == Some(&current) {
53+
return None;
54+
}
55+
56+
let previous_instructions = previous.and_then(|state| state.text.as_ref());
57+
let instructions = match (&self.instructions, previous_instructions) {
58+
(Some(instructions), Some(_)) => UserInstructions {
59+
directory: instructions.directory.clone(),
60+
text: format!("{REPLACEMENT_NOTICE}\n\n{}", instructions.text),
61+
},
62+
(Some(instructions), None) => instructions.clone(),
63+
(None, Some(_)) => UserInstructions {
64+
directory: None,
65+
text: REMOVAL_NOTICE.to_string(),
66+
},
67+
(None, None) => return None,
68+
};
69+
Some(Box::new(instructions))
70+
}
71+
}
72+
73+
#[cfg(test)]
74+
#[path = "agents_md_tests.rs"]
75+
mod tests;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
use super::*;
2+
use crate::context::world_state::WorldState;
3+
use codex_protocol::models::ContentItem;
4+
use codex_protocol::models::ResponseItem;
5+
use pretty_assertions::assert_eq;
6+
use serde_json::json;
7+
8+
#[test]
9+
fn renders_full_state_and_omits_unchanged_state() {
10+
let loaded = LoadedAgentsMd::from_text_for_testing("use the project formatter");
11+
let mut state = WorldState::default();
12+
state.add_section(AgentsMdState::new(Some(&loaded)));
13+
14+
assert_eq!(
15+
vec![user_message(
16+
"# AGENTS.md instructions\n\n<INSTRUCTIONS>\nuse the project formatter\n</INSTRUCTIONS>",
17+
)],
18+
render_fragments(state.render_full()),
19+
);
20+
assert_eq!(
21+
Vec::<ResponseItem>::new(),
22+
render_fragments(state.render_diff(&state.snapshot()))
23+
);
24+
assert_eq!(
25+
state.snapshot().into_value(),
26+
json!({"agents_md": {"text": "use the project formatter"}}),
27+
);
28+
}
29+
30+
#[test]
31+
fn changed_and_removed_state_supersedes_previous_instructions() {
32+
let previous_loaded = LoadedAgentsMd::from_text_for_testing("old instructions");
33+
let mut previous = WorldState::default();
34+
previous.add_section(AgentsMdState::new(Some(&previous_loaded)));
35+
36+
let current_loaded = LoadedAgentsMd::from_text_for_testing("new instructions");
37+
let mut current = WorldState::default();
38+
current.add_section(AgentsMdState::new(Some(&current_loaded)));
39+
assert_eq!(
40+
vec![user_message(
41+
"# AGENTS.md instructions\n\n<INSTRUCTIONS>\nThese AGENTS.md instructions replace all previously provided AGENTS.md instructions.\n\nnew instructions\n</INSTRUCTIONS>",
42+
)],
43+
render_fragments(current.render_diff(&previous.snapshot())),
44+
);
45+
46+
let mut removed = WorldState::default();
47+
removed.add_section(AgentsMdState::default());
48+
assert_eq!(
49+
vec![user_message(
50+
"# AGENTS.md instructions\n\n<INSTRUCTIONS>\nThe previously provided AGENTS.md instructions no longer apply.\n</INSTRUCTIONS>",
51+
)],
52+
render_fragments(removed.render_diff(&current.snapshot())),
53+
);
54+
}
55+
56+
fn render_fragments(fragments: Vec<Box<dyn ContextualUserFragment>>) -> Vec<ResponseItem> {
57+
fragments
58+
.into_iter()
59+
.map(ContextualUserFragment::into_boxed_response_item)
60+
.collect()
61+
}
62+
63+
fn user_message(text: &str) -> ResponseItem {
64+
ResponseItem::Message {
65+
id: None,
66+
role: "user".to_string(),
67+
content: vec![ContentItem::InputText {
68+
text: text.to_string(),
69+
}],
70+
phase: None,
71+
internal_chat_message_metadata_passthrough: None,
72+
}
73+
}

codex-rs/core/src/context/world_state/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod agents_md;
12
mod environment;
23

34
use crate::context::ContextualUserFragment;
@@ -9,6 +10,7 @@ use serde_json::Value;
910
use std::collections::BTreeMap;
1011
use std::fmt;
1112

13+
pub(crate) use agents_md::AgentsMdState;
1214
pub(crate) use environment::EnvironmentsState;
1315

1416
trait ErasedWorldStateSection: Send + Sync {

0 commit comments

Comments
 (0)