diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 8f9c93edb..395e74f13 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -99,6 +99,13 @@ async fn handle_spawn_agent( .await .map_err(FunctionCallError::RespondToModel)?; } + apply_spawn_agent_service_tier( + &session, + &mut config, + turn.config.service_tier.as_deref(), + args.service_tier.as_deref(), + ) + .await?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; apply_spawn_agent_overrides(&mut config, child_depth); @@ -206,6 +213,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, + service_tier: Option, #[serde(default)] fork_context: bool, } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index c01755cb2..cc4e9ebfc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -336,6 +336,51 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides( Ok(()) } +pub(crate) async fn apply_spawn_agent_service_tier( + session: &Session, + config: &mut Config, + parent_service_tier: Option<&str>, + requested_service_tier: Option<&str>, +) -> Result<(), FunctionCallError> { + let Some(candidate_service_tier) = requested_service_tier.or(parent_service_tier) else { + return Ok(()); + }; + let model = config.model.clone().ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawn_agent could not resolve the child model for service tier validation".to_string(), + ) + })?; + let model_info = session + .services + .models_manager + .get_model_info(model.as_str(), &config.to_models_manager_config()) + .await; + + if model_info.supports_service_tier(candidate_service_tier) { + config.service_tier = Some(candidate_service_tier.to_string()); + return Ok(()); + } + + if requested_service_tier.is_none() { + config.service_tier = None; + return Ok(()); + } + + let supported_service_tiers = if model_info.service_tiers.is_empty() { + "none".to_string() + } else { + model_info + .service_tiers + .iter() + .map(|tier| tier.id.as_str()) + .collect::>() + .join(", ") + }; + Err(FunctionCallError::RespondToModel(format!( + "Service tier `{candidate_service_tier}` is not supported for model `{model}`. Supported service tiers: {supported_service_tiers}" + ))) +} + fn find_spawn_agent_model_name( available_models: &[codex_protocol::openai_models::ModelPreset], requested_model: &str, 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 233491efa..111ab6779 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; 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_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one."; +const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one."; #[derive(Debug, Clone, Default)] pub struct SpawnAgentToolOptions { @@ -545,6 +546,12 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap String { .map(|preset| format!("{} ({})", preset.effort, preset.description)) .collect::>() .join(", "); + let service_tiers = if model.service_tiers.is_empty() { + "none".to_string() + } else { + model + .service_tiers + .iter() + .map(|tier| format!("{} ({}: {})", tier.id, tier.name, tier.description)) + .collect::>() + .join(", ") + }; format!( - "- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}.", + "- {} (`{}`): {} Default reasoning effort: {}. Supported reasoning efforts: {}. Supported service tiers: {}.", model.display_name, model.model, model.description, model.default_reasoning_effort, - efforts + efforts, + service_tiers ) }) .collect::>() 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 c8fa1a0f9..aaa29d6d4 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 @@ -1,5 +1,6 @@ use super::*; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_tools::JsonSchemaPrimitiveType; @@ -20,7 +21,11 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { }], supports_personality: false, additional_speed_tiers: Vec::new(), - service_tiers: Vec::new(), + service_tiers: vec![ModelServiceTier { + id: "priority".to_string(), + name: "Fast".to_string(), + description: "1.5x speed, increased usage".to_string(), + }], is_default: false, upgrade: None, show_in_picker, @@ -70,6 +75,10 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { .contains("Available model overrides (optional; inherited parent model is preferred):") ); assert!(description.contains("visible display (`visible-model`)")); + assert!( + description + .contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage).") + ); assert!(!description.contains("hidden display (`hidden-model`)")); assert!(properties.contains_key("task_name")); assert!(properties.contains_key("message")); @@ -86,6 +95,12 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { .and_then(|schema| schema.description.as_deref()), Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION) ); + assert_eq!( + properties + .get("service_tier") + .and_then(|schema| schema.description.as_deref()), + Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION) + ); assert_eq!( parameters.required.as_ref(), Some(&vec!["task_name".to_string(), "message".to_string()]) @@ -127,6 +142,37 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { .and_then(|schema| schema.description.as_deref()), Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION) ); + assert_eq!( + properties + .get("service_tier") + .and_then(|schema| schema.description.as_deref()), + Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION) + ); +} + +#[test] +fn spawn_agent_tool_hides_service_tier_with_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(), + hide_agent_type_model_reasoning: true, + include_usage_hint: true, + usage_hint_text: None, + max_concurrent_threads_per_session: Some(4), + }); + + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { + panic!("spawn_agent should be a function tool"); + }; + let properties = parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + + assert!(!properties.contains_key("agent_type")); + assert!(!properties.contains_key("model")); + assert!(!properties.contains_key("reasoning_effort")); + assert!(!properties.contains_key("service_tier")); } #[test] diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index d1360186a..f051fb0f7 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -23,6 +23,7 @@ use codex_model_provider::create_model_provider; use codex_model_provider_info::built_in_model_providers; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; @@ -444,6 +445,373 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over ); } +#[tokio::test] +async fn spawn_agent_service_tier_override_validates_the_effective_child_model() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + { + let (mut session, turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "gpt-5.4", + "service_tier": ServiceTier::Fast.request_value() + })), + )) + .await + .expect("spawn_agent should accept a supported explicit service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + } + + { + let (session, turn) = make_session_and_context().await; + let err = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "gpt-5.4", + "service_tier": "turbo" + })), + )) + .await + .expect_err("unknown service tier should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Service tier `turbo` is not supported for model `gpt-5.4`. Supported service tiers: priority" + .to_string() + ) + ); + } + + { + let (session, turn) = make_session_and_context().await; + let err = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "gpt-5.3-codex", + "service_tier": ServiceTier::Fast.request_value() + })), + )) + .await + .expect_err("tier unsupported by the final child model should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Service tier `priority` is not supported for model `gpt-5.3-codex`. Supported service tiers: none" + .to_string() + ) + ); + } +} + +#[tokio::test] +async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_tiers() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + { + let (mut session, turn) = make_session_and_context().await; + let mut turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + let mut config = (*turn.config).clone(); + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": "inspect this repo"})), + )) + .await + .expect("spawn_agent should inherit a supported parent service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + } + + { + let (mut session, turn) = make_session_and_context().await; + let mut turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + let mut config = (*turn.config).clone(); + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "model": "gpt-5.3-codex" + })), + )) + .await + .expect("spawn_agent should clear unsupported inherited service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!(snapshot.service_tier, None); + } + + { + let (mut session, mut turn) = make_session_and_context().await; + tokio::fs::create_dir_all(&turn.config.codex_home) + .await + .expect("codex home should be created"); + let role_config_path = turn + .config + .codex_home + .as_path() + .join("service-tier-role.toml"); + tokio::fs::write( + &role_config_path, + r#"model = "gpt-5.4" +service_tier = "priority" +"#, + ) + .await + .expect("role config should be written"); + + let role_name = "service-tier-role".to_string(); + let mut config = (*turn.config).clone(); + config.agent_roles.insert( + role_name.clone(), + AgentRoleConfig { + description: Some("Role with a child service tier".to_string()), + config_file: Some(role_config_path), + nickname_candidates: None, + }, + ); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "agent_type": role_name + })), + )) + .await + .expect("spawn_agent should preserve the child role service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + } +} + +#[tokio::test] +async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + let (mut session, turn) = make_session_and_context().await; + let turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + + let output = SpawnAgentHandler::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "fork_context": true, + "service_tier": ServiceTier::Fast.request_value() + })), + )) + .await + .expect("full-history fork should accept explicit service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let snapshot = manager + .get_thread(parse_agent_id(&result.agent_id)) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + +#[tokio::test] +async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + task_name: String, + } + + let (mut session, turn) = make_session_and_context().await; + let mut turn = turn + .with_model("gpt-5.4".to_string(), &session.services.models_manager) + .await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let session = Arc::new(session); + let turn = Arc::new(turn); + + let output = SpawnAgentHandlerV2::default() + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "fork_with_tier", + "service_tier": ServiceTier::Fast.request_value() + })), + )) + .await + .expect("multi-agent v2 full-history fork should accept explicit service tier"); + let (content, _) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + let child_thread_id = session + .services + .agent_control + .resolve_agent_reference( + session.conversation_id, + &turn.session_source, + result.task_name.as_str(), + ) + .await + .expect("spawned task name should resolve"); + let snapshot = manager + .get_thread(child_thread_id) + .await + .expect("spawned agent thread should exist") + .config_snapshot() + .await; + + assert_eq!( + snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + #[tokio::test] async fn multi_agent_v2_spawn_partial_fork_turns_allows_agent_type_override() { let (mut session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index a849bcd97..1ab59cc3f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -98,6 +98,13 @@ async fn handle_spawn_agent( .await .map_err(FunctionCallError::RespondToModel)?; } + apply_spawn_agent_service_tier( + &session, + &mut config, + turn.config.service_tier.as_deref(), + args.service_tier.as_deref(), + ) + .await?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; apply_spawn_agent_overrides(&mut config, child_depth); @@ -238,6 +245,7 @@ struct SpawnAgentArgs { agent_type: Option, model: Option, reasoning_effort: Option, + service_tier: Option, fork_turns: Option, fork_context: Option, } diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index cc5a9952e..5aab77cd7 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -9,6 +9,7 @@ use codex_models_manager::manager::SharedModelsManager; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; @@ -52,6 +53,7 @@ fn test_model_info( visibility: ModelVisibility, default_reasoning_level: ReasoningEffort, supported_reasoning_levels: Vec, + service_tiers: Vec, ) -> ModelInfo { ModelInfo { slug: slug.to_string(), @@ -67,7 +69,7 @@ fn test_model_info( supports_search_tool: false, priority: 1, additional_speed_tiers: Vec::new(), - service_tiers: Vec::new(), + service_tiers, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -126,6 +128,11 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> description: "Deep dive".to_string(), }, ], + vec![ModelServiceTier { + id: "priority".to_string(), + name: "Fast".to_string(), + description: "1.5x speed, increased usage".to_string(), + }], ), test_model_info( "hidden-model", @@ -137,6 +144,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> effort: ReasoningEffort::Low, description: "Not visible".to_string(), }], + Vec::new(), ), ], }, @@ -195,6 +203,11 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> description.contains("low (Quick scan), high (Deep dive)."), "expected reasoning efforts in spawn_agent description: {description:?}" ); + assert!( + description + .contains("Supported service tiers: priority (Fast: 1.5x speed, increased usage)."), + "expected service tier guidance in spawn_agent description: {description:?}" + ); assert!( !description.contains("Hidden Model"), "hidden picker model should be omitted from spawn_agent description: {description:?}"