diff --git a/codex-rs/config/src/key_aliases.rs b/codex-rs/config/src/key_aliases.rs index 8d417e269fb3..07cb44fa6d48 100644 --- a/codex-rs/config/src/key_aliases.rs +++ b/codex-rs/config/src/key_aliases.rs @@ -8,18 +8,11 @@ struct ConfigKeyAlias { canonical_key: &'static str, } -const CONFIG_KEY_ALIASES: &[ConfigKeyAlias] = &[ - ConfigKeyAlias { - table_path: &["memories"], - legacy_key: "no_memories_if_mcp_or_web_search", - canonical_key: "disable_on_external_context", - }, - ConfigKeyAlias { - table_path: &["agents"], - legacy_key: "max_concurrent_threads_per_session", - canonical_key: "max_threads", - }, -]; +const CONFIG_KEY_ALIASES: &[ConfigKeyAlias] = &[ConfigKeyAlias { + table_path: &["memories"], + legacy_key: "no_memories_if_mcp_or_web_search", + canonical_key: "disable_on_external_context", +}]; pub(crate) fn normalize_key_aliases(path: &[String], table: &mut TomlMap) { for alias in CONFIG_KEY_ALIASES { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3fbbfaf6ebcd..5727a4bdcfa4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1310,6 +1310,11 @@ "hide_spawn_agent_metadata": { "type": "boolean" }, + "max_concurrent_threads_per_session": { + "format": "uint", + "minimum": 1.0, + "type": "integer" + }, "usage_hint_enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index d0ea8980bf37..995e4299c641 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7348,6 +7348,7 @@ async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { codex_home.path().join(CONFIG_TOML_FILE), r#"[features.multi_agent_v2] enabled = true +max_concurrent_threads_per_session = 5 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." hide_spawn_agent_metadata = true @@ -7361,6 +7362,8 @@ hide_spawn_agent_metadata = true .await?; assert!(config.features.enabled(Feature::MultiAgentV2)); + assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 5); + assert_eq!(config.agent_max_threads, Some(4)); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -7379,11 +7382,13 @@ async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { r#"profile = "no_hint" [features.multi_agent_v2] +max_concurrent_threads_per_session = 4 usage_hint_enabled = true usage_hint_text = "base hint" hide_spawn_agent_metadata = true [profiles.no_hint.features.multi_agent_v2] +max_concurrent_threads_per_session = 6 usage_hint_enabled = false usage_hint_text = "profile hint" hide_spawn_agent_metadata = false @@ -7396,6 +7401,7 @@ hide_spawn_agent_metadata = false .build() .await?; + assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -7406,6 +7412,80 @@ hide_spawn_agent_metadata = false Ok(()) } +#[tokio::test] +async fn multi_agent_v2_default_session_thread_cap_counts_root() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4); + assert_eq!(config.agent_max_threads, Some(3)); + + Ok(()) +} + +#[tokio::test] +async fn multi_agent_v2_rejects_agents_max_threads() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true + +[agents] +max_threads = 3 +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("agents.max_threads should conflict with multi_agent_v2"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "agents.max_threads cannot be set when multi_agent_v2 is enabled" + ); + + Ok(()) +} + +#[tokio::test] +async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +max_concurrent_threads_per_session = 1 +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 1); + assert_eq!(config.agent_max_threads, Some(0)); + + Ok(()) +} + #[tokio::test] async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9635034dcc98..ba21b2ea10b2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -131,6 +131,7 @@ pub use codex_git_utils::GhostSnapshotConfig; /// the context window. pub(crate) const AGENTS_MD_MAX_BYTES: usize = 32 * 1024; // 32 KiB pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(6); +pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usize = 4; pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1; pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option = None; const LOCAL_DEV_BUILD_VERSION: &str = "0.0.0"; @@ -704,6 +705,7 @@ pub struct Config { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MultiAgentV2Config { + pub max_concurrent_threads_per_session: usize, pub usage_hint_enabled: bool, pub usage_hint_text: Option, pub hide_spawn_agent_metadata: bool, @@ -712,6 +714,8 @@ pub struct MultiAgentV2Config { impl Default for MultiAgentV2Config { fn default() -> Self { Self { + max_concurrent_threads_per_session: + DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION, usage_hint_enabled: true, usage_hint_text: None, hide_spawn_agent_metadata: false, @@ -1579,6 +1583,10 @@ fn resolve_multi_agent_v2_config( let profile = multi_agent_v2_toml_config(config_profile.features.as_ref()); let default = MultiAgentV2Config::default(); + let max_concurrent_threads_per_session = profile + .and_then(|config| config.max_concurrent_threads_per_session) + .or_else(|| base.and_then(|config| config.max_concurrent_threads_per_session)) + .unwrap_or(default.max_concurrent_threads_per_session); let usage_hint_enabled = profile .and_then(|config| config.usage_hint_enabled) .or_else(|| base.and_then(|config| config.usage_hint_enabled)) @@ -1594,6 +1602,7 @@ fn resolve_multi_agent_v2_config( .unwrap_or(default.hide_spawn_agent_metadata); MultiAgentV2Config { + max_concurrent_threads_per_session, usage_hint_enabled, usage_hint_text, hide_spawn_agent_metadata, @@ -2078,17 +2087,35 @@ impl Config { let history = cfg.history.unwrap_or_default(); - let agent_max_threads = cfg - .agents - .as_ref() - .and_then(|agents| agents.max_threads) - .or(DEFAULT_AGENT_MAX_THREADS); - if agent_max_threads == Some(0) { + if multi_agent_v2.max_concurrent_threads_per_session == 0 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "agents.max_threads must be at least 1", + "features.multi_agent_v2.max_concurrent_threads_per_session must be at least 1", )); } + let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); + let agent_max_threads = if features.enabled(Feature::MultiAgentV2) { + if agent_max_threads_from_config.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads cannot be set when multi_agent_v2 is enabled", + )); + } + Some( + multi_agent_v2 + .max_concurrent_threads_per_session + .saturating_sub(1), + ) + } else { + let agent_max_threads = agent_max_threads_from_config.or(DEFAULT_AGENT_MAX_THREADS); + if agent_max_threads == Some(0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_threads must be at least 1", + )); + } + agent_max_threads + }; let agent_max_depth = cfg .agents .as_ref() diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 383d80292c43..24777f62e996 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -194,7 +194,12 @@ impl TurnContext { .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) .with_goal_tools_allowed(self.tools_config.goal_tools) - .with_max_concurrent_threads_per_session(config.agent_max_threads) + .with_max_concurrent_threads_per_session( + config + .features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.max_concurrent_threads_per_session), + ) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, )); @@ -459,7 +464,16 @@ impl Session { .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) .with_goal_tools_allowed(goal_tools_supported) - .with_max_concurrent_threads_per_session(per_turn_config.agent_max_threads) + .with_max_concurrent_threads_per_session( + per_turn_config + .features + .enabled(Feature::MultiAgentV2) + .then_some( + per_turn_config + .multi_agent_v2 + .max_concurrent_threads_per_session, + ), + ) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &per_turn_config.agent_roles, )); diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index adf777fff7c4..bb5b82190a2c 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -534,6 +534,11 @@ async fn build_runner_options( "agent depth limit reached; this session cannot spawn more subagents".to_string(), )); } + if turn.config.agent_max_threads == Some(0) { + return Err(FunctionCallError::RespondToModel( + "agent thread limit reached; this session cannot spawn more subagents".to_string(), + )); + } let max_concurrency = normalize_concurrency(requested_concurrency, turn.config.agent_max_threads); let base_instructions = session.get_base_instructions().await; diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 0db4e4e82ebf..bead1ce03745 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -9,6 +9,9 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub max_concurrent_threads_per_session: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub usage_hint_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub usage_hint_text: Option, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index e410159b7f6d..ca05d72d2d5b 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -396,6 +396,7 @@ fn multi_agent_v2_feature_config_deserializes_table() { r#" [multi_agent_v2] enabled = true +max_concurrent_threads_per_session = 4 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." hide_spawn_agent_metadata = true @@ -411,6 +412,7 @@ hide_spawn_agent_metadata = true features.multi_agent_v2, Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml { enabled: Some(true), + max_concurrent_threads_per_session: Some(4), usage_hint_enabled: Some(false), usage_hint_text: Some("Custom delegation guidance.".to_string()), hide_spawn_agent_metadata: Some(true), @@ -442,6 +444,7 @@ usage_hint_enabled = false features_toml.multi_agent_v2, Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml { enabled: None, + max_concurrent_threads_per_session: None, usage_hint_enabled: Some(false), usage_hint_text: None, hide_spawn_agent_metadata: None, diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index b99a994705dd..207fd94ca223 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -82,7 +82,7 @@ pub enum CodexErr { ContextWindowExceeded, #[error("no thread with id: {0}")] ThreadNotFound(ThreadId), - #[error("agent thread limit reached (max {max_threads})")] + #[error("agent thread limit reached")] AgentLimitReached { max_threads: usize }, #[error("session configured event was not the first event in the stream")] SessionConfiguredNotFirstEvent,