feat: expose multi-agent v2 as model-only tools (#22514)

## Why

`code_mode_only` filters code-mode nested tools out of the top-level
tool list. For multi-agent v2, we need a rollout shape where the
collaboration tools remain callable as normal model tools without also
being embedded into the code-mode `exec` tool declaration.

Related to this:
https://openai-corpws.slack.com/archives/C0AQLHB4U75/p1778660267922549

## What Changed

- Adds `features.multi_agent_v2.non_code_mode_only`, including config
resolution, profile override handling, and generated schema coverage.
- Introduces `ToolExposure::DirectModelOnly` so a tool can be included
in the initial model-visible list while staying out of the nested
code-mode tool surface.
- Applies that exposure to the multi-agent v2 tools when the new flag is
set: `spawn_agent`, `send_message`, `followup_task`, `wait_agent`,
`close_agent`, and `list_agents`.
- Updates code-mode-only filtering so direct-model-only tools remain
visible while ordinary nested code-mode tools are still hidden.

## Verification

- Added config parsing/profile tests for `non_code_mode_only`.
- Added tool spec coverage for the code-mode-only multi-agent v2
exposure behavior.
This commit is contained in:
jif-oai
2026-05-13 19:49:47 +02:00
committed by GitHub
Unverified
parent 157fffc6a4
commit fc26af377f
13 changed files with 269 additions and 32 deletions
+78 -1
View File
@@ -263,6 +263,79 @@ where
}
}
pub(crate) fn override_tool_exposure(
handler: Arc<dyn RegisteredTool>,
exposure: ToolExposure,
) -> Arc<dyn RegisteredTool> {
if handler.exposure() == exposure {
return handler;
}
Arc::new(ExposureOverride { handler, exposure })
}
struct ExposureOverride {
handler: Arc<dyn RegisteredTool>,
exposure: ToolExposure,
}
impl RegisteredTool for ExposureOverride {
fn tool_name(&self) -> ToolName {
self.handler.tool_name()
}
fn spec(&self) -> Option<ToolSpec> {
self.handler.spec()
}
fn exposure(&self) -> ToolExposure {
self.exposure
}
fn search_info(&self) -> Option<ToolSearchInfo> {
self.handler.search_info()
}
fn supports_parallel_tool_calls(&self) -> bool {
self.handler.supports_parallel_tool_calls()
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
self.handler.matches_kind(payload)
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
self.handler.pre_tool_use_payload(invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
self.handler
.with_updated_hook_input(invocation, updated_input)
}
fn telemetry_tags<'a>(
&'a self,
invocation: &'a ToolInvocation,
) -> BoxFuture<'a, ToolTelemetryTags> {
self.handler.telemetry_tags(invocation)
}
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
self.handler.create_diff_consumer()
}
fn handle_any<'a>(
&'a self,
invocation: ToolInvocation,
) -> BoxFuture<'a, Result<AnyToolResult, FunctionCallError>> {
self.handler.handle_any(invocation)
}
}
pub struct ToolRegistry {
handlers: HashMap<ToolName, Arc<dyn RegisteredTool>>,
}
@@ -290,6 +363,10 @@ impl ToolRegistry {
self.handlers.get(name).map(Arc::clone)
}
pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option<ToolExposure> {
self.handlers.get(name).map(|handler| handler.exposure())
}
#[cfg(test)]
pub(crate) fn has_handler(&self, name: &ToolName) -> bool {
self.handler(name).is_some()
@@ -584,7 +661,7 @@ impl ToolRegistryBuilder {
}
if include_spec
&& handler.exposure() == ToolExposure::Direct
&& handler.exposure().is_direct()
&& let Some(spec) = handler.spec()
{
self.push_spec(spec);
+17 -4
View File
@@ -6,6 +6,7 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::AnyToolResult;
use crate::tools::registry::ToolArgumentDiffConsumer;
use crate::tools::registry::ToolExposure;
use crate::tools::registry::ToolRegistry;
use crate::tools::spec::build_specs_with_discoverable_tools;
use codex_extension_api::ExtensionToolExecutor;
@@ -63,10 +64,7 @@ impl ToolRouter {
let (specs, registry) = builder.build();
let model_visible_specs = specs
.into_iter()
.filter(|spec| {
!config.code_mode_only_enabled
|| !codex_code_mode::is_code_mode_nested_tool(spec.name())
})
.filter(|spec| !is_hidden_by_code_mode_only(config, &registry, spec))
.collect();
Self {
@@ -173,6 +171,21 @@ impl ToolRouter {
}
}
fn is_hidden_by_code_mode_only(
config: &ToolsConfig,
registry: &ToolRegistry,
spec: &ToolSpec,
) -> bool {
if !config.code_mode_only_enabled || !codex_code_mode::is_code_mode_nested_tool(spec.name()) {
return false;
}
let exposure = registry
.tool_exposure(&ToolName::plain(spec.name()))
.unwrap_or(ToolExposure::Direct);
exposure != ToolExposure::DirectModelOnly
}
pub(crate) fn extension_tool_executors(session: &Session) -> Vec<Arc<dyn ExtensionToolExecutor>> {
session
.services
+55 -25
View File
@@ -43,6 +43,7 @@ use crate::tools::hosted_spec::create_web_search_tool;
use crate::tools::registry::RegisteredTool;
use crate::tools::registry::ToolExposure;
use crate::tools::registry::ToolRegistryBuilder;
use crate::tools::registry::override_tool_exposure;
use crate::tools::spec_plan_types::ToolRegistryBuildParams;
use crate::tools::spec_plan_types::agent_type_description;
use codex_extension_api::ExtensionToolExecutor;
@@ -79,9 +80,9 @@ pub fn build_tool_registry_builder(
let mut deferred_search_infos = Vec::new();
for handler in &handlers {
match handler.exposure() {
ToolExposure::Direct => {
ToolExposure::Direct | ToolExposure::DirectModelOnly => {
if let Some(spec) = handler.spec() {
non_deferred_specs.push(spec);
non_deferred_specs.push((spec, handler.exposure()));
}
}
ToolExposure::Deferred => {
@@ -97,21 +98,27 @@ pub fn build_tool_registry_builder(
web_search_config: config.web_search_config.as_ref(),
web_search_tool_type: config.web_search_tool_type,
}) {
non_deferred_specs.push(web_search_tool);
non_deferred_specs.push((web_search_tool, ToolExposure::Direct));
}
if config.image_gen_tool {
non_deferred_specs.push(create_image_generation_tool("png"));
non_deferred_specs.push((create_image_generation_tool("png"), ToolExposure::Direct));
}
let non_deferred_specs = non_deferred_specs
.into_iter()
.map(|(spec, exposure)| {
if config.code_mode_enabled && exposure != ToolExposure::DirectModelOnly {
codex_tools::augment_tool_spec_for_code_mode(spec)
} else {
spec
}
})
.collect();
for spec in merge_into_namespaces(non_deferred_specs) {
if !config.namespace_tools && matches!(spec, ToolSpec::Namespace(_)) {
continue;
}
let spec = if config.code_mode_enabled {
codex_tools::augment_tool_spec_for_code_mode(spec)
} else {
spec
};
builder.push_spec(spec);
}
@@ -142,7 +149,14 @@ fn build_code_mode_handlers(
let mut code_mode_nested_tool_specs = handlers
.iter()
.filter_map(|handler| handler.spec())
.filter_map(|handler| {
if handler.exposure() == ToolExposure::DirectModelOnly {
return None;
}
let spec = handler.spec()?;
Some(spec)
})
.collect::<Vec<_>>();
code_mode_nested_tool_specs.extend(
extension_tool_executors
@@ -344,23 +358,32 @@ fn collect_handler_tools(
if config.collab_tools {
if config.multi_agent_v2 {
let exposure = if config.multi_agent_v2_non_code_mode_only {
ToolExposure::DirectModelOnly
} else {
ToolExposure::Direct
};
let agent_type_description =
agent_type_description(config, params.default_agent_type_description);
handlers.push(Arc::new(SpawnAgentHandlerV2::new(SpawnAgentToolOptions {
available_models: config.available_models.clone(),
agent_type_description,
hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata,
include_usage_hint: config.spawn_agent_usage_hint,
usage_hint_text: config.spawn_agent_usage_hint_text.clone(),
max_concurrent_threads_per_session: config.max_concurrent_threads_per_session,
})));
handlers.push(Arc::new(SendMessageHandlerV2));
handlers.push(Arc::new(FollowupTaskHandlerV2));
handlers.push(Arc::new(WaitAgentHandlerV2::new(
params.wait_agent_timeouts,
)));
handlers.push(Arc::new(CloseAgentHandlerV2));
handlers.push(Arc::new(ListAgentsHandlerV2));
handlers.push(multi_agent_v2_handler(
SpawnAgentHandlerV2::new(SpawnAgentToolOptions {
available_models: config.available_models.clone(),
agent_type_description,
hide_agent_type_model_reasoning: config.hide_spawn_agent_metadata,
include_usage_hint: config.spawn_agent_usage_hint,
usage_hint_text: config.spawn_agent_usage_hint_text.clone(),
max_concurrent_threads_per_session: config.max_concurrent_threads_per_session,
}),
exposure,
));
handlers.push(multi_agent_v2_handler(SendMessageHandlerV2, exposure));
handlers.push(multi_agent_v2_handler(FollowupTaskHandlerV2, exposure));
handlers.push(multi_agent_v2_handler(
WaitAgentHandlerV2::new(params.wait_agent_timeouts),
exposure,
));
handlers.push(multi_agent_v2_handler(CloseAgentHandlerV2, exposure));
handlers.push(multi_agent_v2_handler(ListAgentsHandlerV2, exposure));
} else {
let agent_type_description =
agent_type_description(config, params.default_agent_type_description);
@@ -416,6 +439,13 @@ fn collect_handler_tools(
handlers
}
fn multi_agent_v2_handler(
handler: impl RegisteredTool + 'static,
exposure: ToolExposure,
) -> Arc<dyn RegisteredTool> {
override_tool_exposure(Arc::new(handler), exposure)
}
fn compare_code_mode_tools(
left: &codex_code_mode::ToolDefinition,
right: &codex_code_mode::ToolDefinition,
+64
View File
@@ -1384,3 +1384,67 @@ async fn code_mode_only_restricts_model_tools_to_exec_tools() {
)
.await;
}
#[tokio::test]
async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() {
let config = test_config().await;
let model_info = construct_model_info_offline("gpt-5.4", &config);
let mut features = Features::with_defaults();
features.enable(Feature::CodeMode);
features.enable(Feature::CodeModeOnly);
features.enable(Feature::MultiAgentV2);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Live),
session_source: SessionSource::Cli,
permission_profile: &PermissionProfile::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.with_multi_agent_v2_non_code_mode_only(/*multi_agent_v2_non_code_mode_only*/ true);
let router = ToolRouter::from_config(
&tools_config,
ToolRouterParams {
mcp_tools: None,
deferred_mcp_tools: None,
discoverable_tools: None,
extension_tool_executors: Vec::new(),
dynamic_tools: &[],
},
);
let model_visible_specs = router.model_visible_specs();
let tool_names = model_visible_specs
.iter()
.map(ToolSpec::name)
.collect::<Vec<_>>();
assert_eq!(
tool_names,
vec![
"exec",
"wait",
"spawn_agent",
"send_message",
"followup_task",
"wait_agent",
"close_agent",
"list_agents",
]
);
let exec = find_tool(&model_visible_specs, "exec");
let ToolSpec::Freeform(exec) = exec else {
panic!("exec should be a freeform tool");
};
assert!(!exec.description.contains("spawn_agent"));
assert!(!exec.description.contains("wait_agent"));
let spawn_agent = find_tool(&model_visible_specs, "spawn_agent");
let ToolSpec::Function(spawn_agent) = spawn_agent else {
panic!("spawn_agent should be a function tool");
};
assert!(!spawn_agent.description.contains("exec tool declaration"));
}