mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Route AGENTS.md loading through environment filesystems (#26205)
## Why Workspace-specific `AGENTS.md` loading needs to use the selected environment filesystem so remote workspaces and child agents read instructions from their actual environment instead of the host filesystem. The app-server should report the same instruction sources the initialized thread actually loaded, rather than independently rescanning configuration and filesystem state. ## What changed - Introduce `LoadedAgentsMd` to retain ordered user, project, and internal instructions with their provenance. - Load and canonicalize workspace `AGENTS.md` paths through the primary `EnvironmentManager` environment, then render the loaded instructions when constructing turn context. - Expose cached loaded instruction sources from initialized threads and use them for app-server start, resume, and fork responses. - Preserve global `CODEX_HOME` loading and separator behavior while excluding empty project files that did not supply model-visible instructions. - Add integration coverage for CLI injection, selected-environment provenance and rendering, empty environment selection, and cached sources on loaded-thread resume. ## Validation - `just test -p codex-core agents_md` - `just test -p codex-core selected_environment_sources_match_model_visible_instructions` - `just test -p codex-exec agents_md` - `just test -p codex-app-server instruction_sources` - `just test -p codex-app-server --status-level fail`
This commit is contained in:
committed by
GitHub
Unverified
parent
c3fcb0e745
commit
e64b469bbc
@@ -659,12 +659,6 @@ impl ThreadRequestProcessor {
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
async fn instruction_sources_from_config(config: &Config) -> Vec<AbsolutePathBuf> {
|
||||
codex_core::AgentsMdManager::new(config)
|
||||
.instruction_sources(LOCAL_FS.as_ref())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_thread(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
@@ -1043,7 +1037,6 @@ impl ThreadRequestProcessor {
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
}
|
||||
|
||||
let instruction_sources = Self::instruction_sources_from_config(&config).await;
|
||||
let environments = environments.unwrap_or_else(|| {
|
||||
listener_task_context
|
||||
.thread_manager
|
||||
@@ -1113,6 +1106,7 @@ impl ThreadRequestProcessor {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let instruction_sources = thread.instruction_sources().await;
|
||||
let config_snapshot = thread
|
||||
.config_snapshot()
|
||||
.instrument(tracing::info_span!(
|
||||
@@ -2524,13 +2518,12 @@ impl ThreadRequestProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
let instruction_sources = Self::instruction_sources_from_config(&config).await;
|
||||
let response_history = thread_history.clone();
|
||||
|
||||
match self
|
||||
.thread_manager
|
||||
.resume_thread_with_history(
|
||||
config.clone(),
|
||||
config,
|
||||
thread_history,
|
||||
self.auth_manager.clone(),
|
||||
self.request_trace_context(&request_id).await,
|
||||
@@ -2553,6 +2546,7 @@ impl ThreadRequestProcessor {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return Ok(());
|
||||
}
|
||||
let instruction_sources = codex_thread.instruction_sources().await;
|
||||
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
|
||||
let Some(rollout_path) = rollout_path else {
|
||||
let error =
|
||||
@@ -2853,10 +2847,7 @@ impl ThreadRequestProcessor {
|
||||
/*include_turns*/ false,
|
||||
);
|
||||
thread_summary.session_id = existing_thread.session_configured().session_id.to_string();
|
||||
let mut config_for_instruction_sources = self.config.as_ref().clone();
|
||||
config_for_instruction_sources.cwd = config_snapshot.cwd.clone();
|
||||
let instruction_sources =
|
||||
Self::instruction_sources_from_config(&config_for_instruction_sources).await;
|
||||
let instruction_sources = existing_thread.instruction_sources().await;
|
||||
|
||||
let listener_command_tx = {
|
||||
let thread_state = thread_state.lock().await;
|
||||
@@ -3233,7 +3224,6 @@ impl ThreadRequestProcessor {
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
|
||||
let fallback_model_provider = config.model_provider_id.clone();
|
||||
let instruction_sources = Self::instruction_sources_from_config(&config).await;
|
||||
|
||||
let NewThread {
|
||||
thread_id,
|
||||
@@ -3284,6 +3274,8 @@ impl ThreadRequestProcessor {
|
||||
.map_err(|err| core_thread_write_error("inherit source thread name", err))?;
|
||||
}
|
||||
|
||||
let instruction_sources = forked_thread.instruction_sources().await;
|
||||
|
||||
// Auto-attach a conversation listener when forking a thread.
|
||||
log_listener_attach_result(
|
||||
self.ensure_conversation_listener(
|
||||
|
||||
@@ -256,6 +256,82 @@ async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
let workspace = TempDir::new()?;
|
||||
let project_agents = workspace.path().join("AGENTS.md");
|
||||
std::fs::write(&project_agents, "project instructions")?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let start_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
cwd: Some(workspace.path().display().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let start_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
thread,
|
||||
instruction_sources,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
let project_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(project_agents)?)?;
|
||||
assert_eq!(instruction_sources, vec![project_agents.clone()]);
|
||||
|
||||
let turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
client_user_message_id: None,
|
||||
input: vec![UserInput::Text {
|
||||
text: "materialize rollout".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
|
||||
)
|
||||
.await??;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
std::fs::remove_file(project_agents.as_path())?;
|
||||
|
||||
let resume_id = mcp
|
||||
.send_thread_resume_request(ThreadResumeParams {
|
||||
thread_id: thread.id,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let resume_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadResumeResponse {
|
||||
instruction_sources,
|
||||
..
|
||||
} = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
|
||||
assert_eq!(instruction_sources, vec![project_agents]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
|
||||
@@ -359,6 +359,83 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_response_excludes_empty_project_instruction_source() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
|
||||
let global_agents_path = codex_home.path().join("AGENTS.md");
|
||||
std::fs::write(&global_agents_path, "global instructions")?;
|
||||
let workspace = TempDir::new()?;
|
||||
let project_agents_path = workspace.path().join("AGENTS.md");
|
||||
std::fs::write(project_agents_path, "")?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
cwd: Some(workspace.path().display().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
instruction_sources,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(response)?;
|
||||
|
||||
let instruction_sources = instruction_sources
|
||||
.into_iter()
|
||||
.map(normalize_path_for_comparison)
|
||||
.collect::<Vec<_>>();
|
||||
let expected_instruction_sources = vec![normalize_path_for_comparison(std::fs::canonicalize(
|
||||
global_agents_path,
|
||||
)?)];
|
||||
|
||||
assert_eq!(instruction_sources, expected_instruction_sources);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
|
||||
std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?;
|
||||
let workspace = TempDir::new()?;
|
||||
std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
cwd: Some(workspace.path().display().to_string()),
|
||||
environments: Some(Vec::new()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse {
|
||||
instruction_sources,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(response)?;
|
||||
|
||||
assert!(instruction_sources.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn normalize_path_for_comparison(path: impl AsRef<Path>) -> PathBuf {
|
||||
let path = path.as_ref();
|
||||
|
||||
@@ -26,6 +26,7 @@ pub use codex_config::types::TuiPetAnchor;
|
||||
pub use codex_config::types::UriBasedFileOpener;
|
||||
pub use codex_core::CodexThread;
|
||||
pub use codex_core::ForkSnapshot;
|
||||
pub use codex_core::LoadedAgentsMd;
|
||||
pub use codex_core::McpManager;
|
||||
pub use codex_core::NewThread;
|
||||
pub use codex_core::StartThreadOptions;
|
||||
|
||||
+136
-68
@@ -23,11 +23,9 @@ 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_exec_server::LOCAL_FS;
|
||||
use codex_features::Feature;
|
||||
use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use std::io;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::error;
|
||||
@@ -37,8 +35,8 @@ pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md";
|
||||
/// Preferred local override for AGENTS.md instructions.
|
||||
pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md";
|
||||
|
||||
/// When both `Config::instructions` and AGENTS.md docs are present, they will
|
||||
/// be concatenated with the following separator.
|
||||
/// When both user and project AGENTS.md docs are present, they will be
|
||||
/// concatenated with the following separator.
|
||||
const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
|
||||
/// Resolves AGENTS.md files into model-visible user instructions and source
|
||||
@@ -47,11 +45,6 @@ pub struct AgentsMdManager<'a> {
|
||||
config: &'a Config,
|
||||
}
|
||||
|
||||
pub(crate) struct LoadedAgentsMd {
|
||||
pub(crate) contents: String,
|
||||
pub(crate) path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl<'a> AgentsMdManager<'a> {
|
||||
pub fn new(config: &'a Config) -> Self {
|
||||
Self { config }
|
||||
@@ -81,10 +74,7 @@ impl<'a> AgentsMdManager<'a> {
|
||||
let contents = String::from_utf8_lossy(&data);
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(LoadedAgentsMd {
|
||||
contents: trimmed.to_string(),
|
||||
path,
|
||||
});
|
||||
return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path));
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -94,10 +84,10 @@ impl<'a> AgentsMdManager<'a> {
|
||||
/// single model-visible instruction string.
|
||||
pub(crate) async fn user_instructions(
|
||||
&self,
|
||||
environment: Option<&Environment>,
|
||||
environment: &Environment,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
let fs = environment?.get_filesystem();
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let fs = environment.get_filesystem();
|
||||
self.user_instructions_with_fs(fs.as_ref(), startup_warnings)
|
||||
.await
|
||||
}
|
||||
@@ -106,22 +96,13 @@ impl<'a> AgentsMdManager<'a> {
|
||||
&self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let agents_md_docs = self.read_agents_md(fs, startup_warnings).await;
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
if let Some(instructions) = self.config.user_instructions.clone() {
|
||||
output.push_str(&instructions);
|
||||
}
|
||||
let mut loaded = self.config.user_instructions.clone().unwrap_or_default();
|
||||
|
||||
match agents_md_docs {
|
||||
Ok(Some(docs)) => {
|
||||
if !output.is_empty() {
|
||||
output.push_str(AGENTS_MD_SEPARATOR);
|
||||
}
|
||||
output.push_str(&docs);
|
||||
}
|
||||
Ok(Some(docs)) => loaded.entries.extend(docs.entries),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
error!("error trying to find AGENTS.md docs: {e:#}");
|
||||
@@ -129,50 +110,26 @@ impl<'a> AgentsMdManager<'a> {
|
||||
};
|
||||
|
||||
if self.config.features.enabled(Feature::ChildAgentsMd) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(HIERARCHICAL_AGENTS_MESSAGE);
|
||||
loaded.entries.push(InstructionEntry {
|
||||
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
});
|
||||
}
|
||||
|
||||
if !output.is_empty() {
|
||||
Some(output)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all instruction source files included in the current config.
|
||||
pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec<AbsolutePathBuf> {
|
||||
let mut global_instruction_warnings = Vec::new();
|
||||
let mut paths = Self::load_global_instructions(
|
||||
LOCAL_FS.as_ref(),
|
||||
Some(&self.config.codex_home),
|
||||
&mut global_instruction_warnings,
|
||||
)
|
||||
.await
|
||||
.map(|loaded| vec![loaded.path])
|
||||
.unwrap_or_default();
|
||||
match self.agents_md_paths(fs).await {
|
||||
Ok(agents_md_paths) => paths.extend(agents_md_paths),
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources");
|
||||
}
|
||||
}
|
||||
paths
|
||||
(!loaded.is_empty()).then_some(loaded)
|
||||
}
|
||||
|
||||
/// Attempt to locate and load AGENTS.md documentation.
|
||||
///
|
||||
/// On success returns `Ok(Some(contents))` where `contents` is the
|
||||
/// concatenation of all discovered docs. 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.
|
||||
/// 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<String>> {
|
||||
) -> io::Result<Option<LoadedAgentsMd>> {
|
||||
let max_total = self.config.project_doc_max_bytes;
|
||||
|
||||
if max_total == 0 {
|
||||
@@ -185,7 +142,7 @@ impl<'a> AgentsMdManager<'a> {
|
||||
}
|
||||
|
||||
let mut remaining: u64 = max_total as u64;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut loaded = LoadedAgentsMd::default();
|
||||
|
||||
for p in paths {
|
||||
if remaining == 0 {
|
||||
@@ -221,15 +178,18 @@ impl<'a> AgentsMdManager<'a> {
|
||||
|
||||
let text = String::from_utf8_lossy(&data).to_string();
|
||||
if !text.trim().is_empty() {
|
||||
parts.push(text);
|
||||
loaded.entries.push(InstructionEntry {
|
||||
contents: text,
|
||||
provenance: InstructionProvenance::Project(p),
|
||||
});
|
||||
remaining = remaining.saturating_sub(data.len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
if loaded.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(parts.join("\n\n")))
|
||||
Ok(Some(loaded))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,8 +207,8 @@ impl<'a> AgentsMdManager<'a> {
|
||||
}
|
||||
|
||||
let mut dir = self.config.cwd.clone();
|
||||
if let Ok(canon) = normalize_path(&dir) {
|
||||
dir = AbsolutePathBuf::try_from(canon)?;
|
||||
if let Ok(canonical_dir) = fs.canonicalize(&dir, /*sandbox*/ None).await {
|
||||
dir = canonical_dir;
|
||||
}
|
||||
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
@@ -348,6 +308,114 @@ impl<'a> AgentsMdManager<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-visible instructions loaded from AGENTS.md files and internal
|
||||
/// guidance.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct LoadedAgentsMd {
|
||||
/// Ordered instructions and their provenance.
|
||||
entries: Vec<InstructionEntry>,
|
||||
}
|
||||
|
||||
impl LoadedAgentsMd {
|
||||
/// Creates loaded instructions containing one user-level AGENTS.md entry.
|
||||
pub fn new_user(contents: String, path: AbsolutePathBuf) -> Self {
|
||||
if contents.trim().is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
Self {
|
||||
entries: vec![InstructionEntry {
|
||||
contents,
|
||||
provenance: InstructionProvenance::User(path),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates source-less user instructions for tests.
|
||||
///
|
||||
/// This cannot be gated with `#[cfg(test)]` because integration tests
|
||||
/// compile `codex-core` as a normal dependency without that configuration.
|
||||
pub fn from_text_for_testing(contents: impl Into<String>) -> Self {
|
||||
let contents = contents.into();
|
||||
if contents.trim().is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
Self {
|
||||
entries: vec![InstructionEntry {
|
||||
contents,
|
||||
provenance: InstructionProvenance::Internal,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
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;
|
||||
for entry in &self.entries {
|
||||
if let Some(previous_provenance) = previous_provenance {
|
||||
// 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",
|
||||
};
|
||||
output.push_str(separator);
|
||||
}
|
||||
output.push_str(&entry.contents);
|
||||
previous_provenance = Some(&entry.provenance);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Returns the AGENTS.md files that supplied instruction entries.
|
||||
pub fn sources(&self) -> impl Iterator<Item = &AbsolutePathBuf> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.provenance.path())
|
||||
}
|
||||
}
|
||||
|
||||
/// One model-visible instruction and its provenance.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct InstructionEntry {
|
||||
/// Model-visible instruction text.
|
||||
contents: String,
|
||||
|
||||
/// Origin of the instruction.
|
||||
provenance: InstructionProvenance,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum InstructionProvenance {
|
||||
/// User-level instructions, normally loaded from CODEX_HOME.
|
||||
User(AbsolutePathBuf),
|
||||
|
||||
/// Workspace instructions discovered from project AGENTS.md files.
|
||||
Project(AbsolutePathBuf),
|
||||
|
||||
/// Instructions without a file source, including internally defined guidance.
|
||||
Internal,
|
||||
}
|
||||
|
||||
impl InstructionProvenance {
|
||||
fn path(&self) -> Option<&AbsolutePathBuf> {
|
||||
match self {
|
||||
Self::User(path) | Self::Project(path) => Some(path),
|
||||
Self::Internal => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn warn_invalid_utf8(
|
||||
path: &AbsolutePathBuf,
|
||||
data: &[u8],
|
||||
|
||||
@@ -16,6 +16,7 @@ async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
AgentsMdManager::new(config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
.map(|loaded| loaded.text())
|
||||
}
|
||||
|
||||
async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
|
||||
@@ -53,7 +54,12 @@ 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(ToOwned::to_owned);
|
||||
config.user_instructions = instructions.map(|text| {
|
||||
LoadedAgentsMd::new_user(
|
||||
text.to_owned(),
|
||||
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
)
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
@@ -96,7 +102,12 @@ async fn make_config_with_project_root_markers(
|
||||
|
||||
config.cwd = root.abs();
|
||||
config.project_doc_max_bytes = limit;
|
||||
config.user_instructions = instructions.map(ToOwned::to_owned);
|
||||
config.user_instructions = instructions.map(|text| {
|
||||
LoadedAgentsMd::new_user(
|
||||
text.to_owned(),
|
||||
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
)
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
@@ -115,17 +126,46 @@ async fn no_doc_file_returns_none() {
|
||||
assert!(res.is_none(), "Expected None when AGENTS.md is absent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_environment_returns_none() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
|
||||
#[test]
|
||||
fn empty_loaded_instructions_are_empty() {
|
||||
let source =
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/AGENTS.md").expect("absolute source path");
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let res = AgentsMdManager::new(&config)
|
||||
.user_instructions(/*environment*/ None, &mut warnings)
|
||||
.await;
|
||||
assert_eq!(
|
||||
LoadedAgentsMd::new_user(String::new(), source.clone()),
|
||||
LoadedAgentsMd::default()
|
||||
);
|
||||
assert_eq!(
|
||||
LoadedAgentsMd::new_user(" \n\t".to_string(), source),
|
||||
LoadedAgentsMd::default()
|
||||
);
|
||||
assert_eq!(
|
||||
LoadedAgentsMd::from_text_for_testing(String::new()),
|
||||
LoadedAgentsMd::default()
|
||||
);
|
||||
assert_eq!(
|
||||
LoadedAgentsMd::from_text_for_testing(" \n\t"),
|
||||
LoadedAgentsMd::default()
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(res, None);
|
||||
#[test]
|
||||
fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() {
|
||||
let empty = LoadedAgentsMd {
|
||||
entries: vec![InstructionEntry {
|
||||
contents: String::new(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
}],
|
||||
};
|
||||
let whitespace = LoadedAgentsMd {
|
||||
entries: vec![InstructionEntry {
|
||||
contents: " \n\t".to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
}],
|
||||
};
|
||||
|
||||
assert!(empty.is_empty());
|
||||
assert!(whitespace.is_empty());
|
||||
}
|
||||
|
||||
/// Small file within the byte-limit is returned unmodified.
|
||||
@@ -161,8 +201,10 @@ async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
.await
|
||||
.expect("global doc expected");
|
||||
|
||||
assert_eq!(loaded.contents, "global\u{FFFD} doc");
|
||||
assert_eq!(loaded.path, path);
|
||||
assert_eq!(
|
||||
loaded,
|
||||
LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone())
|
||||
);
|
||||
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
|
||||
}
|
||||
|
||||
@@ -177,7 +219,8 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
let res = AgentsMdManager::new(&config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
.expect("doc expected")
|
||||
.text();
|
||||
|
||||
assert_eq!(res, "project\u{FFFD} doc");
|
||||
let canonical_path = dunce::canonicalize(&path).expect("canonical doc path");
|
||||
@@ -272,6 +315,33 @@ 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 = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
|
||||
)
|
||||
.expect("absolute project doc path");
|
||||
|
||||
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]
|
||||
@@ -310,8 +380,38 @@ async fn concatenates_root_and_cwd_docs() {
|
||||
let mut cfg = make_config(&repo, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
cfg.cwd = nested.abs();
|
||||
|
||||
let res = get_user_instructions(&cfg).await.expect("doc expected");
|
||||
assert_eq!(res, "root doc\n\ncrate doc");
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::new(&cfg)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
let root_agents = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"),
|
||||
)
|
||||
.expect("absolute root doc path");
|
||||
let crate_agents = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"),
|
||||
)
|
||||
.expect("absolute crate doc path");
|
||||
let expected = LoadedAgentsMd {
|
||||
entries: vec![
|
||||
InstructionEntry {
|
||||
contents: "root doc".to_string(),
|
||||
provenance: InstructionProvenance::Project(root_agents.clone()),
|
||||
},
|
||||
InstructionEntry {
|
||||
contents: "crate doc".to_string(),
|
||||
provenance: InstructionProvenance::Project(crate_agents.clone()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(loaded.text(), "root doc\n\ncrate doc");
|
||||
assert_eq!(
|
||||
loaded.sources().collect::<Vec<_>>(),
|
||||
vec![&root_agents, &crate_agents]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -350,6 +450,38 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
|
||||
assert_eq!(res, "parent doc\n\nchild doc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn child_agents_message_after_global_instructions_uses_plain_separator() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await;
|
||||
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)
|
||||
.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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(
|
||||
loaded.text(),
|
||||
format!("global doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instruction_sources_include_global_before_agents_md_docs() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -360,15 +492,85 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
|
||||
fs::create_dir_all(&cfg.codex_home).unwrap();
|
||||
fs::write(&global_agents, "global doc").unwrap();
|
||||
|
||||
let sources = AgentsMdManager::new(&cfg)
|
||||
.instruction_sources(LOCAL_FS.as_ref())
|
||||
.await;
|
||||
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 = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
|
||||
)
|
||||
.expect("absolute project doc path");
|
||||
|
||||
assert_eq!(sources, vec![global_agents, project_agents]);
|
||||
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()),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(
|
||||
loaded.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents, &project_agents]
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.text(),
|
||||
format!("global doc{AGENTS_MD_SEPARATOR}project doc")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn child_agents_message_after_project_docs_is_not_an_instruction_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, Some("global doc")).await;
|
||||
cfg.features.enable(Feature::ChildAgentsMd).unwrap();
|
||||
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
fs::create_dir_all(&cfg.codex_home).unwrap();
|
||||
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)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let project_agents = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
|
||||
)
|
||||
.expect("absolute project doc path");
|
||||
|
||||
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()),
|
||||
},
|
||||
InstructionEntry {
|
||||
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(
|
||||
loaded.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents, &project_agents]
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.text(),
|
||||
format!("global doc{AGENTS_MD_SEPARATOR}project doc\n\n{HIERARCHICAL_AGENTS_MESSAGE}")
|
||||
);
|
||||
}
|
||||
|
||||
/// AGENTS.override.md is preferred over AGENTS.md when both are present.
|
||||
|
||||
@@ -549,6 +549,11 @@ impl CodexThread {
|
||||
self.codex.thread_config_snapshot().await
|
||||
}
|
||||
|
||||
/// Returns the files that supplied the thread's loaded model instructions.
|
||||
pub async fn instruction_sources(&self) -> Vec<AbsolutePathBuf> {
|
||||
self.codex.instruction_sources().await
|
||||
}
|
||||
|
||||
pub async fn config(&self) -> Arc<crate::config::Config> {
|
||||
self.codex.session.get_config().await
|
||||
}
|
||||
|
||||
@@ -208,10 +208,8 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
|
||||
#[tokio::test]
|
||||
async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
"\n global instructions \n",
|
||||
)?;
|
||||
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(),
|
||||
@@ -221,9 +219,14 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
|
||||
.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!(
|
||||
config.user_instructions.as_deref(),
|
||||
Some("global instructions")
|
||||
user_instructions.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents_path]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -235,7 +238,7 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R
|
||||
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
"global instructions",
|
||||
)?;
|
||||
let global_agents_override_path = codex_home.path().join(LOCAL_AGENTS_MD_FILENAME);
|
||||
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(
|
||||
@@ -245,9 +248,14 @@ async fn load_config_prefers_global_agents_override_instructions() -> std::io::R
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_instructions = config
|
||||
.user_instructions
|
||||
.as_ref()
|
||||
.expect("global override instructions expected");
|
||||
assert_eq!(user_instructions.text(), "local override instructions");
|
||||
assert_eq!(
|
||||
config.user_instructions.as_deref(),
|
||||
Some("local override instructions")
|
||||
user_instructions.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents_override_path]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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;
|
||||
@@ -645,7 +646,7 @@ pub struct Config {
|
||||
pub show_raw_agent_reasoning: bool,
|
||||
|
||||
/// User-provided instructions from AGENTS.md.
|
||||
pub user_instructions: Option<String>,
|
||||
pub user_instructions: Option<LoadedAgentsMd>,
|
||||
|
||||
/// Base instructions override.
|
||||
pub base_instructions: Option<String>,
|
||||
@@ -2605,8 +2606,7 @@ impl Config {
|
||||
Some(&codex_home),
|
||||
&mut startup_warnings,
|
||||
)
|
||||
.await
|
||||
.map(|loaded| loaded.contents);
|
||||
.await;
|
||||
|
||||
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
||||
let ConfigOverrides {
|
||||
|
||||
@@ -29,6 +29,7 @@ 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;
|
||||
@@ -146,7 +147,7 @@ struct GuardianReviewSessionReuseKey {
|
||||
permissions: Permissions,
|
||||
developer_instructions: Option<String>,
|
||||
base_instructions: Option<String>,
|
||||
user_instructions: Option<String>,
|
||||
user_instructions: Option<LoadedAgentsMd>,
|
||||
compact_prompt: Option<String>,
|
||||
cwd: AbsolutePathBuf,
|
||||
mcp_servers: Constrained<HashMap<String, McpServerConfig>>,
|
||||
|
||||
@@ -131,6 +131,7 @@ 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;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
mod session_rollout_init_error;
|
||||
|
||||
@@ -507,12 +507,13 @@ impl Codex {
|
||||
|
||||
let primary_environment = environment_selections.primary_environment();
|
||||
let mut user_instruction_warnings = Vec::new();
|
||||
let user_instructions = AgentsMdManager::new(&config)
|
||||
.user_instructions(
|
||||
primary_environment.as_deref(),
|
||||
&mut user_instruction_warnings,
|
||||
)
|
||||
.await;
|
||||
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 exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
|
||||
@@ -800,6 +801,17 @@ impl Codex {
|
||||
state.session_configuration.thread_config_snapshot()
|
||||
}
|
||||
|
||||
pub(crate) async fn instruction_sources(&self) -> Vec<AbsolutePathBuf> {
|
||||
let state = self.session.state.lock().await;
|
||||
state
|
||||
.session_configuration
|
||||
.user_instructions
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |instructions| {
|
||||
instructions.sources().cloned().collect()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_environment_selections(&self) -> Vec<TurnEnvironmentSelection> {
|
||||
let state = self.session.state.lock().await;
|
||||
state.session_configuration.environments.clone()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::input_queue::InputQueue;
|
||||
use super::*;
|
||||
use crate::agents_md::LoadedAgentsMd;
|
||||
use crate::config::ConstraintError;
|
||||
use crate::goals::GoalRuntimeState;
|
||||
use crate::skills::SkillError;
|
||||
@@ -54,8 +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.
|
||||
pub(super) user_instructions: Option<String>,
|
||||
/// Model instructions that are appended to the base instructions and the
|
||||
/// files that supplied them.
|
||||
pub(super) user_instructions: Option<LoadedAgentsMd>,
|
||||
|
||||
/// Personality preference for the model.
|
||||
pub(super) personality: Option<Personality>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::SkillLoadOutcome;
|
||||
use crate::agents_md::LoadedAgentsMd;
|
||||
use crate::config::GhostSnapshotConfig;
|
||||
use crate::environment_selection::ResolvedTurnEnvironments;
|
||||
use codex_core_skills::HostLoadedSkills;
|
||||
@@ -551,7 +552,10 @@ impl Session {
|
||||
app_server_client_name: session_configuration.app_server_client_name.clone(),
|
||||
developer_instructions: session_configuration.developer_instructions.clone(),
|
||||
compact_prompt: session_configuration.compact_prompt.clone(),
|
||||
user_instructions: session_configuration.user_instructions.clone(),
|
||||
user_instructions: session_configuration
|
||||
.user_instructions
|
||||
.as_ref()
|
||||
.map(LoadedAgentsMd::text),
|
||||
collaboration_mode: session_configuration.collaboration_mode.clone(),
|
||||
multi_agent_version,
|
||||
personality: session_configuration.personality,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::LoadedAgentsMd;
|
||||
use crate::ThreadManager;
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
|
||||
@@ -4399,7 +4400,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() {
|
||||
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("base-user".to_string());
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
@@ -7,6 +8,8 @@ use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::TestCodexBuilder;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn agents_instructions(mut builder: TestCodexBuilder) -> Result<String> {
|
||||
let server = start_mock_server().await;
|
||||
@@ -139,3 +142,53 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let resp_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
|
||||
)
|
||||
.await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let global_agents = home.path().join("AGENTS.md");
|
||||
std::fs::write(&global_agents, "global doc")?;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_home(home)
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
fs.write_file(
|
||||
&cwd.join("AGENTS.md"),
|
||||
b"project doc".to_vec(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok::<(), anyhow::Error>(())
|
||||
});
|
||||
let test = builder.build_with_remote_env(&server).await?;
|
||||
let project_agents = test
|
||||
.fs()
|
||||
.canonicalize(
|
||||
&test.executor_environment().cwd().join("AGENTS.md"),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path");
|
||||
|
||||
assert_eq!(
|
||||
test.codex.instruction_sources().await,
|
||||
vec![global_agents, project_agents]
|
||||
);
|
||||
|
||||
test.submit_turn("hello").await?;
|
||||
let instructions = resp_mock
|
||||
.single_request()
|
||||
.message_input_texts("user")
|
||||
.into_iter()
|
||||
.find(|text| text.starts_with("# AGENTS.md instructions for "))
|
||||
.expect("instructions message");
|
||||
assert!(instructions.contains("global doc\n\n--- project-doc ---\n\nproject doc"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::LoadedAgentsMd;
|
||||
use codex_core::ModelClient;
|
||||
use codex_core::NewThread;
|
||||
use codex_core::Prompt;
|
||||
@@ -364,7 +365,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
.with_home(codex_home.clone())
|
||||
.with_config(|config| {
|
||||
// Ensure user instructions are NOT delivered on resume.
|
||||
config.user_instructions = Some("be nice".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice"));
|
||||
});
|
||||
let test = builder
|
||||
.resume(&server, codex_home, session_path.clone())
|
||||
@@ -1180,7 +1181,7 @@ async fn includes_user_instructions_message_in_request() {
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::from_api_key("Test API Key"))
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be nice".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice"));
|
||||
});
|
||||
let codex = builder
|
||||
.build(&server)
|
||||
@@ -2212,7 +2213,7 @@ async fn includes_developer_instructions_message_in_request() {
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::from_api_key("Test API Key"))
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be nice".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing("be nice"));
|
||||
config.developer_instructions = Some("be useful".to_string());
|
||||
});
|
||||
let codex = builder
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::LoadedAgentsMd;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
@@ -517,7 +518,9 @@ async fn build_harness_inner(
|
||||
FIXED_CWD,
|
||||
))
|
||||
.expect("fixed cwd should be absolute");
|
||||
config.user_instructions = Some("PARITY_USER_INSTRUCTIONS".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"PARITY_USER_INSTRUCTIONS",
|
||||
));
|
||||
config.developer_instructions = Some("PARITY_DEVELOPER_INSTRUCTIONS".to_string());
|
||||
if settings.service_tier_fast {
|
||||
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use codex_core::LoadedAgentsMd;
|
||||
use codex_core::shell::default_user_shell;
|
||||
use codex_features::Feature;
|
||||
use codex_prompts::APPLY_PATCH_TOOL_INSTRUCTIONS;
|
||||
@@ -122,7 +123,9 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> {
|
||||
..
|
||||
} = test_codex()
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"be consistent and helpful",
|
||||
));
|
||||
config.model = Some("gpt-5.2".to_string());
|
||||
// Keep tool expectations stable when the default web_search mode changes.
|
||||
config
|
||||
@@ -237,7 +240,9 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"be consistent and helpful",
|
||||
));
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CollaborationModes)
|
||||
@@ -319,7 +324,9 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
|
||||
let TestCodex { codex, config, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"be consistent and helpful",
|
||||
));
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CollaborationModes)
|
||||
@@ -417,7 +424,9 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
|
||||
let TestCodex { codex, config, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"be consistent and helpful",
|
||||
));
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CollaborationModes)
|
||||
@@ -709,7 +718,9 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"be consistent and helpful",
|
||||
));
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CollaborationModes)
|
||||
@@ -844,7 +855,9 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
..
|
||||
} = test_codex()
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"be consistent and helpful",
|
||||
));
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CollaborationModes)
|
||||
@@ -985,7 +998,9 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
..
|
||||
} = test_codex()
|
||||
.with_config(|config| {
|
||||
config.user_instructions = Some("be consistent and helpful".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"be consistent and helpful",
|
||||
));
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CollaborationModes)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::LoadedAgentsMd;
|
||||
use codex_core::build_prompt_input;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
@@ -21,7 +22,9 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> {
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
config.user_instructions = Some("Project-specific test instructions".to_string());
|
||||
config.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"Project-specific test instructions",
|
||||
));
|
||||
|
||||
let input = build_prompt_input(
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex_exec::test_codex_exec;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_includes_workspace_agents_md_in_request() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
std::fs::write(test.cwd_path().join("AGENTS.md"), "workspace instructions")?;
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("resp1"),
|
||||
responses::ev_assistant_message("m1", "fixture hello"),
|
||||
responses::ev_completed("resp1"),
|
||||
]);
|
||||
let response_mock = responses::mount_sse_once(&server, body).await;
|
||||
|
||||
test.cmd_with_server(&server)
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("tell me something")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let user_messages = response_mock.single_request().message_input_texts("user");
|
||||
assert!(
|
||||
user_messages
|
||||
.iter()
|
||||
.any(|text| text.contains("workspace instructions")),
|
||||
"request should include workspace AGENTS.md instructions: {user_messages:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn exec_prefers_workspace_agents_override_md() -> anyhow::Result<()> {
|
||||
let test = test_codex_exec();
|
||||
std::fs::write(test.cwd_path().join("AGENTS.md"), "base instructions")?;
|
||||
std::fs::write(
|
||||
test.cwd_path().join("AGENTS.override.md"),
|
||||
"override instructions",
|
||||
)?;
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let body = responses::sse(vec![
|
||||
responses::ev_response_created("resp1"),
|
||||
responses::ev_assistant_message("m1", "fixture hello"),
|
||||
responses::ev_completed("resp1"),
|
||||
]);
|
||||
let response_mock = responses::mount_sse_once(&server, body).await;
|
||||
|
||||
test.cmd_with_server(&server)
|
||||
.arg("--skip-git-repo-check")
|
||||
.arg("tell me something")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let user_messages = response_mock.single_request().message_input_texts("user");
|
||||
assert!(
|
||||
user_messages
|
||||
.iter()
|
||||
.any(|text| text.contains("override instructions")),
|
||||
"request should include AGENTS.override.md instructions: {user_messages:?}"
|
||||
);
|
||||
assert!(
|
||||
user_messages
|
||||
.iter()
|
||||
.all(|text| !text.contains("base instructions")),
|
||||
"request should exclude shadowed AGENTS.md instructions: {user_messages:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
mod add_dir;
|
||||
mod agents_md;
|
||||
mod apply_patch;
|
||||
mod approval_policy;
|
||||
mod auth_env;
|
||||
|
||||
Reference in New Issue
Block a user