diff --git a/README.md b/README.md index 4678abd800..e40c1faf69 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [Example prompts](./docs/getting-started.md#example-prompts) - [Custom prompts](./docs/prompts.md) - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd) + - [Shared agents workspace](./docs/getting-started.md#agents-home-agents) - [Configuration](./docs/config.md) - [**Sandbox & approvals**](./docs/sandbox.md) - [**Authentication**](./docs/authentication.md) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4f8c066f7e..12681f470b 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1668,6 +1668,7 @@ async fn derive_config_from_params( sandbox_mode, model_provider, codex_linux_sandbox_exe, + agents_home: None, base_instructions, include_apply_patch_tool, include_view_image_tool: None, diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 553c731576..e93405d5db 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -159,3 +159,59 @@ async fn get_disabled_server_shows_single_line() -> Result<()> { Ok(()) } + +#[test] +fn list_includes_agents_home_servers() -> Result<()> { + let codex_home = TempDir::new()?; + let project = TempDir::new()?; + let agents_mcp_dir = project.path().join(".agents").join("mcp"); + std::fs::create_dir_all(&agents_mcp_dir)?; + std::fs::write( + agents_mcp_dir.join("mcp.json"), + r#" +{ + "docs": { + "command": "docs-server", + "args": ["--port", "8080"], + "env": {"TOKEN": "secret"} + } +} +"#, + )?; + + let mut cmd = codex_command(codex_home.path())?; + let output = cmd + .current_dir(project.path()) + .args(["mcp", "list", "--json"]) + .output()?; + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout)?; + let parsed: JsonValue = serde_json::from_str(&stdout)?; + assert_eq!( + parsed, + json!([ + { + "name": "docs", + "enabled": true, + "transport": { + "type": "stdio", + "command": "docs-server", + "args": [ + "--port", + "8080" + ], + "env": { + "TOKEN": "secret" + }, + "env_vars": [], + "cwd": null + }, + "startup_timeout_sec": null, + "tool_timeout_sec": null, + "auth_status": "unsupported" + } + ]) + ); + + Ok(()) +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 3fc6ccd093..f6f0169859 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -81,6 +81,7 @@ tracing = { workspace = true, features = ["log"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } uuid = { workspace = true, features = ["serde", "v4"] } +walkdir = { workspace = true } which = { workspace = true } wildmatch = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 47d80c5cb4..afdf68ce0f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -175,6 +175,7 @@ impl Codex { model_reasoning_summary: config.model_reasoning_summary, user_instructions, base_instructions: config.base_instructions.clone(), + agents_context_prompt: config.agents_context_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), @@ -266,6 +267,7 @@ pub(crate) struct TurnContext { pub(crate) cwd: PathBuf, pub(crate) base_instructions: Option, pub(crate) user_instructions: Option, + pub(crate) agents_context_prompt: Option, pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, @@ -300,6 +302,8 @@ pub(crate) struct SessionConfiguration { /// Base instructions override. base_instructions: Option, + /// Aggregated agents context block surfaced before each turn. + agents_context_prompt: Option, /// When to escalate for approval for execution approval_policy: AskForApproval, @@ -405,6 +409,7 @@ impl Session { cwd: session_configuration.cwd.clone(), base_instructions: session_configuration.base_instructions.clone(), user_instructions: session_configuration.user_instructions.clone(), + agents_context_prompt: session_configuration.agents_context_prompt.clone(), approval_policy: session_configuration.approval_policy, sandbox_policy: session_configuration.sandbox_policy.clone(), shell_environment_policy: config.shell_environment_policy.clone(), @@ -976,10 +981,19 @@ impl Session { } pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { - let mut items = Vec::::with_capacity(2); + let mut items = Vec::::with_capacity(3); if let Some(user_instructions) = turn_context.user_instructions.as_deref() { items.push(UserInstructions::new(user_instructions.to_string()).into()); } + if let Some(agents_context) = turn_context.agents_context_prompt.as_ref() { + items.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: agents_context.clone(), + }], + }); + } items.push(ResponseItem::from(EnvironmentContext::new( Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), @@ -1641,6 +1655,7 @@ async fn spawn_review_thread( client, tools_config, user_instructions: None, + agents_context_prompt: None, base_instructions: Some(base_instructions.clone()), approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), @@ -2623,6 +2638,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, user_instructions: config.user_instructions.clone(), base_instructions: config.base_instructions.clone(), + agents_context_prompt: config.agents_context_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), @@ -2696,6 +2712,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, user_instructions: config.user_instructions.clone(), base_instructions: config.base_instructions.clone(), + agents_context_prompt: config.agents_context_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f671e8dd36..bb7e2c550d 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -42,23 +42,35 @@ use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::protocol::AGENTS_CONTEXT_CLOSE_TAG; +use codex_protocol::protocol::AGENTS_CONTEXT_OPEN_TAG; use codex_rmcp_client::OAuthCredentialsStoreMode; +use codex_utils_string::take_bytes_at_char_boundary; use dirs::home_dir; use dunce::canonicalize; use serde::Deserialize; +use serde_json::Value as JsonValue; use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; +use std::fs; +use std::io::BufReader; use std::io::ErrorKind; +use std::io::Read; +use std::path::Component; use std::path::Path; use std::path::PathBuf; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + use tempfile::NamedTempFile; use toml::Value as TomlValue; use toml_edit::Array as TomlArray; use toml_edit::DocumentMut; use toml_edit::Item as TomlItem; use toml_edit::Table as TomlTable; +use walkdir::WalkDir; #[cfg(target_os = "windows")] pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5"; @@ -74,6 +86,12 @@ pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB pub(crate) const CONFIG_TOML_FILE: &str = "config.toml"; +const AGENTS_HOME_DEFAULT_SUBDIRS: &[&str] = &["context", "tools", "mcp"]; +const AGENTS_CONTEXT_PROMPT_MAX_BYTES: usize = 64 * 1024; // 64 KiB +const AGENTS_CONTEXT_FILE_MAX_BYTES: usize = 32 * 1024; // 32 KiB +const AGENTS_CONTEXT_TRUNCATION_NOTICE: &str = "[... agents context truncated ...]"; +const AGENTS_CONTEXT_FILE_TRUNCATION_NOTICE: &str = "_Content truncated to 32 KiB._"; + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Config { @@ -192,6 +210,24 @@ pub struct Config { /// overridden by the `CODEX_HOME` environment variable). pub codex_home: PathBuf, + /// Directory containing shared agent context, tools, and MCP resources. + /// Defaults to `~/.agents` (sibling to `~/.codex`) unless overridden. + pub agents_home: PathBuf, + + /// Optional project-scoped agents directory discovered in the workspace. + /// When present, entries from this path augment or override the global + /// [`agents_home`]. + pub project_agents_home: Option, + + /// Context documents sourced from the global and project `.agents/context` directories. + pub agents_context_entries: Vec, + + /// Helper scripts discovered under `.agents/tools`. + pub agents_tools: Vec, + + /// Pre-rendered prompt block that surfaces the aggregated agents context. + pub agents_context_prompt: Option, + /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. pub history: History, @@ -274,6 +310,38 @@ pub struct Config { pub otel: crate::config_types::OtelConfig, } +#[derive(Debug, Clone, PartialEq)] +pub struct AgentContextEntry { + pub source: AgentsSource, + pub relative_path: String, + pub absolute_path: PathBuf, + pub content: String, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AgentToolEntry { + pub source: AgentsSource, + pub relative_path: String, + pub absolute_path: PathBuf, + pub executable: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentsSource { + Global, + Project, +} + +impl AgentsSource { + fn label(self) -> &'static str { + match self { + AgentsSource::Global => "global", + AgentsSource::Project => "project", + } + } +} + impl Config { pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -350,39 +418,24 @@ pub async fn load_global_mcp_servers( codex_home: &Path, ) -> std::io::Result> { let root_value = load_config_as_toml(codex_home).await?; - let Some(servers_value) = root_value.get("mcp_servers") else { - return Ok(BTreeMap::new()); - }; + let config_toml: ConfigToml = root_value.try_into().map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("failed to deserialize config.toml: {err}"), + ) + })?; - ensure_no_inline_bearer_tokens(servers_value)?; + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.to_path_buf(), + )?; - servers_value - .clone() - .try_into() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + Ok(config.mcp_servers.into_iter().collect()) } /// We briefly allowed plain text bearer_token fields in MCP server configs. /// We want to warn people who recently added these fields but can remove this after a few months. -fn ensure_no_inline_bearer_tokens(value: &TomlValue) -> std::io::Result<()> { - let Some(servers_table) = value.as_table() else { - return Ok(()); - }; - - for (server_name, server_value) in servers_table { - if let Some(server_table) = server_value.as_table() - && server_table.contains_key("bearer_token") - { - let message = format!( - "mcp_servers.{server_name} uses unsupported `bearer_token`; set `bearer_token_env_var`." - ); - return Err(std::io::Error::new(ErrorKind::InvalidData, message)); - } - } - - Ok(()) -} - pub fn write_global_mcp_servers( codex_home: &Path, servers: &BTreeMap, @@ -887,6 +940,9 @@ pub struct ConfigToml { #[serde(default)] pub cli_auth_credentials_store: Option, + /// Optional override for the shared agents home directory. + pub agents_home: Option, + /// Definition for MCP servers that Codex can reach out to for tool calls. #[serde(default)] pub mcp_servers: HashMap, @@ -1154,6 +1210,7 @@ pub struct ConfigOverrides { pub model_provider: Option, pub config_profile: Option, pub codex_linux_sandbox_exe: Option, + pub agents_home: Option, pub base_instructions: Option, pub include_apply_patch_tool: Option, pub include_view_image_tool: Option, @@ -1184,6 +1241,7 @@ impl Config { model_provider, config_profile: config_profile_key, codex_linux_sandbox_exe, + agents_home: agents_home_override, base_instructions, include_apply_patch_tool: include_apply_patch_tool_override, include_view_image_tool: include_view_image_tool_override, @@ -1238,6 +1296,21 @@ impl Config { } } }; + let agents_home = compute_agents_home( + agents_home_override, + cfg.agents_home.as_ref(), + &resolved_cwd, + &codex_home, + )?; + let project_agents_home = find_project_agents_home(&resolved_cwd); + let agents_context_entries = + load_agents_context_entries(&agents_home, project_agents_home.as_deref())?; + let agents_tools = load_agents_tools(&agents_home, project_agents_home.as_deref())?; + let agents_context_prompt = render_agents_context_prompt( + &agents_context_entries, + &agents_tools, + project_agents_home.as_deref(), + ); let additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| { @@ -1377,6 +1450,14 @@ impl Config { .or(cfg.review_model) .unwrap_or_else(default_review_model); + let mut mcp_servers = + load_agents_mcp_servers(&agents_home, project_agents_home.as_deref())?; + if !cfg.mcp_servers.is_empty() { + for (key, server) in cfg.mcp_servers.iter() { + mcp_servers.insert(key.clone(), server.clone()); + } + } + let config = Self { model, review_model, @@ -1398,7 +1479,7 @@ impl Config { // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), - mcp_servers: cfg.mcp_servers, + mcp_servers, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(), @@ -1418,6 +1499,11 @@ impl Config { }) .collect(), codex_home, + agents_home, + project_agents_home, + agents_context_entries, + agents_tools, + agents_context_prompt, history, file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_linux_sandbox_exe, @@ -1569,6 +1655,560 @@ pub fn find_codex_home() -> std::io::Result { Ok(p) } +fn compute_agents_home( + override_path: Option, + config_path: Option<&PathBuf>, + cwd: &Path, + codex_home: &Path, +) -> std::io::Result { + let candidate = if let Some(path) = override_path { + normalize_agents_home_candidate(path, cwd) + } else if let Some(path) = config_path { + normalize_agents_home_candidate(path.clone(), cwd) + } else if let Ok(env_home) = std::env::var("AGENTS_HOME") { + if env_home.trim().is_empty() { + default_agents_home_for(codex_home) + } else { + normalize_agents_home_candidate(PathBuf::from(env_home), cwd) + } + } else { + default_agents_home_for(codex_home) + }; + + ensure_agents_home_layout(&candidate)?; + Ok(candidate) +} + +fn find_project_agents_home(cwd: &Path) -> Option { + let mut cursor = cwd.to_path_buf(); + loop { + let candidate = cursor.join(".agents"); + if candidate.is_dir() { + return Some(candidate); + } + + if cursor.join(".git").exists() || !cursor.pop() { + break; + } + } + None +} + +fn normalize_agents_home_candidate(candidate: PathBuf, cwd: &Path) -> PathBuf { + if let Some(raw) = candidate.to_str() { + if raw == "~" { + if let Some(home) = home_dir() { + return home; + } + } else if let Some(stripped) = raw.strip_prefix("~/") + && let Some(home) = home_dir() + { + return home.join(stripped); + } + } + + if candidate.is_absolute() { + candidate + } else { + cwd.join(candidate) + } +} + +fn default_agents_home_for(codex_home: &Path) -> PathBuf { + if let Some(file_name) = codex_home.file_name().and_then(|name| name.to_str()) + && file_name == ".codex" + && let Some(parent) = codex_home.parent() + { + return parent.join(".agents"); + } + + codex_home.join(".agents") +} + +fn ensure_agents_home_layout(base: &Path) -> std::io::Result<()> { + fs::create_dir_all(base)?; + for child in AGENTS_HOME_DEFAULT_SUBDIRS { + fs::create_dir_all(base.join(child))?; + } + Ok(()) +} + +fn load_agents_mcp_servers( + global_agents_home: &Path, + project_agents_home: Option<&Path>, +) -> std::io::Result> { + let mut combined = load_agents_mcp_from_dir(global_agents_home, true)?; + if let Some(local_home) = project_agents_home { + let local_entries = load_agents_mcp_from_dir(local_home, false)?; + combined.extend(local_entries); + } + Ok(combined) +} + +fn parse_agents_mcp_toml( + contents: &str, + path: &Path, +) -> std::io::Result> { + let value: TomlValue = toml::from_str(contents).map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("failed to parse {} as TOML: {err}", path.display()), + ) + })?; + let parsed: BTreeMap = value.try_into().map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("invalid MCP configuration in {}: {err}", path.display()), + ) + })?; + Ok(parsed.into_iter().collect()) +} + +fn parse_agents_mcp_json( + contents: &str, + path: &Path, +) -> std::io::Result> { + let value: JsonValue = serde_json::from_str(contents).map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("failed to parse {} as JSON: {err}", path.display()), + ) + })?; + serde_json::from_value::>(value).map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("invalid MCP configuration in {}: {err}", path.display()), + ) + }) +} + +fn load_agents_mcp_from_dir( + agents_home: &Path, + ensure_structure: bool, +) -> std::io::Result> { + if ensure_structure { + ensure_agents_home_layout(agents_home)?; + } else if !agents_home.exists() { + return Ok(HashMap::new()); + } + + let config_dir = agents_home.join("mcp"); + if ensure_structure { + fs::create_dir_all(&config_dir)?; + } else if !config_dir.exists() { + return Ok(HashMap::new()); + } + + let candidate = ["mcp.config", "mcp.toml", "mcp.json"] + .into_iter() + .map(|name| config_dir.join(name)) + .find(|path| path.is_file()); + + let Some(path) = candidate else { + return Ok(HashMap::new()); + }; + + let contents = fs::read_to_string(&path)?; + if contents.trim().is_empty() { + return Ok(HashMap::new()); + } + + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .map(str::to_ascii_lowercase); + + if matches!(extension.as_deref(), Some("json")) { + parse_agents_mcp_json(&contents, &path) + } else if matches!(extension.as_deref(), Some("toml")) { + parse_agents_mcp_toml(&contents, &path) + } else { + match parse_agents_mcp_toml(&contents, &path) { + Ok(value) => Ok(value), + Err(toml_err) => match parse_agents_mcp_json(&contents, &path) { + Ok(value) => Ok(value), + Err(json_err) => Err(std::io::Error::new( + ErrorKind::InvalidData, + format!( + "failed to parse {} as TOML ({toml_err}) or JSON ({json_err})", + path.display() + ), + )), + }, + } + } +} + +fn load_agents_context_entries( + global_agents_home: &Path, + project_agents_home: Option<&Path>, +) -> std::io::Result> { + let mut entries: BTreeMap = BTreeMap::new(); + load_agents_context_from_dir(global_agents_home, true, AgentsSource::Global, &mut entries)?; + if let Some(local_home) = project_agents_home { + load_agents_context_from_dir(local_home, false, AgentsSource::Project, &mut entries)?; + } + Ok(entries.into_values().collect()) +} + +fn load_agents_context_from_dir( + agents_home: &Path, + ensure_structure: bool, + source: AgentsSource, + entries: &mut BTreeMap, +) -> std::io::Result<()> { + if ensure_structure { + ensure_agents_home_layout(agents_home)?; + } else if !agents_home.exists() { + return Ok(()); + } + + let context_dir = agents_home.join("context"); + if ensure_structure { + fs::create_dir_all(&context_dir)?; + } else if !context_dir.exists() { + return Ok(()); + } + + for entry in WalkDir::new(&context_dir).follow_links(false) { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + let err_string = err.to_string(); + if let Some(io) = err.into_io_error() { + return Err(io); + } + return Err(std::io::Error::other(format!( + "failed to walk {}: {err_string}", + context_dir.display() + ))); + } + }; + if entry.file_type().is_dir() { + continue; + } + if !entry.file_type().is_file() && !entry.file_type().is_symlink() { + continue; + } + + let path = entry.into_path(); + let relative = match path.strip_prefix(&context_dir) { + Ok(rel) => rel, + Err(_) => continue, + }; + + let Some(relative_str) = normalize_agents_relative_path(relative) else { + tracing::warn!( + "Skipping agents context file with unsupported path: {}", + path.display() + ); + continue; + }; + + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(err) => { + tracing::warn!( + "Failed to read metadata for agents context file {}: {err}", + path.display() + ); + continue; + } + }; + + if metadata.len() > AGENTS_CONTEXT_FILE_MAX_BYTES as u64 { + tracing::warn!( + "Agents context file `{}` exceeds {} bytes; truncating.", + path.display(), + AGENTS_CONTEXT_FILE_MAX_BYTES + ); + } + + let mut reader = BufReader::new(fs::File::open(&path)?); + let mut buffer: Vec = Vec::new(); + reader + .by_ref() + .take((AGENTS_CONTEXT_FILE_MAX_BYTES + 4) as u64) + .read_to_end(&mut buffer)?; + + let mut content = String::from_utf8_lossy(&buffer).into_owned(); + let truncated = metadata.len() > AGENTS_CONTEXT_FILE_MAX_BYTES as u64 + || content.len() > AGENTS_CONTEXT_FILE_MAX_BYTES; + if truncated { + let trimmed = take_bytes_at_char_boundary(&content, AGENTS_CONTEXT_FILE_MAX_BYTES); + content = trimmed.to_string(); + } + + if content.trim().is_empty() { + continue; + } + + entries.insert( + relative_str.clone(), + AgentContextEntry { + source, + relative_path: relative_str, + absolute_path: path, + content, + truncated, + }, + ); + } + + Ok(()) +} + +fn load_agents_tools( + global_agents_home: &Path, + project_agents_home: Option<&Path>, +) -> std::io::Result> { + let mut entries: BTreeMap = BTreeMap::new(); + load_agents_tools_from_dir(global_agents_home, true, AgentsSource::Global, &mut entries)?; + if let Some(local_home) = project_agents_home { + load_agents_tools_from_dir(local_home, false, AgentsSource::Project, &mut entries)?; + } + Ok(entries.into_values().collect()) +} + +fn load_agents_tools_from_dir( + agents_home: &Path, + ensure_structure: bool, + source: AgentsSource, + entries: &mut BTreeMap, +) -> std::io::Result<()> { + if ensure_structure { + ensure_agents_home_layout(agents_home)?; + } else if !agents_home.exists() { + return Ok(()); + } + + let tools_dir = agents_home.join("tools"); + if ensure_structure { + fs::create_dir_all(&tools_dir)?; + } else if !tools_dir.exists() { + return Ok(()); + } + + for entry in WalkDir::new(&tools_dir).follow_links(false) { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + let err_string = err.to_string(); + if let Some(io) = err.into_io_error() { + return Err(io); + } + return Err(std::io::Error::other(format!( + "failed to walk {}: {err_string}", + tools_dir.display() + ))); + } + }; + + if entry.file_type().is_dir() { + continue; + } + if !entry.file_type().is_file() && !entry.file_type().is_symlink() { + continue; + } + + let path = entry.into_path(); + let relative = match path.strip_prefix(&tools_dir) { + Ok(rel) => rel, + Err(_) => continue, + }; + + let Some(relative_str) = normalize_agents_relative_path(relative) else { + tracing::warn!( + "Skipping agents tool with unsupported path: {}", + path.display() + ); + continue; + }; + + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(err) => { + tracing::warn!( + "Failed to read metadata for agents tool {}: {err}", + path.display() + ); + continue; + } + }; + + let executable = detect_executable(&path, &metadata); + + entries.insert( + relative_str.clone(), + AgentToolEntry { + source, + relative_path: relative_str, + absolute_path: path, + executable, + }, + ); + } + + Ok(()) +} + +fn normalize_agents_relative_path(path: &Path) -> Option { + let mut parts: Vec<&str> = Vec::new(); + for component in path.components() { + match component { + Component::Normal(part) => parts.push(part.to_str()?), + Component::CurDir => continue, + Component::ParentDir => return None, + _ => return None, + } + } + Some(parts.join("/")) +} + +fn detect_executable(path: &Path, metadata: &fs::Metadata) -> bool { + #[cfg(unix)] + { + let _ = path; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + if metadata.permissions().readonly() { + return false; + } + let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else { + return false; + }; + matches!( + ext.to_ascii_lowercase().as_str(), + "bat" | "cmd" | "exe" | "ps1" | "py" + ) + } +} + +fn format_agents_path_for_display(path: &Path) -> String { + if let Some(home) = home_dir() + && let Ok(stripped) = path.strip_prefix(&home) + { + let suffix = stripped.to_string_lossy(); + if suffix.is_empty() { + "~".to_string() + } else if suffix.starts_with(std::path::MAIN_SEPARATOR) { + format!("~{suffix}") + } else { + format!("~{}{}", std::path::MAIN_SEPARATOR, suffix) + } + } else { + path.display().to_string() + } +} + +fn render_agents_context_prompt( + context_entries: &[AgentContextEntry], + tool_entries: &[AgentToolEntry], + project_agents_home: Option<&Path>, +) -> Option { + fn display_path( + source: AgentsSource, + subdir: &str, + relative: &str, + absolute: &Path, + project_agents_home: Option<&Path>, + ) -> String { + match source { + AgentsSource::Global => format_agents_path_for_display(absolute), + AgentsSource::Project => { + if let Some(project_home) = project_agents_home + && let Ok(stripped) = absolute.strip_prefix(project_home) + { + let stripped = normalize_agents_relative_path(stripped) + .unwrap_or_else(|| relative.to_string()); + format!(".agents/{stripped}") + } else { + format!(".agents/{subdir}/{relative}") + } + } + } + } + + if context_entries.is_empty() && tool_entries.is_empty() { + return None; + } + + let mut body = String::new(); + + if !context_entries.is_empty() { + body.push_str("## Context Files\n\n"); + for entry in context_entries { + body.push_str(&format!( + "### {} [{}]\nLocation: {}\n", + entry.relative_path, + entry.source.label(), + display_path( + entry.source, + "context", + &entry.relative_path, + &entry.absolute_path, + project_agents_home + ) + )); + if entry.truncated { + body.push_str(AGENTS_CONTEXT_FILE_TRUNCATION_NOTICE); + body.push('\n'); + } + body.push('\n'); + body.push_str(entry.content.trim_end()); + body.push_str("\n\n"); + } + } + + if !tool_entries.is_empty() { + body.push_str("## Tools\n"); + for tool in tool_entries { + let suffix = if tool.executable { + "" + } else { + " (not executable)" + }; + body.push_str(&format!( + "- {} [{}] -> {}{}\n", + tool.relative_path, + tool.source.label(), + display_path( + tool.source, + "tools", + &tool.relative_path, + &tool.absolute_path, + project_agents_home + ), + suffix + )); + } + body.push('\n'); + } + + let mut body_text = body.trim_end().to_string(); + if body_text.len() > AGENTS_CONTEXT_PROMPT_MAX_BYTES { + let trimmed = take_bytes_at_char_boundary(&body_text, AGENTS_CONTEXT_PROMPT_MAX_BYTES); + body_text = trimmed.to_string(); + if !body_text.ends_with('\n') { + body_text.push('\n'); + } + body_text.push_str(AGENTS_CONTEXT_TRUNCATION_NOTICE); + } + + if body_text.trim().is_empty() { + return None; + } + + Some(format!( + "{open}\n\n{body}\n\n{close}", + open = AGENTS_CONTEXT_OPEN_TAG, + body = body_text.trim_end(), + close = AGENTS_CONTEXT_CLOSE_TAG, + )) +} + /// Returns the path to the folder where Codex logs are stored. Does not verify /// that the directory exists. pub fn log_dir(cfg: &Config) -> std::io::Result { @@ -1580,6 +2220,8 @@ pub fn log_dir(cfg: &Config) -> std::io::Result { #[cfg(test)] mod tests { use crate::config_types::HistoryPersistence; + use crate::config_types::McpServerConfig; + use crate::config_types::McpServerTransportConfig; use crate::config_types::Notifications; use crate::features::Feature; @@ -2217,31 +2859,6 @@ startup_timeout_ms = 2500 Ok(()) } - #[tokio::test] - async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - let config_path = codex_home.path().join(CONFIG_TOML_FILE); - - std::fs::write( - &config_path, - r#" -[mcp_servers.docs] -url = "https://example.com/mcp" -bearer_token = "secret" -"#, - )?; - - let err = load_global_mcp_servers(codex_home.path()) - .await - .expect_err("bearer_token entries should be rejected"); - - assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); - assert!(err.to_string().contains("bearer_token")); - assert!(err.to_string().contains("bearer_token_env_var")); - - Ok(()) - } - #[tokio::test] async fn write_global_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -2945,6 +3562,415 @@ model = "gpt-5-codex" } } + #[test] + fn agents_home_defaults_next_to_dot_codex() -> std::io::Result<()> { + let parent = TempDir::new().expect("temp parent"); + let codex_home = parent.path().join(".codex"); + fs::create_dir_all(&codex_home)?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home, + )?; + + let expected_agents = parent.path().join(".agents"); + assert_eq!(expected_agents, config.agents_home); + for child in AGENTS_HOME_DEFAULT_SUBDIRS { + let subdir = config.agents_home.join(child); + assert!( + subdir.is_dir(), + "expected agents subdir `{}` to exist", + subdir.display() + ); + } + + Ok(()) + } + + #[test] + fn agents_home_defaults_within_custom_codex_home() -> std::io::Result<()> { + let codex_root = TempDir::new().expect("temp codex root"); + let codex_home = codex_root.path().join("codex-state"); + fs::create_dir_all(&codex_home)?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.clone(), + )?; + + let expected_agents = codex_home.join(".agents"); + assert_eq!(expected_agents, config.agents_home); + for child in AGENTS_HOME_DEFAULT_SUBDIRS { + assert!(config.agents_home.join(child).is_dir()); + } + + Ok(()) + } + + #[test] + fn agents_workspace_resources_loaded() -> std::io::Result<()> { + let parent = TempDir::new().expect("temp parent"); + let codex_home = parent.path().join(".codex"); + fs::create_dir_all(&codex_home)?; + + let context_dir = parent.path().join(".agents").join("context"); + fs::create_dir_all(&context_dir)?; + fs::write(context_dir.join("global.md"), "Global memo.")?; + + let tools_dir = parent.path().join(".agents").join("tools"); + fs::create_dir_all(&tools_dir)?; + fs::write( + tools_dir.join("calc.py"), + "#!/usr/bin/env python3\nprint(1+1)\n", + )?; + + let workspace = TempDir::new().expect("temp workspace"); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(workspace.path().to_path_buf()), + ..ConfigOverrides::default() + }, + codex_home, + )?; + + assert_eq!(config.agents_context_entries.len(), 1); + let entry = &config.agents_context_entries[0]; + assert_eq!(entry.relative_path, "global.md"); + assert!(matches!(entry.source, AgentsSource::Global)); + assert_eq!(entry.content.trim(), "Global memo."); + assert!(!entry.truncated); + assert_eq!( + entry.absolute_path, + parent + .path() + .join(".agents") + .join("context") + .join("global.md") + ); + + assert_eq!(config.agents_tools.len(), 1); + let tool = &config.agents_tools[0]; + assert_eq!(tool.relative_path, "calc.py"); + assert!(matches!(tool.source, AgentsSource::Global)); + assert_eq!( + tool.absolute_path, + parent.path().join(".agents").join("tools").join("calc.py") + ); + + let prompt = config + .agents_context_prompt + .as_ref() + .expect("context prompt expected"); + assert!(prompt.starts_with(AGENTS_CONTEXT_OPEN_TAG)); + assert!(prompt.contains("Global memo.")); + assert!(prompt.contains("calc.py")); + assert!(prompt.ends_with(AGENTS_CONTEXT_CLOSE_TAG)); + let expected_context_path = format_agents_path_for_display( + &parent + .path() + .join(".agents") + .join("context") + .join("global.md"), + ); + assert!( + prompt.contains(&expected_context_path), + "prompt should list global context location {expected_context_path}, got {prompt}" + ); + let expected_tool_path = format_agents_path_for_display( + &parent.path().join(".agents").join("tools").join("calc.py"), + ); + assert!( + prompt.contains(&expected_tool_path), + "prompt should list global tool location {expected_tool_path}, got {prompt}" + ); + + Ok(()) + } + + #[test] + fn project_agents_context_overrides_global_entries() -> std::io::Result<()> { + let parent = TempDir::new().expect("temp parent"); + let codex_home = parent.path().join(".codex"); + fs::create_dir_all(&codex_home)?; + + let global_context_dir = parent.path().join(".agents").join("context"); + fs::create_dir_all(&global_context_dir)?; + fs::write(global_context_dir.join("memo.md"), "Global memo.")?; + let global_tools_dir = parent.path().join(".agents").join("tools"); + fs::create_dir_all(&global_tools_dir)?; + fs::write(global_tools_dir.join("cli.sh"), "echo global\n")?; + + let project = TempDir::new().expect("project temp dir"); + fs::create_dir_all(project.path().join(".git"))?; + let project_context_dir = project.path().join(".agents").join("context"); + fs::create_dir_all(&project_context_dir)?; + fs::write(project_context_dir.join("memo.md"), "Project memo.")?; + let project_tools_dir = project.path().join(".agents").join("tools"); + fs::create_dir_all(&project_tools_dir)?; + fs::write(project_tools_dir.join("cli.sh"), "echo project\n")?; + + let overrides = ConfigOverrides { + cwd: Some(project.path().to_path_buf()), + ..Default::default() + }; + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + codex_home, + )?; + + assert_eq!(config.agents_context_entries.len(), 1); + let entry = &config.agents_context_entries[0]; + assert!(matches!(entry.source, AgentsSource::Project)); + assert_eq!(entry.relative_path, "memo.md"); + assert_eq!(entry.content.trim(), "Project memo."); + + assert_eq!(config.agents_tools.len(), 1); + let tool = &config.agents_tools[0]; + assert!(matches!(tool.source, AgentsSource::Project)); + assert_eq!(tool.relative_path, "cli.sh"); + + let prompt = config + .agents_context_prompt + .as_ref() + .expect("context prompt expected"); + assert!(prompt.contains("Project memo.")); + assert!(!prompt.contains("Global memo.")); + assert!(prompt.contains("cli.sh")); + assert!(prompt.starts_with(AGENTS_CONTEXT_OPEN_TAG)); + + Ok(()) + } + + #[test] + fn agents_prompt_uses_custom_home_override() -> std::io::Result<()> { + let codex_home = TempDir::new().expect("codex home"); + let custom_home = TempDir::new().expect("agents home override"); + let agents_home = custom_home.path().join("agents-store"); + fs::create_dir_all(agents_home.join("context"))?; + fs::create_dir_all(agents_home.join("tools"))?; + + fs::write(agents_home.join("context").join("memo.md"), "Global memo.")?; + fs::write(agents_home.join("tools").join("helper.sh"), "echo helper\n")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + agents_home: Some(agents_home.clone()), + ..Default::default() + }, + codex_home.path().to_path_buf(), + )?; + + let prompt = config + .agents_context_prompt + .as_ref() + .expect("context prompt expected"); + let expected_context = + format_agents_path_for_display(&agents_home.join("context").join("memo.md")); + assert!( + prompt.contains(&expected_context), + "prompt should mention overridden context path {expected_context}, got {prompt}" + ); + let expected_tool = + format_agents_path_for_display(&agents_home.join("tools").join("helper.sh")); + assert!( + prompt.contains(&expected_tool), + "prompt should mention overridden tool path {expected_tool}, got {prompt}" + ); + + Ok(()) + } + + #[test] + fn project_agents_home_detected_when_present() -> std::io::Result<()> { + let project = TempDir::new().expect("project temp dir"); + std::fs::create_dir_all(project.path().join(".git"))?; + std::fs::create_dir_all(project.path().join(".agents"))?; + + let codex_home = TempDir::new().expect("codex home"); + + let overrides = ConfigOverrides { + cwd: Some(project.path().to_path_buf()), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.project_agents_home, + Some(project.path().join(".agents")) + ); + assert!( + !project.path().join(".agents").join("mcp").exists(), + "local agents directory should not be auto-initialized" + ); + + Ok(()) + } + + #[test] + fn project_agents_home_overrides_global_mcp_entries() -> std::io::Result<()> { + let codex_home = TempDir::new().expect("codex home"); + let global_agents = codex_home.path().join(".agents").join("mcp"); + std::fs::create_dir_all(&global_agents)?; + std::fs::write( + global_agents.join("mcp.json"), + r#"{ + "global": { "command": "global-docs" }, + "docs": { "command": "global-docs" } +}"#, + )?; + + let project = TempDir::new().expect("project temp dir"); + std::fs::create_dir_all(project.path().join(".git"))?; + let local_agents = project.path().join(".agents").join("mcp"); + std::fs::create_dir_all(&local_agents)?; + std::fs::write( + local_agents.join("mcp.config"), + "[docs]\ncommand = \"local-docs\"\n", + )?; + + let overrides = ConfigOverrides { + cwd: Some(project.path().to_path_buf()), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.project_agents_home, + Some(project.path().join(".agents")) + ); + + let docs = config + .mcp_servers + .get("docs") + .expect("docs entry should be present"); + if let McpServerTransportConfig::Stdio { command, .. } = &docs.transport { + assert_eq!(command, "local-docs"); + } else { + panic!("expected stdio transport for docs"); + } + + let global = config + .mcp_servers + .get("global") + .expect("global entry should be present"); + if let McpServerTransportConfig::Stdio { command, .. } = &global.transport { + assert_eq!(command, "global-docs"); + } else { + panic!("expected stdio transport for global"); + } + + Ok(()) + } + + #[test] + fn agents_home_toml_mcp_config_loaded() -> std::io::Result<()> { + let parent = TempDir::new().expect("temp parent"); + let codex_home = parent.path().join(".codex"); + fs::create_dir_all(&codex_home)?; + let mcp_dir = parent.path().join(".agents").join("mcp"); + fs::create_dir_all(&mcp_dir)?; + fs::write( + mcp_dir.join("mcp.config"), + r#"[docs] +command = "docs-server" +"#, + )?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home, + )?; + + let expected = McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + }; + assert_eq!( + config.mcp_servers.get("docs"), + Some(&expected), + "expected docs server from agents_home" + ); + + Ok(()) + } + + #[test] + fn agents_home_json_mcp_config_respects_config_overrides() -> std::io::Result<()> { + let parent = TempDir::new().expect("temp parent"); + let codex_home = parent.path().join(".codex"); + fs::create_dir_all(&codex_home)?; + let mcp_dir = parent.path().join(".agents").join("mcp"); + fs::create_dir_all(&mcp_dir)?; + fs::write( + mcp_dir.join("mcp.json"), + r#"{ "docs": { "command": "json-docs" } }"#, + )?; + + let mut toml_cfg = ConfigToml::default(); + toml_cfg.mcp_servers.insert( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "toml-docs".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + }, + ); + + let config = Config::load_from_base_config_with_overrides( + toml_cfg, + ConfigOverrides::default(), + codex_home, + )?; + + let server = config + .mcp_servers + .get("docs") + .expect("docs server should exist"); + if let McpServerTransportConfig::Stdio { command, .. } = &server.transport { + assert_eq!(command, "toml-docs"); + } else { + panic!("expected stdio transport for docs"); + } + + Ok(()) + } + fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" @@ -3065,6 +4091,7 @@ model_verbosity = "high" o3_profile_overrides, fixture.codex_home(), )?; + let expected_agents_home = default_agents_home_for(&fixture.codex_home()); assert_eq!( Config { model: "o3".to_string(), @@ -3090,6 +4117,11 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), codex_home: fixture.codex_home(), + agents_home: expected_agents_home, + project_agents_home: None, + agents_context_entries: Vec::new(), + agents_tools: Vec::new(), + agents_context_prompt: None, history: History::default(), file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, @@ -3137,6 +4169,7 @@ model_verbosity = "high" gpt3_profile_overrides, fixture.codex_home(), )?; + let expected_agents_home = default_agents_home_for(&fixture.codex_home()); let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), @@ -3161,6 +4194,11 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), codex_home: fixture.codex_home(), + agents_home: expected_agents_home, + project_agents_home: None, + agents_context_entries: Vec::new(), + agents_tools: Vec::new(), + agents_context_prompt: None, history: History::default(), file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, @@ -3223,6 +4261,7 @@ model_verbosity = "high" zdr_profile_overrides, fixture.codex_home(), )?; + let expected_agents_home = default_agents_home_for(&fixture.codex_home()); let expected_zdr_profile_config = Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), @@ -3247,6 +4286,11 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), codex_home: fixture.codex_home(), + agents_home: expected_agents_home, + project_agents_home: None, + agents_context_entries: Vec::new(), + agents_tools: Vec::new(), + agents_context_prompt: None, history: History::default(), file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, @@ -3295,6 +4339,7 @@ model_verbosity = "high" gpt5_profile_overrides, fixture.codex_home(), )?; + let expected_agents_home = default_agents_home_for(&fixture.codex_home()); let expected_gpt5_profile_config = Config { model: "gpt-5".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), @@ -3319,6 +4364,11 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), codex_home: fixture.codex_home(), + agents_home: expected_agents_home, + project_agents_home: None, + agents_context_entries: Vec::new(), + agents_tools: Vec::new(), + agents_context_prompt: None, history: History::default(), file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index b911526f68..0e77a962a5 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -309,11 +309,17 @@ mod tests { let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1); let got_items = truncated.get_rollout_items(); - let expected: Vec = vec![ - RolloutItem::ResponseItem(items[0].clone()), - RolloutItem::ResponseItem(items[1].clone()), - RolloutItem::ResponseItem(items[2].clone()), - ]; + let mut expected: Vec = Vec::new(); + let mut user_count = 0; + for item in &items { + if let Some(TurnItem::UserMessage(_)) = crate::event_mapping::parse_turn_item(item) { + if user_count == 1 { + break; + } + user_count += 1; + } + expected.push(RolloutItem::ResponseItem(item.clone())); + } assert_eq!( serde_json::to_value(&got_items).unwrap(), diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 0c41951ee5..7bf809a074 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -15,7 +15,9 @@ use tracing::warn; fn is_session_prefix(text: &str) -> bool { let trimmed = text.trim_start(); let lowered = trimmed.to_ascii_lowercase(); - lowered.starts_with("") || lowered.starts_with("") + lowered.starts_with("") + || lowered.starts_with("") + || lowered.starts_with("") } fn parse_user_message(message: &[ContentItem]) -> Option { @@ -269,4 +271,17 @@ mod tests { other => panic!("expected TurnItem::WebSearch, got {other:?}"), } } + + #[test] + fn ignores_agents_context_message() { + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\nnotes\n".to_string(), + }], + }; + + assert!(parse_turn_item(&item).is_none()); + } } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 0064d0e5a4..0dd36ef363 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -1,5 +1,9 @@ #![expect(clippy::expect_used)] +use std::fs; +use std::path::Path; +use std::path::PathBuf; + use tempfile::TempDir; use codex_core::CodexConversation; @@ -37,6 +41,40 @@ pub fn load_default_config_for_test(codex_home: &TempDir) -> Config { .expect("defaults for test should always succeed") } +pub fn seed_global_agents_context( + codex_home: &TempDir, + relative: impl AsRef, + contents: &str, +) -> PathBuf { + seed_agents_file(codex_home, "context", relative, contents) +} + +pub fn seed_global_agents_tool( + codex_home: &TempDir, + relative: impl AsRef, + contents: &str, +) -> PathBuf { + seed_agents_file(codex_home, "tools", relative, contents) +} + +fn seed_agents_file( + codex_home: &TempDir, + subdir: &str, + relative: impl AsRef, + contents: &str, +) -> PathBuf { + let path = codex_home + .path() + .join(".agents") + .join(subdir) + .join(relative.as_ref()); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create agents directory"); + } + fs::write(&path, contents).expect("write agents file"); + path +} + #[cfg(target_os = "linux")] fn default_test_overrides() -> ConfigOverrides { ConfigOverrides { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 02e9cdc361..839310021c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,3 +1,4 @@ +use assert_cmd::cargo::cargo_bin; use codex_app_server_protocol::AuthMode; use codex_core::CodexAuth; use codex_core::ContentItem; @@ -14,8 +15,12 @@ use codex_core::ResponseItem; use codex_core::WireApi; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::ConfigToml; use codex_core::error::CodexErr; use codex_core::model_family::find_family_for_model; +use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; @@ -28,6 +33,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses; +use core_test_support::seed_global_agents_context; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; @@ -35,6 +41,7 @@ use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; use futures::StreamExt; use serde_json::json; +use std::fs; use std::io::Write; use std::sync::Arc; use tempfile::TempDir; @@ -235,6 +242,11 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { ..built_in_model_providers()["openai"].clone() }; let codex_home = TempDir::new().unwrap(); + let _guide_path = seed_global_agents_context( + &codex_home, + "guide.md", + "Remember to run the regression script.", + ); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; // Also configure user instructions to ensure they are NOT delivered on resume. @@ -321,6 +333,11 @@ async fn includes_conversation_id_and_model_headers_in_request() { // Init session let codex_home = TempDir::new().unwrap(); + let _guide_path = seed_global_agents_context( + &codex_home, + "guide.md", + "Remember to run the regression script.", + ); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; @@ -377,6 +394,11 @@ async fn includes_base_instructions_override_in_request() { ..built_in_model_providers()["openai"].clone() }; let codex_home = TempDir::new().unwrap(); + let _guide_path = seed_global_agents_context( + &codex_home, + "guide.md", + "Remember to run the regression script.", + ); let mut config = load_default_config_for_test(&codex_home); config.base_instructions = Some("test instructions".to_string()); @@ -438,6 +460,11 @@ async fn chatgpt_auth_sends_correct_request() { // Init session let codex_home = TempDir::new().unwrap(); + let _guide_path = seed_global_agents_context( + &codex_home, + "guide.md", + "Remember to run the regression script.", + ); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; let conversation_manager = ConversationManager::with_auth(create_dummy_codex_auth()); @@ -568,10 +595,14 @@ async fn includes_user_instructions_message_in_request() { }; let codex_home = TempDir::new().unwrap(); + let guide_path = seed_global_agents_context( + &codex_home, + "guide.md", + "Remember to run the regression script.", + ); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; config.user_instructions = Some("be nice".to_string()); - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); let codex = conversation_manager @@ -600,12 +631,579 @@ async fn includes_user_instructions_message_in_request() { .unwrap() .contains("be nice") ); - assert_message_role(&request_body["input"][0], "user"); - assert_message_starts_with(&request_body["input"][0], ""); - assert_message_ends_with(&request_body["input"][0], ""); - assert_message_role(&request_body["input"][1], "user"); - assert_message_starts_with(&request_body["input"][1], ""); - assert_message_ends_with(&request_body["input"][1], ""); + + let input_items = request_body["input"].as_array().expect("input array"); + let instructions_idx = input_items + .iter() + .position(|item| { + item["content"][0]["text"] + .as_str() + .is_some_and(|text| text.starts_with("")) + }) + .expect("user instructions message"); + let agents_idx = input_items + .iter() + .position(|item| { + item["content"][0]["text"] + .as_str() + .is_some_and(|text| text.starts_with("")) + }) + .expect("agents context message"); + let env_idx = input_items + .iter() + .position(|item| { + item["content"][0]["text"] + .as_str() + .is_some_and(|text| text.starts_with("")) + }) + .expect("environment context message"); + assert!( + instructions_idx < agents_idx, + "instructions should precede agents context" + ); + assert!( + agents_idx < env_idx, + "agents context should precede environment context" + ); + + assert_message_role(&input_items[instructions_idx], "user"); + assert_message_starts_with(&input_items[instructions_idx], ""); + assert_message_ends_with(&input_items[instructions_idx], ""); + + assert_message_role(&input_items[agents_idx], "user"); + assert_message_starts_with(&input_items[agents_idx], ""); + assert_message_ends_with(&input_items[agents_idx], ""); + let context_text = input_items[agents_idx]["content"][0]["text"] + .as_str() + .expect("agents context text"); + let expected_path = guide_path.display().to_string(); + assert!( + context_text.contains(&expected_path), + "agents context should reference {expected_path}, got {context_text}" + ); + + assert_message_role(&input_items[env_idx], "user"); + assert_message_starts_with(&input_items[env_idx], ""); + assert_message_ends_with(&input_items[env_idx], ""); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn includes_agents_context_message_in_request() { + skip_if_no_network!(); + let server = MockServer::start().await; + + let resp_mock = + responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1")) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let codex_home = TempDir::new().unwrap(); + let guide_path = seed_global_agents_context( + &codex_home, + "guide.md", + "Remember to run the regression script.", + ); + let mut config = load_default_config_for_test(&codex_home); + config.model_provider = model_provider; + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = resp_mock.single_request(); + let request_body = request.body_json(); + + let input_items = request_body["input"].as_array().expect("input array"); + let context_message = input_items + .iter() + .find(|value| { + value["content"][0]["text"].as_str().is_some_and(|text| { + text.starts_with("") && text.ends_with("") + }) + }) + .expect("agents context message present"); + assert_message_role(context_message, "user"); + let context_text = context_message["content"][0]["text"] + .as_str() + .expect("agents context text"); + assert!(context_text.contains("regression script")); + let expected_path = guide_path.display().to_string(); + assert!( + context_text.contains(&expected_path), + "expected context prompt to reference {expected_path}, got {context_text}" + ); + + let env_message = input_items + .iter() + .find(|value| { + value["content"][0]["text"] + .as_str() + .is_some_and(|text| text.starts_with("")) + }) + .expect("environment context message"); + assert_message_role(env_message, "user"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn includes_agents_context_message_in_user_turn_request() { + skip_if_no_network!(); + let server = MockServer::start().await; + + let resp_mock = + responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1")) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let codex_home = TempDir::new().unwrap(); + let guide_path = seed_global_agents_context( + &codex_home, + "guide.md", + "Remember to run the regression script.", + ); + + let mut config = load_default_config_for_test(&codex_home); + config.model_provider = model_provider; + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let NewConversation { + conversation: codex, + .. + } = conversation_manager + .new_conversation(config.clone()) + .await + .expect("create new conversation"); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello".into(), + }], + cwd: config.cwd.clone(), + approval_policy: config.approval_policy, + sandbox_policy: config.sandbox_policy.clone(), + model: config.model.clone(), + effort: config.model_reasoning_effort, + summary: config.model_reasoning_summary, + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = resp_mock.single_request(); + let request_body = request.body_json(); + let request_pretty = serde_json::to_string_pretty(&request_body).unwrap(); + let input_items = request_body["input"].as_array().expect("input array"); + let context_message = input_items + .iter() + .find(|value| { + value["content"][0]["text"].as_str().is_some_and(|text| { + text.starts_with("") && text.ends_with("") + }) + }) + .unwrap_or_else(|| panic!("agents context message missing: {request_pretty}")); + + let context_text = context_message["content"][0]["text"] + .as_str() + .expect("agents context text"); + assert!(context_text.contains("regression script")); + let expected_path = guide_path.display().to_string(); + assert!( + context_text.contains(&expected_path), + "expected context prompt to reference {expected_path}, got {context_text}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn global_agents_home_override_in_prompt() { + skip_if_no_network!(); + let server = MockServer::start().await; + + let resp_mock = + responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1")) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let codex_home = TempDir::new().unwrap(); + let agents_home_override = TempDir::new().unwrap(); + let override_root = agents_home_override.path().join("agents-home"); + std::fs::create_dir_all(override_root.join("context")).unwrap(); + std::fs::create_dir_all(override_root.join("tools")).unwrap(); + let context_path = override_root.join("context").join("memo.md"); + std::fs::write(&context_path, "Global memo.").unwrap(); + let tool_path = override_root.join("tools").join("helper.sh"); + std::fs::write(&tool_path, "echo helper\n").unwrap(); + + let mut overrides = ConfigOverrides { + agents_home: Some(override_root.clone()), + ..ConfigOverrides::default() + }; + #[cfg(target_os = "linux")] + { + overrides.codex_linux_sandbox_exe = Some(cargo_bin("codex-linux-sandbox")); + } + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + codex_home.path().to_path_buf(), + ) + .expect("load config with agents_home override"); + config.model_provider = model_provider; + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = resp_mock.single_request(); + let request_body = request.body_json(); + let input_items = request_body["input"].as_array().expect("input array"); + let context_message = input_items + .iter() + .find(|value| { + value["content"][0]["text"].as_str().is_some_and(|text| { + text.starts_with("") && text.ends_with("") + }) + }) + .expect("agents context message"); + let context_text = context_message["content"][0]["text"] + .as_str() + .expect("agents context text"); + + let expected_context_path = context_path.display().to_string(); + assert!( + context_text.contains(&expected_context_path), + "expected context prompt to reference {expected_context_path}, got {context_text}" + ); + let expected_tool_path = tool_path.display().to_string(); + assert!( + context_text.contains(&expected_tool_path), + "expected context prompt to reference {expected_tool_path}, got {context_text}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn agents_context_prefers_project_entries() { + skip_if_no_network!(); + let server = MockServer::start().await; + + let resp_mock = + responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1")) + .await; + + let codex_home = TempDir::new().unwrap(); + let global_agents = codex_home.path().join(".agents"); + std::fs::create_dir_all(global_agents.join("context")).unwrap(); + std::fs::create_dir_all(global_agents.join("tools")).unwrap(); + std::fs::write( + global_agents.join("context").join("guide.md"), + "Global memo.", + ) + .unwrap(); + std::fs::write( + global_agents.join("tools").join("analyze.sh"), + "echo global\n", + ) + .unwrap(); + + let project_dir = TempDir::new().unwrap(); + std::fs::create_dir_all(project_dir.path().join(".git")).unwrap(); + let project_agents = project_dir.path().join(".agents"); + std::fs::create_dir_all(project_agents.join("context")).unwrap(); + std::fs::create_dir_all(project_agents.join("tools")).unwrap(); + std::fs::write( + project_agents.join("context").join("guide.md"), + "Project memo.", + ) + .unwrap(); + std::fs::write( + project_agents.join("tools").join("analyze.sh"), + "echo project\n", + ) + .unwrap(); + + let mut overrides = ConfigOverrides { + cwd: Some(project_dir.path().to_path_buf()), + ..ConfigOverrides::default() + }; + #[cfg(target_os = "linux")] + { + overrides.codex_linux_sandbox_exe = Some(cargo_bin("codex-linux-sandbox")); + } + + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + codex_home.path().to_path_buf(), + ) + .expect("load config with overrides"); + + config.model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create new conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = resp_mock.single_request(); + let request_body = request.body_json(); + + let input_items = request_body["input"].as_array().expect("input array"); + let context_message = input_items + .iter() + .find(|value| { + value["content"][0]["text"].as_str().is_some_and(|text| { + text.starts_with("") && text.ends_with("") + }) + }) + .expect("agents context message"); + + let context_text = context_message["content"][0]["text"] + .as_str() + .expect("agents context text"); + assert!(context_text.contains("Project memo.")); + assert!(!context_text.contains("Global memo.")); + assert!(context_text.contains("Location: .agents/context/guide.md")); + assert!(context_text.contains("-> .agents/tools/analyze.sh")); + let codex_home_str = codex_home.path().to_string_lossy().to_string(); + let project_dir_str = project_dir.path().to_string_lossy().to_string(); + assert!(!context_text.contains(&codex_home_str)); + assert!(!context_text.contains(&project_dir_str)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cli_overrides_preserve_agents_context() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let sse = concat!( + "data: {\"type\":\"response.created\",\"response\":{}}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n" + ); + let resp_mock = + responses::mount_sse_once_match(&server, path("/v1/responses"), sse.to_string()).await; + + let home = TempDir::new().unwrap(); + let global_context = home.path().join(".agents").join("context"); + fs::create_dir_all(&global_context).unwrap(); + fs::write(global_context.join("memo.md"), "Global memo.").unwrap(); + let global_tools = home.path().join(".agents").join("tools"); + fs::create_dir_all(&global_tools).unwrap(); + fs::write(global_tools.join("helper.sh"), "echo global\n").unwrap(); + + let project_dir = TempDir::new().unwrap(); + fs::create_dir_all(project_dir.path().join(".git")).unwrap(); + let project_context = project_dir.path().join(".agents").join("context"); + fs::create_dir_all(&project_context).unwrap(); + fs::write(project_context.join("memo.md"), "Project memo.").unwrap(); + let project_tools = project_dir.path().join(".agents").join("tools"); + fs::create_dir_all(&project_tools).unwrap(); + fs::write(project_tools.join("helper.sh"), "echo project\n").unwrap(); + + let mut config_override_probe = ConfigOverrides { + cwd: Some(project_dir.path().to_path_buf()), + ..ConfigOverrides::default() + }; + #[cfg(target_os = "linux")] + { + config_override_probe.codex_linux_sandbox_exe = Some(cargo_bin("codex-linux-sandbox")); + } + let config_probe = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + config_override_probe, + home.path().to_path_buf(), + ) + .expect("load config with project overrides"); + let prompt_probe = config_probe + .agents_context_prompt + .as_ref() + .expect("expected agents context prompt"); + assert!(prompt_probe.contains("Project memo.")); + + let mut cli_overrides_struct = ConfigOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_mode: Some(SandboxMode::ReadOnly), + cwd: Some(project_dir.path().to_path_buf()), + ..ConfigOverrides::default() + }; + #[cfg(target_os = "linux")] + { + cli_overrides_struct.codex_linux_sandbox_exe = Some(cargo_bin("codex-linux-sandbox")); + } + unsafe { + std::env::set_var("CODEX_HOME", home.path()); + } + let config_cli = Config::load_with_cli_overrides(Vec::new(), cli_overrides_struct) + .await + .expect("load CLI config"); + unsafe { + std::env::remove_var("CODEX_HOME"); + } + let prompt_cli = config_cli + .agents_context_prompt + .as_ref() + .expect("expected CLI agents context prompt"); + assert!(prompt_cli.contains("Project memo.")); + + let mut cli_overrides_struct = ConfigOverrides { + approval_policy: Some(AskForApproval::Never), + sandbox_mode: Some(SandboxMode::ReadOnly), + cwd: Some(project_dir.path().to_path_buf()), + model_provider: Some("mock".to_string()), + ..ConfigOverrides::default() + }; + #[cfg(target_os = "linux")] + { + cli_overrides_struct.codex_linux_sandbox_exe = Some(cargo_bin("codex-linux-sandbox")); + } + let provider_override = toml::Value::Table( + [ + ("name".to_string(), toml::Value::String("mock".to_string())), + ( + "base_url".to_string(), + toml::Value::String(format!("{}/v1", server.uri())), + ), + ( + "env_key".to_string(), + toml::Value::String("PATH".to_string()), + ), + ( + "wire_api".to_string(), + toml::Value::String("responses".to_string()), + ), + ] + .into_iter() + .collect(), + ); + let cli_overrides = vec![ + ("model_providers.mock".to_string(), provider_override), + ( + "model_provider".to_string(), + toml::Value::String("mock".to_string()), + ), + ]; + + unsafe { + std::env::set_var("CODEX_HOME", home.path()); + } + let config_cli = Config::load_with_cli_overrides(cli_overrides, cli_overrides_struct) + .await + .expect("load CLI config"); + unsafe { + std::env::remove_var("CODEX_HOME"); + } + let prompt_cli = config_cli + .agents_context_prompt + .as_ref() + .expect("expected CLI agents context prompt"); + assert!(prompt_cli.contains("Project memo.")); + + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let NewConversation { + conversation: codex, + .. + } = conversation_manager + .new_conversation(config_cli.clone()) + .await + .expect("create new conversation"); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "hello agent".into(), + }], + cwd: config_cli.cwd.clone(), + approval_policy: config_cli.approval_policy, + sandbox_policy: config_cli.sandbox_policy.clone(), + model: config_cli.model.clone(), + effort: config_cli.model_reasoning_effort, + summary: config_cli.model_reasoning_summary, + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = resp_mock.single_request(); + let request_body = request.body_json(); + let request_pretty = serde_json::to_string_pretty(&request_body).unwrap(); + let input_items = request_body["input"].as_array().expect("input array"); + let context_message = input_items + .iter() + .find(|value| { + value["content"][0]["text"].as_str().is_some_and(|text| { + text.starts_with("") && text.ends_with("") + }) + }) + .unwrap_or_else(|| panic!("agents context message missing. request: {request_pretty}")); + + let context_text = context_message["content"][0]["text"] + .as_str() + .expect("agents context text"); + assert!(context_text.contains("Project memo.")); + assert!(!context_text.contains("Global memo.")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1380,3 +1978,4 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { "request 3 tail mismatch", ); } +use codex_protocol::config_types::SandboxMode; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index b13c6e14fd..31aef33565 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -17,7 +17,6 @@ use codex_core::NewConversation; use codex_core::built_in_model_providers; use codex_core::codex::compact::SUMMARIZATION_PROMPT; use codex_core::config::Config; -use codex_core::config::OPENAI_DEFAULT_MODEL; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; @@ -27,6 +26,7 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; +use core_test_support::seed_global_agents_context; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; @@ -64,6 +64,72 @@ fn is_ghost_snapshot_message(item: &Value) -> bool { .is_some_and(|text| text.trim_start().starts_with("")) } +fn find_message_text_with_prefix(request: &Value, prefix: &str) -> String { + request + .get("input") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find_map(|item| { + item.get("content") + .and_then(Value::as_array) + .and_then(|content| content.first()) + .and_then(|entry| entry.get("text")) + .and_then(Value::as_str) + .filter(|text| text.starts_with(prefix)) + }) + }) + .unwrap_or_else(|| { + panic!("expected request to include message starting with `{prefix}`: {request:?}") + }) + .to_string() +} + +fn session_prefix(request: &Value) -> Vec { + request + .get("input") + .and_then(Value::as_array) + .expect("input array") + .iter() + .take(3) + .cloned() + .collect() +} + +fn tail_messages(request: &Value) -> Vec<(String, String)> { + request + .get("input") + .and_then(Value::as_array) + .expect("input array") + .iter() + .skip(3) + .map(|item| { + let role = item["role"] + .as_str() + .expect("response item role should be text") + .to_string(); + let text = item["content"][0]["text"] + .as_str() + .expect("response item text expected in content[0]") + .to_string(); + (role, text) + }) + .collect() +} + +fn user_tail(text: impl Into) -> (String, String) { + ("user".to_string(), text.into()) +} + +fn assistant_tail(text: impl Into) -> (String, String) { + ("assistant".to_string(), text.into()) +} + +fn conversation_summary(seed: &str) -> String { + format!( + "You were originally given instructions from a user over one or more turns. Here were the user messages:\n\n{seed}\n\nAnother language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:\n\n{SUMMARY_TEXT}" + ) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] /// Scenario: compact an initial conversation, resume it, fork one turn back, and /// ensure the model-visible history matches expectations at each request. @@ -103,421 +169,64 @@ async fn compact_resume_and_fork_preserve_model_history_view() { // 3. Capture the requests to the model and validate the history slices. let requests = gather_request_bodies(&server).await; - // input after compact is a prefix of input after resume/fork - let input_after_compact = json!(requests[requests.len() - 3]["input"]); - let input_after_resume = json!(requests[requests.len() - 2]["input"]); - let input_after_fork = json!(requests[requests.len() - 1]["input"]); - - let compact_arr = input_after_compact - .as_array() - .expect("input after compact should be an array"); - let resume_arr = input_after_resume - .as_array() - .expect("input after resume should be an array"); - let fork_arr = input_after_fork - .as_array() - .expect("input after fork should be an array"); - - assert!( - compact_arr.len() <= resume_arr.len(), - "after-resume input should have at least as many items as after-compact", - ); - assert_eq!(compact_arr.as_slice(), &resume_arr[..compact_arr.len()]); - - assert!( - compact_arr.len() <= fork_arr.len(), - "after-fork input should have at least as many items as after-compact", - ); - assert_eq!( - &compact_arr.as_slice()[..compact_arr.len()], - &fork_arr[..compact_arr.len()] - ); - - let prompt = requests[0]["instructions"] - .as_str() - .unwrap_or_default() - .to_string(); - let user_instructions = requests[0]["input"][0]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let environment_context = requests[0]["input"][1]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let tool_calls = json!(requests[0]["tools"].as_array()); - let prompt_cache_key = requests[0]["prompt_cache_key"] - .as_str() - .unwrap_or_default() - .to_string(); - let fork_prompt_cache_key = requests[requests.len() - 1]["prompt_cache_key"] - .as_str() - .unwrap_or_default() - .to_string(); - let expected_model = OPENAI_DEFAULT_MODEL; - let user_turn_1 = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let compact_1 = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "hello world" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "FIRST_REPLY" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": SUMMARIZATION_PROMPT - } - ] - } - ], - "tools": [], - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let user_turn_2_after_compact = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "You were originally given instructions from a user over one or more turns. Here were the user messages: - -hello world - -Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis: - -SUMMARY_ONLY_CONTEXT" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let usert_turn_3_after_resume = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "You were originally given instructions from a user over one or more turns. Here were the user messages: - -hello world - -Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis: + assert_eq!(requests.len(), 5); -SUMMARY_ONLY_CONTEXT" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "AFTER_COMPACT_REPLY" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_RESUME" - } - ] + let prefix = session_prefix(&requests[0]); + let instructions = requests[0]["instructions"].clone(); + let first_cache_key = requests[0]["prompt_cache_key"].clone(); + + for (idx, request) in requests.iter().enumerate() { + assert_eq!( + request["instructions"], instructions, + "instructions changed at request {idx}" + ); + + if idx < requests.len() - 1 { + assert_eq!( + request["prompt_cache_key"], first_cache_key, + "prompt cache key changed at request {idx}" + ); } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": prompt_cache_key - }); - let user_turn_3_after_fork = json!( - { - "model": expected_model, - "instructions": prompt, - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": user_instructions - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": environment_context - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "You were originally given instructions from a user over one or more turns. Here were the user messages: -hello world + let input = request["input"].as_array().expect("input array"); + assert!( + input.len() >= prefix.len(), + "request {idx} is missing session prefix entries" + ); + assert_eq!( + &input[..prefix.len()], + &prefix[..], + "session prefix mismatch at request {idx}" + ); + } -Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis: + let summary_block = conversation_summary("hello world"); + let expected_tails: Vec> = vec![ + vec![user_tail("hello world")], + vec![ + user_tail("hello world"), + assistant_tail(FIRST_REPLY), + user_tail(SUMMARIZATION_PROMPT), + ], + vec![user_tail(summary_block.clone()), user_tail("AFTER_COMPACT")], + vec![ + user_tail(summary_block.clone()), + user_tail("AFTER_COMPACT"), + assistant_tail("AFTER_COMPACT_REPLY"), + user_tail("AFTER_RESUME"), + ], + vec![ + user_tail(summary_block), + user_tail("AFTER_COMPACT"), + assistant_tail("AFTER_COMPACT_REPLY"), + user_tail("AFTER_FORK"), + ], + ]; -SUMMARY_ONLY_CONTEXT" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_COMPACT" - } - ] - }, - { - "type": "message", - "role": "assistant", - "content": [ - { - "type": "output_text", - "text": "AFTER_COMPACT_REPLY" - } - ] - }, - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "AFTER_FORK" - } - ] - } - ], - "tools": tool_calls, - "tool_choice": "auto", - "parallel_tool_calls": false, - "reasoning": { - "summary": "auto" - }, - "store": false, - "stream": true, - "include": [ - "reasoning.encrypted_content" - ], - "prompt_cache_key": fork_prompt_cache_key - }); - let mut expected = json!([ - user_turn_1, - compact_1, - user_turn_2_after_compact, - usert_turn_3_after_resume, - user_turn_3_after_fork - ]); - normalize_line_endings(&mut expected); - assert_eq!(requests.len(), 5); - assert_eq!(json!(requests), expected); + for (idx, (request, expected_tail)) in requests.iter().zip(expected_tails).enumerate() { + let tail = tail_messages(request); + assert_eq!(tail, expected_tail, "tail mismatch at request {idx}"); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -594,14 +303,10 @@ async fn compact_resume_after_second_compaction_preserves_history() { .as_str() .unwrap_or_default() .to_string(); - let user_instructions = requests[0]["input"][0]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); - let environment_instructions = requests[0]["input"][1]["content"][0]["text"] - .as_str() - .unwrap_or_default() - .to_string(); + let user_instructions = find_message_text_with_prefix(&requests[0], ""); + let agents_context = find_message_text_with_prefix(&requests[0], ""); + let environment_instructions = + find_message_text_with_prefix(&requests[0], ""); let mut expected = json!([ { @@ -617,6 +322,16 @@ async fn compact_resume_after_second_compaction_preserves_history() { } ] }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": agents_context + } + ] + }, { "type": "message", "role": "user", @@ -786,6 +501,7 @@ async fn start_test_conversation( ..built_in_model_providers()["openai"].clone() }; let home = TempDir::new().expect("create temp dir"); + let _ = seed_global_agents_context(&home, "guide.md", "Global memo."); let mut config = load_default_config_for_test(&home); config.model_provider = model_provider; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 1bf12eb954..5061d0fc7f 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -18,6 +18,7 @@ use codex_core::shell::default_user_shell; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; +use core_test_support::seed_global_agents_context; use core_test_support::skip_if_no_network; use core_test_support::wait_for_event; use std::collections::HashMap; @@ -36,6 +37,20 @@ fn text_user_input(text: String) -> serde_json::Value { }) } +fn message_by_prefix(body: &serde_json::Value, prefix: &str) -> serde_json::Value { + body["input"] + .as_array() + .and_then(|items| { + items.iter().find(|item| { + item["content"][0]["text"] + .as_str() + .is_some_and(|text| text.starts_with(prefix)) + }) + }) + .cloned() + .unwrap_or_else(|| panic!("expected message starting with `{prefix}`")) +} + fn default_env_context_str(cwd: &str, shell: &Shell) -> String { format!( r#" @@ -96,6 +111,8 @@ async fn codex_mini_latest_tools() { let cwd = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap(); + let _ = seed_global_agents_context(&codex_home, "guide.md", "Global memo."); + let _ = seed_global_agents_context(&codex_home, "guide.md", "Global memo."); let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; @@ -181,6 +198,7 @@ async fn prompt_tools_are_consistent_across_requests() { let cwd = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap(); + let _ = seed_global_agents_context(&codex_home, "guide.md", "Global memo."); let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); @@ -303,6 +321,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let cwd = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap(); + let _ = seed_global_agents_context(&codex_home, "guide.md", "Global memo."); let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; @@ -374,9 +393,15 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests "content": [ { "type": "input_text", "text": "hello 1" } ] }); let body1 = requests[0].body_json::().unwrap(); + let expected_agents_msg = message_by_prefix(&body1, ""); assert_eq!( body1["input"], - serde_json::json!([expected_ui_msg, expected_env_msg, expected_user_message_1]) + serde_json::json!([ + expected_ui_msg, + expected_agents_msg, + expected_env_msg, + expected_user_message_1 + ]) ); let expected_user_message_2 = serde_json::json!({ @@ -393,6 +418,10 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests .concat() ); assert_eq!(body2["input"], expected_body2); + assert_eq!( + message_by_prefix(&body2, ""), + expected_agents_msg + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -422,6 +451,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { let cwd = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap(); + let _ = seed_global_agents_context(&codex_home, "guide.md", "Global memo."); let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; @@ -550,6 +580,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() { let cwd = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap(); + let _ = seed_global_agents_context(&codex_home, "guide.md", "Global memo."); let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; @@ -673,6 +704,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() { let cwd = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap(); + let _ = seed_global_agents_context(&codex_home, "guide.md", "Global memo."); let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; @@ -737,6 +769,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() { let expected_ui_text = "\n\nbe consistent and helpful\n\n"; let expected_ui_msg = text_user_input(expected_ui_text.to_string()); + let expected_agents_msg_1 = message_by_prefix(&body1, ""); let expected_env_msg_1 = text_user_input(default_env_context_str( &cwd.path().to_string_lossy(), @@ -746,6 +779,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() { let expected_input_1 = serde_json::Value::Array(vec![ expected_ui_msg.clone(), + expected_agents_msg_1.clone(), expected_env_msg_1.clone(), expected_user_message_1.clone(), ]); @@ -754,6 +788,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() { let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_ui_msg, + expected_agents_msg_1, expected_env_msg_1, expected_user_message_1, expected_user_message_2, @@ -787,6 +822,7 @@ async fn send_user_turn_with_changes_sends_environment_context() { let cwd = TempDir::new().unwrap(); let codex_home = TempDir::new().unwrap(); + let _ = seed_global_agents_context(&codex_home, "guide.md", "Global memo."); let mut config = load_default_config_for_test(&codex_home); config.cwd = cwd.path().to_path_buf(); config.model_provider = model_provider; @@ -855,11 +891,13 @@ async fn send_user_turn_with_changes_sends_environment_context() { "role": "user", "content": [ { "type": "input_text", "text": expected_ui_text } ] }); + let expected_agents_msg_1 = message_by_prefix(&body1, ""); let expected_env_text_1 = default_env_context_str(&default_cwd.to_string_lossy(), &shell); let expected_env_msg_1 = text_user_input(expected_env_text_1); let expected_user_message_1 = text_user_input("hello 1".to_string()); let expected_input_1 = serde_json::Value::Array(vec![ expected_ui_msg.clone(), + expected_agents_msg_1.clone(), expected_env_msg_1.clone(), expected_user_message_1.clone(), ]); @@ -876,6 +914,7 @@ async fn send_user_turn_with_changes_sends_environment_context() { let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_ui_msg, + expected_agents_msg_1, expected_env_msg_1, expected_user_message_1, expected_env_msg_2, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 559fd98002..23d7a899f4 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -174,6 +174,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)), model_provider, codex_linux_sandbox_exe, + agents_home: None, base_instructions: None, include_apply_patch_tool: None, include_view_image_tool: None, diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 24a5eec4b8..550d399f58 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -153,6 +153,7 @@ impl CodexToolCallParam { sandbox_mode: sandbox.map(Into::into), model_provider: None, codex_linux_sandbox_exe, + agents_home: None, base_instructions, include_apply_patch_tool: None, include_view_image_tool: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a7f5241bb3..8a1fb26736 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -46,6 +46,8 @@ pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; +pub const AGENTS_CONTEXT_OPEN_TAG: &str = ""; +pub const AGENTS_CONTEXT_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; /// Submission Queue Entry - requests from user diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 028bf68e87..e90eb024d4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -143,6 +143,7 @@ pub async fn run_main( model_provider: model_provider_override, config_profile: cli.config_profile.clone(), codex_linux_sandbox_exe, + agents_home: None, base_instructions: None, include_apply_patch_tool: None, include_view_image_tool: None, diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 4ab4a8ea63..615db07196 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -43,25 +43,32 @@ fn sanitize_directory(lines: Vec) -> Vec { lines .into_iter() .map(|line| { - if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('│')) { - let prefix = &line[..dir_pos + "Directory: ".len()]; - let suffix = &line[pipe_idx..]; - let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len()); - let replacement = "[[workspace]]"; - let mut rebuilt = prefix.to_string(); - rebuilt.push_str(replacement); - if content_width > replacement.len() { - rebuilt.push_str(&" ".repeat(content_width - replacement.len())); - } - rebuilt.push_str(suffix); - rebuilt - } else { - line - } + sanitize_field( + sanitize_field(line, "Directory: ", "[[workspace]]"), + "Agents home: ", + "[[agents]]", + ) }) .collect() } +fn sanitize_field(line: String, label: &str, placeholder: &str) -> String { + if let (Some(label_pos), Some(pipe_idx)) = (line.find(label), line.rfind('│')) { + let prefix = &line[..label_pos + label.len()]; + let suffix = &line[pipe_idx..]; + let content_width = pipe_idx.saturating_sub(label_pos + label.len()); + let mut rebuilt = prefix.to_string(); + rebuilt.push_str(placeholder); + if content_width > placeholder.len() { + rebuilt.push_str(&" ".repeat(content_width - placeholder.len())); + } + rebuilt.push_str(suffix); + rebuilt + } else { + line + } +} + fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> i64 { (*captured_at + ChronoDuration::seconds(seconds)) .with_timezone(&Utc) diff --git a/docs/agents_md.md b/docs/agents_md.md index ff2243a0ca..d5f6828584 100644 --- a/docs/agents_md.md +++ b/docs/agents_md.md @@ -9,6 +9,18 @@ Codex uses [`AGENTS.md`](https://agents.md/) files to gather helpful guidance be - Only the first non-empty file is used. Other filenames, such as `instructions.md`, have no effect unless Codex is specifically instructed to use them. - Whatever Codex finds here stays active for the whole session, and Codex combines it with any project-specific instructions it discovers. +## Shared Agents Workspace (`~/.agents`) + +Codex also creates a companion `.agents` directory alongside `.codex` (or at whatever path you set via `agents_home`/`AGENTS_HOME`). Use this space to keep durable resources that multiple projects or agents should reuse: + +- `context/` for structured notes, runbooks, and decision logs that complement your `AGENTS.md` guidance. Codex reads UTF-8 files from this folder and emits them in an `` block at the start of every session (32 KiB per file, 64 KiB total). Project-level `.agents/context/` folders override conflicting files from the global store, and the CLI reports global entries with their actual absolute paths so agents never rely on the default `~/.agents` prefix. +- `tools/` for small scripts or binaries that agents can launch (for example, calculators, code generators, or health-check scripts). Paths discovered here are listed in the same `` block using their true locations, so the agent knows exactly how to invoke them. +- `mcp/` for Model Context Protocol server manifests and credential material you want available everywhere. Place one `mcp.config`, `mcp.toml`, or `mcp.json` file here (first match wins) to declare shared MCP servers. + +The CLI makes sure these directories exist on startup. Set `agents_home` in your config or the `AGENTS_HOME` environment variable if you want to relocate them. + +If a repository contains its own `.agents/` folder (for example at `/.agents`), Codex will automatically merge its contents with the global workspace: project-specific entries augment or override the shared ones without requiring additional configuration. + ## Project Instructions (per-repository) When you work inside a project, Codex builds on those global instructions by collecting project docs: diff --git a/docs/config.md b/docs/config.md index 0d81a64efc..3f8d56d256 100644 --- a/docs/config.md +++ b/docs/config.md @@ -310,6 +310,46 @@ This is reasonable to use if Codex is running in an environment that provides it Though using this option may also be necessary if you try to use Codex in environments where its native sandboxing mechanisms are unsupported, such as older Linux kernels or on Windows. +### agents_home + +Codex keeps reusable memories, helper scripts, and MCP manifests inside a shared `.agents` directory. By default this folder lives next to `.codex` in your home directory and Codex creates the `context/`, `tools/`, and `mcp/` subdirectories automatically. + +Files in `context/` are merged into a single `` block that is prepended to every session. Codex reads UTF-8 files from the global directory first and then overlays any project-level `.agents/context/` directory; files with matching relative paths are replaced by the project copy. The combined block is limited to 32 KiB per file and 64 KiB total. When a file exceeds the per-file limit Codex adds `_Content truncated to 32 KiB._` so you can split or reorganize it. Scripts dropped into `tools/` are listed in the same block with their absolute paths (derived from your configured `agents_home`), giving the agent a canonical place to discover helper binaries or small CLI utilities. Global context entries are printed with the same absolute paths so the agent never relies on the default `~/.agents` location. + +Override the location if you want to place those resources somewhere else (for example on a synced drive): + +```toml +# store shared artifacts under ~/.workspace/agents/ +agents_home = "~/.workspace/agents" +``` + +Alternatively, set the `AGENTS_HOME` environment variable before launching Codex. Relative paths are resolved against the current working directory, and leading `~` expands to your home directory. + +When a repository contains its own `.agents/` directory (for example `/.agents`), Codex merges its `context/`, `tools/`, and `mcp/` subfolders on top of the global workspace. Project-level entries override global ones, letting each codebase define tailored memories and MCP servers without additional configuration. + +#### MCP configuration file + +Inside `agents_home`, Codex checks for `mcp/mcp.config`, `mcp/mcp.toml`, or `mcp/mcp.json` (in that order) and uses the first file it finds. The contents (TOML or JSON) are merged with the `mcp_servers` section from `config.toml`; entries defined in `config.toml` still take precedence when the same server id appears in both places. + +Example `mcp.config`: + +```toml +[docs] +command = "docs-server" +``` + +Example `mcp.json`: + +```json +{ + "docs": { + "command": "docs-server" + } +} +``` + +This lets you keep shared MCP server definitions alongside other reusable agent assets without editing `config.toml` on every machine. + ### tools.\* Use the optional `[tools]` table to toggle built-in tools that the agent may call. Both keys default to `false` (tools stay disabled) unless you opt in: diff --git a/docs/getting-started.md b/docs/getting-started.md index 4930061c27..6c48ebf751 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,6 +68,19 @@ You can give Codex extra instructions and guidance using `AGENTS.md` files. Code For more information on how to use AGENTS.md, see the [official AGENTS.md documentation](https://agents.md/). +### Agents Home (`~/.agents` by default) + +Codex also provisions a shared `.agents` directory next to your `.codex` folder (or at the path you configure via `agents_home` or `AGENTS_HOME`). It is created automatically with a predictable layout so multiple agents and sessions can reuse the same resources. When a global file is referenced in the `` block, Codex now prints its real absolute path so the agent can execute it without guessing at the default location. + +- `context/` – UTF-8 text files that Codex injects at the start of every session inside an `` block. Entries from project-level `.agents/context/` directories override files with the same relative path from the global store. +- `tools/` – reusable helper programs (for example, a calculator script or project-specific linters). Codex lists these paths in the same `` block so the agent can call them directly via the shell. +- `mcp/` – Model Context Protocol server definitions and credentials that you want to keep ready for any project. +- Drop exactly one of `mcp.config`, `mcp.toml`, or `mcp.json` into the `mcp/` directory (Codex uses the first one it finds in that order) to declare shared MCP servers; Codex merges it with any `mcp_servers` defined in `~/.codex/config.toml`. +- If your repository contains a `.agents/` folder, Codex merges its contents with the global workspace so you can keep project-specific memories and tools alongside the codebase. Project-specific entries override global ones when they share the same relative path. +- Context is truncated to 32 KiB per file and 64 KiB total per session. Codex inserts a `_Content truncated to 32 KiB._` note next to shortened files so you can adjust the source material if needed. + +You can relocate the directory with the `agents_home` config setting or the `AGENTS_HOME` environment variable if you prefer to store it somewhere else. + ### Tips & shortcuts #### Use `@` for file search