mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
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:
@@ -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;
|
||||
|
||||
102
codex-rs/core/src/tools/multi_agent_tool.rs
Normal file
102
codex-rs/core/src/tools/multi_agent_tool.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
208
codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs
Normal file
208
codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs
Normal 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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user