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
parent 157fffc6a4
commit fc26af377f
13 changed files with 269 additions and 32 deletions

View File

@@ -1485,6 +1485,9 @@
"minimum": 1.0,
"type": "integer"
},
"non_code_mode_only": {
"type": "boolean"
},
"root_agent_usage_hint_text": {
"type": "string"
},

View File

@@ -9656,6 +9656,7 @@ usage_hint_text = "Custom delegation guidance."
root_agent_usage_hint_text = "Root guidance."
subagent_usage_hint_text = "Subagent guidance."
hide_spawn_agent_metadata = true
non_code_mode_only = true
"#,
)?;
@@ -9683,6 +9684,7 @@ hide_spawn_agent_metadata = true
Some("Subagent guidance.")
);
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
assert!(config.multi_agent_v2.non_code_mode_only);
Ok(())
}
@@ -9702,6 +9704,7 @@ usage_hint_text = "base hint"
root_agent_usage_hint_text = "base root hint"
subagent_usage_hint_text = "base subagent hint"
hide_spawn_agent_metadata = true
non_code_mode_only = false
[profiles.no_hint.features.multi_agent_v2]
max_concurrent_threads_per_session = 6
@@ -9711,6 +9714,7 @@ usage_hint_text = "profile hint"
root_agent_usage_hint_text = "profile root hint"
subagent_usage_hint_text = "profile subagent hint"
hide_spawn_agent_metadata = false
non_code_mode_only = true
"#,
)?;
@@ -9736,6 +9740,7 @@ hide_spawn_agent_metadata = false
Some("profile subagent hint")
);
assert!(!config.multi_agent_v2.hide_spawn_agent_metadata);
assert!(config.multi_agent_v2.non_code_mode_only);
Ok(())
}
@@ -9759,6 +9764,7 @@ enabled = true
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4);
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000);
assert_eq!(config.agent_max_threads, Some(3));
assert!(!config.multi_agent_v2.non_code_mode_only);
Ok(())
}

View File

@@ -846,6 +846,7 @@ pub struct MultiAgentV2Config {
pub root_agent_usage_hint_text: Option<String>,
pub subagent_usage_hint_text: Option<String>,
pub hide_spawn_agent_metadata: bool,
pub non_code_mode_only: bool,
}
impl Default for MultiAgentV2Config {
@@ -859,6 +860,7 @@ impl Default for MultiAgentV2Config {
root_agent_usage_hint_text: None,
subagent_usage_hint_text: None,
hide_spawn_agent_metadata: false,
non_code_mode_only: false,
}
}
}
@@ -1995,6 +1997,10 @@ fn resolve_multi_agent_v2_config(
.and_then(|config| config.hide_spawn_agent_metadata)
.or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata))
.unwrap_or(default.hide_spawn_agent_metadata);
let non_code_mode_only = profile
.and_then(|config| config.non_code_mode_only)
.or_else(|| base.and_then(|config| config.non_code_mode_only))
.unwrap_or(default.non_code_mode_only);
MultiAgentV2Config {
max_concurrent_threads_per_session,
@@ -2004,6 +2010,7 @@ fn resolve_multi_agent_v2_config(
root_agent_usage_hint_text,
subagent_usage_hint_text,
hide_spawn_agent_metadata,
non_code_mode_only,
}
}

View File

@@ -56,6 +56,7 @@ pub(super) async fn spawn_review_thread(
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
.with_goal_tools_allowed(goal_tools_supported)
.with_max_concurrent_threads_per_session(config.agent_max_threads)
.with_wait_agent_min_timeout_ms(

View File

@@ -218,6 +218,7 @@ impl TurnContext {
.with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
.with_multi_agent_v2_non_code_mode_only(config.multi_agent_v2.non_code_mode_only)
.with_goal_tools_allowed(self.tools_config.goal_tools)
.with_max_concurrent_threads_per_session(
config
@@ -512,6 +513,7 @@ impl Session {
.with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled)
.with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone())
.with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata)
.with_multi_agent_v2_non_code_mode_only(per_turn_config.multi_agent_v2.non_code_mode_only)
.with_goal_tools_allowed(goal_tools_supported)
.with_max_concurrent_threads_per_session(
per_turn_config

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);

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

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,

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"));
}

View File

@@ -25,6 +25,8 @@ pub struct MultiAgentV2ConfigToml {
pub subagent_usage_hint_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hide_spawn_agent_metadata: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub non_code_mode_only: Option<bool>,
}
impl FeatureConfig for MultiAgentV2ConfigToml {

View File

@@ -481,6 +481,7 @@ usage_hint_text = "Custom delegation guidance."
root_agent_usage_hint_text = "Root guidance."
subagent_usage_hint_text = "Subagent guidance."
hide_spawn_agent_metadata = true
non_code_mode_only = true
"#,
)
.expect("features table should deserialize");
@@ -500,6 +501,7 @@ hide_spawn_agent_metadata = true
root_agent_usage_hint_text: Some("Root guidance.".to_string()),
subagent_usage_hint_text: Some("Subagent guidance.".to_string()),
hide_spawn_agent_metadata: Some(true),
non_code_mode_only: Some(true),
}))
);
}
@@ -535,6 +537,7 @@ usage_hint_enabled = false
root_agent_usage_hint_text: None,
subagent_usage_hint_text: None,
hide_spawn_agent_metadata: None,
non_code_mode_only: None,
}))
);
}

View File

@@ -117,6 +117,7 @@ pub struct ToolsConfig {
pub collab_tools: bool,
pub goal_tools: bool,
pub multi_agent_v2: bool,
pub multi_agent_v2_non_code_mode_only: bool,
pub hide_spawn_agent_metadata: bool,
pub spawn_agent_usage_hint: bool,
pub spawn_agent_usage_hint_text: Option<String>,
@@ -257,6 +258,7 @@ impl ToolsConfig {
collab_tools: include_collab_tools,
goal_tools: include_goal_tools,
multi_agent_v2: include_multi_agent_v2,
multi_agent_v2_non_code_mode_only: false,
hide_spawn_agent_metadata: false,
spawn_agent_usage_hint: true,
spawn_agent_usage_hint_text: None,
@@ -314,6 +316,15 @@ impl ToolsConfig {
self
}
pub fn with_multi_agent_v2_non_code_mode_only(
mut self,
multi_agent_v2_non_code_mode_only: bool,
) -> Self {
self.multi_agent_v2_non_code_mode_only =
self.multi_agent_v2 && multi_agent_v2_non_code_mode_only;
self
}
pub fn with_goal_tools_allowed(mut self, allowed: bool) -> Self {
self.goal_tools = self.goal_tools && allowed;
self

View File

@@ -5,12 +5,30 @@ use crate::ToolName;
use crate::ToolOutput;
use crate::ToolSpec;
/// Controls whether a tool is exposed in the initial model-visible tool list
/// or registered for later discovery.
/// Controls where a tool is exposed to the model.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ToolExposure {
/// Include this tool in the initial model-visible tool list.
///
/// When code mode is enabled, this tool is also available as a nested
/// code-mode tool.
Direct,
/// Register this tool for later discovery, but omit it from the initial
/// model-visible tool list.
Deferred,
/// Include this tool in the initial model-visible tool list only.
///
/// In code-mode-only sessions, this keeps the tool callable as a normal
/// model tool while excluding it from the nested code-mode tool surface.
DirectModelOnly,
}
impl ToolExposure {
pub fn is_direct(self) -> bool {
matches!(self, Self::Direct | Self::DirectModelOnly)
}
}
/// Shared runtime contract for model-visible tools.