[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`
This commit is contained in:
Adam Perry @ OpenAI authored and GitHub committed 2026-06-11 19:28:47 +00:00
1 parent b2a4e3be27
commit 236b50125d
49 files changed
+1368 -567

No files matched your search

+228 -275
View File
@@ -21,8 +21,8 @@ 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::Environment;
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;
@@ -40,291 +40,220 @@ pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md";
/// concatenated with the following separator.
const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
/// Resolves AGENTS.md files into model-visible user instructions and source
/// paths.
pub struct AgentsMdManager<'a> {
config: &'a Config,
}
impl<'a> AgentsMdManager<'a> {
pub fn new(config: &'a Config) -> Self {
Self { config }
}
pub(crate) async fn load_global_instructions(
fs: &dyn ExecutorFileSystem,
codex_dir: Option<&AbsolutePathBuf>,
startup_warnings: &mut Vec<String>,
) -> Option<LoadedAgentsMd> {
let base = codex_dir?;
for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] {
let path = base.join(candidate);
// A missing global instructions file is normal, but an unrepresentable
// configured path means Codex cannot honor the workspace configuration.
let path_uri = match PathUri::from_abs_path(&path) {
Ok(path_uri) => path_uri,
Err(err) => {
startup_warnings.push(format!(
"Failed to read global AGENTS.md instructions from `{}`: {err}",
path.display()
));
continue;
}
};
let data = match fs.read_file(&path_uri, /*sandbox*/ None).await {
Ok(data) => data,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) if err.kind() == io::ErrorKind::IsADirectory => continue,
Err(err) => {
startup_warnings.push(format!(
"Failed to read global AGENTS.md instructions from `{}`: {err}",
path.display()
));
continue;
}
};
warn_invalid_utf8(&path, &data, "Global", startup_warnings);
let contents = String::from_utf8_lossy(&data);
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path));
}
}
None
}
/// Combines configured user instructions and AGENTS.md content into a
/// single model-visible instruction string.
pub(crate) async fn user_instructions(
&self,
environment: &Environment,
startup_warnings: &mut Vec<String>,
) -> Option<LoadedAgentsMd> {
let fs = environment.get_filesystem();
self.user_instructions_with_fs(fs.as_ref(), startup_warnings)
.await
}
async fn user_instructions_with_fs(
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> Option<LoadedAgentsMd> {
let agents_md_docs = self.read_agents_md(fs, startup_warnings).await;
let mut loaded = self.config.user_instructions.clone().unwrap_or_default();
match agents_md_docs {
/// 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 self.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(
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> io::Result<Option<LoadedAgentsMd>> {
let max_total = self.config.project_doc_max_bytes;
if config.features.enabled(Feature::ChildAgentsMd) {
loaded.entries.push(InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
});
}
if max_total == 0 {
return Ok(None);
(!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 paths = self.agents_md_paths(fs).await?;
if paths.is_empty() {
return Ok(None);
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 remaining: u64 = max_total as u64;
let mut loaded = LoadedAgentsMd::default();
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);
for p in paths {
if remaining == 0 {
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 path_uri = PathUri::from_abs_path(&p)?;
match fs.get_metadata(&path_uri, /*sandbox*/ None).await {
Ok(metadata) if !metadata.is_file => continue,
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),
}
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", 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))
}
}
/// Discover the list of AGENTS.md files using the same search rules as
/// `read_agents_md`, but return the file paths instead of concatenated
/// contents. The list is ordered from project root to the current working
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
/// is zero, returns an empty list.
async fn agents_md_paths(
&self,
fs: &dyn ExecutorFileSystem,
) -> io::Result<Vec<AbsolutePathBuf>> {
if self.config.project_doc_max_bytes == 0 {
return Ok(Vec::new());
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;
}
let dir = self.config.cwd.clone();
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in self.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);
if !names.contains(&candidate) {
names.push(candidate);
}
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 = self.candidate_filenames();
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(&self) -> Vec<&str> {
let mut names: Vec<&str> =
Vec::with_capacity(2 + self.config.project_doc_fallback_filenames.len());
names.push(LOCAL_AGENTS_MD_FILENAME);
names.push(DEFAULT_AGENTS_MD_FILENAME);
for candidate in &self.config.project_doc_fallback_filenames {
let candidate = candidate.as_str();
if candidate.is_empty() {
continue;
}
if !names.contains(&candidate) {
names.push(candidate);
}
}
names
}
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>,
}
@@ -336,10 +265,19 @@ impl LoadedAgentsMd {
return Self::default();
}
Self {
entries: vec![InstructionEntry {
contents,
provenance: InstructionProvenance::User(path),
}],
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(),
}
}
@@ -353,6 +291,7 @@ impl LoadedAgentsMd {
return Self::default();
}
Self {
user_instructions: None,
entries: vec![InstructionEntry {
contents,
provenance: InstructionProvenance::Internal,
@@ -361,40 +300,57 @@ impl LoadedAgentsMd {
}
fn is_empty(&self) -> bool {
self.entries
.iter()
.all(|entry| entry.contents.trim().is_empty())
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 previous_provenance: Option<&InstructionProvenance> = None;
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 {
if let Some(previous_provenance) = previous_provenance {
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 = match (previous_provenance, &entry.provenance) {
(
InstructionProvenance::User(_) | InstructionProvenance::Internal,
InstructionProvenance::Project(_),
) => AGENTS_MD_SEPARATOR,
_ => "\n\n",
let separator = if is_project && !previous_was_project {
AGENTS_MD_SEPARATOR
} else {
"\n\n"
};
output.push_str(separator);
}
output.push_str(&entry.contents);
previous_provenance = Some(&entry.provenance);
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.entries
self.user_instructions
.iter()
.filter_map(|entry| entry.provenance.path())
.map(|instructions| &instructions.source)
.chain(
self.entries
.iter()
.filter_map(|entry| entry.provenance.path()),
)
}
}
@@ -410,9 +366,6 @@ struct InstructionEntry {
#[derive(Clone, Debug, PartialEq, Eq)]
enum InstructionProvenance {
/// User-level instructions, normally loaded from CODEX_HOME.
User(AbsolutePathBuf),
/// Workspace instructions discovered from project AGENTS.md files.
Project(AbsolutePathBuf),
@@ -423,7 +376,7 @@ enum InstructionProvenance {
impl InstructionProvenance {
fn path(&self) -> Option<&AbsolutePathBuf> {
match self {
Self::User(path) | Self::Project(path) => Some(path),
Self::Project(path) => Some(path),
Self::Internal => None,
}
}