mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
b2a4e3be27
commit
236b50125d
+228
-275
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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#"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶ms.spawn_config);
|
||||
let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
¶ms.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(¶ms.spawn_config);
|
||||
review_session.reuse_key = GuardianReviewSessionReuseKey::from_spawn_config(
|
||||
¶ms.spawn_config,
|
||||
params.parent_session.user_instructions().await,
|
||||
);
|
||||
let manager = GuardianReviewSessionManager {
|
||||
state: Arc::new(Mutex::new(GuardianReviewSessionState {
|
||||
trunk: Some(Arc::new(review_session)),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user