diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d22127877a24..a8f7f3f1b3c1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1979,6 +1979,7 @@ dependencies = [ "codex-git-utils", "codex-goal-extension", "codex-guardian", + "codex-home", "codex-hooks", "codex-image-generation-extension", "codex-login", @@ -2321,6 +2322,7 @@ dependencies = [ "codex-execpolicy", "codex-features", "codex-git-utils", + "codex-home", "codex-install-context", "codex-login", "codex-mcp", @@ -2604,6 +2606,7 @@ dependencies = [ "codex-features", "codex-feedback", "codex-git-utils", + "codex-home", "codex-hooks", "codex-image-generation-extension", "codex-install-context", @@ -2708,6 +2711,7 @@ dependencies = [ "codex-exec-server", "codex-extension-api", "codex-features", + "codex-home", "codex-login", "codex-model-provider-info", "codex-models-manager", @@ -2931,6 +2935,7 @@ dependencies = [ "codex-context-fragments", "codex-protocol", "codex-tools", + "codex-utils-absolute-path", "pretty_assertions", "tokio", ] @@ -3081,6 +3086,17 @@ dependencies = [ "codex-protocol", ] +[[package]] +name = "codex-home" +version = "0.0.0" +dependencies = [ + "codex-extension-api", + "codex-utils-absolute-path", + "pretty_assertions", + "tempfile", + "tokio", +] + [[package]] name = "codex-hooks" version = "0.0.0" @@ -3282,6 +3298,7 @@ dependencies = [ "codex-core", "codex-exec-server", "codex-extension-api", + "codex-home", "codex-login", "codex-protocol", "codex-shell-command", @@ -4595,6 +4612,7 @@ dependencies = [ "codex-exec-server", "codex-extension-api", "codex-features", + "codex-home", "codex-hooks", "codex-login", "codex-model-provider-info", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ad4ebf7a792f..dab5e82855e8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -21,6 +21,7 @@ members = [ "install-context", "codex-backend-openapi-models", "code-mode", + "codex-home", "cloud-config", "cloud-tasks", "cloud-tasks-client", @@ -157,6 +158,7 @@ codex-cloud-config = { path = "cloud-config" } codex-cloud-tasks-client = { path = "cloud-tasks-client" } codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } codex-code-mode = { path = "code-mode" } +codex-home = { path = "codex-home" } codex-config = { path = "config" } codex-connectors = { path = "connectors" } codex-context-fragments = { path = "context-fragments" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 442fb5df2914..756a39484147 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -36,6 +36,7 @@ codex-cloud-config = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } codex-core-plugins = { workspace = true } +codex-home = { workspace = true } codex-exec-server = { workspace = true } codex-extension-api = { workspace = true } codex-external-agent-migration = { workspace = true } diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index d11678ff5a9e..0fd63a5f0285 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -113,6 +113,7 @@ mod tests { use codex_core::thread_store_from_config; use codex_exec_server::EnvironmentManager; use codex_extension_api::NoopExtensionEventSink; + use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::protocol::SessionSource; @@ -205,6 +206,9 @@ mod tests { thread_store: Arc::clone(&thread_store), }, ), + Arc::new(CodexHomeUserInstructionsProvider::new( + good_config.codex_home.clone(), + )), /*analytics_events_client*/ None, Arc::clone(&thread_store), Some(state_db.clone()), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6ba59e733fe8..10bbccd9e4df 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -72,6 +72,7 @@ use codex_core::config::Config; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_goal_extension::GoalService; +use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; use codex_login::auth::ExternalAuth; use codex_login::auth::ExternalAuthRefreshContext; @@ -339,6 +340,9 @@ impl MessageProcessor { thread_store: Arc::clone(&thread_store), }, ), + Arc::new(CodexHomeUserInstructionsProvider::new( + config.codex_home.clone(), + )), Some(analytics_events_client.clone()), Arc::clone(&thread_store), state_db.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 6366c6fbcaa5..82a2b6b2c070 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::PathBufExt; @@ -21,6 +22,8 @@ use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_config::loader::project_trust_key; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; @@ -400,11 +403,13 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re } #[tokio::test] -async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> { +async fn thread_start_without_selected_environment_includes_only_global_instruction_source() +-> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?; + let global_agents_path = codex_home.path().join("AGENTS.md"); + std::fs::write(&global_agents_path, "global instructions")?; let workspace = TempDir::new()?; std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?; @@ -424,11 +429,56 @@ async fn thread_start_without_selected_environment_excludes_instruction_sources( ) .await??; let ThreadStartResponse { + thread, instruction_sources, .. } = to_response::(response)?; - assert!(instruction_sources.is_empty()); + assert_eq!( + instruction_sources + .into_iter() + .map(normalize_path_for_comparison) + .collect::>(), + vec![normalize_path_for_comparison(std::fs::canonicalize( + global_agents_path, + )?)] + ); + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "inspect instructions".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let model_request = requests + .iter() + .find(|request| request.url.path().ends_with("/responses")) + .context("expected model request")?; + let model_request_body = model_request + .body_json::() + .context("model request body should be JSON")? + .to_string(); + assert!(model_request_body.contains("global instructions")); + assert!(!model_request_body.contains("project instructions")); Ok(()) } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 6a4d78fbc74c..727f367502ef 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -33,6 +33,7 @@ codex-utils-cli = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } codex-core-plugins = { workspace = true } +codex-home = { workspace = true } codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 235096f37ce7..8b2c02ad1908 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -41,6 +41,7 @@ use std::collections::HashSet; use std::io::IsTerminal; use std::io::Write; use std::path::PathBuf; +use std::sync::Arc; use supports_color::Stream; #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -75,6 +76,7 @@ use codex_core::config::resolve_profile_v2_config_path; use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; +use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::read_codex_access_token_from_env; @@ -1941,7 +1943,16 @@ async fn run_debug_prompt_input_command( }); } - let prompt_input = codex_core::build_prompt_input(config, input, /*state_db*/ None).await?; + let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( + config.codex_home.clone(), + )); + let prompt_input = codex_core::build_prompt_input( + config, + input, + /*state_db*/ None, + user_instructions_provider, + ) + .await?; println!("{}", serde_json::to_string_pretty(&prompt_input)?); Ok(()) diff --git a/codex-rs/codex-home/BUILD.bazel b/codex-rs/codex-home/BUILD.bazel new file mode 100644 index 000000000000..a5a01e4e34fd --- /dev/null +++ b/codex-rs/codex-home/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "codex-home", + crate_name = "codex_home", +) diff --git a/codex-rs/codex-home/Cargo.toml b/codex-rs/codex-home/Cargo.toml new file mode 100644 index 000000000000..a8fa625dcfc1 --- /dev/null +++ b/codex-rs/codex-home/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-home" +version.workspace = true + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-extension-api = { workspace = true } +codex-utils-absolute-path = { workspace = true } +tokio = { workspace = true, features = ["fs"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/codex-rs/codex-home/src/instructions/mod.rs b/codex-rs/codex-home/src/instructions/mod.rs new file mode 100644 index 000000000000..0840263a13f9 --- /dev/null +++ b/codex-rs/codex-home/src/instructions/mod.rs @@ -0,0 +1,83 @@ +use std::io; + +use codex_extension_api::LoadUserInstructionsFuture; +use codex_extension_api::LoadedUserInstructions; +use codex_extension_api::UserInstructions; +use codex_extension_api::UserInstructionsProvider; +use codex_utils_absolute_path::AbsolutePathBuf; + +const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; +const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; + +/// Loads user instructions from a Codex home directory. +#[derive(Clone, Debug)] +pub struct CodexHomeUserInstructionsProvider { + codex_home: AbsolutePathBuf, +} + +impl CodexHomeUserInstructionsProvider { + /// Creates a provider rooted at the supplied absolute Codex home directory. + pub fn new(codex_home: AbsolutePathBuf) -> Self { + Self { codex_home } + } + + async fn load_from_codex_home(&self) -> LoadedUserInstructions { + let mut warnings = Vec::new(); + for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { + let path = self.codex_home.join(candidate); + match tokio::fs::metadata(path.as_path()).await { + Ok(metadata) if !metadata.is_file() => continue, + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + warnings.push(format!( + "Failed to read global AGENTS.md instructions from `{}`: {err}", + path.display() + )); + continue; + } + } + let data = match tokio::fs::read(path.as_path()).await { + Ok(data) => data, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + warnings.push(format!( + "Failed to read global AGENTS.md instructions from `{}`: {err}", + path.display() + )); + continue; + } + }; + if let Err(err) = std::str::from_utf8(&data) { + warnings.push(format!( + "Global AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.", + path.display() + )); + } + let contents = String::from_utf8_lossy(&data); + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return LoadedUserInstructions { + instructions: Some(UserInstructions { + text: trimmed.to_string(), + source: path, + }), + warnings, + }; + } + } + LoadedUserInstructions { + instructions: None, + warnings, + } + } +} + +impl UserInstructionsProvider for CodexHomeUserInstructionsProvider { + fn load_user_instructions(&self) -> LoadUserInstructionsFuture<'_> { + Box::pin(self.load_from_codex_home()) + } +} + +#[cfg(test)] +mod tests; diff --git a/codex-rs/codex-home/src/instructions/tests.rs b/codex-rs/codex-home/src/instructions/tests.rs new file mode 100644 index 000000000000..e88421040320 --- /dev/null +++ b/codex-rs/codex-home/src/instructions/tests.rs @@ -0,0 +1,152 @@ +use std::fs; +use std::path::Path; + +use codex_extension_api::LoadedUserInstructions; +use codex_extension_api::UserInstructions; +use codex_extension_api::UserInstructionsProvider; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +use super::CodexHomeUserInstructionsProvider; +use super::DEFAULT_AGENTS_MD_FILENAME; +use super::LOCAL_AGENTS_MD_FILENAME; + +fn provider(home: &TempDir) -> CodexHomeUserInstructionsProvider { + CodexHomeUserInstructionsProvider::new( + AbsolutePathBuf::try_from(home.path().to_path_buf()).expect("absolute temp dir"), + ) +} + +fn expected( + home: &TempDir, + filename: &str, + text: &str, + warnings: Vec, +) -> LoadedUserInstructions { + LoadedUserInstructions { + instructions: Some(UserInstructions { + text: text.to_string(), + source: AbsolutePathBuf::try_from(home.path().join(filename)) + .expect("absolute source path"), + }), + warnings, + } +} + +#[cfg(unix)] +fn create_symlink_loop(path: &Path) { + std::os::unix::fs::symlink( + path.file_name().expect("override path should have a name"), + path, + ) + .expect("create symlink loop"); +} + +#[cfg(windows)] +fn create_symlink_loop(path: &Path) { + std::os::windows::fs::symlink_file( + path.file_name().expect("override path should have a name"), + path, + ) + .expect("create symlink loop"); +} + +#[tokio::test] +async fn missing_files_return_no_instructions() { + let home = TempDir::new().expect("temp dir"); + + assert_eq!( + provider(&home).load_user_instructions().await, + LoadedUserInstructions::default() + ); +} + +#[tokio::test] +async fn override_takes_precedence_over_default() { + let home = TempDir::new().expect("temp dir"); + fs::write(home.path().join(DEFAULT_AGENTS_MD_FILENAME), "default").expect("write default"); + fs::write(home.path().join(LOCAL_AGENTS_MD_FILENAME), "override").expect("write override"); + + assert_eq!( + provider(&home).load_user_instructions().await, + expected(&home, LOCAL_AGENTS_MD_FILENAME, "override", Vec::new()) + ); +} + +#[tokio::test] +async fn empty_override_falls_back_to_trimmed_default() { + let home = TempDir::new().expect("temp dir"); + fs::write(home.path().join(LOCAL_AGENTS_MD_FILENAME), " \n\t").expect("write override"); + fs::write( + home.path().join(DEFAULT_AGENTS_MD_FILENAME), + "\n default instructions \n", + ) + .expect("write default"); + + assert_eq!( + provider(&home).load_user_instructions().await, + expected( + &home, + DEFAULT_AGENTS_MD_FILENAME, + "default instructions", + Vec::new() + ) + ); +} + +#[tokio::test] +async fn directory_override_falls_back_to_default() { + let home = TempDir::new().expect("temp dir"); + fs::create_dir(home.path().join(LOCAL_AGENTS_MD_FILENAME)).expect("create override directory"); + fs::write(home.path().join(DEFAULT_AGENTS_MD_FILENAME), "default").expect("write default"); + + assert_eq!( + provider(&home).load_user_instructions().await, + expected(&home, DEFAULT_AGENTS_MD_FILENAME, "default", Vec::new()) + ); +} + +#[tokio::test] +async fn recoverable_override_read_error_warns_and_falls_back_to_default() { + let home = TempDir::new().expect("temp dir"); + let override_path = home.path().join(LOCAL_AGENTS_MD_FILENAME); + create_symlink_loop(&override_path); + fs::write(home.path().join(DEFAULT_AGENTS_MD_FILENAME), "default").expect("write default"); + let read_error = fs::read(&override_path).expect_err("symlink loop should not be readable"); + let warning = format!( + "Failed to read global AGENTS.md instructions from `{}`: {read_error}", + override_path.display() + ); + + assert_eq!( + provider(&home).load_user_instructions().await, + expected(&home, DEFAULT_AGENTS_MD_FILENAME, "default", vec![warning]) + ); +} + +#[tokio::test] +async fn invalid_utf8_is_lossy_and_warned() { + let home = TempDir::new().expect("temp dir"); + let path = home.path().join(DEFAULT_AGENTS_MD_FILENAME); + let mut invalid_utf8 = b"global".to_vec(); + invalid_utf8.push(0xff); + invalid_utf8.extend_from_slice(b" doc"); + fs::write(&path, &invalid_utf8).expect("write invalid utf-8"); + + let outcome = provider(&home).load_user_instructions().await; + let utf8_error = std::str::from_utf8(&invalid_utf8).expect_err("invalid utf-8"); + let warning = format!( + "Global AGENTS.md instructions from `{}` contain invalid UTF-8: {utf8_error}. Invalid byte sequences were replaced.", + path.display(), + ); + assert_eq!( + outcome, + expected( + &home, + DEFAULT_AGENTS_MD_FILENAME, + "global\u{fffd} doc", + vec![warning] + ) + ); +} diff --git a/codex-rs/codex-home/src/lib.rs b/codex-rs/codex-home/src/lib.rs new file mode 100644 index 000000000000..7ca5e5807258 --- /dev/null +++ b/codex-rs/codex-home/src/lib.rs @@ -0,0 +1,3 @@ +mod instructions; + +pub use instructions::CodexHomeUserInstructionsProvider; diff --git a/codex-rs/core-api/Cargo.toml b/codex-rs/core-api/Cargo.toml index 998da39ce7b3..f4c89e5640cc 100644 --- a/codex-rs/core-api/Cargo.toml +++ b/codex-rs/core-api/Cargo.toml @@ -20,6 +20,7 @@ codex-analytics = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } +codex-home = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 47452eb03249..74fe9d5a0cdd 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -48,9 +48,14 @@ pub use codex_core::skills::SkillsManager; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; pub use codex_exec_server::ExecServerRuntimePaths; +pub use codex_extension_api::LoadUserInstructionsFuture; +pub use codex_extension_api::LoadedUserInstructions; +pub use codex_extension_api::UserInstructions; +pub use codex_extension_api::UserInstructionsProvider; pub use codex_extension_api::empty_extension_registry; pub use codex_features::Feature; pub use codex_features::Features; +pub use codex_home::CodexHomeUserInstructionsProvider; pub use codex_login::AuthManager; pub use codex_login::default_client::set_default_originator; pub use codex_model_provider_info::OPENAI_PROVIDER_ID; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 60678db5dc2f..5174395e3438 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -135,6 +135,7 @@ codex-shell-escalation = { workspace = true } assert_cmd = { workspace = true } assert_matches = { workspace = true } codex-image-generation-extension = { workspace = true } +codex-home = { workspace = true } codex-otel = { workspace = true } codex-test-binary-support = { workspace = true } codex-utils-cargo-bin = { workspace = true } diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index e60eacc55c67..95ff98f5158d 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -21,8 +21,8 @@ use codex_config::ConfigLayerStackOrdering; use codex_config::default_project_root_markers; use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; -use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; +use codex_extension_api::UserInstructions; use codex_features::Feature; use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; @@ -40,291 +40,220 @@ pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; /// concatenated with the following separator. const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; -/// Resolves AGENTS.md files into model-visible user instructions and source -/// paths. -pub struct AgentsMdManager<'a> { - config: &'a Config, -} - -impl<'a> AgentsMdManager<'a> { - pub fn new(config: &'a Config) -> Self { - Self { config } - } - - pub(crate) async fn load_global_instructions( - fs: &dyn ExecutorFileSystem, - codex_dir: Option<&AbsolutePathBuf>, - startup_warnings: &mut Vec, - ) -> Option { - let base = codex_dir?; - for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { - let path = base.join(candidate); - // A missing global instructions file is normal, but an unrepresentable - // configured path means Codex cannot honor the workspace configuration. - let path_uri = match PathUri::from_abs_path(&path) { - Ok(path_uri) => path_uri, - Err(err) => { - startup_warnings.push(format!( - "Failed to read global AGENTS.md instructions from `{}`: {err}", - path.display() - )); - continue; - } - }; - let data = match fs.read_file(&path_uri, /*sandbox*/ None).await { - Ok(data) => data, - Err(err) if err.kind() == io::ErrorKind::NotFound => continue, - Err(err) if err.kind() == io::ErrorKind::IsADirectory => continue, - Err(err) => { - startup_warnings.push(format!( - "Failed to read global AGENTS.md instructions from `{}`: {err}", - path.display() - )); - continue; - } - }; - warn_invalid_utf8(&path, &data, "Global", startup_warnings); - let contents = String::from_utf8_lossy(&data); - let trimmed = contents.trim(); - if !trimmed.is_empty() { - return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path)); - } - } - None - } - - /// Combines configured user instructions and AGENTS.md content into a - /// single model-visible instruction string. - pub(crate) async fn user_instructions( - &self, - environment: &Environment, - startup_warnings: &mut Vec, - ) -> Option { - let fs = environment.get_filesystem(); - self.user_instructions_with_fs(fs.as_ref(), startup_warnings) - .await - } - - async fn user_instructions_with_fs( - &self, - fs: &dyn ExecutorFileSystem, - startup_warnings: &mut Vec, - ) -> Option { - let agents_md_docs = self.read_agents_md(fs, startup_warnings).await; - - let mut loaded = self.config.user_instructions.clone().unwrap_or_default(); - - match agents_md_docs { +/// Loads project AGENTS.md content and combines it with host-provided user +/// instructions. +pub(crate) async fn load_project_instructions( + config: &mut Config, + user_instructions: Option, + fs: Option<&dyn ExecutorFileSystem>, +) -> Option { + let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); + if let Some(fs) = fs { + match read_agents_md(config, fs).await { Ok(Some(docs)) => loaded.entries.extend(docs.entries), Ok(None) => {} Err(e) => { error!("error trying to find AGENTS.md docs: {e:#}"); } - }; - - if self.config.features.enabled(Feature::ChildAgentsMd) { - loaded.entries.push(InstructionEntry { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - provenance: InstructionProvenance::Internal, - }); } - - (!loaded.is_empty()).then_some(loaded) } - /// Attempt to locate and load AGENTS.md documentation. - /// - /// On success returns `Ok(Some(loaded))` where `loaded` contains every - /// discovered doc. If no documentation file is found the function returns - /// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can - /// decide how to handle them. - async fn read_agents_md( - &self, - fs: &dyn ExecutorFileSystem, - startup_warnings: &mut Vec, - ) -> io::Result> { - let max_total = self.config.project_doc_max_bytes; - - if max_total == 0 { - return Ok(None); - } + if config.features.enabled(Feature::ChildAgentsMd) { + loaded.entries.push(InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }); + } - let paths = self.agents_md_paths(fs).await?; - if paths.is_empty() { - return Ok(None); - } + (!loaded.is_empty()).then_some(loaded) +} - let mut remaining: u64 = max_total as u64; - let mut loaded = LoadedAgentsMd::default(); +/// Attempt to locate and load AGENTS.md documentation. +/// +/// On success returns `Ok(Some(loaded))` where `loaded` contains every +/// discovered doc. If no documentation file is found the function returns +/// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can +/// decide how to handle them. +async fn read_agents_md( + config: &mut Config, + fs: &dyn ExecutorFileSystem, +) -> io::Result> { + let max_total = config.project_doc_max_bytes; + + if max_total == 0 { + return Ok(None); + } - for p in paths { - if remaining == 0 { - break; - } + let paths = agents_md_paths(config, fs).await?; + if paths.is_empty() { + return Ok(None); + } - let path_uri = PathUri::from_abs_path(&p)?; - match fs.get_metadata(&path_uri, /*sandbox*/ None).await { - Ok(metadata) if !metadata.is_file => continue, - Ok(_) => {} - Err(err) if err.kind() == io::ErrorKind::NotFound => continue, - Err(err) => return Err(err), - } + let mut remaining: u64 = max_total as u64; + let mut loaded = LoadedAgentsMd::default(); - let mut data = match fs.read_file(&path_uri, /*sandbox*/ None).await { - Ok(data) => data, - Err(err) if err.kind() == io::ErrorKind::NotFound => continue, - Err(err) => return Err(err), - }; - warn_invalid_utf8(&p, &data, "Project", startup_warnings); + for p in paths { + if remaining == 0 { + break; + } - let size = data.len() as u64; - if size > remaining { - data.truncate(remaining as usize); - } + let path_uri = PathUri::from_abs_path(&p)?; + match fs.get_metadata(&path_uri, /*sandbox*/ None).await { + Ok(metadata) if !metadata.is_file => continue, + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), + } - if size > remaining { - tracing::warn!( - "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", - p.display(), - remaining, - ); - } + let mut data = match fs.read_file(&path_uri, /*sandbox*/ None).await { + Ok(data) => data, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), + }; + warn_invalid_utf8(&p, &data, "Project", &mut config.startup_warnings); - let text = String::from_utf8_lossy(&data).to_string(); - if !text.trim().is_empty() { - loaded.entries.push(InstructionEntry { - contents: text, - provenance: InstructionProvenance::Project(p), - }); - remaining = remaining.saturating_sub(data.len() as u64); - } + let size = data.len() as u64; + if size > remaining { + data.truncate(remaining as usize); } - if loaded.is_empty() { - Ok(None) - } else { - Ok(Some(loaded)) + if size > remaining { + tracing::warn!( + "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", + p.display(), + remaining, + ); } - } - /// Discover the list of AGENTS.md files using the same search rules as - /// `read_agents_md`, but return the file paths instead of concatenated - /// contents. The list is ordered from project root to the current working - /// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes` - /// is zero, returns an empty list. - async fn agents_md_paths( - &self, - fs: &dyn ExecutorFileSystem, - ) -> io::Result> { - if self.config.project_doc_max_bytes == 0 { - return Ok(Vec::new()); + let text = String::from_utf8_lossy(&data).to_string(); + if !text.trim().is_empty() { + loaded.entries.push(InstructionEntry { + contents: text, + provenance: InstructionProvenance::Project(p), + }); + remaining = remaining.saturating_sub(data.len() as u64); } + } - let dir = self.config.cwd.clone(); + if loaded.is_empty() { + Ok(None) + } else { + Ok(Some(loaded)) + } +} - let mut merged = TomlValue::Table(toml::map::Map::new()); - for layer in self.config.config_layer_stack.get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ false, - ) { - if matches!(layer.name, ConfigLayerSource::Project { .. }) { - continue; - } - merge_toml_values(&mut merged, &layer.config); +/// Discovers AGENTS.md files from the project root to the current working +/// directory, inclusive. Symlinks are allowed. +async fn agents_md_paths( + config: &Config, + fs: &dyn ExecutorFileSystem, +) -> io::Result> { + let dir = config.cwd.clone(); + + let mut merged = TomlValue::Table(toml::map::Map::new()); + for layer in config.config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + if matches!(layer.name, ConfigLayerSource::Project { .. }) { + continue; } - let project_root_markers = match project_root_markers_from_config(&merged) { - Ok(Some(markers)) => markers, - Ok(None) => default_project_root_markers(), - Err(err) => { - tracing::warn!("invalid project_root_markers: {err}"); - default_project_root_markers() - } - }; - let mut project_root = None; - if !project_root_markers.is_empty() { - for ancestor in dir.ancestors() { - for marker in &project_root_markers { - let marker_path = ancestor.join(marker); - let marker_path_uri = PathUri::from_abs_path(&marker_path)?; - let marker_exists = - match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await { - Ok(_) => true, - Err(err) if err.kind() == io::ErrorKind::NotFound => false, - Err(err) => return Err(err), - }; - if marker_exists { - project_root = Some(ancestor.clone()); - break; - } - } - if project_root.is_some() { + merge_toml_values(&mut merged, &layer.config); + } + let project_root_markers = match project_root_markers_from_config(&merged) { + Ok(Some(markers)) => markers, + Ok(None) => default_project_root_markers(), + Err(err) => { + tracing::warn!("invalid project_root_markers: {err}"); + default_project_root_markers() + } + }; + let mut project_root = None; + if !project_root_markers.is_empty() { + for ancestor in dir.ancestors() { + for marker in &project_root_markers { + let marker_path = ancestor.join(marker); + let marker_path_uri = PathUri::from_abs_path(&marker_path)?; + let marker_exists = match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await + { + Ok(_) => true, + Err(err) if err.kind() == io::ErrorKind::NotFound => false, + Err(err) => return Err(err), + }; + if marker_exists { + project_root = Some(ancestor.clone()); break; } } + if project_root.is_some() { + break; + } } + } - let search_dirs: Vec = if let Some(root) = project_root { - let mut dirs = Vec::new(); - let mut cursor = dir.clone(); - loop { - dirs.push(cursor.clone()); - if cursor == root { - break; - } - let Some(parent) = cursor.parent() else { - break; - }; - cursor = parent; + let search_dirs: Vec = if let Some(root) = project_root { + let mut dirs = Vec::new(); + let mut cursor = dir.clone(); + loop { + dirs.push(cursor.clone()); + if cursor == root { + break; } - dirs.reverse(); - dirs - } else { - vec![dir] - }; - - let mut found: Vec = Vec::new(); - let candidate_filenames = self.candidate_filenames(); - for d in search_dirs { - for name in &candidate_filenames { - let candidate = d.join(name); - let candidate_uri = PathUri::from_abs_path(&candidate)?; - match fs.get_metadata(&candidate_uri, /*sandbox*/ None).await { - Ok(md) if md.is_file => { - found.push(candidate); - break; - } - Ok(_) => {} - Err(err) if err.kind() == io::ErrorKind::NotFound => continue, - Err(err) => return Err(err), + let Some(parent) = cursor.parent() else { + break; + }; + cursor = parent; + } + dirs.reverse(); + dirs + } else { + vec![dir] + }; + + let mut found: Vec = Vec::new(); + let candidate_filenames = candidate_filenames(config); + for d in search_dirs { + for name in &candidate_filenames { + let candidate = d.join(name); + let candidate_uri = PathUri::from_abs_path(&candidate)?; + match fs.get_metadata(&candidate_uri, /*sandbox*/ None).await { + Ok(md) if md.is_file => { + found.push(candidate); + break; } + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), } } - - Ok(found) } - fn candidate_filenames(&self) -> Vec<&str> { - let mut names: Vec<&str> = - Vec::with_capacity(2 + self.config.project_doc_fallback_filenames.len()); - names.push(LOCAL_AGENTS_MD_FILENAME); - names.push(DEFAULT_AGENTS_MD_FILENAME); - for candidate in &self.config.project_doc_fallback_filenames { - let candidate = candidate.as_str(); - if candidate.is_empty() { - continue; - } - if !names.contains(&candidate) { - names.push(candidate); - } + Ok(found) +} + +fn candidate_filenames(config: &Config) -> Vec<&str> { + let mut names: Vec<&str> = Vec::with_capacity(2 + config.project_doc_fallback_filenames.len()); + names.push(LOCAL_AGENTS_MD_FILENAME); + names.push(DEFAULT_AGENTS_MD_FILENAME); + for candidate in &config.project_doc_fallback_filenames { + let candidate = candidate.as_str(); + if candidate.is_empty() { + continue; + } + if !names.contains(&candidate) { + names.push(candidate); } - names } + names } /// Model-visible instructions loaded from AGENTS.md files and internal /// guidance. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LoadedAgentsMd { + /// Host-provided user instructions. + user_instructions: Option, + /// Ordered instructions and their provenance. entries: Vec, } @@ -336,10 +265,19 @@ impl LoadedAgentsMd { return Self::default(); } Self { - entries: vec![InstructionEntry { - contents, - provenance: InstructionProvenance::User(path), - }], + user_instructions: Some(UserInstructions { + text: contents, + source: path, + }), + entries: Vec::new(), + } + } + + fn from_user_instructions(user_instructions: Option) -> Self { + Self { + user_instructions: user_instructions + .filter(|instructions| !instructions.text.trim().is_empty()), + entries: Vec::new(), } } @@ -353,6 +291,7 @@ impl LoadedAgentsMd { return Self::default(); } Self { + user_instructions: None, entries: vec![InstructionEntry { contents, provenance: InstructionProvenance::Internal, @@ -361,40 +300,57 @@ impl LoadedAgentsMd { } fn is_empty(&self) -> bool { - self.entries - .iter() - .all(|entry| entry.contents.trim().is_empty()) + self.user_instructions.is_none() + && self + .entries + .iter() + .all(|entry| entry.contents.trim().is_empty()) } /// Returns the concatenated model-visible instruction text. pub fn text(&self) -> String { let mut output = String::new(); - let mut previous_provenance: Option<&InstructionProvenance> = None; + let mut has_previous = false; + let mut previous_was_project = false; + if let Some(instructions) = &self.user_instructions { + output.push_str(&instructions.text); + has_previous = true; + } for entry in &self.entries { - if let Some(previous_provenance) = previous_provenance { + let is_project = matches!(&entry.provenance, InstructionProvenance::Project(_)); + if has_previous { // The project-doc marker tells the model where workspace-scoped // instructions begin, so it is only needed on the transition // from user or internal instructions to project instructions. - let separator = match (previous_provenance, &entry.provenance) { - ( - InstructionProvenance::User(_) | InstructionProvenance::Internal, - InstructionProvenance::Project(_), - ) => AGENTS_MD_SEPARATOR, - _ => "\n\n", + let separator = if is_project && !previous_was_project { + AGENTS_MD_SEPARATOR + } else { + "\n\n" }; output.push_str(separator); } output.push_str(&entry.contents); - previous_provenance = Some(&entry.provenance); + has_previous = true; + previous_was_project = is_project; } output } + /// 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. pub fn sources(&self) -> impl Iterator { - self.entries + self.user_instructions .iter() - .filter_map(|entry| entry.provenance.path()) + .map(|instructions| &instructions.source) + .chain( + self.entries + .iter() + .filter_map(|entry| entry.provenance.path()), + ) } } @@ -410,9 +366,6 @@ struct InstructionEntry { #[derive(Clone, Debug, PartialEq, Eq)] enum InstructionProvenance { - /// User-level instructions, normally loaded from CODEX_HOME. - User(AbsolutePathBuf), - /// Workspace instructions discovered from project AGENTS.md files. Project(AbsolutePathBuf), @@ -423,7 +376,7 @@ enum InstructionProvenance { impl InstructionProvenance { fn path(&self) -> Option<&AbsolutePathBuf> { match self { - Self::User(path) | Self::Project(path) => Some(path), + Self::Project(path) => Some(path), Self::Internal => None, } } diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index d8298dfd7dcb..3788a0f6dc98 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -1,29 +1,172 @@ use super::*; use crate::config::ConfigBuilder; +use async_trait::async_trait; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; +use codex_exec_server::CopyOptions; +use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::FileMetadata; +use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::LOCAL_FS; +use codex_exec_server::ReadDirectoryEntry; +use codex_exec_server::RemoveOptions; +use codex_extension_api::UserInstructions; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use core_test_support::PathBufExt; use core_test_support::TempDirExt; use core_test_support::create_directory_symlink; use pretty_assertions::assert_eq; use std::fs; +use std::io; +use std::ops::Deref; +use std::ops::DerefMut; use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; -async fn get_user_instructions(config: &Config) -> Option { +#[derive(Clone, Copy)] +enum InjectedFailure { + Metadata(io::ErrorKind), + Read(io::ErrorKind), +} + +struct FailingFileSystem { + path: AbsolutePathBuf, + failure: InjectedFailure, +} + +#[async_trait] +impl ExecutorFileSystem for FailingFileSystem { + async fn canonicalize( + &self, + _path: &PathUri, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> io::Result { + unreachable!("canonicalize should not be called") + } + + async fn read_file( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> io::Result> { + if path.to_abs_path()? == self.path + && let InjectedFailure::Read(kind) = self.failure + { + return Err(io::Error::new(kind, "injected read failure")); + } + LOCAL_FS.read_file(path, sandbox).await + } + + async fn write_file( + &self, + _path: &PathUri, + _contents: Vec, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> io::Result<()> { + unreachable!("write_file should not be called") + } + + async fn create_directory( + &self, + _path: &PathUri, + _create_directory_options: CreateDirectoryOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> io::Result<()> { + unreachable!("create_directory should not be called") + } + + async fn get_metadata( + &self, + path: &PathUri, + sandbox: Option<&FileSystemSandboxContext>, + ) -> io::Result { + if path.to_abs_path()? == self.path + && let InjectedFailure::Metadata(kind) = self.failure + { + return Err(io::Error::new(kind, "injected metadata failure")); + } + LOCAL_FS.get_metadata(path, sandbox).await + } + + async fn read_directory( + &self, + _path: &PathUri, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> io::Result> { + unreachable!("read_directory should not be called") + } + + async fn remove( + &self, + _path: &PathUri, + _remove_options: RemoveOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> io::Result<()> { + unreachable!("remove should not be called") + } + + async fn copy( + &self, + _source_path: &PathUri, + _destination_path: &PathUri, + _copy_options: CopyOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> io::Result<()> { + unreachable!("copy should not be called") + } +} + +struct TestConfig { + config: Config, + user_instructions: Option, +} + +impl Deref for TestConfig { + type Target = Config; + + fn deref(&self) -> &Self::Target { + &self.config + } +} + +impl DerefMut for TestConfig { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.config + } +} + +async fn get_user_instructions(config: &TestConfig) -> Option { let mut warnings = Vec::new(); - AgentsMdManager::new(config) - .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + load_agents_md(config, &mut warnings) .await .map(|loaded| loaded.text()) } -async fn agents_md_paths(config: &Config) -> std::io::Result> { - AgentsMdManager::new(config) - .agents_md_paths(LOCAL_FS.as_ref()) - .await +async fn load_agents_md(config: &TestConfig, warnings: &mut Vec) -> Option { + let mut core_config = config.config.clone(); + let existing_warning_count = core_config.startup_warnings.len(); + let loaded = load_project_instructions( + &mut core_config, + config.user_instructions.clone(), + Some(LOCAL_FS.as_ref()), + ) + .await; + warnings.extend( + core_config + .startup_warnings + .into_iter() + .skip(existing_warning_count), + ); + loaded +} + +async fn agents_md_paths(config: &TestConfig) -> std::io::Result> { + super::agents_md_paths(&config.config, LOCAL_FS.as_ref()).await } fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) { @@ -44,7 +187,7 @@ fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) { /// optionally specify a custom `instructions` string – when `None` the /// value is cleared to mimic a scenario where no system instructions have /// been configured. -async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> Config { +async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> TestConfig { let codex_home = TempDir::new().unwrap(); let mut config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) @@ -55,13 +198,14 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(|text| { - LoadedAgentsMd::new_user( - text.to_owned(), - config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), - ) + let user_instructions = instructions.map(|text| UserInstructions { + text: text.to_owned(), + source: config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), }); - config + TestConfig { + config, + user_instructions, + } } async fn make_config_with_fallback( @@ -69,7 +213,7 @@ async fn make_config_with_fallback( limit: usize, instructions: Option<&str>, fallbacks: &[&str], -) -> Config { +) -> TestConfig { let mut config = make_config(root, limit, instructions).await; config.project_doc_fallback_filenames = fallbacks .iter() @@ -83,7 +227,7 @@ async fn make_config_with_project_root_markers( limit: usize, instructions: Option<&str>, markers: &[&str], -) -> Config { +) -> TestConfig { let codex_home = TempDir::new().unwrap(); let cli_overrides = vec![( "project_root_markers".to_string(), @@ -103,13 +247,14 @@ async fn make_config_with_project_root_markers( config.cwd = root.abs(); config.project_doc_max_bytes = limit; - config.user_instructions = instructions.map(|text| { - LoadedAgentsMd::new_user( - text.to_owned(), - config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), - ) + let user_instructions = instructions.map(|text| UserInstructions { + text: text.to_owned(), + source: config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME), }); - config + TestConfig { + config, + user_instructions, + } } /// AGENTS.md missing – should yield `None`. @@ -153,12 +298,14 @@ fn empty_loaded_instructions_are_empty() { #[test] fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() { let empty = LoadedAgentsMd { + user_instructions: None, entries: vec![InstructionEntry { contents: String::new(), provenance: InstructionProvenance::Internal, }], }; let whitespace = LoadedAgentsMd { + user_instructions: None, entries: vec![InstructionEntry { contents: " \n\t".to_string(), provenance: InstructionProvenance::Internal, @@ -186,29 +333,6 @@ async fn doc_smaller_than_limit_is_returned() { ); } -#[tokio::test] -async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let codex_home_abs = codex_home.abs(); - let path = codex_home_abs.join(DEFAULT_AGENTS_MD_FILENAME); - fs::write(&path, b"global\xFF doc").unwrap(); - - let mut warnings = Vec::new(); - let loaded = AgentsMdManager::load_global_instructions( - LOCAL_FS.as_ref(), - Some(&codex_home_abs), - &mut warnings, - ) - .await - .expect("global doc expected"); - - assert_eq!( - loaded, - LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone()) - ); - assert_invalid_utf8_warning(&warnings, "Global", path.as_path()); -} - #[tokio::test] async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -217,8 +341,7 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { let config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; let mut warnings = Vec::new(); - let res = AgentsMdManager::new(&config) - .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + let res = load_agents_md(&config, &mut warnings) .await .expect("doc expected") .text(); @@ -244,6 +367,92 @@ async fn doc_larger_than_limit_is_truncated() { assert_eq!(res, huge[..LIMIT]); } +#[tokio::test] +async fn total_byte_limit_truncates_later_project_docs() { + let repo = tempfile::tempdir().expect("tempdir"); + fs::write(repo.path().join(".git"), "").unwrap(); + fs::write(repo.path().join("AGENTS.md"), "root").unwrap(); + let nested = repo.path().join("nested"); + fs::create_dir(&nested).unwrap(); + fs::write(nested.join("AGENTS.md"), "abcdef").unwrap(); + + let mut config = make_config(&repo, /*limit*/ 7, /*instructions*/ None).await; + config.cwd = nested.abs(); + + let mut warnings = Vec::new(); + let loaded = load_agents_md(&config, &mut warnings) + .await + .expect("project instructions"); + let expected = LoadedAgentsMd { + user_instructions: None, + entries: vec![ + InstructionEntry { + contents: "root".to_string(), + provenance: InstructionProvenance::Project(repo.path().join("AGENTS.md").abs()), + }, + InstructionEntry { + contents: "abc".to_string(), + provenance: InstructionProvenance::Project(config.cwd.join("AGENTS.md")), + }, + ], + }; + + assert_eq!(loaded, expected); + assert_eq!(loaded.text(), "root\n\nabc"); + assert_eq!(warnings, Vec::::new()); +} + +#[tokio::test] +async fn read_agents_md_propagates_metadata_errors() { + let tmp = tempfile::tempdir().expect("tempdir"); + let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + let marker_path = config.cwd.join(".git"); + let fs = FailingFileSystem { + path: marker_path, + failure: InjectedFailure::Metadata(io::ErrorKind::PermissionDenied), + }; + + let err = read_agents_md(&mut config.config, &fs) + .await + .expect_err("metadata error"); + + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); +} + +#[tokio::test] +async fn read_agents_md_propagates_read_errors() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + let fs = FailingFileSystem { + path: config.cwd.join("AGENTS.md"), + failure: InjectedFailure::Read(io::ErrorKind::PermissionDenied), + }; + + let err = read_agents_md(&mut config.config, &fs) + .await + .expect_err("read error"); + + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); +} + +#[tokio::test] +async fn read_agents_md_ignores_files_removed_after_discovery() { + let tmp = tempfile::tempdir().expect("tempdir"); + fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); + let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + let fs = FailingFileSystem { + path: config.cwd.join("AGENTS.md"), + failure: InjectedFailure::Read(io::ErrorKind::NotFound), + }; + + let loaded = read_agents_md(&mut config.config, &fs) + .await + .expect("removed file is recoverable"); + + assert_eq!(loaded, None); +} + /// When `cwd` is nested inside a repo, the search should locate AGENTS.md /// placed at the repository root (identified by `.git`). #[tokio::test] @@ -286,17 +495,6 @@ async fn zero_byte_limit_disables_docs() { ); } -#[tokio::test] -async fn zero_byte_limit_disables_discovery() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "something").unwrap(); - - let discovery = agents_md_paths(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await) - .await - .expect("discover paths"); - assert_eq!(discovery, Vec::::new()); -} - /// When both system instructions and AGENTS.md docs are present the two /// should be concatenated with the separator. #[tokio::test] @@ -315,30 +513,6 @@ async fn merges_existing_instructions_with_agents_md() { assert_eq!(res, expected); } -#[tokio::test] -async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() { - let tmp = tempfile::tempdir().expect("tempdir"); - fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap(); - - let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; - cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "user instructions".to_string(), - )); - - let mut warnings = Vec::new(); - let loaded = AgentsMdManager::new(&cfg) - .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) - .await - .expect("instructions expected"); - let project_agents = cfg.cwd.join("AGENTS.md"); - - assert_eq!( - loaded.text(), - format!("user instructions{AGENTS_MD_SEPARATOR}project doc") - ); - assert_eq!(loaded.sources().collect::>(), vec![&project_agents]); -} - /// If there are existing system instructions but AGENTS.md docs are /// missing we expect the original instructions to be returned unchanged. #[tokio::test] @@ -378,13 +552,13 @@ async fn concatenates_root_and_cwd_docs() { cfg.cwd = nested.abs(); let mut warnings = Vec::new(); - let loaded = AgentsMdManager::new(&cfg) - .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + let loaded = load_agents_md(&cfg, &mut warnings) .await .expect("doc expected"); let root_agents = repo.path().join("AGENTS.md").abs(); let crate_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { + user_instructions: None, entries: vec![ InstructionEntry { contents: "root doc".to_string(), @@ -435,6 +609,51 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn project_layers_do_not_override_project_root_markers() { + let root = tempfile::tempdir().expect("tempdir"); + fs::write(root.path().join(".git"), "").unwrap(); + fs::write(root.path().join("AGENTS.md"), "root doc").unwrap(); + let nested = root.path().join("nested"); + fs::create_dir(&nested).unwrap(); + fs::write(nested.join("AGENTS.md"), "nested doc").unwrap(); + + let mut config = make_config(&root, /*limit*/ 4096, /*instructions*/ None).await; + config.cwd = nested.abs(); + let project_layer = |dot_codex_folder: AbsolutePathBuf, marker: &str| { + ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + TomlValue::Table( + [( + "project_root_markers".to_string(), + TomlValue::Array(vec![TomlValue::String(marker.to_string())]), + )] + .into_iter() + .collect(), + ), + ) + }; + config.config_layer_stack = ConfigLayerStack::new( + vec![ + project_layer(root.path().join(".codex").abs(), ".ignored-root-marker"), + project_layer(config.cwd.join(".codex"), ".ignored-nested-marker"), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid project layer ordering"); + + let discovery = agents_md_paths(&config).await.expect("discover paths"); + + assert_eq!( + discovery, + vec![ + root.path().join("AGENTS.md").abs(), + config.cwd.join("AGENTS.md"), + ] + ); +} + #[tokio::test] async fn agents_md_paths_preserve_symlinked_cwd() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -462,22 +681,19 @@ async fn child_agents_message_after_global_instructions_uses_plain_separator() { cfg.features.enable(Feature::ChildAgentsMd).unwrap(); let mut warnings = Vec::new(); - let loaded = AgentsMdManager::new(&cfg) - .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + let loaded = load_agents_md(&cfg, &mut warnings) .await .expect("instructions expected"); let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME); let expected = LoadedAgentsMd { - entries: vec![ - InstructionEntry { - contents: "global doc".to_string(), - provenance: InstructionProvenance::User(global_agents), - }, - InstructionEntry { - contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), - provenance: InstructionProvenance::Internal, - }, - ], + user_instructions: Some(UserInstructions { + text: "global doc".to_string(), + source: global_agents, + }), + entries: vec![InstructionEntry { + contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(), + provenance: InstructionProvenance::Internal, + }], }; assert_eq!(loaded, expected); @@ -498,25 +714,23 @@ async fn instruction_sources_include_global_before_agents_md_docs() { fs::write(&global_agents, "global doc").unwrap(); let mut warnings = Vec::new(); - let loaded = AgentsMdManager::new(&cfg) - .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + let loaded = load_agents_md(&cfg, &mut warnings) .await .expect("instructions expected"); let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { - entries: vec![ - InstructionEntry { - contents: "global doc".to_string(), - provenance: InstructionProvenance::User(global_agents.clone()), - }, - InstructionEntry { - contents: "project doc".to_string(), - provenance: InstructionProvenance::Project(project_agents.clone()), - }, - ], + user_instructions: Some(UserInstructions { + text: "global doc".to_string(), + source: global_agents.clone(), + }), + entries: vec![InstructionEntry { + contents: "project doc".to_string(), + provenance: InstructionProvenance::Project(project_agents.clone()), + }], }; assert_eq!(loaded, expected); + assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref()); assert_eq!( loaded.sources().collect::>(), vec![&global_agents, &project_agents] @@ -539,18 +753,17 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source() fs::write(&global_agents, "global doc").unwrap(); let mut warnings = Vec::new(); - let loaded = AgentsMdManager::new(&cfg) - .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) + let loaded = load_agents_md(&cfg, &mut warnings) .await .expect("instructions expected"); let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { + user_instructions: Some(UserInstructions { + text: "global doc".to_string(), + source: global_agents.clone(), + }), entries: vec![ - InstructionEntry { - contents: "global doc".to_string(), - provenance: InstructionProvenance::User(global_agents.clone()), - }, InstructionEntry { contents: "project doc".to_string(), provenance: InstructionProvenance::Project(project_agents.clone()), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5b68ecf917cb..6ee03284639e 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -5,6 +5,7 @@ use async_channel::Receiver; use async_channel::Sender; use codex_analytics::GuardianApprovalRequestSource; use codex_async_utils::OrCancelExt; +use codex_extension_api::LoadedUserInstructions; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -79,8 +80,13 @@ pub(crate) async fn run_codex_thread_interactive( let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let conversation_history = initial_history.unwrap_or(InitialHistory::New); let forked_from_thread_id = conversation_history.forked_from_id(); + let user_instructions = LoadedUserInstructions { + instructions: parent_session.user_instructions().await, + warnings: Vec::new(), + }; let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, + user_instructions, installation_id: parent_session.installation_id.clone(), auth_manager, models_manager, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 58893f8c040d..ba266f70c516 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,5 +1,3 @@ -use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME; -use crate::agents_md::LOCAL_AGENTS_MD_FILENAME; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; @@ -205,61 +203,6 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { Ok(()) } -#[tokio::test] -async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { - let codex_home = tempdir()?; - let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME); - std::fs::write(&global_agents_path, "\n global instructions \n")?; - - let mut config = Config::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides::default(), - codex_home.abs(), - ) - .await?; - let _ = config.features.enable(Feature::MemoryTool); - - let user_instructions = config - .user_instructions - .as_ref() - .expect("global instructions expected"); - assert_eq!(user_instructions.text(), "global instructions"); - assert_eq!( - user_instructions.sources().collect::>(), - vec![&global_agents_path] - ); - Ok(()) -} - -#[tokio::test] -async fn load_config_prefers_global_agents_override_instructions() -> std::io::Result<()> { - let codex_home = tempdir()?; - std::fs::write( - codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME), - "global instructions", - )?; - let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME); - std::fs::write(&global_agents_override_path, "local override instructions")?; - - let config = Config::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides::default(), - codex_home.abs(), - ) - .await?; - - let user_instructions = config - .user_instructions - .as_ref() - .expect("global override instructions expected"); - assert_eq!(user_instructions.text(), "local override instructions"); - assert_eq!( - user_instructions.sources().collect::>(), - vec![&global_agents_override_path] - ); - Ok(()) -} - #[tokio::test] async fn test_toml_parsing() { let history_with_persistence = r#" diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 40895c39f4bc..bb7b0990e638 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,5 +1,3 @@ -use crate::agents_md::AgentsMdManager; -pub use crate::agents_md::LoadedAgentsMd; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::path_utils::normalize_for_native_workdir; @@ -654,9 +652,6 @@ pub struct Config { /// Defaults to `false`. pub show_raw_agent_reasoning: bool, - /// User-provided instructions from AGENTS.md. - pub user_instructions: Option, - /// Base instructions override. pub base_instructions: Option, @@ -2609,12 +2604,6 @@ impl Config { .startup_warnings() .unwrap_or_default() .to_vec(); - let user_instructions = AgentsMdManager::load_global_instructions( - LOCAL_FS.as_ref(), - Some(&codex_home), - &mut startup_warnings, - ) - .await; // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { @@ -3453,7 +3442,6 @@ impl Config { approvals_reviewer: constrained_approvals_reviewer.value(), enforce_residency: enforce_residency.value, notify: cfg.notify, - user_instructions, base_instructions, personality, developer_instructions, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 54282c1c0ade..63b778838e05 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -7,6 +7,7 @@ use std::time::Duration; use anyhow::anyhow; use codex_analytics::GuardianReviewAnalyticsResult; use codex_analytics::GuardianReviewSessionKind; +use codex_extension_api::UserInstructions; use codex_protocol::ThreadId; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::Personality; @@ -31,7 +32,6 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use tracing::warn; -use crate::LoadedAgentsMd; use crate::codex_delegate::run_codex_thread_interactive; use crate::config::Config; use crate::config::Constrained; @@ -152,7 +152,7 @@ struct GuardianReviewSessionReuseKey { permissions: Permissions, developer_instructions: Option, base_instructions: Option, - user_instructions: Option, + user_instructions: Option, compact_prompt: Option, cwd: AbsolutePathBuf, mcp_servers: Constrained>, @@ -164,7 +164,10 @@ struct GuardianReviewSessionReuseKey { } impl GuardianReviewSessionReuseKey { - fn from_spawn_config(spawn_config: &Config) -> Self { + fn from_spawn_config( + spawn_config: &Config, + user_instructions: Option, + ) -> Self { Self { model: spawn_config.model.clone(), model_provider_id: spawn_config.model_provider_id.clone(), @@ -177,7 +180,7 @@ impl GuardianReviewSessionReuseKey { permissions: spawn_config.permissions.clone(), developer_instructions: spawn_config.developer_instructions.clone(), base_instructions: spawn_config.base_instructions.clone(), - user_instructions: spawn_config.user_instructions.clone(), + user_instructions, compact_prompt: spawn_config.compact_prompt.clone(), cwd: spawn_config.cwd.clone(), mcp_servers: spawn_config.mcp_servers.clone(), @@ -318,7 +321,10 @@ impl GuardianReviewSessionManager { params: GuardianReviewSessionParams, ) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) { let deadline = params.deadline; - let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); + let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + ¶ms.spawn_config, + params.parent_session.user_instructions().await, + ); let mut stale_trunk_to_shutdown = None; let mut spawned_trunk = false; let trunk_candidate = match run_before_review_deadline( @@ -441,6 +447,7 @@ impl GuardianReviewSessionManager { pub(crate) async fn cache_for_test(&self, codex: Codex) { let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( codex.session.get_config().await.as_ref(), + codex.session.user_instructions().await, ); self.state.lock().await.trunk = Some(Arc::new(GuardianReviewSession { reuse_key, @@ -459,6 +466,7 @@ impl GuardianReviewSessionManager { pub(crate) async fn register_ephemeral_for_test(&self, codex: Codex) { let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( codex.session.get_config().await.as_ref(), + codex.session.user_instructions().await, ); self.state .lock() @@ -1075,8 +1083,10 @@ mod tests { let (tx_event, rx_event) = async_channel::unbounded(); let (_agent_status_tx, agent_status) = tokio::sync::watch::channel(AgentStatus::PendingInit); - let reuse_key = - GuardianReviewSessionReuseKey::from_spawn_config(session.get_config().await.as_ref()); + let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + session.get_config().await.as_ref(), + session.user_instructions().await, + ); ( GuardianReviewSession { @@ -1179,8 +1189,10 @@ mod tests { /*reasoning_effort*/ None, ) .expect("cached guardian config"); - let cached_reuse_key = - GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config); + let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + &cached_spawn_config, + /*user_instructions*/ None, + ); let mut changed_parent_config = parent_config; changed_parent_config.model_provider.base_url = @@ -1192,12 +1204,18 @@ mod tests { /*reasoning_effort*/ None, ) .expect("next guardian config"); - let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config); + let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + &next_spawn_config, + /*user_instructions*/ None, + ); assert_ne!(cached_reuse_key, next_reuse_key); assert_eq!( cached_reuse_key, - GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config) + GuardianReviewSessionReuseKey::from_spawn_config( + &cached_spawn_config, + /*user_instructions*/ None, + ) ); } @@ -1251,8 +1269,10 @@ mod tests { /*reasoning_effort*/ None, ) .expect("cached guardian config"); - let cached_reuse_key = - GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config); + let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + &cached_spawn_config, + /*user_instructions*/ None, + ); let mut changed_parent_config = parent_config; changed_parent_config.model_auto_compact_token_limit_scope = @@ -1264,7 +1284,10 @@ mod tests { /*reasoning_effort*/ None, ) .expect("next guardian config"); - let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config); + let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + &next_spawn_config, + /*user_instructions*/ None, + ); assert_ne!(cached_reuse_key, next_reuse_key); } @@ -1495,8 +1518,10 @@ mod tests { async fn run_review_removes_trunk_when_event_stream_is_broken() { let (mut review_session, tx_event, _rx_sub) = test_review_session().await; let params = test_review_params().await; - review_session.reuse_key = - GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config); + review_session.reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + ¶ms.spawn_config, + params.parent_session.user_instructions().await, + ); let manager = GuardianReviewSessionManager { state: Arc::new(Mutex::new(GuardianReviewSessionState { trunk: Some(Arc::new(review_session)), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 8f6e01298c5a..1698ea0c682f 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -187,7 +187,6 @@ async fn guardian_test_session_turn_and_rx( .thread_id = fixed_guardian_parent_session_id(); let mut config = (*turn.config).clone(); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); - config.user_instructions = None; let config = Arc::new(config); let models_manager = test_support::models_manager_with_provider( config.codex_home.to_path_buf(), @@ -225,7 +224,6 @@ async fn guardian_test_session_and_turn_with_base_url( session.thread_id = fixed_guardian_parent_session_id(); let mut config = (*turn.config).clone(); config.model_provider.base_url = Some(format!("{base_url}/v1")); - config.user_instructions = None; let config = Arc::new(config); let models_manager = test_support::models_manager_with_provider( config.codex_home.to_path_buf(), @@ -2049,7 +2047,6 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> crate::session::tests::make_session_and_context_with_rx().await; let mut config = (*turn.config).clone(); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); - config.user_instructions = None; let config = Arc::new(config); let models_manager = test_support::models_manager_with_provider( config.codex_home.to_path_buf(), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1ffd87fced97..775556e47a2f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -126,7 +126,6 @@ pub type NewConversation = NewThread; #[deprecated(note = "use CodexThread")] pub type CodexConversation = CodexThread; pub(crate) mod agents_md; -pub use agents_md::AgentsMdManager; pub use agents_md::DEFAULT_AGENTS_MD_FILENAME; pub use agents_md::LOCAL_AGENTS_MD_FILENAME; pub use agents_md::LoadedAgentsMd; diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 617521608fea..74fe19f38ebe 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; +use codex_extension_api::UserInstructionsProvider; use codex_login::AuthManager; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -26,6 +27,7 @@ pub async fn build_prompt_input( mut config: Config, input: Vec, state_db: Option, + user_instructions_provider: Arc, ) -> CodexResult> { config.ephemeral = true; @@ -52,6 +54,7 @@ pub async fn build_prompt_input( .map_err(|err| CodexErr::Fatal(err.to_string()))?, ), empty_extension_registry(), + user_instructions_provider, /*analytics_events_client*/ None, thread_store, state_db.clone(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4da2b58ac52f..e6a4d03dda77 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -14,6 +14,7 @@ use crate::agent::AgentControl; use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; use crate::agent::status::is_final; +use crate::agents_md::LoadedAgentsMd; use crate::attestation::AttestationProvider; use crate::build_available_skills; use crate::compact; @@ -54,6 +55,7 @@ use codex_exec_server::Environment; use codex_exec_server::EnvironmentManager; use codex_exec_server::FileSystemSandboxContext; use codex_extension_api::ExtensionDataInit; +use codex_extension_api::LoadedUserInstructions; use codex_extension_api::PromptSlot; use codex_features::FEATURES; use codex_features::Feature; @@ -291,7 +293,7 @@ use crate::SkillLoadOutcome; #[cfg(test)] use crate::SkillMetadata; use crate::SkillsManager; -use crate::agents_md::AgentsMdManager; +use crate::agents_md::load_project_instructions; use crate::context::UserInstructions; use crate::exec_policy::ExecPolicyUpdateError; use crate::guardian::GuardianReviewSessionManager; @@ -399,6 +401,7 @@ pub struct CodexSpawnOk { pub(crate) struct CodexSpawnArgs { pub(crate) config: Config, + pub(crate) user_instructions: LoadedUserInstructions, pub(crate) installation_id: String, pub(crate) auth_manager: Arc, pub(crate) models_manager: SharedModelsManager, @@ -484,6 +487,7 @@ impl Codex { async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult { let CodexSpawnArgs { mut config, + user_instructions, installation_id, auth_manager, models_manager, @@ -515,16 +519,21 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); + let LoadedUserInstructions { + instructions: user_instructions, + warnings: user_instruction_provider_warnings, + } = user_instructions; + // TODO(anp) pull startup_warnings out of Config + config + .startup_warnings + .extend(user_instruction_provider_warnings); + // TODO(anp) assemble instructions from multiple environments 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 primary_fs = primary_environment + .as_ref() + .map(|environment| environment.get_filesystem()); + let loaded_agents_md = + load_project_instructions(&mut config, user_instructions, primary_fs.as_deref()).await; let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { // Guardian review should rely on the built-in shell safety checks, @@ -604,7 +613,7 @@ impl Codex { model_reasoning_summary: config.model_reasoning_summary, service_tier, developer_instructions: config.developer_instructions.clone(), - user_instructions, + loaded_agents_md, personality: config.personality, base_instructions, compact_prompt: config.compact_prompt.clone(), @@ -818,7 +827,7 @@ impl Codex { let state = self.session.state.lock().await; state .session_configuration - .user_instructions + .loaded_agents_md .as_ref() .map_or_else(Vec::new, |instructions| { instructions.sources().cloned().collect() @@ -1507,6 +1516,16 @@ impl Session { .clone() } + pub(crate) async fn user_instructions(&self) -> Option { + let state = self.state.lock().await; + state + .session_configuration + .loaded_agents_md + .as_ref() + .and_then(LoadedAgentsMd::user_instructions) + .cloned() + } + pub(crate) async fn provider(&self) -> ModelProviderInfo { let state = self.state.lock().await; state.session_configuration.provider.clone() diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 3da05ed0fd01..da8daf176498 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -55,9 +55,9 @@ pub(crate) struct SessionConfiguration { /// Developer instructions that supplement the base instructions. pub(super) developer_instructions: Option, - /// Model instructions that are appended to the base instructions and the - /// files that supplied them. - pub(super) user_instructions: Option, + /// Model instructions assembled from provider instructions and discovered + /// AGENTS.md files. + pub(super) loaded_agents_md: Option, /// Personality preference for the model. pub(super) personality: Option, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4079deb6b8c0..f44f9a74ff49 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3286,7 +3286,7 @@ async fn set_rate_limits_retains_previous_credits() { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + loaded_agents_md: None, service_tier: None, personality: config.personality, base_instructions: config @@ -3393,7 +3393,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + loaded_agents_md: None, service_tier: None, personality: config.personality, base_instructions: config @@ -3925,7 +3925,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + loaded_agents_md: None, service_tier: None, personality: config.personality, base_instructions: config @@ -4777,7 +4777,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + loaded_agents_md: None, service_tier: None, personality: config.personality, base_instructions: config @@ -4885,7 +4885,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + loaded_agents_md: None, service_tier: None, personality: config.personality, base_instructions: config @@ -5117,7 +5117,7 @@ async fn make_session_with_config_and_rx( collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + loaded_agents_md: None, service_tier: None, personality: config.personality, base_instructions: config @@ -5219,7 +5219,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + loaded_agents_md: None, service_tier: None, personality: config.personality, base_instructions: config @@ -6963,7 +6963,7 @@ where collaboration_mode, model_reasoning_summary: config.model_reasoning_summary, developer_instructions: config.developer_instructions.clone(), - user_instructions: config.user_instructions.clone(), + loaded_agents_md: None, service_tier: None, personality: config.personality, base_instructions: config diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index c73add292079..0d3c9c033b8f 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -705,6 +705,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, + user_instructions: Default::default(), installation_id: "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 32c209c88028..1ee4323708fa 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -552,7 +552,7 @@ impl Session { developer_instructions: session_configuration.developer_instructions.clone(), compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration - .user_instructions + .loaded_agents_md .as_ref() .map(LoadedAgentsMd::text), collaboration_mode: session_configuration.collaboration_mode.clone(), diff --git a/codex-rs/core/src/test_support.rs b/codex-rs/core/src/test_support.rs index e491ca741385..7c084838fd11 100644 --- a/codex-rs/core/src/test_support.rs +++ b/codex-rs/core/src/test_support.rs @@ -8,6 +8,9 @@ use std::path::PathBuf; use std::sync::Arc; use codex_exec_server::EnvironmentManager; +use codex_extension_api::LoadUserInstructionsFuture; +use codex_extension_api::LoadedUserInstructions; +use codex_extension_api::UserInstructionsProvider; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_model_provider::create_model_provider; @@ -36,6 +39,16 @@ static TEST_MODEL_PRESETS: Lazy> = Lazy::new(|| { presets }); +/// Test-only provider that supplies no user instructions. +#[derive(Debug, Default)] +pub struct EmptyUserInstructionsProvider; + +impl UserInstructionsProvider for EmptyUserInstructionsProvider { + fn load_user_instructions(&self) -> LoadUserInstructionsFuture<'_> { + Box::pin(async { LoadedUserInstructions::default() }) + } +} + pub fn set_thread_manager_test_mode(enabled: bool) { thread_manager::set_thread_manager_test_mode_for_tests(enabled); } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index dc0fb91ab824..b82e63947e52 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -23,6 +23,8 @@ use codex_core_plugins::PluginsManager; use codex_exec_server::EnvironmentManager; use codex_extension_api::ExtensionDataInit; use codex_extension_api::ExtensionRegistry; +use codex_extension_api::LoadedUserInstructions; +use codex_extension_api::UserInstructionsProvider; use codex_extension_api::empty_extension_registry; use codex_features::Feature; use codex_login::AuthManager; @@ -209,6 +211,7 @@ pub(crate) struct ThreadManagerState { plugins_manager: Arc, mcp_manager: Arc, extensions: Arc>, + user_instructions_provider: Arc, thread_store: Arc, attestation_provider: Option>, session_source: SessionSource, @@ -259,6 +262,7 @@ impl ThreadManager { session_source: SessionSource, environment_manager: Arc, extensions: Arc>, + user_instructions_provider: Arc, analytics_events_client: Option, thread_store: Arc, state_db: Option, @@ -292,6 +296,7 @@ impl ThreadManager { plugins_manager, mcp_manager, extensions, + user_instructions_provider, thread_store, attestation_provider, auth_manager, @@ -394,6 +399,9 @@ impl ThreadManager { plugins_manager, mcp_manager, extensions: empty_extension_registry(), + user_instructions_provider: Arc::new( + crate::test_support::EmptyUserInstructionsProvider, + ), thread_store, attestation_provider: None, auth_manager, @@ -1091,6 +1099,56 @@ impl ThreadManagerState { resolve_multi_agent_version(initial_history, inherited_multi_agent_version) } + /// Resolves the provider snapshot for a newly spawned runtime. + /// + /// Loads a fresh provider snapshot for: + /// - fresh root threads; + /// - cold resumes; + /// - root forks. + /// + /// Uses an existing snapshot for: + /// - subagents, which inherit from their parent without invoking the + /// provider; + /// - running resumes and compaction paths, which retain the live session. + /// + /// Provider warnings only apply to fresh loads. If a parent runtime is no + /// longer available, its child starts without provider instructions rather + /// than loading independently. + async fn user_instructions_for_spawn( + &self, + session_source: &SessionSource, + parent_thread_id: Option, + forked_from_thread_id: Option, + ) -> LoadedUserInstructions { + let is_root_agent = !session_source.is_non_root_agent(); + if is_root_agent { + return self + .user_instructions_provider + .load_user_instructions() + .await; + } + + let inherited_thread_id = match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) => Some(*parent_thread_id), + _ => parent_thread_id.or(forked_from_thread_id), + }; + let instructions = match inherited_thread_id { + // The spawn path retains only thread IDs, so look up the live + // runtime again here to inherit its user instructions. + Some(thread_id) => match self.get_thread(thread_id).await { + Ok(thread) => thread.codex.session.user_instructions().await, + Err(_) => None, + }, + None => None, + }; + LoadedUserInstructions { + instructions, + warnings: Vec::new(), + } + } + /// Spawn a new thread with no history using a provided config. pub(crate) async fn spawn_new_thread( &self, @@ -1308,6 +1366,9 @@ impl ThreadManagerState { } let environment_selections = resolve_environment_selections(self.environment_manager.as_ref(), &environments)?; + let user_instructions = self + .user_instructions_for_spawn(&session_source, parent_thread_id, forked_from_thread_id) + .await; let parent_rollout_thread_trace = self .parent_rollout_thread_trace_for_source(&session_source, &initial_history) .await; @@ -1324,6 +1385,7 @@ impl ThreadManagerState { codex, thread_id, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, + user_instructions, installation_id: self.installation_id.clone(), auth_manager, models_manager: Arc::clone(&self.models_manager), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 48ca2598ab6c..d7afebfb3319 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -432,6 +432,7 @@ async fn start_thread_seeds_extension_data_before_lifecycle_contributors_run() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), Arc::new(extensions.build()), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, @@ -481,6 +482,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, @@ -598,6 +600,7 @@ async fn explicit_installation_id_skips_codex_home_file() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store, state_db.clone(), @@ -637,6 +640,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, @@ -694,6 +698,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, @@ -758,6 +763,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store, state_db.clone(), @@ -848,6 +854,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store.clone(), state_db, @@ -949,6 +956,7 @@ async fn new_uses_active_provider_for_model_refresh() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, @@ -1169,6 +1177,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, state_db.clone()), state_db.clone(), @@ -1275,6 +1284,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, state_db.clone()), state_db.clone(), @@ -1371,6 +1381,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, state_db.clone()), state_db.clone(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index f55d9c087630..88a8f07dcb08 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::LoadedAgentsMd; use crate::ThreadManager; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; @@ -4241,6 +4240,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(crate::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, state_db.clone()), state_db.clone(), @@ -4520,25 +4520,6 @@ async fn build_agent_spawn_config_uses_turn_context_values() { assert_eq!(config, expected); } -#[tokio::test] -async fn build_agent_spawn_config_preserves_base_user_instructions() { - let (_session, mut turn) = make_session_and_context().await; - let mut base_config = (*turn.config).clone(); - base_config.user_instructions = Some(LoadedAgentsMd::new_user( - "base-user".to_string(), - base_config.codex_home.join("AGENTS.md"), - )); - turn.user_instructions = Some("resolved-user".to_string()); - turn.config = Arc::new(base_config.clone()); - let base_instructions = BaseInstructions { - text: "base".to_string(), - }; - - let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); - - assert_eq!(config.user_instructions, base_config.user_instructions); -} - #[tokio::test] async fn build_agent_resume_config_clears_base_instructions() { let (_session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 575985d2c22f..76f06df1edce 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -20,6 +20,7 @@ codex-config = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } codex-exec-server = { workspace = true } +codex-home = { workspace = true } codex-features = { workspace = true } codex-hooks = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 07f1a8a78e56..1582b44d7dd2 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; use std::process::Command; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use std::time::Duration; @@ -24,7 +25,10 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; use codex_extension_api::ExtensionRegistry; +use codex_extension_api::LoadUserInstructionsFuture; +use codex_extension_api::UserInstructionsProvider; use codex_extension_api::empty_extension_registry; +use codex_home::CodexHomeUserInstructionsProvider; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; @@ -72,6 +76,31 @@ const REMOTE_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_TEST_REMOTE_EXEC_SERVER_URL" static REMOTE_TEST_INSTANCE_COUNTER: AtomicU64 = AtomicU64::new(0); const SUBMIT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); +pub struct RecordingUserInstructionsProvider { + inner: Arc, + load_count: AtomicUsize, +} + +impl RecordingUserInstructionsProvider { + pub fn new(inner: Arc) -> Self { + Self { + inner, + load_count: AtomicUsize::new(0), + } + } + + pub fn load_count(&self) -> usize { + self.load_count.load(Ordering::SeqCst) + } +} + +impl UserInstructionsProvider for RecordingUserInstructionsProvider { + fn load_user_instructions(&self) -> LoadUserInstructionsFuture<'_> { + self.load_count.fetch_add(1, Ordering::SeqCst); + self.inner.load_user_instructions() + } +} + pub fn local(cwd: AbsolutePathBuf) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), @@ -233,6 +262,7 @@ pub struct TestCodexBuilder { user_shell_override: Option, exec_server_url: Option, extensions: Arc>, + user_instructions_provider: Option>, } impl TestCodexBuilder { @@ -322,6 +352,14 @@ impl TestCodexBuilder { self } + pub fn with_user_instructions_provider( + mut self, + provider: Arc, + ) -> Self { + self.user_instructions_provider = Some(provider); + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -510,12 +548,19 @@ impl TestCodexBuilder { let state_db = codex_core::init_state_db(&config).await; let thread_store = thread_store_from_config(&config, state_db.clone()); let installation_id = resolve_installation_id(&config.codex_home).await?; + let user_instructions_provider = + self.user_instructions_provider.clone().unwrap_or_else(|| { + Arc::new(CodexHomeUserInstructionsProvider::new( + config.codex_home.clone(), + )) + }); let thread_manager = ThreadManager::new( &config, codex_core::test_support::auth_manager_from_auth(auth.clone()), SessionSource::Exec, Arc::clone(&environment_manager), Arc::clone(&self.extensions), + user_instructions_provider, /*analytics_events_client*/ None, thread_store, state_db.clone(), @@ -1103,6 +1148,7 @@ pub fn test_codex() -> TestCodexBuilder { user_shell_override: None, exec_server_url: None, extensions: empty_extension_registry(), + user_instructions_provider: None, } } diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 810f73398afb..585fd9e9b366 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,9 +1,12 @@ use anyhow::Result; use anyhow::anyhow; use codex_core::ForkSnapshot; +use codex_core::StartThreadOptions; use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; +use codex_home::CodexHomeUserInstructionsProvider; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -18,6 +21,7 @@ use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::RecordingUserInstructionsProvider; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -364,6 +368,89 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { + let server = start_mock_server().await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("no-primary-environment-response"), + ev_completed("no-primary-environment-response"), + ]), + ) + .await; + let home = Arc::new(TempDir::new()?); + let global_source = + write_global_file(home.as_ref(), GLOBAL_AGENTS_FILENAME, GLOBAL_INSTRUCTIONS)?; + let provider = Arc::new(RecordingUserInstructionsProvider::new(Arc::new( + CodexHomeUserInstructionsProvider::new(AbsolutePathBuf::try_from( + home.path().to_path_buf(), + )?), + ))); + + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_user_instructions_provider(provider.clone()) + .with_workspace_setup(|cwd, fs| async move { + let project_agents_uri = PathUri::from_path(cwd.join(GLOBAL_AGENTS_FILENAME))?; + fs.write_file( + &project_agents_uri, + PROJECT_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + Ok(()) + }); + let test = builder.build_with_remote_env(&server).await?; + assert_eq!(provider.load_count(), 1); + + let no_environment_thread = test + .thread_manager + .start_thread_with_options(StartThreadOptions { + config: test.config.clone(), + initial_history: InitialHistory::New, + session_source: None, + thread_source: None, + dynamic_tools: Vec::new(), + metrics_service_name: None, + parent_trace: None, + environments: Vec::new(), + thread_extension_init: Default::default(), + }) + .await?; + assert_eq!(provider.load_count(), 2); + assert_eq!( + no_environment_thread.thread.instruction_sources().await, + vec![global_source] + ); + + no_environment_thread + .thread + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "inspect global instructions without an environment".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&no_environment_thread.thread, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let instruction_fragments = instruction_fragments(&response_mock.single_request()); + assert_eq!(instruction_fragments.len(), 1); + assert!(instruction_fragments[0].contains(GLOBAL_INSTRUCTIONS)); + assert!(!instruction_fragments[0].contains(PROJECT_INSTRUCTIONS)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Result<()> { // Set up one global source, one project source, and two ordinary model turns. diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a522e041c28a..b7cd636d5d21 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,6 +1,5 @@ use codex_config::ConfigLayerStack; use codex_config::types::AuthCredentialsStoreMode; -use codex_core::LoadedAgentsMd; use codex_core::ModelClient; use codex_core::NewThread; use codex_core::Prompt; @@ -365,9 +364,8 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { let codex_home = Arc::new(TempDir::new().unwrap()); let mut builder = test_codex() .with_home(codex_home.clone()) - .with_config(|config| { - // Ensure user instructions are NOT delivered on resume. - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); + .with_pre_build_hook(|home| { + std::fs::write(home.join("AGENTS.md"), "be nice").expect("write global instructions"); }); let test = builder .resume(&server, codex_home, session_path.clone()) @@ -1137,6 +1135,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { SessionSource::Exec, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), empty_extension_registry(), + Arc::new(codex_core::test_support::EmptyUserInstructionsProvider), /*analytics_events_client*/ None, thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, @@ -1178,8 +1177,8 @@ async fn includes_user_instructions_message_in_request() { let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) - .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); + .with_pre_build_hook(|home| { + std::fs::write(home.join("AGENTS.md"), "be nice").expect("write global instructions"); }); let codex = builder .build(&server) @@ -2246,8 +2245,10 @@ async fn includes_developer_instructions_message_in_request() { .await; let mut builder = test_codex() .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_pre_build_hook(|home| { + std::fs::write(home.join("AGENTS.md"), "be nice").expect("write global instructions"); + }) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice")); config.developer_instructions = Some("be useful".to_string()); }); let codex = builder diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 81038337da14..87f771b74322 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -5,7 +5,6 @@ use std::path::Path; use std::path::PathBuf; use anyhow::Result; -use codex_core::LoadedAgentsMd; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ServiceTier; @@ -29,6 +28,7 @@ const FIXED_CWD: &str = "/tmp/codex_remote_compaction_parity_workspace"; const IMAGE_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; const SUMMARY: &str = "REMOTE_COMPACTION_PARITY_ENCRYPTED_SUMMARY"; const DUMMY_FUNCTION_NAME: &str = "test_tool"; +const USER_INSTRUCTIONS: &str = "PARITY_USER_INSTRUCTIONS"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Mode { @@ -509,7 +509,12 @@ async fn build_harness_inner( auto_compact_limit: Option, ) -> Result { fs::create_dir_all(FIXED_CWD)?; - let mut builder = test_codex().with_auth(settings.auth.build()); + let mut builder = test_codex() + .with_auth(settings.auth.build()) + .with_pre_build_hook(|home| { + fs::write(home.join("AGENTS.md"), USER_INSTRUCTIONS) + .expect("write global instructions"); + }); if hooks { builder = builder.with_pre_build_hook(write_manual_compact_hooks); } @@ -518,9 +523,6 @@ async fn build_harness_inner( FIXED_CWD, )) .expect("fixed cwd should be absolute"); - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "PARITY_USER_INSTRUCTIONS", - )); config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string()); if settings.service_tier_fast { config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 80dee582adc1..809cb0ea3276 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,6 +1,8 @@ #![allow(clippy::unwrap_used)] -use codex_core::LoadedAgentsMd; +use std::fs; +use std::path::Path; + use codex_core::shell::default_user_shell; use codex_features::Feature; use codex_prompts::APPLY_PATCH_TOOL_INSTRUCTIONS; @@ -32,6 +34,11 @@ use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use tempfile::TempDir; +fn write_global_instructions(home: &Path) { + fs::write(home.join("AGENTS.md"), "be consistent and helpful") + .unwrap_or_else(|err| panic!("write global instructions: {err}")); +} + fn text_user_input(text: String) -> serde_json::Value { text_user_input_parts(vec![text]) } @@ -123,10 +130,8 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { thread_manager, .. } = test_codex() + .with_pre_build_hook(write_global_instructions) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "be consistent and helpful", - )); config.model = Some("gpt-5.2".to_string()); // Keep tool expectations stable when the default web_search mode changes. config @@ -235,10 +240,8 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an .await; let TestCodex { codex, .. } = test_codex() + .with_pre_build_hook(write_global_instructions) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "be consistent and helpful", - )); config .features .enable(Feature::CollaborationModes) @@ -317,10 +320,8 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests .await; let TestCodex { codex, config, .. } = test_codex() + .with_pre_build_hook(write_global_instructions) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "be consistent and helpful", - )); config .features .enable(Feature::CollaborationModes) @@ -415,10 +416,8 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an .await; let TestCodex { codex, config, .. } = test_codex() + .with_pre_build_hook(write_global_instructions) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "be consistent and helpful", - )); config .features .enable(Feature::CollaborationModes) @@ -706,10 +705,8 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res .await; let TestCodex { codex, .. } = test_codex() + .with_pre_build_hook(write_global_instructions) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "be consistent and helpful", - )); config .features .enable(Feature::CollaborationModes) @@ -841,10 +838,8 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a session_configured, .. } = test_codex() + .with_pre_build_hook(write_global_instructions) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "be consistent and helpful", - )); config .features .enable(Feature::CollaborationModes) @@ -982,10 +977,8 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu session_configured, .. } = test_codex() + .with_pre_build_hook(write_global_instructions) .with_config(|config| { - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "be consistent and helpful", - )); config .features .enable(Feature::CollaborationModes) diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index bad7e1ab5aa2..c2ebe3999b8a 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -1,19 +1,24 @@ +use std::sync::Arc; + use anyhow::Result; -use codex_core::LoadedAgentsMd; use codex_core::build_prompt_input; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; +use codex_home::CodexHomeUserInstructionsProvider; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; use tempfile::TempDir; +const TEST_INSTRUCTIONS: &str = "Global test instructions"; + #[tokio::test] async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - let mut config = ConfigBuilder::default() + std::fs::write(codex_home.path().join("AGENTS.md"), TEST_INSTRUCTIONS)?; + let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .harness_overrides(ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), @@ -22,8 +27,8 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { }) .build() .await?; - config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing( - "Project-specific test instructions", + let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( + config.codex_home.clone(), )); let input = build_prompt_input( @@ -33,6 +38,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { text_elements: Vec::new(), }], /*state_db*/ None, + user_instructions_provider, ) .await?; @@ -55,7 +61,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { else { return false; }; - text.contains("Project-specific test instructions") + text.contains(TEST_INSTRUCTIONS) }) })); diff --git a/codex-rs/exec/tests/suite/agents_md.rs b/codex-rs/exec/tests/suite/agents_md.rs index d2a53d88e2e1..d4a765f5c6a3 100644 --- a/codex-rs/exec/tests/suite/agents_md.rs +++ b/codex-rs/exec/tests/suite/agents_md.rs @@ -142,3 +142,35 @@ async fn exec_json_surfaces_project_instruction_loading_warnings() -> anyhow::Re Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_surfaces_global_instruction_loading_warnings() -> anyhow::Result<()> { + let test = test_codex_exec(); + let global_agents_path = test.home_path().join("AGENTS.md"); + let global_agents_source_suffix = format!( + "{}{}AGENTS.md", + test.home_path() + .file_name() + .expect("temporary Codex home should have a file name") + .to_string_lossy(), + std::path::MAIN_SEPARATOR, + ); + std::fs::write(&global_agents_path, b"global\xFFinstructions")?; + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + responses::mount_sse_once(&server, body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .success() + .stderr(contains("invalid UTF-8").and(contains(global_agents_source_suffix))); + + Ok(()) +} diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml index 25ed8d0d7147..1a5a9742fbed 100644 --- a/codex-rs/ext/extension-api/Cargo.toml +++ b/codex-rs/ext/extension-api/Cargo.toml @@ -18,6 +18,7 @@ codex-config = { workspace = true } codex-context-fragments = { workspace = true } codex-protocol = { workspace = true } codex-tools = { workspace = true } +codex-utils-absolute-path = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 52f15c894c3e..7004af263347 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -2,6 +2,7 @@ mod capabilities; mod contributors; mod registry; mod state; +mod user_instructions; pub use capabilities::AgentSpawnFuture; pub use capabilities::AgentSpawner; @@ -64,3 +65,7 @@ pub use registry::ExtensionRegistryBuilder; pub use registry::empty_extension_registry; pub use state::ExtensionData; pub use state::ExtensionDataInit; +pub use user_instructions::LoadUserInstructionsFuture; +pub use user_instructions::LoadedUserInstructions; +pub use user_instructions::UserInstructions; +pub use user_instructions::UserInstructionsProvider; diff --git a/codex-rs/ext/extension-api/src/user_instructions.rs b/codex-rs/ext/extension-api/src/user_instructions.rs new file mode 100644 index 000000000000..d5fd63c19712 --- /dev/null +++ b/codex-rs/ext/extension-api/src/user_instructions.rs @@ -0,0 +1,41 @@ +use std::future::Future; +use std::pin::Pin; + +use codex_utils_absolute_path::AbsolutePathBuf; + +/// User instructions supplied by the host. +/// +/// `source` must be an absolute filesystem path because the app-server +/// `instructionSources` API currently exposes instruction sources as +/// `AbsolutePathBuf` values. +// TODO(anp): Replace the absolute path with a more general instruction-source +// abstraction when non-filesystem providers need first-class attribution. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserInstructions { + /// Model-visible user instruction text. + pub text: String, + /// Absolute filesystem path reported through `instructionSources`. + pub source: AbsolutePathBuf, +} + +/// Result of loading host-provided user instructions. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct LoadedUserInstructions { + /// Loaded instructions, or `None` when the provider has no applicable text. + pub instructions: Option, + /// Recoverable loading problems that should be surfaced during startup. + pub warnings: Vec, +} + +/// Future returned by a [`UserInstructionsProvider`]. +pub type LoadUserInstructionsFuture<'a> = + Pin + Send + 'a>>; + +/// Loads the user instructions that apply when a root thread runtime starts. +/// +/// Implementations should return any recoverable loading problems as warnings +/// while still returning usable fallback instructions when available. +pub trait UserInstructionsProvider: Send + Sync { + /// Loads the snapshot to use for a newly created root runtime. + fn load_user_instructions(&self) -> LoadUserInstructionsFuture<'_>; +} diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 29b2d6c7d721..9d568722e8a7 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -21,6 +21,7 @@ anyhow = { workspace = true } codex-arg0 = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } +codex-home = { workspace = true } codex-exec-server = { workspace = true } codex-extension-api = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 2f85b351435e..ce19575a9c60 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -7,6 +7,7 @@ use codex_core::ThreadManager; use codex_core::config::Config; use codex_exec_server::EnvironmentManager; use codex_extension_api::empty_extension_registry; +use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; use codex_login::default_client::USER_AGENT_SUFFIX; use codex_login::default_client::get_codex_user_agent; @@ -62,12 +63,16 @@ impl MessageProcessor { /*enable_codex_api_key_env*/ false, ) .await; + let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( + config.codex_home.clone(), + )); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), auth_manager, SessionSource::Mcp, environment_manager, empty_extension_registry(), + user_instructions_provider, /*analytics_events_client*/ None, codex_core::thread_store_from_config(config.as_ref(), state_db.clone()), state_db.clone(), diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 1bea0cae8ba7..5c8aae8b37e7 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -16,6 +16,7 @@ use codex_core_api::AskForApproval; use codex_core_api::AuthCredentialsStoreMode; use codex_core_api::AuthManager; use codex_core_api::AutoCompactTokenLimitScope; +use codex_core_api::CodexHomeUserInstructionsProvider; use codex_core_api::CodexThread; use codex_core_api::Config; use codex_core_api::ConfigLayerStack; @@ -120,12 +121,16 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?, ); let installation_id = resolve_installation_id(&config.codex_home).await?; + let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new( + config.codex_home.clone(), + )); let thread_manager = ThreadManager::new( &config, auth_manager, SessionSource::Exec, environment_manager, empty_extension_registry(), + user_instructions_provider, /*analytics_events_client*/ None, Arc::clone(&thread_store), state_db, @@ -184,7 +189,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R enforce_residency: Constrained::allow_any(/*initial_value*/ None), hide_agent_reasoning: false, show_raw_agent_reasoning: false, - user_instructions: None, base_instructions: None, developer_instructions: None, guardian_policy_config: None, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index c66a37ae3bab..3dfc373b592a 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -19,7 +19,6 @@ pub(super) async fn test_config() -> Config { config.cwd = PathBuf::from(test_path_display("/tmp/project")).abs(); config.config_layer_stack = ConfigLayerStack::default(); config.startup_warnings.clear(); - config.user_instructions = None; config }