diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 308ec49d85..9e96a63b44 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -1,5 +1,4 @@ use super::*; -use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; use crate::agent::control::render_input_preview; use crate::agent::role::DEFAULT_ROLE_NAME; @@ -54,56 +53,94 @@ impl ToolHandler for Handler { call_id: call_id.clone(), sender_thread_id: session.conversation_id, prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), + model: args + .model_fallback_list + .as_ref() + .and_then(|list| list.first()) + .map(|candidate| candidate.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()), + reasoning_effort: args + .model_fallback_list + .as_ref() + .and_then(|list| list.first()) + .and_then(|candidate| candidate.reasoning_effort) + .unwrap_or_else(|| args.reasoning_effort.unwrap_or_default()), } .into(), ) .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; apply_role_to_config(&mut config, role_name) .await .map_err(FunctionCallError::RespondToModel)?; apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; apply_spawn_agent_overrides(&mut config, child_depth); - let result = session - .services - .agent_control - .spawn_agent_with_metadata( - config, - input_items, - Some(thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - /*task_name*/ None, - )?), - SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), - fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), - }, + let mut candidates_to_try = collect_spawn_agent_model_candidates( + args.model_fallback_list.as_ref(), + args.model.as_deref(), + args.reasoning_effort, + ); + if candidates_to_try.is_empty() { + candidates_to_try.push(SpawnAgentModelCandidate { + model: None, + reasoning_effort: None, + }); + } + + let mut spawn_result = None; + for (idx, candidate) in candidates_to_try.iter().enumerate() { + let mut candidate_config = config.clone(); + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut candidate_config, + candidate.model.as_deref(), + candidate.reasoning_effort, ) - .await - .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), + .await?; + let attempt_result = session + .services + .agent_control + .spawn_agent_with_metadata( + candidate_config, + input_items.clone(), + Some(thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + /*task_name*/ None, + )?), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + }, + ) + .await; + match attempt_result { + Ok(spawned_agent) => { + spawn_result = Some(spawned_agent); + break; + } + Err(err) => { + if spawn_should_retry_on_quota_exhaustion(&err) + && idx + 1 < candidates_to_try.len() + { + continue; + } + return Err(collab_spawn_error(err)); + } + } + } + let Some(spawned_agent) = spawn_result else { + return Err(FunctionCallError::RespondToModel( + "No spawn attempts were executed".to_string(), + )); }; + let new_thread_id = Some(spawned_agent.thread_id); + let new_agent_metadata = Some(spawned_agent.metadata.clone()); + let status = spawned_agent.status.clone(); let agent_snapshot = match new_thread_id { Some(thread_id) => { session @@ -154,7 +191,7 @@ impl ToolHandler for Handler { .into(), ) .await; - let new_thread_id = result?.thread_id; + let new_thread_id = spawned_agent.thread_id; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); turn.session_telemetry.counter( "codex.multi_agent.spawn", @@ -175,6 +212,7 @@ struct SpawnAgentArgs { items: Option>, agent_type: Option, model: Option, + model_fallback_list: Option>, reasoning_effort: Option, #[serde(default)] fork_context: bool, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index dd68465a7f..8a06af0300 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -21,6 +21,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; +use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; use std::collections::HashMap; @@ -71,6 +72,51 @@ where }) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SpawnAgentModelCandidate { + pub(crate) model: Option, + pub(crate) reasoning_effort: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub(crate) struct SpawnAgentModelFallbackCandidate { + pub(crate) model: String, + #[serde(default)] + pub(crate) reasoning_effort: Option, +} + +pub(crate) fn collect_spawn_agent_model_candidates( + model_fallback_list: Option<&Vec>, + requested_model: Option<&str>, + requested_reasoning_effort: Option, +) -> Vec { + if let Some(model_fallback_list) = model_fallback_list { + return model_fallback_list + .iter() + .map(|candidate| SpawnAgentModelCandidate { + model: Some(candidate.model.clone()), + reasoning_effort: candidate.reasoning_effort, + }) + .collect(); + } + + let mut candidates = Vec::new(); + if requested_model.is_some() || requested_reasoning_effort.is_some() { + candidates.push(SpawnAgentModelCandidate { + model: requested_model.map(ToString::to_string), + reasoning_effort: requested_reasoning_effort, + }); + } + candidates +} + +pub(crate) fn spawn_should_retry_on_quota_exhaustion(error: &CodexErr) -> bool { + matches!( + error, + CodexErr::QuotaExceeded | CodexErr::UsageLimitReached(_) + ) +} + pub(crate) fn build_wait_agent_statuses( statuses: &HashMap, receiver_agents: &[CollabAgentRef], @@ -363,3 +409,108 @@ fn validate_spawn_agent_reasoning_effort( "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" ))) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::UsageLimitReachedError; + use crate::protocol::AgentStatus; + + #[test] + fn collect_spawn_agent_model_candidates_prefers_fallback_list() { + let candidates = collect_spawn_agent_model_candidates( + Some(&vec![ + SpawnAgentModelFallbackCandidate { + model: "fallback-a".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + }, + SpawnAgentModelFallbackCandidate { + model: "fallback-b".to_string(), + reasoning_effort: Some(ReasoningEffort::Minimal), + }, + ]), + Some("legacy-model"), + Some(ReasoningEffort::Low), + ); + + assert_eq!( + candidates, + vec![ + SpawnAgentModelCandidate { + model: Some("fallback-a".to_string()), + reasoning_effort: Some(ReasoningEffort::High), + }, + SpawnAgentModelCandidate { + model: Some("fallback-b".to_string()), + reasoning_effort: Some(ReasoningEffort::Minimal), + }, + ] + ); + } + + #[test] + fn collect_spawn_agent_model_candidates_falls_back_to_legacy_args() { + let candidates = collect_spawn_agent_model_candidates( + None, + Some("legacy-model"), + Some(ReasoningEffort::Minimal), + ); + assert_eq!( + candidates, + vec![SpawnAgentModelCandidate { + model: Some("legacy-model".to_string()), + reasoning_effort: Some(ReasoningEffort::Minimal), + }] + ); + } + + #[test] + fn collect_spawn_agent_model_candidates_empty_when_no_model_is_set() { + let candidates = collect_spawn_agent_model_candidates(None, None, None); + assert_eq!(candidates, Vec::new()); + } + + #[test] + fn spawn_should_retry_on_quota_exhaustion_checks_expected_error_variants() { + assert!(spawn_should_retry_on_quota_exhaustion( + &CodexErr::QuotaExceeded + )); + assert!(spawn_should_retry_on_quota_exhaustion( + &CodexErr::UsageLimitReached(UsageLimitReachedError { + plan_type: None, + resets_at: None, + rate_limits: None, + promo_message: None, + }) + )); + assert!(!spawn_should_retry_on_quota_exhaustion( + &CodexErr::UnsupportedOperation("thread manager dropped".to_string()) + )); + } + + #[test] + fn collab_spawn_error_handles_thread_manager_drop() { + assert_eq!( + collab_spawn_error(CodexErr::UnsupportedOperation( + "thread manager dropped".to_string() + )), + FunctionCallError::RespondToModel("collab manager unavailable".to_string()) + ); + } + + #[test] + fn build_wait_agent_statuses_includes_extras_in_sorted_order() { + let receiver_agents = vec![]; + let mut statuses = HashMap::new(); + let thread_a = ThreadId::new(); + let thread_b = ThreadId::new(); + statuses.insert(thread_b, AgentStatus::Completed(Some("done".to_string()))); + statuses.insert(thread_a, AgentStatus::Completed(Some("done".to_string()))); + + let entries = build_wait_agent_statuses(&statuses, &receiver_agents); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].thread_id, thread_a); + assert_eq!(entries[1].thread_id, thread_b); + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index c710a6e219..53a12a906c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -1,5 +1,4 @@ use super::*; -use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; @@ -33,14 +32,13 @@ impl ToolHandler for Handler { } = invocation; let arguments = function_arguments(payload)?; let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let fork_mode = args.fork_mode()?; let role_name = args .agent_type .as_deref() .map(str::trim) .filter(|role| !role.is_empty()); - let initial_operation = parse_collab_input(/*message*/ None, Some(args.items))?; + let initial_operation = parse_collab_input(args.message, args.items)?; let prompt = render_input_preview(&initial_operation); let session_source = turn.session_source.clone(); @@ -58,22 +56,24 @@ impl ToolHandler for Handler { call_id: call_id.clone(), sender_thread_id: session.conversation_id, prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), + model: args + .model_fallback_list + .as_ref() + .and_then(|list| list.first()) + .map(|candidate| candidate.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()), + reasoning_effort: args + .model_fallback_list + .as_ref() + .and_then(|list| list.first()) + .and_then(|candidate| candidate.reasoning_effort) + .unwrap_or_else(|| args.reasoning_effort.unwrap_or_default()), } .into(), ) .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; apply_role_to_config(&mut config, role_name) .await .map_err(FunctionCallError::RespondToModel)?; @@ -87,47 +87,84 @@ impl ToolHandler for Handler { role_name, Some(args.task_name.clone()), )?; - let result = session - .services - .agent_control - .spawn_agent_with_metadata( - config, - match (spawn_source.get_agent_path(), initial_operation) { - (Some(recipient), Op::UserInput { items, .. }) - if items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) => - { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } - } - (_, initial_operation) => initial_operation, - }, - Some(spawn_source), - SpawnAgentOptions { - fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), - fork_mode, - }, - ) - .await - .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), + let initial_agent_op = match (spawn_source.get_agent_path(), initial_operation) { + (Some(recipient), Op::UserInput { items, .. }) + if items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) => + { + Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + recipient, + Vec::new(), + prompt.clone(), + /*trigger_turn*/ true, + ), + } + } + (_, initial_operation) => initial_operation, }; + let mut candidates_to_try = collect_spawn_agent_model_candidates( + args.model_fallback_list.as_ref(), + args.model.as_deref(), + args.reasoning_effort, + ); + if candidates_to_try.is_empty() { + candidates_to_try.push(SpawnAgentModelCandidate { + model: None, + reasoning_effort: None, + }); + } + + let mut spawn_result = None; + for (idx, candidate) in candidates_to_try.iter().enumerate() { + let mut candidate_config = config.clone(); + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut candidate_config, + candidate.model.as_deref(), + candidate.reasoning_effort, + ) + .await?; + let attempt_result = session + .services + .agent_control + .spawn_agent_with_metadata( + candidate_config, + initial_agent_op.clone(), + Some(spawn_source.clone()), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + }, + ) + .await; + match attempt_result { + Ok(spawned_agent) => { + spawn_result = Some(spawned_agent); + break; + } + Err(err) => { + if spawn_should_retry_on_quota_exhaustion(&err) + && idx + 1 < candidates_to_try.len() + { + continue; + } + return Err(collab_spawn_error(err)); + } + } + } + let Some(spawned_agent) = spawn_result else { + return Err(FunctionCallError::RespondToModel( + "No spawn attempts were executed".to_string(), + )); + }; + let new_thread_id = Some(spawned_agent.thread_id); + let new_agent_metadata = Some(spawned_agent.metadata.clone()); + let status = spawned_agent.status.clone(); let agent_snapshot = match new_thread_id { Some(thread_id) => { session @@ -178,7 +215,6 @@ impl ToolHandler for Handler { .into(), ) .await; - let _ = result?; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); turn.session_telemetry.counter( "codex.multi_agent.spawn", @@ -200,54 +236,16 @@ impl ToolHandler for Handler { } #[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] struct SpawnAgentArgs { - items: Vec, + message: Option, + items: Option>, task_name: String, agent_type: Option, model: Option, + model_fallback_list: Option>, reasoning_effort: Option, - fork_turns: Option, - fork_context: Option, -} - -impl SpawnAgentArgs { - fn fork_mode(&self) -> Result, FunctionCallError> { - if self.fork_context.is_some() { - return Err(FunctionCallError::RespondToModel( - "fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string(), - )); - } - - let Some(fork_turns) = self - .fork_turns - .as_deref() - .map(str::trim) - .filter(|fork_turns| !fork_turns.is_empty()) - else { - return Ok(None); - }; - - if fork_turns.eq_ignore_ascii_case("none") { - return Ok(None); - } - if fork_turns.eq_ignore_ascii_case("all") { - return Ok(Some(SpawnAgentForkMode::FullHistory)); - } - - let last_n_turns = fork_turns.parse::().map_err(|_| { - FunctionCallError::RespondToModel( - "fork_turns must be `none`, `all`, or a positive integer string".to_string(), - ) - })?; - if last_n_turns == 0 { - return Err(FunctionCallError::RespondToModel( - "fork_turns must be `none`, `all`, or a positive integer string".to_string(), - )); - } - - Ok(Some(SpawnAgentForkMode::LastNTurns(last_n_turns))) - } + #[serde(default)] + fork_context: bool, } #[derive(Debug, Serialize)] diff --git a/codex-rs/tools/src/agent_tool.rs b/codex-rs/tools/src/agent_tool.rs index 84cead4f2b..75747c8fd9 100644 --- a/codex-rs/tools/src/agent_tool.rs +++ b/codex-rs/tools/src/agent_tool.rs @@ -23,7 +23,7 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions<'_>) -> ToolSpe let available_models_description = spawn_agent_models_description(options.available_models); let return_value_description = "Returns the spawned agent id plus the user-facing nickname when available."; - let properties = spawn_agent_common_properties_v1(&options.agent_type_description); + let properties = spawn_agent_common_properties(&options.agent_type_description); ToolSpec::Function(ResponsesApiTool { name: "spawn_agent".to_string(), @@ -45,7 +45,7 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions<'_>) -> ToolSpe pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpec { let available_models_description = spawn_agent_models_description(options.available_models); let return_value_description = "Returns the canonical task name for the spawned agent, plus the user-facing nickname when available."; - let mut properties = spawn_agent_common_properties_v2(&options.agent_type_description); + let mut properties = spawn_agent_common_properties(&options.agent_type_description); properties.insert( "task_name".to_string(), JsonSchema::String { @@ -66,7 +66,7 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpe defer_loading: None, parameters: JsonSchema::Object { properties, - required: Some(vec!["task_name".to_string(), "items".to_string()]), + required: Some(vec!["task_name".to_string()]), additional_properties: Some(false.into()), }, output_schema: Some(spawn_agent_output_schema_v2()), @@ -128,11 +128,20 @@ pub fn create_send_message_tool() -> ToolSpec { }, ), ("items".to_string(), create_collab_input_items_schema()), + ( + "interrupt".to_string(), + JsonSchema::Boolean { + description: Some( + "When true, stop the agent's current task and handle this immediately. When false (default), queue this message." + .to_string(), + ), + }, + ), ]); ToolSpec::Function(ResponsesApiTool { name: "send_message".to_string(), - description: "Add a message to an existing agent without triggering a new turn. In MultiAgentV2, this tool currently supports text content only." + description: "Add a message to an existing agent without triggering a new turn. Use interrupt=true to stop the current task first. In MultiAgentV2, this tool currently supports text content only." .to_string(), strict: false, defer_loading: None, @@ -535,7 +544,28 @@ fn create_collab_input_items_schema() -> JsonSchema { } } -fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap { +fn spawn_agent_common_properties(agent_type_description: &str) -> BTreeMap { + let model_fallback_item_properties = BTreeMap::from([ + ( + "model".to_string(), + JsonSchema::String { + description: Some( + "Model to try. Must be a model slug from the current model picker list." + .to_string(), + ), + }, + ), + ( + "reasoning_effort".to_string(), + JsonSchema::String { + description: Some( + "Optional reasoning effort override for this candidate. Replaces the inherited reasoning effort." + .to_string(), + ), + }, + ), + ]); + BTreeMap::from([ ( "message".to_string(), @@ -572,40 +602,15 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap { - BTreeMap::from([ - ("items".to_string(), create_collab_input_items_schema()), - ( - "agent_type".to_string(), - JsonSchema::String { - description: Some(agent_type_description.to_string()), - }, - ), - ( - "fork_turns".to_string(), - JsonSchema::String { - description: Some( - "Optional MultiAgentV2 fork mode. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns." - .to_string(), - ), - }, - ), - ( - "model".to_string(), - JsonSchema::String { - description: Some( - "Optional model override for the new agent. Replaces the inherited model." + "Ordered model candidates for fallback retries. Each entry may include an optional reasoning effort." .to_string(), ), }, @@ -725,19 +730,31 @@ fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema } fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema { - let properties = BTreeMap::from([( - "timeout_ms".to_string(), - JsonSchema::Number { - description: Some(format!( - "Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.", - options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms, - )), - }, - )]); + let properties = BTreeMap::from([ + ( + "targets".to_string(), + JsonSchema::Array { + items: Box::new(JsonSchema::String { description: None }), + description: Some( + "Agent ids or canonical task names to wait on. Pass multiple targets to wait for whichever finishes first." + .to_string(), + ), + }, + ), + ( + "timeout_ms".to_string(), + JsonSchema::Number { + description: Some(format!( + "Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.", + options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms, + )), + }, + ), + ]); JsonSchema::Object { properties, - required: None, + required: Some(vec!["targets".to_string()]), additional_properties: Some(false.into()), } } diff --git a/codex-rs/tools/src/agent_tool_tests.rs b/codex-rs/tools/src/agent_tool_tests.rs index 1a6a905a50..2adde24cd5 100644 --- a/codex-rs/tools/src/agent_tool_tests.rs +++ b/codex-rs/tools/src/agent_tool_tests.rs @@ -56,20 +56,34 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { assert!(description.contains("visible display (`visible-model`)")); assert!(!description.contains("hidden display (`hidden-model`)")); assert!(properties.contains_key("task_name")); - assert!(properties.contains_key("items")); - assert!(properties.contains_key("fork_turns")); - assert!(!properties.contains_key("message")); - assert!(!properties.contains_key("fork_context")); assert_eq!( properties.get("agent_type"), Some(&JsonSchema::String { description: Some("role help".to_string()), }) ); + assert_eq!(required, Some(vec!["task_name".to_string()])); + let Some(JsonSchema::Array { items, .. }) = properties.get("model_fallback_list") else { + panic!("spawn_agent v2 should define model_fallback_list as an array of objects"); + }; + let JsonSchema::Object { + properties: model_fallback_item_properties, + required: Some(model_fallback_item_required), + .. + } = items.as_ref() + else { + panic!("spawn_agent v2 model_fallback_list items should be objects"); + }; assert_eq!( - required, - Some(vec!["task_name".to_string(), "items".to_string()]) + model_fallback_item_properties.get("model"), + Some(&JsonSchema::String { + description: Some( + "Model to try. Must be a model slug from the current model picker list." + .to_string(), + ), + }) ); + assert_eq!(model_fallback_item_required, &vec!["model".to_string()]); assert_eq!( output_schema.expect("spawn_agent output schema")["required"], json!(["agent_id", "task_name", "nickname"]) @@ -77,21 +91,22 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { } #[test] -fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { - let tool = create_spawn_agent_tool_v1(SpawnAgentToolOptions { - available_models: &[], - agent_type_description: "role help".to_string(), - }); - - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else { +fn spawn_agent_tool_v1_includes_model_fallback_list() { + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = + create_spawn_agent_tool_v1(SpawnAgentToolOptions { + available_models: &[model_preset("visible", /*show_in_picker*/ true)], + agent_type_description: "role help".to_string(), + }) + else { panic!("spawn_agent should be a function tool"); }; let JsonSchema::Object { properties, .. } = parameters else { panic!("spawn_agent should use object params"); }; - - assert!(properties.contains_key("fork_context")); - assert!(!properties.contains_key("fork_turns")); + let Some(JsonSchema::Array { .. }) = properties.get("model_fallback_list") else { + panic!("model_fallback_list should be an array"); + }; + assert!(properties.contains_key("model_fallback_list")); } #[test] @@ -114,7 +129,6 @@ fn send_message_tool_requires_items_and_uses_submission_output() { }; assert!(properties.contains_key("target")); assert!(properties.contains_key("items")); - assert!(!properties.contains_key("interrupt")); assert!(!properties.contains_key("message")); assert_eq!( required, @@ -127,7 +141,7 @@ fn send_message_tool_requires_items_and_uses_submission_output() { } #[test] -fn wait_agent_tool_v2_uses_timeout_only_summary_output() { +fn wait_agent_tool_v2_uses_task_targets_and_summary_output() { let ToolSpec::Function(ResponsesApiTool { parameters, output_schema, @@ -140,17 +154,17 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() { else { panic!("wait_agent should be a function tool"); }; - let JsonSchema::Object { - properties, - required, - .. - } = parameters - else { + let JsonSchema::Object { properties, .. } = parameters else { panic!("wait_agent should use object params"); }; - assert!(!properties.contains_key("targets")); - assert!(properties.contains_key("timeout_ms")); - assert_eq!(required, None); + let Some(JsonSchema::Array { + description: Some(description), + .. + }) = properties.get("targets") + else { + panic!("wait_agent should define targets array"); + }; + assert!(description.contains("canonical task names")); assert_eq!( output_schema.expect("wait output schema")["properties"]["message"]["description"], json!("Brief wait summary without the agent's final content.")