mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
94311d4475
commit
dc2ccc6843
19 files changed
+513
-266
No files matched your search
@@ -25,6 +25,7 @@ use crate::thread_manager::ThreadManagerState;
|
|||||||
use crate::thread_manager::default_thread_id_generator;
|
use crate::thread_manager::default_thread_id_generator;
|
||||||
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
|
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
|
||||||
use crate::turn_timing::now_unix_timestamp_ms;
|
use crate::turn_timing::now_unix_timestamp_ms;
|
||||||
|
use arc_swap::ArcSwapOption;
|
||||||
use codex_history::InitialHistory;
|
use codex_history::InitialHistory;
|
||||||
use codex_history::ResumedHistory;
|
use codex_history::ResumedHistory;
|
||||||
use codex_history::RolloutItem;
|
use codex_history::RolloutItem;
|
||||||
@@ -72,6 +73,7 @@ use self::residency::V2Residency;
|
|||||||
mod execution;
|
mod execution;
|
||||||
mod legacy;
|
mod legacy;
|
||||||
mod residency;
|
mod residency;
|
||||||
|
mod service_tier;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
mod user_authorization;
|
mod user_authorization;
|
||||||
|
|
||||||
@@ -128,6 +130,8 @@ pub(crate) struct AgentControl {
|
|||||||
agent_execution_limiter: Arc<AgentExecutionLimiter>,
|
agent_execution_limiter: Arc<AgentExecutionLimiter>,
|
||||||
/// Session-scoped state shared by the root thread and every cloned sub-agent control handle.
|
/// Session-scoped state shared by the root thread and every cloned sub-agent control handle.
|
||||||
rollout_budget: Arc<RolloutBudget>,
|
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 {
|
impl Default for AgentControl {
|
||||||
@@ -155,6 +159,7 @@ impl AgentControl {
|
|||||||
v2_residency: Arc::default(),
|
v2_residency: Arc::default(),
|
||||||
agent_execution_limiter: Arc::default(),
|
agent_execution_limiter: Arc::default(),
|
||||||
rollout_budget: Arc::default(),
|
rollout_budget: Arc::default(),
|
||||||
|
root_service_tier: Arc::new(ArcSwapOption::from(None)),
|
||||||
};
|
};
|
||||||
if let Some(rollout_budget) = rollout_budget {
|
if let Some(rollout_budget) = rollout_budget {
|
||||||
control.rollout_budget.configure(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -415,6 +415,7 @@ impl AgentControl {
|
|||||||
CodexErr::InvalidRequest(format!("permission_profile is invalid: {err}"))
|
CodexErr::InvalidRequest(format!("permission_profile is invalid: {err}"))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
config.service_tier = self.root_service_tier();
|
||||||
if let Some(model) = stored_model {
|
if let Some(model) = stored_model {
|
||||||
config.model = Some(model);
|
config.model = Some(model);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,11 +311,7 @@ pub(crate) mod spawn_tool_spec {
|
|||||||
let reasoning_effort = role_toml
|
let reasoning_effort = role_toml
|
||||||
.get("model_reasoning_effort")
|
.get("model_reasoning_effort")
|
||||||
.and_then(TomlValue::as_str);
|
.and_then(TomlValue::as_str);
|
||||||
let service_tier = role_toml
|
match (model, reasoning_effort) {
|
||||||
.get("service_tier")
|
|
||||||
.and_then(TomlValue::as_str);
|
|
||||||
|
|
||||||
let model_and_reasoning_note = match (model, reasoning_effort) {
|
|
||||||
(Some(model), Some(reasoning_effort)) => format!(
|
(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."
|
"\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(),
|
(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();
|
.unwrap_or_default();
|
||||||
format!("{name}: {{\n{description}{locked_settings_note}\n}}")
|
format!("{name}: {{\n{description}{locked_settings_note}\n}}")
|
||||||
|
|||||||
@@ -711,7 +711,7 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 tempdir = TempDir::new().expect("create temp dir");
|
||||||
let role_path = tempdir.path().join("tiered.toml");
|
let role_path = tempdir.path().join("tiered.toml");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -730,9 +730,7 @@ fn spawn_tool_spec_marks_role_locked_service_tier() {
|
|||||||
|
|
||||||
let spec = spawn_tool_spec::build(&user_defined_roles);
|
let spec = spawn_tool_spec::build(&user_defined_roles);
|
||||||
|
|
||||||
assert!(spec.contains(
|
assert!(spec.contains("tiered: {\nStay fast.\n}"));
|
||||||
"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]
|
#[test]
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ pub(super) async fn run_remote_compact_attempt(
|
|||||||
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
|
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
turn_context.config.service_tier.clone()
|
step_context.settings.service_tier.clone()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
&turn_context.session_telemetry,
|
&turn_context.session_telemetry,
|
||||||
|
|||||||
@@ -367,11 +367,12 @@ struct RemoteCompactionV2Output {
|
|||||||
|
|
||||||
async fn run_remote_compaction_request_v2(
|
async fn run_remote_compaction_request_v2(
|
||||||
sess: &Session,
|
sess: &Session,
|
||||||
turn_context: &TurnContext,
|
step_context: &StepContext,
|
||||||
client_session: &mut ModelClientSession,
|
client_session: &mut ModelClientSession,
|
||||||
prompt: &Prompt,
|
prompt: &Prompt,
|
||||||
responses_metadata: &CodexResponsesMetadata,
|
responses_metadata: &CodexResponsesMetadata,
|
||||||
) -> CodexResult<RemoteCompactionV2Output> {
|
) -> CodexResult<RemoteCompactionV2Output> {
|
||||||
|
let turn_context = &step_context.turn;
|
||||||
let max_retries = turn_context
|
let max_retries = turn_context
|
||||||
.provider
|
.provider
|
||||||
.info()
|
.info()
|
||||||
@@ -386,7 +387,7 @@ async fn run_remote_compaction_request_v2(
|
|||||||
&turn_context.session_telemetry,
|
&turn_context.session_telemetry,
|
||||||
turn_context.reasoning_effort().cloned(),
|
turn_context.reasoning_effort().cloned(),
|
||||||
turn_context.reasoning_summary(),
|
turn_context.reasoning_summary(),
|
||||||
turn_context.config.service_tier.clone(),
|
step_context.settings.service_tier.clone(),
|
||||||
responses_metadata,
|
responses_metadata,
|
||||||
&InferenceTraceContext::disabled(),
|
&InferenceTraceContext::disabled(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ pub(super) async fn run_remote_compact_v2_attempt(
|
|||||||
};
|
};
|
||||||
let compaction_output_result = run_remote_compaction_request_v2(
|
let compaction_output_result = run_remote_compaction_request_v2(
|
||||||
sess,
|
sess,
|
||||||
turn_context.as_ref(),
|
step_context,
|
||||||
client_session,
|
client_session,
|
||||||
&prompt,
|
&prompt,
|
||||||
&responses_metadata,
|
&responses_metadata,
|
||||||
|
|||||||
@@ -1649,6 +1649,9 @@ impl Session {
|
|||||||
previous_permission_profile != updated_permission_profile;
|
previous_permission_profile != updated_permission_profile;
|
||||||
let mcp_inputs_changed =
|
let mcp_inputs_changed =
|
||||||
self.mcp_inputs_differ(&state.session_configuration, &updated, &updates);
|
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 {
|
if mcp_inputs_changed {
|
||||||
self.mark_mcp_runtime_dirty();
|
self.mark_mcp_runtime_dirty();
|
||||||
}
|
}
|
||||||
@@ -1665,6 +1668,15 @@ impl Session {
|
|||||||
.update_thread_config(&environment_config);
|
.update_thread_config(&environment_config);
|
||||||
}
|
}
|
||||||
state.session_configuration = updated;
|
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
|
let new_config = notify_config_contributors
|
||||||
.then(|| self.build_effective_session_config(&state.session_configuration));
|
.then(|| self.build_effective_session_config(&state.session_configuration));
|
||||||
let commit = SessionSettingsCommit {
|
let commit = SessionSettingsCommit {
|
||||||
@@ -3320,7 +3332,22 @@ impl Session {
|
|||||||
) -> CodexResult<Arc<StepContext>> {
|
) -> CodexResult<Arc<StepContext>> {
|
||||||
// Capture once before asynchronous planning; all request consumers
|
// Capture once before asynchronous planning; all request consumers
|
||||||
// retain this immutable settings version even if the turn is updated.
|
// 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(
|
let token_budget = token_budget::resolve_token_budget(
|
||||||
turn_context.configured_token_budget.as_ref(),
|
turn_context.configured_token_budget.as_ref(),
|
||||||
turn_context.use_model_token_budget_defaults,
|
turn_context.use_model_token_budget_defaults,
|
||||||
|
|||||||
@@ -715,6 +715,15 @@ impl Session {
|
|||||||
.parent_thread_id
|
.parent_thread_id
|
||||||
.or_else(|| initial_history.get_resumed_parent_thread_id());
|
.or_else(|| initial_history.get_resumed_parent_thread_id());
|
||||||
session_configuration.parent_thread_id = 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!(
|
let is_paginated_subagent = matches!(
|
||||||
session_configuration.history_mode,
|
session_configuration.history_mode,
|
||||||
ThreadHistoryMode::Paginated
|
ThreadHistoryMode::Paginated
|
||||||
|
|||||||
@@ -91,9 +91,6 @@ async fn handle_spawn_agent(
|
|||||||
.await;
|
.await;
|
||||||
let mut config =
|
let mut config =
|
||||||
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
|
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 {
|
if args.fork_context {
|
||||||
reject_full_fork_agent_type_override(role_name)?;
|
reject_full_fork_agent_type_override(role_name)?;
|
||||||
}
|
}
|
||||||
@@ -108,13 +105,7 @@ async fn handle_spawn_agent(
|
|||||||
if !args.fork_context {
|
if !args.fork_context {
|
||||||
apply_spawn_agent_role(&session, &mut config, role_name).await?;
|
apply_spawn_agent_role(&session, &mut config, role_name).await?;
|
||||||
}
|
}
|
||||||
apply_spawn_agent_service_tier(
|
apply_spawn_agent_service_tier(&session, &mut config).await?;
|
||||||
&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_runtime_overrides(&mut config, turn.as_ref())?;
|
||||||
|
|
||||||
let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata(
|
let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata(
|
||||||
@@ -237,7 +228,6 @@ struct SpawnAgentArgs {
|
|||||||
agent_type: Option<String>,
|
agent_type: Option<String>,
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
reasoning_effort: Option<ReasoningEffort>,
|
reasoning_effort: Option<ReasoningEffort>,
|
||||||
service_tier: Option<String>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
fork_context: bool,
|
fork_context: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::tools::context::ToolPayload;
|
|||||||
use codex_models_manager::manager::RefreshStrategy;
|
use codex_models_manager::manager::RefreshStrategy;
|
||||||
use codex_protocol::AgentPath;
|
use codex_protocol::AgentPath;
|
||||||
use codex_protocol::ThreadId;
|
use codex_protocol::ThreadId;
|
||||||
|
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||||
use codex_protocol::error::CodexErr;
|
use codex_protocol::error::CodexErr;
|
||||||
use codex_protocol::error::CodexErrorDetails;
|
use codex_protocol::error::CodexErrorDetails;
|
||||||
use codex_protocol::models::BaseInstructions;
|
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(
|
pub(crate) async fn apply_spawn_agent_service_tier(
|
||||||
session: &Session,
|
session: &Session,
|
||||||
config: &mut Config,
|
config: &mut Config,
|
||||||
parent_service_tier: Option<&str>,
|
|
||||||
requested_service_tier: Option<&str>,
|
|
||||||
) -> Result<(), FunctionCallError> {
|
) -> Result<(), FunctionCallError> {
|
||||||
let candidate_service_tiers = [
|
let Some(service_tier) = session.services.agent_control.root_service_tier() else {
|
||||||
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;
|
config.service_tier = None;
|
||||||
return Ok(());
|
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(|| {
|
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())
|
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Some(requested_service_tier) = requested_service_tier
|
config.service_tier = model_info
|
||||||
&& !model_info.supports_service_tier(requested_service_tier)
|
.supports_service_tier(service_tier.as_str())
|
||||||
{
|
.then_some(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())
|
|
||||||
});
|
|
||||||
Ok(())
|
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_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 =
|
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str =
|
||||||
"Model override for the new agent. Omit unless an explicit override is needed.";
|
"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;
|
const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -110,9 +108,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
|
|||||||
if !options.expose_agent_type {
|
if !options.expose_agent_type {
|
||||||
properties.remove("agent_type");
|
properties.remove("agent_type");
|
||||||
}
|
}
|
||||||
if options.hide_agent_type_model_reasoning {
|
|
||||||
properties.remove("service_tier");
|
|
||||||
}
|
|
||||||
if !options.expose_spawn_agent_model_overrides {
|
if !options.expose_spawn_agent_model_overrides {
|
||||||
properties.remove("model");
|
properties.remove("model");
|
||||||
properties.remove("reasoning_effort");
|
properties.remove("reasoning_effort");
|
||||||
@@ -619,12 +614,6 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
|
|||||||
.to_string(),
|
.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(),
|
.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("agent_type");
|
||||||
properties.remove("model");
|
properties.remove("model");
|
||||||
properties.remove("reasoning_effort");
|
properties.remove("reasoning_effort");
|
||||||
properties.remove("service_tier");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_agent_tool_description(
|
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()),
|
.and_then(|schema| schema.description.as_deref()),
|
||||||
Some("Reasoning effort override for the new agent. Omit to inherit the parent effort.")
|
Some("Reasoning effort override for the new agent. Omit to inherit the parent effort.")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(!properties.contains_key("service_tier"));
|
||||||
properties
|
|
||||||
.get("service_tier")
|
|
||||||
.and_then(|schema| schema.description.as_deref()),
|
|
||||||
Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parameters.required.as_ref(),
|
parameters.required.as_ref(),
|
||||||
Some(&vec!["task_name".to_string(), "message".to_string()])
|
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()),
|
.and_then(|schema| schema.description.as_deref()),
|
||||||
Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION)
|
Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(!properties.contains_key("service_tier"));
|
||||||
properties
|
|
||||||
.get("service_tier")
|
|
||||||
.and_then(|schema| schema.description.as_deref()),
|
|
||||||
Some(SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -407,106 +407,31 @@ async fn multi_agent_v2_spawn_fork_turns_all_applies_agent_type_override() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_agent_service_tier_override_validates_the_effective_child_model() {
|
async fn spawn_agent_service_tier_uses_root_preference_when_root_model_cannot_support_it() {
|
||||||
#[derive(Debug, Deserialize)]
|
let (_session, turn) = make_session_and_context().await;
|
||||||
struct SpawnAgentResult {
|
let mut config = (*turn.config).clone();
|
||||||
agent_id: String,
|
config.model = Some("gpt-5.4-mini".to_string());
|
||||||
}
|
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||||
|
|
||||||
{
|
|
||||||
let (mut session, turn) = make_session_and_context().await;
|
|
||||||
let manager = thread_manager();
|
let manager = thread_manager();
|
||||||
let root = manager
|
let root = manager
|
||||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
.start_thread(StartThreadOptions::new(config.clone()))
|
||||||
.await
|
.await
|
||||||
.expect("root thread should start");
|
.expect("root thread should start");
|
||||||
session.services.agent_control = manager.agent_control();
|
assert_eq!(root.thread.config_snapshot().await.service_tier, None);
|
||||||
session.thread_id = root.thread_id;
|
|
||||||
|
|
||||||
let output = SpawnAgentHandler::default()
|
config.model = Some("gpt-5.4".to_string());
|
||||||
.handle(invocation(
|
apply_spawn_agent_service_tier(root.thread.session.as_ref(), &mut config)
|
||||||
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
|
.await
|
||||||
.expect("spawn_agent should accept a supported explicit service tier");
|
.expect("root preference should be resolved against the child model");
|
||||||
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!(
|
assert_eq!(
|
||||||
snapshot.service_tier,
|
config.service_tier,
|
||||||
Some(ServiceTier::Fast.request_value().to_string())
|
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()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SpawnAgentResult {
|
struct SpawnAgentResult {
|
||||||
agent_id: String,
|
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()))
|
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||||
.await
|
.await
|
||||||
.expect("root thread should start");
|
.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;
|
session.thread_id = root.thread_id;
|
||||||
|
|
||||||
let output = SpawnAgentHandler::default()
|
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()))
|
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||||
.await
|
.await
|
||||||
.expect("root thread should start");
|
.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;
|
session.thread_id = root.thread_id;
|
||||||
|
|
||||||
let output = SpawnAgentHandler::default()
|
let output = SpawnAgentHandler::default()
|
||||||
@@ -643,7 +568,7 @@ service_tier = "priority"
|
|||||||
})),
|
})),
|
||||||
))
|
))
|
||||||
.await
|
.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 (content, _) = expect_text_output(output);
|
||||||
let result: SpawnAgentResult =
|
let result: SpawnAgentResult =
|
||||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
||||||
@@ -654,15 +579,12 @@ service_tier = "priority"
|
|||||||
.config_snapshot()
|
.config_snapshot()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(snapshot.service_tier, None);
|
||||||
snapshot.service_tier,
|
|
||||||
Some(ServiceTier::Fast.request_value().to_string())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SpawnAgentResult {
|
struct SpawnAgentResult {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
@@ -702,7 +624,7 @@ service_tier = "turbo"
|
|||||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||||
.await
|
.await
|
||||||
.expect("root thread should start");
|
.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;
|
session.thread_id = root.thread_id;
|
||||||
|
|
||||||
let output = SpawnAgentHandler::default()
|
let output = SpawnAgentHandler::default()
|
||||||
@@ -734,72 +656,25 @@ service_tier = "turbo"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_agent_role_service_tier_does_not_hide_invalid_spawn_request() {
|
async fn spawn_agent_full_history_fork_inherits_root_service_tier() {
|
||||||
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() {
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SpawnAgentResult {
|
struct SpawnAgentResult {
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mut session, turn) = make_session_and_context().await;
|
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)
|
.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
||||||
.await;
|
.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 manager = thread_manager();
|
||||||
let root = manager
|
let root = manager
|
||||||
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||||
.await
|
.await
|
||||||
.expect("root thread should start");
|
.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;
|
session.thread_id = root.thread_id;
|
||||||
|
|
||||||
let output = SpawnAgentHandler::default()
|
let output = SpawnAgentHandler::default()
|
||||||
@@ -809,12 +684,11 @@ async fn spawn_agent_full_history_fork_accepts_explicit_service_tier() {
|
|||||||
"spawn_agent",
|
"spawn_agent",
|
||||||
function_payload(json!({
|
function_payload(json!({
|
||||||
"message": "inspect this repo",
|
"message": "inspect this repo",
|
||||||
"fork_context": true,
|
"fork_context": true
|
||||||
"service_tier": ServiceTier::Fast.request_value()
|
|
||||||
})),
|
})),
|
||||||
))
|
))
|
||||||
.await
|
.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 (content, _) = expect_text_output(output);
|
||||||
let result: SpawnAgentResult =
|
let result: SpawnAgentResult =
|
||||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
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]
|
#[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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SpawnAgentResult {
|
struct SpawnAgentResult {
|
||||||
task_name: String,
|
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)
|
.with_model("gpt-5.4".to_string(), &session.services.models_manager)
|
||||||
.await;
|
.await;
|
||||||
let mut config = (*turn.config).clone();
|
let mut config = (*turn.config).clone();
|
||||||
|
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
|
||||||
config
|
config
|
||||||
.features
|
.features
|
||||||
.enable(Feature::MultiAgentV2)
|
.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()))
|
.start_thread(StartThreadOptions::new((*turn.config).clone()))
|
||||||
.await
|
.await
|
||||||
.expect("root thread should start");
|
.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;
|
session.thread_id = root.thread_id;
|
||||||
let session = Arc::new(session);
|
let session = Arc::new(session);
|
||||||
let turn = Arc::new(turn);
|
let turn = Arc::new(turn);
|
||||||
@@ -865,12 +740,11 @@ async fn multi_agent_v2_full_history_fork_accepts_explicit_service_tier() {
|
|||||||
"spawn_agent",
|
"spawn_agent",
|
||||||
function_payload(json!({
|
function_payload(json!({
|
||||||
"message": "inspect this repo",
|
"message": "inspect this repo",
|
||||||
"task_name": "fork_with_tier",
|
"task_name": "fork_with_tier"
|
||||||
"service_tier": ServiceTier::Fast.request_value()
|
|
||||||
})),
|
})),
|
||||||
))
|
))
|
||||||
.await
|
.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 (content, _) = expect_text_output(output);
|
||||||
let result: SpawnAgentResult =
|
let result: SpawnAgentResult =
|
||||||
serde_json::from_str(&content).expect("spawn_agent result should be json");
|
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 child_depth = next_thread_spawn_depth(&session_source);
|
||||||
let mut config =
|
let mut config =
|
||||||
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
|
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));
|
let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory));
|
||||||
apply_requested_spawn_agent_model_overrides(
|
apply_requested_spawn_agent_model_overrides(
|
||||||
&session,
|
&session,
|
||||||
@@ -144,13 +141,7 @@ async fn handle_spawn_agent(
|
|||||||
.clone_from(&turn.developer_instructions);
|
.clone_from(&turn.developer_instructions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply_spawn_agent_service_tier(
|
apply_spawn_agent_service_tier(&session, &mut config).await?;
|
||||||
&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_runtime_overrides(&mut config, turn.as_ref())?;
|
||||||
|
|
||||||
// Remember an applied configured default so cold reload reapplies its restrictions.
|
// Remember an applied configured default so cold reload reapplies its restrictions.
|
||||||
@@ -288,7 +279,6 @@ struct SpawnAgentArgs {
|
|||||||
agent_type: Option<String>,
|
agent_type: Option<String>,
|
||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
reasoning_effort: Option<ReasoningEffort>,
|
reasoning_effort: Option<ReasoningEffort>,
|
||||||
service_tier: Option<String>,
|
|
||||||
fork_turns: Option<String>,
|
fork_turns: Option<String>,
|
||||||
fork_context: Option<bool>,
|
fork_context: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2658,7 +2658,7 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
|
|||||||
.properties
|
.properties
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("spawn_agent should use object params");
|
.expect("spawn_agent should use object params");
|
||||||
for property in ["model", "reasoning_effort", "service_tier"] {
|
for property in ["model", "reasoning_effort"] {
|
||||||
assert!(
|
assert!(
|
||||||
properties.contains_key(property),
|
properties.contains_key(property),
|
||||||
"expected v1 spawn_agent to expose `{property}`"
|
"expected v1 spawn_agent to expose `{property}`"
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ mod step_settings_snapshots;
|
|||||||
mod stream_error_allows_next_turn;
|
mod stream_error_allows_next_turn;
|
||||||
mod stream_no_completed;
|
mod stream_no_completed;
|
||||||
mod subagent_notifications;
|
mod subagent_notifications;
|
||||||
|
mod subagent_service_tier;
|
||||||
mod token_budget;
|
mod token_budget;
|
||||||
mod tool_harness;
|
mod tool_harness;
|
||||||
mod tool_lifecycle;
|
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(())
|
||||||
|
}
|
||||||
Reference in new issue
Block a user