From 878255aa6d5758944c3887d26da70caaf64a6205 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Thu, 4 Jun 2026 15:42:43 -0700 Subject: [PATCH] Hoist AGENTS.md loading out of core sessions --- codex-rs/app-server/src/request_processors.rs | 2 ++ .../external_agent_config_processor.rs | 6 +++- .../request_processors/thread_processor.rs | 23 ++++++++++-- .../src/request_processors/turn_processor.rs | 8 ++++- .../request_processors/user_instructions.rs | 35 +++++++++++++++++++ codex-rs/cli/src/main.rs | 21 ++++++++++- codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/src/agents_md.rs | 9 +++-- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/environment_selection.rs | 1 + codex-rs/core/src/session/mod.rs | 14 ++------ codex-rs/core/src/thread_manager.rs | 1 + codex-rs/core/src/thread_manager_tests.rs | 29 +++++++++++++++ codex-rs/core/tests/common/test_codex.rs | 8 ++++- codex-rs/mcp-server/src/codex_tool_runner.rs | 15 ++++++-- codex-rs/memories/write/src/runtime.rs | 17 ++++++++- codex-rs/thread-manager-sample/src/main.rs | 13 ++++++- 17 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 codex-rs/app-server/src/request_processors/user_instructions.rs diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 0e94528a7a..d07373421d 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -474,6 +474,7 @@ mod search; mod thread_processor; mod token_usage_replay; mod turn_processor; +mod user_instructions; mod windows_sandbox_processor; pub(crate) use account_processor::AccountRequestProcessor; @@ -508,6 +509,7 @@ use crate::thread_state::ThreadState; use crate::thread_state::ThreadStateManager; use token_usage_replay::latest_token_usage_turn_id_from_rollout_items; use token_usage_replay::send_thread_token_usage_update_to_connection; +use user_instructions::load_thread_user_instructions; mod config_errors; mod request_errors; diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 87956182d0..cae5a93600 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -42,6 +42,7 @@ use std::path::PathBuf; use tokio::sync::Semaphore; use super::ConfigRequestProcessor; +use super::load_thread_user_instructions; #[derive(Clone)] pub(crate) struct ExternalAgentConfigRequestProcessor { @@ -283,7 +284,7 @@ impl ExternalAgentConfigRequestProcessor { title, rollout_items, } = session; - let config = self + let mut config = self .config_manager .load_with_overrides( /*request_overrides*/ None, @@ -301,6 +302,9 @@ impl ExternalAgentConfigRequestProcessor { let environments = self .thread_manager .default_environment_selections(&config.cwd); + load_thread_user_instructions(self.thread_manager.as_ref(), &mut config, &environments) + .await + .map_err(|err| internal_error(format!("failed to load user instructions: {err}")))?; let imported_thread = self .thread_manager .start_thread_with_options(StartThreadOptions { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index efff8e7cd8..f169d656e5 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1042,6 +1042,13 @@ impl ThreadRequestProcessor { .thread_manager .default_environment_selections(&config.cwd) }); + load_thread_user_instructions( + listener_task_context.thread_manager.as_ref(), + &mut config, + &environments, + ) + .await + .map_err(|err| invalid_request(err.to_string()))?; let dynamic_tools = dynamic_tools.unwrap_or_default(); let core_dynamic_tools = if dynamic_tools.is_empty() { Vec::new() @@ -2505,7 +2512,7 @@ impl ThreadRequestProcessor { .await; // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match self + let mut config = match self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await @@ -2517,6 +2524,12 @@ impl ThreadRequestProcessor { return Ok(()); } }; + let environments = self + .thread_manager + .default_environment_selections(&config.cwd); + load_thread_user_instructions(self.thread_manager.as_ref(), &mut config, &environments) + .await + .map_err(|err| invalid_request(err.to_string()))?; let response_history = thread_history.clone(); @@ -3217,11 +3230,17 @@ impl ThreadRequestProcessor { ); typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = self + let mut config = self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await .map_err(|err| config_load_error(&err))?; + let environments = self + .thread_manager + .default_environment_selections(&config.cwd); + load_thread_user_instructions(self.thread_manager.as_ref(), &mut config, &environments) + .await + .map_err(|err| invalid_request(err.to_string()))?; let fallback_model_provider = config.model_provider_id.clone(); diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 8ab6cf7731..0549d6d5fd 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1080,6 +1080,12 @@ impl TurnRequestProcessor { if let Some(review_model) = &config.review_model { config.model = Some(review_model.clone()); } + let environments = self + .thread_manager + .default_environment_selections(&config.cwd); + load_thread_user_instructions(self.thread_manager.as_ref(), &mut config, &environments) + .await + .map_err(|err| invalid_request(err.to_string()))?; let NewThread { thread_id, @@ -1089,7 +1095,7 @@ impl TurnRequestProcessor { .thread_manager .fork_thread_from_history( ForkSnapshot::Interrupted, - config.clone(), + config, InitialHistory::Resumed(ResumedHistory { conversation_id: parent_thread_id, history: parent_history.items, diff --git a/codex-rs/app-server/src/request_processors/user_instructions.rs b/codex-rs/app-server/src/request_processors/user_instructions.rs new file mode 100644 index 0000000000..fe17821528 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/user_instructions.rs @@ -0,0 +1,35 @@ +use codex_core::AgentsMdManager; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::protocol::TurnEnvironmentSelection; + +/// Loads AGENTS.md instructions from the primary turn environment into +/// `config` before the app server passes it to the thread manager. +pub(super) async fn load_thread_user_instructions( + thread_manager: &ThreadManager, + config: &mut Config, + environments: &[TurnEnvironmentSelection], +) -> CodexResult<()> { + let Some(primary_selection) = environments.first() else { + config.user_instructions = None; + return Ok(()); + }; + let environment = thread_manager + .environment_manager() + .get_environment(&primary_selection.environment_id) + .ok_or_else(|| { + CodexErr::InvalidRequest(format!( + "unknown turn environment id `{}`", + primary_selection.environment_id + )) + })?; + let mut warnings = Vec::new(); + let user_instructions = AgentsMdManager::new(config) + .load_user_instructions(environment.as_ref(), &mut warnings) + .await; + config.startup_warnings.extend(warnings); + config.user_instructions = user_instructions; + Ok(()) +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2a5ce171b7..cc45242764 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1846,12 +1846,31 @@ async fn run_debug_prompt_input_command( additional_writable_roots: shared.add_dir, ..Default::default() }; - let config = ConfigBuilder::default() + let mut config = ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) .loader_overrides(loader_overrides) .build() .await?; + let runtime_paths = codex_exec_server::ExecServerRuntimePaths::from_optional_paths( + config.codex_self_exe.clone(), + config.codex_linux_sandbox_exe.clone(), + )?; + let environment_manager = codex_exec_server::EnvironmentManager::from_codex_home( + config.codex_home.clone(), + Some(runtime_paths), + ) + .await?; + let mut warnings = Vec::new(); + config.user_instructions = match environment_manager.default_environment() { + Some(environment) => { + codex_core::AgentsMdManager::new(&config) + .load_user_instructions(environment.as_ref(), &mut warnings) + .await + } + None => None, + }; + config.startup_warnings.extend(warnings); let mut input = shared .images diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index d1c7c7a18b..d691e6fc2d 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -24,6 +24,7 @@ pub use codex_config::types::TuiKeymap; pub use codex_config::types::TuiNotificationSettings; pub use codex_config::types::TuiPetAnchor; pub use codex_config::types::UriBasedFileOpener; +pub use codex_core::AgentsMdManager; pub use codex_core::CodexThread; pub use codex_core::ForkSnapshot; pub use codex_core::LoadedAgentsMd; diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 5739e42f30..5defd2c696 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -80,9 +80,12 @@ impl<'a> AgentsMdManager<'a> { None } - /// Combines configured user instructions and AGENTS.md content into a - /// single model-visible instruction string. - pub(crate) async fn user_instructions( + /// Loads project instructions from `environment` and combines them with + /// the configured user instructions. + /// + /// Callers should invoke this before constructing a thread and provide the + /// returned value through [`Config::user_instructions`]. + pub async fn load_user_instructions( &self, environment: &Environment, startup_warnings: &mut Vec, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b74f2feefe..9001834155 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -645,7 +645,7 @@ pub struct Config { /// Defaults to `false`. pub show_raw_agent_reasoning: bool, - /// User-provided instructions from AGENTS.md. + /// Instructions preloaded from AGENTS.md. pub user_instructions: Option, /// Base instructions override. diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 479132925c..494f7cca5b 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -41,6 +41,7 @@ impl ResolvedTurnEnvironments { self.turn_environments.first() } + #[cfg(test)] pub(crate) fn primary_environment(&self) -> Option> { self.primary() .map(|environment| Arc::clone(&environment.environment)) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9aeca1991b..8b0961ee19 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -285,7 +285,6 @@ use crate::SkillLoadOutcome; #[cfg(test)] use crate::SkillMetadata; use crate::SkillsManager; -use crate::agents_md::AgentsMdManager; use crate::context::UserInstructions; use crate::exec_policy::ExecPolicyUpdateError; use crate::guardian::GuardianReviewSessionManager; @@ -474,7 +473,7 @@ impl Codex { async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult { let CodexSpawnArgs { - mut config, + config, installation_id, auth_manager, models_manager, @@ -505,16 +504,7 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - let primary_environment = environment_selections.primary_environment(); - let mut user_instruction_warnings = Vec::new(); - let user_instructions = if let Some(primary_environment) = primary_environment { - AgentsMdManager::new(&config) - .user_instructions(primary_environment.as_ref(), &mut user_instruction_warnings) - .await - } else { - None - }; - config.startup_warnings.extend(user_instruction_warnings); + let user_instructions = config.user_instructions.clone(); let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { // Guardian review should rely on the built-in shell safety checks, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d130a35815..2ffd41a451 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -552,6 +552,7 @@ impl ThreadManager { Ok(subtree_thread_ids) } + /// Starts a thread using the provided config. pub async fn start_thread(&self, config: Config) -> CodexResult { // Box delegated thread-spawn futures so these convenience wrappers do // not inline the full spawn path into every caller's async state. diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 52a0f8eb3b..e5b4bed4ee 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -293,6 +293,35 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { assert!(manager.list_thread_ids().await.is_empty()); } +#[tokio::test] +async fn start_thread_does_not_load_user_instructions_from_filesystem() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = temp_dir.path().join("workspace").abs(); + config.user_instructions = None; + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + std::fs::create_dir_all(&config.cwd).expect("create workspace"); + // This file should be ignored because its contents were not preloaded into + // the config passed to the thread manager. + std::fs::write(config.cwd.join("AGENTS.md"), "workspace instructions") + .expect("write AGENTS.md"); + + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + + let thread = manager + .start_thread(config) + .await + .expect("thread should start"); + + assert_eq!(thread.thread.instruction_sources().await, Vec::new()); +} + #[tokio::test] async fn start_thread_rejects_explicit_local_environment_when_default_provider_is_disabled() { let temp_dir = tempdir().expect("tempdir"); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 4fe0609a71..1efecd47ce 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -13,6 +13,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use codex_config::CloudConfigBundleLoader; +use codex_core::AgentsMdManager; use codex_core::CodexThread; use codex_core::ThreadManager; use codex_core::config::Config; @@ -457,13 +458,18 @@ impl TestCodexBuilder { async fn build_from_config( &mut self, - config: Config, + mut config: Config, cwd: Arc, home: Arc, resume_from: Option, test_env: TestEnv, environment_manager: Arc, ) -> anyhow::Result { + let mut warnings = Vec::new(); + config.user_instructions = AgentsMdManager::new(&config) + .load_user_instructions(test_env.environment(), &mut warnings) + .await; + config.startup_warnings.extend(warnings); let auth = self.auth.clone(); let state_db = codex_core::init_state_db(&config).await; let thread_store = thread_store_from_config(&config, state_db.clone()); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 05b995e721..36cbef6f06 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -9,6 +9,7 @@ use crate::exec_approval::handle_exec_approval_request; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotificationMeta; use crate::patch_approval::handle_patch_approval_request; +use codex_core::AgentsMdManager; use codex_core::CodexThread; use codex_core::NewThread; use codex_core::ThreadManager; @@ -57,16 +58,26 @@ pub(crate) fn create_call_tool_result_with_thread_id( pub async fn run_codex_tool_session( id: RequestId, initial_prompt: String, - config: CodexConfig, + mut config: CodexConfig, outgoing: Arc, thread_manager: Arc, running_requests_id_to_codex_uuid: Arc>>, ) { + let mut warnings = Vec::new(); + config.user_instructions = match thread_manager.environment_manager().default_environment() { + Some(environment) => { + AgentsMdManager::new(&config) + .load_user_instructions(environment.as_ref(), &mut warnings) + .await + } + None => None, + }; + config.startup_warnings.extend(warnings); let NewThread { thread_id, thread, session_configured, - } = match thread_manager.start_thread(config.clone()).await { + } = match thread_manager.start_thread(config).await { Ok(res) => res, Err(e) => { let result = CallToolResult::error(vec![Content::text(format!( diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 58bdc9dfc4..0c5534c1a2 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -1,3 +1,4 @@ +use codex_core::AgentsMdManager; use codex_core::CodexThread; use codex_core::ModelClient; use codex_core::NewThread; @@ -230,12 +231,26 @@ impl MemoryStartupContext { pub(crate) async fn spawn_consolidation_agent( &self, - config: Config, + mut config: Config, prompt: Vec, ) -> anyhow::Result { let environments = self .thread_manager .default_environment_selections(&config.cwd); + let mut warnings = Vec::new(); + config.user_instructions = match environments.first().and_then(|selection| { + self.thread_manager + .environment_manager() + .get_environment(&selection.environment_id) + }) { + Some(environment) => { + AgentsMdManager::new(&config) + .load_user_instructions(environment.as_ref(), &mut warnings) + .await + } + None => None, + }; + config.startup_warnings.extend(warnings); let NewThread { thread_id, thread, .. } = self diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 47e0fc3911..975b5b434b 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -9,6 +9,7 @@ use anyhow::Context; use anyhow::bail; use clap::Parser; use codex_core_api::AbsolutePathBuf; +use codex_core_api::AgentsMdManager; use codex_core_api::AltScreenMode; use codex_core_api::ApprovalsReviewer; use codex_core_api::Arg0DispatchPaths; @@ -105,7 +106,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { args.prompt.join(" ") }; - let config = new_config(args.model, arg0_paths)?; + let mut config = new_config(args.model, arg0_paths)?; let state_db = init_state_db(&config).await; let auth_manager = @@ -120,6 +121,16 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?, ); let installation_id = resolve_installation_id(&config.codex_home).await?; + let mut warnings = Vec::new(); + config.user_instructions = match environment_manager.default_environment() { + Some(environment) => { + AgentsMdManager::new(&config) + .load_user_instructions(environment.as_ref(), &mut warnings) + .await + } + None => None, + }; + config.startup_warnings.extend(warnings); let thread_manager = ThreadManager::new( &config, auth_manager,