From 03bb3b12367397e14a8facc2e018d645ff4d8e83 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 16 Jul 2026 08:55:34 +0000 Subject: [PATCH] Unify multi-agent settings under `agents` (#33550) ## What changed - Add `agents.enabled` as a user override for multi-agent tools while keeping an enabled `features.multi_agent_v2` authoritative. - Rename the shared spawned-thread limit to `agents.max_concurrent_threads_per_session`, retain `agents.max_threads` as an alias, and apply the setting to both multi-agent backends. - Add reserved subagent model, reasoning effort, and agent-type settings to the config surface and persist all resolved agent settings in config locks. - Show the resolved `[agents]` values in TUI debug-config output. ## Testing - Cover config loading, legacy alias normalization across merged layers, backend-selection precedence, V2 concurrency, and debug output. GitOrigin-RevId: 2957c954a2e1aef51592768dbbccd56c4bf8f118 --- codex-rs/config/src/config_toml.rs | 21 +++- codex-rs/config/src/key_aliases.rs | 17 ++- codex-rs/config/src/merge_tests.rs | 62 ++++++++++ codex-rs/core/config.schema.json | 33 +++-- codex-rs/core/src/agent/control_tests.rs | 10 +- codex-rs/core/src/config/config_tests.rs | 114 ++++++++++++++---- codex-rs/core/src/config/mod.rs | 94 +++++++++++---- codex-rs/core/src/session/config_lock.rs | 14 +-- codex-rs/core/src/session/mod.rs | 14 +-- codex-rs/core/src/session/turn_context.rs | 8 +- codex-rs/core/src/thread_manager.rs | 3 + .../tools/handlers/multi_agents_v2/spawn.rs | 2 + codex-rs/core/src/tools/spec_plan.rs | 1 + .../tests/suite/model_runtime_selectors.rs | 45 ++++--- codex-rs/thread-manager-sample/src/main.rs | 4 + codex-rs/tui/src/debug_config.rs | 81 +++++++++++++ ...bug_config_output_lists_agents_fields.snap | 15 +++ 17 files changed, 429 insertions(+), 109 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_output_lists_agents_fields.snap diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 492caa1b3493..1831316c021d 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -678,14 +678,23 @@ where #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct AgentsToml { - /// Maximum number of agent threads that can be open concurrently. - /// When unset, no limit is enforced. - #[schemars(range(min = 1))] - pub max_threads: Option, - /// Maximum nesting depth allowed for spawned agent threads. - /// Root sessions start at depth 0. + /// Whether multi-agent tools are enabled. Defaults to true. + /// An enabled `features.multi_agent_v2` setting takes precedence. + pub enabled: Option, + /// Maximum number of spawned agent threads that can be open concurrently per session. + /// When unset, the selected multi-agent backend uses its default. + #[serde(alias = "max_threads")] #[schemars(range(min = 1))] + pub max_concurrent_threads_per_session: Option, + /// Maximum nesting depth for V1 agent threads. Ignored by V2. pub max_depth: Option, + /// Reserved for controlling whether the spawn tool supports agent types. + /// Currently ignored. + pub support_agent_type: Option, + /// Reserved default model for spawned subagents. Currently ignored. + pub default_subagent_model: Option, + /// Reserved default reasoning effort for spawned subagents. Currently ignored. + pub default_subagent_reasoning_effort: Option, /// Default maximum runtime in seconds for agent job workers. #[schemars(range(min = 1))] pub job_max_runtime_seconds: Option, diff --git a/codex-rs/config/src/key_aliases.rs b/codex-rs/config/src/key_aliases.rs index 07cb44fa6d48..c0f2dea391d4 100644 --- a/codex-rs/config/src/key_aliases.rs +++ b/codex-rs/config/src/key_aliases.rs @@ -8,11 +8,18 @@ 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", -}]; +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_threads", + canonical_key: "max_concurrent_threads_per_session", + }, +]; pub(crate) fn normalize_key_aliases(path: &[String], table: &mut TomlMap) { for alias in CONFIG_KEY_ALIASES { diff --git a/codex-rs/config/src/merge_tests.rs b/codex-rs/config/src/merge_tests.rs index f9da6e7ebc14..00cf0c22e725 100644 --- a/codex-rs/config/src/merge_tests.rs +++ b/codex-rs/config/src/merge_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::config_toml::AgentsToml; use crate::config_toml::ConfigToml; use crate::types::MemoriesToml; use pretty_assertions::assert_eq; @@ -99,6 +100,67 @@ disable_on_external_context = true assert_eq!(base, expected); } +#[test] +fn merge_toml_values_normalizes_legacy_agents_key_across_layers() { + let mut base = parse_toml( + r#" +[agents] +max_threads = 4 +"#, + ); + let overlay = parse_toml( + r#" +[agents] +max_concurrent_threads_per_session = 7 +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[agents] +max_concurrent_threads_per_session = 7 +"#, + ); + assert_eq!(base, expected); + + let config: ConfigToml = base.try_into().expect("merged config should deserialize"); + assert_eq!( + config.agents, + Some(AgentsToml { + max_concurrent_threads_per_session: Some(7), + ..Default::default() + }) + ); +} + +#[test] +fn merge_toml_values_normalizes_legacy_agents_key_from_overlay() { + let mut base = parse_toml( + r#" +[agents] +max_concurrent_threads_per_session = 4 +"#, + ); + let overlay = parse_toml( + r#" +[agents] +max_threads = 7 +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[agents] +max_concurrent_threads_per_session = 7 +"#, + ); + assert_eq!(base, expected); +} + #[test] fn merge_toml_values_normalizes_permission_network_domains_before_overlaying() { let mut base = parse_toml( diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8aa084751e24..6d6e1e7bbbe0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -36,6 +36,22 @@ "$ref": "#/definitions/AgentRoleToml" }, "properties": { + "default_subagent_model": { + "description": "Reserved default model for spawned subagents. Currently ignored.", + "type": "string" + }, + "default_subagent_reasoning_effort": { + "allOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + } + ], + "description": "Reserved default reasoning effort for spawned subagents. Currently ignored." + }, + "enabled": { + "description": "Whether multi-agent tools are enabled. Defaults to true. An enabled `features.multi_agent_v2` setting takes precedence.", + "type": "boolean" + }, "interrupt_message": { "description": "Whether to record a model-visible message when an agent turn is interrupted. Defaults to true.", "type": "boolean" @@ -46,17 +62,20 @@ "minimum": 1.0, "type": "integer" }, - "max_depth": { - "description": "Maximum nesting depth allowed for spawned agent threads. Root sessions start at depth 0.", - "format": "int32", + "max_concurrent_threads_per_session": { + "description": "Maximum number of spawned agent threads that can be open concurrently per session. When unset, the selected multi-agent backend uses its default.", + "format": "uint", "minimum": 1.0, "type": "integer" }, - "max_threads": { - "description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.", - "format": "uint", - "minimum": 1.0, + "max_depth": { + "description": "Maximum nesting depth for V1 agent threads. Ignored by V2.", + "format": "int32", "type": "integer" + }, + "support_agent_type": { + "description": "Reserved for controlling whether the spawn tool supports agent types. Currently ignored.", + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index a9d1948bbe35..97016c0bd4b1 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1953,7 +1953,7 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { } #[tokio::test] -async fn spawn_agent_respects_max_threads_limit() { +async fn spawn_agent_respects_legacy_max_threads_alias() { let max_threads = 1usize; let (_home, config) = test_config_with_cli_overrides(vec![( "agents.max_threads".to_string(), @@ -2008,7 +2008,7 @@ async fn spawn_agent_respects_max_threads_limit() { async fn spawn_agent_releases_slot_after_shutdown() { let max_threads = 1usize; let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), + "agents.max_concurrent_threads_per_session".to_string(), TomlValue::Integer(max_threads as i64), )]) .await; @@ -2051,7 +2051,7 @@ async fn spawn_agent_releases_slot_after_shutdown() { async fn spawn_agent_limit_shared_across_clones() { let max_threads = 1usize; let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), + "agents.max_concurrent_threads_per_session".to_string(), TomlValue::Integer(max_threads as i64), )]) .await; @@ -2096,7 +2096,7 @@ async fn spawn_agent_limit_shared_across_clones() { async fn resume_agent_respects_max_threads_limit() { let max_threads = 1usize; let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), + "agents.max_concurrent_threads_per_session".to_string(), TomlValue::Integer(max_threads as i64), )]) .await; @@ -2152,7 +2152,7 @@ async fn resume_agent_respects_max_threads_limit() { async fn resume_agent_releases_slot_after_resume_failure() { let max_threads = 1usize; let (_home, config) = test_config_with_cli_overrides(vec![( - "agents.max_threads".to_string(), + "agents.max_concurrent_threads_per_session".to_string(), TomlValue::Integer(max_threads as i64), )]) .await; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 5f65ef3a1a64..370f1508aca0 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7348,8 +7348,12 @@ async fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result let missing_path = codex_home.path().join("agents").join("researcher.toml"); let cfg = ConfigToml { agents: Some(AgentsToml { - max_threads: None, + enabled: None, + max_concurrent_threads_per_session: None, max_depth: None, + support_agent_type: None, + default_subagent_model: None, + default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( @@ -8269,10 +8273,15 @@ model = "gpt-5-mini" } #[tokio::test] -async fn load_config_resolves_agent_interrupt_message() -> std::io::Result<()> { +async fn load_config_resolves_agent_controls() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { + enabled: Some(false), + max_depth: Some(2), + support_agent_type: Some(true), + default_subagent_model: Some("gpt-5.6-terra".to_string()), + default_subagent_reasoning_effort: Some(ReasoningEffort::High), interrupt_message: Some(false), ..Default::default() }), @@ -8286,18 +8295,60 @@ async fn load_config_resolves_agent_interrupt_message() -> std::io::Result<()> { ) .await?; - assert!(!config.agent_interrupt_message_enabled); + assert_eq!( + ( + config.agents_enabled, + config.agent_max_depth, + config.agent_support_agent_type, + config.agent_default_subagent_model.as_deref(), + config.agent_default_subagent_reasoning_effort, + config.agent_interrupt_message_enabled, + ), + ( + false, + 2, + Some(true), + Some("gpt-5.6-terra"), + Some(ReasoningEffort::High), + false, + ) + ); Ok(()) } +#[test] +fn agents_max_threads_alias_matches_canonical_config() { + let canonical: ConfigToml = toml::from_str( + r#"[agents] +max_concurrent_threads_per_session = 7 +"#, + ) + .expect("canonical agents thread limit should parse"); + let legacy: ConfigToml = toml::from_str( + r#"[agents] +max_threads = 7 +"#, + ) + .expect("legacy agents thread limit should parse"); + + assert_eq!(legacy, canonical); + let serialized = toml::to_string(&legacy).expect("agents config should serialize"); + assert!(serialized.contains("max_concurrent_threads_per_session = 7")); + assert!(!serialized.contains("max_threads")); +} + #[tokio::test] async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { - max_threads: None, + enabled: None, + max_concurrent_threads_per_session: None, max_depth: None, + support_agent_type: None, + default_subagent_model: None, + default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( @@ -8339,8 +8390,12 @@ async fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io:: let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { - max_threads: None, + enabled: None, + max_concurrent_threads_per_session: None, max_depth: None, + support_agent_type: None, + default_subagent_model: None, + default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( @@ -8376,8 +8431,12 @@ async fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std:: let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { - max_threads: None, + enabled: None, + max_concurrent_threads_per_session: None, max_depth: None, + support_agent_type: None, + default_subagent_model: None, + default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( @@ -8413,8 +8472,12 @@ async fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io: let codex_home = TempDir::new()?; let cfg = ConfigToml { agents: Some(AgentsToml { - max_threads: None, + enabled: None, + max_concurrent_threads_per_session: None, max_depth: None, + support_agent_type: None, + default_subagent_model: None, + default_subagent_reasoning_effort: None, job_max_runtime_seconds: None, interrupt_message: None, roles: BTreeMap::from([( @@ -10389,6 +10452,9 @@ tool_namespace = "agents" hide_spawn_agent_metadata = true expose_spawn_agent_model_overrides = false non_code_mode_only = true + +[agents] +max_concurrent_threads_per_session = 9 "#, )?; @@ -10408,7 +10474,7 @@ non_code_mode_only = true config.agent_max_threads, config.effective_agent_max_threads(MultiAgentVersion::V2) ), - (None, Some(4)) + (Some(9), Some(4)) ); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -10576,7 +10642,7 @@ subagent_usage_hint_text = "" } #[tokio::test] -async fn multi_agent_v2_feature_rejects_agents_max_threads() -> std::io::Result<()> { +async fn multi_agent_v2_uses_agents_max_concurrent_threads_per_session() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -10584,7 +10650,7 @@ async fn multi_agent_v2_feature_rejects_agents_max_threads() -> std::io::Result< enabled = true [agents] -max_threads = 3 +max_concurrent_threads_per_session = 7 "#, )?; @@ -10593,25 +10659,19 @@ max_threads = 3 .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await?; - let err = config - .validate_multi_agent_v2_config() - .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 features.multi_agent_v2 is enabled" - ); - assert_eq!( - config.effective_agent_max_threads(MultiAgentVersion::V2), - Some(3) + ( + config.multi_agent_v2.max_concurrent_threads_per_session, + config.effective_agent_max_threads(MultiAgentVersion::V2), + ), + (8, Some(7)) ); Ok(()) } #[tokio::test] -async fn catalog_v2_allows_agents_max_threads_when_feature_disabled() -> std::io::Result<()> { +async fn catalog_v2_allows_agents_thread_limit_when_feature_disabled() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -10619,7 +10679,7 @@ async fn catalog_v2_allows_agents_max_threads_when_feature_disabled() -> std::io enabled = false [agents] -max_threads = 3 +max_concurrent_threads_per_session = 3 "#, )?; @@ -10629,10 +10689,12 @@ max_threads = 3 .build() .await?; - config.validate_multi_agent_v2_config()?; assert_eq!( - config.effective_agent_max_threads(MultiAgentVersion::V2), - Some(3) + ( + config.multi_agent_v2.max_concurrent_threads_per_session, + config.effective_agent_max_threads(MultiAgentVersion::V2), + ), + (4, Some(3)) ); Ok(()) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 336b7464dbb0..b93b4298c9d2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -857,15 +857,28 @@ pub struct Config { /// Token budget applied when storing tool/function outputs in the context manager. pub tool_output_token_limit: Option, - /// User-configured maximum number of agent threads that can be open concurrently. + /// Whether multi-agent tools are enabled through `[agents]`. + pub agents_enabled: bool, + + /// User-configured maximum number of spawned agent threads per session. pub agent_max_threads: Option, + + /// Reserved setting for whether the spawn tool supports agent types. + pub agent_support_agent_type: Option, + + /// Reserved default model for spawned subagents. + pub agent_default_subagent_model: Option, + + /// Reserved default reasoning effort for spawned subagents. + pub agent_default_subagent_reasoning_effort: Option, + /// Maximum runtime in seconds for agent job workers before they are failed. pub agent_job_max_runtime_seconds: Option, /// Whether to record a model-visible message when an agent turn is interrupted. pub agent_interrupt_message_enabled: bool, - /// Maximum nesting depth allowed for spawned agent threads. + /// Maximum nesting depth for V1 agent threads. Ignored by V2. pub agent_max_depth: i32, /// User-defined role declarations keyed by role name. @@ -1420,25 +1433,33 @@ impl ConfigBuilder { } impl Config { - pub(crate) fn multi_agent_version_from_features(&self) -> MultiAgentVersion { + pub(crate) fn multi_agent_version_override(&self) -> Option { if self.features.enabled(Feature::MultiAgentV2) { - MultiAgentVersion::V2 - } else if self.features.enabled(Feature::Collab) { - MultiAgentVersion::V1 + Some(MultiAgentVersion::V2) + } else if !self.agents_enabled { + Some(MultiAgentVersion::Disabled) } else { - MultiAgentVersion::Disabled + None } } - pub(crate) fn validate_multi_agent_v2_config(&self) -> std::io::Result<()> { - if self.features.enabled(Feature::MultiAgentV2) && self.agent_max_threads.is_some() { - Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "agents.max_threads cannot be set when features.multi_agent_v2 is enabled", - )) - } else { - Ok(()) - } + pub(crate) fn multi_agent_version_from_features(&self) -> MultiAgentVersion { + self.multi_agent_version_override().unwrap_or_else(|| { + if self.features.enabled(Feature::Collab) { + MultiAgentVersion::V1 + } else { + MultiAgentVersion::Disabled + } + }) + } + + pub(crate) fn multi_agent_version_for_model( + &self, + model_multi_agent_version: Option, + ) -> MultiAgentVersion { + self.multi_agent_version_override() + .or(model_multi_agent_version) + .unwrap_or_else(|| self.multi_agent_version_from_features()) } pub(crate) fn effective_agent_max_threads( @@ -2516,6 +2537,13 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config let base = multi_agent_v2_toml_config(config_toml.features.as_ref()); let max_concurrent_threads_per_session = base .and_then(|config| config.max_concurrent_threads_per_session) + .or_else(|| { + config_toml + .agents + .as_ref() + .and_then(|agents| agents.max_concurrent_threads_per_session) + .map(|max_threads| max_threads.saturating_add(1)) + }) .unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION); let default = MultiAgentV2Config::defaults_for_max_concurrency(max_concurrent_threads_per_session); @@ -3558,11 +3586,19 @@ impl Config { )); } validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?; - let agent_max_threads = cfg.agents.as_ref().and_then(|agents| agents.max_threads); + let agents_enabled = cfg + .agents + .as_ref() + .and_then(|agents| agents.enabled) + .unwrap_or(true); + let agent_max_threads = cfg + .agents + .as_ref() + .and_then(|agents| agents.max_concurrent_threads_per_session); if agent_max_threads == Some(0) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "agents.max_threads must be at least 1", + "agents.max_concurrent_threads_per_session must be at least 1", )); } let agent_max_depth = cfg @@ -3570,12 +3606,18 @@ impl Config { .as_ref() .and_then(|agents| agents.max_depth) .unwrap_or(DEFAULT_AGENT_MAX_DEPTH); - if agent_max_depth < 1 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "agents.max_depth must be at least 1", - )); - } + let agent_support_agent_type = cfg + .agents + .as_ref() + .and_then(|agents| agents.support_agent_type); + let agent_default_subagent_model = cfg + .agents + .as_ref() + .and_then(|agents| agents.default_subagent_model.clone()); + let agent_default_subagent_reasoning_effort = cfg + .agents + .as_ref() + .and_then(|agents| agents.default_subagent_reasoning_effort.clone()); let agent_job_max_runtime_seconds = cfg .agents .as_ref() @@ -3926,7 +3968,11 @@ impl Config { }) .collect(), tool_output_token_limit: cfg.tool_output_token_limit, + agents_enabled, agent_max_threads, + agent_support_agent_type, + agent_default_subagent_model, + agent_default_subagent_reasoning_effort, agent_max_depth, agent_roles, memories: memories_config, diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index e3dfb174b02e..3fca512d87fc 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -177,14 +177,13 @@ fn save_config_resolved_fields( )?); let agents = lock_config.agents.get_or_insert_with(Default::default); - // Multi-agent v2 owns thread fanout through its feature config. Preserve - // the legacy agents.max_threads setting only when v2 is disabled. - agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) { - None - } else { - config.agent_max_threads - }; + agents.enabled = Some(config.agents_enabled); + agents.max_concurrent_threads_per_session = config.agent_max_threads; agents.max_depth = Some(config.agent_max_depth); + agents.support_agent_type = config.agent_support_agent_type; + agents.default_subagent_model = config.agent_default_subagent_model.clone(); + agents.default_subagent_reasoning_effort = + config.agent_default_subagent_reasoning_effort.clone(); agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds; agents.interrupt_message = Some(config.agent_interrupt_message_enabled); @@ -295,7 +294,6 @@ mod tests { .is_none_or(|debug| debug.config_lockfile.is_none()) ); assert!(lock.memories.is_some()); - let features = lock .features .as_ref() diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index acc69b2a6529..eb9cb33684cf 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -599,14 +599,12 @@ impl Session { let model_info = models_manager .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; - let multi_agent_version = - resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); + let multi_agent_version = config.multi_agent_version_override().or_else(|| { + resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version) + }); let history_mode = conversation_history.get_history_mode( requested_history_mode.unwrap_or_else(|| thread_store.default_history_mode()), ); - config - .validate_multi_agent_v2_config() - .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; let base_instructions = config .base_instructions .clone() @@ -3074,12 +3072,10 @@ impl Session { config: &Config, ) -> MultiAgentVersion { if let Some(multi_agent_version) = self.multi_agent_version() { - return multi_agent_version; + return config.multi_agent_version_for_model(Some(multi_agent_version)); } - let selected = model_info - .multi_agent_version - .unwrap_or_else(|| config.multi_agent_version_from_features()); + let selected = config.multi_agent_version_for_model(model_info.multi_agent_version); self.set_multi_agent_version_if_unset(selected) } diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b0e6bafa641a..07a6fdb00557 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -737,10 +737,10 @@ impl Session { TurnMultiAgentRuntime::ResolveAndStore => { self.resolve_multi_agent_version_for_model(&model_info, &per_turn_config) } - TurnMultiAgentRuntime::Preview => self - .multi_agent_version() - .or(model_info.multi_agent_version) - .unwrap_or_else(|| per_turn_config.multi_agent_version_from_features()), + TurnMultiAgentRuntime::Preview => per_turn_config.multi_agent_version_for_model( + self.multi_agent_version() + .or(model_info.multi_agent_version), + ), }; let plugins_input = per_turn_config.plugins_config_input(); let plugin_outcome = self diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 0df56c4b0665..7bbbb9efe62d 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1224,6 +1224,9 @@ impl ThreadManagerState { forked_from_thread_id: Option, config: &Config, ) -> MultiAgentVersion { + if let Some(multi_agent_version) = config.multi_agent_version_override() { + return multi_agent_version; + } self.initial_multi_agent_version_for_spawn( initial_history, session_source, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 1c68849c5e06..840a5d57b0aa 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -61,6 +61,8 @@ async fn handle_spawn_agent( let child_depth = next_thread_spawn_depth(&session_source); let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + // TODO(jif): Apply `agents.default_subagent_model` as the default model here. + // TODO(jif): Apply `agents.default_subagent_reasoning_effort` as the default effort here. if let Some(service_tier) = args.service_tier.as_ref() { config.service_tier = Some(service_tier.clone()); } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 83169918e0b3..3c723438abf5 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -798,6 +798,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu .flatten(); let agent_type_description = agent_type_description(turn_context, context.default_agent_type_description); + // TODO(jif): Wire `agents.support_agent_type` into the V2 spawn tool surface. planned_tools.add_arc(override_tool_exposure( multi_agent_v2_handler( SpawnAgentHandlerV2::new(SpawnAgentToolOptions { diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index f6ccd049c51f..55ee5de21f7b 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -321,13 +321,13 @@ async fn unsupported_code_mode_warning_is_emitted_each_turn() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { +async fn multi_agent_config_precedence_overrides_remote_model_selector() -> Result<()> { skip_if_no_network!(Ok(())); let mut v2_model = remote_model("test-multi-agent-v2"); v2_model.multi_agent_version = Some(MultiAgentVersion::V2); - let v2_body = response_body_for_remote_model(v2_model, |config| { - config.agent_max_threads = Some(3); + let disabled_body = response_body_for_remote_model(v2_model, |config| { + config.agents_enabled = false; config .features .enable(Feature::Collab) @@ -338,27 +338,42 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { .expect("test config should allow feature update"); }) .await?; - assert!(tool_names(&v2_body).contains(&MULTI_AGENT_V2_NAMESPACE.to_string())); + assert!(tool_names(&disabled_body).iter().all(|name| !matches!( + name.as_str(), + "multi_agent_v1" + | MULTI_AGENT_V2_NAMESPACE + | "spawn_agent" + | "send_message" + | "wait_agent" + | "list_agents" + ))); + + let mut v1_model = remote_model("test-multi-agent-v1"); + v1_model.multi_agent_version = Some(MultiAgentVersion::V1); + let v1_body = response_body_for_remote_model(v1_model, |config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .await?; + assert!(tool_names(&v1_body).contains(&"multi_agent_v1".to_string())); let mut disabled_model = remote_model("test-multi-agent-disabled"); disabled_model.multi_agent_version = Some(MultiAgentVersion::Disabled); - let disabled_body = response_body_for_remote_model(disabled_model, |config| { + let v2_body = response_body_for_remote_model(disabled_model, |config| { + config.agents_enabled = false; config .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); }) .await?; - let disabled_tools = tool_names(&disabled_body); - assert!(disabled_tools.iter().all(|name| !matches!( - name.as_str(), - "multi_agent_v1" - | MULTI_AGENT_V2_NAMESPACE - | "spawn_agent" - | "send_message" - | "wait_agent" - | "list_agents" - ))); + assert!(tool_names(&v2_body).contains(&MULTI_AGENT_V2_NAMESPACE.to_string())); Ok(()) } diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index fbf3ba9c5333..8f7b83d22d49 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -240,7 +240,11 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R project_doc_max_bytes: 32 * 1024, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, + agents_enabled: true, agent_max_threads: Some(6), + agent_support_agent_type: None, + agent_default_subagent_model: None, + agent_default_subagent_reasoning_effort: None, agent_job_max_runtime_seconds: None, agent_interrupt_message_enabled: false, agent_max_depth: 1, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index c02d4c82611d..19abc3357e00 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -29,6 +29,7 @@ pub(crate) fn new_debug_config_output( let mut lines = render_debug_config_lines(&config.config_layer_stack, |mode| { sandbox_mode_is_allowed_by_permissions(&config.permissions, mode) }); + lines.extend(render_agents_config_lines(config)); if let Some(proxy) = session_network_proxy { lines.push("".into()); @@ -54,6 +55,55 @@ pub(crate) fn new_debug_config_output( PlainHistoryCell::new(lines) } +fn render_agents_config_lines(config: &Config) -> Vec> { + vec![ + "".into(), + "[agents]:".bold().into(), + format!(" - enabled = {}", config.agents_enabled).into(), + format!( + " - max_concurrent_threads_per_session = {}", + format_optional(config.agent_max_threads) + ) + .into(), + format!( + " - max_depth = {} (V1 only; ignored by V2)", + config.agent_max_depth + ) + .into(), + format!( + " - support_agent_type = {} (not yet wired)", + format_optional(config.agent_support_agent_type) + ) + .into(), + format!( + " - default_subagent_model = {} (not yet wired)", + format_optional(config.agent_default_subagent_model.as_deref()) + ) + .into(), + format!( + " - default_subagent_reasoning_effort = {} (not yet wired)", + format_optional(config.agent_default_subagent_reasoning_effort.as_ref()) + ) + .into(), + format!( + " - job_max_runtime_seconds = {}", + format_optional(config.agent_job_max_runtime_seconds) + ) + .into(), + format!( + " - interrupt_message = {}", + config.agent_interrupt_message_enabled + ) + .into(), + ] +} + +fn format_optional(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "".to_string()) +} + fn sandbox_mode_is_allowed_by_permissions( permissions: &Permissions, mode: SandboxModeRequirement, @@ -552,9 +602,11 @@ fn format_network_unix_socket_permission( #[cfg(test)] mod tests { + use super::render_agents_config_lines; use super::render_debug_config_lines; use super::sandbox_mode_is_allowed_by_permissions; use super::session_all_proxy_url; + use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::Permissions; use codex_app_server_protocol::AskForApproval; use codex_config::ConfigLayerEntry; @@ -569,6 +621,7 @@ mod tests { use codex_config::FilesystemConstraints; use codex_config::HookEventsToml; use codex_config::HookHandlerConfig; + use codex_config::LoaderOverrides; use codex_config::ManagedHooksRequirementsToml; use codex_config::MatcherGroup; use codex_config::McpServerIdentity; @@ -592,6 +645,34 @@ mod tests { use std::collections::BTreeMap; use toml::Value as TomlValue; + #[tokio::test] + async fn debug_config_output_lists_agents_fields() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + std::fs::write( + codex_home.path().join(codex_config::CONFIG_TOML_FILE), + r#"[agents] +enabled = false +max_concurrent_threads_per_session = 7 +max_depth = -2 +support_agent_type = true +default_subagent_model = "gpt-5.6-terra" +default_subagent_reasoning_effort = "high" +job_max_runtime_seconds = 900 +interrupt_message = false +"#, + ) + .expect("write config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await + .expect("load config"); + + insta::assert_snapshot!(render_to_text(&render_agents_config_lines(&config))); + } + fn empty_toml_table() -> TomlValue { TomlValue::Table(toml::map::Map::new()) } diff --git a/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_output_lists_agents_fields.snap b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_output_lists_agents_fields.snap new file mode 100644 index 000000000000..aec47700d2ef --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__debug_config__tests__debug_config_output_lists_agents_fields.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/debug_config.rs +assertion_line: 667 +expression: render_to_text(&render_agents_config_lines(&config)) +--- + +[agents]: + - enabled = false + - max_concurrent_threads_per_session = 7 + - max_depth = -2 (V1 only; ignored by V2) + - support_agent_type = true (not yet wired) + - default_subagent_model = gpt-5.6-terra (not yet wired) + - default_subagent_reasoning_effort = high (not yet wired) + - job_max_runtime_seconds = 900 + - interrupt_message = false