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
2026-07-13 09:52:27 +00:00
committed by copyberry
parent ea15456284
commit 92938d880e
8 changed files with 112 additions and 13 deletions
@@ -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)]
+2
View File
@@ -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,
+6
View File
@@ -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,
+1
View File
@@ -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(),