diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4091c0d243..ab422bfd23 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1375,6 +1375,17 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "codex-agent-runtime" +version = "0.0.0" +dependencies = [ + "codex-otel", + "codex-protocol", + "pretty_assertions", + "rand 0.9.3", + "tokio", +] + [[package]] name = "codex-analytics" version = "0.0.0" @@ -1454,6 +1465,7 @@ dependencies = [ "codex-backend-client", "codex-chatgpt", "codex-cloud-requirements", + "codex-code-mode-runtime", "codex-config", "codex-core", "codex-core-plugins", @@ -1846,13 +1858,28 @@ dependencies = [ "async-channel", "async-trait", "codex-protocol", - "deno_core_icudata", "pretty_assertions", "serde", "serde_json", "tokio", "tokio-util", "tracing", +] + +[[package]] +name = "codex-code-mode-runtime" +version = "0.0.0" +dependencies = [ + "async-channel", + "async-trait", + "codex-code-mode", + "codex-protocol", + "deno_core_icudata", + "pretty_assertions", + "serde_json", + "tokio", + "tokio-util", + "tracing", "v8", ] @@ -1873,6 +1900,7 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "codex-utils-path", + "dunce", "futures", "multimap", "pretty_assertions", @@ -1890,6 +1918,28 @@ dependencies = [ "wildmatch", ] +[[package]] +name = "codex-config-loader" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "codex-app-server-protocol", + "codex-config", + "codex-exec-server", + "codex-git-utils", + "codex-protocol", + "codex-utils-absolute-path", + "core-foundation 0.9.4", + "dunce", + "serde", + "tempfile", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "codex-connectors" version = "0.0.0" @@ -1916,6 +1966,7 @@ dependencies = [ "bm25", "chrono", "clap", + "codex-agent-runtime", "codex-analytics", "codex-api", "codex-app-server-protocol", @@ -1924,6 +1975,7 @@ dependencies = [ "codex-async-utils", "codex-code-mode", "codex-config", + "codex-config-loader", "codex-connectors", "codex-core-plugins", "codex-core-skills", @@ -1931,12 +1983,17 @@ dependencies = [ "codex-execpolicy", "codex-features", "codex-feedback", + "codex-file-watcher", "codex-git-utils", "codex-hooks", "codex-instructions", + "codex-js-repl", "codex-login", "codex-mcp", "codex-model-provider", + "codex-mcp-tool-approval", + "codex-memory-prompts", + "codex-message-history", "codex-model-provider-info", "codex-models-manager", "codex-network-proxy", @@ -1944,16 +2001,19 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-response-debug-context", + "codex-review", "codex-rmcp-client", "codex-rollout", "codex-sandboxing", "codex-secrets", + "codex-session-runtime", "codex-shell-command", "codex-shell-escalation", "codex-state", "codex-terminal-detection", "codex-test-binary-support", "codex-thread-store", + "codex-tool-spec", "codex-tools", "codex-utils-absolute-path", "codex-utils-cache", @@ -1969,7 +2029,6 @@ dependencies = [ "codex-utils-string", "codex-utils-template", "codex-windows-sandbox", - "core-foundation 0.9.4", "core_test_support", "crypto_box", "csv", @@ -1987,7 +2046,6 @@ dependencies = [ "insta", "libc", "maplit", - "notify", "once_cell", "openssl-sys", "opentelemetry", @@ -2023,7 +2081,6 @@ dependencies = [ "walkdir", "which 8.0.0", "whoami", - "windows-sys 0.52.0", "wiremock", "zip 2.4.2", "zstd 0.13.3", @@ -2262,6 +2319,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-file-watcher" +version = "0.0.0" +dependencies = [ + "notify", + "pretty_assertions", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-git-utils" version = "0.0.0" @@ -2320,6 +2388,10 @@ dependencies = [ "serde", ] +[[package]] +name = "codex-js-repl" +version = "0.0.0" + [[package]] name = "codex-keyring-store" version = "0.0.0" @@ -2482,6 +2554,35 @@ dependencies = [ "pretty_assertions", ] +[[package]] +name = "codex-mcp-tool-approval" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "codex-memory-prompts" +version = "0.0.0" + +[[package]] +name = "codex-message-history" +version = "0.0.0" +dependencies = [ + "codex-config", + "codex-protocol", + "codex-utils-absolute-path", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-model-provider-info" version = "0.0.0" @@ -2707,6 +2808,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "codex-review" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-git-utils", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-template", + "pretty_assertions", +] + [[package]] name = "codex-rmcp-client" version = "0.0.0" @@ -2808,6 +2921,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "codex-session-runtime" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-rmcp-client", + "codex-sandboxing", + "rmcp", + "tokio", +] + [[package]] name = "codex-shell-command" version = "0.0.0" @@ -2932,6 +3056,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-tool-spec" +version = "0.0.0" +dependencies = [ + "codex-mcp", + "codex-protocol", + "codex-tools", +] + [[package]] name = "codex-tools" version = "0.0.0" @@ -3493,6 +3626,7 @@ dependencies = [ "assert_cmd", "base64 0.22.1", "codex-arg0", + "codex-code-mode-runtime", "codex-core", "codex-exec-server", "codex-features", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ed6bdc0fd6..73592175ba 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "analytics", + "agent-runtime", "backend-client", "ansi-escape", "async-utils", @@ -16,6 +17,8 @@ members = [ "install-context", "codex-backend-openapi-models", "code-mode", + "code-mode-runtime", + "config-loader", "cloud-requirements", "cloud-tasks", "cloud-tasks-client", @@ -38,11 +41,16 @@ members = [ "execpolicy", "execpolicy-legacy", "keyring-store", + "file-watcher", "file-search", + "js-repl", "linux-sandbox", "lmstudio", "login", "codex-mcp", + "message-history", + "memory-prompts", + "mcp-tool-approval", "mcp-server", "model-provider-info", "models-manager", @@ -51,13 +59,16 @@ members = [ "process-hardening", "protocol", "realtime-webrtc", + "review", "rollout", "rmcp-client", "responses-api-proxy", "response-debug-context", "sandboxing", + "session-runtime", "stdio-to-uds", "otel", + "tool-spec", "tui", "tools", "v8-poc", @@ -109,6 +120,7 @@ license = "Apache-2.0" # Internal app_test_support = { path = "app-server/tests/common" } codex-analytics = { path = "analytics" } +codex-agent-runtime = { path = "agent-runtime" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } codex-app-server = { path = "app-server" } @@ -127,7 +139,9 @@ codex-cloud-requirements = { path = "cloud-requirements" } codex-cloud-tasks-client = { path = "cloud-tasks-client" } codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } codex-code-mode = { path = "code-mode" } +codex-code-mode-runtime = { path = "code-mode-runtime" } codex-config = { path = "config" } +codex-config-loader = { path = "config-loader" } codex-connectors = { path = "connectors" } codex-core = { path = "core" } codex-core-plugins = { path = "core-plugins" } @@ -138,16 +152,21 @@ codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } codex-features = { path = "features" } codex-feedback = { path = "feedback" } +codex-file-watcher = { path = "file-watcher" } codex-install-context = { path = "install-context" } codex-file-search = { path = "file-search" } codex-git-utils = { path = "git-utils" } codex-hooks = { path = "hooks" } codex-instructions = { path = "instructions" } +codex-js-repl = { path = "js-repl" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } codex-mcp = { path = "codex-mcp" } +codex-message-history = { path = "message-history" } +codex-memory-prompts = { path = "memory-prompts" } +codex-mcp-tool-approval = { path = "mcp-tool-approval" } codex-mcp-server = { path = "mcp-server" } codex-model-provider-info = { path = "model-provider-info" } codex-models-manager = { path = "models-manager" } @@ -161,10 +180,12 @@ codex-protocol = { path = "protocol" } codex-realtime-webrtc = { path = "realtime-webrtc" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-response-debug-context = { path = "response-debug-context" } +codex-review = { path = "review" } codex-rmcp-client = { path = "rmcp-client" } codex-rollout = { path = "rollout" } codex-sandboxing = { path = "sandboxing" } codex-secrets = { path = "secrets" } +codex-session-runtime = { path = "session-runtime" } codex-shell-command = { path = "shell-command" } codex-shell-escalation = { path = "shell-escalation" } codex-skills = { path = "skills" } @@ -173,6 +194,7 @@ codex-stdio-to-uds = { path = "stdio-to-uds" } codex-terminal-detection = { path = "terminal-detection" } codex-test-binary-support = { path = "test-binary-support" } codex-thread-store = { path = "thread-store" } +codex-tool-spec = { path = "tool-spec" } codex-tools = { path = "tools" } codex-tui = { path = "tui" } codex-utils-absolute-path = { path = "utils/absolute-path" } diff --git a/codex-rs/agent-runtime/BUILD.bazel b/codex-rs/agent-runtime/BUILD.bazel new file mode 100644 index 0000000000..01346f1972 --- /dev/null +++ b/codex-rs/agent-runtime/BUILD.bazel @@ -0,0 +1,9 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "agent-runtime", + crate_name = "codex_agent_runtime", + compile_data = [ + "src/agent_names.txt", + ], +) diff --git a/codex-rs/agent-runtime/Cargo.toml b/codex-rs/agent-runtime/Cargo.toml new file mode 100644 index 0000000000..b3cc1431f0 --- /dev/null +++ b/codex-rs/agent-runtime/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-agent-runtime" +version.workspace = true + +[lib] +doctest = false +name = "codex_agent_runtime" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +rand = { workspace = true } +tokio = { workspace = true, features = ["sync"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/codex-rs/core/src/agent/agent_names.txt b/codex-rs/agent-runtime/src/agent_names.txt similarity index 100% rename from codex-rs/core/src/agent/agent_names.txt rename to codex-rs/agent-runtime/src/agent_names.txt diff --git a/codex-rs/agent-runtime/src/control.rs b/codex-rs/agent-runtime/src/control.rs new file mode 100644 index 0000000000..962352b9d4 --- /dev/null +++ b/codex-rs/agent-runtime/src/control.rs @@ -0,0 +1,225 @@ +use crate::registry::AgentMetadata; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::user_input::UserInput; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SpawnAgentForkMode { + FullHistory, + LastNTurns(usize), +} + +#[derive(Clone, Debug, Default)] +pub struct SpawnAgentOptions { + pub fork_parent_spawn_call_id: Option, + pub fork_mode: Option, +} + +#[derive(Clone, Debug)] +pub struct LiveAgent { + pub thread_id: ThreadId, + pub metadata: AgentMetadata, + pub status: AgentStatus, +} + +pub fn keep_forked_rollout_item(item: &RolloutItem) -> bool { + match item { + RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() + { + "system" | "developer" | "user" => true, + "assistant" => *phase == Some(MessagePhase::FinalAnswer), + _ => false, + }, + RolloutItem::ResponseItem( + ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::GhostSnapshot { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::Other, + ) => false, + RolloutItem::Compacted(_) + | RolloutItem::EventMsg(_) + | RolloutItem::SessionMeta(_) + | RolloutItem::TurnContext(_) => true, + } +} + +pub fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) => Some(*parent_thread_id), + _ => None, + } +} + +pub fn thread_spawn_depth(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), + _ => None, + } +} + +pub fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool { + if prefix.is_root() { + return true; + } + + agent_path.is_some_and(|agent_path| { + agent_path == prefix + || agent_path + .as_str() + .strip_prefix(prefix.as_str()) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +pub fn render_input_preview(initial_operation: &Op) -> String { + match initial_operation { + Op::UserInput { items, .. } => items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), + UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + _ => "[input]".to_string(), + }) + .collect::>() + .join("\n"), + Op::InterAgentCommunication { communication } => communication.content.clone(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ContentItem; + use codex_protocol::protocol::InterAgentCommunication; + use pretty_assertions::assert_eq; + + fn agent_path(path: &str) -> AgentPath { + AgentPath::try_from(path).expect("valid agent path") + } + + #[test] + fn render_input_preview_summarizes_user_input_items() { + let op = Op::UserInput { + items: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + UserInput::Image { + image_url: "data:image/png;base64,abc".to_string(), + }, + UserInput::Mention { + name: "doc".to_string(), + path: "app://doc".to_string(), + }, + ], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }; + + assert_eq!( + render_input_preview(&op), + "hello\n[image]\n[mention:$doc](app://doc)" + ); + } + + #[test] + fn render_input_preview_uses_inter_agent_message_content() { + let communication = InterAgentCommunication::new( + AgentPath::root(), + agent_path("/root/worker"), + Vec::new(), + "wake up".to_string(), + /*trigger_turn*/ true, + ); + let op = Op::InterAgentCommunication { communication }; + + assert_eq!(render_input_preview(&op), "wake up"); + } + + #[test] + fn agent_matches_prefix_accepts_root_exact_and_descendants() { + let worker = agent_path("/root/worker"); + let worker_child = agent_path("/root/worker/child"); + let other = agent_path("/root/other"); + + assert!(agent_matches_prefix(Some(&worker), &AgentPath::root())); + assert!(agent_matches_prefix(Some(&worker), &worker)); + assert!(agent_matches_prefix(Some(&worker_child), &worker)); + assert!(!agent_matches_prefix(Some(&other), &worker)); + assert!(!agent_matches_prefix(/*agent_path*/ None, &worker)); + } + + #[test] + fn thread_spawn_parent_and_depth_only_match_thread_spawn_sources() { + let parent_thread_id = ThreadId::new(); + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + + assert_eq!( + thread_spawn_parent_thread_id(&session_source), + Some(parent_thread_id) + ); + assert_eq!(thread_spawn_depth(&session_source), Some(2)); + assert_eq!(thread_spawn_parent_thread_id(&SessionSource::Cli), None); + assert_eq!( + thread_spawn_depth(&SessionSource::SubAgent(SubAgentSource::Review)), + None + ); + } + + #[test] + fn forked_rollout_filter_keeps_only_contextual_items_and_final_assistant_messages() { + let final_assistant_message = RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "done".to_string(), + }], + end_turn: None, + phase: Some(MessagePhase::FinalAnswer), + }); + let in_progress_assistant_message = RolloutItem::ResponseItem(ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: "thinking".to_string(), + }], + end_turn: None, + phase: None, + }); + + assert!(keep_forked_rollout_item(&final_assistant_message)); + assert!(!keep_forked_rollout_item(&in_progress_assistant_message)); + assert!(!keep_forked_rollout_item(&RolloutItem::ResponseItem( + ResponseItem::Other + ))); + } +} diff --git a/codex-rs/agent-runtime/src/lib.rs b/codex-rs/agent-runtime/src/lib.rs new file mode 100644 index 0000000000..94d148ea47 --- /dev/null +++ b/codex-rs/agent-runtime/src/lib.rs @@ -0,0 +1,34 @@ +//! Shared runtime primitives for Codex multi-agent orchestration. + +pub mod control; +pub mod mailbox; +pub mod registry; +pub mod status; + +pub use codex_protocol::protocol::AgentStatus; +pub use control::LiveAgent; +pub use control::SpawnAgentForkMode; +pub use control::SpawnAgentOptions; +pub use control::agent_matches_prefix; +pub use control::keep_forked_rollout_item; +pub use control::render_input_preview; +pub use control::thread_spawn_depth; +pub use control::thread_spawn_parent_thread_id; +pub use mailbox::Mailbox; +pub use mailbox::MailboxReceiver; +pub use registry::AgentMetadata; +pub use registry::AgentRegistry; +pub use registry::SpawnReservation; +pub use registry::exceeds_thread_spawn_depth_limit; +pub use registry::next_thread_spawn_depth; +pub use status::agent_status_from_event; + +const AGENT_NAMES: &str = include_str!("agent_names.txt"); + +pub fn default_agent_nickname_list() -> Vec<&'static str> { + AGENT_NAMES + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect() +} diff --git a/codex-rs/agent-runtime/src/mailbox.rs b/codex-rs/agent-runtime/src/mailbox.rs new file mode 100644 index 0000000000..7d38da31bd --- /dev/null +++ b/codex-rs/agent-runtime/src/mailbox.rs @@ -0,0 +1,161 @@ +use codex_protocol::protocol::InterAgentCommunication; +use std::collections::VecDeque; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use tokio::sync::mpsc; +use tokio::sync::watch; + +#[cfg(test)] +use codex_protocol::AgentPath; + +pub struct Mailbox { + tx: mpsc::UnboundedSender, + next_seq: AtomicU64, + seq_tx: watch::Sender, +} + +pub struct MailboxReceiver { + rx: mpsc::UnboundedReceiver, + pending_mails: VecDeque, +} + +impl Mailbox { + pub fn new() -> (Self, MailboxReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + let (seq_tx, _) = watch::channel(0); + ( + Self { + tx, + next_seq: AtomicU64::new(0), + seq_tx, + }, + MailboxReceiver { + rx, + pending_mails: VecDeque::new(), + }, + ) + } + + pub fn subscribe(&self) -> watch::Receiver { + self.seq_tx.subscribe() + } + + pub fn send(&self, communication: InterAgentCommunication) -> u64 { + let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1; + let _ = self.tx.send(communication); + self.seq_tx.send_replace(seq); + seq + } +} + +impl MailboxReceiver { + fn sync_pending_mails(&mut self) { + while let Ok(mail) = self.rx.try_recv() { + self.pending_mails.push_back(mail); + } + } + + pub fn has_pending(&mut self) -> bool { + self.sync_pending_mails(); + !self.pending_mails.is_empty() + } + + pub fn has_pending_trigger_turn(&mut self) -> bool { + self.sync_pending_mails(); + self.pending_mails.iter().any(|mail| mail.trigger_turn) + } + + pub fn drain(&mut self) -> Vec { + self.sync_pending_mails(); + self.pending_mails.drain(..).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn make_mail( + author: AgentPath, + recipient: AgentPath, + content: &str, + trigger_turn: bool, + ) -> InterAgentCommunication { + InterAgentCommunication::new( + author, + recipient, + Vec::new(), + content.to_string(), + trigger_turn, + ) + } + + #[tokio::test] + async fn mailbox_assigns_monotonic_sequence_numbers() { + let (mailbox, _receiver) = Mailbox::new(); + let mut seq_rx = mailbox.subscribe(); + + let seq_a = mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + )); + let seq_b = mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "two", + /*trigger_turn*/ false, + )); + + seq_rx.changed().await.expect("first seq update"); + assert_eq!(*seq_rx.borrow(), seq_b); + assert_eq!(seq_a, 1); + assert_eq!(seq_b, 2); + } + + #[tokio::test] + async fn mailbox_drains_in_delivery_order() { + let (mailbox, mut receiver) = Mailbox::new(); + let mail_one = make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "one", + /*trigger_turn*/ false, + ); + let mail_two = make_mail( + AgentPath::try_from("/root/worker").expect("agent path"), + AgentPath::root(), + "two", + /*trigger_turn*/ false, + ); + + mailbox.send(mail_one.clone()); + mailbox.send(mail_two.clone()); + + assert_eq!(receiver.drain(), vec![mail_one, mail_two]); + assert!(!receiver.has_pending()); + } + + #[tokio::test] + async fn mailbox_tracks_pending_trigger_turn_mail() { + let (mailbox, mut receiver) = Mailbox::new(); + + mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "queued", + /*trigger_turn*/ false, + )); + assert!(!receiver.has_pending_trigger_turn()); + + mailbox.send(make_mail( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + "wake", + /*trigger_turn*/ true, + )); + assert!(receiver.has_pending_trigger_turn()); + } +} diff --git a/codex-rs/agent-runtime/src/registry.rs b/codex-rs/agent-runtime/src/registry.rs new file mode 100644 index 0000000000..63c94754d2 --- /dev/null +++ b/codex-rs/agent-runtime/src/registry.rs @@ -0,0 +1,344 @@ +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use rand::prelude::IndexedRandom; +use std::collections::HashMap; +use std::collections::HashSet; +use std::collections::hash_map::Entry; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +/// This structure is used to add some limits on the multi-agent capabilities for Codex. In +/// the current implementation, it limits: +/// * Total number of sub-agents (i.e. threads) per user session +/// +/// This structure is shared by all agents in the same user session (because the `AgentControl` +/// is). +#[derive(Default)] +pub struct AgentRegistry { + active_agents: Mutex, + total_count: AtomicUsize, +} + +#[derive(Default)] +struct ActiveAgents { + agent_tree: HashMap, + used_agent_nicknames: HashSet, + nickname_reset_count: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct AgentMetadata { + pub agent_id: Option, + pub agent_path: Option, + pub agent_nickname: Option, + pub agent_role: Option, + pub last_task_message: Option, +} + +fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String { + match nickname_reset_count { + 0 => name.to_string(), + reset_count => { + let value = reset_count + 1; + let suffix = match value % 100 { + 11..=13 => "th", + _ => match value % 10 { + 1 => "st", // codespell:ignore + 2 => "nd", // codespell:ignore + 3 => "rd", // codespell:ignore + _ => "th", // codespell:ignore + }, + }; + format!("{name} the {value}{suffix}") + } + } +} + +fn session_depth(session_source: &SessionSource) -> i32 { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, + SessionSource::SubAgent(_) => 0, + _ => 0, + } +} + +pub fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 { + session_depth(session_source).saturating_add(1) +} + +pub fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bool { + depth > max_depth +} + +impl AgentRegistry { + pub fn reserve_spawn_slot( + self: &Arc, + max_threads: Option, + ) -> Result { + if let Some(max_threads) = max_threads { + if !self.try_increment_spawned(max_threads) { + return Err(CodexErr::AgentLimitReached { max_threads }); + } + } else { + self.total_count.fetch_add(1, Ordering::AcqRel); + } + Ok(SpawnReservation { + state: Arc::clone(self), + active: true, + reserved_agent_nickname: None, + reserved_agent_path: None, + }) + } + + pub fn release_spawned_thread(&self, thread_id: ThreadId) { + let removed_counted_agent = { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let removed_key = active_agents + .agent_tree + .iter() + .find_map(|(key, metadata)| (metadata.agent_id == Some(thread_id)).then_some(key)) + .cloned(); + removed_key + .and_then(|key| active_agents.agent_tree.remove(key.as_str())) + .is_some_and(|metadata| { + !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) + }) + }; + if removed_counted_agent { + self.total_count.fetch_sub(1, Ordering::AcqRel); + } + } + + pub fn register_root_thread(&self, thread_id: ThreadId) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + active_agents + .agent_tree + .entry(AgentPath::ROOT.to_string()) + .or_insert_with(|| AgentMetadata { + agent_id: Some(thread_id), + agent_path: Some(AgentPath::root()), + ..Default::default() + }); + } + + pub fn agent_id_for_path(&self, agent_path: &AgentPath) -> Option { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .get(agent_path.as_str()) + .and_then(|metadata| metadata.agent_id) + } + + pub fn agent_metadata_for_thread(&self, thread_id: ThreadId) -> Option { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .values() + .find(|metadata| metadata.agent_id == Some(thread_id)) + .cloned() + } + + pub fn live_agents(&self) -> Vec { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .values() + .filter(|metadata| { + metadata.agent_id.is_some() + && !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) + }) + .cloned() + .collect() + } + + pub fn update_last_task_message(&self, thread_id: ThreadId, last_task_message: String) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = Some(last_task_message); + } + } + + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { + let Some(thread_id) = agent_metadata.agent_id else { + return; + }; + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let key = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| format!("thread:{thread_id}")); + if let Some(agent_nickname) = agent_metadata.agent_nickname.clone() { + active_agents.used_agent_nicknames.insert(agent_nickname); + } + active_agents.agent_tree.insert(key, agent_metadata); + } + + fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let agent_nickname = if let Some(preferred) = preferred { + preferred.to_string() + } else { + if names.is_empty() { + return None; + } + let available_names: Vec = names + .iter() + .map(|name| format_agent_nickname(name, active_agents.nickname_reset_count)) + .filter(|name| !active_agents.used_agent_nicknames.contains(name)) + .collect(); + if let Some(name) = available_names.choose(&mut rand::rng()) { + name.clone() + } else { + active_agents.used_agent_nicknames.clear(); + active_agents.nickname_reset_count += 1; + if let Some(metrics) = codex_otel::global() { + let _ = metrics.counter( + "codex.multi_agent.nickname_pool_reset", + /*inc*/ 1, + &[], + ); + } + format_agent_nickname( + names.choose(&mut rand::rng())?, + active_agents.nickname_reset_count, + ) + } + }; + active_agents + .used_agent_nicknames + .insert(agent_nickname.clone()); + Some(agent_nickname) + } + + fn reserve_agent_path(&self, agent_path: &AgentPath) -> Result<()> { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match active_agents.agent_tree.entry(agent_path.to_string()) { + Entry::Occupied(_) => Err(CodexErr::UnsupportedOperation(format!( + "agent path `{agent_path}` already exists" + ))), + Entry::Vacant(entry) => { + entry.insert(AgentMetadata { + agent_path: Some(agent_path.clone()), + ..Default::default() + }); + Ok(()) + } + } + } + + fn release_reserved_agent_path(&self, agent_path: &AgentPath) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if active_agents + .agent_tree + .get(agent_path.as_str()) + .is_some_and(|metadata| metadata.agent_id.is_none()) + { + active_agents.agent_tree.remove(agent_path.as_str()); + } + } + + fn try_increment_spawned(&self, max_threads: usize) -> bool { + let mut current = self.total_count.load(Ordering::Acquire); + loop { + if current >= max_threads { + return false; + } + match self.total_count.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return true, + Err(updated) => current = updated, + } + } + } +} + +pub struct SpawnReservation { + state: Arc, + active: bool, + reserved_agent_nickname: Option, + reserved_agent_path: Option, +} + +impl SpawnReservation { + pub fn reserve_agent_nickname_with_preference( + &mut self, + names: &[&str], + preferred: Option<&str>, + ) -> Result { + let agent_nickname = self + .state + .reserve_agent_nickname(names, preferred) + .ok_or_else(|| { + CodexErr::UnsupportedOperation("no available agent nicknames".to_string()) + })?; + self.reserved_agent_nickname = Some(agent_nickname.clone()); + Ok(agent_nickname) + } + + pub fn reserve_agent_path(&mut self, agent_path: &AgentPath) -> Result<()> { + self.state.reserve_agent_path(agent_path)?; + self.reserved_agent_path = Some(agent_path.clone()); + Ok(()) + } + + pub fn commit(mut self, agent_metadata: AgentMetadata) { + self.reserved_agent_nickname = None; + self.reserved_agent_path = None; + self.state.register_spawned_thread(agent_metadata); + self.active = false; + } +} + +impl Drop for SpawnReservation { + fn drop(&mut self) { + if self.active { + if let Some(agent_path) = self.reserved_agent_path.take() { + self.state.release_reserved_agent_path(&agent_path); + } + self.state.total_count.fetch_sub(1, Ordering::AcqRel); + } + } +} + +#[cfg(test)] +#[path = "registry_tests.rs"] +mod tests; diff --git a/codex-rs/agent-runtime/src/registry_tests.rs b/codex-rs/agent-runtime/src/registry_tests.rs new file mode 100644 index 0000000000..fc172fb336 --- /dev/null +++ b/codex-rs/agent-runtime/src/registry_tests.rs @@ -0,0 +1,350 @@ +use super::*; +use codex_protocol::AgentPath; +use pretty_assertions::assert_eq; +use std::collections::HashSet; + +fn agent_path(path: &str) -> AgentPath { + AgentPath::try_from(path).expect("valid agent path") +} + +fn agent_metadata(thread_id: ThreadId) -> AgentMetadata { + AgentMetadata { + agent_id: Some(thread_id), + ..Default::default() + } +} + +#[test] +fn format_agent_nickname_adds_ordinals_after_reset() { + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 0), + "Plato" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 1), + "Plato the 2nd" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 2), + "Plato the 3rd" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 10), + "Plato the 11th" + ); + assert_eq!( + format_agent_nickname("Plato", /*nickname_reset_count*/ 20), + "Plato the 21st" + ); +} + +#[test] +fn session_depth_defaults_to_zero_for_root_sources() { + assert_eq!(session_depth(&SessionSource::Cli), 0); +} + +#[test] +fn thread_spawn_depth_increments_and_enforces_limit() { + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + let child_depth = next_thread_spawn_depth(&session_source); + assert_eq!(child_depth, 2); + assert!(exceeds_thread_spawn_depth_limit( + child_depth, + /*max_depth*/ 1 + )); +} + +#[test] +fn non_thread_spawn_subagents_default_to_depth_zero() { + let session_source = SessionSource::SubAgent(SubAgentSource::Review); + assert_eq!(session_depth(&session_source), 0); + assert_eq!(next_thread_spawn_depth(&session_source), 1); + assert!(!exceeds_thread_spawn_depth_limit( + /*depth*/ 1, /*max_depth*/ 1 + )); +} + +#[test] +fn reservation_drop_releases_slot() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + drop(reservation); + + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot released"); + drop(reservation); +} + +#[test] +fn commit_holds_slot_until_release() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(agent_metadata(thread_id)); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(thread_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after thread removal"); + drop(reservation); +} + +#[test] +fn release_ignores_unknown_thread_id() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let thread_id = ThreadId::new(); + reservation.commit(agent_metadata(thread_id)); + + registry.release_spawned_thread(ThreadId::new()); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(thread_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after real thread removal"); + drop(reservation); +} + +#[test] +fn release_is_idempotent_for_registered_threads() { + let registry = Arc::new(AgentRegistry::default()); + let reservation = registry.reserve_spawn_slot(Some(1)).expect("reserve slot"); + let first_id = ThreadId::new(); + reservation.commit(agent_metadata(first_id)); + + registry.release_spawned_thread(first_id); + + let reservation = registry.reserve_spawn_slot(Some(1)).expect("slot reused"); + let second_id = ThreadId::new(); + reservation.commit(agent_metadata(second_id)); + + registry.release_spawned_thread(first_id); + + let err = match registry.reserve_spawn_slot(Some(1)) { + Ok(_) => panic!("limit should still be enforced"), + Err(err) => err, + }; + let CodexErr::AgentLimitReached { max_threads } = err else { + panic!("expected CodexErr::AgentLimitReached"); + }; + assert_eq!(max_threads, 1); + + registry.release_spawned_thread(second_id); + let reservation = registry + .reserve_spawn_slot(Some(1)) + .expect("slot released after second thread removal"); + drop(reservation); +} + +#[test] +fn failed_spawn_keeps_nickname_marked_used() { + let registry = Arc::new(AgentRegistry::default()); + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve agent name"); + assert_eq!(agent_nickname, "alpha"); + drop(reservation); + + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); + let agent_nickname = reservation + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("unused name should still be preferred"); + assert_eq!(agent_nickname, "beta"); +} + +#[test] +fn agent_nickname_resets_used_pool_when_exhausted() { + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "alpha"); + + let mut second = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("name should be reused after pool reset"); + assert_eq!(second_name, "alpha the 2nd"); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); +} + +#[test] +fn released_nickname_stays_used_until_pool_reset() { + let registry = Arc::new(AgentRegistry::default()); + + let mut first = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["alpha"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "alpha"); + + registry.release_spawned_thread(first_id); + + let mut second = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("released name should still be marked used"); + assert_eq!(second_name, "beta"); + let second_id = ThreadId::new(); + second.commit(agent_metadata(second_id)); + registry.release_spawned_thread(second_id); + + let mut third = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname_with_preference(&["alpha", "beta"], /*preferred*/ None) + .expect("pool reset should permit a duplicate"); + let expected_names = HashSet::from(["alpha the 2nd".to_string(), "beta the 2nd".to_string()]); + assert!(expected_names.contains(&third_name)); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 1); +} + +#[test] +fn repeated_resets_advance_the_ordinal_suffix() { + let registry = Arc::new(AgentRegistry::default()); + + let mut first = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve first slot"); + let first_name = first + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve first agent name"); + let first_id = ThreadId::new(); + first.commit(agent_metadata(first_id)); + assert_eq!(first_name, "Plato"); + registry.release_spawned_thread(first_id); + + let mut second = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve second slot"); + let second_name = second + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve second agent name"); + let second_id = ThreadId::new(); + second.commit(agent_metadata(second_id)); + assert_eq!(second_name, "Plato the 2nd"); + registry.release_spawned_thread(second_id); + + let mut third = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve third slot"); + let third_name = third + .reserve_agent_nickname_with_preference(&["Plato"], /*preferred*/ None) + .expect("reserve third agent name"); + assert_eq!(third_name, "Plato the 3rd"); + let active_agents = registry + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(active_agents.nickname_reset_count, 2); +} + +#[test] +fn register_root_thread_indexes_root_path() { + let registry = Arc::new(AgentRegistry::default()); + let root_thread_id = ThreadId::new(); + + registry.register_root_thread(root_thread_id); + + assert_eq!( + registry.agent_id_for_path(&AgentPath::root()), + Some(root_thread_id) + ); +} + +#[test] +fn reserved_agent_path_is_released_when_spawn_fails() { + let registry = Arc::new(AgentRegistry::default()); + let mut first = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve first slot"); + first + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("reserve first path"); + drop(first); + + let mut second = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve second slot"); + second + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("dropped reservation should free the path"); +} + +#[test] +fn committed_agent_path_is_indexed_until_release() { + let registry = Arc::new(AgentRegistry::default()); + let thread_id = ThreadId::new(); + let mut reservation = registry + .reserve_spawn_slot(/*max_threads*/ None) + .expect("reserve slot"); + reservation + .reserve_agent_path(&agent_path("/root/researcher")) + .expect("reserve path"); + reservation.commit(AgentMetadata { + agent_id: Some(thread_id), + agent_path: Some(agent_path("/root/researcher")), + ..Default::default() + }); + + assert_eq!( + registry.agent_id_for_path(&agent_path("/root/researcher")), + Some(thread_id) + ); + + registry.release_spawned_thread(thread_id); + assert_eq!( + registry.agent_id_for_path(&agent_path("/root/researcher")), + None + ); +} diff --git a/codex-rs/agent-runtime/src/status.rs b/codex-rs/agent-runtime/src/status.rs new file mode 100644 index 0000000000..e1b9a8156e --- /dev/null +++ b/codex-rs/agent-runtime/src/status.rs @@ -0,0 +1,27 @@ +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::EventMsg; + +/// Derive the next agent status from a single emitted event. +/// Returns `None` when the event does not affect status tracking. +pub fn agent_status_from_event(msg: &EventMsg) -> Option { + match msg { + EventMsg::TurnStarted(_) => Some(AgentStatus::Running), + EventMsg::TurnComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())), + EventMsg::TurnAborted(ev) => match ev.reason { + codex_protocol::protocol::TurnAbortReason::Interrupted => { + Some(AgentStatus::Interrupted) + } + _ => Some(AgentStatus::Errored(format!("{:?}", ev.reason))), + }, + EventMsg::Error(ev) => Some(AgentStatus::Errored(ev.message.clone())), + EventMsg::ShutdownComplete => Some(AgentStatus::Shutdown), + _ => None, + } +} + +pub fn is_final(status: &AgentStatus) -> bool { + !matches!( + status, + AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted + ) +} diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 097abdb713..6c112d44ce 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -33,6 +33,7 @@ codex-analytics = { workspace = true } codex-arg0 = { workspace = true } codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } +codex-code-mode-runtime = { workspace = true } codex-core = { workspace = true } codex-core-plugins = { workspace = true } codex-exec-server = { workspace = true } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index da72af81c5..2759237caa 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -272,7 +272,7 @@ impl MessageProcessor { config.chatgpt_base_url.trim_end_matches('/').to_string(), config.analytics_enabled, ); - let thread_manager = Arc::new(ThreadManager::new( + let thread_manager = Arc::new(ThreadManager::new_with_code_mode_runtime_factory( config.as_ref(), auth_manager.clone(), session_source, @@ -283,6 +283,7 @@ impl MessageProcessor { }, environment_manager, Some(analytics_events_client.clone()), + codex_code_mode_runtime::runtime_factory(), )); thread_manager .plugins_manager() diff --git a/codex-rs/code-mode-runtime/BUILD.bazel b/codex-rs/code-mode-runtime/BUILD.bazel new file mode 100644 index 0000000000..b3903eb77d --- /dev/null +++ b/codex-rs/code-mode-runtime/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "code-mode-runtime", + crate_name = "codex_code_mode_runtime", +) diff --git a/codex-rs/code-mode-runtime/Cargo.toml b/codex-rs/code-mode-runtime/Cargo.toml new file mode 100644 index 0000000000..1b075e182f --- /dev/null +++ b/codex-rs/code-mode-runtime/Cargo.toml @@ -0,0 +1,28 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-code-mode-runtime" +version.workspace = true + +[lib] +doctest = false +name = "codex_code_mode_runtime" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +async-channel = { workspace = true } +async-trait = { workspace = true } +codex-code-mode = { workspace = true } +codex-protocol = { workspace = true } +deno_core_icudata = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +tracing = { workspace = true } +v8 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/code-mode-runtime/src/lib.rs b/codex-rs/code-mode-runtime/src/lib.rs new file mode 100644 index 0000000000..f82301fffa --- /dev/null +++ b/codex-rs/code-mode-runtime/src/lib.rs @@ -0,0 +1,14 @@ +mod runtime; +mod service; + +pub use codex_code_mode::CodeModeTurnHost; +pub use codex_code_mode::*; +pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; +pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; +pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; +pub use runtime::ExecuteRequest; +pub use runtime::RuntimeResponse; +pub use runtime::WaitRequest; +pub use service::CodeModeService; +pub use service::CodeModeTurnWorker; +pub use service::runtime_factory; diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode-runtime/src/runtime/callbacks.rs similarity index 99% rename from codex-rs/code-mode/src/runtime/callbacks.rs rename to codex-rs/code-mode-runtime/src/runtime/callbacks.rs index a9755f6eb0..b858b1d033 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode-runtime/src/runtime/callbacks.rs @@ -1,4 +1,4 @@ -use crate::response::FunctionCallOutputContentItem; +use codex_code_mode::FunctionCallOutputContentItem; use super::EXIT_SENTINEL; use super::RuntimeEvent; diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode-runtime/src/runtime/globals.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/globals.rs rename to codex-rs/code-mode-runtime/src/runtime/globals.rs diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode-runtime/src/runtime/mod.rs similarity index 88% rename from codex-rs/code-mode/src/runtime/mod.rs rename to codex-rs/code-mode-runtime/src/runtime/mod.rs index 0f50edd329..202756d89a 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode-runtime/src/runtime/mod.rs @@ -13,51 +13,19 @@ use codex_protocol::ToolName; use serde_json::Value as JsonValue; use tokio::sync::mpsc; -use crate::description::EnabledToolMetadata; -use crate::description::ToolDefinition; -use crate::description::enabled_tool_metadata; -use crate::response::FunctionCallOutputContentItem; +pub use codex_code_mode::DEFAULT_EXEC_YIELD_TIME_MS; +pub use codex_code_mode::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; +pub use codex_code_mode::DEFAULT_WAIT_YIELD_TIME_MS; +pub use codex_code_mode::ExecuteRequest; +pub use codex_code_mode::RuntimeResponse; +pub use codex_code_mode::WaitRequest; + +use codex_code_mode::EnabledToolMetadata; +use codex_code_mode::FunctionCallOutputContentItem; +use codex_code_mode::enabled_tool_metadata; -pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; -pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; -pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL: usize = 10_000; const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; -#[derive(Clone, Debug)] -pub struct ExecuteRequest { - pub tool_call_id: String, - pub enabled_tools: Vec, - pub source: String, - pub stored_values: HashMap, - pub yield_time_ms: Option, - pub max_output_tokens: Option, -} - -#[derive(Clone, Debug)] -pub struct WaitRequest { - pub cell_id: String, - pub yield_time_ms: u64, - pub terminate: bool, -} - -#[derive(Debug, PartialEq)] -pub enum RuntimeResponse { - Yielded { - cell_id: String, - content_items: Vec, - }, - Terminated { - cell_id: String, - content_items: Vec, - }, - Result { - cell_id: String, - content_items: Vec, - stored_values: HashMap, - error_text: Option, - }, -} - #[derive(Debug)] pub(crate) enum TurnMessage { ToolCall { diff --git a/codex-rs/code-mode/src/runtime/module_loader.rs b/codex-rs/code-mode-runtime/src/runtime/module_loader.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/module_loader.rs rename to codex-rs/code-mode-runtime/src/runtime/module_loader.rs diff --git a/codex-rs/code-mode/src/runtime/timers.rs b/codex-rs/code-mode-runtime/src/runtime/timers.rs similarity index 100% rename from codex-rs/code-mode/src/runtime/timers.rs rename to codex-rs/code-mode-runtime/src/runtime/timers.rs diff --git a/codex-rs/code-mode/src/runtime/value.rs b/codex-rs/code-mode-runtime/src/runtime/value.rs similarity index 98% rename from codex-rs/code-mode/src/runtime/value.rs rename to codex-rs/code-mode-runtime/src/runtime/value.rs index 5c63434f4f..c90fbfe38b 100644 --- a/codex-rs/code-mode/src/runtime/value.rs +++ b/codex-rs/code-mode-runtime/src/runtime/value.rs @@ -1,7 +1,7 @@ use serde_json::Value as JsonValue; -use crate::response::FunctionCallOutputContentItem; -use crate::response::ImageDetail; +use codex_code_mode::FunctionCallOutputContentItem; +use codex_code_mode::ImageDetail; const IMAGE_HELPER_EXPECTS_MESSAGE: &str = "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block"; const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail"; diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode-runtime/src/service.rs similarity index 96% rename from codex-rs/code-mode/src/service.rs rename to codex-rs/code-mode-runtime/src/service.rs index 79ca010c1a..a23b93a886 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode-runtime/src/service.rs @@ -5,7 +5,6 @@ use std::sync::atomic::Ordering; use std::time::Duration; use async_trait::async_trait; -use codex_protocol::ToolName; use serde_json::Value as JsonValue; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -13,7 +12,6 @@ use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use tracing::warn; -use crate::FunctionCallOutputContentItem; use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS; use crate::runtime::ExecuteRequest; use crate::runtime::RuntimeCommand; @@ -22,18 +20,10 @@ use crate::runtime::RuntimeResponse; use crate::runtime::TurnMessage; use crate::runtime::WaitRequest; use crate::runtime::spawn_runtime; - -#[async_trait] -pub trait CodeModeTurnHost: Send + Sync { - async fn invoke_tool( - &self, - tool_name: ToolName, - input: Option, - cancellation_token: CancellationToken, - ) -> Result; - - async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; -} +use codex_code_mode::CodeModeRuntimeFactory; +use codex_code_mode::CodeModeRuntimeService; +use codex_code_mode::CodeModeTurnHost; +use codex_code_mode::FunctionCallOutputContentItem; #[derive(Clone)] struct SessionHandle { @@ -213,6 +203,29 @@ impl Default for CodeModeService { } } +#[async_trait] +impl CodeModeRuntimeService for CodeModeService { + async fn stored_values(&self) -> HashMap { + self.stored_values().await + } + + async fn replace_stored_values(&self, values: HashMap) { + self.replace_stored_values(values).await; + } + + async fn execute(&self, request: ExecuteRequest) -> Result { + self.execute(request).await + } + + async fn wait(&self, request: WaitRequest) -> Result { + self.wait(request).await + } + + fn start_turn_worker(&self, host: Arc) -> Box { + Box::new(self.start_turn_worker(host)) + } +} + pub struct CodeModeTurnWorker { shutdown_tx: Option>, } @@ -225,6 +238,10 @@ impl Drop for CodeModeTurnWorker { } } +pub fn runtime_factory() -> CodeModeRuntimeFactory { + Arc::new(|| Arc::new(CodeModeService::new())) +} + enum SessionControlCommand { Poll { yield_time_ms: u64, @@ -480,10 +497,10 @@ mod tests { use super::SessionControlCommand; use super::SessionControlContext; use super::run_session_control; - use crate::FunctionCallOutputContentItem; use crate::runtime::ExecuteRequest; use crate::runtime::RuntimeEvent; use crate::runtime::spawn_runtime; + use codex_code_mode::FunctionCallOutputContentItem; fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml index 23b2ce2306..d408cb1eac 100644 --- a/codex-rs/code-mode/Cargo.toml +++ b/codex-rs/code-mode/Cargo.toml @@ -13,16 +13,12 @@ path = "src/lib.rs" workspace = true [dependencies] -async-channel = { workspace = true } async-trait = { workspace = true } codex-protocol = { workspace = true } -deno_core_icudata = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } tokio-util = { workspace = true, features = ["rt"] } -tracing = { workspace = true } -v8 = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 880e84ef4a..d44f91386f 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -1,15 +1,19 @@ mod description; mod response; +#[path = "runtime_stub.rs"] mod runtime; +#[path = "service_stub.rs"] mod service; pub use description::CODE_MODE_PRAGMA_PREFIX; pub use description::CodeModeToolKind; +pub use description::EnabledToolMetadata; pub use description::ToolDefinition; pub use description::ToolNamespaceDescription; pub use description::augment_tool_definition; pub use description::build_exec_tool_description; pub use description::build_wait_tool_description; +pub use description::enabled_tool_metadata; pub use description::is_code_mode_nested_tool; pub use description::normalize_code_mode_identifier; pub use description::parse_exec_source; @@ -23,9 +27,12 @@ pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; pub use runtime::ExecuteRequest; pub use runtime::RuntimeResponse; pub use runtime::WaitRequest; +pub use service::CodeModeRuntimeFactory; +pub use service::CodeModeRuntimeService; pub use service::CodeModeService; pub use service::CodeModeTurnHost; pub use service::CodeModeTurnWorker; +pub use service::default_runtime_factory; pub const PUBLIC_TOOL_NAME: &str = "exec"; pub const WAIT_TOOL_NAME: &str = "wait"; diff --git a/codex-rs/code-mode/src/runtime_stub.rs b/codex-rs/code-mode/src/runtime_stub.rs new file mode 100644 index 0000000000..2faa8ca448 --- /dev/null +++ b/codex-rs/code-mode/src/runtime_stub.rs @@ -0,0 +1,44 @@ +use std::collections::HashMap; + +use serde_json::Value as JsonValue; + +use crate::response::FunctionCallOutputContentItem; + +pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; +pub const DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL: usize = 10_000; + +#[derive(Clone, Debug)] +pub struct ExecuteRequest { + pub tool_call_id: String, + pub enabled_tools: Vec, + pub source: String, + pub stored_values: HashMap, + pub yield_time_ms: Option, + pub max_output_tokens: Option, +} + +#[derive(Clone, Debug)] +pub struct WaitRequest { + pub cell_id: String, + pub yield_time_ms: u64, + pub terminate: bool, +} + +#[derive(Debug, PartialEq)] +pub enum RuntimeResponse { + Yielded { + cell_id: String, + content_items: Vec, + }, + Terminated { + cell_id: String, + content_items: Vec, + }, + Result { + cell_id: String, + content_items: Vec, + stored_values: HashMap, + error_text: Option, + }, +} diff --git a/codex-rs/code-mode/src/service_stub.rs b/codex-rs/code-mode/src/service_stub.rs new file mode 100644 index 0000000000..85669c0f22 --- /dev/null +++ b/codex-rs/code-mode/src/service_stub.rs @@ -0,0 +1,120 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use codex_protocol::ToolName; +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use crate::runtime::ExecuteRequest; +use crate::runtime::RuntimeResponse; +use crate::runtime::WaitRequest; + +pub type CodeModeRuntimeFactory = Arc Arc + Send + Sync>; + +#[async_trait] +pub trait CodeModeTurnHost: Send + Sync { + async fn invoke_tool( + &self, + tool_name: ToolName, + input: Option, + cancellation_token: CancellationToken, + ) -> Result; + + async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; +} + +#[async_trait] +pub trait CodeModeRuntimeService: Send + Sync { + async fn stored_values(&self) -> HashMap; + + async fn replace_stored_values(&self, values: HashMap); + + async fn execute(&self, request: ExecuteRequest) -> Result; + + async fn wait(&self, request: WaitRequest) -> Result; + + fn start_turn_worker(&self, host: Arc) -> Box; +} + +pub struct CodeModeService { + stored_values: Mutex>, +} + +impl CodeModeService { + pub fn new() -> Self { + Self { + stored_values: Mutex::new(HashMap::new()), + } + } + + pub async fn stored_values(&self) -> HashMap { + self.stored_values.lock().await.clone() + } + + pub async fn replace_stored_values(&self, values: HashMap) { + *self.stored_values.lock().await = values; + } + + pub async fn execute(&self, request: ExecuteRequest) -> Result { + Ok(RuntimeResponse::Result { + cell_id: request.tool_call_id, + content_items: Vec::new(), + stored_values: request.stored_values, + error_text: Some( + "code mode runtime is unavailable in this build of codex-code-mode".to_string(), + ), + }) + } + + pub async fn wait(&self, request: WaitRequest) -> Result { + Ok(RuntimeResponse::Result { + cell_id: request.cell_id, + content_items: Vec::new(), + stored_values: self.stored_values().await, + error_text: Some( + "code mode runtime is unavailable in this build of codex-code-mode".to_string(), + ), + }) + } + + pub fn start_turn_worker(&self, _host: Arc) -> CodeModeTurnWorker { + CodeModeTurnWorker {} + } +} + +#[async_trait] +impl CodeModeRuntimeService for CodeModeService { + async fn stored_values(&self) -> HashMap { + self.stored_values().await + } + + async fn replace_stored_values(&self, values: HashMap) { + self.replace_stored_values(values).await; + } + + async fn execute(&self, request: ExecuteRequest) -> Result { + self.execute(request).await + } + + async fn wait(&self, request: WaitRequest) -> Result { + self.wait(request).await + } + + fn start_turn_worker(&self, host: Arc) -> Box { + Box::new(self.start_turn_worker(host)) + } +} + +impl Default for CodeModeService { + fn default() -> Self { + Self::new() + } +} + +pub struct CodeModeTurnWorker {} + +pub fn default_runtime_factory() -> CodeModeRuntimeFactory { + Arc::new(|| Arc::new(CodeModeService::new())) +} diff --git a/codex-rs/config-loader/BUILD.bazel b/codex-rs/config-loader/BUILD.bazel new file mode 100644 index 0000000000..855558e33d --- /dev/null +++ b/codex-rs/config-loader/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "config-loader", + crate_name = "codex_config_loader", +) diff --git a/codex-rs/config-loader/Cargo.toml b/codex-rs/config-loader/Cargo.toml new file mode 100644 index 0000000000..c5713bbf59 --- /dev/null +++ b/codex-rs/config-loader/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "codex-config-loader" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +base64 = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-config = { workspace = true } +codex-exec-server = { workspace = true } +codex-git-utils = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +dunce = { workspace = true } +serde = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["rt"] } +toml = { workspace = true } +tracing = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.9" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_UI_Shell", +] } + +[dev-dependencies] +anyhow = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/config-loader/README.md b/codex-rs/config-loader/README.md new file mode 100644 index 0000000000..84a52aa77c --- /dev/null +++ b/codex-rs/config-loader/README.md @@ -0,0 +1,76 @@ +# `codex-config-loader` + +This crate loads and describes Codex configuration layers (user config, +CLI/session overrides, managed config, requirements, and MDM-managed +preferences) and produces: + +- An effective merged TOML config. +- Per-key origins metadata. +- Per-layer versions used for optimistic concurrency and conflict detection. + +The canonical implementation lives here instead of `codex-core` so callers that +only need config loading do not force the loader implementation into core. The +`codex_core::config_loader` module is a compatibility re-export for existing +callers. + +## Public Surface + +Exported from `codex_config_loader` and re-exported from +`codex_core::config_loader`: + +- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack` +- `ConfigLayerStack` + - `effective_config() -> toml::Value` + - `origins() -> HashMap` + - `layers_high_to_low() -> Vec` + - `with_user_config(user_config) -> ConfigLayerStack` +- `ConfigLayerEntry` for one layer's source, config, version, and optional disabled reason. +- `LoaderOverrides` for test and override hooks for managed config sources. +- `merge_toml_values(base, overlay)` for recursive TOML merge. + +## Layering Model + +Precedence is top overrides bottom: + +1. MDM managed preferences on macOS. +2. Legacy managed config. +3. Session flags. +4. Project config layers. +5. User config. +6. System config. + +Layers with a `disabled_reason` are still surfaced for UI, but are ignored when +computing the effective config and origins metadata. + +## Typical Usage + +```rust +use codex_config_loader::{ + CloudRequirementsLoader, LoaderOverrides, load_config_layers_state, +}; +use codex_exec_server::LOCAL_FS; +use codex_utils_absolute_path::AbsolutePathBuf; +use toml::Value as TomlValue; + +let cli_overrides: Vec<(String, TomlValue)> = Vec::new(); +let cwd = AbsolutePathBuf::current_dir()?; +let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &cli_overrides, + LoaderOverrides::default(), + CloudRequirementsLoader::default(), +).await?; + +let effective = layers.effective_config(); +let origins = layers.origins(); +let layers_for_ui = layers.layers_high_to_low(); +``` + +## Internal Layout + +- `src/lib.rs`: layer assembly, trust decisions, project config discovery, and path resolution. +- `src/layer_io.rs`: config and managed config reads. +- `src/macos.rs`: managed preferences integration on macOS. +- `codex-config`: owns layer state, requirements, merging, overrides, diagnostics, fingerprints, and config TOML types. diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/config-loader/src/layer_io.rs similarity index 100% rename from codex-rs/core/src/config_loader/layer_io.rs rename to codex-rs/config-loader/src/layer_io.rs diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/config-loader/src/lib.rs similarity index 98% rename from codex-rs/core/src/config_loader/mod.rs rename to codex-rs/config-loader/src/lib.rs index 9eaeb7149c..5140ee5b76 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/config-loader/src/lib.rs @@ -2,13 +2,8 @@ mod layer_io; #[cfg(target_os = "macos")] mod macos; -#[cfg(test)] -mod tests; - -use crate::config_loader::layer_io::LoadedConfigLayers; -use codex_app_server_protocol::ConfigLayerSource; +use crate::layer_io::LoadedConfigLayers; use codex_config::CONFIG_TOML_FILE; -use codex_config::ConfigRequirementsWithSources; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_exec_server::ExecutorFileSystem; @@ -27,6 +22,7 @@ use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; +pub use codex_app_server_protocol::ConfigLayerSource; pub use codex_config::AppRequirementToml; pub use codex_config::AppsRequirementsToml; pub use codex_config::CloudRequirementsLoadError; @@ -39,6 +35,7 @@ pub use codex_config::ConfigLayerStackOrdering; pub use codex_config::ConfigLoadError; pub use codex_config::ConfigRequirements; pub use codex_config::ConfigRequirementsToml; +pub use codex_config::ConfigRequirementsWithSources; pub use codex_config::ConstrainedWithSource; pub use codex_config::FeatureRequirementsToml; pub use codex_config::FilesystemConstraints; @@ -59,16 +56,15 @@ pub use codex_config::Sourced; pub use codex_config::TextPosition; pub use codex_config::TextRange; pub use codex_config::WebSearchModeRequirement; -pub(crate) use codex_config::build_cli_overrides_layer; -pub(crate) use codex_config::config_error_from_toml; +pub use codex_config::build_cli_overrides_layer; +pub use codex_config::config_error_from_toml; pub use codex_config::default_project_root_markers; pub use codex_config::format_config_error; pub use codex_config::format_config_error_with_source; -pub(crate) use codex_config::io_error_from_config_error; +pub use codex_config::io_error_from_config_error; pub use codex_config::merge_toml_values; pub use codex_config::project_root_markers_from_config; -#[cfg(test)] -pub(crate) use codex_config::version_for_toml; +pub use codex_config::version_for_toml; /// On Unix systems, load default settings from this file path, if present. /// Note that /etc/codex/ is treated as a "config folder," so subfolders such @@ -78,11 +74,11 @@ pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; #[cfg(windows)] const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; -pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { +pub async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { codex_config::first_layer_config_error::(layers, CONFIG_TOML_FILE).await } -pub(crate) async fn first_layer_config_error_from_entries( +pub async fn first_layer_config_error_from_entries( layers: &[ConfigLayerEntry], ) -> Option { codex_config::first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE) @@ -370,7 +366,7 @@ async fn load_config_toml_for_required_layer( /// If available, apply requirements from the platform system /// `requirements.toml` location to `config_requirements_toml` by filling in /// any unset fields. -async fn load_requirements_toml( +pub async fn load_requirements_toml( fs: &dyn ExecutorFileSystem, config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: &AbsolutePathBuf, @@ -768,7 +764,7 @@ fn project_trust_for_lookup_key( /// /// This ensures that multiple config layers can be merged together correctly /// even if they were loaded from different directories. -pub(crate) fn resolve_relative_paths_in_config_toml( +pub fn resolve_relative_paths_in_config_toml( value_from_config_toml: TomlValue, base_dir: &Path, ) -> io::Result { diff --git a/codex-rs/core/src/config_loader/macos.rs b/codex-rs/config-loader/src/macos.rs similarity index 100% rename from codex-rs/core/src/config_loader/macos.rs rename to codex-rs/config-loader/src/macos.rs diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 93ca283b86..94737ce3e3 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -17,6 +17,7 @@ codex-network-proxy = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } +dunce = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } multimap = { workspace = true } schemars = { workspace = true } diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 6556ca73e3..9bb5c30c8d 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -50,7 +50,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path::normalize_for_path_comparison; +use dunce::canonicalize as normalize_path; use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; @@ -733,7 +733,7 @@ impl ConfigToml { fn normalized_project_lookup_keys(path: &Path) -> Vec { let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string()); let normalized_canonical_path = normalize_project_lookup_key( - normalize_for_path_comparison(path) + normalize_path(path) .unwrap_or_else(|_| path.to_path_buf()) .to_string_lossy() .to_string(), @@ -771,6 +771,13 @@ fn project_config_for_lookup_key( .map(|(_, project_config)| (**project_config).clone()) } +pub(crate) fn project_trust_key(project_path: &Path) -> String { + normalized_project_lookup_keys(project_path) + .into_iter() + .next() + .unwrap_or_else(|| normalize_project_lookup_key(project_path.to_string_lossy().to_string())) +} + pub fn validate_reserved_model_provider_ids( model_providers: &HashMap, ) -> Result<(), String> { diff --git a/codex-rs/config/src/edit.rs b/codex-rs/config/src/edit.rs new file mode 100644 index 0000000000..58ee93c64b --- /dev/null +++ b/codex-rs/config/src/edit.rs @@ -0,0 +1,1218 @@ +use crate::CONFIG_TOML_FILE; +use crate::types::McpServerConfig; +use anyhow::Context; +use codex_features::FEATURES; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_path::resolve_symlink_write_paths; +use codex_utils_path::write_atomically; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use tokio::task; +use toml_edit::ArrayOfTables; +use toml_edit::DocumentMut; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; +use toml_edit::value; + +const NOTICE_TABLE_KEY: &str = "notice"; + +/// Discrete config mutations supported by the persistence engine. +#[derive(Clone, Debug)] +pub enum ConfigEdit { + /// Update the active (or default) model selection and optional reasoning effort. + SetModel { + model: Option, + effort: Option, + }, + /// Update the service tier preference for future turns. + SetServiceTier { service_tier: Option }, + /// Update the active (or default) model personality. + SetModelPersonality { personality: Option }, + /// Toggle the acknowledgement flag under `[notice]`. + SetNoticeHideFullAccessWarning(bool), + /// Toggle the Windows world-writable directories warning acknowledgement flag. + SetNoticeHideWorldWritableWarning(bool), + /// Toggle the rate limit model nudge acknowledgement flag. + SetNoticeHideRateLimitModelNudge(bool), + /// Toggle the Windows onboarding acknowledgement flag. + SetWindowsWslSetupAcknowledged(bool), + /// Toggle the model migration prompt acknowledgement flag. + SetNoticeHideModelMigrationPrompt(String, bool), + /// Toggle the home external config migration prompt acknowledgement flag. + SetNoticeHideExternalConfigMigrationPromptHome(bool), + /// Record when the home external config migration prompt was last shown. + SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(i64), + /// Toggle the project external config migration prompt acknowledgement flag. + SetNoticeHideExternalConfigMigrationPromptProject(String, bool), + /// Record when the project external config migration prompt was last shown. + SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(String, i64), + /// Record that a migration prompt was shown for an old->new model mapping. + RecordModelMigrationSeen { from: String, to: String }, + /// Replace the entire `[mcp_servers]` table. + ReplaceMcpServers(BTreeMap), + /// Set or clear a skill config entry under `[[skills.config]]` by path. + SetSkillConfig { path: PathBuf, enabled: bool }, + /// Set or clear a skill config entry under `[[skills.config]]` by name. + SetSkillConfigByName { name: String, enabled: bool }, + /// Set trust_level under `[projects.""]`, + /// migrating inline tables to explicit tables. + SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, + /// Set the value stored at the exact dotted path. + SetPath { + segments: Vec, + value: TomlItem, + }, + /// Remove the value stored at the exact dotted path. + ClearPath { segments: Vec }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum SkillConfigSelector { + Name(String), + Path(PathBuf), +} + +/// Produces a config edit that sets `[tui].theme = ""`. +pub fn syntax_theme_edit(name: &str) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "theme".to_string()], + value: value(name.to_string()), + } +} + +/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list. +/// +/// The array is written even when it is empty so "hide the status line" stays +/// distinct from "unset, so use defaults". +pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { + let array = items.iter().cloned().collect::(); + + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "status_line".to_string()], + value: TomlItem::Value(array.into()), + } +} + +/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list. +/// +/// The array is written even when it is empty so "disabled title updates" stays +/// distinct from "unset, so use defaults". +pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit { + let array = items.iter().cloned().collect::(); + + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "terminal_title".to_string()], + value: TomlItem::Value(array.into()), + } +} + +pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { + let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); + shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); + + let mut edits = vec![ConfigEdit::ClearPath { + segments: vec!["tui".to_string(), "model_availability_nux".to_string()], + }]; + for (model_slug, count) in shown_count_entries { + edits.push(ConfigEdit::SetPath { + segments: vec![ + "tui".to_string(), + "model_availability_nux".to_string(), + model_slug.clone(), + ], + value: value(i64::from(*count)), + }); + } + + edits +} + +// TODO(jif) move to a dedicated file +mod document_helpers { + use crate::types::AppToolApproval; + use crate::types::McpServerConfig; + use crate::types::McpServerToolConfig; + use crate::types::McpServerTransportConfig; + use toml_edit::Array as TomlArray; + use toml_edit::InlineTable; + use toml_edit::Item as TomlItem; + use toml_edit::Table as TomlTable; + use toml_edit::value; + + pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> { + match item { + TomlItem::Table(table) => Some(table), + TomlItem::Value(value) => { + if let Some(inline) = value.as_inline_table() { + *item = TomlItem::Table(table_from_inline(inline)); + item.as_table_mut() + } else { + *item = TomlItem::Table(new_implicit_table()); + item.as_table_mut() + } + } + TomlItem::None => { + *item = TomlItem::Table(new_implicit_table()); + item.as_table_mut() + } + _ => None, + } + } + + pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> { + match item { + TomlItem::Table(table) => Some(table), + TomlItem::Value(value) => { + let inline = value.as_inline_table()?; + *item = TomlItem::Table(table_from_inline(inline)); + item.as_table_mut() + } + _ => None, + } + } + + fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + + match &config.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + entry["command"] = value(command.clone()); + if !args.is_empty() { + entry["args"] = array_from_iter(args.iter().cloned()); + } + if let Some(env) = env + && !env.is_empty() + { + entry["env"] = table_from_pairs(env.iter()); + } + if !env_vars.is_empty() { + entry["env_vars"] = array_from_iter(env_vars.iter().cloned()); + } + if let Some(cwd) = cwd { + entry["cwd"] = value(cwd.to_string_lossy().to_string()); + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + entry["url"] = value(url.clone()); + if let Some(env_var) = bearer_token_env_var { + entry["bearer_token_env_var"] = value(env_var.clone()); + } + if let Some(headers) = http_headers + && !headers.is_empty() + { + entry["http_headers"] = table_from_pairs(headers.iter()); + } + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + entry["env_http_headers"] = table_from_pairs(headers.iter()); + } + } + } + + if !config.enabled { + entry["enabled"] = value(false); + } + if let Some(environment) = &config.experimental_environment { + entry["experimental_environment"] = value(environment.clone()); + } + if config.required { + entry["required"] = value(true); + } + if config.supports_parallel_tool_calls { + entry["supports_parallel_tool_calls"] = value(true); + } + if let Some(timeout) = config.startup_timeout_sec { + entry["startup_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(timeout) = config.tool_timeout_sec { + entry["tool_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(approval_mode) = config.default_tools_approval_mode { + entry["default_tools_approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + if let Some(enabled_tools) = &config.enabled_tools + && !enabled_tools.is_empty() + { + entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned()); + } + if let Some(disabled_tools) = &config.disabled_tools + && !disabled_tools.is_empty() + { + entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned()); + } + if let Some(scopes) = &config.scopes + && !scopes.is_empty() + { + entry["scopes"] = array_from_iter(scopes.iter().cloned()); + } + if let Some(resource) = &config.oauth_resource + && !resource.is_empty() + { + entry["oauth_resource"] = value(resource.clone()); + } + if !config.tools.is_empty() { + let mut tools = new_implicit_table(); + let mut tool_entries: Vec<_> = config.tools.iter().collect(); + tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + for (name, tool_config) in tool_entries { + tools.insert(name, serialize_mcp_server_tool(tool_config)); + } + entry.insert("tools", TomlItem::Table(tools)); + } + + entry + } + + fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + if let Some(approval_mode) = config.approval_mode { + entry["approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + TomlItem::Table(entry) + } + + pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { + TomlItem::Table(serialize_mcp_server_table(config)) + } + + pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable { + serialize_mcp_server_table(config).into_inline_table() + } + + pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) { + existing.retain(|key, _| replacement.get(key).is_some()); + + for (key, value) in replacement.iter() { + if let Some(existing_value) = existing.get_mut(key) { + let mut updated_value = value.clone(); + *updated_value.decor_mut() = existing_value.decor().clone(); + *existing_value = updated_value; + } else { + existing.insert(key.to_string(), value.clone()); + } + } + } + + fn table_from_inline(inline: &InlineTable) -> TomlTable { + let mut table = new_implicit_table(); + for (key, value) in inline.iter() { + let mut value = value.clone(); + let decor = value.decor_mut(); + decor.set_suffix(""); + table.insert(key, TomlItem::Value(value)); + } + table + } + + pub(super) fn new_implicit_table() -> TomlTable { + let mut table = TomlTable::new(); + table.set_implicit(true); + table + } + + fn array_from_iter(iter: I) -> TomlItem + where + I: Iterator, + { + let mut array = TomlArray::new(); + for value in iter { + array.push(value); + } + TomlItem::Value(array.into()) + } + + fn table_from_pairs<'a, I>(pairs: I) -> TomlItem + where + I: IntoIterator, + { + let mut entries: Vec<_> = pairs.into_iter().collect(); + entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + let mut table = TomlTable::new(); + table.set_implicit(false); + for (key, val) in entries { + table.insert(key, value(val.clone())); + } + TomlItem::Table(table) + } +} + +struct ConfigDocument { + doc: DocumentMut, + profile: Option, +} + +#[derive(Copy, Clone)] +enum Scope { + Global, + Profile, +} + +#[derive(Copy, Clone)] +enum TraversalMode { + Create, + Existing, +} + +impl ConfigDocument { + fn new(doc: DocumentMut, profile: Option) -> Self { + Self { doc, profile } + } + + fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result { + match edit { + ConfigEdit::SetModel { model, effort } => Ok({ + let mut mutated = false; + mutated |= self.write_profile_value( + &["model"], + model.as_ref().map(|model_value| value(model_value.clone())), + ); + mutated |= self.write_profile_value( + &["model_reasoning_effort"], + effort.map(|effort| value(effort.to_string())), + ); + mutated + }), + ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( + &["service_tier"], + service_tier.map(|service_tier| value(service_tier.to_string())), + )), + ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( + &["personality"], + personality.map(|personality| value(personality.to_string())), + )), + ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value( + Scope::Global, + &[NOTICE_TABLE_KEY, "hide_full_access_warning"], + value(*acknowledged), + )), + ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value( + Scope::Global, + &[NOTICE_TABLE_KEY, "hide_world_writable_warning"], + value(*acknowledged), + )), + ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( + Scope::Global, + &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"], + value(*acknowledged), + )), + ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => { + Ok(self.write_value( + Scope::Global, + &[NOTICE_TABLE_KEY, migration_config.as_str()], + value(*acknowledged), + )) + } + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self + .write_value( + Scope::Global, + &[ + NOTICE_TABLE_KEY, + "external_config_migration_prompts", + "home", + ], + value(*acknowledged), + )), + ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => { + Ok(self.write_value( + Scope::Global, + &[ + NOTICE_TABLE_KEY, + "external_config_migration_prompts", + "home_last_prompted_at", + ], + value(*timestamp), + )) + } + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( + project, + acknowledged, + ) => Ok(self.write_value( + Scope::Global, + &[ + NOTICE_TABLE_KEY, + "external_config_migration_prompts", + "projects", + project.as_str(), + ], + value(*acknowledged), + )), + ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( + project, + timestamp, + ) => Ok(self.write_value( + Scope::Global, + &[ + NOTICE_TABLE_KEY, + "external_config_migration_prompts", + "project_last_prompted_at", + project.as_str(), + ], + value(*timestamp), + )), + ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value( + Scope::Global, + &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], + value(to.clone()), + )), + ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( + Scope::Global, + &["windows_wsl_setup_acknowledged"], + value(*acknowledged), + )), + ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), + ConfigEdit::SetSkillConfig { path, enabled } => { + Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled)) + } + ConfigEdit::SetSkillConfigByName { name, enabled } => { + Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) + } + ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), + ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), + ConfigEdit::SetProjectTrustLevel { path, level } => { + // Delegate to the existing, tested logic in config.rs to + // ensure tables are explicit and migration is preserved. + set_project_trust_level_inner(&mut self.doc, path.as_path(), *level)?; + Ok(true) + } + } + } + + fn write_profile_value(&mut self, segments: &[&str], value: Option) -> bool { + match value { + Some(item) => self.write_value(Scope::Profile, segments, item), + None => self.clear(Scope::Profile, segments), + } + } + + fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool { + let resolved = self.scoped_segments(scope, segments); + self.insert(&resolved, value) + } + + fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool { + let resolved = self.scoped_segments(scope, segments); + self.remove(&resolved) + } + + fn clear_owned(&mut self, segments: &[String]) -> bool { + self.remove(segments) + } + + fn replace_mcp_servers(&mut self, servers: &BTreeMap) -> bool { + if servers.is_empty() { + return self.clear(Scope::Global, &["mcp_servers"]); + } + + let root = self.doc.as_table_mut(); + if !root.contains_key("mcp_servers") { + root.insert( + "mcp_servers", + TomlItem::Table(document_helpers::new_implicit_table()), + ); + } + + let Some(item) = root.get_mut("mcp_servers") else { + return false; + }; + + if document_helpers::ensure_table_for_write(item).is_none() { + *item = TomlItem::Table(document_helpers::new_implicit_table()); + } + + let Some(table) = item.as_table_mut() else { + return false; + }; + + let keys_to_remove: Vec = table + .iter() + .map(|(key, _)| key.to_string()) + .filter(|key| !servers.contains_key(key.as_str())) + .collect(); + + for key in keys_to_remove { + table.remove(&key); + } + + for (name, config) in servers { + if let Some(existing) = table.get_mut(name.as_str()) { + if let TomlItem::Value(value) = existing + && let Some(inline) = value.as_inline_table_mut() + { + let replacement = document_helpers::serialize_mcp_server_inline(config); + document_helpers::merge_inline_table(inline, replacement); + } else { + *existing = document_helpers::serialize_mcp_server(config); + } + } else { + table.insert(name, document_helpers::serialize_mcp_server(config)); + } + } + + true + } + + fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool { + let selector = match selector { + SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()), + SkillConfigSelector::Path(path) => { + SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path))) + } + }; + if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) { + return false; + } + let mut remove_skills_table = false; + let mut mutated = false; + + { + let root = self.doc.as_table_mut(); + let skills_item = match root.get_mut("skills") { + Some(item) => item, + None => { + if enabled { + return false; + } + root.insert( + "skills", + TomlItem::Table(document_helpers::new_implicit_table()), + ); + let Some(item) = root.get_mut("skills") else { + return false; + }; + item + } + }; + + if document_helpers::ensure_table_for_write(skills_item).is_none() { + if enabled { + return false; + } + *skills_item = TomlItem::Table(document_helpers::new_implicit_table()); + } + let Some(skills_table) = skills_item.as_table_mut() else { + return false; + }; + + let config_item = match skills_table.get_mut("config") { + Some(item) => item, + None => { + if enabled { + return false; + } + skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); + let Some(item) = skills_table.get_mut("config") else { + return false; + }; + item + } + }; + + if !matches!(config_item, TomlItem::ArrayOfTables(_)) { + if enabled { + return false; + } + *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new()); + } + + let TomlItem::ArrayOfTables(overrides) = config_item else { + return false; + }; + + let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { + skill_config_selector_from_table(table) + .filter(|value| value == &selector) + .map(|_| idx) + }); + + if enabled { + if let Some(index) = existing_index { + overrides.remove(index); + mutated = true; + if overrides.is_empty() { + skills_table.remove("config"); + if skills_table.is_empty() { + remove_skills_table = true; + } + } + } + } else if let Some(index) = existing_index { + for (idx, table) in overrides.iter_mut().enumerate() { + if idx == index { + write_skill_config_selector(table, &selector); + table["enabled"] = value(false); + mutated = true; + break; + } + } + } else { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + write_skill_config_selector(&mut entry, &selector); + entry["enabled"] = value(false); + overrides.push(entry); + mutated = true; + } + } + + if remove_skills_table { + let root = self.doc.as_table_mut(); + root.remove("skills"); + } + + mutated + } + + fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { + let resolved: Vec = segments + .iter() + .map(|segment| (*segment).to_string()) + .collect(); + + if matches!(scope, Scope::Profile) + && resolved.first().is_none_or(|segment| segment != "profiles") + && let Some(profile) = self.profile.as_deref() + { + let mut scoped = Vec::with_capacity(resolved.len() + 2); + scoped.push("profiles".to_string()); + scoped.push(profile.to_string()); + scoped.extend(resolved); + return scoped; + } + + resolved + } + + fn insert(&mut self, segments: &[String], value: TomlItem) -> bool { + let Some((last, parents)) = segments.split_last() else { + return false; + }; + + let Some(parent) = self.descend(parents, TraversalMode::Create) else { + return false; + }; + + let mut value = value; + if let Some(existing) = parent.get(last) { + Self::preserve_decor(existing, &mut value); + } + parent[last] = value; + true + } + + fn remove(&mut self, segments: &[String]) -> bool { + let Some((last, parents)) = segments.split_last() else { + return false; + }; + + let Some(parent) = self.descend(parents, TraversalMode::Existing) else { + return false; + }; + + parent.remove(last).is_some() + } + + fn descend(&mut self, segments: &[String], mode: TraversalMode) -> Option<&mut TomlTable> { + let mut current = self.doc.as_table_mut(); + + for segment in segments { + match mode { + TraversalMode::Create => { + if !current.contains_key(segment.as_str()) { + current.insert( + segment.as_str(), + TomlItem::Table(document_helpers::new_implicit_table()), + ); + } + + let item = current.get_mut(segment.as_str())?; + current = document_helpers::ensure_table_for_write(item)?; + } + TraversalMode::Existing => { + let item = current.get_mut(segment.as_str())?; + current = document_helpers::ensure_table_for_read(item)?; + } + } + } + + Some(current) + } + + fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) { + match (existing, replacement) { + (TomlItem::Table(existing_table), TomlItem::Table(replacement_table)) => { + replacement_table + .decor_mut() + .clone_from(existing_table.decor()); + for (key, existing_item) in existing_table.iter() { + if let (Some(existing_key), Some(mut replacement_key)) = + (existing_table.key(key), replacement_table.key_mut(key)) + { + replacement_key + .leaf_decor_mut() + .clone_from(existing_key.leaf_decor()); + replacement_key + .dotted_decor_mut() + .clone_from(existing_key.dotted_decor()); + } + if let Some(replacement_item) = replacement_table.get_mut(key) { + Self::preserve_decor(existing_item, replacement_item); + } + } + } + (TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) => { + replacement_value + .decor_mut() + .clone_from(existing_value.decor()); + } + _ => {} + } + } +} + +fn normalize_skill_config_path(path: &Path) -> String { + dunce::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .to_string() +} + +#[doc(hidden)] +pub fn set_project_trust_level_inner( + doc: &mut DocumentMut, + project_path: &Path, + trust_level: TrustLevel, +) -> anyhow::Result<()> { + let project_key = crate::config_toml::project_trust_key(project_path); + + { + let root = doc.as_table_mut(); + let existing_projects = root.get("projects").cloned(); + if existing_projects.as_ref().is_none_or(|i| !i.is_table()) { + let mut projects_tbl = toml_edit::Table::new(); + projects_tbl.set_implicit(true); + + if let Some(inline_tbl) = existing_projects.as_ref().and_then(|i| i.as_inline_table()) { + for (k, v) in inline_tbl.iter() { + if let Some(inner_tbl) = v.as_inline_table() { + let new_tbl = inner_tbl.clone().into_table(); + projects_tbl.insert(k, toml_edit::Item::Table(new_tbl)); + } + } + } + + root.insert("projects", toml_edit::Item::Table(projects_tbl)); + } + } + + let Some(projects_tbl) = doc["projects"].as_table_mut() else { + return Err(anyhow::anyhow!( + "projects table missing after initialization" + )); + }; + + let needs_proj_table = !projects_tbl.contains_key(project_key.as_str()) + || projects_tbl + .get(project_key.as_str()) + .and_then(|i| i.as_table()) + .is_none(); + if needs_proj_table { + projects_tbl.insert(project_key.as_str(), toml_edit::table()); + } + let Some(proj_tbl) = projects_tbl + .get_mut(project_key.as_str()) + .and_then(|i| i.as_table_mut()) + else { + return Err(anyhow::anyhow!("project table missing for {project_key}")); + }; + proj_tbl.set_implicit(false); + proj_tbl["trust_level"] = toml_edit::value(trust_level.to_string()); + Ok(()) +} + +fn skill_config_selector_from_table(table: &TomlTable) -> Option { + let path = table + .get("path") + .and_then(|item| item.as_str()) + .map(Path::new) + .map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path)))); + let name = table + .get("name") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| SkillConfigSelector::Name(name.to_string())); + + match (path, name) { + (Some(selector), None) | (None, Some(selector)) => Some(selector), + _ => None, + } +} + +fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) { + match selector { + SkillConfigSelector::Name(name) => { + table.remove("path"); + table["name"] = value(name.clone()); + } + SkillConfigSelector::Path(path) => { + table.remove("name"); + table["path"] = value(path.to_string_lossy().to_string()); + } + } +} + +/// Persist edits using a blocking strategy. +pub fn apply_blocking( + codex_home: &Path, + profile: Option<&str>, + edits: &[ConfigEdit], +) -> anyhow::Result<()> { + if edits.is_empty() { + return Ok(()); + } + + let config_path = codex_home.join(CONFIG_TOML_FILE); + let write_paths = resolve_symlink_write_paths(&config_path)?; + let serialized = match write_paths.read_path { + Some(path) => match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err.into()), + }, + None => String::new(), + }; + + let doc = if serialized.is_empty() { + DocumentMut::new() + } else { + serialized.parse::()? + }; + + let profile = profile.map(ToOwned::to_owned).or_else(|| { + doc.get("profile") + .and_then(|item| item.as_str()) + .map(ToOwned::to_owned) + }); + + let mut document = ConfigDocument::new(doc, profile); + let mut mutated = false; + + for edit in edits { + mutated |= document.apply(edit)?; + } + + if !mutated { + return Ok(()); + } + + write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| { + format!( + "failed to persist config.toml at {}", + write_paths.write_path.display() + ) + })?; + + Ok(()) +} + +/// Persist edits asynchronously by offloading the blocking writer. +pub async fn apply( + codex_home: &Path, + profile: Option<&str>, + edits: Vec, +) -> anyhow::Result<()> { + let codex_home = codex_home.to_path_buf(); + let profile = profile.map(ToOwned::to_owned); + task::spawn_blocking(move || apply_blocking(&codex_home, profile.as_deref(), &edits)) + .await + .context("config persistence task panicked")? +} + +/// Fluent builder to batch config edits and apply them atomically. +#[derive(Default)] +pub struct ConfigEditsBuilder { + codex_home: PathBuf, + profile: Option, + edits: Vec, +} + +impl ConfigEditsBuilder { + pub fn new(codex_home: &Path) -> Self { + Self { + codex_home: codex_home.to_path_buf(), + profile: None, + edits: Vec::new(), + } + } + + pub fn with_profile(mut self, profile: Option<&str>) -> Self { + self.profile = profile.map(ToOwned::to_owned); + self + } + + pub fn set_model(mut self, model: Option<&str>, effort: Option) -> Self { + self.edits.push(ConfigEdit::SetModel { + model: model.map(ToOwned::to_owned), + effort, + }); + self + } + + pub fn set_service_tier(mut self, service_tier: Option) -> Self { + self.edits.push(ConfigEdit::SetServiceTier { service_tier }); + self + } + + pub fn set_personality(mut self, personality: Option) -> Self { + self.edits + .push(ConfigEdit::SetModelPersonality { personality }); + self + } + + pub fn set_hide_full_access_warning(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged)); + self + } + + pub fn set_hide_world_writable_warning(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged)); + self + } + + pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged)); + self + } + + pub fn set_hide_model_migration_prompt(mut self, model: &str, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHideModelMigrationPrompt( + model.to_string(), + acknowledged, + )); + self + } + + pub fn set_hide_external_config_migration_prompt_home(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( + acknowledged, + )); + self + } + + pub fn set_hide_external_config_migration_prompt_project( + mut self, + project: &str, + acknowledged: bool, + ) -> Self { + self.edits.push( + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( + project.to_string(), + acknowledged, + ), + ); + self + } + + pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self { + self.edits.push(ConfigEdit::RecordModelMigrationSeen { + from: from.to_string(), + to: to.to_string(), + }); + self + } + + pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self { + self.edits + .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged)); + self + } + + pub fn set_model_availability_nux_count(mut self, shown_count: &HashMap) -> Self { + self.edits + .extend(model_availability_nux_count_edits(shown_count)); + self + } + + pub fn replace_mcp_servers(mut self, servers: &BTreeMap) -> Self { + self.edits + .push(ConfigEdit::ReplaceMcpServers(servers.clone())); + self + } + + pub fn set_project_trust_level>( + mut self, + project_path: P, + trust_level: TrustLevel, + ) -> Self { + self.edits.push(ConfigEdit::SetProjectTrustLevel { + path: project_path.into(), + level: trust_level, + }); + self + } + + /// Enable or disable a feature flag by key under the `[features]` table. + /// + /// Disabling a default-false feature clears the root-scoped key instead of + /// persisting `false`, so the config does not pin the feature once it + /// graduates to globally enabled. Profile-scoped disables still persist + /// `false` so they can override an inherited root enable. + pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self { + let profile_scoped = self.profile.is_some(); + let segments = if let Some(profile) = self.profile.as_ref() { + vec![ + "profiles".to_string(), + profile.clone(), + "features".to_string(), + key.to_string(), + ] + } else { + vec!["features".to_string(), key.to_string()] + }; + let is_default_false_feature = FEATURES + .iter() + .find(|spec| spec.key == key) + .is_some_and(|spec| !spec.default_enabled); + if enabled || profile_scoped || !is_default_false_feature { + self.edits.push(ConfigEdit::SetPath { + segments, + value: value(enabled), + }); + } else { + self.edits.push(ConfigEdit::ClearPath { segments }); + } + self + } + + pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self { + let segments = if let Some(profile) = self.profile.as_ref() { + vec![ + "profiles".to_string(), + profile.clone(), + "windows".to_string(), + "sandbox".to_string(), + ] + } else { + vec!["windows".to_string(), "sandbox".to_string()] + }; + self.edits.push(ConfigEdit::SetPath { + segments, + value: value(mode), + }); + self + } + + pub fn set_realtime_microphone(mut self, microphone: Option<&str>) -> Self { + let segments = vec!["audio".to_string(), "microphone".to_string()]; + match microphone { + Some(microphone) => self.edits.push(ConfigEdit::SetPath { + segments, + value: value(microphone), + }), + None => self.edits.push(ConfigEdit::ClearPath { segments }), + } + self + } + + pub fn set_realtime_speaker(mut self, speaker: Option<&str>) -> Self { + let segments = vec!["audio".to_string(), "speaker".to_string()]; + match speaker { + Some(speaker) => self.edits.push(ConfigEdit::SetPath { + segments, + value: value(speaker), + }), + None => self.edits.push(ConfigEdit::ClearPath { segments }), + } + self + } + + pub fn set_realtime_voice(mut self, voice: Option<&str>) -> Self { + let segments = vec!["realtime".to_string(), "voice".to_string()]; + match voice { + Some(voice) => self.edits.push(ConfigEdit::SetPath { + segments, + value: value(voice), + }), + None => self.edits.push(ConfigEdit::ClearPath { segments }), + } + self + } + + pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self { + for key in [ + "experimental_windows_sandbox", + "elevated_windows_sandbox", + "enable_experimental_windows_sandbox", + ] { + let mut segments = vec!["features".to_string(), key.to_string()]; + if let Some(profile) = self.profile.as_ref() { + segments = vec![ + "profiles".to_string(), + profile.clone(), + "features".to_string(), + key.to_string(), + ]; + } + self.edits.push(ConfigEdit::ClearPath { segments }); + } + self + } + + pub fn with_edits(mut self, edits: I) -> Self + where + I: IntoIterator, + { + self.edits.extend(edits); + self + } + + /// Apply edits on a blocking thread. + pub fn apply_blocking(self) -> anyhow::Result<()> { + apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) + } + + /// Apply edits asynchronously via a blocking offload. + pub async fn apply(self) -> anyhow::Result<()> { + task::spawn_blocking(move || { + apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) + }) + .await + .context("config persistence task panicked")? + } +} + +#[cfg(test)] +#[path = "edit_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/config/src/edit_tests.rs similarity index 99% rename from codex-rs/core/src/config/edit_tests.rs rename to codex-rs/config/src/edit_tests.rs index 84a4a34ea6..bcec33fa90 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/config/src/edit_tests.rs @@ -1,7 +1,7 @@ use super::*; -use codex_config::types::AppToolApproval; -use codex_config::types::McpServerToolConfig; -use codex_config::types::McpServerTransportConfig; +use crate::types::AppToolApproval; +use crate::types::McpServerToolConfig; +use crate::types::McpServerTransportConfig; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; #[cfg(unix)] diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 18779f9809..96a3d8d857 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -3,6 +3,7 @@ mod config_requirements; pub mod config_toml; mod constraint; mod diagnostics; +pub mod edit; mod fingerprint; mod key_aliases; mod marketplace_edit; @@ -61,6 +62,11 @@ pub use diagnostics::first_layer_config_error_from_entries; pub use diagnostics::format_config_error; pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; +pub use edit::ConfigEdit; +pub use edit::model_availability_nux_count_edits; +pub use edit::status_line_items_edit; +pub use edit::syntax_theme_edit; +pub use edit::terminal_title_items_edit; pub use fingerprint::version_for_toml; pub use marketplace_edit::MarketplaceConfigUpdate; pub use marketplace_edit::record_user_marketplace; diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index dd52bce43d..4c29a799c2 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -1,4 +1,59 @@ -load("//:defs.bzl", "codex_rust_crate") +load("@crates//:defs.bzl", "all_crate_deps") +load("@rules_rust//rust:defs.bzl", "rust_test") +load( + "//:defs.bzl", + "WINDOWS_RUSTC_LINK_FLAGS", + "codex_rust_crate", + "workspace_root_test", +) + +CORE_COMPILE_DATA = [ + "//codex-rs:node-version.txt", + "hierarchical_agents_message.md", + "review_prompt.md", + "src/agent/builtins/awaiter.toml", + "src/agent/builtins/explorer.toml", + "src/guardian/policy.md", + "src/guardian/policy_template.md", + "templates/compact/prompt.md", + "templates/compact/summary_prefix.md", + "templates/realtime/backend_prompt.md", + "templates/review/exit_interrupted.xml", + "templates/review/exit_success.xml", +] + +CORE_TEST_DATA = [ + "config.schema.json", +] + glob([ + "src/**/snapshots/**", +]) + [ + # This is a bit of a hack, but empirically, some of our integration tests + # are relying on the presence of this file as a repo root marker. When + # running tests locally, this "just works," but in remote execution, + # the working directory is different and so the file is not found unless it + # is explicitly added as test data. + # + # TODO(aibrahim): Update the tests so that `just bazel-remote-test` + # succeeds without this workaround. + "//:AGENTS.md", +] + +CORE_LIB_SRCS = glob( + ["src/**/*.rs"], + exclude = [ + "src/**/*_tests.rs", + "src/**/tests.rs", + "src/**/mod_tests.rs", + "src/core_unit_tests.rs", + ], +) + +CORE_UNIT_TEST_SRCS = glob(["src/**/*.rs"]) + glob(["tests/unit/**/*.rs"]) + +filegroup( + name = "core_test_data", + srcs = CORE_TEST_DATA, +) filegroup( name = "model_availability_nux_fixtures", @@ -11,17 +66,8 @@ filegroup( codex_rust_crate( name = "core", crate_name = "codex_core", - compile_data = glob( - include = ["**"], - exclude = [ - "**/* *", - "BUILD.bazel", - "Cargo.toml", - ], - allow_empty = True, - ) + [ - "//codex-rs:node-version.txt", - ], + crate_srcs = CORE_LIB_SRCS, + compile_data = CORE_COMPILE_DATA, rustc_env = { # Keep manifest-root path lookups inside the Bazel execroot for code # that relies on env!("CARGO_MANIFEST_DIR"). @@ -29,30 +75,18 @@ codex_rust_crate( }, integration_compile_data_extra = [ "//codex-rs/apply-patch:apply_patch_tool_instructions.md", + "prompt_with_apply_patch_instructions.md", "templates/realtime/backend_prompt.md", ], integration_test_timeout = "long", - test_data_extra = [ - "config.schema.json", - ] + glob([ - "src/**/snapshots/**", - ]) + [ - # This is a bit of a hack, but empirically, some of our integration tests - # are relying on the presence of this file as a repo root marker. When - # running tests locally, this "just works," but in remote execution, - # the working directory is different and so the file is not found unless it - # is explicitly added as test data. - # - # TODO(aibrahim): Update the tests so that `just bazel-remote-test` - # succeeds without this workaround. - "//:AGENTS.md", - ], + test_data_extra = CORE_TEST_DATA, test_shard_counts = { "core-all-test": 8, "core-unit-tests": 8, }, test_tags = ["no-sandbox"], unit_test_timeout = "long", + generate_unit_tests = False, extra_binaries = [ "//codex-rs/linux-sandbox:codex-linux-sandbox", "//codex-rs/rmcp-client:test_stdio_server", @@ -61,3 +95,46 @@ codex_rust_crate( "//codex-rs/cli:codex", ], ) + +rust_test( + name = "core-unit-tests-bin", + crate_name = "codex_core", + crate_root = "src/lib.rs", + compile_data = CORE_COMPILE_DATA + [ + "prompt_with_apply_patch_instructions.md", + ], + data = [":core_test_data"], + deps = all_crate_deps(normal = True, normal_dev = True), + edition = "2024", + experimental_enable_sharding = True, + rustc_env = { + "BAZEL_PACKAGE": "codex-rs/core", + # Keep manifest-root path lookups inside the Bazel execroot for code + # that relies on env!("CARGO_MANIFEST_DIR"). + "CARGO_MANIFEST_DIR": "codex-rs/core", + }, + rustc_flags = WINDOWS_RUSTC_LINK_FLAGS + [ + "--remap-path-prefix=../codex-rs=", + "--remap-path-prefix=codex-rs=", + ], + srcs = CORE_UNIT_TEST_SRCS, + tags = [ + "manual", + "no-sandbox", + ], +) + +workspace_root_test( + name = "core-unit-tests", + data = [":core_test_data"], + env = { + "INSTA_SNAPSHOT_PATH": "src", + "INSTA_WORKSPACE_ROOT": ".", + }, + flaky = True, + shard_count = 8, + tags = ["no-sandbox"], + test_bin = ":core-unit-tests-bin", + timeout = "long", + workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker", +) diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8770aaf487..2de53d1d73 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -26,6 +26,7 @@ bm25 = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-analytics = { workspace = true } +codex-agent-runtime = { workspace = true } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } @@ -33,14 +34,19 @@ codex-async-utils = { workspace = true } codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } +codex-config-loader = { workspace = true } codex-core-plugins = { workspace = true } codex-core-skills = { workspace = true } crypto_box = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } +codex-file-watcher = { workspace = true } codex-login = { workspace = true } codex-mcp = { workspace = true } +codex-message-history = { workspace = true } +codex-memory-prompts = { workspace = true } +codex-mcp-tool-approval = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } ed25519-dalek = { workspace = true } @@ -49,18 +55,22 @@ codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } codex-instructions = { workspace = true } +codex-js-repl = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-model-provider = { workspace = true } codex-protocol = { workspace = true } codex-response-debug-context = { workspace = true } +codex-review = { workspace = true } codex-rollout = { workspace = true } codex-rmcp-client = { workspace = true } codex-sandboxing = { workspace = true } +codex-session-runtime = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-thread-store = { workspace = true } +codex-tool-spec = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } @@ -87,7 +97,6 @@ iana-time-zone = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } indexmap = { workspace = true } libc = { workspace = true } -notify = { workspace = true } once_cell = { workspace = true } rand = { workspace = true } regex-lite = { workspace = true } @@ -125,9 +134,6 @@ which = { workspace = true } whoami = { workspace = true } zip = { workspace = true } -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "0.9" - # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } @@ -136,13 +142,6 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } -[target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.52", features = [ - "Win32_Foundation", - "Win32_System_Com", - "Win32_UI_Shell", -] } - [target.'cfg(unix)'.dependencies] codex-shell-escalation = { workspace = true } diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 984b92e7bb..f7b4895f72 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -14,21 +14,27 @@ use crate::session_prefix::format_subagent_notification_message; use crate::shell_snapshot::ShellSnapshot; use crate::thread_manager::ThreadManagerState; use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; +pub(crate) use codex_agent_runtime::LiveAgent; +pub(crate) use codex_agent_runtime::SpawnAgentForkMode; +pub(crate) use codex_agent_runtime::SpawnAgentOptions; +use codex_agent_runtime::agent_matches_prefix; +use codex_agent_runtime::keep_forked_rollout_item; +pub(crate) use codex_agent_runtime::render_input_preview; +use codex_agent_runtime::thread_spawn_depth; +use codex_agent_runtime::thread_spawn_parent_thread_id; use codex_features::Feature; use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; -use codex_protocol::models::MessagePhase; +#[cfg(test)] use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; -use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; -use codex_protocol::user_input::UserInput; use codex_rollout::state_db; use codex_state::DirectionalThreadSpawnEdgeStatus; use serde::Serialize; @@ -39,28 +45,8 @@ use std::sync::Weak; use tokio::sync::watch; use tracing::warn; -const AGENT_NAMES: &str = include_str!("agent_names.txt"); const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum SpawnAgentForkMode { - FullHistory, - LastNTurns(usize), -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct SpawnAgentOptions { - pub(crate) fork_parent_spawn_call_id: Option, - pub(crate) fork_mode: Option, -} - -#[derive(Clone, Debug)] -pub(crate) struct LiveAgent { - pub(crate) thread_id: ThreadId, - pub(crate) metadata: AgentMetadata, - pub(crate) status: AgentStatus, -} - #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub(crate) struct ListedAgent { pub(crate) agent_name: String, @@ -69,11 +55,7 @@ pub(crate) struct ListedAgent { } fn default_agent_nickname_list() -> Vec<&'static str> { - AGENT_NAMES - .lines() - .map(str::trim) - .filter(|name| !name.is_empty()) - .collect() + codex_agent_runtime::default_agent_nickname_list() } fn agent_nickname_candidates( @@ -93,36 +75,6 @@ fn agent_nickname_candidates( .collect() } -fn keep_forked_rollout_item(item: &RolloutItem) -> bool { - match item { - RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() - { - "system" | "developer" | "user" => true, - "assistant" => *phase == Some(MessagePhase::FinalAnswer), - _ => false, - }, - RolloutItem::ResponseItem( - ResponseItem::Reasoning { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::GhostSnapshot { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::Other, - ) => false, - RolloutItem::Compacted(_) - | RolloutItem::EventMsg(_) - | RolloutItem::SessionMeta(_) - | RolloutItem::TurnContext(_) => true, - } -} - /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -1160,54 +1112,6 @@ impl AgentControl { } } -fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option { - match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }) => Some(*parent_thread_id), - _ => None, - } -} - -fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool { - if prefix.is_root() { - return true; - } - - agent_path.is_some_and(|agent_path| { - agent_path == prefix - || agent_path - .as_str() - .strip_prefix(prefix.as_str()) - .is_some_and(|suffix| suffix.starts_with('/')) - }) -} - -pub(crate) fn render_input_preview(initial_operation: &Op) -> String { - match initial_operation { - Op::UserInput { items, .. } => items - .iter() - .map(|item| match item { - UserInput::Text { text, .. } => text.clone(), - UserInput::Image { .. } => "[image]".to_string(), - UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), - UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), - UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), - _ => "[input]".to_string(), - }) - .collect::>() - .join("\n"), - Op::InterAgentCommunication { communication } => communication.content.clone(), - _ => String::new(), - } -} - -fn thread_spawn_depth(session_source: &SessionSource) -> Option { - match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), - _ => None, - } -} #[cfg(test)] -#[path = "control_tests.rs"] +#[path = "../../tests/unit/agent/control_tests.rs"] mod tests; diff --git a/codex-rs/core/src/agent/mailbox.rs b/codex-rs/core/src/agent/mailbox.rs index c328236475..4e3fcf7e1f 100644 --- a/codex-rs/core/src/agent/mailbox.rs +++ b/codex-rs/core/src/agent/mailbox.rs @@ -1,161 +1 @@ -use codex_protocol::protocol::InterAgentCommunication; -use std::collections::VecDeque; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; -use tokio::sync::mpsc; -use tokio::sync::watch; - -#[cfg(test)] -use codex_protocol::AgentPath; - -pub(crate) struct Mailbox { - tx: mpsc::UnboundedSender, - next_seq: AtomicU64, - seq_tx: watch::Sender, -} - -pub(crate) struct MailboxReceiver { - rx: mpsc::UnboundedReceiver, - pending_mails: VecDeque, -} - -impl Mailbox { - pub(crate) fn new() -> (Self, MailboxReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - let (seq_tx, _) = watch::channel(0); - ( - Self { - tx, - next_seq: AtomicU64::new(0), - seq_tx, - }, - MailboxReceiver { - rx, - pending_mails: VecDeque::new(), - }, - ) - } - - pub(crate) fn subscribe(&self) -> watch::Receiver { - self.seq_tx.subscribe() - } - - pub(crate) fn send(&self, communication: InterAgentCommunication) -> u64 { - let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1; - let _ = self.tx.send(communication); - self.seq_tx.send_replace(seq); - seq - } -} - -impl MailboxReceiver { - fn sync_pending_mails(&mut self) { - while let Ok(mail) = self.rx.try_recv() { - self.pending_mails.push_back(mail); - } - } - - pub(crate) fn has_pending(&mut self) -> bool { - self.sync_pending_mails(); - !self.pending_mails.is_empty() - } - - pub(crate) fn has_pending_trigger_turn(&mut self) -> bool { - self.sync_pending_mails(); - self.pending_mails.iter().any(|mail| mail.trigger_turn) - } - - pub(crate) fn drain(&mut self) -> Vec { - self.sync_pending_mails(); - self.pending_mails.drain(..).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn make_mail( - author: AgentPath, - recipient: AgentPath, - content: &str, - trigger_turn: bool, - ) -> InterAgentCommunication { - InterAgentCommunication::new( - author, - recipient, - Vec::new(), - content.to_string(), - trigger_turn, - ) - } - - #[tokio::test] - async fn mailbox_assigns_monotonic_sequence_numbers() { - let (mailbox, _receiver) = Mailbox::new(); - let mut seq_rx = mailbox.subscribe(); - - let seq_a = mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "one", - /*trigger_turn*/ false, - )); - let seq_b = mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "two", - /*trigger_turn*/ false, - )); - - seq_rx.changed().await.expect("first seq update"); - assert_eq!(*seq_rx.borrow(), seq_b); - assert_eq!(seq_a, 1); - assert_eq!(seq_b, 2); - } - - #[tokio::test] - async fn mailbox_drains_in_delivery_order() { - let (mailbox, mut receiver) = Mailbox::new(); - let mail_one = make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "one", - /*trigger_turn*/ false, - ); - let mail_two = make_mail( - AgentPath::try_from("/root/worker").expect("agent path"), - AgentPath::root(), - "two", - /*trigger_turn*/ false, - ); - - mailbox.send(mail_one.clone()); - mailbox.send(mail_two.clone()); - - assert_eq!(receiver.drain(), vec![mail_one, mail_two]); - assert!(!receiver.has_pending()); - } - - #[tokio::test] - async fn mailbox_tracks_pending_trigger_turn_mail() { - let (mailbox, mut receiver) = Mailbox::new(); - - mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "queued", - /*trigger_turn*/ false, - )); - assert!(!receiver.has_pending_trigger_turn()); - - mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "wake", - /*trigger_turn*/ true, - )); - assert!(receiver.has_pending_trigger_turn()); - } -} +pub(crate) use codex_agent_runtime::mailbox::*; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index 1acd73085f..3b45539125 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -1,344 +1 @@ -use codex_protocol::AgentPath; -use codex_protocol::ThreadId; -use codex_protocol::error::CodexErr; -use codex_protocol::error::Result; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; -use rand::prelude::IndexedRandom; -use std::collections::HashMap; -use std::collections::HashSet; -use std::collections::hash_map::Entry; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; - -/// This structure is used to add some limits on the multi-agent capabilities for Codex. In -/// the current implementation, it limits: -/// * Total number of sub-agents (i.e. threads) per user session -/// -/// This structure is shared by all agents in the same user session (because the `AgentControl` -/// is). -#[derive(Default)] -pub(crate) struct AgentRegistry { - active_agents: Mutex, - total_count: AtomicUsize, -} - -#[derive(Default)] -struct ActiveAgents { - agent_tree: HashMap, - used_agent_nicknames: HashSet, - nickname_reset_count: usize, -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct AgentMetadata { - pub(crate) agent_id: Option, - pub(crate) agent_path: Option, - pub(crate) agent_nickname: Option, - pub(crate) agent_role: Option, - pub(crate) last_task_message: Option, -} - -fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String { - match nickname_reset_count { - 0 => name.to_string(), - reset_count => { - let value = reset_count + 1; - let suffix = match value % 100 { - 11..=13 => "th", - _ => match value % 10 { - 1 => "st", // codespell:ignore - 2 => "nd", // codespell:ignore - 3 => "rd", // codespell:ignore - _ => "th", // codespell:ignore - }, - }; - format!("{name} the {value}{suffix}") - } - } -} - -fn session_depth(session_source: &SessionSource) -> i32 { - match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, - SessionSource::SubAgent(_) => 0, - _ => 0, - } -} - -pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 { - session_depth(session_source).saturating_add(1) -} - -pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bool { - depth > max_depth -} - -impl AgentRegistry { - pub(crate) fn reserve_spawn_slot( - self: &Arc, - max_threads: Option, - ) -> Result { - if let Some(max_threads) = max_threads { - if !self.try_increment_spawned(max_threads) { - return Err(CodexErr::AgentLimitReached { max_threads }); - } - } else { - self.total_count.fetch_add(1, Ordering::AcqRel); - } - Ok(SpawnReservation { - state: Arc::clone(self), - active: true, - reserved_agent_nickname: None, - reserved_agent_path: None, - }) - } - - pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) { - let removed_counted_agent = { - let mut active_agents = self - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let removed_key = active_agents - .agent_tree - .iter() - .find_map(|(key, metadata)| (metadata.agent_id == Some(thread_id)).then_some(key)) - .cloned(); - removed_key - .and_then(|key| active_agents.agent_tree.remove(key.as_str())) - .is_some_and(|metadata| { - !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) - }) - }; - if removed_counted_agent { - self.total_count.fetch_sub(1, Ordering::AcqRel); - } - } - - pub(crate) fn register_root_thread(&self, thread_id: ThreadId) { - let mut active_agents = self - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - active_agents - .agent_tree - .entry(AgentPath::ROOT.to_string()) - .or_insert_with(|| AgentMetadata { - agent_id: Some(thread_id), - agent_path: Some(AgentPath::root()), - ..Default::default() - }); - } - - pub(crate) fn agent_id_for_path(&self, agent_path: &AgentPath) -> Option { - self.active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .agent_tree - .get(agent_path.as_str()) - .and_then(|metadata| metadata.agent_id) - } - - pub(crate) fn agent_metadata_for_thread(&self, thread_id: ThreadId) -> Option { - self.active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .agent_tree - .values() - .find(|metadata| metadata.agent_id == Some(thread_id)) - .cloned() - } - - pub(crate) fn live_agents(&self) -> Vec { - self.active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .agent_tree - .values() - .filter(|metadata| { - metadata.agent_id.is_some() - && !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) - }) - .cloned() - .collect() - } - - pub(crate) fn update_last_task_message(&self, thread_id: ThreadId, last_task_message: String) { - let mut active_agents = self - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(metadata) = active_agents - .agent_tree - .values_mut() - .find(|metadata| metadata.agent_id == Some(thread_id)) - { - metadata.last_task_message = Some(last_task_message); - } - } - - fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { - let Some(thread_id) = agent_metadata.agent_id else { - return; - }; - let mut active_agents = self - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let key = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| format!("thread:{thread_id}")); - if let Some(agent_nickname) = agent_metadata.agent_nickname.clone() { - active_agents.used_agent_nicknames.insert(agent_nickname); - } - active_agents.agent_tree.insert(key, agent_metadata); - } - - fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option { - let mut active_agents = self - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let agent_nickname = if let Some(preferred) = preferred { - preferred.to_string() - } else { - if names.is_empty() { - return None; - } - let available_names: Vec = names - .iter() - .map(|name| format_agent_nickname(name, active_agents.nickname_reset_count)) - .filter(|name| !active_agents.used_agent_nicknames.contains(name)) - .collect(); - if let Some(name) = available_names.choose(&mut rand::rng()) { - name.clone() - } else { - active_agents.used_agent_nicknames.clear(); - active_agents.nickname_reset_count += 1; - if let Some(metrics) = codex_otel::global() { - let _ = metrics.counter( - "codex.multi_agent.nickname_pool_reset", - /*inc*/ 1, - &[], - ); - } - format_agent_nickname( - names.choose(&mut rand::rng())?, - active_agents.nickname_reset_count, - ) - } - }; - active_agents - .used_agent_nicknames - .insert(agent_nickname.clone()); - Some(agent_nickname) - } - - fn reserve_agent_path(&self, agent_path: &AgentPath) -> Result<()> { - let mut active_agents = self - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - match active_agents.agent_tree.entry(agent_path.to_string()) { - Entry::Occupied(_) => Err(CodexErr::UnsupportedOperation(format!( - "agent path `{agent_path}` already exists" - ))), - Entry::Vacant(entry) => { - entry.insert(AgentMetadata { - agent_path: Some(agent_path.clone()), - ..Default::default() - }); - Ok(()) - } - } - } - - fn release_reserved_agent_path(&self, agent_path: &AgentPath) { - let mut active_agents = self - .active_agents - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if active_agents - .agent_tree - .get(agent_path.as_str()) - .is_some_and(|metadata| metadata.agent_id.is_none()) - { - active_agents.agent_tree.remove(agent_path.as_str()); - } - } - - fn try_increment_spawned(&self, max_threads: usize) -> bool { - let mut current = self.total_count.load(Ordering::Acquire); - loop { - if current >= max_threads { - return false; - } - match self.total_count.compare_exchange_weak( - current, - current + 1, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => return true, - Err(updated) => current = updated, - } - } - } -} - -pub(crate) struct SpawnReservation { - state: Arc, - active: bool, - reserved_agent_nickname: Option, - reserved_agent_path: Option, -} - -impl SpawnReservation { - pub(crate) fn reserve_agent_nickname_with_preference( - &mut self, - names: &[&str], - preferred: Option<&str>, - ) -> Result { - let agent_nickname = self - .state - .reserve_agent_nickname(names, preferred) - .ok_or_else(|| { - CodexErr::UnsupportedOperation("no available agent nicknames".to_string()) - })?; - self.reserved_agent_nickname = Some(agent_nickname.clone()); - Ok(agent_nickname) - } - - pub(crate) fn reserve_agent_path(&mut self, agent_path: &AgentPath) -> Result<()> { - self.state.reserve_agent_path(agent_path)?; - self.reserved_agent_path = Some(agent_path.clone()); - Ok(()) - } - - pub(crate) fn commit(mut self, agent_metadata: AgentMetadata) { - self.reserved_agent_nickname = None; - self.reserved_agent_path = None; - self.state.register_spawned_thread(agent_metadata); - self.active = false; - } -} - -impl Drop for SpawnReservation { - fn drop(&mut self) { - if self.active { - if let Some(agent_path) = self.reserved_agent_path.take() { - self.state.release_reserved_agent_path(&agent_path); - } - self.state.total_count.fetch_sub(1, Ordering::AcqRel); - } - } -} - -#[cfg(test)] -#[path = "registry_tests.rs"] -mod tests; +pub(crate) use codex_agent_runtime::registry::*; diff --git a/codex-rs/core/src/agent/status.rs b/codex-rs/core/src/agent/status.rs index c343e19503..e5a5e03c40 100644 --- a/codex-rs/core/src/agent/status.rs +++ b/codex-rs/core/src/agent/status.rs @@ -1,27 +1 @@ -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::EventMsg; - -/// Derive the next agent status from a single emitted event. -/// Returns `None` when the event does not affect status tracking. -pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option { - match msg { - EventMsg::TurnStarted(_) => Some(AgentStatus::Running), - EventMsg::TurnComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())), - EventMsg::TurnAborted(ev) => match ev.reason { - codex_protocol::protocol::TurnAbortReason::Interrupted => { - Some(AgentStatus::Interrupted) - } - _ => Some(AgentStatus::Errored(format!("{:?}", ev.reason))), - }, - EventMsg::Error(ev) => Some(AgentStatus::Errored(ev.message.clone())), - EventMsg::ShutdownComplete => Some(AgentStatus::Shutdown), - _ => None, - } -} - -pub(crate) fn is_final(status: &AgentStatus) -> bool { - !matches!( - status, - AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted - ) -} +pub(crate) use codex_agent_runtime::status::*; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5d67223b33..48bb368868 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -97,6 +97,7 @@ pub(crate) async fn run_codex_thread_interactive( inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_trace: None, analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), + code_mode_runtime_factory: Arc::clone(&parent_session.services.code_mode_runtime_factory), })) .await?; if parent_session.enabled(codex_features::Feature::GeneralAnalytics) { diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 8f336f7ff5..2b96cd0ab7 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1,1169 +1 @@ -use crate::path_utils::resolve_symlink_write_paths; -use crate::path_utils::write_atomically; -use anyhow::Context; -use codex_config::CONFIG_TOML_FILE; -use codex_config::types::McpServerConfig; -use codex_features::FEATURES; -use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ServiceTier; -use codex_protocol::config_types::TrustLevel; -use codex_protocol::openai_models::ReasoningEffort; -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::path::Path; -use std::path::PathBuf; -use tokio::task; -use toml_edit::ArrayOfTables; -use toml_edit::DocumentMut; -use toml_edit::Item as TomlItem; -use toml_edit::Table as TomlTable; -use toml_edit::value; - -const NOTICE_TABLE_KEY: &str = "notice"; - -/// Discrete config mutations supported by the persistence engine. -#[derive(Clone, Debug)] -pub enum ConfigEdit { - /// Update the active (or default) model selection and optional reasoning effort. - SetModel { - model: Option, - effort: Option, - }, - /// Update the service tier preference for future turns. - SetServiceTier { service_tier: Option }, - /// Update the active (or default) model personality. - SetModelPersonality { personality: Option }, - /// Toggle the acknowledgement flag under `[notice]`. - SetNoticeHideFullAccessWarning(bool), - /// Toggle the Windows world-writable directories warning acknowledgement flag. - SetNoticeHideWorldWritableWarning(bool), - /// Toggle the rate limit model nudge acknowledgement flag. - SetNoticeHideRateLimitModelNudge(bool), - /// Toggle the Windows onboarding acknowledgement flag. - SetWindowsWslSetupAcknowledged(bool), - /// Toggle the model migration prompt acknowledgement flag. - SetNoticeHideModelMigrationPrompt(String, bool), - /// Toggle the home external config migration prompt acknowledgement flag. - SetNoticeHideExternalConfigMigrationPromptHome(bool), - /// Record when the home external config migration prompt was last shown. - SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(i64), - /// Toggle the project external config migration prompt acknowledgement flag. - SetNoticeHideExternalConfigMigrationPromptProject(String, bool), - /// Record when the project external config migration prompt was last shown. - SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(String, i64), - /// Record that a migration prompt was shown for an old->new model mapping. - RecordModelMigrationSeen { from: String, to: String }, - /// Replace the entire `[mcp_servers]` table. - ReplaceMcpServers(BTreeMap), - /// Set or clear a skill config entry under `[[skills.config]]` by path. - SetSkillConfig { path: PathBuf, enabled: bool }, - /// Set or clear a skill config entry under `[[skills.config]]` by name. - SetSkillConfigByName { name: String, enabled: bool }, - /// Set trust_level under `[projects.""]`, - /// migrating inline tables to explicit tables. - SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, - /// Set the value stored at the exact dotted path. - SetPath { - segments: Vec, - value: TomlItem, - }, - /// Remove the value stored at the exact dotted path. - ClearPath { segments: Vec }, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum SkillConfigSelector { - Name(String), - Path(PathBuf), -} - -/// Produces a config edit that sets `[tui].theme = ""`. -pub fn syntax_theme_edit(name: &str) -> ConfigEdit { - ConfigEdit::SetPath { - segments: vec!["tui".to_string(), "theme".to_string()], - value: value(name.to_string()), - } -} - -/// Produces a config edit that sets `[tui].status_line` to an explicit ordered list. -/// -/// The array is written even when it is empty so "hide the status line" stays -/// distinct from "unset, so use defaults". -pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { - let array = items.iter().cloned().collect::(); - - ConfigEdit::SetPath { - segments: vec!["tui".to_string(), "status_line".to_string()], - value: TomlItem::Value(array.into()), - } -} - -/// Produces a config edit that sets `[tui].terminal_title` to an explicit ordered list. -/// -/// The array is written even when it is empty so "disabled title updates" stays -/// distinct from "unset, so use defaults". -pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit { - let array = items.iter().cloned().collect::(); - - ConfigEdit::SetPath { - segments: vec!["tui".to_string(), "terminal_title".to_string()], - value: TomlItem::Value(array.into()), - } -} - -pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { - let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); - shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); - - let mut edits = vec![ConfigEdit::ClearPath { - segments: vec!["tui".to_string(), "model_availability_nux".to_string()], - }]; - for (model_slug, count) in shown_count_entries { - edits.push(ConfigEdit::SetPath { - segments: vec![ - "tui".to_string(), - "model_availability_nux".to_string(), - model_slug.clone(), - ], - value: value(i64::from(*count)), - }); - } - - edits -} - -// TODO(jif) move to a dedicated file -mod document_helpers { - use codex_config::types::AppToolApproval; - use codex_config::types::McpServerConfig; - use codex_config::types::McpServerToolConfig; - use codex_config::types::McpServerTransportConfig; - use toml_edit::Array as TomlArray; - use toml_edit::InlineTable; - use toml_edit::Item as TomlItem; - use toml_edit::Table as TomlTable; - use toml_edit::value; - - pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> { - match item { - TomlItem::Table(table) => Some(table), - TomlItem::Value(value) => { - if let Some(inline) = value.as_inline_table() { - *item = TomlItem::Table(table_from_inline(inline)); - item.as_table_mut() - } else { - *item = TomlItem::Table(new_implicit_table()); - item.as_table_mut() - } - } - TomlItem::None => { - *item = TomlItem::Table(new_implicit_table()); - item.as_table_mut() - } - _ => None, - } - } - - pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> { - match item { - TomlItem::Table(table) => Some(table), - TomlItem::Value(value) => { - let inline = value.as_inline_table()?; - *item = TomlItem::Table(table_from_inline(inline)); - item.as_table_mut() - } - _ => None, - } - } - - fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - - match &config.transport { - McpServerTransportConfig::Stdio { - command, - args, - env, - env_vars, - cwd, - } => { - entry["command"] = value(command.clone()); - if !args.is_empty() { - entry["args"] = array_from_iter(args.iter().cloned()); - } - if let Some(env) = env - && !env.is_empty() - { - entry["env"] = table_from_pairs(env.iter()); - } - if !env_vars.is_empty() { - entry["env_vars"] = array_from_iter(env_vars.iter().cloned()); - } - if let Some(cwd) = cwd { - entry["cwd"] = value(cwd.to_string_lossy().to_string()); - } - } - McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers, - env_http_headers, - } => { - entry["url"] = value(url.clone()); - if let Some(env_var) = bearer_token_env_var { - entry["bearer_token_env_var"] = value(env_var.clone()); - } - if let Some(headers) = http_headers - && !headers.is_empty() - { - entry["http_headers"] = table_from_pairs(headers.iter()); - } - if let Some(headers) = env_http_headers - && !headers.is_empty() - { - entry["env_http_headers"] = table_from_pairs(headers.iter()); - } - } - } - - if !config.enabled { - entry["enabled"] = value(false); - } - if let Some(environment) = &config.experimental_environment { - entry["experimental_environment"] = value(environment.clone()); - } - if config.required { - entry["required"] = value(true); - } - if config.supports_parallel_tool_calls { - entry["supports_parallel_tool_calls"] = value(true); - } - if let Some(timeout) = config.startup_timeout_sec { - entry["startup_timeout_sec"] = value(timeout.as_secs_f64()); - } - if let Some(timeout) = config.tool_timeout_sec { - entry["tool_timeout_sec"] = value(timeout.as_secs_f64()); - } - if let Some(approval_mode) = config.default_tools_approval_mode { - entry["default_tools_approval_mode"] = value(match approval_mode { - AppToolApproval::Auto => "auto", - AppToolApproval::Prompt => "prompt", - AppToolApproval::Approve => "approve", - }); - } - if let Some(enabled_tools) = &config.enabled_tools - && !enabled_tools.is_empty() - { - entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned()); - } - if let Some(disabled_tools) = &config.disabled_tools - && !disabled_tools.is_empty() - { - entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned()); - } - if let Some(scopes) = &config.scopes - && !scopes.is_empty() - { - entry["scopes"] = array_from_iter(scopes.iter().cloned()); - } - if let Some(resource) = &config.oauth_resource - && !resource.is_empty() - { - entry["oauth_resource"] = value(resource.clone()); - } - if !config.tools.is_empty() { - let mut tools = new_implicit_table(); - let mut tool_entries: Vec<_> = config.tools.iter().collect(); - tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - for (name, tool_config) in tool_entries { - tools.insert(name, serialize_mcp_server_tool(tool_config)); - } - entry.insert("tools", TomlItem::Table(tools)); - } - - entry - } - - fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - if let Some(approval_mode) = config.approval_mode { - entry["approval_mode"] = value(match approval_mode { - AppToolApproval::Auto => "auto", - AppToolApproval::Prompt => "prompt", - AppToolApproval::Approve => "approve", - }); - } - TomlItem::Table(entry) - } - - pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { - TomlItem::Table(serialize_mcp_server_table(config)) - } - - pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable { - serialize_mcp_server_table(config).into_inline_table() - } - - pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) { - existing.retain(|key, _| replacement.get(key).is_some()); - - for (key, value) in replacement.iter() { - if let Some(existing_value) = existing.get_mut(key) { - let mut updated_value = value.clone(); - *updated_value.decor_mut() = existing_value.decor().clone(); - *existing_value = updated_value; - } else { - existing.insert(key.to_string(), value.clone()); - } - } - } - - fn table_from_inline(inline: &InlineTable) -> TomlTable { - let mut table = new_implicit_table(); - for (key, value) in inline.iter() { - let mut value = value.clone(); - let decor = value.decor_mut(); - decor.set_suffix(""); - table.insert(key, TomlItem::Value(value)); - } - table - } - - pub(super) fn new_implicit_table() -> TomlTable { - let mut table = TomlTable::new(); - table.set_implicit(true); - table - } - - fn array_from_iter(iter: I) -> TomlItem - where - I: Iterator, - { - let mut array = TomlArray::new(); - for value in iter { - array.push(value); - } - TomlItem::Value(array.into()) - } - - fn table_from_pairs<'a, I>(pairs: I) -> TomlItem - where - I: IntoIterator, - { - let mut entries: Vec<_> = pairs.into_iter().collect(); - entries.sort_by(|(a, _), (b, _)| a.cmp(b)); - let mut table = TomlTable::new(); - table.set_implicit(false); - for (key, val) in entries { - table.insert(key, value(val.clone())); - } - TomlItem::Table(table) - } -} - -struct ConfigDocument { - doc: DocumentMut, - profile: Option, -} - -#[derive(Copy, Clone)] -enum Scope { - Global, - Profile, -} - -#[derive(Copy, Clone)] -enum TraversalMode { - Create, - Existing, -} - -impl ConfigDocument { - fn new(doc: DocumentMut, profile: Option) -> Self { - Self { doc, profile } - } - - fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result { - match edit { - ConfigEdit::SetModel { model, effort } => Ok({ - let mut mutated = false; - mutated |= self.write_profile_value( - &["model"], - model.as_ref().map(|model_value| value(model_value.clone())), - ); - mutated |= self.write_profile_value( - &["model_reasoning_effort"], - effort.map(|effort| value(effort.to_string())), - ); - mutated - }), - ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( - &["service_tier"], - service_tier.map(|service_tier| value(service_tier.to_string())), - )), - ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( - &["personality"], - personality.map(|personality| value(personality.to_string())), - )), - ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value( - Scope::Global, - &[NOTICE_TABLE_KEY, "hide_full_access_warning"], - value(*acknowledged), - )), - ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value( - Scope::Global, - &[NOTICE_TABLE_KEY, "hide_world_writable_warning"], - value(*acknowledged), - )), - ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( - Scope::Global, - &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"], - value(*acknowledged), - )), - ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => { - Ok(self.write_value( - Scope::Global, - &[NOTICE_TABLE_KEY, migration_config.as_str()], - value(*acknowledged), - )) - } - ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self - .write_value( - Scope::Global, - &[ - NOTICE_TABLE_KEY, - "external_config_migration_prompts", - "home", - ], - value(*acknowledged), - )), - ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => { - Ok(self.write_value( - Scope::Global, - &[ - NOTICE_TABLE_KEY, - "external_config_migration_prompts", - "home_last_prompted_at", - ], - value(*timestamp), - )) - } - ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( - project, - acknowledged, - ) => Ok(self.write_value( - Scope::Global, - &[ - NOTICE_TABLE_KEY, - "external_config_migration_prompts", - "projects", - project.as_str(), - ], - value(*acknowledged), - )), - ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( - project, - timestamp, - ) => Ok(self.write_value( - Scope::Global, - &[ - NOTICE_TABLE_KEY, - "external_config_migration_prompts", - "project_last_prompted_at", - project.as_str(), - ], - value(*timestamp), - )), - ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value( - Scope::Global, - &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], - value(to.clone()), - )), - ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value( - Scope::Global, - &["windows_wsl_setup_acknowledged"], - value(*acknowledged), - )), - ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)), - ConfigEdit::SetSkillConfig { path, enabled } => { - Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled)) - } - ConfigEdit::SetSkillConfigByName { name, enabled } => { - Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) - } - ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), - ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), - ConfigEdit::SetProjectTrustLevel { path, level } => { - // Delegate to the existing, tested logic in config.rs to - // ensure tables are explicit and migration is preserved. - crate::config::set_project_trust_level_inner( - &mut self.doc, - path.as_path(), - *level, - )?; - Ok(true) - } - } - } - - fn write_profile_value(&mut self, segments: &[&str], value: Option) -> bool { - match value { - Some(item) => self.write_value(Scope::Profile, segments, item), - None => self.clear(Scope::Profile, segments), - } - } - - fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool { - let resolved = self.scoped_segments(scope, segments); - self.insert(&resolved, value) - } - - fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool { - let resolved = self.scoped_segments(scope, segments); - self.remove(&resolved) - } - - fn clear_owned(&mut self, segments: &[String]) -> bool { - self.remove(segments) - } - - fn replace_mcp_servers(&mut self, servers: &BTreeMap) -> bool { - if servers.is_empty() { - return self.clear(Scope::Global, &["mcp_servers"]); - } - - let root = self.doc.as_table_mut(); - if !root.contains_key("mcp_servers") { - root.insert( - "mcp_servers", - TomlItem::Table(document_helpers::new_implicit_table()), - ); - } - - let Some(item) = root.get_mut("mcp_servers") else { - return false; - }; - - if document_helpers::ensure_table_for_write(item).is_none() { - *item = TomlItem::Table(document_helpers::new_implicit_table()); - } - - let Some(table) = item.as_table_mut() else { - return false; - }; - - let keys_to_remove: Vec = table - .iter() - .map(|(key, _)| key.to_string()) - .filter(|key| !servers.contains_key(key.as_str())) - .collect(); - - for key in keys_to_remove { - table.remove(&key); - } - - for (name, config) in servers { - if let Some(existing) = table.get_mut(name.as_str()) { - if let TomlItem::Value(value) = existing - && let Some(inline) = value.as_inline_table_mut() - { - let replacement = document_helpers::serialize_mcp_server_inline(config); - document_helpers::merge_inline_table(inline, replacement); - } else { - *existing = document_helpers::serialize_mcp_server(config); - } - } else { - table.insert(name, document_helpers::serialize_mcp_server(config)); - } - } - - true - } - - fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool { - let selector = match selector { - SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()), - SkillConfigSelector::Path(path) => { - SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path))) - } - }; - if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) { - return false; - } - let mut remove_skills_table = false; - let mut mutated = false; - - { - let root = self.doc.as_table_mut(); - let skills_item = match root.get_mut("skills") { - Some(item) => item, - None => { - if enabled { - return false; - } - root.insert( - "skills", - TomlItem::Table(document_helpers::new_implicit_table()), - ); - let Some(item) = root.get_mut("skills") else { - return false; - }; - item - } - }; - - if document_helpers::ensure_table_for_write(skills_item).is_none() { - if enabled { - return false; - } - *skills_item = TomlItem::Table(document_helpers::new_implicit_table()); - } - let Some(skills_table) = skills_item.as_table_mut() else { - return false; - }; - - let config_item = match skills_table.get_mut("config") { - Some(item) => item, - None => { - if enabled { - return false; - } - skills_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); - let Some(item) = skills_table.get_mut("config") else { - return false; - }; - item - } - }; - - if !matches!(config_item, TomlItem::ArrayOfTables(_)) { - if enabled { - return false; - } - *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new()); - } - - let TomlItem::ArrayOfTables(overrides) = config_item else { - return false; - }; - - let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { - skill_config_selector_from_table(table) - .filter(|value| value == &selector) - .map(|_| idx) - }); - - if enabled { - if let Some(index) = existing_index { - overrides.remove(index); - mutated = true; - if overrides.is_empty() { - skills_table.remove("config"); - if skills_table.is_empty() { - remove_skills_table = true; - } - } - } - } else if let Some(index) = existing_index { - for (idx, table) in overrides.iter_mut().enumerate() { - if idx == index { - write_skill_config_selector(table, &selector); - table["enabled"] = value(false); - mutated = true; - break; - } - } - } else { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - write_skill_config_selector(&mut entry, &selector); - entry["enabled"] = value(false); - overrides.push(entry); - mutated = true; - } - } - - if remove_skills_table { - let root = self.doc.as_table_mut(); - root.remove("skills"); - } - - mutated - } - - fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { - let resolved: Vec = segments - .iter() - .map(|segment| (*segment).to_string()) - .collect(); - - if matches!(scope, Scope::Profile) - && resolved.first().is_none_or(|segment| segment != "profiles") - && let Some(profile) = self.profile.as_deref() - { - let mut scoped = Vec::with_capacity(resolved.len() + 2); - scoped.push("profiles".to_string()); - scoped.push(profile.to_string()); - scoped.extend(resolved); - return scoped; - } - - resolved - } - - fn insert(&mut self, segments: &[String], value: TomlItem) -> bool { - let Some((last, parents)) = segments.split_last() else { - return false; - }; - - let Some(parent) = self.descend(parents, TraversalMode::Create) else { - return false; - }; - - let mut value = value; - if let Some(existing) = parent.get(last) { - Self::preserve_decor(existing, &mut value); - } - parent[last] = value; - true - } - - fn remove(&mut self, segments: &[String]) -> bool { - let Some((last, parents)) = segments.split_last() else { - return false; - }; - - let Some(parent) = self.descend(parents, TraversalMode::Existing) else { - return false; - }; - - parent.remove(last).is_some() - } - - fn descend(&mut self, segments: &[String], mode: TraversalMode) -> Option<&mut TomlTable> { - let mut current = self.doc.as_table_mut(); - - for segment in segments { - match mode { - TraversalMode::Create => { - if !current.contains_key(segment.as_str()) { - current.insert( - segment.as_str(), - TomlItem::Table(document_helpers::new_implicit_table()), - ); - } - - let item = current.get_mut(segment.as_str())?; - current = document_helpers::ensure_table_for_write(item)?; - } - TraversalMode::Existing => { - let item = current.get_mut(segment.as_str())?; - current = document_helpers::ensure_table_for_read(item)?; - } - } - } - - Some(current) - } - - fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) { - match (existing, replacement) { - (TomlItem::Table(existing_table), TomlItem::Table(replacement_table)) => { - replacement_table - .decor_mut() - .clone_from(existing_table.decor()); - for (key, existing_item) in existing_table.iter() { - if let (Some(existing_key), Some(mut replacement_key)) = - (existing_table.key(key), replacement_table.key_mut(key)) - { - replacement_key - .leaf_decor_mut() - .clone_from(existing_key.leaf_decor()); - replacement_key - .dotted_decor_mut() - .clone_from(existing_key.dotted_decor()); - } - if let Some(replacement_item) = replacement_table.get_mut(key) { - Self::preserve_decor(existing_item, replacement_item); - } - } - } - (TomlItem::Value(existing_value), TomlItem::Value(replacement_value)) => { - replacement_value - .decor_mut() - .clone_from(existing_value.decor()); - } - _ => {} - } - } -} - -fn normalize_skill_config_path(path: &Path) -> String { - dunce::canonicalize(path) - .unwrap_or_else(|_| path.to_path_buf()) - .to_string_lossy() - .to_string() -} - -fn skill_config_selector_from_table(table: &TomlTable) -> Option { - let path = table - .get("path") - .and_then(|item| item.as_str()) - .map(Path::new) - .map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path)))); - let name = table - .get("name") - .and_then(|item| item.as_str()) - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(|name| SkillConfigSelector::Name(name.to_string())); - - match (path, name) { - (Some(selector), None) | (None, Some(selector)) => Some(selector), - _ => None, - } -} - -fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) { - match selector { - SkillConfigSelector::Name(name) => { - table.remove("path"); - table["name"] = value(name.clone()); - } - SkillConfigSelector::Path(path) => { - table.remove("name"); - table["path"] = value(path.to_string_lossy().to_string()); - } - } -} - -/// Persist edits using a blocking strategy. -pub fn apply_blocking( - codex_home: &Path, - profile: Option<&str>, - edits: &[ConfigEdit], -) -> anyhow::Result<()> { - if edits.is_empty() { - return Ok(()); - } - - let config_path = codex_home.join(CONFIG_TOML_FILE); - let write_paths = resolve_symlink_write_paths(&config_path)?; - let serialized = match write_paths.read_path { - Some(path) => match std::fs::read_to_string(&path) { - Ok(contents) => contents, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(err) => return Err(err.into()), - }, - None => String::new(), - }; - - let doc = if serialized.is_empty() { - DocumentMut::new() - } else { - serialized.parse::()? - }; - - let profile = profile.map(ToOwned::to_owned).or_else(|| { - doc.get("profile") - .and_then(|item| item.as_str()) - .map(ToOwned::to_owned) - }); - - let mut document = ConfigDocument::new(doc, profile); - let mut mutated = false; - - for edit in edits { - mutated |= document.apply(edit)?; - } - - if !mutated { - return Ok(()); - } - - write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| { - format!( - "failed to persist config.toml at {}", - write_paths.write_path.display() - ) - })?; - - Ok(()) -} - -/// Persist edits asynchronously by offloading the blocking writer. -pub async fn apply( - codex_home: &Path, - profile: Option<&str>, - edits: Vec, -) -> anyhow::Result<()> { - let codex_home = codex_home.to_path_buf(); - let profile = profile.map(ToOwned::to_owned); - task::spawn_blocking(move || apply_blocking(&codex_home, profile.as_deref(), &edits)) - .await - .context("config persistence task panicked")? -} - -/// Fluent builder to batch config edits and apply them atomically. -#[derive(Default)] -pub struct ConfigEditsBuilder { - codex_home: PathBuf, - profile: Option, - edits: Vec, -} - -impl ConfigEditsBuilder { - pub fn new(codex_home: &Path) -> Self { - Self { - codex_home: codex_home.to_path_buf(), - profile: None, - edits: Vec::new(), - } - } - - pub fn with_profile(mut self, profile: Option<&str>) -> Self { - self.profile = profile.map(ToOwned::to_owned); - self - } - - pub fn set_model(mut self, model: Option<&str>, effort: Option) -> Self { - self.edits.push(ConfigEdit::SetModel { - model: model.map(ToOwned::to_owned), - effort, - }); - self - } - - pub fn set_service_tier(mut self, service_tier: Option) -> Self { - self.edits.push(ConfigEdit::SetServiceTier { service_tier }); - self - } - - pub fn set_personality(mut self, personality: Option) -> Self { - self.edits - .push(ConfigEdit::SetModelPersonality { personality }); - self - } - - pub fn set_hide_full_access_warning(mut self, acknowledged: bool) -> Self { - self.edits - .push(ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged)); - self - } - - pub fn set_hide_world_writable_warning(mut self, acknowledged: bool) -> Self { - self.edits - .push(ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged)); - self - } - - pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self { - self.edits - .push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged)); - self - } - - pub fn set_hide_model_migration_prompt(mut self, model: &str, acknowledged: bool) -> Self { - self.edits - .push(ConfigEdit::SetNoticeHideModelMigrationPrompt( - model.to_string(), - acknowledged, - )); - self - } - - pub fn set_hide_external_config_migration_prompt_home(mut self, acknowledged: bool) -> Self { - self.edits - .push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( - acknowledged, - )); - self - } - - pub fn set_hide_external_config_migration_prompt_project( - mut self, - project: &str, - acknowledged: bool, - ) -> Self { - self.edits.push( - ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( - project.to_string(), - acknowledged, - ), - ); - self - } - - pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self { - self.edits.push(ConfigEdit::RecordModelMigrationSeen { - from: from.to_string(), - to: to.to_string(), - }); - self - } - - pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self { - self.edits - .push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged)); - self - } - - pub fn set_model_availability_nux_count(mut self, shown_count: &HashMap) -> Self { - self.edits - .extend(model_availability_nux_count_edits(shown_count)); - self - } - - pub fn replace_mcp_servers(mut self, servers: &BTreeMap) -> Self { - self.edits - .push(ConfigEdit::ReplaceMcpServers(servers.clone())); - self - } - - pub fn set_project_trust_level>( - mut self, - project_path: P, - trust_level: TrustLevel, - ) -> Self { - self.edits.push(ConfigEdit::SetProjectTrustLevel { - path: project_path.into(), - level: trust_level, - }); - self - } - - /// Enable or disable a feature flag by key under the `[features]` table. - /// - /// Disabling a default-false feature clears the root-scoped key instead of - /// persisting `false`, so the config does not pin the feature once it - /// graduates to globally enabled. Profile-scoped disables still persist - /// `false` so they can override an inherited root enable. - pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self { - let profile_scoped = self.profile.is_some(); - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "features".to_string(), - key.to_string(), - ] - } else { - vec!["features".to_string(), key.to_string()] - }; - let is_default_false_feature = FEATURES - .iter() - .find(|spec| spec.key == key) - .is_some_and(|spec| !spec.default_enabled); - if enabled || profile_scoped || !is_default_false_feature { - self.edits.push(ConfigEdit::SetPath { - segments, - value: value(enabled), - }); - } else { - self.edits.push(ConfigEdit::ClearPath { segments }); - } - self - } - - pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self { - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "windows".to_string(), - "sandbox".to_string(), - ] - } else { - vec!["windows".to_string(), "sandbox".to_string()] - }; - self.edits.push(ConfigEdit::SetPath { - segments, - value: value(mode), - }); - self - } - - pub fn set_realtime_microphone(mut self, microphone: Option<&str>) -> Self { - let segments = vec!["audio".to_string(), "microphone".to_string()]; - match microphone { - Some(microphone) => self.edits.push(ConfigEdit::SetPath { - segments, - value: value(microphone), - }), - None => self.edits.push(ConfigEdit::ClearPath { segments }), - } - self - } - - pub fn set_realtime_speaker(mut self, speaker: Option<&str>) -> Self { - let segments = vec!["audio".to_string(), "speaker".to_string()]; - match speaker { - Some(speaker) => self.edits.push(ConfigEdit::SetPath { - segments, - value: value(speaker), - }), - None => self.edits.push(ConfigEdit::ClearPath { segments }), - } - self - } - - pub fn set_realtime_voice(mut self, voice: Option<&str>) -> Self { - let segments = vec!["realtime".to_string(), "voice".to_string()]; - match voice { - Some(voice) => self.edits.push(ConfigEdit::SetPath { - segments, - value: value(voice), - }), - None => self.edits.push(ConfigEdit::ClearPath { segments }), - } - self - } - - pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self { - for key in [ - "experimental_windows_sandbox", - "elevated_windows_sandbox", - "enable_experimental_windows_sandbox", - ] { - let mut segments = vec!["features".to_string(), key.to_string()]; - if let Some(profile) = self.profile.as_ref() { - segments = vec![ - "profiles".to_string(), - profile.clone(), - "features".to_string(), - key.to_string(), - ]; - } - self.edits.push(ConfigEdit::ClearPath { segments }); - } - self - } - - pub fn with_edits(mut self, edits: I) -> Self - where - I: IntoIterator, - { - self.edits.extend(edits); - self - } - - /// Apply edits on a blocking thread. - pub fn apply_blocking(self) -> anyhow::Result<()> { - apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) - } - - /// Apply edits asynchronously via a blocking offload. - pub async fn apply(self) -> anyhow::Result<()> { - task::spawn_blocking(move || { - apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) - }) - .await - .context("config persistence task panicked")? - } -} - -#[cfg(test)] -#[path = "edit_tests.rs"] -mod tests; +pub use codex_config::edit::*; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6320bff2e3..4e104db34e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -13,7 +13,6 @@ use crate::config_loader::McpServerRequirement; use crate::config_loader::ResidencyRequirement; use crate::config_loader::Sourced; use crate::config_loader::load_config_layers_state; -use crate::config_loader::project_trust_key; use crate::memories::memory_root; use crate::path_utils::normalize_for_native_workdir; use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS; @@ -95,7 +94,6 @@ use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_from_profile_network; use codex_network_proxy::NetworkProxyConfig; use toml::Value as TomlValue; -use toml_edit::DocumentMut; pub(crate) mod agent_roles; pub mod edit; @@ -1068,75 +1066,6 @@ fn ensure_no_inline_bearer_tokens(value: &TomlValue) -> std::io::Result<()> { Ok(()) } -pub(crate) fn set_project_trust_level_inner( - doc: &mut DocumentMut, - project_path: &Path, - trust_level: TrustLevel, -) -> anyhow::Result<()> { - // Ensure we render a human-friendly structure: - // - // [projects] - // [projects."/path/to/project"] - // trust_level = "trusted" or "untrusted" - // - // rather than inline tables like: - // - // [projects] - // "/path/to/project" = { trust_level = "trusted" } - let project_key = project_trust_key(project_path); - - // Ensure top-level `projects` exists as a non-inline, explicit table. If it - // exists but was previously represented as a non-table (e.g., inline), - // replace it with an explicit table. - { - let root = doc.as_table_mut(); - // If `projects` exists but isn't a standard table (e.g., it's an inline table), - // convert it to an explicit table while preserving existing entries. - let existing_projects = root.get("projects").cloned(); - if existing_projects.as_ref().is_none_or(|i| !i.is_table()) { - let mut projects_tbl = toml_edit::Table::new(); - projects_tbl.set_implicit(true); - - // If there was an existing inline table, migrate its entries to explicit tables. - if let Some(inline_tbl) = existing_projects.as_ref().and_then(|i| i.as_inline_table()) { - for (k, v) in inline_tbl.iter() { - if let Some(inner_tbl) = v.as_inline_table() { - let new_tbl = inner_tbl.clone().into_table(); - projects_tbl.insert(k, toml_edit::Item::Table(new_tbl)); - } - } - } - - root.insert("projects", toml_edit::Item::Table(projects_tbl)); - } - } - let Some(projects_tbl) = doc["projects"].as_table_mut() else { - return Err(anyhow::anyhow!( - "projects table missing after initialization" - )); - }; - - // Ensure the per-project entry is its own explicit table. If it exists but - // is not a table (e.g., an inline table), replace it with an explicit table. - let needs_proj_table = !projects_tbl.contains_key(project_key.as_str()) - || projects_tbl - .get(project_key.as_str()) - .and_then(|i| i.as_table()) - .is_none(); - if needs_proj_table { - projects_tbl.insert(project_key.as_str(), toml_edit::table()); - } - let Some(proj_tbl) = projects_tbl - .get_mut(project_key.as_str()) - .and_then(|i| i.as_table_mut()) - else { - return Err(anyhow::anyhow!("project table missing for {project_key}")); - }; - proj_tbl.set_implicit(false); - proj_tbl["trust_level"] = toml_edit::value(trust_level.to_string()); - Ok(()) -} - /// Patch `CODEX_HOME/config.toml` project state to set trust level. /// Use with caution. pub fn set_project_trust_level( @@ -2441,5 +2370,5 @@ pub fn log_dir(cfg: &Config) -> std::io::Result { } #[cfg(test)] -#[path = "config_tests.rs"] +#[path = "../../tests/unit/config/config_tests.rs"] mod tests; diff --git a/codex-rs/core/src/config_loader.rs b/codex-rs/core/src/config_loader.rs new file mode 100644 index 0000000000..34b53de942 --- /dev/null +++ b/codex-rs/core/src/config_loader.rs @@ -0,0 +1,5 @@ +pub use codex_config_loader::*; + +#[cfg(test)] +#[path = "config_loader/tests.rs"] +mod tests; diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md deleted file mode 100644 index 44a514a10a..0000000000 --- a/codex-rs/core/src/config_loader/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# `codex-core` config loader - -This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce: - -- An **effective merged** TOML config. -- **Per-key origins** metadata (which layer “wins” for a given key). -- **Per-layer versions** (stable fingerprints) used for optimistic concurrency / conflict detection. - -## Public surface - -Exported from `codex_core::config_loader`: - -- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements) -> ConfigLayerStack` -- `ConfigLayerStack` - - `effective_config() -> toml::Value` - - `origins() -> HashMap` - - `layers_high_to_low() -> Vec` - - `with_user_config(user_config) -> ConfigLayerStack` -- `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata) -- `LoaderOverrides` (test/override hooks for managed config sources) -- `merge_toml_values(base, overlay)` (public helper used elsewhere) - -## Layering model - -Precedence is **top overrides bottom**: - -1. **MDM** managed preferences (macOS only) -2. **System** managed config (e.g. `managed_config.toml`) -3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) -4. **User** config (`config.toml`) - -Layers with a `disabled_reason` are still surfaced for UI, but are ignored when -computing the effective config and origins metadata. This is what -`ConfigLayerStack::effective_config()` implements. - -## Typical usage - -Most callers want the effective config plus metadata: - -```rust -use codex_core::config_loader::{ - CloudRequirementsLoader, LoaderOverrides, load_config_layers_state, -}; -use codex_exec_server::LOCAL_FS; -use codex_utils_absolute_path::AbsolutePathBuf; -use toml::Value as TomlValue; - -let cli_overrides: Vec<(String, TomlValue)> = Vec::new(); -let cwd = AbsolutePathBuf::current_dir()?; -let layers = load_config_layers_state( - LOCAL_FS.as_ref(), - &codex_home, - Some(cwd), - &cli_overrides, - LoaderOverrides::default(), - CloudRequirementsLoader::default(), -).await?; - -let effective = layers.effective_config(); -let origins = layers.origins(); -let layers_for_ui = layers.layers_high_to_low(); -``` - -## Internal layout - -Implementation is split by concern: - -- `state.rs`: public types (`ConfigLayerEntry`, `ConfigLayerStack`) + merge/origins convenience methods. -- `layer_io.rs`: reading `config.toml`, managed config, and managed preferences inputs. -- `overrides.rs`: CLI dotted-path overrides → TOML “session flags” layer. -- `merge.rs`: recursive TOML merge. -- `fingerprint.rs`: stable per-layer hashing and per-key origins traversal. -- `macos.rs`: managed preferences integration (macOS only). diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index db2c6b58b1..1551b60558 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -724,5 +724,5 @@ fn user_message_positions(items: &[ResponseItem]) -> Vec { } #[cfg(test)] -#[path = "history_tests.rs"] +#[path = "../../tests/unit/context_manager/history_tests.rs"] mod tests; diff --git a/codex-rs/core/src/core_unit_tests.rs b/codex-rs/core/src/core_unit_tests.rs new file mode 100644 index 0000000000..1aff48e518 --- /dev/null +++ b/codex-rs/core/src/core_unit_tests.rs @@ -0,0 +1,13 @@ +mod tools_spec { + pub(crate) use crate::tools::registry::ToolRegistryBuilder; + pub(crate) use crate::tools::spec::build_specs_with_discoverable_tools; + pub(crate) use crate::tools::spec::tool_user_shell_type; + pub(crate) use codex_mcp::ToolInfo; + pub(crate) use codex_protocol::dynamic_tools::DynamicToolSpec; + + mod tests { + use std::collections::HashMap; + + include!("../tests/unit/tools/spec_tests.rs"); + } +} diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 8f0f076f08..f2945e4480 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -890,5 +890,5 @@ async fn collect_policy_files(dir: impl AsRef) -> Result, Exe } #[cfg(test)] -#[path = "exec_policy_tests.rs"] +#[path = "../tests/unit/exec_policy_tests.rs"] mod tests; diff --git a/codex-rs/core/src/file_watcher.rs b/codex-rs/core/src/file_watcher.rs index bfa01f8c9e..1e4efe0b5f 100644 --- a/codex-rs/core/src/file_watcher.rs +++ b/codex-rs/core/src/file_watcher.rs @@ -1,588 +1,9 @@ -//! Watches subscribed files or directories and routes coarse-grained change -//! notifications to the subscribers that own matching watched paths. +//! Compatibility re-exports for the generic file watcher crate. -use std::collections::BTreeSet; -use std::collections::HashMap; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::RwLock; -use std::sync::atomic::AtomicUsize; -use std::sync::atomic::Ordering; -use std::time::Duration; - -use notify::Event; -use notify::EventKind; -use notify::RecommendedWatcher; -use notify::RecursiveMode; -use notify::Watcher; -use tokio::runtime::Handle; -use tokio::sync::Mutex as AsyncMutex; -use tokio::sync::Notify; -use tokio::sync::mpsc; -use tokio::time::Instant; -use tokio::time::sleep_until; -use tracing::warn; - -#[derive(Debug, Clone, PartialEq, Eq)] -/// Coalesced file change notification for a subscriber. -pub struct FileWatcherEvent { - /// Changed paths delivered in sorted order with duplicates removed. - pub paths: Vec, -} - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -/// Path subscription registered by a [`FileWatcherSubscriber`]. -pub struct WatchPath { - /// Root path to watch. - pub path: PathBuf, - /// Whether events below `path` should match recursively. - pub recursive: bool, -} - -type SubscriberId = u64; - -#[derive(Default)] -struct WatchState { - next_subscriber_id: SubscriberId, - path_ref_counts: HashMap, - subscribers: HashMap, -} - -struct SubscriberState { - watched_paths: HashMap, - tx: WatchSender, -} - -/// Receives coalesced change notifications for a single subscriber. -pub struct Receiver { - inner: Arc, -} - -struct WatchSender { - inner: Arc, -} - -struct ReceiverInner { - changed_paths: AsyncMutex>, - notify: Notify, - sender_count: AtomicUsize, -} - -impl Receiver { - /// Waits for the next batch of changed paths, or returns `None` once the - /// corresponding subscriber has been removed and no more events can arrive. - pub async fn recv(&mut self) -> Option { - loop { - let notified = self.inner.notify.notified(); - { - let mut changed_paths = self.inner.changed_paths.lock().await; - if !changed_paths.is_empty() { - return Some(FileWatcherEvent { - paths: std::mem::take(&mut *changed_paths).into_iter().collect(), - }); - } - if self.inner.sender_count.load(Ordering::Acquire) == 0 { - return None; - } - } - notified.await; - } - } -} - -impl WatchSender { - async fn add_changed_paths(&self, paths: &[PathBuf]) { - if paths.is_empty() { - return; - } - - let mut changed_paths = self.inner.changed_paths.lock().await; - let previous_len = changed_paths.len(); - changed_paths.extend(paths.iter().cloned()); - if changed_paths.len() != previous_len { - self.inner.notify.notify_one(); - } - } -} - -impl Clone for WatchSender { - fn clone(&self) -> Self { - self.inner.sender_count.fetch_add(1, Ordering::Relaxed); - Self { - inner: Arc::clone(&self.inner), - } - } -} - -impl Drop for WatchSender { - fn drop(&mut self) { - if self.inner.sender_count.fetch_sub(1, Ordering::AcqRel) == 1 { - self.inner.notify.notify_waiters(); - } - } -} - -fn watch_channel() -> (WatchSender, Receiver) { - let inner = Arc::new(ReceiverInner { - changed_paths: AsyncMutex::new(BTreeSet::new()), - notify: Notify::new(), - sender_count: AtomicUsize::new(1), - }); - ( - WatchSender { - inner: Arc::clone(&inner), - }, - Receiver { inner }, - ) -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -struct PathWatchCounts { - non_recursive: usize, - recursive: usize, -} - -impl PathWatchCounts { - fn increment(&mut self, recursive: bool, amount: usize) { - if recursive { - self.recursive += amount; - } else { - self.non_recursive += amount; - } - } - - fn decrement(&mut self, recursive: bool, amount: usize) { - if recursive { - self.recursive = self.recursive.saturating_sub(amount); - } else { - self.non_recursive = self.non_recursive.saturating_sub(amount); - } - } - - fn effective_mode(self) -> Option { - if self.recursive > 0 { - Some(RecursiveMode::Recursive) - } else if self.non_recursive > 0 { - Some(RecursiveMode::NonRecursive) - } else { - None - } - } - - fn is_empty(self) -> bool { - self.non_recursive == 0 && self.recursive == 0 - } -} - -struct FileWatcherInner { - watcher: RecommendedWatcher, - watched_paths: HashMap, -} - -/// Coalesces bursts of watch notifications and emits at most once per interval. -pub struct ThrottledWatchReceiver { - rx: Receiver, - interval: Duration, - next_allowed: Option, -} - -impl ThrottledWatchReceiver { - /// Creates a throttling wrapper around a raw watcher [`Receiver`]. - pub fn new(rx: Receiver, interval: Duration) -> Self { - Self { - rx, - interval, - next_allowed: None, - } - } - - /// Receives the next event, enforcing the configured minimum delay after - /// the previous emission. - pub async fn recv(&mut self) -> Option { - if let Some(next_allowed) = self.next_allowed { - sleep_until(next_allowed).await; - } - - let event = self.rx.recv().await; - if event.is_some() { - self.next_allowed = Some(Instant::now() + self.interval); - } - event - } -} - -/// Handle used to register watched paths for one logical consumer. -pub struct FileWatcherSubscriber { - id: SubscriberId, - file_watcher: Arc, -} - -impl FileWatcherSubscriber { - /// Registers the provided paths for this subscriber and returns an RAII - /// guard that unregisters them on drop. - pub fn register_paths(&self, watched_paths: Vec) -> WatchRegistration { - let watched_paths = dedupe_watched_paths(watched_paths); - self.file_watcher.register_paths(self.id, &watched_paths); - - WatchRegistration { - file_watcher: Arc::downgrade(&self.file_watcher), - subscriber_id: self.id, - watched_paths, - } - } - - #[cfg(test)] - pub(crate) fn register_path(&self, path: PathBuf, recursive: bool) -> WatchRegistration { - self.register_paths(vec![WatchPath { path, recursive }]) - } -} - -impl Drop for FileWatcherSubscriber { - fn drop(&mut self) { - self.file_watcher.remove_subscriber(self.id); - } -} - -/// RAII guard for a set of active path registrations. -pub struct WatchRegistration { - file_watcher: std::sync::Weak, - subscriber_id: SubscriberId, - watched_paths: Vec, -} - -impl Default for WatchRegistration { - fn default() -> Self { - Self { - file_watcher: std::sync::Weak::new(), - subscriber_id: 0, - watched_paths: Vec::new(), - } - } -} - -impl Drop for WatchRegistration { - fn drop(&mut self) { - if let Some(file_watcher) = self.file_watcher.upgrade() { - file_watcher.unregister_paths(self.subscriber_id, &self.watched_paths); - } - } -} - -/// Multi-subscriber file watcher built on top of `notify`. -pub struct FileWatcher { - inner: Option>, - state: Arc>, -} - -impl FileWatcher { - /// Creates a live filesystem watcher and starts its background event loop - /// on the current Tokio runtime. - pub fn new() -> notify::Result { - let (raw_tx, raw_rx) = mpsc::unbounded_channel(); - let raw_tx_clone = raw_tx; - let watcher = notify::recommended_watcher(move |res| { - let _ = raw_tx_clone.send(res); - })?; - let inner = FileWatcherInner { - watcher, - watched_paths: HashMap::new(), - }; - let state = Arc::new(RwLock::new(WatchState::default())); - let file_watcher = Self { - inner: Some(Mutex::new(inner)), - state, - }; - file_watcher.spawn_event_loop(raw_rx); - Ok(file_watcher) - } - - /// Creates an inert watcher that only supports test-driven synthetic - /// notifications. - pub fn noop() -> Self { - Self { - inner: None, - state: Arc::new(RwLock::new(WatchState::default())), - } - } - - /// Adds a new subscriber and returns both its registration handle and its - /// dedicated event receiver. - pub fn add_subscriber(self: &Arc) -> (FileWatcherSubscriber, Receiver) { - let (tx, rx) = watch_channel(); - let mut state = self - .state - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let subscriber_id = state.next_subscriber_id; - state.next_subscriber_id += 1; - state.subscribers.insert( - subscriber_id, - SubscriberState { - watched_paths: HashMap::new(), - tx, - }, - ); - - let subscriber = FileWatcherSubscriber { - id: subscriber_id, - file_watcher: self.clone(), - }; - (subscriber, rx) - } - - fn register_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) { - let mut state = self - .state - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut inner_guard: Option> = None; - - for watched_path in watched_paths { - { - let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else { - return; - }; - *subscriber - .watched_paths - .entry(watched_path.clone()) - .or_default() += 1; - } - - let counts = state - .path_ref_counts - .entry(watched_path.path.clone()) - .or_default(); - let previous_mode = counts.effective_mode(); - counts.increment(watched_path.recursive, /*amount*/ 1); - let next_mode = counts.effective_mode(); - if previous_mode != next_mode { - self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard); - } - } - } - - // Bridge `notify`'s callback-based events into the Tokio runtime and - // notify the matching subscribers. - fn spawn_event_loop(&self, mut raw_rx: mpsc::UnboundedReceiver>) { - if let Ok(handle) = Handle::try_current() { - let state = Arc::clone(&self.state); - handle.spawn(async move { - loop { - match raw_rx.recv().await { - Some(Ok(event)) => { - if !is_mutating_event(&event) { - continue; - } - if event.paths.is_empty() { - continue; - } - Self::notify_subscribers(&state, &event.paths).await; - } - Some(Err(err)) => { - warn!("file watcher error: {err}"); - } - None => break, - } - } - }); - } else { - warn!("file watcher loop skipped: no Tokio runtime available"); - } - } - - fn unregister_paths(&self, subscriber_id: SubscriberId, watched_paths: &[WatchPath]) { - let mut state = self - .state - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut inner_guard: Option> = None; - - for watched_path in watched_paths { - { - let Some(subscriber) = state.subscribers.get_mut(&subscriber_id) else { - return; - }; - let Some(subscriber_count) = subscriber.watched_paths.get_mut(watched_path) else { - continue; - }; - *subscriber_count = subscriber_count.saturating_sub(1); - if *subscriber_count == 0 { - subscriber.watched_paths.remove(watched_path); - } - } - let Some(counts) = state.path_ref_counts.get_mut(&watched_path.path) else { - continue; - }; - let previous_mode = counts.effective_mode(); - counts.decrement(watched_path.recursive, /*amount*/ 1); - let next_mode = counts.effective_mode(); - if counts.is_empty() { - state.path_ref_counts.remove(&watched_path.path); - } - if previous_mode != next_mode { - self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard); - } - } - } - - fn remove_subscriber(&self, subscriber_id: SubscriberId) { - let mut state = self - .state - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let Some(subscriber) = state.subscribers.remove(&subscriber_id) else { - return; - }; - - let mut inner_guard: Option> = None; - for (watched_path, count) in subscriber.watched_paths { - let Some(path_counts) = state.path_ref_counts.get_mut(&watched_path.path) else { - continue; - }; - let previous_mode = path_counts.effective_mode(); - path_counts.decrement(watched_path.recursive, count); - let next_mode = path_counts.effective_mode(); - if path_counts.is_empty() { - state.path_ref_counts.remove(&watched_path.path); - } - if previous_mode != next_mode { - self.reconfigure_watch(&watched_path.path, next_mode, &mut inner_guard); - } - } - } - - fn reconfigure_watch<'a>( - &'a self, - path: &Path, - next_mode: Option, - inner_guard: &mut Option>, - ) { - let Some(inner) = &self.inner else { - return; - }; - if inner_guard.is_none() { - let guard = inner - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - *inner_guard = Some(guard); - } - let Some(guard) = inner_guard.as_mut() else { - return; - }; - - let existing_mode = guard.watched_paths.get(path).copied(); - if existing_mode == next_mode { - return; - } - - if existing_mode.is_some() { - if let Err(err) = guard.watcher.unwatch(path) { - warn!("failed to unwatch {}: {err}", path.display()); - } - guard.watched_paths.remove(path); - } - - let Some(next_mode) = next_mode else { - return; - }; - if !path.exists() { - return; - } - - if let Err(err) = guard.watcher.watch(path, next_mode) { - warn!("failed to watch {}: {err}", path.display()); - return; - } - guard.watched_paths.insert(path.to_path_buf(), next_mode); - } - - async fn notify_subscribers(state: &RwLock, event_paths: &[PathBuf]) { - let subscribers_to_notify: Vec<(WatchSender, Vec)> = { - let state = state - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - state - .subscribers - .values() - .filter_map(|subscriber| { - let changed_paths: Vec = event_paths - .iter() - .filter(|event_path| { - subscriber.watched_paths.keys().any(|watched_path| { - watch_path_matches_event(watched_path, event_path) - }) - }) - .cloned() - .collect(); - (!changed_paths.is_empty()).then_some((subscriber.tx.clone(), changed_paths)) - }) - .collect() - }; - - for (subscriber, changed_paths) in subscribers_to_notify { - subscriber.add_changed_paths(&changed_paths).await; - } - } - - #[cfg(test)] - pub(crate) async fn send_paths_for_test(&self, paths: Vec) { - Self::notify_subscribers(&self.state, &paths).await; - } - - #[cfg(test)] - pub(crate) fn spawn_event_loop_for_test( - &self, - raw_rx: mpsc::UnboundedReceiver>, - ) { - self.spawn_event_loop(raw_rx); - } - - #[cfg(test)] - pub(crate) fn watch_counts_for_test(&self, path: &Path) -> Option<(usize, usize)> { - let state = self - .state - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - state - .path_ref_counts - .get(path) - .map(|counts| (counts.non_recursive, counts.recursive)) - } -} - -fn is_mutating_event(event: &Event) -> bool { - matches!( - event.kind, - EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) - ) -} - -fn dedupe_watched_paths(mut watched_paths: Vec) -> Vec { - watched_paths.sort_unstable_by(|a, b| { - a.path - .as_os_str() - .cmp(b.path.as_os_str()) - .then(a.recursive.cmp(&b.recursive)) - }); - watched_paths.dedup(); - watched_paths -} - -fn watch_path_matches_event(watched_path: &WatchPath, event_path: &Path) -> bool { - if event_path == watched_path.path { - return true; - } - if watched_path.path.starts_with(event_path) { - return true; - } - if !event_path.starts_with(&watched_path.path) { - return false; - } - watched_path.recursive || event_path.parent() == Some(watched_path.path.as_path()) -} - -#[cfg(test)] -#[path = "file_watcher_tests.rs"] -mod tests; +pub use codex_file_watcher::FileWatcher; +pub use codex_file_watcher::FileWatcherEvent; +pub use codex_file_watcher::FileWatcherSubscriber; +pub use codex_file_watcher::Receiver; +pub use codex_file_watcher::ThrottledWatchReceiver; +pub use codex_file_watcher::WatchPath; +pub use codex_file_watcher::WatchRegistration; diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 4fa150a232..44abf388a0 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -106,4 +106,5 @@ use review::run_guardian_review_session as run_guardian_review_session_for_test; use review_session::build_guardian_review_session_config as build_guardian_review_session_config_for_test; #[cfg(test)] +#[path = "../../tests/unit/guardian/tests.rs"] mod tests; diff --git a/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_followup_review_request_layout.snap b/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_followup_review_request_layout.snap index 7bbc4cb147..681467444f 100644 --- a/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_followup_review_request_layout.snap +++ b/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_followup_review_request_layout.snap @@ -1,6 +1,6 @@ --- -source: core/src/guardian/tests.rs -expression: "format!(\"{}\\n\\nshared_prompt_cache_key: {}\\nfollowup_contains_first_rationale: {}\",\ncontext_snapshot::format_labeled_requests_snapshot(\"Guardian follow-up review request layout\",\n&[(\"Initial Guardian Review Request\", &requests[0]),\n(\"Follow-up Guardian Review Request\", &requests[1]),],\n&guardian_snapshot_options(),), first_body[\"prompt_cache_key\"] ==\nsecond_body[\"prompt_cache_key\"],\nsecond_body.to_string().contains(first_rationale),)" +source: core/tests/unit/guardian/tests.rs +expression: snapshot --- Scenario: Guardian follow-up review request layout diff --git a/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_review_request_layout.snap b/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_review_request_layout.snap index f8dc40b274..6ec8014030 100644 --- a/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_review_request_layout.snap +++ b/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_review_request_layout.snap @@ -1,6 +1,6 @@ --- -source: core/src/guardian/tests.rs -expression: "context_snapshot::format_labeled_requests_snapshot(\"Guardian review request layout\",\n&[(\"Guardian Review Request\", &request)], &guardian_snapshot_options(),)" +source: core/tests/unit/guardian/tests.rs +expression: snapshot --- Scenario: Guardian review request layout diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1fef33c3f3..d30d7eb825 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -201,3 +201,6 @@ pub(crate) mod memory_trace; pub use memory_trace::BuiltMemory; pub use memory_trace::build_memories_from_trace_files; pub mod otel_init; + +#[cfg(test)] +mod core_unit_tests; diff --git a/codex-rs/core/src/mcp_tool_approval_templates.rs b/codex-rs/core/src/mcp_tool_approval_templates.rs index 66002ab5ab..3514094061 100644 --- a/codex-rs/core/src/mcp_tool_approval_templates.rs +++ b/codex-rs/core/src/mcp_tool_approval_templates.rs @@ -1,371 +1,4 @@ -use std::collections::HashSet; -use std::sync::LazyLock; +//! Compatibility re-exports for MCP approval template rendering. -use serde::Deserialize; -use serde::Serialize; -use serde_json::Map; -use serde_json::Value; -use tracing::warn; - -const CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION: u8 = 4; -const CONNECTOR_NAME_TEMPLATE_VAR: &str = "{connector_name}"; - -static CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES: LazyLock< - Option>, -> = LazyLock::new(load_consequential_tool_message_templates); - -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct RenderedMcpToolApprovalTemplate { - pub(crate) question: String, - pub(crate) elicitation_message: String, - pub(crate) tool_params: Option, - pub(crate) tool_params_display: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -pub(crate) struct RenderedMcpToolApprovalParam { - pub(crate) name: String, - pub(crate) value: Value, - pub(crate) display_name: String, -} - -#[derive(Debug, Deserialize)] -struct ConsequentialToolMessageTemplatesFile { - schema_version: u8, - templates: Vec, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] -struct ConsequentialToolMessageTemplate { - connector_id: String, - server_name: String, - tool_title: String, - template: String, - template_params: Vec, -} - -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] -struct ConsequentialToolTemplateParam { - name: String, - label: String, -} - -pub(crate) fn render_mcp_tool_approval_template( - server_name: &str, - connector_id: Option<&str>, - connector_name: Option<&str>, - tool_title: Option<&str>, - tool_params: Option<&Value>, -) -> Option { - let templates = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES.as_ref()?; - render_mcp_tool_approval_template_from_templates( - templates, - server_name, - connector_id, - connector_name, - tool_title, - tool_params, - ) -} - -fn load_consequential_tool_message_templates() -> Option> { - let templates = match serde_json::from_str::( - include_str!("consequential_tool_message_templates.json"), - ) { - Ok(templates) => templates, - Err(err) => { - warn!(error = %err, "failed to parse consequential tool approval templates"); - return None; - } - }; - - if templates.schema_version != CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION { - warn!( - found_schema_version = templates.schema_version, - expected_schema_version = CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES_SCHEMA_VERSION, - "unexpected consequential tool approval templates schema version" - ); - return None; - } - - Some(templates.templates) -} - -fn render_mcp_tool_approval_template_from_templates( - templates: &[ConsequentialToolMessageTemplate], - server_name: &str, - connector_id: Option<&str>, - connector_name: Option<&str>, - tool_title: Option<&str>, - tool_params: Option<&Value>, -) -> Option { - let connector_id = connector_id?; - let tool_title = tool_title.map(str::trim).filter(|name| !name.is_empty())?; - let template = templates.iter().find(|template| { - template.server_name == server_name - && template.connector_id == connector_id - && template.tool_title == tool_title - })?; - let elicitation_message = render_question_template(&template.template, connector_name)?; - let (tool_params, tool_params_display) = match tool_params { - Some(Value::Object(tool_params)) => { - render_tool_params(tool_params, &template.template_params)? - } - Some(_) => return None, - None => (None, Vec::new()), - }; - - Some(RenderedMcpToolApprovalTemplate { - question: elicitation_message.clone(), - elicitation_message, - tool_params, - tool_params_display, - }) -} - -fn render_question_template(template: &str, connector_name: Option<&str>) -> Option { - let template = template.trim(); - if template.is_empty() { - return None; - } - - if template.contains(CONNECTOR_NAME_TEMPLATE_VAR) { - let connector_name = connector_name - .map(str::trim) - .filter(|name| !name.is_empty())?; - return Some(template.replace(CONNECTOR_NAME_TEMPLATE_VAR, connector_name)); - } - - Some(template.to_string()) -} - -fn render_tool_params( - tool_params: &Map, - template_params: &[ConsequentialToolTemplateParam], -) -> Option<(Option, Vec)> { - let mut display_params = Vec::new(); - let mut display_names = HashSet::new(); - let mut handled_names = HashSet::new(); - - for template_param in template_params { - let label = template_param.label.trim(); - if label.is_empty() { - return None; - } - let Some(value) = tool_params.get(&template_param.name) else { - continue; - }; - if !display_names.insert(label.to_string()) { - return None; - } - display_params.push(RenderedMcpToolApprovalParam { - name: template_param.name.clone(), - value: value.clone(), - display_name: label.to_string(), - }); - handled_names.insert(template_param.name.as_str()); - } - - let mut remaining_params = tool_params - .iter() - .filter(|(name, _)| !handled_names.contains(name.as_str())) - .collect::>(); - remaining_params.sort_by(|(left_name, _), (right_name, _)| left_name.cmp(right_name)); - - for (name, value) in remaining_params { - if handled_names.contains(name.as_str()) { - continue; - } - if !display_names.insert(name.clone()) { - return None; - } - display_params.push(RenderedMcpToolApprovalParam { - name: name.clone(), - value: value.clone(), - display_name: name.clone(), - }); - } - - Some((Some(Value::Object(tool_params.clone())), display_params)) -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - use serde_json::json; - - use super::*; - - #[test] - fn renders_exact_match_with_readable_param_labels() { - let templates = vec![ConsequentialToolMessageTemplate { - connector_id: "calendar".to_string(), - server_name: "codex_apps".to_string(), - tool_title: "create_event".to_string(), - template: "Allow {connector_name} to create an event?".to_string(), - template_params: vec![ - ConsequentialToolTemplateParam { - name: "calendar_id".to_string(), - label: "Calendar".to_string(), - }, - ConsequentialToolTemplateParam { - name: "title".to_string(), - label: "Title".to_string(), - }, - ], - }]; - - let rendered = render_mcp_tool_approval_template_from_templates( - &templates, - "codex_apps", - Some("calendar"), - Some("Calendar"), - Some("create_event"), - Some(&json!({ - "title": "Roadmap review", - "calendar_id": "primary", - "timezone": "UTC", - })), - ); - - assert_eq!( - rendered, - Some(RenderedMcpToolApprovalTemplate { - question: "Allow Calendar to create an event?".to_string(), - elicitation_message: "Allow Calendar to create an event?".to_string(), - tool_params: Some(json!({ - "title": "Roadmap review", - "calendar_id": "primary", - "timezone": "UTC", - })), - tool_params_display: vec![ - RenderedMcpToolApprovalParam { - name: "calendar_id".to_string(), - value: json!("primary"), - display_name: "Calendar".to_string(), - }, - RenderedMcpToolApprovalParam { - name: "title".to_string(), - value: json!("Roadmap review"), - display_name: "Title".to_string(), - }, - RenderedMcpToolApprovalParam { - name: "timezone".to_string(), - value: json!("UTC"), - display_name: "timezone".to_string(), - }, - ], - }) - ); - } - - #[test] - fn returns_none_when_no_exact_match_exists() { - let templates = vec![ConsequentialToolMessageTemplate { - connector_id: "calendar".to_string(), - server_name: "codex_apps".to_string(), - tool_title: "create_event".to_string(), - template: "Allow {connector_name} to create an event?".to_string(), - template_params: Vec::new(), - }]; - - assert_eq!( - render_mcp_tool_approval_template_from_templates( - &templates, - "codex_apps", - Some("calendar"), - Some("Calendar"), - Some("delete_event"), - Some(&json!({})), - ), - None - ); - } - - #[test] - fn returns_none_when_relabeling_would_collide() { - let templates = vec![ConsequentialToolMessageTemplate { - connector_id: "calendar".to_string(), - server_name: "codex_apps".to_string(), - tool_title: "create_event".to_string(), - template: "Allow {connector_name} to create an event?".to_string(), - template_params: vec![ConsequentialToolTemplateParam { - name: "calendar_id".to_string(), - label: "timezone".to_string(), - }], - }]; - - assert_eq!( - render_mcp_tool_approval_template_from_templates( - &templates, - "codex_apps", - Some("calendar"), - Some("Calendar"), - Some("create_event"), - Some(&json!({ - "calendar_id": "primary", - "timezone": "UTC", - })), - ), - None - ); - } - - #[test] - fn bundled_templates_load() { - assert_eq!(CONSEQUENTIAL_TOOL_MESSAGE_TEMPLATES.is_some(), true); - } - - #[test] - fn renders_literal_template_without_connector_substitution() { - let templates = vec![ConsequentialToolMessageTemplate { - connector_id: "github".to_string(), - server_name: "codex_apps".to_string(), - tool_title: "add_comment".to_string(), - template: "Allow GitHub to add a comment to a pull request?".to_string(), - template_params: Vec::new(), - }]; - - let rendered = render_mcp_tool_approval_template_from_templates( - &templates, - "codex_apps", - Some("github"), - /*connector_name*/ None, - Some("add_comment"), - Some(&json!({})), - ); - - assert_eq!( - rendered, - Some(RenderedMcpToolApprovalTemplate { - question: "Allow GitHub to add a comment to a pull request?".to_string(), - elicitation_message: "Allow GitHub to add a comment to a pull request?".to_string(), - tool_params: Some(json!({})), - tool_params_display: Vec::new(), - }) - ); - } - - #[test] - fn returns_none_when_connector_placeholder_has_no_value() { - let templates = vec![ConsequentialToolMessageTemplate { - connector_id: "calendar".to_string(), - server_name: "codex_apps".to_string(), - tool_title: "create_event".to_string(), - template: "Allow {connector_name} to create an event?".to_string(), - template_params: Vec::new(), - }]; - - assert_eq!( - render_mcp_tool_approval_template_from_templates( - &templates, - "codex_apps", - Some("calendar"), - /*connector_name*/ None, - Some("create_event"), - Some(&json!({})), - ), - None - ); - } -} +pub(crate) use codex_mcp_tool_approval::RenderedMcpToolApprovalParam; +pub(crate) use codex_mcp_tool_approval::render_mcp_tool_approval_template; diff --git a/codex-rs/core/src/memories/README.md b/codex-rs/core/src/memories/README.md index a1d365435b..8d021bd838 100644 --- a/codex-rs/core/src/memories/README.md +++ b/codex-rs/core/src/memories/README.md @@ -4,7 +4,7 @@ This module runs a startup memory pipeline for eligible sessions. ## Prompt Templates -Memory prompt templates live under `codex-rs/core/templates/memories/`. +Memory prompt templates live under `codex-rs/memory-prompts/src/`. - The undated template files are the canonical latest versions used at runtime: - `stage_one_system.md` diff --git a/codex-rs/core/src/memories/mod.rs b/codex-rs/core/src/memories/mod.rs index d796063d2d..d977f7f5b0 100644 --- a/codex-rs/core/src/memories/mod.rs +++ b/codex-rs/core/src/memories/mod.rs @@ -39,7 +39,7 @@ mod phase_one { /// Default reasoning effort used for phase 1. pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Low; /// Prompt used for phase 1. - pub(super) const PROMPT: &str = include_str!("../../templates/memories/stage_one_system.md"); + pub(super) const PROMPT: &str = codex_memory_prompts::STAGE_ONE_SYSTEM_PROMPT; /// Concurrency cap for startup memory extraction and consolidation scheduling. pub(super) const CONCURRENCY_LIMIT: usize = 8; /// Fallback stage-1 rollout truncation limit (tokens) when model metadata diff --git a/codex-rs/core/src/memories/prompts.rs b/codex-rs/core/src/memories/prompts.rs index fc85917c9b..b4cf2f205d 100644 --- a/codex-rs/core/src/memories/prompts.rs +++ b/codex-rs/core/src/memories/prompts.rs @@ -20,19 +20,19 @@ use tracing::warn; static CONSOLIDATION_PROMPT_TEMPLATE: LazyLock