chore: namespace v1 sub-agent tools (#23475)

## Why

The v1 sub-agent tools are a single tool family, but they were exposed
as separate flat function tools. This makes the model-visible surface
less clearly grouped and leaves the legacy names in the same flat
namespace as newer agent tooling.

## What

- Wraps the v1 `spawn_agent`, `send_input`, `resume_agent`,
`wait_agent`, and `close_agent` specs in the `multi_agent_v1` namespace.
- Registers the corresponding handlers with namespaced runtime tool
names.
- Updates tool-planning, deferred tool search, and sub-agent
notification tests to assert the namespace shape and child `spawn_agent`
lookup.

## Verification

- Updated `codex-core` coverage for the v1 multi-agent tool plan,
deferred tool search output, and sub-agent tool descriptions.
This commit is contained in:
jif-oai
2026-05-19 19:46:17 +02:00
committed by GitHub
Unverified
parent ccbf0137db
commit 05b8ce4354
15 changed files with 226 additions and 159 deletions
@@ -15,6 +15,7 @@ use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
pub(crate) use crate::tools::handlers::multi_agents_common::*;
use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
@@ -8,7 +8,7 @@ pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("close_agent")
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "close_agent")
}
fn spec(&self) -> Option<ToolSpec> {
@@ -10,7 +10,7 @@ pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("resume_agent")
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "resume_agent")
}
fn spec(&self) -> Option<ToolSpec> {
@@ -9,7 +9,7 @@ pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("send_input")
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "send_input")
}
fn spec(&self) -> Option<ToolSpec> {
@@ -25,7 +25,7 @@ impl Handler {
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("spawn_agent")
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent")
}
fn spec(&self) -> Option<ToolSpec> {
@@ -30,7 +30,7 @@ impl Handler {
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("wait_agent")
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "wait_agent")
}
fn spec(&self) -> Option<ToolSpec> {
@@ -1,11 +1,16 @@
use codex_protocol::openai_models::ModelPreset;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
pub const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1";
const MULTI_AGENT_V1_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one.";
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one.";
@@ -48,18 +53,22 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec {
hide_spawn_agent_metadata_options(&mut properties);
}
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
description: spawn_agent_tool_description(
available_models_description.as_deref(),
return_value_description,
options.include_usage_hint,
options.usage_hint_text,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: Some(spawn_agent_output_schema_v1()),
ToolSpec::Namespace(ResponsesApiNamespace {
name: MULTI_AGENT_V1_NAMESPACE.to_string(),
description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
description: spawn_agent_tool_description(
available_models_description.as_deref(),
return_value_description,
options.include_usage_hint,
options.usage_hint_text,
),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: Some(spawn_agent_output_schema_v1()),
})],
})
}
@@ -122,14 +131,18 @@ pub fn create_send_input_tool_v1() -> ToolSpec {
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "send_input".to_string(),
description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
output_schema: Some(send_input_output_schema()),
ToolSpec::Namespace(ResponsesApiNamespace {
name: MULTI_AGENT_V1_NAMESPACE.to_string(),
description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "send_input".to_string(),
description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
output_schema: Some(send_input_output_schema()),
})],
})
}
@@ -197,27 +210,35 @@ pub fn create_resume_agent_tool() -> ToolSpec {
JsonSchema::string(Some("Agent id to resume.".to_string())),
)]);
ToolSpec::Function(ResponsesApiTool {
name: "resume_agent".to_string(),
description:
"Resume a previously closed agent by id so it can receive send_input and wait_agent calls."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["id".to_string()]), Some(false.into())),
output_schema: Some(resume_agent_output_schema()),
ToolSpec::Namespace(ResponsesApiNamespace {
name: MULTI_AGENT_V1_NAMESPACE.to_string(),
description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "resume_agent".to_string(),
description:
"Resume a previously closed agent by id so it can receive send_input and wait_agent calls."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["id".to_string()]), Some(false.into())),
output_schema: Some(resume_agent_output_schema()),
})],
})
}
pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
.to_string(),
strict: false,
defer_loading: None,
parameters: wait_agent_tool_parameters_v1(options),
output_schema: Some(wait_output_schema_v1()),
ToolSpec::Namespace(ResponsesApiNamespace {
name: MULTI_AGENT_V1_NAMESPACE.to_string(),
description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
.to_string(),
strict: false,
defer_loading: None,
parameters: wait_agent_tool_parameters_v1(options),
output_schema: Some(wait_output_schema_v1()),
})],
})
}
@@ -260,13 +281,17 @@ pub fn create_close_agent_tool_v1() -> ToolSpec {
JsonSchema::string(Some("Agent id to close (from spawn_agent).".to_string())),
)]);
ToolSpec::Function(ResponsesApiTool {
name: "close_agent".to_string(),
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
output_schema: Some(close_agent_output_schema()),
ToolSpec::Namespace(ResponsesApiNamespace {
name: MULTI_AGENT_V1_NAMESPACE.to_string(),
description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "close_agent".to_string(),
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
output_schema: Some(close_agent_output_schema()),
})],
})
}
@@ -120,11 +120,17 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
max_concurrent_threads_per_session: None,
});
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
panic!("spawn_agent should be a function tool");
let ToolSpec::Namespace(namespace) = tool else {
panic!("spawn_agent v1 should be a namespace tool");
};
assert_eq!(namespace.name, MULTI_AGENT_V1_NAMESPACE);
let Some(ResponsesApiNamespaceTool::Function(ResponsesApiTool { parameters, .. })) =
namespace.tools.first()
else {
panic!("spawn_agent should be a namespace function tool");
};
assert_eq!(
parameters.schema_type,
parameters.schema_type.clone(),
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
+29 -4
View File
@@ -27,6 +27,7 @@ use serde_json::json;
use crate::session::tests::make_session_and_context;
use crate::session::turn_context::TurnContext;
use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;
use crate::tools::router::ToolRouter;
use crate::tools::router::ToolRouterParams;
@@ -602,14 +603,27 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
set_feature(turn, Feature::MultiAgentV2, /*enabled*/ false);
})
.await;
v1.assert_visible_contains(&[
v1.assert_visible_contains(&[MULTI_AGENT_V1_NAMESPACE]);
v1.assert_visible_lacks(&[
"spawn_agent",
"send_input",
"resume_agent",
"wait_agent",
"close_agent",
"send_message",
"followup_task",
"list_agents",
]);
v1.assert_visible_lacks(&["send_message", "followup_task", "list_agents"]);
assert_eq!(
v1.namespace_function_names(MULTI_AGENT_V1_NAMESPACE),
&[
"close_agent".to_string(),
"resume_agent".to_string(),
"send_input".to_string(),
"spawn_agent".to_string(),
"wait_agent".to_string(),
]
);
let v2 = probe(|turn| {
set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);
@@ -678,8 +692,19 @@ async fn v1_multi_agent_tools_defer_when_tool_search_available() {
"wait_agent",
"close_agent",
] {
plan.assert_registered_contains(&[tool_name]);
assert_eq!(plan.exposure(tool_name), ToolExposure::Deferred);
let namespaced_tool_name = ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, tool_name);
let namespaced_tool_name = namespaced_tool_name.to_string();
assert!(
plan.registered_names.contains(&namespaced_tool_name),
"expected namespaced runtime for {tool_name}"
);
assert!(
!plan
.registered_names
.contains(&ToolName::plain(tool_name).to_string()),
"expected no plain runtime for deferred {tool_name}"
);
assert_eq!(plan.exposure(&namespaced_tool_name), ToolExposure::Deferred);
}
let ToolSpec::ToolSearch { description, .. } = plan.visible_spec("tool_search") else {
panic!("expected visible tool_search spec");