Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions codex-rs/core/src/agents_md.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
//! 3. We do **not** walk past the project root.

use crate::config::Config;
use crate::context::ContextualUserFragment;
use crate::context::UserInstructions as ContextUserInstructions;
use crate::environment_selection::TurnEnvironmentSnapshot;
use codex_config::ConfigLayerSource;
Expand Down Expand Up @@ -378,8 +377,7 @@ impl LoadedAgentsMd {
output
}

/// Returns the complete model-visible contextual user fragment.
pub(crate) fn render(&self) -> String {
pub(crate) fn contextual_user_fragment(&self) -> ContextUserInstructions {
// One contributing project environment retains the legacy cwd wrapper. With two or more,
// the body labels every contributing environment itself, so the outer cwd is omitted.
let directory = if self.has_multiple_project_environments() {
Expand All @@ -392,12 +390,6 @@ impl LoadedAgentsMd {
directory,
text: self.text(),
}
.render()
}

/// Returns the host-provided user instructions.
pub(crate) fn user_instructions(&self) -> Option<&UserInstructions> {
self.user_instructions.as_ref()
}

/// Returns the AGENTS.md files that supplied instruction entries.
Expand Down
53 changes: 53 additions & 0 deletions codex-rs/core/src/agents_md_manager.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use crate::agents_md::LoadedAgentsMd;
use crate::agents_md::load_project_instructions;
use crate::config::Config;
use crate::environment_selection::TurnEnvironmentSnapshot;
use codex_extension_api::UserInstructions;
use codex_protocol::protocol::TurnEnvironmentSelection;
use std::sync::Arc;
use tokio::sync::Mutex;

/// Owns the inputs and cached result of AGENTS.md discovery for a session.
pub(crate) struct AgentsMdManager {
user_instructions: Option<UserInstructions>,
cache: Mutex<AgentsMdCache>,
}

#[derive(Default)]
struct AgentsMdCache {
selections: Option<Vec<TurnEnvironmentSelection>>,
loaded: Option<Arc<LoadedAgentsMd>>,
}

impl AgentsMdManager {
pub(crate) fn new(user_instructions: Option<UserInstructions>) -> Self {
Self {
user_instructions: user_instructions
.filter(|instructions| !instructions.text.trim().is_empty()),
cache: Mutex::new(AgentsMdCache::default()),
}
}

pub(crate) async fn refresh(&self, config: &Config, environments: &TurnEnvironmentSnapshot) {
let selections = environments.to_selections();
if self.cache.lock().await.selections.as_ref() == Some(&selections) {
return;
}

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.loaded = loaded;
Comment thread
sayan-oai marked this conversation as resolved.
}

pub(crate) async fn get_loaded(&self) -> Option<Arc<LoadedAgentsMd>> {
self.cache.lock().await.loaded.clone()
}

pub(crate) fn user_instructions(&self) -> Option<UserInstructions> {
self.user_instructions.clone()
}
}
21 changes: 15 additions & 6 deletions codex-rs/core/src/agents_md_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use super::*;
use crate::config::ConfigBuilder;
use crate::context::ContextualUserFragment;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::session::turn_context::TurnEnvironment;
use codex_config::ConfigLayerEntry;
Expand Down Expand Up @@ -345,7 +346,7 @@ fn foreign_agents_md_uses_environment_native_paths() {
};

assert_eq!(
loaded.render(),
loaded.contextual_user_fragment().render(),
format!(
"# AGENTS.md instructions for {rendered_cwd}

Expand Down Expand Up @@ -388,7 +389,7 @@ fn multi_environment_agents_md_renders_mixed_path_conventions() {
};

assert_eq!(
loaded.render(),
loaded.contextual_user_fragment().render(),
r#"# AGENTS.md instructions

<INSTRUCTIONS>
Expand Down Expand Up @@ -931,7 +932,10 @@ secondary doc"#,
{inner}
</INSTRUCTIONS>"#
);
assert_eq!(loaded.render(), expected_fragment);
assert_eq!(
loaded.contextual_user_fragment().render(),
expected_fragment
);
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![
Expand Down Expand Up @@ -972,7 +976,10 @@ async fn secondary_only_project_doc_uses_single_contributor_layout() {
"# AGENTS.md instructions for {}\n\n<INSTRUCTIONS>\n{inner}\n</INSTRUCTIONS>",
secondary.path().display()
);
assert_eq!(loaded.render(), expected_fragment);
assert_eq!(
loaded.contextual_user_fragment().render(),
expected_fragment
);
}

#[tokio::test]
Expand All @@ -998,7 +1005,10 @@ async fn primary_only_project_doc_preserves_legacy_layout_with_multiple_bound_en
"# AGENTS.md instructions for {}\n\n<INSTRUCTIONS>\n{inner}\n</INSTRUCTIONS>",
primary.path().display()
);
assert_eq!(loaded.render(), expected_fragment);
assert_eq!(
loaded.contextual_user_fragment().render(),
expected_fragment
);
}

#[tokio::test]
Expand Down Expand Up @@ -1276,7 +1286,6 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
}],
};
assert_eq!(loaded, expected);
assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref());
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![
Expand Down
8 changes: 7 additions & 1 deletion codex-rs/core/src/codex_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,9 +466,15 @@ impl CodexThread {

let turn_context = self.codex.session.new_default_turn().await;
if self.codex.session.reference_context_item().await.is_none() {
// This history-only API runs without run_turn, so it owns its initial step.
let step_context = self
.codex
.session
.capture_step_context(Arc::clone(&turn_context))
.await;
self.codex
.session
.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.record_context_updates_and_set_reference_context_item(step_context.as_ref())
.await;
}
self.codex
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/compact_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ async fn process_compacted_history_with_test_session(
previous_turn_settings: Option<&PreviousTurnSettings>,
) -> (Vec<ResponseItem>, Vec<ResponseItem>) {
let (session, turn_context) = crate::session::tests::make_session_and_context().await;
let turn_context = Arc::new(turn_context);
session
.set_previous_turn_settings(previous_turn_settings.cloned())
.await;
Expand Down
20 changes: 10 additions & 10 deletions codex-rs/core/src/compact_token_budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::hook_runtime::PreCompactHookOutcome;
use crate::hook_runtime::run_post_compact_hooks;
use crate::hook_runtime::run_pre_compact_hooks;
use crate::session::session::Session;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnContext;
use codex_analytics::CompactionTrigger;
use codex_protocol::error::CodexErr;
Expand Down Expand Up @@ -34,10 +35,9 @@ pub(crate) async fn run_manual_compact_task(
});
sess.send_event(&turn_context, start_event).await;

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

Expand All @@ -48,17 +48,17 @@ pub(crate) async fn run_manual_compact_task(
/// observe the same lifecycle as local or remote compaction.
pub(crate) async fn run_inline_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
step_context: Arc<StepContext>,
initial_context_injection: InitialContextInjection,
) -> CodexResult<()> {
let turn_context = &step_context.turn;
let world_state = match initial_context_injection {
InitialContextInjection::BeforeLastUserMessage(world_state) => world_state,
InitialContextInjection::DoNotInject => Arc::new(
sess.build_world_state_for_environments(&turn_context, &turn_context.environments)
.await,
),
InitialContextInjection::DoNotInject => {
Arc::new(sess.build_world_state_for_step(&step_context).await)
}
};
run_compact_task_inner(&sess, &turn_context, world_state, CompactionTrigger::Auto).await
run_compact_task_inner(&sess, turn_context, world_state, CompactionTrigger::Auto).await
}

async fn run_compact_task_inner(
Expand Down
75 changes: 75 additions & 0 deletions codex-rs/core/src/context/world_state/agents_md.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use super::WorldStateSection;
use crate::agents_md::LoadedAgentsMd;
use crate::context::ContextualUserFragment;
use crate::context::UserInstructions;
use serde::Deserialize;
use serde::Serialize;

const REPLACEMENT_NOTICE: &str =
"These AGENTS.md instructions replace all previously provided AGENTS.md instructions.";
const REMOVAL_NOTICE: &str = "The previously provided AGENTS.md instructions no longer apply.";

/// The AGENTS.md instructions currently visible to the model.
#[derive(Clone, Debug, Default)]
pub(crate) struct AgentsMdState {
instructions: Option<UserInstructions>,
}

/// Persisted model-visible AGENTS.md state, without filesystem provenance.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub(crate) struct AgentsMdSnapshot {
directory: Option<String>,
text: Option<String>,
}

impl AgentsMdState {
pub(crate) fn new(loaded: Option<&LoadedAgentsMd>) -> Self {
Self {
instructions: loaded.map(LoadedAgentsMd::contextual_user_fragment),
}
}
}

impl WorldStateSection for AgentsMdState {
const ID: &'static str = "agents_md";
type Snapshot = AgentsMdSnapshot;

fn snapshot(&self) -> Self::Snapshot {
match &self.instructions {
Some(instructions) => AgentsMdSnapshot {
directory: instructions.directory.clone(),
text: Some(instructions.text.clone()),
},
None => AgentsMdSnapshot::default(),
}
}

fn render_diff(
&self,
previous: Option<&Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
let current = self.snapshot();
if previous == Some(&current) {
return None;
}

let previous_instructions = previous.and_then(|state| state.text.as_ref());
let instructions = match (&self.instructions, previous_instructions) {
(Some(instructions), Some(_)) => UserInstructions {
directory: instructions.directory.clone(),
text: format!("{REPLACEMENT_NOTICE}\n\n{}", instructions.text),
Comment thread
sayan-oai marked this conversation as resolved.
},
(Some(instructions), None) => instructions.clone(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Seed AGENTS.md baselines when resuming legacy rollouts

When resuming a rollout written before this change, the prompt history already contains the old AGENTS.md contextual user message, but the persisted WorldState has no agents_md section. On the first resumed turn previous is therefore None for this section, so this branch emits another full AGENTS.md item instead of treating the legacy history as the baseline, duplicating instructions for existing saved threads. Please migrate/seed the agents_md snapshot during reconstruction or suppress this first diff for legacy rollouts.

AGENTS.md reference: AGENTS.md:L102-L110

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coming in stacked pr

(None, Some(_)) => UserInstructions {
directory: None,
text: REMOVAL_NOTICE.to_string(),
},
(None, None) => return None,
};
Some(Box::new(instructions))
}
}

#[cfg(test)]
#[path = "agents_md_tests.rs"]
mod tests;
73 changes: 73 additions & 0 deletions codex-rs/core/src/context/world_state/agents_md_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use super::*;
use crate::context::world_state::WorldState;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
use serde_json::json;

#[test]
fn renders_full_state_and_omits_unchanged_state() {
let loaded = LoadedAgentsMd::from_text_for_testing("use the project formatter");
let mut state = WorldState::default();
state.add_section(AgentsMdState::new(Some(&loaded)));

assert_eq!(
vec![user_message(
"# AGENTS.md instructions\n\n<INSTRUCTIONS>\nuse the project formatter\n</INSTRUCTIONS>",
)],
render_fragments(state.render_full()),
);
assert_eq!(
Vec::<ResponseItem>::new(),
render_fragments(state.render_diff(&state.snapshot()))
);
assert_eq!(
state.snapshot().into_value(),
json!({"agents_md": {"text": "use the project formatter"}}),
);
}

#[test]
fn changed_and_removed_state_supersedes_previous_instructions() {
let previous_loaded = LoadedAgentsMd::from_text_for_testing("old instructions");
let mut previous = WorldState::default();
previous.add_section(AgentsMdState::new(Some(&previous_loaded)));

let current_loaded = LoadedAgentsMd::from_text_for_testing("new instructions");
let mut current = WorldState::default();
current.add_section(AgentsMdState::new(Some(&current_loaded)));
assert_eq!(
vec![user_message(
"# AGENTS.md instructions\n\n<INSTRUCTIONS>\nThese AGENTS.md instructions replace all previously provided AGENTS.md instructions.\n\nnew instructions\n</INSTRUCTIONS>",
)],
render_fragments(current.render_diff(&previous.snapshot())),
);

let mut removed = WorldState::default();
removed.add_section(AgentsMdState::default());
assert_eq!(
vec![user_message(
"# AGENTS.md instructions\n\n<INSTRUCTIONS>\nThe previously provided AGENTS.md instructions no longer apply.\n</INSTRUCTIONS>",
)],
render_fragments(removed.render_diff(&current.snapshot())),
);
}

fn render_fragments(fragments: Vec<Box<dyn ContextualUserFragment>>) -> Vec<ResponseItem> {
fragments
.into_iter()
.map(ContextualUserFragment::into_boxed_response_item)
.collect()
}

fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
}
2 changes: 2 additions & 0 deletions codex-rs/core/src/context/world_state/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod agents_md;
mod environment;

use crate::context::ContextualUserFragment;
Expand All @@ -9,6 +10,7 @@ use serde_json::Value;
use std::collections::BTreeMap;
use std::fmt;

pub(crate) use agents_md::AgentsMdState;
pub(crate) use environment::EnvironmentsState;

trait ErasedWorldStateSection: Send + Sync {
Expand Down
Loading
Loading