Compare commits

...

8 Commits

Author SHA1 Message Date
jif-oai
b17e4e1a47 Release 0.119.0-alpha.2 2026-04-01 12:26:58 +02:00
jif-oai
5bbfee69b6 nit: deny field v2 (#16427) 2026-04-01 12:26:40 +02:00
jif-oai
609ac0c7ab chore: interrupted as state (#16426) 2026-04-01 12:26:29 +02:00
jif-oai
df5f79da36 nit: update wait v2 desc (#16425) 2026-04-01 12:26:25 +02:00
jif-oai
0c776c433b feat: tasks can't be assigned to root agent (#16424) 2026-04-01 12:18:50 +02:00
jif-oai
3152d1a557 Use message string in v2 assign_task (#16419)
Fix assign task and clean everything

---------

Co-authored-by: Codex <noreply@openai.com>
2026-04-01 11:40:19 +02:00
jif-oai
23d638a573 Use message string in v2 send_message (#16409)
## Summary
- switch MultiAgentV2 send_message to accept a single message string
instead of items
- keep the old assign_task item parser in place for the next branch
- update send_message schema/spec and focused handler tests

## Verification
- cargo test -p codex-tools
send_message_tool_requires_message_and_uses_submission_output
- cargo test -p codex-core multi_agent_v2_send_message
- just fix -p codex-tools
- just fix -p codex-core
- just argument-comment-lint

---------

Co-authored-by: Codex <noreply@openai.com>
2026-04-01 11:26:22 +02:00
jif-oai
d0474f2bc1 Use message string in v2 spawn_agent (#16406)
## Summary
- switch MultiAgentV2 spawn_agent to accept a single message string
instead of items
- update v2 spawn tool schema and focused handler/spec tests

## Verification
- cargo test -p codex-tools
spawn_agent_tool_v2_requires_task_name_and_lists_visible_models
- cargo test -p codex-core multi_agent_v2_spawn
- just fix -p codex-tools
- just fix -p codex-core
- just argument-comment-lint

Co-authored-by: Codex <noreply@openai.com>
2026-04-01 11:26:12 +02:00
12 changed files with 312 additions and 104 deletions

View File

@@ -85,7 +85,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.0.0"
version = "0.119.0-alpha.2"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
# crates created with `cargo new -w ...` automatically inherit the 2024

View File

@@ -347,7 +347,7 @@ async fn multi_agent_v2_spawn_requires_task_name() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}]
"message": "inspect this repo"
})),
);
let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else {
@@ -360,7 +360,7 @@ async fn multi_agent_v2_spawn_requires_task_name() {
}
#[tokio::test]
async fn multi_agent_v2_spawn_rejects_legacy_message_field() {
async fn multi_agent_v2_spawn_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -387,12 +387,12 @@ async fn multi_agent_v2_spawn_rejects_legacy_message_field() {
})),
);
let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else {
panic!("legacy message field should be rejected");
panic!("legacy items field should be rejected");
};
let FunctionCallError::RespondToModel(message) = err else {
panic!("legacy message field should surface as a model-facing error");
panic!("legacy items field should surface as a model-facing error");
};
assert!(message.contains("unknown field `message`"));
assert!(message.contains("unknown field `items`"));
}
#[tokio::test]
@@ -444,7 +444,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "test_process"
})),
))
@@ -496,7 +496,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat
"send_message",
function_payload(json!({
"target": "test_process",
"items": [{"type": "text", "text": "continue"}]
"message": "continue"
})),
))
.await
@@ -539,7 +539,7 @@ async fn multi_agent_v2_spawn_rejects_legacy_fork_context() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker",
"fork_context": true
})),
@@ -578,7 +578,7 @@ async fn multi_agent_v2_spawn_rejects_invalid_fork_turns_string() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker",
"fork_turns": "banana"
})),
@@ -617,7 +617,7 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker",
"fork_turns": "0"
})),
@@ -689,7 +689,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
"send_message",
function_payload(json!({
"target": "/root",
"items": [{"type": "text", "text": "done"}]
"message": "done"
})),
))
.await
@@ -709,6 +709,86 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
}));
}
#[tokio::test]
async fn multi_agent_v2_assign_task_rejects_root_target_from_child() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
turn.config = Arc::new(config);
let child_path = AgentPath::try_from("/root/worker").expect("agent path");
let child_thread_id = session
.services
.agent_control
.spawn_agent_with_metadata(
(*turn.config).clone(),
vec![UserInput::Text {
text: "inspect this repo".to_string(),
text_elements: Vec::new(),
}]
.into(),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(child_path.clone()),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker spawn should succeed")
.thread_id;
session.conversation_id = child_thread_id;
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(child_path),
agent_nickname: None,
agent_role: None,
});
let err = AssignTaskHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"assign_task",
function_payload(json!({
"target": "/root",
"message": "run this",
"interrupt": true
})),
))
.await
.expect_err("assign_task should reject the root target");
assert_eq!(
err,
FunctionCallError::RespondToModel("Tasks can't be assigned to the root agent".to_string())
);
let root_ops = manager
.captured_ops()
.into_iter()
.filter_map(|(id, op)| (id == root.thread_id).then_some(op))
.collect::<Vec<_>>();
assert!(!root_ops.iter().any(|op| matches!(op, Op::Interrupt)));
assert!(
!root_ops
.iter()
.any(|op| matches!(op, Op::InterAgentCommunication { .. }))
);
}
#[tokio::test]
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -731,7 +811,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker"
})),
))
@@ -909,7 +989,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker"
})),
))
@@ -952,7 +1032,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() {
}
#[tokio::test]
async fn multi_agent_v2_send_message_rejects_structured_items() {
async fn multi_agent_v2_send_message_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -973,7 +1053,7 @@ async fn multi_agent_v2_send_message_rejects_structured_items() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -999,14 +1079,12 @@ async fn multi_agent_v2_send_message_rejects_structured_items() {
);
let Err(err) = SendMessageHandlerV2.handle(invocation).await else {
panic!("structured items should be rejected in v2");
panic!("legacy items field should be rejected in v2");
};
assert_eq!(
err,
FunctionCallError::RespondToModel(
"send_message only supports text content in MultiAgentV2 for now".to_string()
)
);
let FunctionCallError::RespondToModel(message) = err else {
panic!("legacy items field should surface as a model-facing error");
};
assert!(message.contains("unknown field `items`"));
}
#[tokio::test]
@@ -1031,7 +1109,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -1050,7 +1128,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
"send_message",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
"message": "continue",
"interrupt": true
})),
);
@@ -1062,7 +1140,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
panic!("expected model-facing parse error");
};
assert!(message.starts_with(
"failed to parse function arguments: unknown field `interrupt`, expected `target` or `items`"
"failed to parse function arguments: unknown field `interrupt`, expected `target` or `message`"
));
let ops = manager.captured_ops();
@@ -1104,7 +1182,7 @@ async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -1142,7 +1220,7 @@ async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
"message": "continue",
"interrupt": true
})),
))
@@ -1233,7 +1311,7 @@ async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -1271,7 +1349,7 @@ async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
"message": "continue",
})),
))
.await
@@ -1340,6 +1418,59 @@ async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
assert_eq!(notifications.len(), 2);
}
#[tokio::test]
async fn multi_agent_v2_assign_task_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
SpawnAgentHandlerV2
.handle(invocation(
session.clone(),
turn.clone(),
"spawn_agent",
function_payload(json!({
"message": "boot worker",
"task_name": "worker"
})),
))
.await
.expect("spawn worker");
let agent_id = session
.services
.agent_control
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
.await
.expect("worker should resolve");
let invocation = invocation(
session,
turn,
"assign_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
})),
);
let Err(err) = AssignTaskHandlerV2.handle(invocation).await else {
panic!("legacy items field should be rejected in v2");
};
let FunctionCallError::RespondToModel(message) = err else {
panic!("legacy items field should surface as a model-facing error");
};
assert!(message.contains("unknown field `items`"));
}
#[tokio::test]
async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -1362,7 +1493,7 @@ async fn multi_agent_v2_interrupted_turn_does_not_notify_parent() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -1438,7 +1569,7 @@ async fn multi_agent_v2_spawn_includes_agent_id_key_when_named() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "test_process"
})),
))
@@ -1476,7 +1607,7 @@ async fn multi_agent_v2_spawn_surfaces_task_name_validation_errors() {
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "BadName"
})),
);
@@ -2103,7 +2234,7 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -2349,7 +2480,7 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "test_process"
})),
))
@@ -2440,7 +2571,7 @@ async fn multi_agent_v2_wait_agent_waits_for_new_mail_after_start() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -2540,7 +2671,7 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": format!("boot {task_name}")}],
"message": format!("boot {task_name}"),
"task_name": task_name
})),
))
@@ -2627,7 +2758,7 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "boot worker"}],
"message": "boot worker",
"task_name": "worker"
})),
))
@@ -2713,7 +2844,7 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() {
turn.clone(),
"spawn_agent",
function_payload(json!({
"items": [{"type": "text", "text": "inspect this repo"}],
"message": "inspect this repo",
"task_name": "worker"
})),
))

View File

@@ -1,7 +1,7 @@
use super::message_tool::AssignTaskArgs;
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::handle_message_tool;
use super::message_tool::handle_message_string_tool;
use super::*;
pub(crate) struct Handler;
@@ -21,11 +21,11 @@ impl ToolHandler for Handler {
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let arguments = function_arguments(invocation.payload.clone())?;
let args: AssignTaskArgs = parse_arguments(&arguments)?;
handle_message_tool(
handle_message_string_tool(
invocation,
MessageDeliveryMode::TriggerTurn,
args.target,
args.items,
args.message,
args.interrupt,
)
.await

View File

@@ -106,6 +106,7 @@ impl ToolHandler for Handler {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CloseAgentArgs {
target: String,
}

View File

@@ -40,6 +40,7 @@ impl ToolHandler for Handler {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ListAgentsArgs {
path_prefix: Option<String>,
}

View File

@@ -1,27 +1,18 @@
//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools.
//!
//! `send_message` and `assign_task` intentionally expose the same input shape and differ only in
//! whether the resulting `InterAgentCommunication` should wake the target immediately.
//! `send_message` and `assign_task` share the same submission path and differ only in whether the
//! resulting `InterAgentCommunication` should wake the target immediately.
use super::*;
use crate::agent::control::render_input_preview;
use codex_protocol::protocol::InterAgentCommunication;
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MessageDeliveryMode {
QueueOnly,
TriggerTurn,
}
impl MessageDeliveryMode {
/// Returns the model-visible error message for non-text inputs.
fn unsupported_items_error(self) -> &'static str {
match self {
Self::QueueOnly => "send_message only supports text content in MultiAgentV2 for now",
Self::TriggerTurn => "assign_task only supports text content in MultiAgentV2 for now",
}
}
/// Returns whether the produced communication should start a turn immediately.
fn apply(self, communication: InterAgentCommunication) -> InterAgentCommunication {
match self {
@@ -42,7 +33,7 @@ impl MessageDeliveryMode {
/// Input for the MultiAgentV2 `send_message` tool.
pub(crate) struct SendMessageArgs {
pub(crate) target: String,
pub(crate) items: Vec<UserInput>,
pub(crate) message: String,
}
#[derive(Debug, Deserialize)]
@@ -50,7 +41,7 @@ pub(crate) struct SendMessageArgs {
/// Input for the MultiAgentV2 `assign_task` tool.
pub(crate) struct AssignTaskArgs {
pub(crate) target: String,
pub(crate) items: Vec<UserInput>,
pub(crate) message: String,
#[serde(default)]
pub(crate) interrupt: bool,
}
@@ -79,33 +70,38 @@ impl ToolOutput for MessageToolResult {
}
}
/// Validates that the tool input is non-empty text-only content and returns its preview string.
fn text_content(
items: &[UserInput],
mode: MessageDeliveryMode,
) -> Result<String, FunctionCallError> {
if items.is_empty() {
fn message_content(message: String) -> Result<String, FunctionCallError> {
if message.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"Items can't be empty".to_string(),
"Empty message can't be sent to an agent".to_string(),
));
}
if items
.iter()
.all(|item| matches!(item, UserInput::Text { .. }))
{
return Ok(render_input_preview(&(items.to_vec().into())));
}
Err(FunctionCallError::RespondToModel(
mode.unsupported_items_error().to_string(),
))
Ok(message)
}
/// Handles the shared MultiAgentV2 text-message flow for both `send_message` and `assign_task`.
pub(crate) async fn handle_message_tool(
/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `assign_task`.
pub(crate) async fn handle_message_string_tool(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
target: String,
items: Vec<UserInput>,
message: String,
interrupt: bool,
) -> Result<MessageToolResult, FunctionCallError> {
handle_message_submission(
invocation,
mode,
target,
message_content(message)?,
interrupt,
)
.await
}
async fn handle_message_submission(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
target: String,
prompt: String,
interrupt: bool,
) -> Result<MessageToolResult, FunctionCallError> {
let ToolInvocation {
@@ -117,12 +113,21 @@ pub(crate) async fn handle_message_tool(
} = invocation;
let _ = payload;
let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?;
let prompt = text_content(&items, mode)?;
let receiver_agent = session
.services
.agent_control
.get_agent_metadata(receiver_thread_id)
.unwrap_or_default();
if mode == MessageDeliveryMode::TriggerTurn
&& receiver_agent
.agent_path
.as_ref()
.is_some_and(AgentPath::is_root)
{
return Err(FunctionCallError::RespondToModel(
"Tasks can't be assigned to the root agent".to_string(),
));
}
if interrupt {
session
.services

View File

@@ -1,7 +1,7 @@
use super::message_tool::MessageDeliveryMode;
use super::message_tool::MessageToolResult;
use super::message_tool::SendMessageArgs;
use super::message_tool::handle_message_tool;
use super::message_tool::handle_message_string_tool;
use super::*;
pub(crate) struct Handler;
@@ -21,11 +21,11 @@ impl ToolHandler for Handler {
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let arguments = function_arguments(invocation.payload.clone())?;
let args: SendMessageArgs = parse_arguments(&arguments)?;
handle_message_tool(
handle_message_string_tool(
invocation,
MessageDeliveryMode::QueueOnly,
args.target,
args.items,
args.message,
/*interrupt*/ false,
)
.await

View File

@@ -40,7 +40,7 @@ impl ToolHandler for Handler {
.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(Some(args.message), /*items*/ None)?;
let prompt = render_input_preview(&initial_operation);
let session_source = turn.session_source.clone();
@@ -202,7 +202,7 @@ impl ToolHandler for Handler {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SpawnAgentArgs {
items: Vec<UserInput>,
message: String,
task_name: String,
agent_type: Option<String>,
model: Option<String>,

View File

@@ -75,6 +75,7 @@ impl ToolHandler for Handler {
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct WaitArgs {
timeout_ms: Option<i64>,
}

View File

@@ -497,13 +497,13 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
panic!("spawn_agent should use object params");
};
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("items"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
required.as_ref(),
Some(&vec!["task_name".to_string(), "items".to_string()])
Some(&vec!["task_name".to_string(), "message".to_string()])
);
let output_schema = output_schema
.as_ref()
@@ -527,10 +527,11 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
};
assert!(properties.contains_key("target"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("message"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
required.as_ref(),
Some(&vec!["target".to_string(), "items".to_string()])
Some(&vec!["target".to_string(), "message".to_string()])
);
let assign_task = find_tool(&tools, "assign_task");
@@ -546,10 +547,11 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
panic!("assign_task should use object params");
};
assert!(properties.contains_key("target"));
assert!(!properties.contains_key("message"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
required.as_ref(),
Some(&vec!["target".to_string(), "items".to_string()])
Some(&vec!["target".to_string(), "message".to_string()])
);
let wait_agent = find_tool(&tools, "wait_agent");

View File

@@ -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(), "message".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(spawn_agent_output_schema_v2()),
@@ -127,7 +127,12 @@ pub fn create_send_message_tool() -> ToolSpec {
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"message".to_string(),
JsonSchema::String {
description: Some("Message text to queue on the target agent.".to_string()),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
@@ -138,7 +143,7 @@ pub fn create_send_message_tool() -> ToolSpec {
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
required: Some(vec!["target".to_string(), "message".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
@@ -155,7 +160,12 @@ pub fn create_assign_task_tool() -> ToolSpec {
),
},
),
("items".to_string(), create_collab_input_items_schema()),
(
"message".to_string(),
JsonSchema::String {
description: Some("Message text to send to the target agent.".to_string()),
},
),
(
"interrupt".to_string(),
JsonSchema::Boolean {
@@ -169,13 +179,13 @@ pub fn create_assign_task_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "assign_task".to_string(),
description: "Add a message to an existing agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
description: "Add a message to an existing non-root agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "items".to_string()]),
required: Some(vec!["target".to_string(), "message".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: Some(send_input_output_schema()),
@@ -221,7 +231,7 @@ pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec {
pub fn create_wait_agent_tool_v2(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for agents to reach a final status. Returns a brief wait summary instead of the agent's final content. Returns a timeout summary when no agent reaches a final status before the deadline."
description: "Wait for a mailbox update from any live agent, including queued messages and final-status notifications. Returns a brief wait summary instead of agent content, or a timeout summary if no mailbox update arrives before the deadline."
.to_string(),
strict: false,
defer_loading: None,
@@ -308,7 +318,7 @@ fn agent_status_output_schema() -> Value {
"oneOf": [
{
"type": "string",
"enum": ["pending_init", "running", "shutdown", "not_found"]
"enum": ["pending_init", "running", "interrupted", "shutdown", "not_found"]
},
{
"type": "object",
@@ -585,7 +595,12 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<St
fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
("items".to_string(), create_collab_input_items_schema()),
(
"message".to_string(),
JsonSchema::String {
description: Some("Initial plain-text task for the new agent.".to_string()),
},
),
(
"agent_type".to_string(),
JsonSchema::String {

View File

@@ -56,9 +56,9 @@ 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("message"));
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
properties.get("agent_type"),
@@ -68,7 +68,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
);
assert_eq!(
required,
Some(vec!["task_name".to_string(), "items".to_string()])
Some(vec!["task_name".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("spawn_agent output schema")["required"],
@@ -95,7 +95,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
}
#[test]
fn send_message_tool_requires_items_and_uses_submission_output() {
fn send_message_tool_requires_message_and_uses_submission_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
@@ -113,12 +113,12 @@ fn send_message_tool_requires_items_and_uses_submission_output() {
panic!("send_message should use object params");
};
assert!(properties.contains_key("target"));
assert!(properties.contains_key("items"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
required,
Some(vec!["target".to_string(), "items".to_string()])
Some(vec!["target".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("send_message output schema")["required"],
@@ -126,6 +126,38 @@ fn send_message_tool_requires_items_and_uses_submission_output() {
);
}
#[test]
fn assign_task_tool_requires_message_and_uses_submission_output() {
let ToolSpec::Function(ResponsesApiTool {
parameters,
output_schema,
..
}) = create_assign_task_tool()
else {
panic!("assign_task should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("assign_task should use object params");
};
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
required,
Some(vec!["target".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("assign_task output schema")["required"],
json!(["submission_id"])
);
}
#[test]
fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
let ToolSpec::Function(ResponsesApiTool {
@@ -176,3 +208,23 @@ fn list_agents_tool_includes_path_prefix_and_agent_fields() {
json!(["agent_name", "agent_status", "last_task_message"])
);
}
#[test]
fn list_agents_tool_status_schema_includes_interrupted() {
let ToolSpec::Function(ResponsesApiTool { output_schema, .. }) = create_list_agents_tool()
else {
panic!("list_agents should be a function tool");
};
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["properties"]
["agent_status"]["allOf"][0]["oneOf"][0]["enum"],
json!([
"pending_init",
"running",
"interrupted",
"shutdown",
"not_found"
])
);
}