From 34d71d43eb87e16429a3945ec3de5799ea2153c0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 28 Apr 2026 22:36:44 +0200 Subject: [PATCH] Make MultiAgentV2 wait minimum configurable (#20052) ## Why MultiAgentV2 `wait_agent` currently clamps short waits to a fixed 10 second minimum. That default is still useful for preventing tight polling loops, but it is too rigid for environments that need faster mailbox wake-up checks or a larger minimum to discourage frequent polling. This PR makes the minimum wait timeout configurable from the existing MultiAgentV2 feature config section, so operators can tune the behavior without changing the legacy multi-agent tool surface. ## What Changed - Added `features.multi_agent_v2.min_wait_timeout_ms`. - Defaulted the new setting to the existing 10 second floor. - Validated the configured value as `1..=3600000`, matching the existing one hour maximum wait bound. - Applied the configured minimum to MultiAgentV2 `wait_agent` runtime clamping. - Plumbed the configured minimum into the `wait_agent` tool schema, including the effective default when the minimum is above the normal 30 second default. - Regenerated `core/config.schema.json`. ## Verification - `cargo test -p codex-features` - `cargo test -p codex-tools` - `cargo test -p codex-core --lib multi_agent_v2` - `just fix -p codex-core` --- codex-rs/core/config.schema.json | 6 +++ codex-rs/core/src/config/config_tests.rs | 54 +++++++++++++++++++ codex-rs/core/src/config/mod.rs | 23 ++++++++ codex-rs/core/src/session/review.rs | 5 ++ codex-rs/core/src/session/turn_context.rs | 12 +++++ .../src/tools/handlers/multi_agents_common.rs | 6 ++- .../src/tools/handlers/multi_agents_tests.rs | 53 ++++++++++++++++++ .../tools/handlers/multi_agents_v2/wait.rs | 7 ++- codex-rs/core/src/tools/spec.rs | 14 ++++- codex-rs/core/src/tools/spec_tests.rs | 29 ++++++++++ codex-rs/features/src/feature_configs.rs | 3 ++ codex-rs/features/src/tests.rs | 3 ++ codex-rs/tools/src/tool_config.rs | 10 ++++ 13 files changed, 220 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f0e7c23c3..1f8a93da4 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1336,6 +1336,12 @@ "minimum": 1.0, "type": "integer" }, + "min_wait_timeout_ms": { + "format": "int64", + "maximum": 3600000.0, + "minimum": 1.0, + "type": "integer" + }, "root_agent_usage_hint_text": { "type": "string" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c9ddaa1ba..f075a4b7f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7804,6 +7804,7 @@ async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { r#"[features.multi_agent_v2] enabled = true max_concurrent_threads_per_session = 5 +min_wait_timeout_ms = 2500 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." @@ -7820,6 +7821,7 @@ hide_spawn_agent_metadata = true assert!(config.features.enabled(Feature::MultiAgentV2)); assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 5); + assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 2500); assert_eq!(config.agent_max_threads, Some(4)); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( @@ -7848,6 +7850,7 @@ async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { [features.multi_agent_v2] max_concurrent_threads_per_session = 4 +min_wait_timeout_ms = 3000 usage_hint_enabled = true usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" @@ -7856,6 +7859,7 @@ hide_spawn_agent_metadata = true [profiles.no_hint.features.multi_agent_v2] max_concurrent_threads_per_session = 6 +min_wait_timeout_ms = 1500 usage_hint_enabled = false usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" @@ -7871,6 +7875,7 @@ hide_spawn_agent_metadata = false .await?; assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6); + assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 1500); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -7906,6 +7911,7 @@ enabled = true .await?; assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4); + assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000); assert_eq!(config.agent_max_threads, Some(3)); Ok(()) @@ -7940,6 +7946,54 @@ max_threads = 3 Ok(()) } +#[tokio::test] +async fn multi_agent_v2_rejects_invalid_min_wait_timeout() -> 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 +min_wait_timeout_ms = 0 +"#, + )?; + + 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("zero min_wait_timeout_ms should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.min_wait_timeout_ms must be at least 1" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = 3600001 +"#, + )?; + + 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("too large min_wait_timeout_ms should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.min_wait_timeout_ms must be at most 3600000" + ); + + Ok(()) +} + #[tokio::test] async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> 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 dcf443b84..6c8258c51 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -156,6 +156,8 @@ impl Default for GhostSnapshotConfig { 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_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000; +pub(crate) const MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; 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"; @@ -753,6 +755,7 @@ pub struct Config { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, + pub min_wait_timeout_ms: i64, pub usage_hint_enabled: bool, pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, @@ -765,6 +768,7 @@ impl Default for MultiAgentV2Config { Self { max_concurrent_threads_per_session: DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION, + min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS, usage_hint_enabled: true, usage_hint_text: None, root_agent_usage_hint_text: None, @@ -1638,6 +1642,10 @@ fn resolve_multi_agent_v2_config( .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 min_wait_timeout_ms = profile + .and_then(|config| config.min_wait_timeout_ms) + .or_else(|| base.and_then(|config| config.min_wait_timeout_ms)) + .unwrap_or(default.min_wait_timeout_ms); let usage_hint_enabled = profile .and_then(|config| config.usage_hint_enabled) .or_else(|| base.and_then(|config| config.usage_hint_enabled)) @@ -1664,6 +1672,7 @@ fn resolve_multi_agent_v2_config( MultiAgentV2Config { max_concurrent_threads_per_session, + min_wait_timeout_ms, usage_hint_enabled, usage_hint_text, root_agent_usage_hint_text, @@ -2186,6 +2195,20 @@ impl Config { "features.multi_agent_v2.max_concurrent_threads_per_session must be at least 1", )); } + if multi_agent_v2.min_wait_timeout_ms <= 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.multi_agent_v2.min_wait_timeout_ms must be at least 1", + )); + } + if multi_agent_v2.min_wait_timeout_ms > MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "features.multi_agent_v2.min_wait_timeout_ms must be at most {MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS}" + ), + )); + } 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() { diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 9401c2d0b..c22a9faaf 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -54,6 +54,11 @@ pub(super) async fn spawn_review_thread( .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) + .with_wait_agent_min_timeout_ms( + review_features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.min_wait_timeout_ms), + ) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, )); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index a8244303d..35b517b72 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -201,6 +201,12 @@ impl TurnContext { .enabled(Feature::MultiAgentV2) .then_some(config.multi_agent_v2.max_concurrent_threads_per_session), ) + .with_wait_agent_min_timeout_ms( + config + .features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.min_wait_timeout_ms), + ) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, )); @@ -475,6 +481,12 @@ impl Session { .max_concurrent_threads_per_session, ), ) + .with_wait_agent_min_timeout_ms( + per_turn_config + .features + .enabled(Feature::MultiAgentV2) + .then_some(per_turn_config.multi_agent_v2.min_wait_timeout_ms), + ) .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/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index c722ddb8d..c01755cb2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -1,5 +1,7 @@ use crate::agent::AgentStatus; use crate::config::Config; +use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; +use crate::config::MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -26,9 +28,9 @@ use serde_json::Value as JsonValue; use std::collections::HashMap; /// Minimum wait timeout to prevent tight polling loops from burning CPU. -pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000; +pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; -pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; +pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS; pub(crate) fn function_arguments(payload: ToolPayload) -> Result { match payload { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index fc861be61..2fecb7780 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2742,6 +2742,59 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { assert_eq!(success, None); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { + let (session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.min_wait_timeout_ms = 50; + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + let early = timeout( + Duration::from_millis(/*millis*/ 20), + WaitAgentHandlerV2.handle(invocation( + session.clone(), + turn.clone(), + "wait_agent", + function_payload(json!({"timeout_ms": 1})), + )), + ) + .await; + assert!( + early.is_err(), + "wait_agent should not return before the configured minimum timeout" + ); + + let output = timeout( + Duration::from_secs(/*secs*/ 1), + WaitAgentHandlerV2.handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({"timeout_ms": 1})), + )), + ) + .await + .expect("configured minimum should be shorter than the test timeout") + .expect("wait_agent should succeed"); + let (content, success) = expect_text_output(output); + let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult = + serde_json::from_str(&content).expect("wait_agent result should be json"); + assert_eq!( + result, + crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult { + message: "Wait timed out.".to_string(), + timed_out: true, + } + ); + assert_eq!(success, None); +} + #[tokio::test] async fn wait_agent_returns_not_found_for_missing_agents() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs index e50c6cab2..778c57be2 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs @@ -28,13 +28,18 @@ impl ToolHandler for Handler { let arguments = function_arguments(payload)?; let args: WaitArgs = parse_arguments(&arguments)?; let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); + let min_timeout_ms = turn + .config + .multi_agent_v2 + .min_wait_timeout_ms + .clamp(1, MAX_WAIT_TIMEOUT_MS); let timeout_ms = match timeout_ms { ms if ms <= 0 => { return Err(FunctionCallError::RespondToModel( "timeout_ms must be greater than zero".to_owned(), )); } - ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS), + ms => ms.clamp(min_timeout_ms, MAX_WAIT_TIMEOUT_MS), }; let mut mailbox_seq_rx = session.subscribe_mailbox_seq(); diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index ebbc38b8b..91d38f25b 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -124,6 +124,16 @@ pub(crate) fn build_specs_with_discoverable_tools( }); let default_agent_type_description = crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()); + let min_wait_timeout_ms = if config.multi_agent_v2 { + config + .wait_agent_min_timeout_ms + .unwrap_or(MIN_WAIT_TIMEOUT_MS) + .clamp(1, MAX_WAIT_TIMEOUT_MS) + } else { + MIN_WAIT_TIMEOUT_MS + }; + let default_wait_timeout_ms = + DEFAULT_WAIT_TIMEOUT_MS.clamp(min_wait_timeout_ms, MAX_WAIT_TIMEOUT_MS); let plan = build_tool_registry_plan( config, ToolRegistryPlanParams { @@ -138,8 +148,8 @@ pub(crate) fn build_specs_with_discoverable_tools( dynamic_tools, default_agent_type_description: &default_agent_type_description, wait_agent_timeouts: WaitAgentTimeoutOptions { - default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS, - min_timeout_ms: MIN_WAIT_TIMEOUT_MS, + default_timeout_ms: default_wait_timeout_ms, + min_timeout_ms: min_wait_timeout_ms, max_timeout_ms: MAX_WAIT_TIMEOUT_MS, }, }, diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index f88bb04f3..5942bf806 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -784,6 +784,35 @@ async fn spawn_agent_description_uses_configured_usage_hint_text() { ); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_schema_uses_configured_min_timeout() { + let wait_agent_min_timeout_ms = Some(60_000); + let tools_config = multi_agent_v2_tools_config() + .await + .with_wait_agent_min_timeout_ms(wait_agent_min_timeout_ms); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ) + .build(); + let wait_agent = find_tool(&tools, "wait_agent"); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &wait_agent.spec else { + panic!("wait_agent should be a function tool"); + }; + let timeout_description = parameters + .properties + .as_ref() + .and_then(|properties| properties.get("timeout_ms")) + .and_then(|schema| schema.description.as_deref()); + + assert_eq!( + timeout_description, + Some("Optional timeout in milliseconds. Defaults to 60000, min 60000, max 3600000.") + ); +} + #[tokio::test] async fn tool_suggest_requires_apps_and_plugins_features() { let model_info = search_capable_model_info().await; diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 3906bc2e8..fd489db50 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -12,6 +12,9 @@ pub struct MultiAgentV2ConfigToml { #[schemars(range(min = 1))] pub max_concurrent_threads_per_session: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 3600000))] + pub min_wait_timeout_ms: 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 4e1f9a095..0ed1d7ecc 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -413,6 +413,7 @@ fn multi_agent_v2_feature_config_deserializes_table() { [multi_agent_v2] enabled = true max_concurrent_threads_per_session = 4 +min_wait_timeout_ms = 2500 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." @@ -431,6 +432,7 @@ hide_spawn_agent_metadata = true Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml { enabled: Some(true), max_concurrent_threads_per_session: Some(4), + min_wait_timeout_ms: Some(2500), usage_hint_enabled: Some(false), usage_hint_text: Some("Custom delegation guidance.".to_string()), root_agent_usage_hint_text: Some("Root guidance.".to_string()), @@ -465,6 +467,7 @@ usage_hint_enabled = false Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml { enabled: None, max_concurrent_threads_per_session: None, + min_wait_timeout_ms: None, usage_hint_enabled: Some(false), usage_hint_text: None, root_agent_usage_hint_text: None, diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 3ddcf481a..208efead9 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -107,6 +107,7 @@ pub struct ToolsConfig { pub spawn_agent_usage_hint: bool, pub spawn_agent_usage_hint_text: Option, pub max_concurrent_threads_per_session: Option, + pub wait_agent_min_timeout_ms: Option, pub default_mode_request_user_input: bool, pub experimental_supported_tools: Vec, pub agent_jobs_tools: bool, @@ -226,6 +227,7 @@ impl ToolsConfig { spawn_agent_usage_hint: true, spawn_agent_usage_hint_text: None, max_concurrent_threads_per_session: None, + wait_agent_min_timeout_ms: None, default_mode_request_user_input: include_default_mode_request_user_input, experimental_supported_tools: model_info.experimental_supported_tools.clone(), agent_jobs_tools: include_agent_jobs, @@ -270,6 +272,14 @@ impl ToolsConfig { self } + pub fn with_wait_agent_min_timeout_ms( + mut self, + wait_agent_min_timeout_ms: Option, + ) -> Self { + self.wait_agent_min_timeout_ms = wait_agent_min_timeout_ms; + self + } + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { self.allow_login_shell = allow_login_shell; self