Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 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,36 @@ 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_active_profile_and_model_provider() {
let home = TempDir::new().expect("create temp dir");
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 @@ -82,6 +82,9 @@ impl ToolHandler for Handler {
.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,
Expand Down
16 changes: 12 additions & 4 deletions codex-rs/core/src/tools/handlers/multi_agents_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,15 @@ 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 requested_service_tier_is_effective =
requested_service_tier.is_some_and(|requested_service_tier| {
config.service_tier.as_deref() == Some(requested_service_tier)
});
let Some(candidate_service_tier) = config
.service_tier
.clone()
.or_else(|| parent_service_tier.map(str::to_string))
else {
config.service_tier = None;
return Ok(());
};
Expand All @@ -357,12 +365,12 @@ 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());
if model_info.supports_service_tier(candidate_service_tier.as_str()) {
config.service_tier = Some(candidate_service_tier);
return Ok(());
}

if requested_service_tier.is_none() {
if !requested_service_tier_is_effective {
config.service_tier = None;
return Ok(());
}
Expand Down
254 changes: 254 additions & 0 deletions codex-rs/core/src/tools/handlers/multi_agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,38 @@ model_reasoning_effort = "minimal"
role_name
}

async fn install_role_with_service_tier(turn: &mut TurnContext, model: &str) -> String {
let role_name = "tier-role".to_string();
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(format!("{role_name}.toml"));
tokio::fs::write(
&role_config_path,
format!(
"developer_instructions = \"Use the configured tier\"\nmodel = \"{model}\"\nservice_tier = \"{}\"\n",
ServiceTier::Fast.request_value()
),
)
.await
.expect("role config should be written");
let mut config = (*turn.config).clone();
config.agent_roles.insert(
role_name.clone(),
AgentRoleConfig {
description: Some("Tiered role".to_string()),
config_file: Some(role_config_path),
nickname_candidates: None,
},
);
turn.config = Arc::new(config);
role_name
}

fn expect_text_output<T>(output: T) -> (String, Option<bool>)
where
T: ToolOutput,
Expand Down Expand Up @@ -490,6 +522,97 @@ async fn spawn_agent_service_tier_override_uses_supported_child_model_tier() {
);
}

#[tokio::test]
async fn spawn_agent_role_service_tier_persists_in_child_config() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
}

let (mut session, mut turn) = make_session_and_context().await;
let role_name = install_role_with_service_tier(&mut turn, "gpt-5.4").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",
"agent_type": role_name
})),
))
.await
.expect("role-configured service tier should persist in the child config");
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_role_service_tier_overrides_spawn_argument() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
}

let (mut session, mut turn) = make_session_and_context().await;
let role_name = install_role_with_service_tier(&mut turn, "gpt-5.4").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",
"agent_type": role_name,
"service_tier": "turbo"
})),
))
.await
.expect("role-configured service tier should win over the spawn argument");
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_service_tier_override_rejects_unknown_tier() {
let (session, turn) = make_session_and_context().await;
Expand Down Expand Up @@ -542,6 +665,137 @@ async fn spawn_agent_service_tier_override_rejects_tier_unsupported_by_child_mod
);
}

#[tokio::test]
async fn multi_agent_v2_spawn_role_service_tier_persists_in_child_config() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
}

let (mut session, mut turn) = make_session_and_context().await;
let role_name = install_role_with_service_tier(&mut turn, "gpt-5.4").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": "tiered_role",
"agent_type": role_name,
"fork_turns": "1"
})),
))
.await
.expect("role-owned service tier should persist in v2 child config");
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_role_service_tier_overrides_spawn_argument() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
}

let (mut session, mut turn) = make_session_and_context().await;
let role_name = install_role_with_service_tier(&mut turn, "gpt-5.4").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": "tiered_role_override",
"agent_type": role_name,
"service_tier": "turbo",
"fork_turns": "1"
})),
))
.await
.expect("role-configured service tier should win over the spawn argument");
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 spawn_agent_inherits_supported_parent_service_tier() {
#[derive(Debug, Deserialize)]
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ impl ToolHandler for Handler {
.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 matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) {
reject_full_fork_spawn_overrides(
role_name,
Expand Down
Loading