Support catalog descriptions for all multi-agent V2 tools (#46297)

## Why

Model catalog description overrides only covered `spawn_agent`, leaving the other multi-agent V2 tools with fixed descriptions.

## What changed

Extend `model_messages.tools.multi_agent` description overrides to `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents`. Resolve each override by tool name across namespaced, plain, and Code Mode exposure.

Missing or null descriptions retain bundled text; empty strings suppress static text without disabling tools. Preserve `spawn_agent` runtime guidance, tool schemas, and execution behavior. Descriptions follow mid-turn model changes.

## Testing

Expand integration coverage to all six tools, including missing, null, empty, and sparse overrides; plain, namespaced, and Code Mode exposure; unchanged V1 behavior; and mid-turn model changes.

GitOrigin-RevId: a2c47eb8efb28c3eeebcc6482e36c188c38a6dd1
This commit is contained in:
rhan-oai
2026-09-17 19:54:05 +00:00
committed by copyberry
parent 17baabd01b
commit 3e581ebca8
10 changed files with 401 additions and 191 deletions

View File

@@ -9,6 +9,7 @@ pub(crate) mod handlers;
pub(crate) mod hook_names;
pub(crate) mod hosted_spec;
pub(crate) mod lifecycle;
mod multi_agent_tool;
pub(crate) mod network_approval;
pub(crate) mod orchestrator;
pub(crate) mod parallel;

View File

@@ -0,0 +1,102 @@
//! Applies captured Multi-Agent V2 description and namespace overrides to tool specifications.
//! Execution and parameter schemas are delegated unchanged to the underlying runtime.
use crate::session::session::Session;
use crate::tools::context::ToolInvocation;
use crate::tools::registry::CoreToolRuntime;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolExecutor;
use codex_tools::ToolExposure;
use codex_tools::ToolName;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
use futures::future::BoxFuture;
use std::sync::Arc;
const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
pub(super) fn multi_agent_v2_handler(
handler: impl CoreToolRuntime + 'static,
namespace: Option<&str>,
description_override: Option<&str>,
) -> Arc<dyn CoreToolRuntime> {
if namespace.is_none() && description_override.is_none() {
return Arc::new(handler);
}
Arc::new(MultiAgentV2ToolOverrides {
handler: Arc::new(handler),
namespace: namespace.map(str::to_owned),
description_override: description_override.map(str::to_owned),
})
}
struct MultiAgentV2ToolOverrides {
handler: Arc<dyn CoreToolRuntime>,
namespace: Option<String>,
description_override: Option<String>,
}
impl ToolExecutor<ToolInvocation> for MultiAgentV2ToolOverrides {
fn tool_name(&self) -> ToolName {
let tool_name = self.handler.tool_name();
match &self.namespace {
Some(namespace) => ToolName::namespaced(namespace.clone(), tool_name.name),
None => tool_name,
}
}
fn spec(&self) -> ToolSpec {
let mut spec = self.handler.spec();
if let ToolSpec::Function(tool) = &mut spec
&& let Some(description) = &self.description_override
{
tool.description.clone_from(description);
}
match (&self.namespace, spec) {
(Some(namespace), ToolSpec::Function(tool)) => {
ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
})
}
(_, spec) => spec,
}
}
fn exposure(&self) -> ToolExposure {
self.handler.exposure()
}
fn supports_parallel_tool_calls(&self) -> bool {
self.handler.supports_parallel_tool_calls()
}
fn search_info(&self) -> Option<ToolSearchInfo> {
self.handler.search_info()
}
fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
where
ToolInvocation: 'a,
{
self.handler.handle(invocation)
}
}
impl CoreToolRuntime for MultiAgentV2ToolOverrides {
fn wait_until_ready<'a>(&'a self, session: &'a Arc<Session>) -> Option<BoxFuture<'a, ()>> {
self.handler.wait_until_ready(session)
}
fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool {
self.handler.matches_kind(payload)
}
fn create_diff_consumer(
&self,
) -> Option<Box<dyn crate::tools::registry::ToolArgumentDiffConsumer>> {
self.handler.create_diff_consumer()
}
}

View File

@@ -5,7 +5,6 @@ use crate::image_preparation::unified_image_budget_enabled;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::code_mode::execute_spec::create_code_mode_tool;
use crate::tools::context::ToolInvocation;
use crate::tools::effective_tool_mode;
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::CodeModeExecuteHandler;
@@ -54,7 +53,7 @@ use crate::tools::handlers::tool_search_spec::ToolSearchSourceListing;
use crate::tools::handlers::view_image_spec::ViewImageToolOptions;
use crate::tools::hosted_spec::WebSearchToolOptions;
use crate::tools::hosted_spec::create_web_search_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::multi_agent_tool::multi_agent_v2_handler;
#[cfg(test)]
use crate::tools::registry::RegisteredTool;
use crate::tools::registry::ToolExposure;
@@ -81,7 +80,6 @@ use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolCall as ExtensionToolCall;
@@ -89,7 +87,6 @@ use codex_tools::ToolEnvironmentMode;
use codex_tools::ToolExecutor;
use codex_tools::ToolExposures;
use codex_tools::ToolName;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
use codex_tools::UnifiedExecShellMode;
use codex_tools::can_request_original_image_detail;
@@ -97,7 +94,6 @@ use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
use codex_tools::collect_request_plugin_install_entries;
use codex_tools::default_namespace_description;
use codex_tools::request_user_input_available_modes;
use futures::future::BoxFuture;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -105,7 +101,6 @@ use std::collections::btree_map::Entry;
use std::sync::Arc;
use tracing::instrument;
const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";
const IMAGE_GEN_NAMESPACE: &str = "image_gen";
const IMAGEGEN_TOOL_NAME: &str = "imagegen";
@@ -1248,8 +1243,9 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too
let turn_context = context.turn_context;
if collab_tools_enabled(turn_context, context.model_info) {
if multi_agent_v2_enabled(turn_context) {
let spawn_agent_description = ResolvedModelMessages::from_model(context.model_info)
.spawn_agent_description_override();
let model_messages = ResolvedModelMessages::from_model(context.model_info);
let spawn_agent_description =
model_messages.multi_agent_tool_description_override("spawn_agent");
let exposure = if turn_context.config.multi_agent_v2.non_code_mode_only {
ToolExposure::DirectModelOnly
} else {
@@ -1284,15 +1280,26 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too
spawn_agent_description.map(str::to_owned),
),
tool_namespace,
// Spawn composes the selected description with runtime model and usage guidance.
/*description_override*/
None,
),
exposure,
);
registry.register_trusted_with_exposure(
multi_agent_v2_handler(SendMessageHandlerV2, tool_namespace),
multi_agent_v2_handler(
SendMessageHandlerV2,
tool_namespace,
model_messages.multi_agent_tool_description_override("send_message"),
),
exposure,
);
registry.register_trusted_with_exposure(
multi_agent_v2_handler(FollowupTaskHandlerV2, tool_namespace),
multi_agent_v2_handler(
FollowupTaskHandlerV2,
tool_namespace,
model_messages.multi_agent_tool_description_override("followup_task"),
),
exposure,
);
if turn_context.config.multi_agent_v2.wait_agent_enabled {
@@ -1300,16 +1307,25 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too
multi_agent_v2_handler(
WaitAgentHandlerV2::new(context.wait_agent_timeouts),
tool_namespace,
model_messages.multi_agent_tool_description_override("wait_agent"),
),
exposure,
);
}
registry.register_trusted_with_exposure(
multi_agent_v2_handler(InterruptAgentHandler, tool_namespace),
multi_agent_v2_handler(
InterruptAgentHandler,
tool_namespace,
model_messages.multi_agent_tool_description_override("interrupt_agent"),
),
exposure,
);
registry.register_trusted_with_exposure(
multi_agent_v2_handler(ListAgentsHandlerV2, tool_namespace),
multi_agent_v2_handler(
ListAgentsHandlerV2,
tool_namespace,
model_messages.multi_agent_tool_description_override("list_agents"),
),
exposure,
);
} else {
@@ -1423,76 +1439,6 @@ fn append_extension_tool_executors(
standalone_web_search_tool
}
fn multi_agent_v2_handler(
handler: impl CoreToolRuntime + 'static,
namespace: Option<&str>,
) -> Arc<dyn CoreToolRuntime> {
match namespace {
Some(namespace) => Arc::new(MultiAgentV2NamespaceOverride {
handler: Arc::new(handler),
namespace: namespace.to_string(),
}),
None => Arc::new(handler),
}
}
struct MultiAgentV2NamespaceOverride {
handler: Arc<dyn CoreToolRuntime>,
namespace: String,
}
impl ToolExecutor<ToolInvocation> for MultiAgentV2NamespaceOverride {
fn tool_name(&self) -> ToolName {
ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name)
}
fn spec(&self) -> ToolSpec {
match self.handler.spec() {
ToolSpec::Function(tool) => ToolSpec::Namespace(ResponsesApiNamespace {
name: self.namespace.clone(),
description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
}),
spec => spec,
}
}
fn exposure(&self) -> ToolExposure {
self.handler.exposure()
}
fn supports_parallel_tool_calls(&self) -> bool {
self.handler.supports_parallel_tool_calls()
}
fn search_info(&self) -> Option<ToolSearchInfo> {
self.handler.search_info()
}
fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
where
ToolInvocation: 'a,
{
self.handler.handle(invocation)
}
}
impl CoreToolRuntime for MultiAgentV2NamespaceOverride {
fn wait_until_ready<'a>(&'a self, session: &'a Arc<Session>) -> Option<BoxFuture<'a, ()>> {
self.handler.wait_until_ready(session)
}
fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool {
self.handler.matches_kind(payload)
}
fn create_diff_consumer(
&self,
) -> Option<Box<dyn crate::tools::registry::ToolArgumentDiffConsumer>> {
self.handler.create_diff_consumer()
}
}
fn compare_code_mode_tools(
left: &codex_code_mode::ToolDefinition,
right: &codex_code_mode::ToolDefinition,

View File

@@ -131,6 +131,7 @@ mod models_cache_ttl;
mod models_etag_responses;
mod multi_agent_mode;
mod multi_agent_resume;
mod multi_agent_tool_descriptions;
#[cfg(unix)]
mod multi_exec_server_sandbox;
mod network_approval;
@@ -184,7 +185,6 @@ mod skill_approval;
mod skills;
mod skills_extension;
mod spawn_agent_description;
mod spawn_agent_tool_descriptions;
mod sqlite_state;
mod startup_cancellation;
mod step_settings;

View File

@@ -0,0 +1,208 @@
//! Verifies catalog descriptions reach V2 tools without changing their schemas or availability.
use anyhow::Result;
use codex_core::config::AgentRoleConfig;
use codex_features::Feature;
use codex_protocol::openai_models::ToolMessages;
use codex_protocol::protocol::MultiAgentVersion;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse_completed;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use test_case::test_case;
const TOOL_NAMES: [&str; 6] = [
"spawn_agent",
"send_message",
"followup_task",
"wait_agent",
"interrupt_agent",
"list_agents",
];
#[derive(Clone, Copy)]
enum Exposure {
Namespaced,
Plain,
CodeMode,
V1,
}
fn all_tool_messages(message: Value) -> Value {
json!({
"multi_agent": TOOL_NAMES
.map(|name| {
let mut message = message.clone();
if let Some(description) = message["description"].as_str() {
message["description"] = json!(description.replace("TOOL_NAME", name));
}
(name.to_string(), message)
})
.into_iter()
.collect::<serde_json::Map<String, Value>>()
})
}
#[test_case(json!(null), Exposure::Namespaced; "missing_tools")]
#[test_case(json!({}), Exposure::Namespaced; "missing_multi_agent")]
#[test_case(json!({"multi_agent": null}), Exposure::Namespaced; "null_multi_agent")]
#[test_case(json!({"multi_agent": {}}), Exposure::Namespaced; "missing_tools_in_family")]
#[test_case(all_tool_messages(json!(null)), Exposure::Namespaced; "null_tool")]
#[test_case(all_tool_messages(json!({})), Exposure::Namespaced; "missing_description")]
#[test_case(all_tool_messages(json!({"description": null})), Exposure::Namespaced; "null_description")]
#[test_case(all_tool_messages(json!({"description": " Catalog TOOL_NAME description.\n{{literal_placeholder}} "})), Exposure::Namespaced; "catalog_description")]
#[test_case(all_tool_messages(json!({"description": ""})), Exposure::Namespaced; "empty_description")]
#[test_case(json!({"multi_agent": {"send_message": {"description": "Catalog send."}}}), Exposure::Namespaced; "sparse_sibling_fallback")]
#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::Plain; "plain_tools")]
#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::CodeMode; "code_mode_declarations")]
#[test_case(all_tool_messages(json!({"description": ""})), Exposure::CodeMode; "empty_code_mode_descriptions")]
#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::V1; "v1_unchanged")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_agent_catalog_descriptions_preserve_outbound_schema(
tool_messages: Value,
exposure: Exposure,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = mount_sse_sequence(
&server,
vec![sse_completed("resp-default"), sse_completed("resp-catalog")],
)
.await;
for messages in [
None,
serde_json::from_value::<Option<ToolMessages>>(tool_messages.clone())?,
] {
let test = test_codex()
.with_model_info_override("gpt-5.2", move |model| {
model.multi_agent_version = Some(if matches!(exposure, Exposure::V1) {
MultiAgentVersion::V1
} else {
MultiAgentVersion::V2
});
model.model_messages.as_mut().expect("model messages").tools = messages;
})
.with_config(move |config| {
if matches!(exposure, Exposure::V1) {
config.features.enable(Feature::Collab).expect("enable V1");
config
.features
.disable(Feature::MultiAgentV2)
.expect("disable V2");
} else {
config
.features
.enable(Feature::MultiAgentV2)
.expect("enable V2");
}
if matches!(exposure, Exposure::CodeMode) {
config
.features
.enable(Feature::CodeMode)
.expect("enable Code Mode");
// Inspect declarations even when the test has no Code Mode host to execute them.
config.code_mode.disable_in_process_fallback = true;
config.multi_agent_v2.non_code_mode_only = false;
}
config.multi_agent_v2.tool_namespace =
(!matches!(exposure, Exposure::Plain)).then(|| "delegation".to_string());
config.multi_agent_v2.hide_spawn_agent_metadata = false;
config.multi_agent_v2.expose_spawn_agent_model_overrides = true;
config.multi_agent_v2.usage_hint_text = Some("Local delegation hint.".to_string());
config.agent_roles.insert(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research the assigned question.".to_string()),
config_file: None,
nickname_candidates: None,
},
);
})
.build_with_auto_env(&server)
.await?;
test.submit_turn("Inspect the available tools.").await?;
}
let requests = response.requests();
assert_eq!(requests.len(), 2);
let mut expected = requests[0].body_json()["tools"].clone();
let actual = requests[1].body_json()["tools"].clone();
if !matches!(exposure, Exposure::V1) {
let tools = if matches!(exposure, Exposure::Plain) {
expected.as_array_mut().expect("plain tools")
} else {
expected
.as_array_mut()
.expect("tools")
.iter_mut()
.find(|tool| tool["name"] == "delegation")
.expect("delegation namespace")["tools"]
.as_array_mut()
.expect("namespace tools")
};
let actual_tools = if matches!(exposure, Exposure::Plain) {
actual.as_array().expect("plain tools")
} else {
actual
.as_array()
.expect("tools")
.iter()
.find(|tool| tool["name"] == "delegation")
.expect("delegation namespace")["tools"]
.as_array()
.expect("namespace tools")
};
for name in TOOL_NAMES {
let expected_tool = tools
.iter_mut()
.find(|tool| tool["name"] == name)
.expect(name);
let actual_tool = actual_tools
.iter()
.find(|tool| tool["name"] == name)
.expect(name);
if let Some(description) = tool_messages["multi_agent"][name]["description"].as_str() {
let bundled = expected_tool["description"]
.as_str()
.expect("bundled description");
let declaration = bundled
.find("\n\nexec tool declaration:")
.map(|index| &bundled[index..])
.unwrap_or_default();
if matches!(exposure, Exposure::CodeMode) {
assert!(
declaration.contains(&format!("delegation__{name}")),
"Code Mode description for {name}: {bundled}"
);
}
let replacement = if name == "spawn_agent" {
let actual_description = actual_tool["description"]
.as_str()
.expect("spawn description");
assert!(actual_description.contains(description));
assert!(
!actual_description
.contains("Spawns an agent to work on the specified task.")
);
assert!(
actual_description
.strip_suffix(declaration)
.expect("unchanged declaration")
.ends_with("Local delegation hint.")
);
actual_description.to_string()
} else {
format!("{description}{declaration}")
};
expected_tool["description"] = json!(replacement);
}
}
}
assert_eq!(actual, expected);
Ok(())
}

View File

@@ -1,90 +0,0 @@
//! Verifies catalog tool descriptions reach the V2 outbound tool without changing its parameters.
use anyhow::Result;
use codex_core::config::AgentRoleConfig;
use codex_features::Feature;
use codex_protocol::openai_models::ToolMessages;
use codex_protocol::protocol::MultiAgentVersion;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::namespace_child_tool;
use core_test_support::responses::sse_completed;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use test_case::test_case;
#[test_case(json!(null); "missing_tools")]
#[test_case(json!({}); "missing_multi_agent")]
#[test_case(json!({"multi_agent": null}); "null_multi_agent")]
#[test_case(json!({"multi_agent": {}}); "missing_spawn_agent")]
#[test_case(json!({"multi_agent": {"spawn_agent": null}}); "null_spawn_agent")]
#[test_case(json!({"multi_agent": {"spawn_agent": {}}}); "missing_description")]
#[test_case(json!({"multi_agent": {"spawn_agent": {"description": null}}}); "null_description")]
#[test_case(json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn."}}}); "catalog_description")]
#[test_case(json!({"multi_agent": {"spawn_agent": {"description": ""}}}); "empty_description")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn spawn_agent_catalog_descriptions_preserve_outbound_schema(
tool_messages: Value,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let response = mount_sse_sequence(
&server,
vec![sse_completed("resp-default"), sse_completed("resp-catalog")],
)
.await;
for messages in [
None,
serde_json::from_value::<Option<ToolMessages>>(tool_messages.clone())?,
] {
let test = test_codex()
.with_model_info_override("gpt-5.2", move |model| {
model.multi_agent_version = Some(MultiAgentVersion::V2);
model.model_messages.as_mut().expect("model messages").tools = messages;
})
.with_config(|config| {
config
.features
.enable(Feature::MultiAgentV2)
.expect("enable V2");
config.multi_agent_v2.tool_namespace = Some("delegation".to_string());
config.multi_agent_v2.hide_spawn_agent_metadata = false;
config.multi_agent_v2.expose_spawn_agent_model_overrides = true;
config.multi_agent_v2.usage_hint_text = Some("Local delegation hint.".to_string());
config.agent_roles.insert(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research the assigned question.".to_string()),
config_file: None,
nickname_candidates: None,
},
);
})
.build_with_auto_env(&server)
.await?;
test.submit_turn("Inspect the available tools.").await?;
}
let requests = response.requests();
assert_eq!(requests.len(), 2);
let mut expected = namespace_child_tool(&requests[0].body_json(), "delegation", "spawn_agent")
.expect("default spawn agent tool")
.clone();
let actual = namespace_child_tool(&requests[1].body_json(), "delegation", "spawn_agent")
.expect("catalog spawn agent tool")
.clone();
let messages = &tool_messages["multi_agent"]["spawn_agent"];
if let Some(description) = messages["description"].as_str() {
let actual_description = actual["description"].as_str().expect("tool description");
assert!(actual_description.contains(description));
assert!(!actual_description.contains("Spawns an agent to work on the specified task."));
assert!(actual_description.ends_with("Local delegation hint."));
expected["description"] = actual["description"].clone();
}
assert_eq!(actual, expected);
Ok(())
}

View File

@@ -2229,6 +2229,15 @@ async fn model_activation_uses_destination_metadata_defaults(
async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> {
skip_if_no_network!(Ok(()));
const MULTI_AGENT_TOOLS: [&str; 6] = [
"spawn_agent",
"send_message",
"followup_task",
"wait_agent",
"interrupt_agent",
"list_agents",
];
let server = start_mock_server().await;
let response_mock = mount_sse_sequence(
&server,
@@ -2254,18 +2263,24 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> {
model
.experimental_supported_tools
.push("send_user_message_async".to_string());
let description = |name| {
Some(ToolMessage {
description: Some(format!("{name} description for {}.", model.slug)),
})
};
model
.model_messages
.as_mut()
.expect("model instruction metadata")
.tools = Some(ToolMessages {
send_user_message_async: Some(ToolMessage {
description: Some(format!("Async message description for {}.", model.slug)),
}),
send_user_message_async: description("Async message"),
multi_agent: Some(MultiAgentToolMessages {
spawn_agent: Some(ToolMessage {
description: Some(format!("Spawn description for {}.", model.slug)),
}),
spawn_agent: description("spawn_agent"),
send_message: description("send_message"),
followup_task: description("followup_task"),
wait_agent: description("wait_agent"),
interrupt_agent: description("interrupt_agent"),
list_agents: description("list_agents"),
}),
});
}
@@ -2302,12 +2317,14 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> {
.iter()
.find(|tool| tool["name"] == "request_user_input_async")
.expect("async message tool");
let spawn = namespace_child_tool(&body, "collaboration", "spawn_agent")
.expect("spawn agent tool");
let descriptions = MULTI_AGENT_TOOLS.map(|name| {
let tool = namespace_child_tool(&body, "collaboration", name).expect(name);
(name.to_string(), json!(tool["description"].as_str().expect("tool description").trim()))
}).into_iter().collect::<serde_json::Map<String, Value>>();
json!({
"model": body["model"],
"async_description": tool["description"],
"spawn_description": spawn["description"].as_str().expect("spawn description").trim(),
"multi_agent_descriptions": descriptions,
})
})
.collect::<Vec<_>>(),
@@ -2315,7 +2332,10 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> {
.map(|model| json!({
"model": model,
"async_description": format!("Async message description for {model}."),
"spawn_description": format!("Spawn description for {model}."),
"multi_agent_descriptions": MULTI_AGENT_TOOLS
.map(|name| (name.to_string(), json!(format!("{name} description for {model}."))))
.into_iter()
.collect::<serde_json::Map<String, Value>>(),
}))
.to_vec(),
);

View File

@@ -91,6 +91,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
spawn_agent: Some(ToolMessage {
description: Some("Catalog spawn description.".to_string()),
}),
..Default::default()
}),
}),
instructions_template: Some("template".to_string()),
@@ -161,6 +162,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() {
spawn_agent: Some(ToolMessage {
description: Some(String::new()),
}),
..Default::default()
}),
}),
approvals: Some(ApprovalMessages {

View File

@@ -151,13 +151,23 @@ impl<'a> ResolvedModelMessages<'a> {
.unwrap_or(REQUEST_USER_INPUT_ASYNC_DESCRIPTION)
}
/// Returns the V2 spawn tool's static description override, leaving missing text to the tool.
pub fn spawn_agent_description_override(&self) -> Option<&'a str> {
self.catalog_messages
/// Selects a V2 tool's static description by its name, independently of its runtime namespace.
/// Missing text retains the tool's bundled description; an empty string replaces it.
pub fn multi_agent_tool_description_override(&self, tool_name: &str) -> Option<&'a str> {
let tools = self
.catalog_messages
.and_then(|messages| messages.tools.as_ref())
.and_then(|tools| tools.multi_agent.as_ref())
.and_then(|tools| tools.spawn_agent.as_ref())
.and_then(|tool| tool.description.as_deref())
.and_then(|tools| tools.multi_agent.as_ref())?;
let tool = match tool_name {
"spawn_agent" => &tools.spawn_agent,
"send_message" => &tools.send_message,
"followup_task" => &tools.followup_task,
"wait_agent" => &tools.wait_agent,
"interrupt_agent" => &tools.interrupt_agent,
"list_agents" => &tools.list_agents,
_ => return None,
};
tool.as_ref()?.description.as_deref()
}
/// Resolves persistent-mode instructions without deciding whether the mode is active.

View File

@@ -583,8 +583,8 @@ pub struct ToolMessages {
/// Model-owned messages for a built-in tool.
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ToolMessage {
/// Missing or null uses the built-in description; an empty string leaves the description
/// empty without disabling the tool.
/// Missing or null uses the built-in description; an empty string suppresses its static
/// text without disabling the tool. Tool-owned runtime guidance is retained.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
@@ -596,6 +596,16 @@ pub struct MultiAgentToolMessages {
/// suppresses it. Generated model information and local usage hints are retained.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spawn_agent: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub send_message: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub followup_task: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_agent: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interrupt_agent: Option<ToolMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub list_agents: Option<ToolMessage>,
}
/// Model-owned defaults for the context-window token-budget feature.
@@ -1387,6 +1397,7 @@ mod tests {
spawn_agent: Some(ToolMessage {
description: Some("Catalog spawn description".to_string()),
}),
..Default::default()
}),
}),
instructions_template: None,