Namespace multi-agent v2 tools under collaboration (#29067)

## Summary

Multi-agent v2 tools now use the fixed `collaboration` namespace when
namespace tools are available. This keeps the model-visible hint and the
actual tool surface aligned around `functions.collaboration.*`, without
exposing an unshipped namespace knob to users.

The PR also removes the old `features.multi_agent_v2.tool_namespace`
config/schema surface, updates the MAv2 test fixtures for namespaced
calls, and fixes stale `TurnContext.features` references that were
breaking `codex-core` builds.

## Changes

- Expose MAv2 tools under `collaboration` instead of relying on a
configurable namespace.
- Remove `tool_namespace` from MAv2 TOML config, resolved config,
validation, schema, and tests.
- Update tool-planning and integration fixtures to assert or emit
namespaced MAv2 tool calls.
- Read feature state through `TurnContext.config.features` in the
multi-agent mode context paths.

## Testing

- `just write-config-schema`
- `just test -p codex-features`
This commit is contained in:
jif
2026-06-23 13:15:20 +01:00
committed by GitHub
Unverified
parent 55bc38a22b
commit 49614a0391
8 changed files with 138 additions and 39 deletions
@@ -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,
@@ -3650,7 +3651,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"),
]),
)
+3 -2
View File
@@ -246,7 +246,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.
All agents share the same directory. In detail:
- All agents have access to the same container and filesystem as you.
@@ -1157,7 +1158,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,
}
+51 -15
View File
@@ -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<Vec<ToolInfo>>,
@@ -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
@@ -1464,12 +1505,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",
+14 -3
View File
@@ -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::<serde_json::Value>(&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"),
]),
)
@@ -37,6 +37,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";
const UNSUPPORTED_CODE_MODE_WARNING: &str = "does not advertise Code Mode support";
struct RemoteModelResponse {
@@ -335,7 +336,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);
@@ -349,7 +350,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(())
@@ -432,7 +438,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)
);
+4 -1
View File
@@ -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}"#,
)),
+9 -2
View File
@@ -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;
@@ -25,6 +25,8 @@ use std::time::Duration;
use test_case::test_case;
use tokio::time::timeout;
const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration";
fn rollout_budget() -> RolloutBudgetConfig {
RolloutBudgetConfig {
limit_tokens: 100,
@@ -130,7 +132,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),
]),
)
@@ -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;
@@ -48,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";
@@ -63,6 +63,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::<Value>(&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<Vec<u8>> {
let is_zstd = req
.headers
.get("content-encoding")
@@ -72,14 +89,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 {
@@ -1040,14 +1054,19 @@ 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"),
]),
)
.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"),
@@ -1057,7 +1076,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"),
@@ -1129,7 +1148,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"),
]),
)
@@ -1144,7 +1168,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;
@@ -1183,7 +1207,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"),
]),
)