//! AGENTS.md discovery and user instruction assembly. //! //! Project-level documentation is primarily stored in files named `AGENTS.md`. //! Additional fallback filenames can be configured via `project_doc_fallback_filenames`. //! Fallback entries containing path syntax for the executor's OS are ignored //! before any filesystem probes use them. //! We include the concatenation of all files found along the path from the //! project root to the current working directory as follows: //! //! 1. Determine the project root by walking upwards from the current working //! directory until a configured `project_root_markers` entry is found. //! When `project_root_markers` is unset, the default marker list is used //! (`.git`). If no marker is found, only the current working directory is //! considered. An empty marker list disables parent traversal. //! 2. Collect every `AGENTS.md` found from the project root down to the //! current working directory (inclusive) and concatenate their contents in //! that order. //! 3. We do **not** walk past the project root. use crate::config::Config; use crate::context::UserInstructions as ContextUserInstructions; use crate::environment_selection::TurnEnvironmentSnapshot; use codex_config::ConfigLayerSource; 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::ExecutorFileSystem; use codex_exec_server::GetMetadataOptions; use codex_exec_server::ReadFileOptions; use codex_extension_api::Instructions; use codex_file_system::FileSystemSandboxContext; use codex_file_system::FindUpErrorPolicy; use codex_file_system::find_nearest_ancestor_with_markers; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use futures::StreamExt; use std::io; use toml::Value as TomlValue; use tracing::error; /// Default filename scanned for AGENTS.md instructions. pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; /// Preferred local override for AGENTS.md instructions. pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; /// When both user and project AGENTS.md docs are present, they will be /// concatenated with the following separator. const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; // Metadata probes are cheap and the exec-server transport already bounds total in-flight calls. // This covers typical project hierarchies in one remote round trip without monopolizing that // transport when independent startup discovery runs concurrently. const MAX_CONCURRENT_ANCESTOR_PROBES: usize = 256; /// Loads project AGENTS.md content and combines it with host-provided user /// instructions. pub(crate) async fn load_project_instructions( config: &Config, user_instructions: Option, environments: &TurnEnvironmentSnapshot, ) -> io::Result> { let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); if config.active_project.is_untrusted() { return Ok((!loaded.is_empty()).then_some(loaded)); } let mut remaining = config.project_doc_max_bytes; for turn_environment in environments.turn_environments() { if remaining == 0 { break; } let filesystem = turn_environment.environment.get_filesystem(); let sandbox = (!turn_environment .permission_profile() .file_system_sandbox_policy() .has_full_disk_read_access()) .then(|| turn_environment.sandbox_context(/*additional_permissions*/ None)); match read_agents_md( config, filesystem.as_ref(), &turn_environment.selection.environment_id, turn_environment.cwd(), remaining, sandbox.as_ref(), ) .await { Ok(Some(docs)) => { for entry in docs.entries { remaining = remaining.saturating_sub(entry.contents.len()); loaded.entries.push(entry); } } Ok(None) => {} Err(error) if sandbox.is_none() => { error!( environment_id = turn_environment.selection.environment_id, "error trying to find AGENTS.md docs: {error:#}" ); } Err(error) => { return Err(io::Error::new( error.kind(), format!( "failed to load AGENTS.md instructions for environment `{}`: {error}", turn_environment.selection.environment_id ), )); } } } Ok((!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. #[tracing::instrument(name = "agents_md.load", skip_all, fields(max_total = max_total))] async fn read_agents_md( config: &Config, fs: &dyn ExecutorFileSystem, environment_id: &str, cwd: &PathUri, max_total: usize, sandbox: Option<&FileSystemSandboxContext>, ) -> io::Result> { if max_total == 0 { return Ok(None); } let paths = agents_md_paths(config, cwd, fs, sandbox).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 mut data = match fs.read_file(&p, ReadFileOptions::default(), sandbox).await { Ok(data) => data, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), }; let size = data.len() as u64; if size > remaining { data.truncate(remaining as usize); } if size > remaining { tracing::warn!( path = %p, remaining_bytes = remaining, "project doc exceeds remaining budget; truncating" ); } let text = String::from_utf8_lossy(&data).to_string(); if !text.trim().is_empty() { loaded.entries.push(InstructionEntry { contents: text, provenance: InstructionProvenance::Project { source_path: p, environment_id: environment_id.to_string(), cwd: cwd.clone(), }, }); 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. #[tracing::instrument(name = "agents_md.discover", skip_all)] async fn agents_md_paths( config: &Config, cwd: &PathUri, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, ) -> io::Result> { let dir = cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in config.config_layer_stack.layers_low_to_high() { 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 project_root = find_nearest_ancestor_with_markers( fs, &dir, project_root_markers, FindUpErrorPolicy::Ignore, sandbox, ) .await?; let search_dirs = 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 candidate_filenames = candidate_filenames(config, cwd); let candidate_filenames = &candidate_filenames; let mut results = futures::stream::iter(search_dirs) .map(|directory| async move { for name in candidate_filenames { let candidate = directory .join(name) .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; match fs .get_metadata(&candidate, GetMetadataOptions::default(), sandbox) .await { Ok(metadata) if metadata.is_file => return Ok(Some(candidate)), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} Err(err) => return Err(err), } } Ok(None) }) .buffered(MAX_CONCURRENT_ANCESTOR_PROBES); let mut found = Vec::new(); while let Some(result) = results.next().await { if let Some(candidate) = result? { found.push(candidate); } } Ok(found) } fn candidate_filenames<'a>(config: &'a Config, cwd: &PathUri) -> Vec<&'a 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; } // Use the executor's path convention, not the host's: resolving a Windows // network path can send ambient credentials even during metadata probes. if matches!(candidate, "." | "..") || candidate.contains(['/', '\0']) || cwd.infer_path_convention() == Some(PathConvention::Windows) && candidate.contains(['\\', ':']) { tracing::warn!("ignoring project_doc_fallback_filenames entry that is not a filename"); continue; } if !names.contains(&candidate) { names.push(candidate); } } names } /// Model-visible instructions loaded from AGENTS.md files and internal /// guidance. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct LoadedAgentsMd { /// Account- or home-scoped instructions supplied by the host. user_instructions: Option, /// Thread-scoped instructions supplied by the host. thread_instructions: Option, /// Ordered instructions and their provenance. entries: Vec, } impl LoadedAgentsMd { /// Creates loaded instructions containing one user-level AGENTS.md entry. pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self { if contents.trim().is_empty() { return Self::default(); } Self { user_instructions: Some(Instructions { text: contents, source: Some(path), }), thread_instructions: None, entries: Vec::new(), } } fn from_user_instructions(user_instructions: Option) -> Self { Self { user_instructions: user_instructions .filter(|instructions| !instructions.text.trim().is_empty()), thread_instructions: None, entries: Vec::new(), } } pub(crate) fn with_instructions( mut self, user_instructions: Option, thread_instructions: Option, ) -> Option { self.user_instructions = user_instructions.filter(|instructions| !instructions.text.trim().is_empty()); self.thread_instructions = thread_instructions.filter(|instructions| !instructions.text.trim().is_empty()); (!self.is_empty()).then_some(self) } /// Creates source-less user instructions for tests. /// /// This cannot be gated with `#[cfg(test)]` because integration tests /// compile `codex-core` as a normal dependency without that configuration. pub fn from_text_for_testing(contents: impl Into) -> Self { let contents = contents.into(); if contents.trim().is_empty() { return Self::default(); } Self { user_instructions: None, thread_instructions: None, entries: vec![InstructionEntry { contents, provenance: InstructionProvenance::Internal, }], } } fn is_empty(&self) -> bool { self.user_instructions.is_none() && self.thread_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 { if self.has_multiple_project_environments() { self.environment_labeled_text() } else { self.legacy_text() } } fn legacy_text(&self) -> String { let mut output = String::new(); let mut has_previous = false; let mut previous_was_project = false; for instructions in self .user_instructions .iter() .chain(self.thread_instructions.iter()) { if has_previous { output.push_str("\n\n"); } output.push_str(&instructions.text); has_previous = true; } for entry in &self.entries { 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 = if is_project && !previous_was_project { AGENTS_MD_SEPARATOR } else { "\n\n" }; output.push_str(separator); } output.push_str(&entry.contents); has_previous = true; previous_was_project = is_project; } output } fn environment_labeled_text(&self) -> String { let mut output = String::new(); let mut has_previous = false; let mut previous_environment: Option<(&str, &PathUri)> = None; for instructions in self .user_instructions .iter() .chain(self.thread_instructions.iter()) { if has_previous { output.push_str("\n\n"); } output.push_str(&instructions.text); has_previous = true; } for entry in &self.entries { match &entry.provenance { InstructionProvenance::Project { environment_id, cwd, .. } => { if has_previous { output.push_str("\n\n"); } // One environment can contribute several hierarchical AGENTS.md files from // its project root through its cwd. Label that environment once for the // complete group rather than repeating the label before every file. let environment = (environment_id.as_str(), cwd); if previous_environment != Some(environment) { output.push_str(&format!( "for `{}` with root {}\n\n", environment_id, cwd.inferred_native_path_string() )); } output.push_str(&entry.contents); previous_environment = Some(environment); } InstructionProvenance::Internal => { if has_previous { output.push_str("\n\n"); } output.push_str(&entry.contents); previous_environment = None; } } has_previous = true; } output } pub(crate) fn contextual_user_fragment(&self) -> ContextUserInstructions { // One contributing project environment retains the legacy cwd wrapper. With two or more, // the body labels every contributing environment itself, so the outer cwd is omitted. let directory = if self.has_multiple_project_environments() { None } else { self.single_project_cwd() .map(PathUri::inferred_native_path_string) }; ContextUserInstructions { directory, text: self.text(), } } /// Returns the AGENTS.md files that supplied instruction entries. pub fn sources(&self) -> impl Iterator + '_ { self.user_instructions .iter() .chain(self.thread_instructions.iter()) .filter_map(|instructions| instructions.source.as_ref().map(PathUri::from_abs_path)) .chain( self.entries .iter() .filter_map(|entry| entry.provenance.path()), ) } fn has_multiple_project_environments(&self) -> bool { let mut first_environment_id = None; self.entries.iter().any(|entry| { let InstructionProvenance::Project { environment_id, .. } = &entry.provenance else { return false; }; match first_environment_id { Some(first_environment_id) => first_environment_id != environment_id, None => { first_environment_id = Some(environment_id); false } } }) } fn single_project_cwd(&self) -> Option<&PathUri> { self.entries .iter() .find_map(|entry| match &entry.provenance { InstructionProvenance::Project { cwd, .. } => Some(cwd), InstructionProvenance::Internal => None, }) } } /// One model-visible instruction and its provenance. #[derive(Clone, Debug, PartialEq, Eq)] struct InstructionEntry { /// Model-visible instruction text. contents: String, /// Origin of the instruction. provenance: InstructionProvenance, } #[derive(Clone, Debug, PartialEq, Eq)] enum InstructionProvenance { /// Workspace instructions discovered from project AGENTS.md files. Project { /// Exact AGENTS.md file, distinct from the environment's selected cwd. source_path: PathUri, environment_id: String, cwd: PathUri, }, /// Instructions without a file source, including internally defined guidance. Internal, } impl InstructionProvenance { fn path(&self) -> Option { match self { Self::Project { source_path, .. } => Some(source_path.clone()), Self::Internal => None, } } } #[cfg(test)] #[path = "agents_md_tests.rs"] mod tests;