From f8c527e5298f2cd047a12624133b24de1bf3829d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 27 Apr 2026 13:31:56 +0200 Subject: [PATCH] multi_agent_v2: move thread cap into feature config (#19792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `features.multi_agent_v2.max_concurrent_threads_per_session` is meant to be the MultiAgentV2-specific session thread cap: it counts the root thread and all open subagent threads. The previous implementation kept this surface tied to `agents.max_threads`, which made it a global subagent-only cap and allowed the legacy setting to coexist with MultiAgentV2. ## What Changed - Added `max_concurrent_threads_per_session` to `[features.multi_agent_v2]` with default `4`. - Removed the `[agents] max_concurrent_threads_per_session` alias to `agents.max_threads`. - When MultiAgentV2 is enabled, reject `agents.max_threads` and derive the existing internal subagent slot limit as `max_concurrent_threads_per_session - 1`. - Regenerated `core/config.schema.json` and added coverage for the new config semantics. ## Result ``` ➜ codex git:(jif/clean-multi-agent-v2-config) codex -c features.multi_agent_v2.enabled=true -c features.multi_agent_v2.max_concurrent_threads_per_session=3 ╭────────────────────────────────────────────────────╮ │ >_ OpenAI Codex (v0.0.0) │ │ │ │ model: gpt-5.5 xhigh fast /model to change │ │ directory: ~/code/codex │ ╰────────────────────────────────────────────────────╯ Tip: Update Required - This version will no longer be supported starting May 8th. Please upgrade to the latest version (https://github.com/openai/codex/releases/latest) using your preferred package manager. › Can you try to spawn 4 agents • I’ll try to start four lightweight agents at once and report exactly what the runtime accepts. • Spawned Russell [no-apps] (gpt-5.5 xhigh) └ Spawn probe 1: reply briefly that you started, then wait for further instructions. Do not do any repo work. • Spawned Descartes [no-apps] (gpt-5.5 xhigh) └ Spawn probe 2: reply briefly that you started, then wait for further instructions. Do not do any repo work. • Agent spawn failed └ Spawn probe 3: reply briefly that you started, then wait for further instructions. Do not do any repo work. • Agent spawn failed └ Spawn probe 4: reply briefly that you started, then wait for further instructions. Do not do any repo work. • The runtime accepted the first two and rejected the next two with agent thread limit reached. I’m checking whether the two accepted probes have returned cleanly, then I’ll close them if needed. ``` --------- Co-authored-by: Codex --- codex-rs/config/src/key_aliases.rs | 17 ++-- codex-rs/core/config.schema.json | 5 ++ codex-rs/core/src/config/config_tests.rs | 80 +++++++++++++++++++ codex-rs/core/src/config/mod.rs | 41 ++++++++-- codex-rs/core/src/session/turn_context.rs | 18 ++++- .../core/src/tools/handlers/agent_jobs.rs | 5 ++ codex-rs/features/src/feature_configs.rs | 3 + codex-rs/features/src/tests.rs | 3 + codex-rs/protocol/src/error.rs | 2 +- 9 files changed, 152 insertions(+), 22 deletions(-) diff --git a/codex-rs/config/src/key_aliases.rs b/codex-rs/config/src/key_aliases.rs index 8d417e269..07cb44fa6 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 3fbbfaf6e..5727a4bdc 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 d0ea8980b..995e4299c 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 9635034dc..ba21b2ea1 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 383d80292..24777f62e 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 adf777fff..bb5b82190 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 0db4e4e82..bead1ce03 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 e410159b7..ca05d72d2 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 b99a99470..207fd94ca 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,