Skip to content
31 changes: 27 additions & 4 deletions codex-rs/core/src/agent/role.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,14 @@ async fn apply_role_to_config_inner(
}
let (preserve_current_profile, preserve_current_provider) =
preservation_policy(config, &role_layer_toml);
let preserve_current_service_tier = role_layer_toml.get("service_tier").is_none();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor profile-scoped role service tiers

This only checks for a top-level service_tier, but role files can select or define profiles, and ConfigProfile has its own service_tier; Config::load_config_with_layer_stack only reads config_profile.service_tier when the service-tier override is None. With a role such as profile = "tiered" plus [profiles.tiered] service_tier = "priority" (or a role update to the active profile), this line preserves the parent/request tier as an override, so the profile-defined role tier is ignored instead of being honored.

Useful? React with 👍 / 👎.


*config = reload::build_next_config(
config,
role_layer_toml,
preserve_current_profile,
preserve_current_provider,
preserve_current_service_tier,
)
.await?;
Ok(())
Expand Down Expand Up @@ -157,6 +159,7 @@ mod reload {
role_layer_toml: TomlValue,
preserve_current_profile: bool,
preserve_current_provider: bool,
preserve_current_service_tier: bool,
) -> anyhow::Result<Config> {
let active_profile_name = preserve_current_profile
.then_some(config.active_profile.as_deref())
Expand All @@ -171,7 +174,11 @@ mod reload {
let mut next_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
merged_config,
reload_overrides(config, preserve_current_provider),
reload_overrides(
config,
preserve_current_provider,
preserve_current_service_tier,
),
config.codex_home.clone(),
config_layer_stack,
)
Expand Down Expand Up @@ -261,10 +268,15 @@ mod reload {
ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, role_layer_toml)
}

fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides {
fn reload_overrides(
config: &Config,
preserve_current_provider: bool,
preserve_current_service_tier: bool,
) -> ConfigOverrides {
ConfigOverrides {
cwd: Some(config.cwd.to_path_buf()),
model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()),
service_tier: preserve_current_service_tier.then(|| config.service_tier.clone()),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
..Default::default()
Expand Down Expand Up @@ -323,8 +335,11 @@ pub(crate) mod spawn_tool_spec {
let reasoning_effort = role_toml
.get("model_reasoning_effort")
.and_then(TomlValue::as_str);
let service_tier = role_toml
.get("service_tier")
.and_then(TomlValue::as_str);

match (model, reasoning_effort) {
let model_and_reasoning_note = match (model, reasoning_effort) {
(Some(model), Some(reasoning_effort)) => format!(
"\n- This role's model is set to `{model}` and its reasoning effort is set to `{reasoning_effort}`. These settings cannot be changed."
),
Expand All @@ -339,7 +354,15 @@ pub(crate) mod spawn_tool_spec {
)
}
(None, None) => String::new(),
}
};
let service_tier_note = service_tier
.map(|service_tier| {
format!(
"\n- This role's service tier is set to `{service_tier}`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier."
)
})
.unwrap_or_default();
format!("{model_and_reasoning_note}{service_tier_note}")
})
.unwrap_or_default();
format!("{name}: {{\n{description}{locked_settings_note}\n}}")
Expand Down
86 changes: 86 additions & 0 deletions codex-rs/core/src/agent/role_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::skills_load_input_from_config;
use codex_config::ConfigLayerStackOrdering;
use codex_core_plugins::PluginsManager;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::test_support::PathExt;
Expand Down Expand Up @@ -214,6 +215,66 @@ async fn apply_role_preserves_unspecified_keys() {
);
}

#[tokio::test]
async fn apply_role_reports_explicit_service_tier() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let role_path = write_role_config(
&home,
"tiered-role.toml",
r#"developer_instructions = "Stay focused"
service_tier = "priority"
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);

apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");

assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
}

#[tokio::test]
async fn apply_role_preserves_existing_service_tier_without_override() {
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
let role_path = write_role_config(
&home,
"default-tier-role.toml",
r#"developer_instructions = "Stay focused"
"#,
)
.await;
config.agent_roles.insert(
"custom".to_string(),
AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
);

apply_role_to_config(&mut config, Some("custom"))
.await
.expect("custom role should apply");

assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
}

#[tokio::test]
async fn apply_role_preserves_active_profile_and_model_provider() {
let home = TempDir::new().expect("create temp dir");
Expand Down Expand Up @@ -766,6 +827,31 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
));
}

#[test]
fn spawn_tool_spec_marks_role_locked_service_tier() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("tiered.toml");
fs::write(
&role_path,
"developer_instructions = \"Stay fast\"\nservice_tier = \"priority\"\n",
)
.expect("write role config");
let user_defined_roles = BTreeMap::from([(
"tiered".to_string(),
AgentRoleConfig {
description: Some("Stay fast.".to_string()),
config_file: Some(role_path),
nickname_candidates: None,
},
)]);

let spec = spawn_tool_spec::build(&user_defined_roles);

assert!(spec.contains(
"Stay fast.\n- This role's service tier is set to `priority`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier."
));
}

#[test]
fn built_in_config_file_contents_resolves_explorer_only() {
assert_eq!(
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ async fn handle_spawn_agent(
.await;
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
if let Some(service_tier) = args.service_tier.as_ref() {
config.service_tier = Some(service_tier.clone());
}
if args.fork_context {
reject_full_fork_spawn_overrides(role_name, args.model.as_deref(), args.reasoning_effort)?;
} else {
Expand Down
56 changes: 33 additions & 23 deletions codex-rs/core/src/tools/handlers/multi_agents_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,16 @@ pub(crate) async fn apply_spawn_agent_service_tier(
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 {
let candidate_service_tiers = [
config.service_tier.clone(),
requested_service_tier.map(str::to_string),
parent_service_tier.map(str::to_string),
];
if candidate_service_tiers.iter().all(Option::is_none) {
config.service_tier = None;
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(),
Expand All @@ -356,29 +363,32 @@ pub(crate) async fn apply_spawn_agent_service_tier(
.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(());
if let Some(requested_service_tier) = requested_service_tier
&& !model_info.supports_service_tier(requested_service_tier)
{
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::<Vec<_>>()
.join(", ")
};
return Err(FunctionCallError::RespondToModel(format!(
"Service tier `{requested_service_tier}` is not supported for model `{model}`. Supported service tiers: {supported_service_tiers}"
)));
}

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::<Vec<_>>()
.join(", ")
};
Err(FunctionCallError::RespondToModel(format!(
"Service tier `{candidate_service_tier}` is not supported for model `{model}`. Supported service tiers: {supported_service_tiers}"
)))
config.service_tier =
candidate_service_tiers
.into_iter()
.flatten()
.find(|candidate_service_tier| {
model_info.supports_service_tier(candidate_service_tier.as_str())
});
Ok(())
}

fn find_spawn_agent_model_name(
Expand Down
Loading
Loading