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
This commit is contained in:
jif
2026-07-16 09:00:51 +00:00
committed by copyberry
parent cbc83d961e
commit 03bb3b1236
17 changed files with 431 additions and 111 deletions
+5 -5
View File
@@ -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;
+88 -26
View File
@@ -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(())
+70 -24
View File
@@ -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<usize>,
/// 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<usize>,
/// Reserved setting for whether the spawn tool supports agent types.
pub agent_support_agent_type: Option<bool>,
/// Reserved default model for spawned subagents.
pub agent_default_subagent_model: Option<String>,
/// Reserved default reasoning effort for spawned subagents.
pub agent_default_subagent_reasoning_effort: Option<ReasoningEffort>,
/// Maximum runtime in seconds for agent job workers before they are failed.
pub agent_job_max_runtime_seconds: Option<u64>,
/// 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<MultiAgentVersion> {
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>,
) -> 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,
+6 -8
View File
@@ -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()
+5 -9
View File
@@ -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)
}
+4 -4
View File
@@ -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
+3
View File
@@ -1224,6 +1224,9 @@ impl ThreadManagerState {
forked_from_thread_id: Option<ThreadId>,
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,
@@ -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());
}
+1
View File
@@ -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 {