Add ordered model fallback list for subagent spawn

This commit is contained in:
Friel
2026-03-29 21:36:03 +00:00
parent ae057e0bb9
commit 66fadc7cfa
5 changed files with 428 additions and 210 deletions

View File

@@ -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<Vec<UserInput>>,
agent_type: Option<String>,
model: Option<String>,
model_fallback_list: Option<Vec<SpawnAgentModelFallbackCandidate>>,
reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
fork_context: bool,

View File

@@ -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<String>,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct SpawnAgentModelFallbackCandidate {
pub(crate) model: String,
#[serde(default)]
pub(crate) reasoning_effort: Option<ReasoningEffort>,
}
pub(crate) fn collect_spawn_agent_model_candidates(
model_fallback_list: Option<&Vec<SpawnAgentModelFallbackCandidate>>,
requested_model: Option<&str>,
requested_reasoning_effort: Option<ReasoningEffort>,
) -> Vec<SpawnAgentModelCandidate> {
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<ThreadId, AgentStatus>,
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);
}
}

View File

@@ -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<UserInput>,
message: Option<String>,
items: Option<Vec<UserInput>>,
task_name: String,
agent_type: Option<String>,
model: Option<String>,
model_fallback_list: Option<Vec<SpawnAgentModelFallbackCandidate>>,
reasoning_effort: Option<ReasoningEffort>,
fork_turns: Option<String>,
fork_context: Option<bool>,
}
impl SpawnAgentArgs {
fn fork_mode(&self) -> Result<Option<SpawnAgentForkMode>, 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::<usize>().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)]

View File

@@ -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<String, JsonSchema> {
fn spawn_agent_common_properties(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
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<St
},
),
(
"reasoning_effort".to_string(),
JsonSchema::String {
"model_fallback_list".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties: model_fallback_item_properties,
required: Some(vec!["model".to_string()]),
additional_properties: Some(false.into()),
}),
description: Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
),
},
),
])
}
fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
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()),
}
}

View File

@@ -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.")