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:
jif authored and copyberry committed 2026-07-13 09:52:27 +00:00
1 parent ea15456284
commit 92938d880e
8 files changed
+112 -13

No files matched your search

@@ -13,8 +13,10 @@ use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErr;
use codex_protocol::models::BaseInstructions; use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseInputItem;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput; 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 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 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_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> { pub(crate) fn function_arguments(payload: ToolPayload) -> Result<String, FunctionCallError> {
match payload { match payload {
@@ -248,7 +259,11 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides(
.models_manager .models_manager
.list_models(RefreshStrategy::Offline, config.http_client_factory()) .list_models(RefreshStrategy::Offline, config.http_client_factory())
.await; .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 let selected_model_info = session
.services .services
.models_manager .models_manager
@@ -338,16 +353,23 @@ pub(crate) async fn apply_spawn_agent_service_tier(
} }
fn find_spawn_agent_model_name( fn find_spawn_agent_model_name(
available_models: &[codex_protocol::openai_models::ModelPreset], available_models: &[ModelPreset],
requested_model: &str, requested_model: &str,
multi_agent_version: MultiAgentVersion,
) -> Result<String, FunctionCallError> { ) -> Result<String, FunctionCallError> {
available_models available_models
.iter() .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()) .map(|model| model.model.clone())
.ok_or_else(|| { .ok_or_else(|| {
let available = available_models let available = available_models
.iter() .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()) .map(|model| model.model.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .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::openai_models::ModelPreset;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::JsonSchema; use codex_tools::JsonSchema;
use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool; 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."; "Model override for the new agent. Omit unless an explicit override is needed.";
const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str =
"Service tier override for the new agent. Omit unless explicitly requested."; "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; const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone)]
pub struct SpawnAgentToolOptions { pub struct SpawnAgentToolOptions {
pub available_models: Vec<ModelPreset>, pub available_models: Vec<ModelPreset>,
pub agent_type_description: String, pub agent_type_description: String,
pub hide_agent_type_model_reasoning: bool, pub hide_agent_type_model_reasoning: bool,
pub expose_spawn_agent_model_overrides: bool, pub expose_spawn_agent_model_overrides: bool,
pub multi_agent_version: MultiAgentVersion,
pub usage_hint_text: Option<String>, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaitAgentTimeoutOptions { pub struct WaitAgentTimeoutOptions {
pub default_timeout_ms: i64, pub default_timeout_ms: i64,
@@ -46,8 +62,9 @@ impl Default for WaitAgentTimeoutOptions {
} }
pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec {
let available_models_description = (!options.hide_agent_type_model_reasoning) let available_models_description = (!options.hide_agent_type_model_reasoning).then(|| {
.then(|| spawn_agent_models_description(&options.available_models)); spawn_agent_models_description(&options.available_models, options.multi_agent_version)
});
let inherited_model_guidance = let inherited_model_guidance =
(!options.hide_agent_type_model_reasoning).then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE); (!options.hide_agent_type_model_reasoning).then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE);
let return_value_description = 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 { pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec {
let available_models_description = options let available_models_description = options.expose_spawn_agent_model_overrides.then(|| {
.expose_spawn_agent_model_overrides spawn_agent_models_description(&options.available_models, options.multi_agent_version)
.then(|| spawn_agent_models_description(&options.available_models)); });
let inherited_model_guidance = (options.expose_spawn_agent_model_overrides let inherited_model_guidance = (options.expose_spawn_agent_model_overrides
&& !options.hide_agent_type_model_reasoning) && !options.hide_agent_type_model_reasoning)
.then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE); .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 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 let visible_models: Vec<&ModelPreset> = models
.iter() .iter()
.filter(|model| model.show_in_picker) .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(); .collect();
if visible_models.is_empty() { if visible_models.is_empty() {
return "No picker-visible model overrides are currently loaded.".to_string(); 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, is_default: false,
upgrade: None, upgrade: None,
show_in_picker, show_in_picker,
multi_agent_version: Some(MultiAgentVersion::V2),
availability_nux: None, availability_nux: None,
supported_in_api: true, supported_in_api: true,
input_modalities: Vec::new(), input_modalities: Vec::new(),
@@ -38,14 +39,18 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset {
#[test] #[test]
fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { 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 { let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions {
available_models: vec![ available_models: vec![
model_preset("visible", /*show_in_picker*/ true), model_preset("visible", /*show_in_picker*/ true),
model_preset("hidden", /*show_in_picker*/ false), model_preset("hidden", /*show_in_picker*/ false),
incompatible,
], ],
agent_type_description: "role help".to_string(), agent_type_description: "role help".to_string(),
hide_agent_type_model_reasoning: false, hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: true, expose_spawn_agent_model_overrides: true,
multi_agent_version: MultiAgentVersion::V2,
usage_hint_text: None, 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." "- `visible-model`: visible description Reasoning efforts: medium (default). Service tiers: priority."
)); ));
assert!(!description.contains("hidden-model")); assert!(!description.contains("hidden-model"));
assert!(!description.contains("incompatible-model"));
assert!(properties.contains_key("task_name")); assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("message")); assert!(properties.contains_key("message"));
assert_eq!( assert_eq!(
@@ -128,6 +134,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
agent_type_description: "role help".to_string(), agent_type_description: "role help".to_string(),
hide_agent_type_model_reasoning: false, hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: true, expose_spawn_agent_model_overrides: true,
multi_agent_version: MultiAgentVersion::V1,
usage_hint_text: None, usage_hint_text: None,
}); });
@@ -185,6 +192,7 @@ fn spawn_agent_tool_caps_visible_model_summaries() {
agent_type_description: "role help".to_string(), agent_type_description: "role help".to_string(),
hide_agent_type_model_reasoning: false, hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: true, expose_spawn_agent_model_overrides: true,
multi_agent_version: MultiAgentVersion::V2,
usage_hint_text: None, usage_hint_text: None,
}); });
@@ -214,7 +222,7 @@ fn spawn_agent_tool_caps_reasoning_effort_value_length() {
}]; }];
assert_eq!( assert_eq!(
spawn_agent_models_description(&[model]), spawn_agent_models_description(&[model], MultiAgentVersion::V2),
format!( format!(
"Available model overrides (optional; inherited parent model is preferred):\n- `visible-model`: visible description Reasoning efforts: {} (default). Service tiers: priority.", "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) "é".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(), agent_type_description: "role help".to_string(),
hide_agent_type_model_reasoning: true, hide_agent_type_model_reasoning: true,
expose_spawn_agent_model_overrides: true, expose_spawn_agent_model_overrides: true,
multi_agent_version: MultiAgentVersion::V2,
usage_hint_text: None, 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(), agent_type_description: "role help".to_string(),
hide_agent_type_model_reasoning: true, hide_agent_type_model_reasoning: true,
expose_spawn_agent_model_overrides: false, expose_spawn_agent_model_overrides: false,
multi_agent_version: MultiAgentVersion::V2,
usage_hint_text: None, 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] #[tokio::test]
async fn spawn_agent_service_tier_override_validates_the_effective_child_model() { async fn spawn_agent_service_tier_override_validates_the_effective_child_model() {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
+2
View File
@@ -787,6 +787,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
.config .config
.multi_agent_v2 .multi_agent_v2
.expose_spawn_agent_model_overrides, .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(), usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
}), }),
tool_namespace, tool_namespace,
@@ -830,6 +831,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
agent_type_description, agent_type_description,
hide_agent_type_model_reasoning: false, hide_agent_type_model_reasoning: false,
expose_spawn_agent_model_overrides: true, 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(), usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
}), }),
exposure, exposure,
+6
View File
@@ -230,6 +230,11 @@ pub struct ModelPreset {
pub upgrade: Option<ModelUpgrade>, pub upgrade: Option<ModelUpgrade>,
/// Whether this preset should appear in the picker UI. /// Whether this preset should appear in the picker UI.
pub show_in_picker: bool, 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. /// Availability NUX shown when this preset becomes accessible to the user.
pub availability_nux: Option<ModelAvailabilityNux>, pub availability_nux: Option<ModelAvailabilityNux>,
/// whether this model is supported in the api /// whether this model is supported in the api
@@ -606,6 +611,7 @@ impl From<ModelInfo> for ModelPreset {
migration_markdown: Some(upgrade.migration_markdown.clone()), migration_markdown: Some(upgrade.migration_markdown.clone()),
}), }),
show_in_picker: info.visibility == ModelVisibility::List, show_in_picker: info.visibility == ModelVisibility::List,
multi_agent_version: info.multi_agent_version,
availability_nux: info.availability_nux, availability_nux: info.availability_nux,
supported_in_api: info.supported_in_api, supported_in_api: info.supported_in_api,
input_modalities: info.input_modalities, input_modalities: info.input_modalities,
+1
View File
@@ -1272,6 +1272,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
is_default: model.is_default, is_default: model.is_default,
upgrade, upgrade,
show_in_picker: !model.hidden, show_in_picker: !model.hidden,
multi_agent_version: None,
availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux { availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux {
message: nux.message, message: nux.message,
}), }),
@@ -3128,6 +3128,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() {
is_default: false, is_default: false,
upgrade: None, upgrade: None,
show_in_picker, show_in_picker,
multi_agent_version: None,
availability_nux: None, availability_nux: None,
supported_in_api: true, supported_in_api: true,
input_modalities: default_input_modalities(), input_modalities: default_input_modalities(),
@@ -3623,6 +3624,7 @@ async fn single_reasoning_option_skips_selection() {
is_default: false, is_default: false,
upgrade: None, upgrade: None,
show_in_picker: true, show_in_picker: true,
multi_agent_version: None,
availability_nux: None, availability_nux: None,
supported_in_api: true, supported_in_api: true,
input_modalities: default_input_modalities(), input_modalities: default_input_modalities(),