Make subagents follow the root service tier (#41308)

## What changed

- Share the root thread's current service tier across its agent tree.
- Apply that tier to each subagent request when the selected child model supports it, including remote compaction requests.
- Propagate root service-tier updates to existing and newly spawned subagents without rewriting child-owned settings.
- Remove per-spawn and role-level service-tier overrides so routing remains controlled by the root thread.

## Testing

- Cover supported and unsupported child models, full-history forks, live tier changes, remote compaction, new subagents, and reloaded subagents.

GitOrigin-RevId: 6c299e224e90b41a558788fb7aa4d5f336fa383a
This commit is contained in:
jif
2026-08-28 09:48:13 +00:00
committed by copyberry
parent 94311d4475
commit dc2ccc6843
19 changed files with 521 additions and 274 deletions
+5
View File
@@ -25,6 +25,7 @@ use crate::thread_manager::ThreadManagerState;
use crate::thread_manager::default_thread_id_generator;
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
use crate::turn_timing::now_unix_timestamp_ms;
use arc_swap::ArcSwapOption;
use codex_history::InitialHistory;
use codex_history::ResumedHistory;
use codex_history::RolloutItem;
@@ -72,6 +73,7 @@ use self::residency::V2Residency;
mod execution;
mod legacy;
mod residency;
mod service_tier;
mod spawn;
mod user_authorization;
@@ -128,6 +130,8 @@ pub(crate) struct AgentControl {
agent_execution_limiter: Arc<AgentExecutionLimiter>,
/// Session-scoped state shared by the root thread and every cloned sub-agent control handle.
rollout_budget: Arc<RolloutBudget>,
/// The user-selected root routing tier, shared by the entire agent tree.
root_service_tier: Arc<ArcSwapOption<String>>,
}
impl Default for AgentControl {
@@ -155,6 +159,7 @@ impl AgentControl {
v2_residency: Arc::default(),
agent_execution_limiter: Arc::default(),
rollout_budget: Arc::default(),
root_service_tier: Arc::new(ArcSwapOption::from(None)),
};
if let Some(rollout_budget) = rollout_budget {
control.rollout_budget.configure(rollout_budget);
@@ -0,0 +1,18 @@
//! Shares the root user's selected routing tier across the entire agent tree.
use super::AgentControl;
use std::sync::Arc;
impl AgentControl {
/// Returns the latest user-selected tier for this root and all its descendants.
pub(crate) fn root_service_tier(&self) -> Option<String> {
self.root_service_tier
.load_full()
.map(|service_tier| (*service_tier).clone())
}
/// Publishes a root-owned tier without mutating individual child sessions.
pub(crate) fn set_root_service_tier(&self, service_tier: Option<String>) {
self.root_service_tier.store(service_tier.map(Arc::new));
}
}
+1
View File
@@ -415,6 +415,7 @@ impl AgentControl {
CodexErr::InvalidRequest(format!("permission_profile is invalid: {err}"))
})?;
}
config.service_tier = self.root_service_tier();
if let Some(model) = stored_model {
config.model = Some(model);
}
+2 -14
View File
@@ -311,11 +311,7 @@ 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);
let model_and_reasoning_note = match (model, reasoning_effort) {
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."
),
@@ -330,15 +326,7 @@ 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}}")
+2 -4
View File
@@ -711,7 +711,7 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
}
#[test]
fn spawn_tool_spec_marks_role_locked_service_tier() {
fn spawn_tool_spec_omits_role_service_tier() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("tiered.toml");
fs::write(
@@ -730,9 +730,7 @@ fn spawn_tool_spec_marks_role_locked_service_tier() {
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."
));
assert!(spec.contains("tiered: {\nStay fast.\n}"));
}
#[test]
+1 -1
View File
@@ -88,7 +88,7 @@ pub(super) async fn run_remote_compact_attempt(
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
None
} else {
turn_context.config.service_tier.clone()
step_context.settings.service_tier.clone()
},
},
&turn_context.session_telemetry,
+3 -2
View File
@@ -367,11 +367,12 @@ struct RemoteCompactionV2Output {
async fn run_remote_compaction_request_v2(
sess: &Session,
turn_context: &TurnContext,
step_context: &StepContext,
client_session: &mut ModelClientSession,
prompt: &Prompt,
responses_metadata: &CodexResponsesMetadata,
) -> CodexResult<RemoteCompactionV2Output> {
let turn_context = &step_context.turn;
let max_retries = turn_context
.provider
.info()
@@ -386,7 +387,7 @@ async fn run_remote_compaction_request_v2(
&turn_context.session_telemetry,
turn_context.reasoning_effort().cloned(),
turn_context.reasoning_summary(),
turn_context.config.service_tier.clone(),
step_context.settings.service_tier.clone(),
responses_metadata,
&InferenceTraceContext::disabled(),
)
@@ -104,7 +104,7 @@ pub(super) async fn run_remote_compact_v2_attempt(
};
let compaction_output_result = run_remote_compaction_request_v2(
sess,
turn_context.as_ref(),
step_context,
client_session,
&prompt,
&responses_metadata,
+28 -1
View File
@@ -1649,6 +1649,9 @@ impl Session {
previous_permission_profile != updated_permission_profile;
let mcp_inputs_changed =
self.mcp_inputs_differ(&state.session_configuration, &updated, &updates);
let root_service_tier_changed = updated.parent_thread_id.is_none()
&& state.session_configuration.step_settings.service_tier
!= updated.step_settings.service_tier;
if mcp_inputs_changed {
self.mark_mcp_runtime_dirty();
}
@@ -1665,6 +1668,15 @@ impl Session {
.update_thread_config(&environment_config);
}
state.session_configuration = updated;
if root_service_tier_changed {
self.services.agent_control.set_root_service_tier(
state
.session_configuration
.step_settings
.service_tier
.clone(),
);
}
let new_config = notify_config_contributors
.then(|| self.build_effective_session_config(&state.session_configuration));
let commit = SessionSettingsCommit {
@@ -3320,7 +3332,22 @@ impl Session {
) -> CodexResult<Arc<StepContext>> {
// Capture once before asynchronous planning; all request consumers
// retain this immutable settings version even if the turn is updated.
let settings = turn_context.current_settings.load_full();
let mut settings = turn_context.current_settings.load_full();
if matches!(
turn_context.session_source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
) {
let root_service_tier = self.services.agent_control.root_service_tier();
if settings.selected().service_tier != root_service_tier {
let mut selected = settings.selected().clone();
selected.service_tier = root_service_tier;
settings = Arc::new(ResolvedStepSettings::new(
Arc::new(selected),
Arc::clone(&settings.model_info),
self.features.enabled(Feature::FastMode),
));
}
}
let token_budget = token_budget::resolve_token_budget(
turn_context.configured_token_budget.as_ref(),
turn_context.use_model_token_budget_defaults,
+9
View File
@@ -715,6 +715,15 @@ impl Session {
.parent_thread_id
.or_else(|| initial_history.get_resumed_parent_thread_id());
session_configuration.parent_thread_id = parent_thread_id;
if parent_thread_id.is_none() {
agent_control.set_root_service_tier(
session_configuration
.step_settings
.service_tier
.clone()
.or_else(|| config.service_tier.clone()),
);
}
let is_paginated_subagent = matches!(
session_configuration.history_mode,
ThreadHistoryMode::Paginated
@@ -91,9 +91,6 @@ 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_agent_type_override(role_name)?;
}
@@ -108,13 +105,7 @@ async fn handle_spawn_agent(
if !args.fork_context {
apply_spawn_agent_role(&session, &mut config, role_name).await?;
}
apply_spawn_agent_service_tier(
&session,
&mut config,
turn.config.service_tier.as_deref(),
args.service_tier.as_deref(),
)
.await?;
apply_spawn_agent_service_tier(&session, &mut config).await?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata(
@@ -237,7 +228,6 @@ struct SpawnAgentArgs {
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
service_tier: Option<String>,
#[serde(default)]
fork_context: bool,
}
@@ -11,6 +11,7 @@ use crate::tools::context::ToolPayload;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::models::BaseInstructions;
@@ -321,17 +322,14 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides(
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 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) {
let Some(service_tier) = session.services.agent_control.root_service_tier() else {
config.service_tier = None;
return Ok(());
};
if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE {
config.service_tier = Some(service_tier);
return Ok(());
}
let model = config.model.clone().ok_or_else(|| {
@@ -345,31 +343,9 @@ pub(crate) async fn apply_spawn_agent_service_tier(
.get_model_info(model.as_str(), &config.to_models_manager_config())
.await;
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}"
)));
}
config.service_tier =
candidate_service_tiers
.into_iter()
.flatten()
.find(|candidate_service_tier| {
model_info.supports_service_tier(candidate_service_tier.as_str())
});
config.service_tier = model_info
.supports_service_tier(service_tier.as_str())
.then_some(service_tier);
Ok(())
}
@@ -18,8 +18,6 @@ const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your
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 =
"Service tier override for the new agent. Omit unless explicitly requested.";
const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64;
#[derive(Debug, Clone)]
@@ -110,9 +108,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
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 {
properties.remove("model");
properties.remove("reasoning_effort");
@@ -619,12 +614,6 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
.to_string(),
)),
),
(
"service_tier".to_string(),
JsonSchema::string(Some(
SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION.to_string(),
)),
),
])
}
@@ -663,12 +652,6 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
.to_string(),
)),
),
(
"service_tier".to_string(),
JsonSchema::string(Some(
SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION.to_string(),
)),
),
])
}
@@ -676,7 +659,6 @@ fn hide_spawn_agent_metadata_options(properties: &mut BTreeMap<String, JsonSchem
properties.remove("agent_type");
properties.remove("model");
properties.remove("reasoning_effort");
properties.remove("service_tier");
}
fn spawn_agent_tool_description(
@@ -115,12 +115,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
.and_then(|schema| schema.description.as_deref()),
Some("Reasoning effort override for the new agent. Omit to inherit the parent effort.")
);
assert_eq!(
properties
.get("service_tier")
.and_then(|schema| schema.description.as_deref()),
Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION)
);
assert!(!properties.contains_key("service_tier"));
assert_eq!(
parameters.required.as_ref(),
Some(&vec!["task_name".to_string(), "message".to_string()])
@@ -181,12 +176,7 @@ 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)
);
assert!(!properties.contains_key("service_tier"));
}
#[test]
@@ -407,106 +407,31 @@ async fn multi_agent_v2_spawn_fork_turns_all_applies_agent_type_override() {
}
#[tokio::test]
async fn spawn_agent_service_tier_override_validates_the_effective_child_model() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
}
async fn spawn_agent_service_tier_uses_root_preference_when_root_model_cannot_support_it() {
let (_session, turn) = make_session_and_context().await;
let mut config = (*turn.config).clone();
config.model = Some("gpt-5.4-mini".to_string());
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
let manager = thread_manager();
let root = manager
.start_thread(StartThreadOptions::new(config.clone()))
.await
.expect("root thread should start");
assert_eq!(root.thread.config_snapshot().await.service_tier, None);
{
let (mut session, turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread(StartThreadOptions::new((*turn.config).clone()))
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.thread_id = root.thread_id;
config.model = Some("gpt-5.4".to_string());
apply_spawn_agent_service_tier(root.thread.session.as_ref(), &mut config)
.await
.expect("root preference should be resolved against the child model");
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
.err()
.expect("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.4-mini",
"service_tier": ServiceTier::Fast.request_value()
})),
))
.await
.err()
.expect("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.4-mini`. Supported service tiers: none"
.to_string()
)
);
}
assert_eq!(
config.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
}
#[tokio::test]
async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_tiers() {
async fn spawn_agent_service_tier_inheritance_uses_root_preference_and_child_model() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
@@ -525,7 +450,7 @@ async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_
.start_thread(StartThreadOptions::new((*turn.config).clone()))
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.services.agent_control = root.thread.session.services.agent_control.clone();
session.thread_id = root.thread_id;
let output = SpawnAgentHandler::default()
@@ -566,7 +491,7 @@ async fn spawn_agent_service_tier_inheritance_preserves_supported_or_configured_
.start_thread(StartThreadOptions::new((*turn.config).clone()))
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.services.agent_control = root.thread.session.services.agent_control.clone();
session.thread_id = root.thread_id;
let output = SpawnAgentHandler::default()
@@ -643,7 +568,7 @@ service_tier = "priority"
})),
))
.await
.expect("spawn_agent should preserve the child role service tier");
.expect("spawn_agent should ignore 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");
@@ -654,15 +579,12 @@ service_tier = "priority"
.config_snapshot()
.await;
assert_eq!(
snapshot.service_tier,
Some(ServiceTier::Fast.request_value().to_string())
);
assert_eq!(snapshot.service_tier, None);
}
}
#[tokio::test]
async fn spawn_agent_role_service_tier_falls_back_to_supported_parent_tier() {
async fn spawn_agent_role_service_tier_cannot_override_root_preference() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
@@ -702,7 +624,7 @@ service_tier = "turbo"
.start_thread(StartThreadOptions::new((*turn.config).clone()))
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.services.agent_control = root.thread.session.services.agent_control.clone();
session.thread_id = root.thread_id;
let output = SpawnAgentHandler::default()
@@ -734,72 +656,25 @@ service_tier = "turbo"
}
#[tokio::test]
async fn spawn_agent_role_service_tier_does_not_hide_invalid_spawn_request() {
let (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("tiered-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 = "tiered-role".to_string();
let mut config = (*turn.config).clone();
config.agent_roles.insert(
role_name.clone(),
AgentRoleConfig {
description: Some("Role with a supported child tier".to_string()),
config_file: Some(role_config_path),
nickname_candidates: None,
},
);
turn.config = Arc::new(config);
let result = 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;
assert_eq!(
result.err(),
Some(FunctionCallError::RespondToModel(
"Service tier `turbo` is not supported for model `gpt-5.4`. Supported service tiers: priority"
.to_string()
))
);
}
#[tokio::test]
async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() {
async fn spawn_agent_full_history_fork_inherits_root_service_tier() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
}
let (mut session, turn) = make_session_and_context().await;
let turn = turn
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(StartThreadOptions::new((*turn.config).clone()))
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.services.agent_control = root.thread.session.services.agent_control.clone();
session.thread_id = root.thread_id;
let output = SpawnAgentHandler::default()
@@ -809,12 +684,11 @@ async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() {
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"fork_context": true,
"service_tier": ServiceTier::Fast.request_value()
"fork_context": true
})),
))
.await
.expect("full-history fork should accept explicit service tier");
.expect("full-history fork should inherit the root service tier");
let (content, _) = expect_text_output(output);
let result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn_agent result should be json");
@@ -832,7 +706,7 @@ async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() {
}
#[tokio::test]
async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
async fn multi_agent_v2_full_history_fork_inherits_root_service_tier() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
@@ -843,6 +717,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
.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());
config
.features
.enable(Feature::MultiAgentV2)
@@ -853,7 +728,7 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
.start_thread(StartThreadOptions::new((*turn.config).clone()))
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.services.agent_control = root.thread.session.services.agent_control.clone();
session.thread_id = root.thread_id;
let session = Arc::new(session);
let turn = Arc::new(turn);
@@ -865,12 +740,11 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
"spawn_agent",
function_payload(json!({
"message": "inspect this repo",
"task_name": "fork_with_tier",
"service_tier": ServiceTier::Fast.request_value()
"task_name": "fork_with_tier"
})),
))
.await
.expect("multi-agent v2 full-history fork should accept explicit service tier");
.expect("multi-agent v2 full-history fork should inherit the root service tier");
let (content, _) = expect_text_output(output);
let result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn_agent result should be json");
@@ -124,9 +124,6 @@ async fn handle_spawn_agent(
let child_depth = next_thread_spawn_depth(&session_source);
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());
}
let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory));
apply_requested_spawn_agent_model_overrides(
&session,
@@ -144,13 +141,7 @@ async fn handle_spawn_agent(
.clone_from(&turn.developer_instructions);
}
}
apply_spawn_agent_service_tier(
&session,
&mut config,
turn.config.service_tier.as_deref(),
args.service_tier.as_deref(),
)
.await?;
apply_spawn_agent_service_tier(&session, &mut config).await?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
// Remember an applied configured default so cold reload reapplies its restrictions.
@@ -288,7 +279,6 @@ struct SpawnAgentArgs {
agent_type: Option<String>,
model: Option<String>,
reasoning_effort: Option<ReasoningEffort>,
service_tier: Option<String>,
fork_turns: Option<String>,
fork_context: Option<bool>,
}
+1 -1
View File
@@ -2658,7 +2658,7 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
.properties
.as_ref()
.expect("spawn_agent should use object params");
for property in ["model", "reasoning_effort", "service_tier"] {
for property in ["model", "reasoning_effort"] {
assert!(
properties.contains_key(property),
"expected v1 spawn_agent to expose `{property}`"
+1
View File
@@ -161,6 +161,7 @@ mod step_settings_snapshots;
mod stream_error_allows_next_turn;
mod stream_no_completed;
mod subagent_notifications;
mod subagent_service_tier;
mod token_budget;
mod tool_harness;
mod tool_lifecycle;
@@ -0,0 +1,397 @@
//! Verifies that root service-tier changes reach existing child turns and future work.
use anyhow::Result;
use codex_core::TurnInputRequest;
use codex_core::config::AgentRoleConfig;
use codex_core::config::Config;
use codex_features::Feature;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::submit_thread_settings;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::json;
use test_case::test_case;
const ROOT_PROMPT: &str = "spawn the service-tier worker";
const CHILD_PROMPT: &str = "pause the service-tier worker";
const FOLLOWUP_PROMPT: &str = "continue the service-tier worker later";
const FRESH_ROOT_PROMPT: &str = "spawn another service-tier worker";
const FRESH_CHILD_PROMPT: &str = "finish the new service-tier worker";
const SPAWN_CALL_ID: &str = "spawn-service-tier-worker";
const FRESH_SPAWN_CALL_ID: &str = "spawn-fresh-service-tier-worker";
const PAUSE_CALL_ID: &str = "pause-service-tier-worker";
const PRIORITY_ROLE: &str = "priority-worker";
fn body_contains(request: &wiremock::Request, text: &str) -> bool {
let body = match request
.headers
.get("content-encoding")
.and_then(|encoding| encoding.to_str().ok())
{
Some(encoding) if encoding.eq_ignore_ascii_case("zstd") => {
zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok()
}
_ => Some(request.body.clone()),
};
body.and_then(|body| String::from_utf8(body).ok())
.is_some_and(|body| body.contains(text))
}
fn assert_request_service_tier(request: &ResponseMock, expected: Option<&str>) {
assert_eq!(
request
.single_request()
.body_json()
.get("service_tier")
.and_then(serde_json::Value::as_str),
expected
);
}
fn configure_priority_role(config: &mut Config) {
for feature in [Feature::Collab, Feature::MultiAgentV2] {
config
.features
.enable(feature)
.expect("test config should allow feature update");
}
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
let role_path = config.codex_home.join("priority-worker.toml");
std::fs::write(&role_path, "service_tier = \"priority\"\n")
.expect("priority role should be written");
config.agent_roles.insert(
PRIORITY_ROLE.to_string(),
AgentRoleConfig {
description: Some("Role with a configured priority tier".to_string()),
config_file: Some(role_path.to_path_buf()),
nickname_candidates: None,
},
);
}
async fn wait_for_turn_complete(thread: &codex_core::CodexThread) {
wait_for_event(thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
}
async fn mount_root_collaboration_call(
server: &wiremock::MockServer,
prompt: &'static str,
call_id: &'static str,
arguments: serde_json::Value,
) {
let response_id = format!("root-{call_id}");
mount_sse_once_match(
server,
move |request: &wiremock::Request| {
body_contains(request, prompt) && !body_contains(request, call_id)
},
sse(vec![
ev_response_created(&response_id),
ev_function_call_with_namespace(
call_id,
"collaboration",
"spawn_agent",
&arguments.to_string(),
),
ev_completed(&response_id),
]),
)
.await;
let completion_id = format!("root-{call_id}-finished");
mount_sse_once_match(
server,
move |request: &wiremock::Request| {
body_contains(request, prompt) && body_contains(request, call_id)
},
sse(vec![
ev_response_created(&completion_id),
ev_assistant_message(&format!("{completion_id}-message"), "worker started"),
ev_completed(&completion_id),
]),
)
.await;
}
async fn mount_completed_child(
server: &wiremock::MockServer,
prompt: &'static str,
root_prompt: &'static str,
) -> ResponseMock {
mount_sse_once_match(
server,
move |request: &wiremock::Request| {
body_contains(request, prompt) && !body_contains(request, root_prompt)
},
sse(vec![
ev_response_created(prompt),
ev_assistant_message(&format!("{prompt}-message"), "worker completed"),
ev_completed(prompt),
]),
)
.await
}
#[test_case(Some("priority"), None; "disabling fast mode updates active and idle child work")]
#[test_case(Some("priority"), Some("default"); "explicit default updates active and idle child work")]
#[test_case(None, Some("priority"); "enabling fast mode updates active and idle child work")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn root_service_tier_change_updates_existing_subagent(
initial_service_tier: Option<&str>,
updated_service_tier: Option<&str>,
) -> Result<()> {
let server = start_mock_server().await;
let initial_service_tier_owned = initial_service_tier.map(str::to_string);
let updated_request_service_tier = updated_service_tier
.filter(|service_tier| *service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE);
let mut builder = test_codex()
.with_model("gpt-5.6-sol")
.with_config(move |config| {
config.service_tier = initial_service_tier_owned;
configure_priority_role(config);
});
let test = builder.build_with_auto_env(&server).await?;
assert!(!test.config.features.enabled(Feature::StepModelSwitching));
let mut created_threads = test.thread_manager.subscribe_thread_created();
mount_root_collaboration_call(
&server,
ROOT_PROMPT,
SPAWN_CALL_ID,
json!({
"message": CHILD_PROMPT,
"task_name": "worker",
"agent_type": PRIORITY_ROLE,
"fork_turns": "none",
}),
)
.await;
let initial_child_request = mount_sse_once_match(
&server,
|request: &wiremock::Request| {
body_contains(request, CHILD_PROMPT)
&& !body_contains(request, ROOT_PROMPT)
&& !body_contains(request, PAUSE_CALL_ID)
},
sse(vec![
ev_response_created("child-paused"),
ev_function_call_with_namespace(
PAUSE_CALL_ID,
"collaboration",
"wait_agent",
&json!({ "timeout_ms": 30_000 }).to_string(),
),
ev_completed("child-paused"),
]),
)
.await;
let continued_child_request = mount_sse_once_match(
&server,
|request: &wiremock::Request| {
body_contains(request, PAUSE_CALL_ID) && !body_contains(request, ROOT_PROMPT)
},
sse(vec![
ev_response_created("child-continued"),
ev_assistant_message("child-continued-message", "child continued"),
ev_completed("child-continued"),
]),
)
.await;
test.submit_text_turn(ROOT_PROMPT).await?;
let child_thread_id = created_threads.recv().await?;
let child = test.thread_manager.get_thread(child_thread_id).await?;
let original_child_service_tier = child.config_snapshot().await.service_tier;
assert_eq!(original_child_service_tier.as_deref(), initial_service_tier);
wait_for_event_match(child.as_ref(), |event| match event {
EventMsg::CollabWaitingBegin(request) if request.call_id == PAUSE_CALL_ID => Some(()),
EventMsg::Error(error) => panic!("child failed before pausing: {}", error.message),
EventMsg::TurnComplete(completed) => {
panic!("child completed before pausing: {completed:?}")
}
_ => None,
})
.await;
assert_request_service_tier(&initial_child_request, initial_service_tier);
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
service_tier: Some(updated_service_tier.map(str::to_string)),
..Default::default()
},
)
.await?;
assert_eq!(
child.config_snapshot().await.service_tier,
original_child_service_tier,
"the root routing policy must not rewrite child-owned settings"
);
child
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "continue after the service-tier update".to_string(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_turn_complete(&child).await;
assert_request_service_tier(&continued_child_request, updated_request_service_tier);
let child_compaction_request = mount_sse_once_match(
&server,
|request: &wiremock::Request| body_contains(request, "compaction_trigger"),
sse(vec![
ev_response_created("child-remote-compaction"),
json!({
"type": "response.output_item.done",
"item": {
"type": "compaction",
"encrypted_content": "compacted child history",
},
}),
ev_completed("child-remote-compaction"),
]),
)
.await;
child.submit(Op::Compact).await?;
wait_for_turn_complete(&child).await;
assert_request_service_tier(&child_compaction_request, updated_request_service_tier);
let future_child_request = mount_completed_child(&server, FOLLOWUP_PROMPT, ROOT_PROMPT).await;
child
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: FOLLOWUP_PROMPT.to_string(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_turn_complete(&child).await;
assert_request_service_tier(&future_child_request, updated_request_service_tier);
mount_root_collaboration_call(
&server,
FRESH_ROOT_PROMPT,
FRESH_SPAWN_CALL_ID,
json!({
"message": FRESH_CHILD_PROMPT,
"task_name": "fresh",
"agent_type": PRIORITY_ROLE,
"fork_turns": "none",
}),
)
.await;
let fresh_child_request =
mount_completed_child(&server, FRESH_CHILD_PROMPT, FRESH_ROOT_PROMPT).await;
test.submit_text_turn(FRESH_ROOT_PROMPT).await?;
let fresh_thread_id = created_threads.recv().await?;
let fresh_thread = test.thread_manager.get_thread(fresh_thread_id).await?;
wait_for_turn_complete(&fresh_thread).await;
assert_request_service_tier(&fresh_child_request, updated_request_service_tier);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn evicted_role_subagent_uses_root_service_tier_after_reload() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex()
.with_model("gpt-5.6-sol")
.with_config(|config| {
config.service_tier = Some("priority".to_string());
config.multi_agent_v2.max_concurrent_threads_per_session = 2;
configure_priority_role(config);
});
let test = builder.build_with_auto_env(&server).await?;
let mut created_threads = test.thread_manager.subscribe_thread_created();
mount_root_collaboration_call(
&server,
ROOT_PROMPT,
SPAWN_CALL_ID,
json!({
"message": CHILD_PROMPT,
"task_name": "original",
"agent_type": PRIORITY_ROLE,
"fork_turns": "none",
}),
)
.await;
let original_request = mount_completed_child(&server, CHILD_PROMPT, ROOT_PROMPT).await;
test.submit_text_turn(ROOT_PROMPT).await?;
let original_thread_id = created_threads.recv().await?;
let original_thread = test.thread_manager.get_thread(original_thread_id).await?;
wait_for_turn_complete(&original_thread).await;
assert_request_service_tier(&original_request, Some("priority"));
drop(original_thread);
mount_root_collaboration_call(
&server,
FRESH_ROOT_PROMPT,
FRESH_SPAWN_CALL_ID,
json!({
"message": FRESH_CHILD_PROMPT,
"task_name": "replacement",
"fork_turns": "none",
}),
)
.await;
mount_completed_child(&server, FRESH_CHILD_PROMPT, FRESH_ROOT_PROMPT).await;
test.submit_text_turn(FRESH_ROOT_PROMPT).await?;
let replacement_thread_id = created_threads.recv().await?;
let replacement_thread = test
.thread_manager
.get_thread(replacement_thread_id)
.await?;
wait_for_turn_complete(&replacement_thread).await;
assert!(
test.thread_manager
.get_thread(original_thread_id)
.await
.is_err()
);
submit_thread_settings(
&test.codex,
ThreadSettingsOverrides {
service_tier: Some(None),
..Default::default()
},
)
.await?;
test.thread_manager
.ensure_multi_agent_v2_child_loaded(original_thread_id)
.await?;
let reloaded_thread = test.thread_manager.get_thread(original_thread_id).await?;
assert_eq!(
reloaded_thread.config_snapshot().await.service_tier,
test.codex.config_snapshot().await.service_tier,
"reload ignores the role tier and preserves the root-owned preference"
);
let reloaded_request = mount_completed_child(&server, FOLLOWUP_PROMPT, ROOT_PROMPT).await;
reloaded_thread
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: FOLLOWUP_PROMPT.to_string(),
text_elements: Vec::new(),
}]))
.await?;
wait_for_turn_complete(&reloaded_thread).await;
assert_request_service_tier(&reloaded_request, /*expected*/ None);
reloaded_thread.shutdown_and_wait().await?;
test.codex.shutdown_and_wait().await?;
Ok(())
}