From 13f3a4694869c1e6fbab031e8e79034e51f929b0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 19 Jun 2026 10:50:02 +0200 Subject: [PATCH 1/5] Namespace multi-agent v2 tools under collaboration --- codex-rs/core/src/config/mod.rs | 5 +- codex-rs/core/src/tools/spec_plan_tests.rs | 66 ++++++++++++++----- codex-rs/core/tests/suite/agent_execution.rs | 17 ++++- .../tests/suite/model_runtime_selectors.rs | 12 +++- codex-rs/core/tests/suite/rollout_budget.rs | 10 ++- .../tests/suite/subagent_notifications.rs | 22 ++++++- 6 files changed, 104 insertions(+), 28 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4e0cb77febc6..5376e4442e64 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -238,7 +238,8 @@ Payload: ``` You may also see them addressed as to=/root/..., which indicates your identity is /root/... "#; -const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.spawn_agent` without a configured namespace or `to=functions.agents.spawn_agent` with `tool_namespace = "agents"`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. +const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; +const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. The goal is to correctly solve the problem in as little time as possible. Therefore, if at any point you can parallelize work by delegating tasks to another agent, you should do so to save time. @@ -1124,7 +1125,7 @@ impl MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, )), - tool_namespace: None, + tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), hide_spawn_agent_metadata: true, non_code_mode_only: true, } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 78eba0bc7d85..06d365358159 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -39,6 +39,8 @@ use crate::tools::router::ToolRouterParams; use crate::tools::router::ToolSuggestCandidates; use crate::tools::router::ToolSuggestPresentation; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; + #[derive(Default)] struct ToolPlanInputs { mcp_tools: Option>, @@ -1151,19 +1153,48 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { }); }) .await; - v2.assert_visible_contains(&[ + v2.assert_visible_contains(&[MULTI_AGENT_V2_NAMESPACE]); + v2.assert_visible_lacks(&[ "spawn_agent", "send_message", "followup_task", "wait_agent", "interrupt_agent", "list_agents", + "send_input", + "resume_agent", + "assign_task", + "close_agent", ]); - v2.assert_visible_lacks(&["send_input", "resume_agent", "assign_task", "close_agent"]); - let spawn_agent_description = match v2.visible_spec("spawn_agent") { - ToolSpec::Function(tool) => tool.description.as_str(), - other => panic!("expected spawn_agent function spec, got {other:?}"), + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "interrupt_agent", + "list_agents", + ] { + assert!( + v2.namespace_function_names(MULTI_AGENT_V2_NAMESPACE) + .iter() + .any(|name| name == tool_name), + "expected {tool_name} in {MULTI_AGENT_V2_NAMESPACE} namespace" + ); + } + let ToolSpec::Namespace(namespace) = v2.visible_spec(MULTI_AGENT_V2_NAMESPACE) else { + panic!("expected {MULTI_AGENT_V2_NAMESPACE} namespace"); }; + let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) = + namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent" + ) + }) + else { + panic!("expected spawn_agent in {MULTI_AGENT_V2_NAMESPACE} namespace"); + }; + let spawn_agent_description = spawn_agent.description.as_str(); assert!(!spawn_agent_description.contains("max_concurrent_threads_per_session")); assert!(spawn_agent_description.contains( "Note that passing `fork_turns=\"none\"` will not pass any surrounding context to the spawned subagent" @@ -1183,9 +1214,11 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { }); }) .await; - direct_model_only.assert_visible_contains(&["spawn_agent", "send_message", "wait_agent"]); + direct_model_only.assert_visible_contains(&[MULTI_AGENT_V2_NAMESPACE]); + direct_model_only.assert_visible_lacks(&["spawn_agent", "send_message", "wait_agent"]); assert_eq!( - direct_model_only.exposure("spawn_agent"), + direct_model_only + .exposure(&ToolName::namespaced(MULTI_AGENT_V2_NAMESPACE, "spawn_agent").to_string()), ToolExposure::DirectModelOnly ); } @@ -1196,9 +1229,17 @@ async fn multi_agent_v2_message_schemas_are_encrypted() { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); }) .await; + let ToolSpec::Namespace(namespace) = plan.visible_spec(MULTI_AGENT_V2_NAMESPACE) else { + panic!("expected {MULTI_AGENT_V2_NAMESPACE} namespace"); + }; for tool_name in ["spawn_agent", "send_message", "followup_task"] { - let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else { - panic!("expected {tool_name} function spec"); + let Some(ResponsesApiNamespaceTool::Function(tool)) = namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == tool_name + ) + }) else { + panic!("expected {tool_name} in {MULTI_AGENT_V2_NAMESPACE} namespace"); }; let properties = tool .parameters @@ -1463,12 +1504,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { codex_code_mode::WAIT_TOOL_NAME, "request_user_input", // Multi-agent v2 tools. - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "interrupt_agent", - "list_agents", + MULTI_AGENT_V2_NAMESPACE, // Hosted Responses tools. "web_search", "image_generation", diff --git a/codex-rs/core/tests/suite/agent_execution.rs b/codex-rs/core/tests/suite/agent_execution.rs index f073d7008915..69db20cc57f6 100644 --- a/codex-rs/core/tests/suite/agent_execution.rs +++ b/codex-rs/core/tests/suite/agent_execution.rs @@ -2,7 +2,7 @@ use anyhow::Result; use codex_features::Feature; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; @@ -15,6 +15,7 @@ use std::time::Duration; const FIRST_PROMPT: &str = "spawn the first worker"; const FIRST_TASK: &str = "first worker task"; const SECOND_TASK: &str = "second worker task"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; fn body_contains(request: &wiremock::Request, text: &str) -> bool { serde_json::from_slice::(&request.body) @@ -47,7 +48,12 @@ async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()> |request: &wiremock::Request| body_contains(request, FIRST_PROMPT), sse(vec![ ev_response_created("first-response"), - ev_function_call("first-call", "spawn_agent", &first_args), + ev_function_call_with_namespace( + "first-call", + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &first_args, + ), ev_completed("first-response"), ]), ) @@ -63,7 +69,12 @@ async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()> }, sse(vec![ ev_response_created("first-worker-response"), - ev_function_call("second-call", "spawn_agent", &second_args), + ev_function_call_with_namespace( + "second-call", + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &second_args, + ), ev_completed("first-worker-response"), ]), ) diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 3ee758372d22..560da1a98625 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -36,6 +36,7 @@ use tokio::time::sleep; const CHILD_MODEL: &str = "test-multi-agent-child"; const ROOT_MODEL: &str = "test-multi-agent-root"; const ROOT_PROMPT: &str = "spawn a child"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; fn remote_model(slug: &str) -> ModelInfo { ModelInfo { @@ -201,7 +202,7 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { .expect("test config should allow feature update"); }) .await?; - assert!(tool_names(&v2_body).contains(&"send_message".to_string())); + assert!(tool_names(&v2_body).contains(&MULTI_AGENT_V2_NAMESPACE.to_string())); let mut disabled_model = remote_model("test-multi-agent-disabled"); disabled_model.multi_agent_version = Some(MultiAgentVersion::Disabled); @@ -215,7 +216,12 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { let disabled_tools = tool_names(&disabled_body); assert!(disabled_tools.iter().all(|name| !matches!( name.as_str(), - "multi_agent_v1" | "spawn_agent" | "send_message" | "wait_agent" | "list_agents" + "multi_agent_v1" + | MULTI_AGENT_V2_NAMESPACE + | "spawn_agent" + | "send_message" + | "wait_agent" + | "list_agents" ))); Ok(()) @@ -298,7 +304,7 @@ async fn remote_multi_agent_selector_uses_model_selected_before_first_turn() -> .expect("expected response request") .body_json(), ) - .contains(&"send_message".to_string()), + .contains(&MULTI_AGENT_V2_NAMESPACE.to_string()), ), (1, Some(MultiAgentVersion::V2), true) ); diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 61c6dfe4240b..90a607903be7 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -10,7 +10,7 @@ use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; -use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; @@ -31,6 +31,7 @@ const ROLLOUT_BUDGET: RolloutBudgetConfig = RolloutBudgetConfig { sampling_token_weight: 1.0, prefill_token_weight: 1.0, }; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; fn rollout_budget_texts(request: &ResponsesRequest) -> Vec { request @@ -128,7 +129,12 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { |request: &wiremock::Request| wire_request_contains(request, ROOT_PROMPT), sse(vec![ ev_response_created("root-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed_with_tokens("root-1", /*total_tokens*/ 10), ]), ) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 2b9395f598e1..88feca03e844 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -47,6 +47,7 @@ use wiremock::MockServer; const SPAWN_CALL_ID: &str = "spawn-call-1"; const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; const TURN_0_FORK_PROMPT: &str = "seed fork context"; const TURN_1_PROMPT: &str = "spawn a child and continue"; const TURN_2_NO_WAIT_PROMPT: &str = "follow up without wait"; @@ -1039,7 +1040,12 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-parent-1"), ]), ) @@ -1131,7 +1137,12 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-parent-1"), ]), ) @@ -1185,7 +1196,12 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( }, sse(vec![ ev_response_created("resp-parent-3"), - ev_function_call("wait-agent-call", "wait_agent", "{}"), + ev_function_call_with_namespace( + "wait-agent-call", + MULTI_AGENT_V2_NAMESPACE, + "wait_agent", + "{}", + ), ev_completed("resp-parent-3"), ]), ) From 311fb54b831fb22cc42e17a7a1be282784592eb7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 19 Jun 2026 11:16:57 +0200 Subject: [PATCH 2/5] simplification --- codex-rs/core/src/config/config_tests.rs | 41 ------------ codex-rs/core/src/config/mod.rs | 73 +-------------------- codex-rs/core/src/tools/spec_plan.rs | 4 +- codex-rs/core/src/tools/spec_plan_tests.rs | 74 ++-------------------- codex-rs/features/src/feature_configs.rs | 3 - codex-rs/features/src/tests.rs | 5 +- 6 files changed, 11 insertions(+), 189 deletions(-) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index a6a9ab636523..e16780f9cdca 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9968,7 +9968,6 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." -tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -10005,10 +10004,6 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); - assert_eq!( - config.multi_agent_v2.tool_namespace.as_deref(), - Some("agents") - ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); @@ -10353,42 +10348,6 @@ default_wait_timeout_ms = 2500 Ok(()) } -#[tokio::test] -async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> { - for (namespace, expected_message) in [ - ( - "bad namespace", - "features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$", - ), - ( - "functions", - "features.multi_agent_v2.tool_namespace uses a reserved namespace: functions", - ), - ] { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - format!( - r#"[features.multi_agent_v2] -enabled = true -tool_namespace = "{namespace}" -"# - ), - )?; - - 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("invalid multi_agent_v2 tool namespace should fail"); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); - assert_eq!(err.to_string(), expected_message); - } - - 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 5376e4442e64..b21dd72c6408 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -238,7 +238,7 @@ Payload: ``` You may also see them addressed as to=/root/..., which indicates your identity is /root/... "#; -const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; +pub(crate) const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. The goal is to correctly solve the problem in as little time as possible. Therefore, if at any point you can parallelize work by delegating tasks to another agent, you should do so to save time. @@ -1103,7 +1103,6 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, - pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, pub non_code_mode_only: bool, } @@ -1125,7 +1124,6 @@ impl MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, )), - tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), hide_spawn_agent_metadata: true, non_code_mode_only: true, } @@ -2472,10 +2470,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config base.map(|config| &config.subagent_usage_hint_text), default.subagent_usage_hint_text, ); - let tool_namespace = base - .and_then(|config| config.tool_namespace.as_ref()) - .cloned() - .or(default.tool_namespace); let hide_spawn_agent_metadata = base .and_then(|config| config.hide_spawn_agent_metadata) .unwrap_or(default.hide_spawn_agent_metadata); @@ -2492,7 +2486,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, - tool_namespace, hide_spawn_agent_metadata, non_code_mode_only, } @@ -2713,69 +2706,6 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res Ok(()) } -fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> { - const LABEL: &str = "features.multi_agent_v2.tool_namespace"; - const MAX_LEN: usize = 64; - const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ - "api_tool", - "browser", - "computer", - "container", - "file_search", - "functions", - "image_gen", - "multi_tool_use", - "python", - "python_user_visible", - "submodel_delegator", - "terminal", - "tool_search", - "web", - ]; - - let Some(namespace) = namespace else { - return Ok(()); - }; - if namespace.is_empty() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{LABEL} must not be empty"), - )); - } - if namespace.trim() != namespace { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{LABEL} must not have leading or trailing whitespace"), - )); - } - if !namespace - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"), - )); - } - if namespace.chars().count() > MAX_LEN { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{LABEL} must be at most {MAX_LEN} characters"), - )); - } - if namespace == "mcp" - || namespace.starts_with("mcp__") - || RESERVED_RESPONSES_NAMESPACES.contains(&namespace) - { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("{LABEL} uses a reserved namespace: {namespace}"), - )); - } - - Ok(()) -} - impl Config { #[cfg(test)] async fn load_from_base_config_with_overrides( @@ -3344,7 +3274,6 @@ impl Config { "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } - 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); if agent_max_threads == Some(0) { return Err(std::io::Error::new( diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index af7acf17a010..b94504335aec 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1,5 +1,6 @@ use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; +use crate::config::DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE; use crate::session::turn_context::TurnContext; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::context::ToolInvocation; @@ -773,8 +774,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu ToolExposure::Direct }; let tool_namespace = namespace_tools_enabled(turn_context) - .then_some(turn_context.config.multi_agent_v2.tool_namespace.as_deref()) - .flatten(); + .then_some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE); let agent_type_description = agent_type_description(turn_context, context.default_agent_type_description); planned_tools.add_arc(override_tool_exposure( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 06d365358159..25a42d08464b 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1313,74 +1313,15 @@ async fn v1_multi_agent_tools_defer_when_tool_search_available() { assert!(description.contains("- Multi-agent tools: Spawn and manage sub-agents.")); } -#[tokio::test] -async fn multi_agent_v2_can_use_configured_tool_namespace() { - let namespaced = probe(|turn| { - set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); - update_config(turn, |config| { - config.multi_agent_v2.tool_namespace = Some("agents".to_string()); - }); - }) - .await; - - namespaced.assert_visible_contains(&["agents"]); - namespaced.assert_visible_lacks(&["assign_task"]); - assert!( - !namespaced - .registered_names - .contains(&ToolName::namespaced("agents", "assign_task").to_string()), - "expected no namespaced runtime for assign_task" - ); - assert!( - !namespaced - .namespace_function_names("agents") - .iter() - .any(|name| name == "assign_task"), - "expected assign_task to be absent from agents namespace" - ); - for tool_name in [ - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "interrupt_agent", - "list_agents", - ] { - namespaced.assert_visible_lacks(&[tool_name]); - assert!( - namespaced - .registered_names - .contains(&ToolName::namespaced("agents", tool_name).to_string()), - "expected namespaced runtime for {tool_name}" - ); - assert!( - !namespaced - .registered_names - .contains(&ToolName::plain(tool_name).to_string()), - "expected no plain runtime for {tool_name}" - ); - assert!( - namespaced - .namespace_function_names("agents") - .iter() - .any(|name| name == tool_name), - "expected {tool_name} in agents namespace" - ); - } -} - #[tokio::test] async fn multi_agent_v2_namespace_is_supported_by_bedrock_provider() { let plan = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); - update_config(turn, |config| { - config.multi_agent_v2.tool_namespace = Some("agents".to_string()); - }); use_bedrock_provider(turn); }) .await; - plan.assert_visible_contains(&["agents"]); + plan.assert_visible_contains(&[MULTI_AGENT_V2_NAMESPACE]); plan.assert_visible_lacks(&["spawn_agent", "send_message", "list_agents"]); assert!( !plan @@ -1389,7 +1330,7 @@ async fn multi_agent_v2_namespace_is_supported_by_bedrock_provider() { ); assert!( plan.registered_names - .contains(&ToolName::namespaced("agents", "spawn_agent").to_string()) + .contains(&ToolName::namespaced(MULTI_AGENT_V2_NAMESPACE, "spawn_agent").to_string()) ); } @@ -1406,7 +1347,6 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { ); update_config(turn, |config| { config.multi_agent_v2.non_code_mode_only = true; - config.multi_agent_v2.tool_namespace = Some("agents".to_string()); }); }) .await; @@ -1417,17 +1357,17 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { "exec", "wait", "request_user_input", - "agents", + MULTI_AGENT_V2_NAMESPACE, // Hosted Responses tools. "web_search", ] ); assert!( !plan - .namespace_function_names("agents") + .namespace_function_names(MULTI_AGENT_V2_NAMESPACE) .iter() .any(|name| name == "assign_task"), - "expected assign_task to be absent from agents namespace" + "expected assign_task to be absent from {MULTI_AGENT_V2_NAMESPACE} namespace" ); for tool_name in [ "spawn_agent", @@ -1438,10 +1378,10 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { "list_agents", ] { assert!( - plan.namespace_function_names("agents") + plan.namespace_function_names(MULTI_AGENT_V2_NAMESPACE) .iter() .any(|name| name == tool_name), - "expected {tool_name} in agents namespace" + "expected {tool_name} in {MULTI_AGENT_V2_NAMESPACE} namespace" ); } } diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index dc78d8cd4396..cfd99bfaadb2 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -55,9 +55,6 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] - #[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))] - pub tool_namespace: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub non_code_mode_only: Option, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7ee46f4ba290..23f374e510d5 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -574,7 +574,6 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." -tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -597,7 +596,6 @@ non_code_mode_only = true usage_hint_text: Some("Custom delegation guidance.".to_string()), root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), - tool_namespace: Some("agents".to_string()), hide_spawn_agent_metadata: Some(true), non_code_mode_only: Some(true), })) @@ -636,7 +634,6 @@ usage_hint_enabled = false usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, - tool_namespace: None, hide_spawn_agent_metadata: None, non_code_mode_only: None, })) @@ -737,7 +734,7 @@ fn unstable_warning_event_only_mentions_enabled_under_development_features() { fn unstable_warning_event_mentions_enabled_structured_under_development_feature() { let configured_features: Table = toml::from_str( r#" -multi_agent_v2 = { enabled = true, tool_namespace = "agents" } +multi_agent_v2 = { enabled = true, max_concurrent_threads_per_session = 2 } code_mode = true "#, ) From 325e89d927c01d87d21286a1cf2a2454ee119450 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 19 Jun 2026 11:22:05 +0200 Subject: [PATCH 3/5] Update config schema for fixed MAv2 namespace --- codex-rs/core/config.schema.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7d21c9069061..d2c268943aef 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1705,12 +1705,6 @@ "subagent_usage_hint_text": { "type": "string" }, - "tool_namespace": { - "maxLength": 64, - "minLength": 1, - "pattern": "^[a-zA-Z0-9_-]+$", - "type": "string" - }, "usage_hint_enabled": { "type": "boolean" }, From 3015fcab814eff506c3d631619326de3f29340ad Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 19 Jun 2026 11:50:32 +0200 Subject: [PATCH 4/5] Default MAv2 namespace to collaboration --- .../app-server/tests/suite/v2/turn_start.rs | 8 ++- codex-rs/core/config.schema.json | 6 ++ codex-rs/core/src/config/config_tests.rs | 41 +++++++++++ codex-rs/core/src/config/mod.rs | 71 +++++++++++++++++++ codex-rs/core/src/tools/spec_plan.rs | 4 +- codex-rs/core/tests/suite/pending_input.rs | 5 +- .../tests/suite/subagent_notifications.rs | 31 +++++--- codex-rs/features/src/feature_configs.rs | 3 + codex-rs/features/src/tests.rs | 5 +- 9 files changed, 160 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 10d15999676a..18a048455133 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -100,6 +100,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const TEST_ORIGINATOR: &str = "codex_vscode"; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, @@ -3653,7 +3654,12 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> { |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), responses::sse(vec![ responses::ev_response_created("resp-parent-1"), - responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), responses::ev_completed("resp-parent-1"), ]), ) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index d2c268943aef..7d21c9069061 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1705,6 +1705,12 @@ "subagent_usage_hint_text": { "type": "string" }, + "tool_namespace": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "type": "string" + }, "usage_hint_enabled": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e16780f9cdca..a6a9ab636523 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9968,6 +9968,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -10004,6 +10005,10 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); + assert_eq!( + config.multi_agent_v2.tool_namespace.as_deref(), + Some("agents") + ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); @@ -10348,6 +10353,42 @@ default_wait_timeout_ms = 2500 Ok(()) } +#[tokio::test] +async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> { + for (namespace, expected_message) in [ + ( + "bad namespace", + "features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$", + ), + ( + "functions", + "features.multi_agent_v2.tool_namespace uses a reserved namespace: functions", + ), + ] { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#"[features.multi_agent_v2] +enabled = true +tool_namespace = "{namespace}" +"# + ), + )?; + + 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("invalid multi_agent_v2 tool namespace should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(err.to_string(), expected_message); + } + + 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 b21dd72c6408..68d1bfd8c228 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1103,6 +1103,7 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, + pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, pub non_code_mode_only: bool, } @@ -1124,6 +1125,7 @@ impl MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, )), + tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), hide_spawn_agent_metadata: true, non_code_mode_only: true, } @@ -2470,6 +2472,10 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config base.map(|config| &config.subagent_usage_hint_text), default.subagent_usage_hint_text, ); + let tool_namespace = base + .and_then(|config| config.tool_namespace.as_ref()) + .cloned() + .or(default.tool_namespace); let hide_spawn_agent_metadata = base .and_then(|config| config.hide_spawn_agent_metadata) .unwrap_or(default.hide_spawn_agent_metadata); @@ -2486,6 +2492,7 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, + tool_namespace, hide_spawn_agent_metadata, non_code_mode_only, } @@ -2706,6 +2713,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res Ok(()) } +fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> { + const LABEL: &str = "features.multi_agent_v2.tool_namespace"; + const MAX_LEN: usize = 64; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + let Some(namespace) = namespace else { + return Ok(()); + }; + if namespace.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not be empty"), + )); + } + if namespace.trim() != namespace { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not have leading or trailing whitespace"), + )); + } + if !namespace + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"), + )); + } + if namespace.chars().count() > MAX_LEN { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must be at most {MAX_LEN} characters"), + )); + } + if namespace == "mcp" + || namespace.starts_with("mcp__") + || RESERVED_RESPONSES_NAMESPACES.contains(&namespace) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} uses a reserved namespace: {namespace}"), + )); + } + + Ok(()) +} + impl Config { #[cfg(test)] async fn load_from_base_config_with_overrides( @@ -3274,6 +3344,7 @@ impl Config { "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } + 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); if agent_max_threads == Some(0) { return Err(std::io::Error::new( diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index b94504335aec..af7acf17a010 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1,6 +1,5 @@ use crate::agent::exceeds_thread_spawn_depth_limit; use crate::agent::next_thread_spawn_depth; -use crate::config::DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE; use crate::session::turn_context::TurnContext; use crate::tools::code_mode::execute_spec::create_code_mode_tool; use crate::tools::context::ToolInvocation; @@ -774,7 +773,8 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu ToolExposure::Direct }; let tool_namespace = namespace_tools_enabled(turn_context) - .then_some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE); + .then_some(turn_context.config.multi_agent_v2.tool_namespace.as_deref()) + .flatten(); let agent_type_description = agent_type_description(turn_context, context.default_agent_type_description); planned_tools.add_arc(override_tool_exposure( diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 269beae63833..17e00513fed5 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -20,6 +20,7 @@ use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_reasoning_item; @@ -290,11 +291,13 @@ async fn steer_interrupts_wait_agent_and_is_sent_in_follow_up_request() { const WAIT_CALL_ID: &str = "wait-call"; const INITIAL_PROMPT: &str = "wait for an agent"; const STEER_PROMPT: &str = "stop waiting and continue"; + const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; let first_chunks = vec![ chunk(ev_response_created("resp-1")), - chunk(ev_function_call( + chunk(ev_function_call_with_namespace( WAIT_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, "wait_agent", r#"{"timeout_ms":10000}"#, )), diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 88feca03e844..a8869e8c2bea 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -17,7 +17,6 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -63,6 +62,23 @@ const SUBAGENT_STOP_CONTINUATION: &str = "continue only the child"; const INTERNAL_SUBAGENT_PROMPT: &str = "internal subagent: review"; fn body_contains(req: &wiremock::Request, text: &str) -> bool { + decoded_body(req) + .and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) +} + +fn request_has_input_type(req: &wiremock::Request, ty: &str) -> bool { + decoded_body(req) + .and_then(|body| serde_json::from_slice::(&body).ok()) + .and_then(|body| body.get("input").and_then(Value::as_array).cloned()) + .is_some_and(|items| { + items + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some(ty)) + }) +} + +fn decoded_body(req: &wiremock::Request) -> Option> { let is_zstd = req .headers .get("content-encoding") @@ -72,14 +88,11 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool { .split(',') .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) }); - let bytes = if is_zstd { + if is_zstd { zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok() } else { Some(req.body.clone()) - }; - bytes - .and_then(|body| String::from_utf8(body).ok()) - .is_some_and(|body| body.contains(text)) + } } fn has_subagent_notification(req: &ResponsesRequest) -> bool { @@ -1052,7 +1065,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result .await; let child_request_log = mount_sse_once_match( &server, - |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + |req: &wiremock::Request| request_has_input_type(req, "agent_message"), sse(vec![ ev_response_created("resp-child-1"), ev_completed("resp-child-1"), @@ -1062,7 +1075,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result mount_sse_once_match( &server, |req: &wiremock::Request| { - body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "\"type\":\"agent_message\"") + body_contains(req, SPAWN_CALL_ID) && !request_has_input_type(req, "agent_message") }, sse(vec![ ev_response_created("resp-parent-2"), @@ -1157,7 +1170,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( }; let child_request = mount_response_once_match( &server, - |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + |req: &wiremock::Request| request_has_input_type(req, "agent_message"), sse_response(sse(child_events)).set_delay(Duration::from_secs(1)), ) .await; diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index cfd99bfaadb2..dc78d8cd4396 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -55,6 +55,9 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))] + pub tool_namespace: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] pub non_code_mode_only: Option, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 23f374e510d5..7ee46f4ba290 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -574,6 +574,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -596,6 +597,7 @@ non_code_mode_only = true usage_hint_text: Some("Custom delegation guidance.".to_string()), root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), + tool_namespace: Some("agents".to_string()), hide_spawn_agent_metadata: Some(true), non_code_mode_only: Some(true), })) @@ -634,6 +636,7 @@ usage_hint_enabled = false usage_hint_text: None, root_agent_usage_hint_text: None, subagent_usage_hint_text: None, + tool_namespace: None, hide_spawn_agent_metadata: None, non_code_mode_only: None, })) @@ -734,7 +737,7 @@ fn unstable_warning_event_only_mentions_enabled_under_development_features() { fn unstable_warning_event_mentions_enabled_structured_under_development_feature() { let configured_features: Table = toml::from_str( r#" -multi_agent_v2 = { enabled = true, max_concurrent_threads_per_session = 2 } +multi_agent_v2 = { enabled = true, tool_namespace = "agents" } code_mode = true "#, ) From 0f8faee3040a45439438557341405b8acfff046e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 19 Jun 2026 12:05:44 +0200 Subject: [PATCH 5/5] Trim MAv2 namespace PR scope --- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/tools/spec_plan_tests.rs | 74 ++++++++++++++++++++-- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 68d1bfd8c228..5376e4442e64 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -238,7 +238,7 @@ Payload: ``` You may also see them addressed as to=/root/..., which indicates your identity is /root/... "#; -pub(crate) const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; +const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. The goal is to correctly solve the problem in as little time as possible. Therefore, if at any point you can parallelize work by delegating tasks to another agent, you should do so to save time. diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 25a42d08464b..06d365358159 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1313,15 +1313,74 @@ async fn v1_multi_agent_tools_defer_when_tool_search_available() { assert!(description.contains("- Multi-agent tools: Spawn and manage sub-agents.")); } +#[tokio::test] +async fn multi_agent_v2_can_use_configured_tool_namespace() { + let namespaced = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); + }) + .await; + + namespaced.assert_visible_contains(&["agents"]); + namespaced.assert_visible_lacks(&["assign_task"]); + assert!( + !namespaced + .registered_names + .contains(&ToolName::namespaced("agents", "assign_task").to_string()), + "expected no namespaced runtime for assign_task" + ); + assert!( + !namespaced + .namespace_function_names("agents") + .iter() + .any(|name| name == "assign_task"), + "expected assign_task to be absent from agents namespace" + ); + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "interrupt_agent", + "list_agents", + ] { + namespaced.assert_visible_lacks(&[tool_name]); + assert!( + namespaced + .registered_names + .contains(&ToolName::namespaced("agents", tool_name).to_string()), + "expected namespaced runtime for {tool_name}" + ); + assert!( + !namespaced + .registered_names + .contains(&ToolName::plain(tool_name).to_string()), + "expected no plain runtime for {tool_name}" + ); + assert!( + namespaced + .namespace_function_names("agents") + .iter() + .any(|name| name == tool_name), + "expected {tool_name} in agents namespace" + ); + } +} + #[tokio::test] async fn multi_agent_v2_namespace_is_supported_by_bedrock_provider() { let plan = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); use_bedrock_provider(turn); }) .await; - plan.assert_visible_contains(&[MULTI_AGENT_V2_NAMESPACE]); + plan.assert_visible_contains(&["agents"]); plan.assert_visible_lacks(&["spawn_agent", "send_message", "list_agents"]); assert!( !plan @@ -1330,7 +1389,7 @@ async fn multi_agent_v2_namespace_is_supported_by_bedrock_provider() { ); assert!( plan.registered_names - .contains(&ToolName::namespaced(MULTI_AGENT_V2_NAMESPACE, "spawn_agent").to_string()) + .contains(&ToolName::namespaced("agents", "spawn_agent").to_string()) ); } @@ -1347,6 +1406,7 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { ); update_config(turn, |config| { config.multi_agent_v2.non_code_mode_only = true; + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); }); }) .await; @@ -1357,17 +1417,17 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { "exec", "wait", "request_user_input", - MULTI_AGENT_V2_NAMESPACE, + "agents", // Hosted Responses tools. "web_search", ] ); assert!( !plan - .namespace_function_names(MULTI_AGENT_V2_NAMESPACE) + .namespace_function_names("agents") .iter() .any(|name| name == "assign_task"), - "expected assign_task to be absent from {MULTI_AGENT_V2_NAMESPACE} namespace" + "expected assign_task to be absent from agents namespace" ); for tool_name in [ "spawn_agent", @@ -1378,10 +1438,10 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { "list_agents", ] { assert!( - plan.namespace_function_names(MULTI_AGENT_V2_NAMESPACE) + plan.namespace_function_names("agents") .iter() .any(|name| name == tool_name), - "expected {tool_name} in {MULTI_AGENT_V2_NAMESPACE} namespace" + "expected {tool_name} in agents namespace" ); } }