[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
2026-06-11 12:28:47 -07:00
committed by GitHub
Unverified
parent b2a4e3be27
commit 236b50125d
49 changed files with 1368 additions and 567 deletions
+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,
}
}
+327 -114
View File
@@ -1,29 +1,172 @@
use super::*;
use crate::config::ConfigBuilder;
use async_trait::async_trait;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::FileMetadata;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::LOCAL_FS;
use codex_exec_server::ReadDirectoryEntry;
use codex_exec_server::RemoveOptions;
use codex_extension_api::UserInstructions;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use core_test_support::PathBufExt;
use core_test_support::TempDirExt;
use core_test_support::create_directory_symlink;
use pretty_assertions::assert_eq;
use std::fs;
use std::io;
use std::ops::Deref;
use std::ops::DerefMut;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
async fn get_user_instructions(config: &Config) -> Option<String> {
#[derive(Clone, Copy)]
enum InjectedFailure {
Metadata(io::ErrorKind),
Read(io::ErrorKind),
}
struct FailingFileSystem {
path: AbsolutePathBuf,
failure: InjectedFailure,
}
#[async_trait]
impl ExecutorFileSystem for FailingFileSystem {
async fn canonicalize(
&self,
_path: &PathUri,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<PathUri> {
unreachable!("canonicalize should not be called")
}
async fn read_file(
&self,
path: &PathUri,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<Vec<u8>> {
if path.to_abs_path()? == self.path
&& let InjectedFailure::Read(kind) = self.failure
{
return Err(io::Error::new(kind, "injected read failure"));
}
LOCAL_FS.read_file(path, sandbox).await
}
async fn write_file(
&self,
_path: &PathUri,
_contents: Vec<u8>,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
unreachable!("write_file should not be called")
}
async fn create_directory(
&self,
_path: &PathUri,
_create_directory_options: CreateDirectoryOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
unreachable!("create_directory should not be called")
}
async fn get_metadata(
&self,
path: &PathUri,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<FileMetadata> {
if path.to_abs_path()? == self.path
&& let InjectedFailure::Metadata(kind) = self.failure
{
return Err(io::Error::new(kind, "injected metadata failure"));
}
LOCAL_FS.get_metadata(path, sandbox).await
}
async fn read_directory(
&self,
_path: &PathUri,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<Vec<ReadDirectoryEntry>> {
unreachable!("read_directory should not be called")
}
async fn remove(
&self,
_path: &PathUri,
_remove_options: RemoveOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
unreachable!("remove should not be called")
}
async fn copy(
&self,
_source_path: &PathUri,
_destination_path: &PathUri,
_copy_options: CopyOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
unreachable!("copy should not be called")
}
}
struct TestConfig {
config: Config,
user_instructions: Option<UserInstructions>,
}
impl Deref for TestConfig {
type Target = Config;
fn deref(&self) -> &Self::Target {
&self.config
}
}
impl DerefMut for TestConfig {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.config
}
}
async fn get_user_instructions(config: &TestConfig) -> Option<String> {
let mut warnings = Vec::new();
AgentsMdManager::new(config)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
load_agents_md(config, &mut warnings)
.await
.map(|loaded| loaded.text())
}
async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
AgentsMdManager::new(config)
.agents_md_paths(LOCAL_FS.as_ref())
.await
async fn load_agents_md(config: &TestConfig, warnings: &mut Vec<String>) -> Option<LoadedAgentsMd> {
let mut core_config = config.config.clone();
let existing_warning_count = core_config.startup_warnings.len();
let loaded = load_project_instructions(
&mut core_config,
config.user_instructions.clone(),
Some(LOCAL_FS.as_ref()),
)
.await;
warnings.extend(
core_config
.startup_warnings
.into_iter()
.skip(existing_warning_count),
);
loaded
}
async fn agents_md_paths(config: &TestConfig) -> std::io::Result<Vec<AbsolutePathBuf>> {
super::agents_md_paths(&config.config, LOCAL_FS.as_ref()).await
}
fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) {
@@ -44,7 +187,7 @@ fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) {
/// optionally specify a custom `instructions` string when `None` the
/// value is cleared to mimic a scenario where no system instructions have
/// been configured.
async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> Config {
async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> TestConfig {
let codex_home = TempDir::new().unwrap();
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
@@ -55,13 +198,14 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -
config.cwd = root.abs();
config.project_doc_max_bytes = limit;
config.user_instructions = instructions.map(|text| {
LoadedAgentsMd::new_user(
text.to_owned(),
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
)
let user_instructions = instructions.map(|text| UserInstructions {
text: text.to_owned(),
source: config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
});
config
TestConfig {
config,
user_instructions,
}
}
async fn make_config_with_fallback(
@@ -69,7 +213,7 @@ async fn make_config_with_fallback(
limit: usize,
instructions: Option<&str>,
fallbacks: &[&str],
) -> Config {
) -> TestConfig {
let mut config = make_config(root, limit, instructions).await;
config.project_doc_fallback_filenames = fallbacks
.iter()
@@ -83,7 +227,7 @@ async fn make_config_with_project_root_markers(
limit: usize,
instructions: Option<&str>,
markers: &[&str],
) -> Config {
) -> TestConfig {
let codex_home = TempDir::new().unwrap();
let cli_overrides = vec![(
"project_root_markers".to_string(),
@@ -103,13 +247,14 @@ async fn make_config_with_project_root_markers(
config.cwd = root.abs();
config.project_doc_max_bytes = limit;
config.user_instructions = instructions.map(|text| {
LoadedAgentsMd::new_user(
text.to_owned(),
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
)
let user_instructions = instructions.map(|text| UserInstructions {
text: text.to_owned(),
source: config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
});
config
TestConfig {
config,
user_instructions,
}
}
/// AGENTS.md missing should yield `None`.
@@ -153,12 +298,14 @@ fn empty_loaded_instructions_are_empty() {
#[test]
fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() {
let empty = LoadedAgentsMd {
user_instructions: None,
entries: vec![InstructionEntry {
contents: String::new(),
provenance: InstructionProvenance::Internal,
}],
};
let whitespace = LoadedAgentsMd {
user_instructions: None,
entries: vec![InstructionEntry {
contents: " \n\t".to_string(),
provenance: InstructionProvenance::Internal,
@@ -186,29 +333,6 @@ async fn doc_smaller_than_limit_is_returned() {
);
}
#[tokio::test]
async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
let codex_home = tempfile::tempdir().expect("tempdir");
let codex_home_abs = codex_home.abs();
let path = codex_home_abs.join(DEFAULT_AGENTS_MD_FILENAME);
fs::write(&path, b"global\xFF doc").unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::load_global_instructions(
LOCAL_FS.as_ref(),
Some(&codex_home_abs),
&mut warnings,
)
.await
.expect("global doc expected");
assert_eq!(
loaded,
LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone())
);
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
}
#[tokio::test]
async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
let tmp = tempfile::tempdir().expect("tempdir");
@@ -217,8 +341,7 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
let config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let mut warnings = Vec::new();
let res = AgentsMdManager::new(&config)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
let res = load_agents_md(&config, &mut warnings)
.await
.expect("doc expected")
.text();
@@ -244,6 +367,92 @@ async fn doc_larger_than_limit_is_truncated() {
assert_eq!(res, huge[..LIMIT]);
}
#[tokio::test]
async fn total_byte_limit_truncates_later_project_docs() {
let repo = tempfile::tempdir().expect("tempdir");
fs::write(repo.path().join(".git"), "").unwrap();
fs::write(repo.path().join("AGENTS.md"), "root").unwrap();
let nested = repo.path().join("nested");
fs::create_dir(&nested).unwrap();
fs::write(nested.join("AGENTS.md"), "abcdef").unwrap();
let mut config = make_config(&repo, /*limit*/ 7, /*instructions*/ None).await;
config.cwd = nested.abs();
let mut warnings = Vec::new();
let loaded = load_agents_md(&config, &mut warnings)
.await
.expect("project instructions");
let expected = LoadedAgentsMd {
user_instructions: None,
entries: vec![
InstructionEntry {
contents: "root".to_string(),
provenance: InstructionProvenance::Project(repo.path().join("AGENTS.md").abs()),
},
InstructionEntry {
contents: "abc".to_string(),
provenance: InstructionProvenance::Project(config.cwd.join("AGENTS.md")),
},
],
};
assert_eq!(loaded, expected);
assert_eq!(loaded.text(), "root\n\nabc");
assert_eq!(warnings, Vec::<String>::new());
}
#[tokio::test]
async fn read_agents_md_propagates_metadata_errors() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let marker_path = config.cwd.join(".git");
let fs = FailingFileSystem {
path: marker_path,
failure: InjectedFailure::Metadata(io::ErrorKind::PermissionDenied),
};
let err = read_agents_md(&mut config.config, &fs)
.await
.expect_err("metadata error");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
#[tokio::test]
async fn read_agents_md_propagates_read_errors() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let fs = FailingFileSystem {
path: config.cwd.join("AGENTS.md"),
failure: InjectedFailure::Read(io::ErrorKind::PermissionDenied),
};
let err = read_agents_md(&mut config.config, &fs)
.await
.expect_err("read error");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
#[tokio::test]
async fn read_agents_md_ignores_files_removed_after_discovery() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let fs = FailingFileSystem {
path: config.cwd.join("AGENTS.md"),
failure: InjectedFailure::Read(io::ErrorKind::NotFound),
};
let loaded = read_agents_md(&mut config.config, &fs)
.await
.expect("removed file is recoverable");
assert_eq!(loaded, None);
}
/// When `cwd` is nested inside a repo, the search should locate AGENTS.md
/// placed at the repository root (identified by `.git`).
#[tokio::test]
@@ -286,17 +495,6 @@ async fn zero_byte_limit_disables_docs() {
);
}
#[tokio::test]
async fn zero_byte_limit_disables_discovery() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
let discovery = agents_md_paths(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await)
.await
.expect("discover paths");
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
}
/// When both system instructions and AGENTS.md docs are present the two
/// should be concatenated with the separator.
#[tokio::test]
@@ -315,30 +513,6 @@ async fn merges_existing_instructions_with_agents_md() {
assert_eq!(res, expected);
}
#[tokio::test]
async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
"user instructions".to_string(),
));
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
assert_eq!(
loaded.text(),
format!("user instructions{AGENTS_MD_SEPARATOR}project doc")
);
assert_eq!(loaded.sources().collect::<Vec<_>>(), vec![&project_agents]);
}
/// If there are existing system instructions but AGENTS.md docs are
/// missing we expect the original instructions to be returned unchanged.
#[tokio::test]
@@ -378,13 +552,13 @@ async fn concatenates_root_and_cwd_docs() {
cfg.cwd = nested.abs();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
let loaded = load_agents_md(&cfg, &mut warnings)
.await
.expect("doc expected");
let root_agents = repo.path().join("AGENTS.md").abs();
let crate_agents = cfg.cwd.join("AGENTS.md");
let expected = LoadedAgentsMd {
user_instructions: None,
entries: vec![
InstructionEntry {
contents: "root doc".to_string(),
@@ -435,6 +609,51 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
assert_eq!(res, "parent doc\n\nchild doc");
}
#[tokio::test]
async fn project_layers_do_not_override_project_root_markers() {
let root = tempfile::tempdir().expect("tempdir");
fs::write(root.path().join(".git"), "").unwrap();
fs::write(root.path().join("AGENTS.md"), "root doc").unwrap();
let nested = root.path().join("nested");
fs::create_dir(&nested).unwrap();
fs::write(nested.join("AGENTS.md"), "nested doc").unwrap();
let mut config = make_config(&root, /*limit*/ 4096, /*instructions*/ None).await;
config.cwd = nested.abs();
let project_layer = |dot_codex_folder: AbsolutePathBuf, marker: &str| {
ConfigLayerEntry::new(
ConfigLayerSource::Project { dot_codex_folder },
TomlValue::Table(
[(
"project_root_markers".to_string(),
TomlValue::Array(vec![TomlValue::String(marker.to_string())]),
)]
.into_iter()
.collect(),
),
)
};
config.config_layer_stack = ConfigLayerStack::new(
vec![
project_layer(root.path().join(".codex").abs(), ".ignored-root-marker"),
project_layer(config.cwd.join(".codex"), ".ignored-nested-marker"),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("valid project layer ordering");
let discovery = agents_md_paths(&config).await.expect("discover paths");
assert_eq!(
discovery,
vec![
root.path().join("AGENTS.md").abs(),
config.cwd.join("AGENTS.md"),
]
);
}
#[tokio::test]
async fn agents_md_paths_preserve_symlinked_cwd() {
let tmp = tempfile::tempdir().expect("tempdir");
@@ -462,22 +681,19 @@ async fn child_agents_message_after_global_instructions_uses_plain_separator() {
cfg.features.enable(Feature::ChildAgentsMd).unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
let loaded = load_agents_md(&cfg, &mut warnings)
.await
.expect("instructions expected");
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents),
},
InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
},
],
user_instructions: Some(UserInstructions {
text: "global doc".to_string(),
source: global_agents,
}),
entries: vec![InstructionEntry {
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
provenance: InstructionProvenance::Internal,
}],
};
assert_eq!(loaded, expected);
@@ -498,25 +714,23 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
fs::write(&global_agents, "global doc").unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
let loaded = load_agents_md(&cfg, &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
let expected = LoadedAgentsMd {
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents.clone()),
},
InstructionEntry {
contents: "project doc".to_string(),
provenance: InstructionProvenance::Project(project_agents.clone()),
},
],
user_instructions: Some(UserInstructions {
text: "global doc".to_string(),
source: global_agents.clone(),
}),
entries: vec![InstructionEntry {
contents: "project doc".to_string(),
provenance: InstructionProvenance::Project(project_agents.clone()),
}],
};
assert_eq!(loaded, expected);
assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref());
assert_eq!(
loaded.sources().collect::<Vec<_>>(),
vec![&global_agents, &project_agents]
@@ -539,18 +753,17 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source()
fs::write(&global_agents, "global doc").unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::new(&cfg)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
let loaded = load_agents_md(&cfg, &mut warnings)
.await
.expect("instructions expected");
let project_agents = cfg.cwd.join("AGENTS.md");
let expected = LoadedAgentsMd {
user_instructions: Some(UserInstructions {
text: "global doc".to_string(),
source: global_agents.clone(),
}),
entries: vec![
InstructionEntry {
contents: "global doc".to_string(),
provenance: InstructionProvenance::User(global_agents.clone()),
},
InstructionEntry {
contents: "project doc".to_string(),
provenance: InstructionProvenance::Project(project_agents.clone()),
+6
View File
@@ -5,6 +5,7 @@ use async_channel::Receiver;
use async_channel::Sender;
use codex_analytics::GuardianApprovalRequestSource;
use codex_async_utils::OrCancelExt;
use codex_extension_api::LoadedUserInstructions;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
@@ -79,8 +80,13 @@ pub(crate) async fn run_codex_thread_interactive(
let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let conversation_history = initial_history.unwrap_or(InitialHistory::New);
let forked_from_thread_id = conversation_history.forked_from_id();
let user_instructions = LoadedUserInstructions {
instructions: parent_session.user_instructions().await,
warnings: Vec::new(),
};
let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs {
config,
user_instructions,
installation_id: parent_session.installation_id.clone(),
auth_manager,
models_manager,
-57
View File
@@ -1,5 +1,3 @@
use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME;
use crate::agents_md::LOCAL_AGENTS_MD_FILENAME;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::edit::apply_blocking;
@@ -205,61 +203,6 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
Ok(())
}
#[tokio::test]
async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
let codex_home = tempdir()?;
let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME);
std::fs::write(&global_agents_path, "\n global instructions \n")?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let _ = config.features.enable(Feature::MemoryTool);
let user_instructions = config
.user_instructions
.as_ref()
.expect("global instructions expected");
assert_eq!(user_instructions.text(), "global instructions");
assert_eq!(
user_instructions.sources().collect::<Vec<_>>(),
vec![&global_agents_path]
);
Ok(())
}
#[tokio::test]
async fn load_config_prefers_global_agents_override_instructions() -> std::io::Result<()> {
let codex_home = tempdir()?;
std::fs::write(
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
"global instructions",
)?;
let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME);
std::fs::write(&global_agents_override_path, "local override instructions")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let user_instructions = config
.user_instructions
.as_ref()
.expect("global override instructions expected");
assert_eq!(user_instructions.text(), "local override instructions");
assert_eq!(
user_instructions.sources().collect::<Vec<_>>(),
vec![&global_agents_override_path]
);
Ok(())
}
#[tokio::test]
async fn test_toml_parsing() {
let history_with_persistence = r#"
-12
View File
@@ -1,5 +1,3 @@
use crate::agents_md::AgentsMdManager;
pub use crate::agents_md::LoadedAgentsMd;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::path_utils::normalize_for_native_workdir;
@@ -654,9 +652,6 @@ pub struct Config {
/// Defaults to `false`.
pub show_raw_agent_reasoning: bool,
/// User-provided instructions from AGENTS.md.
pub user_instructions: Option<LoadedAgentsMd>,
/// Base instructions override.
pub base_instructions: Option<String>,
@@ -2609,12 +2604,6 @@ impl Config {
.startup_warnings()
.unwrap_or_default()
.to_vec();
let user_instructions = AgentsMdManager::load_global_instructions(
LOCAL_FS.as_ref(),
Some(&codex_home),
&mut startup_warnings,
)
.await;
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
@@ -3453,7 +3442,6 @@ impl Config {
approvals_reviewer: constrained_approvals_reviewer.value(),
enforce_residency: enforce_residency.value,
notify: cfg.notify,
user_instructions,
base_instructions,
personality,
developer_instructions,
+41 -16
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use anyhow::anyhow;
use codex_analytics::GuardianReviewAnalyticsResult;
use codex_analytics::GuardianReviewSessionKind;
use codex_extension_api::UserInstructions;
use codex_protocol::ThreadId;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::config_types::Personality;
@@ -31,7 +32,6 @@ use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::LoadedAgentsMd;
use crate::codex_delegate::run_codex_thread_interactive;
use crate::config::Config;
use crate::config::Constrained;
@@ -152,7 +152,7 @@ struct GuardianReviewSessionReuseKey {
permissions: Permissions,
developer_instructions: Option<String>,
base_instructions: Option<String>,
user_instructions: Option<LoadedAgentsMd>,
user_instructions: Option<UserInstructions>,
compact_prompt: Option<String>,
cwd: AbsolutePathBuf,
mcp_servers: Constrained<HashMap<String, McpServerConfig>>,
@@ -164,7 +164,10 @@ struct GuardianReviewSessionReuseKey {
}
impl GuardianReviewSessionReuseKey {
fn from_spawn_config(spawn_config: &Config) -> Self {
fn from_spawn_config(
spawn_config: &Config,
user_instructions: Option<UserInstructions>,
) -> Self {
Self {
model: spawn_config.model.clone(),
model_provider_id: spawn_config.model_provider_id.clone(),
@@ -177,7 +180,7 @@ impl GuardianReviewSessionReuseKey {
permissions: spawn_config.permissions.clone(),
developer_instructions: spawn_config.developer_instructions.clone(),
base_instructions: spawn_config.base_instructions.clone(),
user_instructions: spawn_config.user_instructions.clone(),
user_instructions,
compact_prompt: spawn_config.compact_prompt.clone(),
cwd: spawn_config.cwd.clone(),
mcp_servers: spawn_config.mcp_servers.clone(),
@@ -318,7 +321,10 @@ impl GuardianReviewSessionManager {
params: GuardianReviewSessionParams,
) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) {
let deadline = params.deadline;
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&params.spawn_config);
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
&params.spawn_config,
params.parent_session.user_instructions().await,
);
let mut stale_trunk_to_shutdown = None;
let mut spawned_trunk = false;
let trunk_candidate = match run_before_review_deadline(
@@ -441,6 +447,7 @@ impl GuardianReviewSessionManager {
pub(crate) async fn cache_for_test(&self, codex: Codex) {
let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
codex.session.get_config().await.as_ref(),
codex.session.user_instructions().await,
);
self.state.lock().await.trunk = Some(Arc::new(GuardianReviewSession {
reuse_key,
@@ -459,6 +466,7 @@ impl GuardianReviewSessionManager {
pub(crate) async fn register_ephemeral_for_test(&self, codex: Codex) {
let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
codex.session.get_config().await.as_ref(),
codex.session.user_instructions().await,
);
self.state
.lock()
@@ -1075,8 +1083,10 @@ mod tests {
let (tx_event, rx_event) = async_channel::unbounded();
let (_agent_status_tx, agent_status) =
tokio::sync::watch::channel(AgentStatus::PendingInit);
let reuse_key =
GuardianReviewSessionReuseKey::from_spawn_config(session.get_config().await.as_ref());
let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
session.get_config().await.as_ref(),
session.user_instructions().await,
);
(
GuardianReviewSession {
@@ -1179,8 +1189,10 @@ mod tests {
/*reasoning_effort*/ None,
)
.expect("cached guardian config");
let cached_reuse_key =
GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config);
let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
&cached_spawn_config,
/*user_instructions*/ None,
);
let mut changed_parent_config = parent_config;
changed_parent_config.model_provider.base_url =
@@ -1192,12 +1204,18 @@ mod tests {
/*reasoning_effort*/ None,
)
.expect("next guardian config");
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config);
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
&next_spawn_config,
/*user_instructions*/ None,
);
assert_ne!(cached_reuse_key, next_reuse_key);
assert_eq!(
cached_reuse_key,
GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config)
GuardianReviewSessionReuseKey::from_spawn_config(
&cached_spawn_config,
/*user_instructions*/ None,
)
);
}
@@ -1251,8 +1269,10 @@ mod tests {
/*reasoning_effort*/ None,
)
.expect("cached guardian config");
let cached_reuse_key =
GuardianReviewSessionReuseKey::from_spawn_config(&cached_spawn_config);
let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
&cached_spawn_config,
/*user_instructions*/ None,
);
let mut changed_parent_config = parent_config;
changed_parent_config.model_auto_compact_token_limit_scope =
@@ -1264,7 +1284,10 @@ mod tests {
/*reasoning_effort*/ None,
)
.expect("next guardian config");
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(&next_spawn_config);
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
&next_spawn_config,
/*user_instructions*/ None,
);
assert_ne!(cached_reuse_key, next_reuse_key);
}
@@ -1495,8 +1518,10 @@ mod tests {
async fn run_review_removes_trunk_when_event_stream_is_broken() {
let (mut review_session, tx_event, _rx_sub) = test_review_session().await;
let params = test_review_params().await;
review_session.reuse_key =
GuardianReviewSessionReuseKey::from_spawn_config(&params.spawn_config);
review_session.reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
&params.spawn_config,
params.parent_session.user_instructions().await,
);
let manager = GuardianReviewSessionManager {
state: Arc::new(Mutex::new(GuardianReviewSessionState {
trunk: Some(Arc::new(review_session)),
-3
View File
@@ -187,7 +187,6 @@ async fn guardian_test_session_turn_and_rx(
.thread_id = fixed_guardian_parent_session_id();
let mut config = (*turn.config).clone();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
config.user_instructions = None;
let config = Arc::new(config);
let models_manager = test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
@@ -225,7 +224,6 @@ async fn guardian_test_session_and_turn_with_base_url(
session.thread_id = fixed_guardian_parent_session_id();
let mut config = (*turn.config).clone();
config.model_provider.base_url = Some(format!("{base_url}/v1"));
config.user_instructions = None;
let config = Arc::new(config);
let models_manager = test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
@@ -2049,7 +2047,6 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
crate::session::tests::make_session_and_context_with_rx().await;
let mut config = (*turn.config).clone();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
config.user_instructions = None;
let config = Arc::new(config);
let models_manager = test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
-1
View File
@@ -126,7 +126,6 @@ pub type NewConversation = NewThread;
#[deprecated(note = "use CodexThread")]
pub type CodexConversation = CodexThread;
pub(crate) mod agents_md;
pub use agents_md::AgentsMdManager;
pub use agents_md::DEFAULT_AGENTS_MD_FILENAME;
pub use agents_md::LOCAL_AGENTS_MD_FILENAME;
pub use agents_md::LoadedAgentsMd;
+3
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_extension_api::UserInstructionsProvider;
use codex_login::AuthManager;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
@@ -26,6 +27,7 @@ pub async fn build_prompt_input(
mut config: Config,
input: Vec<UserInput>,
state_db: Option<StateDbHandle>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
) -> CodexResult<Vec<ResponseItem>> {
config.ephemeral = true;
@@ -52,6 +54,7 @@ pub async fn build_prompt_input(
.map_err(|err| CodexErr::Fatal(err.to_string()))?,
),
empty_extension_registry(),
user_instructions_provider,
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),
+31 -12
View File
@@ -14,6 +14,7 @@ use crate::agent::AgentControl;
use crate::agent::AgentStatus;
use crate::agent::agent_status_from_event;
use crate::agent::status::is_final;
use crate::agents_md::LoadedAgentsMd;
use crate::attestation::AttestationProvider;
use crate::build_available_skills;
use crate::compact;
@@ -54,6 +55,7 @@ use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::FileSystemSandboxContext;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::LoadedUserInstructions;
use codex_extension_api::PromptSlot;
use codex_features::FEATURES;
use codex_features::Feature;
@@ -291,7 +293,7 @@ use crate::SkillLoadOutcome;
#[cfg(test)]
use crate::SkillMetadata;
use crate::SkillsManager;
use crate::agents_md::AgentsMdManager;
use crate::agents_md::load_project_instructions;
use crate::context::UserInstructions;
use crate::exec_policy::ExecPolicyUpdateError;
use crate::guardian::GuardianReviewSessionManager;
@@ -399,6 +401,7 @@ pub struct CodexSpawnOk {
pub(crate) struct CodexSpawnArgs {
pub(crate) config: Config,
pub(crate) user_instructions: LoadedUserInstructions,
pub(crate) installation_id: String,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) models_manager: SharedModelsManager,
@@ -484,6 +487,7 @@ impl Codex {
async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult<CodexSpawnOk> {
let CodexSpawnArgs {
mut config,
user_instructions,
installation_id,
auth_manager,
models_manager,
@@ -515,16 +519,21 @@ impl Codex {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
let LoadedUserInstructions {
instructions: user_instructions,
warnings: user_instruction_provider_warnings,
} = user_instructions;
// TODO(anp) pull startup_warnings out of Config
config
.startup_warnings
.extend(user_instruction_provider_warnings);
// TODO(anp) assemble instructions from multiple environments
let primary_environment = environment_selections.primary_environment();
let mut user_instruction_warnings = Vec::new();
let user_instructions = if let Some(primary_environment) = primary_environment {
AgentsMdManager::new(&config)
.user_instructions(primary_environment.as_ref(), &mut user_instruction_warnings)
.await
} else {
None
};
config.startup_warnings.extend(user_instruction_warnings);
let primary_fs = primary_environment
.as_ref()
.map(|environment| environment.get_filesystem());
let loaded_agents_md =
load_project_instructions(&mut config, user_instructions, primary_fs.as_deref()).await;
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
// Guardian review should rely on the built-in shell safety checks,
@@ -604,7 +613,7 @@ impl Codex {
model_reasoning_summary: config.model_reasoning_summary,
service_tier,
developer_instructions: config.developer_instructions.clone(),
user_instructions,
loaded_agents_md,
personality: config.personality,
base_instructions,
compact_prompt: config.compact_prompt.clone(),
@@ -818,7 +827,7 @@ impl Codex {
let state = self.session.state.lock().await;
state
.session_configuration
.user_instructions
.loaded_agents_md
.as_ref()
.map_or_else(Vec::new, |instructions| {
instructions.sources().cloned().collect()
@@ -1507,6 +1516,16 @@ impl Session {
.clone()
}
pub(crate) async fn user_instructions(&self) -> Option<codex_extension_api::UserInstructions> {
let state = self.state.lock().await;
state
.session_configuration
.loaded_agents_md
.as_ref()
.and_then(LoadedAgentsMd::user_instructions)
.cloned()
}
pub(crate) async fn provider(&self) -> ModelProviderInfo {
let state = self.state.lock().await;
state.session_configuration.provider.clone()
+3 -3
View File
@@ -55,9 +55,9 @@ pub(crate) struct SessionConfiguration {
/// Developer instructions that supplement the base instructions.
pub(super) developer_instructions: Option<String>,
/// Model instructions that are appended to the base instructions and the
/// files that supplied them.
pub(super) user_instructions: Option<LoadedAgentsMd>,
/// Model instructions assembled from provider instructions and discovered
/// AGENTS.md files.
pub(super) loaded_agents_md: Option<LoadedAgentsMd>,
/// Personality preference for the model.
pub(super) personality: Option<Personality>,
+8 -8
View File
@@ -3286,7 +3286,7 @@ async fn set_rate_limits_retains_previous_credits() {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
loaded_agents_md: None,
service_tier: None,
personality: config.personality,
base_instructions: config
@@ -3393,7 +3393,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
loaded_agents_md: None,
service_tier: None,
personality: config.personality,
base_instructions: config
@@ -3925,7 +3925,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
loaded_agents_md: None,
service_tier: None,
personality: config.personality,
base_instructions: config
@@ -4777,7 +4777,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
loaded_agents_md: None,
service_tier: None,
personality: config.personality,
base_instructions: config
@@ -4885,7 +4885,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
loaded_agents_md: None,
service_tier: None,
personality: config.personality,
base_instructions: config
@@ -5117,7 +5117,7 @@ async fn make_session_with_config_and_rx(
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
loaded_agents_md: None,
service_tier: None,
personality: config.personality,
base_instructions: config
@@ -5219,7 +5219,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
loaded_agents_md: None,
service_tier: None,
personality: config.personality,
base_instructions: config
@@ -6963,7 +6963,7 @@ where
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
loaded_agents_md: None,
service_tier: None,
personality: config.personality,
base_instructions: config
@@ -705,6 +705,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs {
config,
user_instructions: Default::default(),
installation_id: "11111111-1111-4111-8111-111111111111".to_string(),
auth_manager,
models_manager,
+1 -1
View File
@@ -552,7 +552,7 @@ impl Session {
developer_instructions: session_configuration.developer_instructions.clone(),
compact_prompt: session_configuration.compact_prompt.clone(),
user_instructions: session_configuration
.user_instructions
.loaded_agents_md
.as_ref()
.map(LoadedAgentsMd::text),
collaboration_mode: session_configuration.collaboration_mode.clone(),
+13
View File
@@ -8,6 +8,9 @@ use std::path::PathBuf;
use std::sync::Arc;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::LoadUserInstructionsFuture;
use codex_extension_api::LoadedUserInstructions;
use codex_extension_api::UserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
@@ -36,6 +39,16 @@ static TEST_MODEL_PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
presets
});
/// Test-only provider that supplies no user instructions.
#[derive(Debug, Default)]
pub struct EmptyUserInstructionsProvider;
impl UserInstructionsProvider for EmptyUserInstructionsProvider {
fn load_user_instructions(&self) -> LoadUserInstructionsFuture<'_> {
Box::pin(async { LoadedUserInstructions::default() })
}
}
pub fn set_thread_manager_test_mode(enabled: bool) {
thread_manager::set_thread_manager_test_mode_for_tests(enabled);
}
+62
View File
@@ -23,6 +23,8 @@ use codex_core_plugins::PluginsManager;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::LoadedUserInstructions;
use codex_extension_api::UserInstructionsProvider;
use codex_extension_api::empty_extension_registry;
use codex_features::Feature;
use codex_login::AuthManager;
@@ -209,6 +211,7 @@ pub(crate) struct ThreadManagerState {
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
thread_store: Arc<dyn ThreadStore>,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
session_source: SessionSource,
@@ -259,6 +262,7 @@ impl ThreadManager {
session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
analytics_events_client: Option<AnalyticsEventsClient>,
thread_store: Arc<dyn ThreadStore>,
state_db: Option<StateDbHandle>,
@@ -292,6 +296,7 @@ impl ThreadManager {
plugins_manager,
mcp_manager,
extensions,
user_instructions_provider,
thread_store,
attestation_provider,
auth_manager,
@@ -394,6 +399,9 @@ impl ThreadManager {
plugins_manager,
mcp_manager,
extensions: empty_extension_registry(),
user_instructions_provider: Arc::new(
crate::test_support::EmptyUserInstructionsProvider,
),
thread_store,
attestation_provider: None,
auth_manager,
@@ -1091,6 +1099,56 @@ impl ThreadManagerState {
resolve_multi_agent_version(initial_history, inherited_multi_agent_version)
}
/// Resolves the provider snapshot for a newly spawned runtime.
///
/// Loads a fresh provider snapshot for:
/// - fresh root threads;
/// - cold resumes;
/// - root forks.
///
/// Uses an existing snapshot for:
/// - subagents, which inherit from their parent without invoking the
/// provider;
/// - running resumes and compaction paths, which retain the live session.
///
/// Provider warnings only apply to fresh loads. If a parent runtime is no
/// longer available, its child starts without provider instructions rather
/// than loading independently.
async fn user_instructions_for_spawn(
&self,
session_source: &SessionSource,
parent_thread_id: Option<ThreadId>,
forked_from_thread_id: Option<ThreadId>,
) -> LoadedUserInstructions {
let is_root_agent = !session_source.is_non_root_agent();
if is_root_agent {
return self
.user_instructions_provider
.load_user_instructions()
.await;
}
let inherited_thread_id = match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) => Some(*parent_thread_id),
_ => parent_thread_id.or(forked_from_thread_id),
};
let instructions = match inherited_thread_id {
// The spawn path retains only thread IDs, so look up the live
// runtime again here to inherit its user instructions.
Some(thread_id) => match self.get_thread(thread_id).await {
Ok(thread) => thread.codex.session.user_instructions().await,
Err(_) => None,
},
None => None,
};
LoadedUserInstructions {
instructions,
warnings: Vec::new(),
}
}
/// Spawn a new thread with no history using a provided config.
pub(crate) async fn spawn_new_thread(
&self,
@@ -1308,6 +1366,9 @@ impl ThreadManagerState {
}
let environment_selections =
resolve_environment_selections(self.environment_manager.as_ref(), &environments)?;
let user_instructions = self
.user_instructions_for_spawn(&session_source, parent_thread_id, forked_from_thread_id)
.await;
let parent_rollout_thread_trace = self
.parent_rollout_thread_trace_for_source(&session_source, &initial_history)
.await;
@@ -1324,6 +1385,7 @@ impl ThreadManagerState {
codex, thread_id, ..
} = Box::pin(Codex::spawn(CodexSpawnArgs {
config,
user_instructions,
installation_id: self.installation_id.clone(),
auth_manager,
models_manager: Arc::clone(&self.models_manager),
+11
View File
@@ -432,6 +432,7 @@ async fn start_thread_seeds_extension_data_before_lifecycle_contributors_run() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
Arc::new(extensions.build()),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -481,6 +482,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -598,6 +600,7 @@ async fn explicit_installation_id_skips_codex_home_file() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),
@@ -637,6 +640,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -694,6 +698,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -758,6 +763,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),
@@ -848,6 +854,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store.clone(),
state_db,
@@ -949,6 +956,7 @@ async fn new_uses_active_provider_for_model_refresh() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -1169,6 +1177,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
@@ -1275,6 +1284,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
@@ -1371,6 +1381,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
@@ -1,5 +1,4 @@
use super::*;
use crate::LoadedAgentsMd;
use crate::ThreadManager;
use crate::config::AgentRoleConfig;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
@@ -4241,6 +4240,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
Arc::new(crate::test_support::EmptyUserInstructionsProvider),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
@@ -4520,25 +4520,6 @@ async fn build_agent_spawn_config_uses_turn_context_values() {
assert_eq!(config, expected);
}
#[tokio::test]
async fn build_agent_spawn_config_preserves_base_user_instructions() {
let (_session, mut turn) = make_session_and_context().await;
let mut base_config = (*turn.config).clone();
base_config.user_instructions = Some(LoadedAgentsMd::new_user(
"base-user".to_string(),
base_config.codex_home.join("AGENTS.md"),
));
turn.user_instructions = Some("resolved-user".to_string());
turn.config = Arc::new(base_config.clone());
let base_instructions = BaseInstructions {
text: "base".to_string(),
};
let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config");
assert_eq!(config.user_instructions, base_config.user_instructions);
}
#[tokio::test]
async fn build_agent_resume_config_clears_base_instructions() {
let (_session, mut turn) = make_session_and_context().await;