mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Restrict spawned-agent models to the active backend (#32751)
## Why Model overrides for `spawn_agent` must be compatible with the multi-agent backend used by the current turn. ## What changed - Carry each model's multi-agent backend metadata into `ModelPreset`. - Filter the advertised `spawn_agent` model overrides for multi-agent v2 and reject overrides assigned to another backend. - Limit error suggestions to picker-visible, backend-compatible models. ## Testing Added coverage for hiding incompatible models from the tool description and rejecting them during spawn validation. GitOrigin-RevId: 22c12aba67df46e9743a74f019b72a1b8b76b308
This commit is contained in:
@@ -13,8 +13,10 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -25,6 +27,15 @@ use serde_json::Value as JsonValue;
|
||||
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
|
||||
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
|
||||
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS;
|
||||
pub(crate) const MAX_SPAWN_AGENT_MODEL_OVERRIDES: usize = 5;
|
||||
|
||||
pub(crate) fn model_supports_multi_agent_backend(
|
||||
model: &ModelPreset,
|
||||
multi_agent_version: MultiAgentVersion,
|
||||
) -> bool {
|
||||
multi_agent_version != MultiAgentVersion::V2
|
||||
|| model.multi_agent_version == Some(multi_agent_version)
|
||||
}
|
||||
|
||||
pub(crate) fn function_arguments(payload: ToolPayload) -> Result<String, FunctionCallError> {
|
||||
match payload {
|
||||
@@ -248,7 +259,11 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides(
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::Offline, config.http_client_factory())
|
||||
.await;
|
||||
let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?;
|
||||
let selected_model_name = find_spawn_agent_model_name(
|
||||
&available_models,
|
||||
requested_model,
|
||||
turn.multi_agent_version,
|
||||
)?;
|
||||
let selected_model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
@@ -338,16 +353,23 @@ pub(crate) async fn apply_spawn_agent_service_tier(
|
||||
}
|
||||
|
||||
fn find_spawn_agent_model_name(
|
||||
available_models: &[codex_protocol::openai_models::ModelPreset],
|
||||
available_models: &[ModelPreset],
|
||||
requested_model: &str,
|
||||
multi_agent_version: MultiAgentVersion,
|
||||
) -> Result<String, FunctionCallError> {
|
||||
available_models
|
||||
.iter()
|
||||
.find(|model| model.model == requested_model)
|
||||
.find(|model| {
|
||||
model.model == requested_model
|
||||
&& model_supports_multi_agent_backend(model, multi_agent_version)
|
||||
})
|
||||
.map(|model| model.model.clone())
|
||||
.ok_or_else(|| {
|
||||
let available = available_models
|
||||
.iter()
|
||||
.filter(|model| model.show_in_picker)
|
||||
.filter(|model| model_supports_multi_agent_backend(model, multi_agent_version))
|
||||
.take(MAX_SPAWN_AGENT_MODEL_OVERRIDES)
|
||||
.map(|model| model.model.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::multi_agents_common::MAX_SPAWN_AGENT_MODEL_OVERRIDES;
|
||||
use super::multi_agents_common::model_supports_multi_agent_backend;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiNamespace;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
@@ -16,18 +19,31 @@ 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_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5;
|
||||
const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpawnAgentToolOptions {
|
||||
pub available_models: Vec<ModelPreset>,
|
||||
pub agent_type_description: String,
|
||||
pub hide_agent_type_model_reasoning: bool,
|
||||
pub expose_spawn_agent_model_overrides: bool,
|
||||
pub multi_agent_version: MultiAgentVersion,
|
||||
pub usage_hint_text: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SpawnAgentToolOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
available_models: Vec::new(),
|
||||
agent_type_description: String::new(),
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: false,
|
||||
multi_agent_version: MultiAgentVersion::Disabled,
|
||||
usage_hint_text: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WaitAgentTimeoutOptions {
|
||||
pub default_timeout_ms: i64,
|
||||
@@ -46,8 +62,9 @@ impl Default for WaitAgentTimeoutOptions {
|
||||
}
|
||||
|
||||
pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec {
|
||||
let available_models_description = (!options.hide_agent_type_model_reasoning)
|
||||
.then(|| spawn_agent_models_description(&options.available_models));
|
||||
let available_models_description = (!options.hide_agent_type_model_reasoning).then(|| {
|
||||
spawn_agent_models_description(&options.available_models, options.multi_agent_version)
|
||||
});
|
||||
let inherited_model_guidance =
|
||||
(!options.hide_agent_type_model_reasoning).then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE);
|
||||
let return_value_description =
|
||||
@@ -77,9 +94,9 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec {
|
||||
}
|
||||
|
||||
pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
|
||||
let available_models_description = options
|
||||
.expose_spawn_agent_model_overrides
|
||||
.then(|| spawn_agent_models_description(&options.available_models));
|
||||
let available_models_description = options.expose_spawn_agent_model_overrides.then(|| {
|
||||
spawn_agent_models_description(&options.available_models, options.multi_agent_version)
|
||||
});
|
||||
let inherited_model_guidance = (options.expose_spawn_agent_model_overrides
|
||||
&& !options.hide_agent_type_model_reasoning)
|
||||
.then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE);
|
||||
@@ -748,11 +765,15 @@ Note that passing `fork_turns="none"` will not pass any surrounding context to t
|
||||
tool_description
|
||||
}
|
||||
|
||||
fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
|
||||
fn spawn_agent_models_description(
|
||||
models: &[ModelPreset],
|
||||
multi_agent_version: MultiAgentVersion,
|
||||
) -> String {
|
||||
let visible_models: Vec<&ModelPreset> = models
|
||||
.iter()
|
||||
.filter(|model| model.show_in_picker)
|
||||
.take(MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION)
|
||||
.filter(|model| model_supports_multi_agent_backend(model, multi_agent_version))
|
||||
.take(MAX_SPAWN_AGENT_MODEL_OVERRIDES)
|
||||
.collect();
|
||||
if visible_models.is_empty() {
|
||||
return "No picker-visible model overrides are currently loaded.".to_string();
|
||||
|
||||
@@ -30,6 +30,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset {
|
||||
is_default: false,
|
||||
upgrade: None,
|
||||
show_in_picker,
|
||||
multi_agent_version: Some(MultiAgentVersion::V2),
|
||||
availability_nux: None,
|
||||
supported_in_api: true,
|
||||
input_modalities: Vec::new(),
|
||||
@@ -38,14 +39,18 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset {
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
let mut incompatible = model_preset("incompatible", /*show_in_picker*/ true);
|
||||
incompatible.multi_agent_version = Some(MultiAgentVersion::V1);
|
||||
let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
|
||||
available_models: vec![
|
||||
model_preset("visible", /*show_in_picker*/ true),
|
||||
model_preset("hidden", /*show_in_picker*/ false),
|
||||
incompatible,
|
||||
],
|
||||
agent_type_description: "role help".to_string(),
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
|
||||
@@ -78,6 +83,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
|
||||
"- `visible-model`: visible description Reasoning efforts: medium (default). Service tiers: priority."
|
||||
));
|
||||
assert!(!description.contains("hidden-model"));
|
||||
assert!(!description.contains("incompatible-model"));
|
||||
assert!(properties.contains_key("task_name"));
|
||||
assert!(properties.contains_key("message"));
|
||||
assert_eq!(
|
||||
@@ -128,6 +134,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
|
||||
agent_type_description: "role help".to_string(),
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V1,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
|
||||
@@ -185,6 +192,7 @@ fn spawn_agent_tool_caps_visible_model_summaries() {
|
||||
agent_type_description: "role help".to_string(),
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
|
||||
@@ -214,7 +222,7 @@ fn spawn_agent_tool_caps_reasoning_effort_value_length() {
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
spawn_agent_models_description(&[model]),
|
||||
spawn_agent_models_description(&[model], MultiAgentVersion::V2),
|
||||
format!(
|
||||
"Available model overrides (optional; inherited parent model is preferred):\n- `visible-model`: visible description Reasoning efforts: {} (default). Service tiers: priority.",
|
||||
"é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION)
|
||||
@@ -229,6 +237,7 @@ fn spawn_agent_tool_keeps_model_controls_when_spawn_metadata_is_hidden() {
|
||||
agent_type_description: "role help".to_string(),
|
||||
hide_agent_type_model_reasoning: true,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
|
||||
@@ -260,6 +269,7 @@ fn spawn_agent_tool_hides_model_controls_without_override_exposure() {
|
||||
agent_type_description: "role help".to_string(),
|
||||
hide_agent_type_model_reasoning: true,
|
||||
expose_spawn_agent_model_overrides: false,
|
||||
multi_agent_version: MultiAgentVersion::V2,
|
||||
usage_hint_text: None,
|
||||
});
|
||||
|
||||
|
||||
@@ -467,6 +467,41 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_spawn_rejects_child_model_from_different_backend() {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
let mut config = (*turn.config).clone();
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("test config should allow feature update");
|
||||
set_turn_config(&mut turn, config);
|
||||
|
||||
let err = SpawnAgentHandlerV2::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"task_name": "incompatible_model",
|
||||
"model": "gpt-5.4",
|
||||
"fork_turns": "none"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.err()
|
||||
.expect("model from a different multi-agent backend should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Unknown model `gpt-5.4` for spawn_agent. Available models: gpt-5.6-sol, gpt-5.6-terra"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_service_tier_override_validates_the_effective_child_model() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -787,6 +787,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.expose_spawn_agent_model_overrides,
|
||||
multi_agent_version: turn_context.multi_agent_version,
|
||||
usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
|
||||
}),
|
||||
tool_namespace,
|
||||
@@ -830,6 +831,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
|
||||
agent_type_description,
|
||||
hide_agent_type_model_reasoning: false,
|
||||
expose_spawn_agent_model_overrides: true,
|
||||
multi_agent_version: turn_context.multi_agent_version,
|
||||
usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
|
||||
}),
|
||||
exposure,
|
||||
|
||||
@@ -230,6 +230,11 @@ pub struct ModelPreset {
|
||||
pub upgrade: Option<ModelUpgrade>,
|
||||
/// Whether this preset should appear in the picker UI.
|
||||
pub show_in_picker: bool,
|
||||
/// Multi-agent backend selected when this model starts a new thread.
|
||||
#[serde(default, skip_serializing, skip_deserializing)]
|
||||
#[schemars(skip)]
|
||||
#[ts(skip)]
|
||||
pub multi_agent_version: Option<MultiAgentVersion>,
|
||||
/// Availability NUX shown when this preset becomes accessible to the user.
|
||||
pub availability_nux: Option<ModelAvailabilityNux>,
|
||||
/// whether this model is supported in the api
|
||||
@@ -606,6 +611,7 @@ impl From<ModelInfo> for ModelPreset {
|
||||
migration_markdown: Some(upgrade.migration_markdown.clone()),
|
||||
}),
|
||||
show_in_picker: info.visibility == ModelVisibility::List,
|
||||
multi_agent_version: info.multi_agent_version,
|
||||
availability_nux: info.availability_nux,
|
||||
supported_in_api: info.supported_in_api,
|
||||
input_modalities: info.input_modalities,
|
||||
|
||||
@@ -1272,6 +1272,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
|
||||
is_default: model.is_default,
|
||||
upgrade,
|
||||
show_in_picker: !model.hidden,
|
||||
multi_agent_version: None,
|
||||
availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux {
|
||||
message: nux.message,
|
||||
}),
|
||||
|
||||
@@ -3128,6 +3128,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() {
|
||||
is_default: false,
|
||||
upgrade: None,
|
||||
show_in_picker,
|
||||
multi_agent_version: None,
|
||||
availability_nux: None,
|
||||
supported_in_api: true,
|
||||
input_modalities: default_input_modalities(),
|
||||
@@ -3623,6 +3624,7 @@ async fn single_reasoning_option_skips_selection() {
|
||||
is_default: false,
|
||||
upgrade: None,
|
||||
show_in_picker: true,
|
||||
multi_agent_version: None,
|
||||
availability_nux: None,
|
||||
supported_in_api: true,
|
||||
input_modalities: default_input_modalities(),
|
||||
|
||||
Reference in New Issue
Block a user