From acfd94f625c4f75d927c3debc533347bcd19da24 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 9 Jan 2026 13:47:37 -0800 Subject: [PATCH] Add hierarchical agent prompt (#8996) --- codex-rs/core/hierarchical_agents_message.md | 7 ++ codex-rs/core/src/features.rs | 8 +++ codex-rs/core/src/project_doc.rs | 65 +++++++++-------- .../core/tests/suite/hierarchical_agents.rs | 71 +++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + docs/agents_md.md | 4 ++ 6 files changed, 125 insertions(+), 31 deletions(-) create mode 100644 codex-rs/core/hierarchical_agents_message.md create mode 100644 codex-rs/core/tests/suite/hierarchical_agents.rs diff --git a/codex-rs/core/hierarchical_agents_message.md b/codex-rs/core/hierarchical_agents_message.md new file mode 100644 index 0000000000..4f782078c8 --- /dev/null +++ b/codex-rs/core/hierarchical_agents_message.md @@ -0,0 +1,7 @@ +Files called AGENTS.md commonly appear in many places inside a container - at "/", in "~", deep within git repositories, or in any other directory; their location is not limited to version-controlled folders. + +Their purpose is to pass along human guidance to you, the agent. Such guidance can include coding standards, explanations of the project layout, steps for building or testing, and even wording that must accompany a GitHub pull-request description produced by the agent; all of it is to be followed. + +Each AGENTS.md governs the entire directory that contains it and every child directory beneath that point. Whenever you change a file, you have to comply with every AGENTS.md whose scope covers that file. Naming conventions, stylistic rules and similar directives are restricted to the code that falls inside that scope unless the document explicitly states otherwise. + +When two AGENTS.md files disagree, the one located deeper in the directory structure overrides the higher-level file, while instructions given directly in the prompt by the system, developer, or user outrank any AGENTS.md content. diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index b268bf6d78..8c1c597ee7 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -86,6 +86,8 @@ pub enum Feature { RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, + /// Append additional AGENTS.md guidance to user instructions. + HierarchicalAgents, /// Experimental TUI v2 (viewport) implementation. Tui2, /// Enforce UTF8 output in Powershell. @@ -352,6 +354,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::HierarchicalAgents, + key: "hierarchical_agents", + stage: Stage::Experimental, + default_enabled: false, + }, FeatureSpec { id: Feature::ApplyPatchFreeform, key: "apply_patch_freeform", diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 79f82c4598..365475e621 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -14,6 +14,7 @@ //! 3. We do **not** walk past the Git root. use crate::config::Config; +use crate::features::Feature; use crate::skills::SkillMetadata; use crate::skills::render_skills_section; use dunce::canonicalize as normalize_path; @@ -21,6 +22,9 @@ use std::path::PathBuf; use tokio::io::AsyncReadExt; use tracing::error; +pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str = + include_str!("../hierarchical_agents_message.md"); + /// Default filename scanned for project-level docs. pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md"; /// Preferred local override for project-level docs. @@ -36,35 +40,46 @@ pub(crate) async fn get_user_instructions( config: &Config, skills: Option<&[SkillMetadata]>, ) -> Option { - let skills_section = skills.and_then(render_skills_section); + let project_docs = read_project_docs(config).await; - let project_docs = match read_project_docs(config).await { - Ok(docs) => docs, + let mut output = String::new(); + + if let Some(instructions) = config.user_instructions.clone() { + output.push_str(&instructions); + } + + match project_docs { + Ok(Some(docs)) => { + if !output.is_empty() { + output.push_str(PROJECT_DOC_SEPARATOR); + } + output.push_str(&docs); + } + Ok(None) => {} Err(e) => { error!("error trying to find project doc: {e:#}"); - return config.user_instructions.clone(); } }; - let combined_project_docs = merge_project_docs_with_skills(project_docs, skills_section); - - let mut parts: Vec = Vec::new(); - - if let Some(instructions) = config.user_instructions.clone() { - parts.push(instructions); - } - - if let Some(project_doc) = combined_project_docs { - if !parts.is_empty() { - parts.push(PROJECT_DOC_SEPARATOR.to_string()); + let skills_section = skills.and_then(render_skills_section); + if let Some(skills_section) = skills_section { + if !output.is_empty() { + output.push_str("\n\n"); } - parts.push(project_doc); + output.push_str(&skills_section); } - if parts.is_empty() { - None + if config.features.enabled(Feature::HierarchicalAgents) { + if !output.is_empty() { + output.push_str("\n\n"); + } + output.push_str(HIERARCHICAL_AGENTS_MESSAGE); + } + + if !output.is_empty() { + Some(output) } else { - Some(parts.concat()) + None } } @@ -217,18 +232,6 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> { names } -fn merge_project_docs_with_skills( - project_doc: Option, - skills_section: Option, -) -> Option { - match (project_doc, skills_section) { - (Some(doc), Some(skills)) => Some(format!("{doc}\n\n{skills}")), - (Some(doc), None) => Some(doc), - (None, Some(skills)) => Some(skills), - (None, None) => None, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs new file mode 100644 index 0000000000..cc7b78a94e --- /dev/null +++ b/codex-rs/core/tests/suite/hierarchical_agents.rs @@ -0,0 +1,71 @@ +use codex_core::features::Feature; +use core_test_support::load_sse_fixture_with_id; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; + +const HIERARCHICAL_AGENTS_SNIPPET: &str = + "Files called AGENTS.md commonly appear in many places inside a container"; + +fn sse_completed(id: &str) -> String { + load_sse_fixture_with_id("../fixtures/completed_template.json", id) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { + let server = start_mock_server().await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::HierarchicalAgents); + std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md"); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.submit_turn("hello").await.expect("submit turn"); + + let request = resp_mock.single_request(); + let user_messages = request.message_input_texts("user"); + let instructions = user_messages + .iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!( + instructions.contains("be nice"), + "expected AGENTS.md text included: {instructions}" + ); + let snippet_pos = instructions + .find(HIERARCHICAL_AGENTS_SNIPPET) + .expect("expected hierarchical agents snippet"); + let base_pos = instructions + .find("be nice") + .expect("expected AGENTS.md text"); + assert!( + snippet_pos > base_pos, + "expected hierarchical agents message appended after base instructions: {instructions}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hierarchical_agents_emits_when_no_project_doc() { + let server = start_mock_server().await; + let resp_mock = mount_sse_once(&server, sse_completed("resp1")).await; + + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::HierarchicalAgents); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.submit_turn("hello").await.expect("submit turn"); + + let request = resp_mock.single_request(); + let user_messages = request.message_input_texts("user"); + let instructions = user_messages + .iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!( + instructions.contains(HIERARCHICAL_AGENTS_SNIPPET), + "expected hierarchical agents message appended: {instructions}" + ); +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index effbc8a931..44093778d3 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -30,6 +30,7 @@ mod exec; mod exec_policy; mod fork_thread; mod grep_files; +mod hierarchical_agents; mod items; mod json_result; mod list_dir; diff --git a/docs/agents_md.md b/docs/agents_md.md index 4fa02abd1d..40222d1135 100644 --- a/docs/agents_md.md +++ b/docs/agents_md.md @@ -1,3 +1,7 @@ # AGENTS.md For information about AGENTS.md, see [this documentation](https://developers.openai.com/codex/guides/agents-md). + +## Hierarchical agents message + +When the `hierarchical_agents` feature flag is enabled (via `[features]` in `config.toml`), Codex appends additional guidance about AGENTS.md scope and precedence to the user instructions message and emits that message even when no AGENTS.md is present.