Expose spawn agent types only when roles are configured (#33572)

## Why

The `agent_type` argument is only useful when agent roles are configured. Avoid
advertising an inapplicable override in the model-visible `spawn_agent` schema.

## What changed

- Include `agent_type` in the V1 and V2 spawn tools only when configured roles
  are available.
- Clarify that `agent_type` is an explicit override and document its interaction
  with context forking.
- Remove the unused `agents.support_agent_type` configuration field.

## Testing

- Add coverage for hiding `agent_type` without roles and exposing it when a role
  is configured.

GitOrigin-RevId: 32e0f123b15f317e9cdb71774029375bc073d9ad
This commit is contained in:
jif
2026-07-16 10:28:36 +00:00
committed by copyberry
parent 03bb3b1236
commit 9ff47868eb
14 changed files with 96 additions and 49 deletions
-3
View File
@@ -688,9 +688,6 @@ pub struct AgentsToml {
pub max_concurrent_threads_per_session: Option<usize>,
/// Maximum nesting depth for V1 agent threads. Ignored by V2.
pub max_depth: Option<i32>,
/// Reserved for controlling whether the spawn tool supports agent types.
/// Currently ignored.
pub support_agent_type: Option<bool>,
/// Reserved default model for spawned subagents. Currently ignored.
pub default_subagent_model: Option<String>,
/// Reserved default reasoning effort for spawned subagents. Currently ignored.
-4
View File
@@ -72,10 +72,6 @@
"description": "Maximum nesting depth for V1 agent threads. Ignored by V2.",
"format": "int32",
"type": "integer"
},
"support_agent_type": {
"description": "Reserved for controlling whether the spawn tool supports agent types. Currently ignored.",
"type": "boolean"
}
},
"type": "object"
+1 -4
View File
@@ -241,10 +241,7 @@ pub(crate) mod spawn_tool_spec {
}
}
format!(
"Optional type name for the new agent. If omitted, `{DEFAULT_ROLE_NAME}` is used.\nAvailable roles:\n{}",
formatted_roles.join("\n"),
)
format!("Available roles:\n{}", formatted_roles.join("\n"))
}
fn format_role(name: &str, declaration: &AgentRoleConfig) -> String {
-8
View File
@@ -7351,7 +7351,6 @@ async fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
support_agent_type: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
@@ -8279,7 +8278,6 @@ async fn load_config_resolves_agent_controls() -> std::io::Result<()> {
agents: Some(AgentsToml {
enabled: Some(false),
max_depth: Some(2),
support_agent_type: Some(true),
default_subagent_model: Some("gpt-5.6-terra".to_string()),
default_subagent_reasoning_effort: Some(ReasoningEffort::High),
interrupt_message: Some(false),
@@ -8299,7 +8297,6 @@ async fn load_config_resolves_agent_controls() -> std::io::Result<()> {
(
config.agents_enabled,
config.agent_max_depth,
config.agent_support_agent_type,
config.agent_default_subagent_model.as_deref(),
config.agent_default_subagent_reasoning_effort,
config.agent_interrupt_message_enabled,
@@ -8307,7 +8304,6 @@ async fn load_config_resolves_agent_controls() -> std::io::Result<()> {
(
false,
2,
Some(true),
Some("gpt-5.6-terra"),
Some(ReasoningEffort::High),
false,
@@ -8346,7 +8342,6 @@ async fn load_config_normalizes_agent_role_nickname_candidates() -> std::io::Res
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
support_agent_type: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
@@ -8393,7 +8388,6 @@ async fn load_config_rejects_empty_agent_role_nickname_candidates() -> std::io::
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
support_agent_type: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
@@ -8434,7 +8428,6 @@ async fn load_config_rejects_duplicate_agent_role_nickname_candidates() -> std::
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
support_agent_type: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
@@ -8475,7 +8468,6 @@ async fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io:
enabled: None,
max_concurrent_threads_per_session: None,
max_depth: None,
support_agent_type: None,
default_subagent_model: None,
default_subagent_reasoning_effort: None,
job_max_runtime_seconds: None,
-8
View File
@@ -863,9 +863,6 @@ pub struct Config {
/// User-configured maximum number of spawned agent threads per session.
pub agent_max_threads: Option<usize>,
/// Reserved setting for whether the spawn tool supports agent types.
pub agent_support_agent_type: Option<bool>,
/// Reserved default model for spawned subagents.
pub agent_default_subagent_model: Option<String>,
@@ -3606,10 +3603,6 @@ impl Config {
.as_ref()
.and_then(|agents| agents.max_depth)
.unwrap_or(DEFAULT_AGENT_MAX_DEPTH);
let agent_support_agent_type = cfg
.agents
.as_ref()
.and_then(|agents| agents.support_agent_type);
let agent_default_subagent_model = cfg
.agents
.as_ref()
@@ -3970,7 +3963,6 @@ impl Config {
tool_output_token_limit: cfg.tool_output_token_limit,
agents_enabled,
agent_max_threads,
agent_support_agent_type,
agent_default_subagent_model,
agent_default_subagent_reasoning_effort,
agent_max_depth,
-1
View File
@@ -180,7 +180,6 @@ fn save_config_resolved_fields(
agents.enabled = Some(config.agents_enabled);
agents.max_concurrent_threads_per_session = config.agent_max_threads;
agents.max_depth = Some(config.agent_max_depth);
agents.support_agent_type = config.agent_support_agent_type;
agents.default_subagent_model = config.agent_default_subagent_model.clone();
agents.default_subagent_reasoning_effort =
config.agent_default_subagent_reasoning_effort.clone();
@@ -15,6 +15,7 @@ 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_TYPE_OVERRIDE_DESCRIPTION_V1: &str = "Agent type override for the new agent. Omit to inherit the parent agent type with a full-history fork; otherwise, `default` is used.";
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str =
"Model override for the new agent. Omit unless an explicit override is needed.";
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str =
@@ -25,6 +26,7 @@ const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64;
pub struct SpawnAgentToolOptions {
pub available_models: Vec<ModelPreset>,
pub agent_type_description: String,
pub expose_agent_type: bool,
pub hide_agent_type_model_reasoning: bool,
pub expose_spawn_agent_model_overrides: bool,
pub multi_agent_version: MultiAgentVersion,
@@ -36,6 +38,7 @@ impl Default for SpawnAgentToolOptions {
Self {
available_models: Vec::new(),
agent_type_description: String::new(),
expose_agent_type: true,
hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: false,
multi_agent_version: MultiAgentVersion::Disabled,
@@ -70,6 +73,9 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec {
let return_value_description =
"Returns the spawned agent id plus the user-facing nickname when available.";
let mut properties = spawn_agent_common_properties_v1(&options.agent_type_description);
if !options.expose_agent_type {
properties.remove("agent_type");
}
if options.hide_agent_type_model_reasoning {
hide_spawn_agent_metadata_options(&mut properties);
}
@@ -101,8 +107,10 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
&& !options.hide_agent_type_model_reasoning)
.then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE);
let mut properties = spawn_agent_common_properties_v2(&options.agent_type_description);
if options.hide_agent_type_model_reasoning {
if !options.expose_agent_type {
properties.remove("agent_type");
}
if options.hide_agent_type_model_reasoning {
properties.remove("service_tier");
}
if !options.expose_spawn_agent_model_overrides {
@@ -582,7 +590,9 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
("items".to_string(), create_collab_input_items_schema()),
(
"agent_type".to_string(),
JsonSchema::string(Some(agent_type_description.to_string())),
JsonSchema::string(Some(format!(
"{SPAWN_AGENT_TYPE_OVERRIDE_DESCRIPTION_V1}\n{agent_type_description}"
))),
),
(
"fork_context".to_string(),
@@ -624,7 +634,9 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
),
(
"agent_type".to_string(),
JsonSchema::string(Some(agent_type_description.to_string())),
JsonSchema::string(Some(format!(
"Agent type override for the new agent. Omit unless explicitly asked. Set `fork_turns` to `none` or a positive integer when an explicit override is needed.\n{agent_type_description}"
))),
),
(
"fork_turns".to_string(),
@@ -48,6 +48,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
incompatible,
],
agent_type_description: "role help".to_string(),
expose_agent_type: true,
hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: true,
multi_agent_version: MultiAgentVersion::V2,
@@ -95,10 +96,6 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
properties.get("agent_type"),
Some(&JsonSchema::string(Some("role help".to_string())))
);
assert_eq!(
properties
.get("model")
@@ -132,6 +129,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
let tool = create_spawn_agent_tool_v1(SpawnAgentToolOptions {
available_models: Vec::new(),
agent_type_description: "role help".to_string(),
expose_agent_type: true,
hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: true,
multi_agent_version: MultiAgentVersion::V1,
@@ -158,6 +156,12 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
assert!(properties.contains_key("fork_context"));
assert!(!properties.contains_key("fork_turns"));
assert_eq!(
properties.get("agent_type"),
Some(&JsonSchema::string(Some(format!(
"{SPAWN_AGENT_TYPE_OVERRIDE_DESCRIPTION_V1}\nrole help"
))))
);
assert_eq!(
properties
.get("message")
@@ -190,6 +194,7 @@ fn spawn_agent_tool_caps_visible_model_summaries() {
model_preset("sixth", /*show_in_picker*/ true),
],
agent_type_description: "role help".to_string(),
expose_agent_type: true,
hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: true,
multi_agent_version: MultiAgentVersion::V2,
@@ -235,6 +240,7 @@ fn spawn_agent_tool_keeps_model_controls_when_spawn_metadata_is_hidden() {
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
agent_type_description: "role help".to_string(),
expose_agent_type: false,
hide_agent_type_model_reasoning: true,
expose_spawn_agent_model_overrides: true,
multi_agent_version: MultiAgentVersion::V2,
@@ -267,6 +273,7 @@ fn spawn_agent_tool_hides_model_controls_without_override_exposure() {
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
available_models: vec![model_preset("visible", /*show_in_picker*/ true)],
agent_type_description: "role help".to_string(),
expose_agent_type: false,
hide_agent_type_model_reasoning: true,
expose_spawn_agent_model_overrides: false,
multi_agent_version: MultiAgentVersion::V2,
+5 -5
View File
@@ -798,16 +798,15 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
.flatten();
let agent_type_description =
agent_type_description(turn_context, context.default_agent_type_description);
// TODO(jif): Wire `agents.support_agent_type` into the V2 spawn tool surface.
let hide_spawn_agent_metadata =
turn_context.config.multi_agent_v2.hide_spawn_agent_metadata;
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(
SpawnAgentHandlerV2::new(SpawnAgentToolOptions {
available_models: turn_context.available_models.clone(),
agent_type_description,
hide_agent_type_model_reasoning: turn_context
.config
.multi_agent_v2
.hide_spawn_agent_metadata,
expose_agent_type: !turn_context.config.agent_roles.is_empty(),
hide_agent_type_model_reasoning: hide_spawn_agent_metadata,
expose_spawn_agent_model_overrides: turn_context
.config
.multi_agent_v2
@@ -854,6 +853,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
SpawnAgentHandler::new(SpawnAgentToolOptions {
available_models: turn_context.available_models.clone(),
agent_type_description,
expose_agent_type: !turn_context.config.agent_roles.is_empty(),
hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: true,
multi_agent_version: turn_context.multi_agent_version,
+2 -1
View File
@@ -1207,12 +1207,13 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
.properties
.as_ref()
.expect("spawn_agent should use object params");
for property in ["agent_type", "model", "reasoning_effort", "service_tier"] {
for property in ["model", "reasoning_effort", "service_tier"] {
assert!(
properties.contains_key(property),
"expected v1 spawn_agent to expose `{property}`"
);
}
assert!(!properties.contains_key("agent_type"));
let v2 = probe(|turn| {
set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true);
@@ -2,6 +2,7 @@
#![allow(clippy::unwrap_used)]
use anyhow::Result;
use codex_core::config::AgentRoleConfig;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_models_manager::manager::RefreshStrategy;
@@ -27,6 +28,7 @@ use core_test_support::test_codex::test_codex;
use serde_json::Value;
use std::time::Duration;
use std::time::Instant;
use test_case::test_case;
use tokio::time::sleep;
const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1";
@@ -39,6 +41,12 @@ fn spawn_agent_description(body: &Value) -> Option<String> {
.map(str::to_string)
}
fn spawn_agent_exposes_agent_type(body: &Value, namespace: &str) -> bool {
namespace_child_tool(body, namespace, SPAWN_AGENT_TOOL_NAME)
.and_then(|tool| tool.pointer("/parameters/properties/agent_type"))
.is_some()
}
fn test_model_info(
slug: &str,
display_name: &str,
@@ -242,3 +250,57 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
Ok(())
}
#[test_case(false, false, MULTI_AGENT_V1_NAMESPACE; "v1 hides agent type without roles")]
#[test_case(true, true, "collaboration"; "v2 exposes agent type with a role")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn configured_agent_roles_control_spawn_agent_type(
multi_agent_v2: bool,
has_agent_role: bool,
namespace: &str,
) -> Result<()> {
let server = start_mock_server().await;
let response = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let test = test_codex()
.with_config(move |config| {
config
.features
.enable(Feature::Collab)
.expect("test config should allow feature update");
if multi_agent_v2 {
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
} else {
config
.features
.disable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
}
if has_agent_role {
config.agent_roles.insert(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research role".to_string()),
config_file: None,
nickname_candidates: None,
},
);
}
})
.build_with_auto_env(&server)
.await?;
test.submit_turn("hello").await?;
assert_eq!(
spawn_agent_exposes_agent_type(&response.single_request().body_json(), namespace),
has_agent_role
);
Ok(())
}
@@ -242,7 +242,6 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
tool_output_token_limit: None,
agents_enabled: true,
agent_max_threads: Some(6),
agent_support_agent_type: None,
agent_default_subagent_model: None,
agent_default_subagent_reasoning_effort: None,
agent_job_max_runtime_seconds: None,
-6
View File
@@ -70,11 +70,6 @@ fn render_agents_config_lines(config: &Config) -> Vec<Line<'static>> {
config.agent_max_depth
)
.into(),
format!(
" - support_agent_type = {} (not yet wired)",
format_optional(config.agent_support_agent_type)
)
.into(),
format!(
" - default_subagent_model = {} (not yet wired)",
format_optional(config.agent_default_subagent_model.as_deref())
@@ -654,7 +649,6 @@ mod tests {
enabled = false
max_concurrent_threads_per_session = 7
max_depth = -2
support_agent_type = true
default_subagent_model = "gpt-5.6-terra"
default_subagent_reasoning_effort = "high"
job_max_runtime_seconds = 900
@@ -8,7 +8,6 @@ expression: render_to_text(&render_agents_config_lines(&config))
- enabled = false
- max_concurrent_threads_per_session = 7
- max_depth = -2 (V1 only; ignored by V2)
- support_agent_type = true (not yet wired)
- default_subagent_model = gpt-5.6-terra (not yet wired)
- default_subagent_reasoning_effort = high (not yet wired)
- job_max_runtime_seconds = 900