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.
This commit is contained in:
Andrey Mishchenko
2026-05-13 14:43:06 -07:00
committed by GitHub
Unverified
parent 8ae0c837f0
commit 7c57a59f51
14 changed files with 541 additions and 53 deletions
+13 -1
View File
@@ -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": {
+166 -3
View File
@@ -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(())
}
+57 -7
View File
@@ -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<usize> = 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<u64> = 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<String>,
pub root_agent_usage_hint_text: Option<String>,
@@ -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);
+2
View File
@@ -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(_),
..
+10
View File
@@ -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,
));
+24
View File
@@ -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,
));
@@ -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<String, FunctionCallError> {
match payload {
@@ -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
}
@@ -40,19 +40,22 @@ impl ToolExecutor<ToolInvocation> 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();
+27 -11
View File
@@ -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,
},
},
)
+8 -4
View File
@@ -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.")
);
}
+7 -1
View File
@@ -13,9 +13,15 @@ pub struct MultiAgentV2ConfigToml {
#[schemars(range(min = 1))]
pub max_concurrent_threads_per_session: Option<usize>,
#[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<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 3600000))]
pub max_wait_timeout_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 0, max = 3600000))]
pub default_wait_timeout_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_hint_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_hint_text: Option<String>,
+6
View File
@@ -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,
+20
View File
@@ -123,6 +123,8 @@ pub struct ToolsConfig {
pub spawn_agent_usage_hint_text: Option<String>,
pub max_concurrent_threads_per_session: Option<usize>,
pub wait_agent_min_timeout_ms: Option<i64>,
pub wait_agent_max_timeout_ms: Option<i64>,
pub wait_agent_default_timeout_ms: Option<i64>,
pub request_user_input_available_modes: Vec<ModeKind>,
pub experimental_supported_tools: Vec<String>,
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<i64>,
) -> 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<i64>,
) -> 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