mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Route AGENTS.md loading through environment filesystems (#26205)
## Why Workspace-specific `AGENTS.md` loading needs to use the selected environment filesystem so remote workspaces and child agents read instructions from their actual environment instead of the host filesystem. The app-server should report the same instruction sources the initialized thread actually loaded, rather than independently rescanning configuration and filesystem state. ## What changed - Introduce `LoadedAgentsMd` to retain ordered user, project, and internal instructions with their provenance. - Load and canonicalize workspace `AGENTS.md` paths through the primary `EnvironmentManager` environment, then render the loaded instructions when constructing turn context. - Expose cached loaded instruction sources from initialized threads and use them for app-server start, resume, and fork responses. - Preserve global `CODEX_HOME` loading and separator behavior while excluding empty project files that did not supply model-visible instructions. - Add integration coverage for CLI injection, selected-environment provenance and rendering, empty environment selection, and cached sources on loaded-thread resume. ## Validation - `just test -p codex-core agents_md` - `just test -p codex-core selected_environment_sources_match_model_visible_instructions` - `just test -p codex-exec agents_md` - `just test -p codex-app-server instruction_sources` - `just test -p codex-app-server --status-level fail`
This commit is contained in:
committed by
GitHub
Unverified
parent
c3fcb0e745
commit
e64b469bbc
@@ -16,6 +16,7 @@ async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
AgentsMdManager::new(config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
.map(|loaded| loaded.text())
|
||||
}
|
||||
|
||||
async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
|
||||
@@ -53,7 +54,12 @@ 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(ToOwned::to_owned);
|
||||
config.user_instructions = instructions.map(|text| {
|
||||
LoadedAgentsMd::new_user(
|
||||
text.to_owned(),
|
||||
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
)
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
@@ -96,7 +102,12 @@ async fn make_config_with_project_root_markers(
|
||||
|
||||
config.cwd = root.abs();
|
||||
config.project_doc_max_bytes = limit;
|
||||
config.user_instructions = instructions.map(ToOwned::to_owned);
|
||||
config.user_instructions = instructions.map(|text| {
|
||||
LoadedAgentsMd::new_user(
|
||||
text.to_owned(),
|
||||
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
)
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
@@ -115,17 +126,46 @@ async fn no_doc_file_returns_none() {
|
||||
assert!(res.is_none(), "Expected None when AGENTS.md is absent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_environment_returns_none() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
|
||||
#[test]
|
||||
fn empty_loaded_instructions_are_empty() {
|
||||
let source =
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path");
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let res = AgentsMdManager::new(&config)
|
||||
.user_instructions(/*environment*/ None, &mut warnings)
|
||||
.await;
|
||||
assert_eq!(
|
||||
LoadedAgentsMd::new_user(String::new(), source.clone()),
|
||||
LoadedAgentsMd::default()
|
||||
);
|
||||
assert_eq!(
|
||||
LoadedAgentsMd::new_user(" \n\t".to_string(), source),
|
||||
LoadedAgentsMd::default()
|
||||
);
|
||||
assert_eq!(
|
||||
LoadedAgentsMd::from_text_for_testing(String::new()),
|
||||
LoadedAgentsMd::default()
|
||||
);
|
||||
assert_eq!(
|
||||
LoadedAgentsMd::from_text_for_testing(" \n\t"),
|
||||
LoadedAgentsMd::default()
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(res, None);
|
||||
#[test]
|
||||
fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() {
|
||||
let empty = LoadedAgentsMd {
|
||||
entries: vec![InstructionEntry {
|
||||
contents: String::new(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
}],
|
||||
};
|
||||
let whitespace = LoadedAgentsMd {
|
||||
entries: vec![InstructionEntry {
|
||||
contents: " \n\t".to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(empty.is_empty());
|
||||
assert!(whitespace.is_empty());
|
||||
}
|
||||
|
||||
/// Small file within the byte-limit is returned unmodified.
|
||||
@@ -161,8 +201,10 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
.await
|
||||
.expect("global doc expected");
|
||||
|
||||
assert_eq!(loaded.contents, "global\u{FFFD} doc");
|
||||
assert_eq!(loaded.path, path);
|
||||
assert_eq!(
|
||||
loaded,
|
||||
LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone())
|
||||
);
|
||||
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
|
||||
}
|
||||
|
||||
@@ -177,7 +219,8 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
let res = AgentsMdManager::new(&config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
.expect("doc expected")
|
||||
.text();
|
||||
|
||||
assert_eq!(res, "project\u{FFFD} doc");
|
||||
let canonical_path = dunce::canonicalize(&path).expect("canonical doc path");
|
||||
@@ -272,6 +315,33 @@ 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 = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
|
||||
)
|
||||
.expect("absolute project doc path");
|
||||
|
||||
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]
|
||||
@@ -310,8 +380,38 @@ async fn concatenates_root_and_cwd_docs() {
|
||||
let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
cfg.cwd = nested.abs();
|
||||
|
||||
let res = get_user_instructions(&cfg).await.expect("doc expected");
|
||||
assert_eq!(res, "root doc\n\ncrate doc");
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::new(&cfg)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
let root_agents = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"),
|
||||
)
|
||||
.expect("absolute root doc path");
|
||||
let crate_agents = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"),
|
||||
)
|
||||
.expect("absolute crate doc path");
|
||||
let expected = LoadedAgentsMd {
|
||||
entries: vec![
|
||||
InstructionEntry {
|
||||
contents: "root doc".to_string(),
|
||||
provenance: InstructionProvenance::Project(root_agents.clone()),
|
||||
},
|
||||
InstructionEntry {
|
||||
contents: "crate doc".to_string(),
|
||||
provenance: InstructionProvenance::Project(crate_agents.clone()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(loaded.text(), "root doc\n\ncrate doc");
|
||||
assert_eq!(
|
||||
loaded.sources().collect::<Vec<_>>(),
|
||||
vec![&root_agents, &crate_agents]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -350,6 +450,38 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
|
||||
assert_eq!(res, "parent doc\n\nchild doc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn child_agents_message_after_global_instructions_uses_plain_separator() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await;
|
||||
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)
|
||||
.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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(
|
||||
loaded.text(),
|
||||
format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instruction_sources_include_global_before_agents_md_docs() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -360,15 +492,85 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
|
||||
fs::create_dir_all(&cfg.codex_home).unwrap();
|
||||
fs::write(&global_agents, "global doc").unwrap();
|
||||
|
||||
let sources = AgentsMdManager::new(&cfg)
|
||||
.instruction_sources(LOCAL_FS.as_ref())
|
||||
.await;
|
||||
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 = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
|
||||
)
|
||||
.expect("absolute project doc path");
|
||||
|
||||
assert_eq!(sources, vec![global_agents, project_agents]);
|
||||
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()),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(
|
||||
loaded.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents, &project_agents]
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.text(),
|
||||
format!("global doc{AGENTS_MD_SEPARATOR}project doc")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn child_agents_message_after_project_docs_is_not_an_instruction_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, Some("global doc")).await;
|
||||
cfg.features.enable(Feature::ChildAgentsMd).unwrap();
|
||||
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
fs::create_dir_all(&cfg.codex_home).unwrap();
|
||||
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)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let project_agents = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
|
||||
)
|
||||
.expect("absolute project doc path");
|
||||
|
||||
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()),
|
||||
},
|
||||
InstructionEntry {
|
||||
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(
|
||||
loaded.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents, &project_agents]
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.text(),
|
||||
format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}")
|
||||
);
|
||||
}
|
||||
|
||||
/// AGENTS.override.md is preferred over AGENTS.md when both are present.
|
||||
|
||||
Reference in New Issue
Block a user