mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
b2a4e3be27
commit
236b50125d
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "codex-home",
|
||||
crate_name = "codex_home",
|
||||
)
|
||||
@@ -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]
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod instructions;
|
||||
|
||||
pub use instructions::CodexHomeUserInstructionsProvider;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶ms.spawn_config);
|
||||
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
¶ms.spawn_config,
|
||||
params.parent_session.user_instructions().await,
|
||||
);
|
||||
let mut stale_trunk_to_shutdown = None;
|
||||
let mut spawned_trunk = false;
|
||||
let trunk_candidate = match run_before_review_deadline(
|
||||
@@ -441,6 +447,7 @@ impl GuardianReviewSessionManager {
|
||||
pub(crate) async fn cache_for_test(&self, codex: Codex) {
|
||||
let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
codex.session.get_config().await.as_ref(),
|
||||
codex.session.user_instructions().await,
|
||||
);
|
||||
self.state.lock().await.trunk = Some(Arc::new(GuardianReviewSession {
|
||||
reuse_key,
|
||||
@@ -459,6 +466,7 @@ impl GuardianReviewSessionManager {
|
||||
pub(crate) async fn register_ephemeral_for_test(&self, codex: Codex) {
|
||||
let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
codex.session.get_config().await.as_ref(),
|
||||
codex.session.user_instructions().await,
|
||||
);
|
||||
self.state
|
||||
.lock()
|
||||
@@ -1075,8 +1083,10 @@ mod tests {
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
let (_agent_status_tx, agent_status) =
|
||||
tokio::sync::watch::channel(AgentStatus::PendingInit);
|
||||
let reuse_key =
|
||||
GuardianReviewSessionReuseKey::from_spawn_config(session.get_config().await.as_ref());
|
||||
let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
session.get_config().await.as_ref(),
|
||||
session.user_instructions().await,
|
||||
);
|
||||
|
||||
(
|
||||
GuardianReviewSession {
|
||||
@@ -1179,8 +1189,10 @@ mod tests {
|
||||
/*reasoning_effort*/ None,
|
||||
)
|
||||
.expect("cached guardian config");
|
||||
let cached_reuse_key =
|
||||
GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config);
|
||||
let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
&cached_spawn_config,
|
||||
/*user_instructions*/ None,
|
||||
);
|
||||
|
||||
let mut changed_parent_config = parent_config;
|
||||
changed_parent_config.model_provider.base_url =
|
||||
@@ -1192,12 +1204,18 @@ mod tests {
|
||||
/*reasoning_effort*/ None,
|
||||
)
|
||||
.expect("next guardian config");
|
||||
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config);
|
||||
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
&next_spawn_config,
|
||||
/*user_instructions*/ None,
|
||||
);
|
||||
|
||||
assert_ne!(cached_reuse_key, next_reuse_key);
|
||||
assert_eq!(
|
||||
cached_reuse_key,
|
||||
GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config)
|
||||
GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
&cached_spawn_config,
|
||||
/*user_instructions*/ None,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1251,8 +1269,10 @@ mod tests {
|
||||
/*reasoning_effort*/ None,
|
||||
)
|
||||
.expect("cached guardian config");
|
||||
let cached_reuse_key =
|
||||
GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config);
|
||||
let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
&cached_spawn_config,
|
||||
/*user_instructions*/ None,
|
||||
);
|
||||
|
||||
let mut changed_parent_config = parent_config;
|
||||
changed_parent_config.model_auto_compact_token_limit_scope =
|
||||
@@ -1264,7 +1284,10 @@ mod tests {
|
||||
/*reasoning_effort*/ None,
|
||||
)
|
||||
.expect("next guardian config");
|
||||
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config);
|
||||
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
&next_spawn_config,
|
||||
/*user_instructions*/ None,
|
||||
);
|
||||
|
||||
assert_ne!(cached_reuse_key, next_reuse_key);
|
||||
}
|
||||
@@ -1495,8 +1518,10 @@ mod tests {
|
||||
async fn run_review_removes_trunk_when_event_stream_is_broken() {
|
||||
let (mut review_session, tx_event, _rx_sub) = test_review_session().await;
|
||||
let params = test_review_params().await;
|
||||
review_session.reuse_key =
|
||||
GuardianReviewSessionReuseKey::from_spawn_config(¶ms.spawn_config);
|
||||
review_session.reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
¶ms.spawn_config,
|
||||
params.parent_session.user_instructions().await,
|
||||
);
|
||||
let manager = GuardianReviewSessionManager {
|
||||
state: Arc::new(Mutex::new(GuardianReviewSessionState {
|
||||
trunk: Some(Arc::new(review_session)),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}));
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<'_>;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user