From 7c57a59f51b605b82f49e71833aa6e675b9ec54c Mon Sep 17 00:00:00 2001 From: Andrey Mishchenko Date: Wed, 13 May 2026 14:43:06 -0700 Subject: [PATCH] Make multi_agent_v2 wait_agent timeouts configurable (#22528) ## Why `multi_agent_v2` already allowed configuring the minimum `wait_agent` timeout, but the default timeout and upper bound were still hard-coded. That made it hard to tune waits for subagent mailbox activity in sessions that need either faster wakeups or longer waits, and it meant the model-visible `wait_agent` schema could not fully reflect the resolved runtime limits. ## What Changed - Added `features.multi_agent_v2.max_wait_timeout_ms` and `features.multi_agent_v2.default_wait_timeout_ms` alongside the existing `min_wait_timeout_ms` setting. - Validated all three timeouts in config as `0..=3_600_000`, with `min_wait_timeout_ms <= default_wait_timeout_ms <= max_wait_timeout_ms`. - Thread and review session tool config now passes the resolved min/default/max values into the `wait_agent` tool schema. - `wait_agent` now uses the configured default when `timeout_ms` is omitted and rejects explicit values outside the configured min/max range instead of silently clamping them. - Updated the generated config schema and config-lock test coverage for the new fields. --- codex-rs/core/config.schema.json | 14 +- codex-rs/core/src/config/config_tests.rs | 169 ++++++++++++++- codex-rs/core/src/config/mod.rs | 64 +++++- codex-rs/core/src/session/config_lock.rs | 2 + codex-rs/core/src/session/review.rs | 10 + codex-rs/core/src/session/turn_context.rs | 24 +++ .../src/tools/handlers/multi_agents_common.rs | 4 +- .../src/tools/handlers/multi_agents_tests.rs | 196 ++++++++++++++++-- .../tools/handlers/multi_agents_v2/wait.rs | 27 +-- codex-rs/core/src/tools/spec.rs | 38 +++- codex-rs/core/src/tools/spec_tests.rs | 12 +- codex-rs/features/src/feature_configs.rs | 8 +- codex-rs/features/src/tests.rs | 6 + codex-rs/tools/src/tool_config.rs | 20 ++ 14 files changed, 541 insertions(+), 53 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 82fde87e7..560e87b05 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1465,6 +1465,12 @@ "MultiAgentV2ConfigToml": { "additionalProperties": false, "properties": { + "default_wait_timeout_ms": { + "format": "int64", + "maximum": 3600000.0, + "minimum": 0.0, + "type": "integer" + }, "enabled": { "type": "boolean" }, @@ -1476,10 +1482,16 @@ "minimum": 1.0, "type": "integer" }, + "max_wait_timeout_ms": { + "format": "int64", + "maximum": 3600000.0, + "minimum": 0.0, + "type": "integer" + }, "min_wait_timeout_ms": { "format": "int64", "maximum": 3600000.0, - "minimum": 1.0, + "minimum": 0.0, "type": "integer" }, "non_code_mode_only": { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index f6d6eff96..5f060414e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9687,6 +9687,8 @@ async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { enabled = true max_concurrent_threads_per_session = 5 min_wait_timeout_ms = 2500 +max_wait_timeout_ms = 120000 +default_wait_timeout_ms = 30000 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." @@ -9705,6 +9707,8 @@ non_code_mode_only = 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.multi_agent_v2.max_wait_timeout_ms, 120000); + assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30000); assert_eq!(config.agent_max_threads, Some(4)); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( @@ -9735,6 +9739,8 @@ 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 +max_wait_timeout_ms = 120000 +default_wait_timeout_ms = 30000 usage_hint_enabled = true usage_hint_text = "base hint" root_agent_usage_hint_text = "base root hint" @@ -9745,6 +9751,8 @@ non_code_mode_only = false [profiles.no_hint.features.multi_agent_v2] max_concurrent_threads_per_session = 6 min_wait_timeout_ms = 1500 +max_wait_timeout_ms = 90000 +default_wait_timeout_ms = 15000 usage_hint_enabled = false usage_hint_text = "profile hint" root_agent_usage_hint_text = "profile root hint" @@ -9762,6 +9770,8 @@ non_code_mode_only = true assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6); assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 1500); + assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 90000); + assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 15000); assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), @@ -9799,6 +9809,8 @@ enabled = true 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.multi_agent_v2.max_wait_timeout_ms, 3_600_000); + assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000); assert_eq!(config.agent_max_threads, Some(3)); assert!(!config.multi_agent_v2.non_code_mode_only); @@ -9835,13 +9847,33 @@ max_threads = 3 } #[tokio::test] -async fn multi_agent_v2_rejects_invalid_min_wait_timeout() -> std::io::Result<()> { +async fn multi_agent_v2_rejects_invalid_wait_timeouts() -> 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 +max_wait_timeout_ms = 0 +default_wait_timeout_ms = 0 +"#, + )?; + + 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.min_wait_timeout_ms, 0); + assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 0); + assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 0); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = -1 "#, )?; @@ -9850,12 +9882,12 @@ min_wait_timeout_ms = 0 .fallback_cwd(Some(codex_home.path().to_path_buf())) .build() .await - .expect_err("zero min_wait_timeout_ms should be rejected"); + .expect_err("negative 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" + "features.multi_agent_v2.min_wait_timeout_ms must be at least 0" ); std::fs::write( @@ -9879,6 +9911,137 @@ min_wait_timeout_ms = 3600001 "features.multi_agent_v2.min_wait_timeout_ms must be at most 3600000" ); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +max_wait_timeout_ms = -1 +"#, + )?; + + 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("negative max_wait_timeout_ms should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.max_wait_timeout_ms must be at least 0" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +max_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 max_wait_timeout_ms should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.max_wait_timeout_ms must be at most 3600000" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +default_wait_timeout_ms = -1 +"#, + )?; + + 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("negative default_wait_timeout_ms should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.default_wait_timeout_ms must be at least 0" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = 1000 +max_wait_timeout_ms = 500 +"#, + )?; + + 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("min greater than max 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 features.multi_agent_v2.max_wait_timeout_ms" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = 1000 +max_wait_timeout_ms = 2000 +default_wait_timeout_ms = 500 +"#, + )?; + + 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("default less than min should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.default_wait_timeout_ms must be at least features.multi_agent_v2.min_wait_timeout_ms" + ); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +min_wait_timeout_ms = 1000 +max_wait_timeout_ms = 2000 +default_wait_timeout_ms = 2500 +"#, + )?; + + 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("default greater than max should be rejected"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms" + ); + Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4aa323033..3c5088059 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -177,7 +177,11 @@ pub(crate) const AGENTS_MD_MAX_BYTES: usize = DEFAULT_PROJECT_DOC_MAX_BYTES; // 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_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; +pub(crate) const DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; +pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0; +pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 = + DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; 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"; @@ -832,6 +836,8 @@ pub struct Config { pub struct MultiAgentV2Config { pub max_concurrent_threads_per_session: usize, pub min_wait_timeout_ms: i64, + pub max_wait_timeout_ms: i64, + pub default_wait_timeout_ms: i64, pub usage_hint_enabled: bool, pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, @@ -846,6 +852,8 @@ impl Default for MultiAgentV2Config { 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, + max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS, + default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS, usage_hint_enabled: true, usage_hint_text: None, root_agent_usage_hint_text: None, @@ -1965,6 +1973,14 @@ fn resolve_multi_agent_v2_config( .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 max_wait_timeout_ms = profile + .and_then(|config| config.max_wait_timeout_ms) + .or_else(|| base.and_then(|config| config.max_wait_timeout_ms)) + .unwrap_or(default.max_wait_timeout_ms); + let default_wait_timeout_ms = profile + .and_then(|config| config.default_wait_timeout_ms) + .or_else(|| base.and_then(|config| config.default_wait_timeout_ms)) + .unwrap_or(default.default_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)) @@ -1996,6 +2012,8 @@ fn resolve_multi_agent_v2_config( MultiAgentV2Config { max_concurrent_threads_per_session, min_wait_timeout_ms, + max_wait_timeout_ms, + default_wait_timeout_ms, usage_hint_enabled, usage_hint_text, root_agent_usage_hint_text, @@ -2078,6 +2096,22 @@ pub(crate) fn resolve_web_search_mode_for_turn( WebSearchMode::Disabled } +fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Result<()> { + if value < HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{label} must be at least {HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS}"), + )); + } + if value > HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{label} must be at most {HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS}"), + )); + } + Ok(()) +} + impl Config { #[cfg(test)] async fn load_from_base_config_with_overrides( @@ -2652,18 +2686,34 @@ 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 { + validate_multi_agent_v2_wait_timeout( + "features.multi_agent_v2.min_wait_timeout_ms", + multi_agent_v2.min_wait_timeout_ms, + )?; + validate_multi_agent_v2_wait_timeout( + "features.multi_agent_v2.max_wait_timeout_ms", + multi_agent_v2.max_wait_timeout_ms, + )?; + validate_multi_agent_v2_wait_timeout( + "features.multi_agent_v2.default_wait_timeout_ms", + multi_agent_v2.default_wait_timeout_ms, + )?; + if multi_agent_v2.min_wait_timeout_ms > multi_agent_v2.max_wait_timeout_ms { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "features.multi_agent_v2.min_wait_timeout_ms must be at least 1", + "features.multi_agent_v2.min_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } - if multi_agent_v2.min_wait_timeout_ms > MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS { + if multi_agent_v2.default_wait_timeout_ms < multi_agent_v2.min_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}" - ), + "features.multi_agent_v2.default_wait_timeout_ms must be at least features.multi_agent_v2.min_wait_timeout_ms", + )); + } + if multi_agent_v2.default_wait_timeout_ms > multi_agent_v2.max_wait_timeout_ms { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 18748b33f..99137f4db 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -264,6 +264,8 @@ mod tests { enabled: Some(false), max_concurrent_threads_per_session: Some(_), min_wait_timeout_ms: Some(_), + max_wait_timeout_ms: Some(_), + default_wait_timeout_ms: Some(_), usage_hint_enabled: Some(_), hide_spawn_agent_metadata: Some(_), .. diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index e84465508..acb4adb94 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -64,6 +64,16 @@ pub(super) async fn spawn_review_thread( .enabled(Feature::MultiAgentV2) .then_some(config.multi_agent_v2.min_wait_timeout_ms), ) + .with_wait_agent_max_timeout_ms( + review_features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.max_wait_timeout_ms), + ) + .with_wait_agent_default_timeout_ms( + review_features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.default_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 71e238a54..c646b5833 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -234,6 +234,18 @@ impl TurnContext { .enabled(Feature::MultiAgentV2) .then_some(config.multi_agent_v2.min_wait_timeout_ms), ) + .with_wait_agent_max_timeout_ms( + config + .features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.max_wait_timeout_ms), + ) + .with_wait_agent_default_timeout_ms( + config + .features + .enabled(Feature::MultiAgentV2) + .then_some(config.multi_agent_v2.default_wait_timeout_ms), + ) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, )); @@ -539,6 +551,18 @@ impl Session { .enabled(Feature::MultiAgentV2) .then_some(per_turn_config.multi_agent_v2.min_wait_timeout_ms), ) + .with_wait_agent_max_timeout_ms( + per_turn_config + .features + .enabled(Feature::MultiAgentV2) + .then_some(per_turn_config.multi_agent_v2.max_wait_timeout_ms), + ) + .with_wait_agent_default_timeout_ms( + per_turn_config + .features + .enabled(Feature::MultiAgentV2) + .then_some(per_turn_config.multi_agent_v2.default_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 216a420e3..968810494 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -1,7 +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::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -30,7 +30,7 @@ use std::collections::HashMap; /// Minimum wait timeout to prevent tight polling loops from burning CPU. 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 = MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS; +pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = HARD_MAX_MULTI_AGENT_V2_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 1a5856173..12c25aa81 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2775,7 +2775,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )) .await } @@ -2808,7 +2808,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() { } #[tokio::test] -async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { +async fn multi_agent_v2_wait_agent_rejects_timeout_below_configured_min() { let (session, mut turn) = make_session_and_context().await; let mut config = (*turn.config).clone(); config @@ -2816,6 +2816,73 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); config.multi_agent_v2.min_wait_timeout_ms = 50; + config.multi_agent_v2.max_wait_timeout_ms = 1_000; + config.multi_agent_v2.default_wait_timeout_ms = 50; + turn.config = Arc::new(config); + + let Err(err) = WaitAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"timeout_ms": 1})), + )) + .await + else { + panic!("timeout below configured minimum should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("timeout_ms must be at least 50".to_string()) + ); +} + +#[tokio::test] +async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_min() { + 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 = 1; + config.multi_agent_v2.max_wait_timeout_ms = 1_000; + config.multi_agent_v2.default_wait_timeout_ms = 50; + turn.config = Arc::new(config); + + let output = WaitAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"timeout_ms": 1})), + )) + .await + .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 multi_agent_v2_wait_agent_uses_configured_default_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 = 1; + config.multi_agent_v2.max_wait_timeout_ms = 1_000; + config.multi_agent_v2.default_wait_timeout_ms = 50; turn.config = Arc::new(config); let session = Arc::new(session); let turn = Arc::new(turn); @@ -2826,13 +2893,13 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { session.clone(), turn.clone(), "wait_agent", - function_payload(json!({"timeout_ms": 1})), + function_payload(json!({})), )), ) .await; assert!( early.is_err(), - "wait_agent should not return before the configured minimum timeout" + "wait_agent should not return before the configured default timeout" ); let output = timeout( @@ -2841,11 +2908,11 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1})), + function_payload(json!({})), )), ) .await - .expect("configured minimum should be shorter than the test timeout") + .expect("configured default 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 = @@ -2860,6 +2927,111 @@ async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() { assert_eq!(success, None); } +#[tokio::test] +async fn multi_agent_v2_wait_agent_allows_zero_configured_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 = 0; + config.multi_agent_v2.max_wait_timeout_ms = 0; + config.multi_agent_v2.default_wait_timeout_ms = 0; + turn.config = Arc::new(config); + let session = Arc::new(session); + let turn = Arc::new(turn); + + let output = timeout( + Duration::from_secs(/*secs*/ 1), + WaitAgentHandlerV2::default().handle(invocation( + session, + turn, + "wait_agent", + function_payload(json!({})), + )), + ) + .await + .expect("zero timeout should complete immediately") + .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 multi_agent_v2_wait_agent_rejects_timeout_above_configured_max() { + 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 = 1; + config.multi_agent_v2.max_wait_timeout_ms = 50; + config.multi_agent_v2.default_wait_timeout_ms = 1; + turn.config = Arc::new(config); + + let Err(err) = WaitAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"timeout_ms": 500})), + )) + .await + else { + panic!("timeout above configured maximum should be rejected"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel("timeout_ms must be at most 50".to_string()) + ); +} + +#[tokio::test] +async fn multi_agent_v2_wait_agent_accepts_explicit_timeout_at_configured_max() { + 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 = 1; + config.multi_agent_v2.max_wait_timeout_ms = 1; + config.multi_agent_v2.default_wait_timeout_ms = 1; + turn.config = Arc::new(config); + + let output = WaitAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "wait_agent", + function_payload(json!({"timeout_ms": 1})), + )) + .await + .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; @@ -2873,7 +3045,7 @@ async fn wait_agent_returns_not_found_for_missing_agents() { "wait_agent", function_payload(json!({ "targets": [id_a.to_string(), id_b.to_string()], - "timeout_ms": 1000 + "timeout_ms": 10_000 })), ); let output = WaitAgentHandler::default() @@ -3009,7 +3181,7 @@ async fn wait_agent_returns_final_status_without_timeout() { "wait_agent", function_payload(json!({ "targets": [agent_id.to_string()], - "timeout_ms": 1000 + "timeout_ms": 10_000 })), ); let output = WaitAgentHandler::default() @@ -3088,7 +3260,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )) .await } @@ -3179,7 +3351,7 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )), ) .await @@ -3254,7 +3426,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )) .await } @@ -3339,7 +3511,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() { session, turn, "wait_agent", - function_payload(json!({"timeout_ms": 1000})), + function_payload(json!({"timeout_ms": 10_000})), )) .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 48cbebdea..5f246d679 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 @@ -40,19 +40,22 @@ impl ToolExecutor for Handler { } = invocation; 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(), - )); + let min_timeout_ms = turn.config.multi_agent_v2.min_wait_timeout_ms; + let max_timeout_ms = turn.config.multi_agent_v2.max_wait_timeout_ms; + let default_timeout_ms = turn.config.multi_agent_v2.default_wait_timeout_ms; + let timeout_ms = match args.timeout_ms { + Some(ms) if ms < min_timeout_ms => { + return Err(FunctionCallError::RespondToModel(format!( + "timeout_ms must be at least {min_timeout_ms}" + ))); } - ms => ms.clamp(min_timeout_ms, MAX_WAIT_TIMEOUT_MS), + Some(ms) if ms > max_timeout_ms => { + return Err(FunctionCallError::RespondToModel(format!( + "timeout_ms must be at most {max_timeout_ms}" + ))); + } + Some(ms) => ms, + None => default_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 1eca4d1dd..92aeed011 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,3 +1,6 @@ +use crate::config::DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS; +use crate::config::DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; +use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; @@ -35,16 +38,29 @@ pub(crate) fn build_specs_with_discoverable_tools( ) -> ToolRegistryBuilder { 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 (min_wait_timeout_ms, max_wait_timeout_ms, default_wait_timeout_ms) = + if config.multi_agent_v2 { + let min_wait_timeout_ms = config + .wait_agent_min_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS); + let max_wait_timeout_ms = config + .wait_agent_max_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS); + let default_wait_timeout_ms = config + .wait_agent_default_timeout_ms + .unwrap_or(DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS); + ( + min_wait_timeout_ms, + max_wait_timeout_ms, + default_wait_timeout_ms, + ) + } else { + ( + MIN_WAIT_TIMEOUT_MS, + MAX_WAIT_TIMEOUT_MS, + DEFAULT_WAIT_TIMEOUT_MS, + ) + }; build_tool_registry_builder( config, ToolRegistryBuildParams { @@ -57,7 +73,7 @@ pub(crate) fn build_specs_with_discoverable_tools( wait_agent_timeouts: WaitAgentTimeoutOptions { default_timeout_ms: default_wait_timeout_ms, min_timeout_ms: min_wait_timeout_ms, - max_timeout_ms: MAX_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 4f8141065..cf7386fc1 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -743,11 +743,15 @@ 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); +async fn multi_agent_v2_wait_agent_schema_uses_configured_timeouts() { + let wait_agent_min_timeout_ms = Some(20_000); + let wait_agent_max_timeout_ms = Some(120_000); + let wait_agent_default_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); + .with_wait_agent_min_timeout_ms(wait_agent_min_timeout_ms) + .with_wait_agent_max_timeout_ms(wait_agent_max_timeout_ms) + .with_wait_agent_default_timeout_ms(wait_agent_default_timeout_ms); let (tools, _) = build_specs( &tools_config, /*mcp_tools*/ None, @@ -767,7 +771,7 @@ async fn multi_agent_v2_wait_agent_schema_uses_configured_min_timeout() { assert_eq!( timeout_description, - Some("Optional timeout in milliseconds. Defaults to 60000, min 60000, max 3600000.") + Some("Optional timeout in milliseconds. Defaults to 60000, min 20000, max 120000.") ); } diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index b50d14f4e..2b69c2d04 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -13,9 +13,15 @@ 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))] + #[schemars(range(min = 0, max = 3600000))] pub min_wait_timeout_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 3600000))] + pub max_wait_timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 3600000))] + pub default_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 2b10f8336..e7aab9cec 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -482,6 +482,8 @@ fn multi_agent_v2_feature_config_deserializes_table() { enabled = true max_concurrent_threads_per_session = 4 min_wait_timeout_ms = 2500 +max_wait_timeout_ms = 120000 +default_wait_timeout_ms = 30000 usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." @@ -502,6 +504,8 @@ non_code_mode_only = true enabled: Some(true), max_concurrent_threads_per_session: Some(4), min_wait_timeout_ms: Some(2500), + max_wait_timeout_ms: Some(120000), + default_wait_timeout_ms: Some(30000), usage_hint_enabled: Some(false), usage_hint_text: Some("Custom delegation guidance.".to_string()), root_agent_usage_hint_text: Some("Root guidance.".to_string()), @@ -538,6 +542,8 @@ usage_hint_enabled = false enabled: None, max_concurrent_threads_per_session: None, min_wait_timeout_ms: None, + max_wait_timeout_ms: None, + default_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 390c4ee38..ad884e5be 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -123,6 +123,8 @@ pub struct ToolsConfig { pub spawn_agent_usage_hint_text: Option, pub max_concurrent_threads_per_session: Option, pub wait_agent_min_timeout_ms: Option, + pub wait_agent_max_timeout_ms: Option, + pub wait_agent_default_timeout_ms: Option, pub request_user_input_available_modes: Vec, pub experimental_supported_tools: Vec, pub agent_jobs_tools: bool, @@ -264,6 +266,8 @@ impl ToolsConfig { spawn_agent_usage_hint_text: None, max_concurrent_threads_per_session: None, wait_agent_min_timeout_ms: None, + wait_agent_max_timeout_ms: None, + wait_agent_default_timeout_ms: None, request_user_input_available_modes: request_user_input_available_modes(features), experimental_supported_tools: model_info.experimental_supported_tools.clone(), agent_jobs_tools: include_agent_jobs, @@ -346,6 +350,22 @@ impl ToolsConfig { self } + pub fn with_wait_agent_max_timeout_ms( + mut self, + wait_agent_max_timeout_ms: Option, + ) -> Self { + self.wait_agent_max_timeout_ms = wait_agent_max_timeout_ms; + self + } + + pub fn with_wait_agent_default_timeout_ms( + mut self, + wait_agent_default_timeout_ms: Option, + ) -> Self { + self.wait_agent_default_timeout_ms = wait_agent_default_timeout_ms; + self + } + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { self.allow_login_shell = allow_login_shell; self