diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 6d49c0557..2e5f8211b 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -220,13 +220,26 @@ pub(crate) mod spawn_tool_spec { /// Builds the spawn-agent tool description text from built-in and configured roles. pub(crate) fn build(user_defined_agent_roles: &BTreeMap) -> String { let built_in_roles = built_in::configs(); - build_from_configs(built_in_roles, user_defined_agent_roles) + build_from_configs(built_in_roles, user_defined_agent_roles, format_role) + } + + /// Builds the spawn-agent tool description without role configuration metadata. + pub(crate) fn build_without_metadata( + user_defined_agent_roles: &BTreeMap, + ) -> String { + let built_in_roles = built_in::configs(); + build_from_configs( + built_in_roles, + user_defined_agent_roles, + format_role_without_metadata, + ) } // This function is not inlined for testing purpose. fn build_from_configs( built_in_roles: &BTreeMap, user_defined_roles: &BTreeMap, + format_role: fn(&str, &AgentRoleConfig) -> String, ) -> String { let mut seen = BTreeSet::new(); let mut formatted_roles = Vec::new(); @@ -300,6 +313,14 @@ pub(crate) mod spawn_tool_spec { format!("{name}: no description") } } + + fn format_role_without_metadata(name: &str, declaration: &AgentRoleConfig) -> String { + if let Some(description) = &declaration.description { + format!("{name}: {{\n{description}\n}}") + } else { + format!("{name}: no description") + } + } } mod built_in { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 0437c1a26..0dd0e778b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -689,7 +689,9 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap) { - properties.remove("agent_type"); + // `agent_type` is the selector for configured roles, not an optional runtime + // override. Hiding it makes discovered roles impossible to choose even + // though both V1 and V2 handlers still validate and apply the field. properties.remove("model"); properties.remove("provider"); properties.remove("reasoning_effort"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 8b8a3e88a..26464e97c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -269,7 +269,7 @@ fn spawn_agent_tool_caps_reasoning_effort_value_length() { } #[test] -fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { +fn spawn_agent_tool_keeps_agent_type_while_hiding_spawn_metadata() { 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(), @@ -282,6 +282,7 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { let ToolSpec::Function(ResponsesApiTool { description, parameters, + output_schema, .. }) = tool else { @@ -292,7 +293,10 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { .as_ref() .expect("spawn_agent should use object params"); - assert!(!properties.contains_key("agent_type")); + assert_eq!( + properties.get("agent_type"), + Some(&JsonSchema::string(Some("role help".to_string()))) + ); assert!(!properties.contains_key("model")); assert!(!properties.contains_key("provider")); assert!(!properties.contains_key("reasoning_effort")); @@ -300,6 +304,9 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { assert!(!properties.contains_key("auth_profile")); assert!(!description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE)); assert!(!description.contains("Available model overrides")); + let output_schema = output_schema.expect("spawn_agent output schema"); + assert_eq!(output_schema["required"], json!(["task_name"])); + assert!(output_schema["properties"].get("nickname").is_none()); } #[test] diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 65f8853fd..2983649f8 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -591,6 +591,20 @@ fn agent_type_description( } } +fn agent_type_description_without_metadata( + turn_context: &TurnContext, + default_agent_type_description: &str, +) -> String { + let agent_type_description = crate::agent::role::spawn_tool_spec::build_without_metadata( + &turn_context.config.agent_roles, + ); + if agent_type_description.is_empty() { + default_agent_type_description.to_string() + } else { + agent_type_description + } +} + fn is_hidden_by_code_mode( turn_context: &TurnContext, tool_name: &ToolName, @@ -961,17 +975,22 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu let tool_namespace = namespace_tools_enabled(turn_context) .then_some(turn_context.config.multi_agent_v2.tool_namespace.as_deref()) .flatten(); - let agent_type_description = - agent_type_description(turn_context, context.default_agent_type_description); + let hide_spawn_agent_metadata = + turn_context.config.multi_agent_v2.hide_spawn_agent_metadata; + let agent_type_description = if hide_spawn_agent_metadata { + agent_type_description_without_metadata( + turn_context, + context.default_agent_type_description, + ) + } else { + agent_type_description(turn_context, context.default_agent_type_description) + }; 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, + hide_agent_type_model_reasoning: hide_spawn_agent_metadata, include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), max_concurrent_threads_per_session: max_concurrent_threads_per_session( diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 100b5db25..e2f369601 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -45,7 +45,9 @@ use codex_tools::ToolSpec; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use tempfile::TempDir; +use crate::config::ConfigBuilder; use crate::session::tests::make_session_and_context; use crate::session::turn_context::TurnContext; use crate::tools::context::ToolPayload; @@ -1421,6 +1423,82 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { ); } +#[tokio::test] +async fn multi_agent_v2_default_spawn_schema_keeps_custom_roles_selectable() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + let agents_dir = codex_home.path().join("agents"); + std::fs::create_dir(&agents_dir)?; + let role_path = agents_dir.join("synthetic.toml"); + std::fs::write( + &role_path, + r#"name = "synthetic" +description = "Synthetic workspace role." +developer_instructions = "Stay focused." +model = "metadata-role-model" +model_provider = "openai" +model_reasoning_effort = "high" +service_tier = "priority" +"#, + )?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + let role = config + .agent_roles + .get("synthetic") + .expect("config-backed role should be discovered"); + assert_eq!( + role.description.as_deref(), + Some("Synthetic workspace role.") + ); + assert_eq!(role.config_file.as_deref(), Some(role_path.as_path())); + + let plan = probe(move |turn| { + turn.config = Arc::new(config); + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + assert!(turn.config.multi_agent_v2.hide_spawn_agent_metadata); + }) + .await; + + let ToolSpec::Function(spawn_agent) = plan.visible_spec("spawn_agent") else { + panic!("expected v2 spawn_agent function"); + }; + let properties = spawn_agent + .parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + let agent_type_description = properties + .get("agent_type") + .and_then(|schema| schema.description.as_deref()) + .expect("default v2 spawn_agent should expose agent_type"); + assert!(agent_type_description.contains("synthetic: {\nSynthetic workspace role.\n}")); + for hidden_role_metadata in ["metadata-role-model", "`openai`", "`high`", "`priority`"] { + assert!( + !agent_type_description.contains(hidden_role_metadata), + "expected default v2 agent_type description to hide `{hidden_role_metadata}`" + ); + } + + for hidden_override in [ + "model", + "provider", + "reasoning_effort", + "service_tier", + "auth_profile", + ] { + assert!( + !properties.contains_key(hidden_override), + "expected default v2 spawn_agent to keep `{hidden_override}` hidden" + ); + } + + Ok(()) +} + #[tokio::test] async fn multi_agent_v2_message_schemas_are_encrypted() { let plan = probe(|turn| {