[codex] Load user instructions through an injected provider (#27101)

## Why

We want to remove implicit use of `$CODEX_HOME` from `codex-core` and
make embedders responsible for supplying user-level instructions. This
also ensures user instructions load when no primary environment is
selected.

## What changed

Stacked on #27415, which makes `codex exec` surface thread-scoped
runtime warnings.

- Added `UserInstructionsProvider` to `codex-extension-api`, with
absolute source attribution and recoverable loading warnings.
- Added `codex-home` with the filesystem-backed provider for
`AGENTS.override.md` and `AGENTS.md`, preserving precedence, fallback,
trimming, lossy UTF-8 handling, and the existing uncapped global
instruction size.
- Removed global instruction loading from `Config` and require
`ThreadManager` callers to inject a provider.
- Load provider instructions once for each fresh root runtime, including
runtimes without a primary environment. Running sessions retain their
snapshot, while child agents inherit the parent snapshot without
invoking the provider.
- Keep provider instructions separate while loading project `AGENTS.md`,
then assemble the model-visible instructions with the existing ordering,
source attribution, warning, and turn-context behavior.
- Wired the Codex home provider through the CLI, app server, MCP server,
core facade, and thread-manager sample.

## Validation

- `just test -p codex-home -p codex-extension-api`
- `just test -p codex-core agents_md`
- `just test -p codex-core guardian`
- `just test -p codex-app-server
thread_start_without_selected_environment_includes_only_global_instruction_source`
- `just test -p codex-exec warning`
- `just bazel-lock-check`
This commit is contained in:
Adam Perry @ OpenAI
2026-06-11 12:28:47 -07:00
committed by GitHub
Unverified
parent b2a4e3be27
commit 236b50125d
49 changed files with 1368 additions and 567 deletions
+18
View File
@@ -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",
+2
View File
@@ -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" }
+1
View File
@@ -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 }
+4
View File
@@ -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()),
@@ -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(),
@@ -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::<ThreadStartResponse>(response)?;
assert!(instruction_sources.is_empty());
assert_eq!(
instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>(),
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::<Value>()
.context("model request body should be JSON")?
.to_string();
assert!(model_request_body.contains("global instructions"));
assert!(!model_request_body.contains("project instructions"));
Ok(())
}
+1
View File
@@ -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 }
+12 -1
View File
@@ -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(())
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "codex-home",
crate_name = "codex_home",
)
+21
View File
@@ -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"] }
@@ -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;
@@ -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<String>,
) -> 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]
)
);
}
+3
View File
@@ -0,0 +1,3 @@
mod instructions;
pub use instructions::CodexHomeUserInstructionsProvider;
+1
View File
@@ -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 }
+5
View File
@@ -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;
+1
View File
@@ -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 }
+228 -275
View File
@@ -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<String>,
) -> Option<LoadedAgentsMd> {
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<String>,
) -> Option<LoadedAgentsMd> {
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<String>,
) -> Option<LoadedAgentsMd> {
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<UserInstructions>,
fs: Option<&dyn ExecutorFileSystem>,
) -> Option<LoadedAgentsMd> {
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<String>,
) -> io::Result<Option<LoadedAgentsMd>> {
let max_total = self.config.project_doc_max_bytes;
if config.features.enabled(Feature::ChildAgentsMd) {
loaded.entries.push(InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
});
}
if max_total == 0 {
return Ok(None);
(!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(
config: &mut Config,
fs: &dyn ExecutorFileSystem,
) -> io::Result<Option<LoadedAgentsMd>> {
let max_total = config.project_doc_max_bytes;
if max_total == 0 {
return Ok(None);
}
let paths = agents_md_paths(config, fs).await?;
if paths.is_empty() {
return Ok(None);
}
let mut remaining: u64 = max_total as u64;
let mut loaded = LoadedAgentsMd::default();
for p in paths {
if remaining == 0 {
break;
}
let paths = self.agents_md_paths(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", &mut config.startup_warnings);
for p in paths {
if remaining == 0 {
let size = data.len() as u64;
if size > remaining {
data.truncate(remaining as usize);
}
if size > remaining {
tracing::warn!(
"Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
p.display(),
remaining,
);
}
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);
}
}
if loaded.is_empty() {
Ok(None)
} else {
Ok(Some(loaded))
}
}
/// 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<Vec<AbsolutePathBuf>> {
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;
}
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 path_uri = PathUri::from_abs_path(&p)?;
match fs.get_metadata(&path_uri, /*sandbox*/ None).await {
Ok(metadata) if !metadata.is_file => continue,
let search_dirs: Vec<AbsolutePathBuf> = 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;
}
dirs.reverse();
dirs
} else {
vec![dir]
};
let mut found: Vec<AbsolutePathBuf> = 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),
}
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);
let size = data.len() as u64;
if size > remaining {
data.truncate(remaining as usize);
}
if size > remaining {
tracing::warn!(
"Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
p.display(),
remaining,
);
}
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);
}
}
if loaded.is_empty() {
Ok(None)
} else {
Ok(Some(loaded))
}
}
/// 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<Vec<AbsolutePathBuf>> {
if self.config.project_doc_max_bytes == 0 {
return Ok(Vec::new());
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;
}
let dir = self.config.cwd.clone();
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);
if !names.contains(&candidate) {
names.push(candidate);
}
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<AbsolutePathBuf> = 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;
}
dirs.reverse();
dirs
} else {
vec![dir]
};
let mut found: Vec<AbsolutePathBuf> = 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),
}
}
}
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);
}
}
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<UserInstructions>,
/// Ordered instructions and their provenance.
entries: Vec<InstructionEntry>,
}
@@ -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<UserInstructions>) -> 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<Item = &AbsolutePathBuf> {
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,
}
}
+327 -114
View File
@@ -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<String> {
#[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<PathUri> {
unreachable!("canonicalize should not be called")
}
async fn read_file(
&self,
path: &PathUri,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<Vec<u8>> {
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<u8>,
_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<FileMetadata> {
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<Vec<ReadDirectoryEntry>> {
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<UserInstructions>,
}
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<String> {
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<Vec<AbsolutePathBuf>> {
AgentsMdManager::new(config)
.agents_md_paths(LOCAL_FS.as_ref())
.await
async fn load_agents_md(config: &TestConfig, warnings: &mut Vec<String>) -> Option<LoadedAgentsMd> {
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<Vec<AbsolutePathBuf>> {
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::<String>::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::<AbsolutePathBuf>::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<_>>(), 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<_>>(),
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()),
+6
View File
@@ -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,
-57
View File
@@ -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<_>>(),
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<_>>(),
vec![&global_agents_override_path]
);
Ok(())
}
#[tokio::test]
async fn test_toml_parsing() {
let history_with_persistence = r#"
-12
View File
@@ -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<LoadedAgentsMd>,
/// Base instructions override.
pub base_instructions: Option<String>,
@@ -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,
+41 -16
View File
@@ -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<String>,
base_instructions: Option<String>,
user_instructions: Option<LoadedAgentsMd>,
user_instructions: Option<UserInstructions>,
compact_prompt: Option<String>,
cwd: AbsolutePathBuf,
mcp_servers: Constrained<HashMap<String, McpServerConfig>>,
@@ -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<UserInstructions>,
) -> 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(&params.spawn_config);
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
&params.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(&params.spawn_config);
review_session.reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
&params.spawn_config,
params.parent_session.user_instructions().await,
);
let manager = GuardianReviewSessionManager {
state: Arc::new(Mutex::new(GuardianReviewSessionState {
trunk: Some(Arc::new(review_session)),
-3
View File
@@ -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(),
-1
View File
@@ -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;
+3
View File
@@ -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<UserInput>,
state_db: Option<StateDbHandle>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
) -> CodexResult<Vec<ResponseItem>> {
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(),
+31 -12
View File
@@ -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<AuthManager>,
pub(crate) models_manager: SharedModelsManager,
@@ -484,6 +487,7 @@ impl Codex {
async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
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<codex_extension_api::UserInstructions> {
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()
+3 -3
View File
@@ -55,9 +55,9 @@ pub(crate) struct SessionConfiguration {
/// Developer instructions that supplement the base instructions.
pub(super) developer_instructions: Option<String>,
/// Model instructions that are appended to the base instructions and the
/// files that supplied them.
pub(super) user_instructions: Option<LoadedAgentsMd>,
/// Model instructions assembled from provider instructions and discovered
/// AGENTS.md files.
pub(super) loaded_agents_md: Option<LoadedAgentsMd>,
/// Personality preference for the model.
pub(super) personality: Option<Personality>,
+8 -8
View File
@@ -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
@@ -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,
+1 -1
View File
@@ -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(),
+13
View File
@@ -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<Vec<ModelPreset>> = 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);
}
+62
View File
@@ -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<PluginsManager>,
mcp_manager: Arc<McpManager>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
thread_store: Arc<dyn ThreadStore>,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
session_source: SessionSource,
@@ -259,6 +262,7 @@ impl ThreadManager {
session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
analytics_events_client: Option<AnalyticsEventsClient>,
thread_store: Arc<dyn ThreadStore>,
state_db: Option<StateDbHandle>,
@@ -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<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
) -> 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),
+11
View File
@@ -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(),
@@ -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;
+1
View File
@@ -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 }
+46
View File
@@ -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<dyn UserInstructionsProvider>,
load_count: AtomicUsize,
}
impl RecordingUserInstructionsProvider {
pub fn new(inner: Arc<dyn UserInstructionsProvider>) -> 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<Shell>,
exec_server_url: Option<String>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Option<Arc<dyn UserInstructionsProvider>>,
}
impl TestCodexBuilder {
@@ -322,6 +352,14 @@ impl TestCodexBuilder {
self
}
pub fn with_user_instructions_provider(
mut self,
provider: Arc<dyn UserInstructionsProvider>,
) -> 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,
}
}
+87
View File
@@ -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.
+8 -7
View File
@@ -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
@@ -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<i64>,
) -> Result<TestCodexHarness> {
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());
+15 -22
View File
@@ -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)
@@ -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)
})
}));
+32
View File
@@ -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(())
}
+1
View File
@@ -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 }
+5
View File
@@ -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;
@@ -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<UserInstructions>,
/// Recoverable loading problems that should be surfaced during startup.
pub warnings: Vec<String>,
}
/// Future returned by a [`UserInstructionsProvider`].
pub type LoadUserInstructionsFuture<'a> =
Pin<Box<dyn Future<Output = LoadedUserInstructions> + 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<'_>;
}
+1
View File
@@ -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 }
@@ -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(),
+5 -1
View File
@@ -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<String>, 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,
@@ -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
}