mirror of
https://github.com/openai/codex.git
synced 2026-04-01 21:14:08 +03:00
Compare commits
8 Commits
pr16456
...
model-fall
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e2386a978 | ||
|
|
3dafa14edd | ||
|
|
9e3862778b | ||
|
|
a765eb2cef | ||
|
|
693d5109cd | ||
|
|
5f6011e12b | ||
|
|
88cb524699 | ||
|
|
b8e0514643 |
@@ -309,7 +309,7 @@ where
|
||||
D: Deserializer<'de>,
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
|
||||
Option::<Vec<T>>::deserialize(deserializer).map(std::option::Option::unwrap_or_default)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
||||
@@ -54,56 +54,105 @@ 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 =
|
||||
let 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,
|
||||
|
||||
let mut candidates_to_try = collect_spawn_agent_model_candidates(
|
||||
args.model_fallback_list.as_ref(),
|
||||
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);
|
||||
);
|
||||
if candidates_to_try.is_empty() {
|
||||
candidates_to_try.push(SpawnAgentModelCandidate {
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
});
|
||||
}
|
||||
|
||||
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 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?;
|
||||
apply_role_to_config(&mut candidate_config, role_name)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
apply_spawn_agent_runtime_overrides(&mut candidate_config, turn.as_ref())?;
|
||||
apply_spawn_agent_overrides(&mut candidate_config, child_depth);
|
||||
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()),
|
||||
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match attempt_result {
|
||||
Ok(spawned_agent) => {
|
||||
if spawn_should_retry_on_async_quota_exhaustion(
|
||||
spawned_agent.status.clone(),
|
||||
spawned_agent.thread_id,
|
||||
&session.services.agent_control,
|
||||
)
|
||||
.await
|
||||
&& idx + 1 < candidates_to_try.len()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
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 +203,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 +224,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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
@@ -21,9 +22,12 @@ 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;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
|
||||
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
|
||||
@@ -71,6 +75,96 @@ 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) async fn spawn_should_retry_on_async_quota_exhaustion(
|
||||
thread_status: AgentStatus,
|
||||
thread_id: ThreadId,
|
||||
agent_control: &crate::agent::control::AgentControl,
|
||||
) -> bool {
|
||||
if is_final(&thread_status) && spawn_should_retry_on_quota_exhaustion_status(&thread_status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Ok(mut status_rx) = agent_control.subscribe_status(thread_id).await else {
|
||||
return false;
|
||||
};
|
||||
let mut status = status_rx.borrow_and_update().clone();
|
||||
if is_final(&status) && spawn_should_retry_on_quota_exhaustion_status(&status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
loop {
|
||||
if timeout(Duration::from_millis(250), status_rx.changed())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
status = status_rx.borrow().clone();
|
||||
if is_final(&status) {
|
||||
return spawn_should_retry_on_quota_exhaustion_status(&status);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn spawn_should_retry_on_quota_exhaustion_status(status: &AgentStatus) -> bool {
|
||||
match status {
|
||||
AgentStatus::Errored(message) => {
|
||||
let message = message.to_lowercase();
|
||||
message.contains("insufficient_quota")
|
||||
|| message.contains("usage limit")
|
||||
|| message.contains("quota")
|
||||
}
|
||||
AgentStatus::NotFound => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_wait_agent_statuses(
|
||||
statuses: &HashMap<ThreadId, AgentStatus>,
|
||||
receiver_agents: &[CollabAgentRef],
|
||||
@@ -363,3 +457,111 @@ 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(
|
||||
/*model_fallback_list*/ 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(
|
||||
/*model_fallback_list*/ None, /*requested_model*/ None,
|
||||
/*requested_reasoning_effort*/ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,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,27 +57,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 =
|
||||
let 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 spawn_source = thread_spawn_source(
|
||||
session.conversation_id,
|
||||
@@ -87,47 +83,100 @@ 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?;
|
||||
apply_role_to_config(&mut candidate_config, role_name)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
apply_spawn_agent_runtime_overrides(&mut candidate_config, turn.as_ref())?;
|
||||
apply_spawn_agent_overrides(&mut candidate_config, child_depth);
|
||||
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()),
|
||||
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match attempt_result {
|
||||
Ok(spawned_agent) => {
|
||||
if spawn_should_retry_on_async_quota_exhaustion(
|
||||
spawned_agent.status.clone(),
|
||||
spawned_agent.thread_id,
|
||||
&session.services.agent_control,
|
||||
)
|
||||
.await
|
||||
&& idx + 1 < candidates_to_try.len()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
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 +227,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 +248,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)]
|
||||
|
||||
@@ -18,6 +18,7 @@ use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
@@ -36,6 +37,10 @@ const REQUESTED_MODEL: &str = "gpt-5.1";
|
||||
const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low;
|
||||
const ROLE_MODEL: &str = "gpt-5.1-codex-max";
|
||||
const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High;
|
||||
const FALLBACK_MODEL_A: &str = "gpt-5.1";
|
||||
const FALLBACK_REASONING_EFFORT_A: ReasoningEffort = ReasoningEffort::Low;
|
||||
const FALLBACK_MODEL_B: &str = "gpt-5.2";
|
||||
const FALLBACK_REASONING_EFFORT_B: ReasoningEffort = ReasoningEffort::Medium;
|
||||
|
||||
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
|
||||
let is_zstd = req
|
||||
@@ -57,6 +62,57 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool {
|
||||
.is_some_and(|body| body.contains(text))
|
||||
}
|
||||
|
||||
fn request_uses_model_and_effort(
|
||||
req: &wiremock::Request,
|
||||
model: &str,
|
||||
reasoning_effort: &str,
|
||||
) -> bool {
|
||||
let is_zstd = req
|
||||
.headers
|
||||
.get("content-encoding")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| {
|
||||
value
|
||||
.split(',')
|
||||
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
|
||||
});
|
||||
let bytes = if is_zstd {
|
||||
zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok()
|
||||
} else {
|
||||
Some(req.body.clone())
|
||||
};
|
||||
bytes
|
||||
.and_then(|body| serde_json::from_slice::<Value>(&body).ok())
|
||||
.is_some_and(|body| {
|
||||
body.get("model").and_then(Value::as_str) == Some(model)
|
||||
&& body
|
||||
.get("reasoning")
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(reasoning_effort)
|
||||
})
|
||||
}
|
||||
|
||||
fn request_uses_model(req: &wiremock::Request, model: &str) -> bool {
|
||||
let is_zstd = req
|
||||
.headers
|
||||
.get("content-encoding")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| {
|
||||
value
|
||||
.split(',')
|
||||
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
|
||||
});
|
||||
let bytes = if is_zstd {
|
||||
zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok()
|
||||
} else {
|
||||
Some(req.body.clone())
|
||||
};
|
||||
bytes
|
||||
.and_then(|body| serde_json::from_slice::<Value>(&body).ok())
|
||||
.is_some_and(|body| body.get("model").and_then(Value::as_str) == Some(model))
|
||||
}
|
||||
|
||||
fn has_subagent_notification(req: &ResponsesRequest) -> bool {
|
||||
req.message_input_texts("user")
|
||||
.iter()
|
||||
@@ -480,6 +536,184 @@ async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_agent_model_fallback_list_retries_after_quota_exhaustion() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let spawn_args = serde_json::to_string(&json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"model_fallback_list": [
|
||||
{
|
||||
"model": FALLBACK_MODEL_A,
|
||||
"reasoning_effort": FALLBACK_REASONING_EFFORT_A,
|
||||
},
|
||||
{
|
||||
"model": FALLBACK_MODEL_B,
|
||||
"reasoning_effort": FALLBACK_REASONING_EFFORT_B,
|
||||
}
|
||||
]
|
||||
}))?;
|
||||
|
||||
mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, TURN_1_PROMPT),
|
||||
sse(vec![
|
||||
ev_response_created("resp-turn1-1"),
|
||||
ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
|
||||
ev_completed("resp-turn1-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let quota_child_attempt = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| {
|
||||
body_contains(req, CHILD_PROMPT)
|
||||
&& request_uses_model_and_effort(req, FALLBACK_MODEL_A, "low")
|
||||
&& !body_contains(req, SPAWN_CALL_ID)
|
||||
},
|
||||
sse(vec![
|
||||
ev_response_created("resp-child-quota"),
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp-child-quota",
|
||||
"error": {
|
||||
"code": "insufficient_quota",
|
||||
"message": "You exceeded your current quota, please check your plan and billing details."
|
||||
}
|
||||
}
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let fallback_child_attempt = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| {
|
||||
body_contains(req, CHILD_PROMPT)
|
||||
&& request_uses_model(req, FALLBACK_MODEL_B)
|
||||
&& !body_contains(req, SPAWN_CALL_ID)
|
||||
},
|
||||
sse(vec![
|
||||
ev_response_created("resp-child-fallback"),
|
||||
ev_assistant_message("msg-child-fallback", "child done"),
|
||||
ev_completed("resp-child-fallback"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _turn1_followup = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID),
|
||||
sse(vec![
|
||||
ev_response_created("resp-turn1-2"),
|
||||
ev_assistant_message("msg-turn1-2", "parent done"),
|
||||
ev_completed("resp-turn1-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Collab)
|
||||
.expect("test config should allow feature update");
|
||||
config.model = Some(INHERITED_MODEL.to_string());
|
||||
config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn(TURN_1_PROMPT).await?;
|
||||
|
||||
let quota_requests = quota_child_attempt
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|request| {
|
||||
request.body_json().get("model").and_then(Value::as_str) == Some(FALLBACK_MODEL_A)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!quota_requests.is_empty());
|
||||
for quota_request in "a_requests {
|
||||
let body = quota_request.body_json();
|
||||
assert_eq!(
|
||||
body.get("model").and_then(Value::as_str),
|
||||
Some(FALLBACK_MODEL_A)
|
||||
);
|
||||
assert_eq!(
|
||||
body.get("reasoning")
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(Value::as_str),
|
||||
Some("low")
|
||||
);
|
||||
}
|
||||
|
||||
let fallback_requests = fallback_child_attempt
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|request| {
|
||||
request.body_json().get("model").and_then(Value::as_str) == Some(FALLBACK_MODEL_B)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(!fallback_requests.is_empty());
|
||||
for fallback_request in &fallback_requests {
|
||||
let fallback_body = fallback_request.body_json();
|
||||
assert_eq!(
|
||||
fallback_body.get("model").and_then(Value::as_str),
|
||||
Some(FALLBACK_MODEL_B)
|
||||
);
|
||||
if let Some(effort) = fallback_body
|
||||
.get("reasoning")
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
assert_eq!(effort, "medium");
|
||||
}
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let child_snapshot = loop {
|
||||
let spawned_ids = test
|
||||
.thread_manager
|
||||
.list_thread_ids()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|id| *id != test.session_configured.session_id)
|
||||
.collect::<Vec<_>>();
|
||||
let mut matching_snapshot = None;
|
||||
for thread_id in spawned_ids {
|
||||
let snapshot = test
|
||||
.thread_manager
|
||||
.get_thread(thread_id)
|
||||
.await?
|
||||
.config_snapshot()
|
||||
.await;
|
||||
if snapshot.model == FALLBACK_MODEL_B
|
||||
&& snapshot.reasoning_effort == Some(FALLBACK_REASONING_EFFORT_B)
|
||||
{
|
||||
matching_snapshot = Some(snapshot);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(snapshot) = matching_snapshot {
|
||||
break snapshot;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!("timed out waiting for fallback child snapshot");
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
};
|
||||
|
||||
assert_eq!(child_snapshot.model, FALLBACK_MODEL_B);
|
||||
assert_eq!(
|
||||
child_snapshot.reasoning_effort,
|
||||
Some(FALLBACK_REASONING_EFFORT_B)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_agent_tool_description_mentions_role_locked_settings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user