Files
codex/codex-rs/core/src/agents_md.rs
T
Adam Perry @ OpenAIandGitHub 236b50125d [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`
2026-06-11 19:28:47 +00:00

402 lines
13 KiB
Rust

//! 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`.
//! 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 codex_app_server_protocol::ConfigLayerSource;
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::ExecutorFileSystem;
use codex_extension_api::UserInstructions;
use codex_features::Feature;
use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
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";
/// 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 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(
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 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 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);
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 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),
}
}
}
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;
}
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 {
/// Host-provided user instructions.
user_instructions: Option<UserInstructions>,
/// Ordered instructions and their provenance.
entries: Vec<InstructionEntry>,
}
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(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(),
}
}
/// 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<String>) -> Self {
let contents = contents.into();
if contents.trim().is_empty() {
return Self::default();
}
Self {
user_instructions: None,
entries: vec![InstructionEntry {
contents,
provenance: InstructionProvenance::Internal,
}],
}
}
fn is_empty(&self) -> bool {
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 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 {
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
}
/// 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.user_instructions
.iter()
.map(|instructions| &instructions.source)
.chain(
self.entries
.iter()
.filter_map(|entry| entry.provenance.path()),
)
}
}
/// 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(AbsolutePathBuf),
/// Instructions without a file source, including internally defined guidance.
Internal,
}
impl InstructionProvenance {
fn path(&self) -> Option<&AbsolutePathBuf> {
match self {
Self::Project(path) => Some(path),
Self::Internal => None,
}
}
}
fn warn_invalid_utf8(
path: &AbsolutePathBuf,
data: &[u8],
source: &str,
startup_warnings: &mut Vec<String>,
) {
if let Err(err) = std::str::from_utf8(data) {
startup_warnings.push(format!(
"{source} AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.",
path.display()
));
}
}
#[cfg(test)]
#[path = "agents_md_tests.rs"]
mod tests;