From 4aacd80fa6254fd5227be38a33ad55a32d66aee9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 9 May 2025 11:36:15 -0700 Subject: [PATCH] feat: add support for AGENTS.md (formerly CODEX.md) --- codex-rs/core/src/codex.rs | 22 ++++++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/project_doc.rs | 107 +++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 codex-rs/core/src/project_doc.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5cd5a6799d..d4d8bb0bfe 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,7 @@ use crate::models::FunctionCallOutputPayload; use crate::models::ResponseInputItem; use crate::models::ResponseItem; use crate::models::ShellToolCallParams; +use crate::project_doc::find_project_doc; use crate::protocol::AskForApproval; use crate::protocol::Event; use crate::protocol::EventMsg; @@ -83,10 +84,12 @@ impl Codex { pub async fn spawn(config: Config, ctrl_c: Arc) -> CodexResult<(Codex, String)> { let (tx_sub, rx_sub) = async_channel::bounded(64); let (tx_event, rx_event) = async_channel::bounded(64); + + let instructions = create_full_instructions(&config).await; let configure_session = Op::ConfigureSession { provider: config.model_provider.clone(), model: config.model.clone(), - instructions: config.instructions.clone(), + instructions, approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), disable_response_storage: config.disable_response_storage, @@ -136,6 +139,23 @@ impl Codex { } } +async fn create_full_instructions(config: &Config) -> Option { + match find_project_doc(config).await { + Ok(Some(project_doc)) => { + let original_instructions = config.instructions.clone(); + match original_instructions { + Some(instructions) => Some(format!("{instructions}{project_doc}")), + None => Some(project_doc), + } + } + Ok(None) => config.instructions.clone(), + Err(e) => { + error!("error trying to find project doc: {e:#}"); + config.instructions.clone() + } + } +} + /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 7774e0f5cb..57b7e188ef 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -27,6 +27,7 @@ mod model_provider_info; pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::WireApi; mod models; +mod project_doc; pub mod protocol; mod rollout; mod safety; diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs new file mode 100644 index 0000000000..c8c9ebc233 --- /dev/null +++ b/codex-rs/core/src/project_doc.rs @@ -0,0 +1,107 @@ +//! Project-level documentation discovery. +//! +//! Project-level documentation can be stored in a file named `AGENTS.md`. +//! Currently, we include only the contents of the first file found as follows: +//! +//! 1. Look for the doc file in the current working directory. +//! 2. If not found, walk *upwards* until the Git repository root is reached +//! (detected by the presence of a `.git` directory/file). +//! 3. If/when the Git root is encountered, look for the doc file there. If it +//! exists, the search stops – we do **not** walk past the Git root. + +use crate::config::Config; + +use std::path::Path; + +/// Maximum number of bytes of the documentation that will be embedded. Larger +/// files are *silently truncated* to this size so we never blow the context +/// window. +pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB + +/// Currently, we only match `AGENTS.md` exactly. +const CANDIDATE_FILENAMES: &[&str] = &["AGENTS.md"]; + +/// Attempt to locate and load the project documentation. +/// +/// On success returns `Ok(Some(contents))`. 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. +pub(crate) async fn find_project_doc(config: &Config) -> std::io::Result> { + // Attempt to load from the working directory first. + if let Some(doc) = load_first_candidate(&config.cwd, CANDIDATE_FILENAMES).await? { + return Ok(Some(doc)); + } + + // Walk up towards the filesystem root, stopping once we encounter the Git + // repository root. The presence of **either** a `.git` *file* or + // *directory* counts. + let mut dir = config.cwd.clone(); + + // Canonicalize the path so that we do not end up in an infinite loop when + // `cwd` contains `..` components. + if let Ok(canon) = dir.canonicalize() { + dir = canon; + } + + while let Some(parent) = dir.parent() { + // `.git` can be a *file* (for worktrees or submodules) or a *dir*. + let git_marker = dir.join(".git"); + let git_exists = match tokio::fs::metadata(&git_marker).await { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(e), + }; + + if git_exists { + // We are at the repo root – attempt one final load. + if let Some(doc) = load_first_candidate(&dir, CANDIDATE_FILENAMES).await? { + return Ok(Some(doc)); + } + break; + } + + dir = parent.to_path_buf(); + } + + Ok(None) +} + +/// Attempt to load the first candidate file found in `dir`. Returns the file +/// contents (truncated) when successful. +async fn load_first_candidate(dir: &Path, names: &[&str]) -> std::io::Result> { + use tokio::io::AsyncReadExt; + + for name in names { + let candidate = dir.join(name); + + let file = match tokio::fs::File::open(&candidate).await { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + Ok(f) => f, + }; + + let size = file.metadata().await?.len(); + + let reader = tokio::io::BufReader::new(file); + let mut data = Vec::with_capacity(std::cmp::min(size as usize, PROJECT_DOC_MAX_BYTES)); + let mut limited = reader.take(PROJECT_DOC_MAX_BYTES as u64); + limited.read_to_end(&mut data).await?; + + if size as usize > PROJECT_DOC_MAX_BYTES { + tracing::warn!( + "Project doc `{}` exceeds {PROJECT_DOC_MAX_BYTES} bytes - truncating.", + candidate.display(), + ); + } + + let contents = String::from_utf8_lossy(&data).to_string(); + if contents.trim().is_empty() { + // Empty file – treat as not found. + continue; + } + + return Ok(Some(contents)); + } + + Ok(None) +}