diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 2d8dff3f6..a3b989768 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -812,24 +812,40 @@ impl AgentControl { return; }; let child_thread = state.get_thread(child_thread_id).await.ok(); + let message = format_subagent_notification_message(child_reference.as_str(), &status); if child_agent_path.is_some() && child_thread .as_ref() .map(|thread| thread.enabled(Feature::MultiAgentV2)) .unwrap_or(true) { - // Disable v2 completion notifications for now so child turns do - // not enqueue synthetic parent messages on completion. + let Some(child_agent_path) = child_agent_path.clone() else { + return; + }; + let Some(parent_agent_path) = child_agent_path + .as_str() + .rsplit_once('/') + .and_then(|(parent, _)| AgentPath::try_from(parent).ok()) + else { + return; + }; + let communication = InterAgentCommunication::new( + child_agent_path, + parent_agent_path, + Vec::new(), + message, + /*trigger_turn*/ false, + ); + let _ = control + .send_inter_agent_communication(parent_thread_id, communication) + .await; return; } let Ok(parent_thread) = state.get_thread(parent_thread_id).await else { return; }; parent_thread - .inject_user_message_without_turn(format_subagent_notification_message( - child_reference.as_str(), - &status, - )) + .inject_user_message_without_turn(message) .await; }); } @@ -1089,7 +1105,7 @@ fn last_task_message_from_item(item: &ResponseItem) -> Option { } match item { - ResponseItem::Message { role, content, .. } if role == "user" => { + ResponseItem::Message { role, .. } if role == "user" => { let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else { return None; }; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 067713dfe..10e9821ae 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -407,6 +407,67 @@ async fn send_input_submits_user_message() { assert_eq!(captured, Some(expected)); } +#[tokio::test] +async fn send_inter_agent_communication_without_turn_queues_message_without_triggering_turn() { + let harness = AgentControlHarness::new().await; + let (thread_id, thread) = harness.start_thread().await; + let communication = InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "hello from tests".to_string(), + false, + ); + + let submission_id = harness + .control + .send_inter_agent_communication(thread_id, communication.clone()) + .await + .expect("send_inter_agent_communication should succeed"); + assert!(!submission_id.is_empty()); + + let expected = ( + thread_id, + Op::InterAgentCommunication { + communication: communication.clone(), + }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); + + timeout(Duration::from_secs(5), async { + loop { + if thread + .codex + .session + .has_queued_response_items_for_next_turn() + .await + { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("inter-agent communication should stay pending"); + + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &history_items, + &communication + )); +} + #[tokio::test] async fn append_message_records_assistant_message() { let harness = AgentControlHarness::new().await; @@ -1057,11 +1118,109 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() { AgentPath::root(), Vec::new(), "done".to_string(), + true, ) )); assert!(!has_subagent_notification(&root_history_items)); } +#[tokio::test] +async fn multi_agent_v2_completion_queues_message_for_direct_parent() { + let harness = AgentControlHarness::new().await; + let (_root_thread_id, root_thread) = harness.start_thread().await; + let (worker_thread_id, _worker_thread) = harness.start_thread().await; + let mut tester_config = harness.config.clone(); + let _ = tester_config.features.enable(Feature::MultiAgentV2); + let tester_thread_id = harness + .manager + .start_thread(tester_config) + .await + .expect("tester thread should start") + .thread_id; + let tester_thread = harness + .manager + .get_thread(tester_thread_id) + .await + .expect("tester thread should exist"); + let worker_path = AgentPath::root().join("worker_a").expect("worker path"); + let tester_path = worker_path.join("tester").expect("tester path"); + harness.control.maybe_start_completion_watcher( + tester_thread_id, + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: worker_thread_id, + depth: 2, + agent_path: Some(tester_path.clone()), + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + tester_path.to_string(), + Some(tester_path.clone()), + ); + let tester_turn = tester_thread.codex.session.new_default_turn().await; + tester_thread + .codex + .session + .send_event( + tester_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: tester_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + let expected_message = crate::session_prefix::format_subagent_notification_message( + tester_path.as_str(), + &AgentStatus::Completed(Some("done".to_string())), + ); + let expected = ( + worker_thread_id, + Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + tester_path.clone(), + worker_path.clone(), + Vec::new(), + expected_message.clone(), + false, + ), + }, + ); + + timeout(Duration::from_secs(5), async { + loop { + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + if captured == Some(expected.clone()) { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("completion watcher should queue a direct-parent message"); + + let root_history_items = root_thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + assert!(!history_contains_assistant_inter_agent_communication( + &root_history_items, + &InterAgentCommunication::new( + tester_path, + AgentPath::root(), + Vec::new(), + expected_message, + false, + ) + )); +} + #[tokio::test] async fn completion_watcher_notifies_parent_when_child_is_missing() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6165aee5a..6eecd5ed1 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4650,27 +4650,27 @@ mod handlers { } } + /// Records an inter-agent assistant envelope and, when requested, wakes the recipient by + /// starting a regular turn if the session is currently idle. pub async fn inter_agent_communication( sess: &Arc, sub_id: String, communication: InterAgentCommunication, ) { let pending_item = communication.to_response_input_item(); - if sess - .inject_response_items(vec![pending_item.clone()]) - .await - .is_ok() - { + if let Ok(()) = sess.inject_response_items(vec![pending_item.clone()]).await { return; } - let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; - sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) - .await; sess.queue_response_items_for_next_turn(vec![pending_item]) .await; - sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new()) - .await; + if communication.trigger_turn { + let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; + sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + .await; + sess.spawn_task(turn_context, Vec::new(), crate::tasks::RegularTask::new()) + .await; + } } pub async fn run_user_shell_command(sess: &Arc, sub_id: String, command: String) { diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 33fb5f9af..906c72c92 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -41,6 +41,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem { AgentPath::root().join("worker").unwrap(), Vec::new(), text.to_string(), + true, ); ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 0cd680bbd..fa6ee8306 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -46,6 +46,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem { AgentPath::root().join("worker").unwrap(), Vec::new(), text.to_string(), + true, ); ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 80e5e5c2e..4a6aa26ff 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -21,8 +21,9 @@ use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::context::ToolOutput; +use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2; use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; -use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2; +use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use crate::turn_diff_tracker::TurnDiffTracker; @@ -337,7 +338,7 @@ async fn spawn_agent_errors_when_manager_dropped() { } #[tokio::test] -async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path() { +async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_path() { #[derive(Debug, Deserialize)] struct SpawnAgentResult { task_name: String, @@ -400,18 +401,18 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( Some("/root/test_process") ); - SendInputHandlerV2 + SendMessageHandlerV2 .handle(invocation( session.clone(), turn.clone(), - "send_input", + "send_message", function_payload(json!({ "target": "test_process", - "message": "continue" + "items": [{"type": "text", "text": "continue"}] })), )) .await - .expect("send_input should accept v2 path"); + .expect("send_message should accept v2 path"); assert!(manager.captured_ops().iter().any(|(id, op)| { *id == child_thread_id @@ -422,6 +423,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( && communication.recipient.as_str() == "/root/test_process" && communication.other_recipients.is_empty() && communication.content == "continue" + && !communication.trigger_turn ) })); } @@ -647,7 +649,7 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { } #[tokio::test] -async fn multi_agent_v2_send_input_accepts_structured_items() { +async fn multi_agent_v2_send_message_rejects_structured_items() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -683,7 +685,7 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { let invocation = invocation( session, turn, - "send_input", + "send_message", function_payload(json!({ "target": agent_id.to_string(), "items": [ @@ -693,33 +695,19 @@ async fn multi_agent_v2_send_input_accepts_structured_items() { })), ); - SendInputHandlerV2 - .handle(invocation) - .await - .expect("structured items should be accepted in v2"); - - let expected = Op::UserInput { - items: vec![ - UserInput::Mention { - name: "drive".to_string(), - path: "app://google_drive".to_string(), - }, - UserInput::Text { - text: "read the folder".to_string(), - text_elements: Vec::new(), - }, - ], - final_output_json_schema: None, + let Err(err) = SendMessageHandlerV2.handle(invocation).await else { + panic!("structured items should be rejected in v2"); }; - let captured = manager - .captured_ops() - .into_iter() - .find(|(id, op)| *id == agent_id && *op == expected); - assert_eq!(captured, Some((agent_id, expected))); + assert_eq!( + err, + FunctionCallError::RespondToModel( + "send_message only supports text content in MultiAgentV2 for now".to_string() + ) + ); } #[tokio::test] -async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() { +async fn multi_agent_v2_send_message_interrupts_busy_child_without_triggering_turn() { let (mut session, mut turn) = make_session_and_context().await; let manager = thread_manager(); let root = manager @@ -771,19 +759,161 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( ) .await; - SendInputHandlerV2 + SendMessageHandlerV2 .handle(invocation( - session, - turn, - "send_input", + session.clone(), + turn.clone(), + "send_message", function_payload(json!({ "target": agent_id.to_string(), - "message": "continue", + "items": [{"type": "text", "text": "continue"}], "interrupt": true })), )) .await - .expect("interrupting v2 send_input should succeed"); + .expect("interrupting v2 send_message should succeed"); + + let ops = manager.captured_ops(); + let ops_for_agent: Vec<&Op> = ops + .iter() + .filter_map(|(id, op)| (*id == agent_id).then_some(op)) + .collect(); + assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt))); + assert!(ops_for_agent.iter().any(|op| { + matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == AgentPath::root() + && communication.recipient.as_str() == "/root/worker" + && communication.other_recipients.is_empty() + && communication.content == "continue" + && !communication.trigger_turn + ) + })); + + timeout(Duration::from_secs(5), async { + loop { + if !thread + .codex + .session + .has_queued_response_items_for_next_turn() + .await + { + tokio::time::sleep(Duration::from_millis(10)).await; + continue; + } + let history_items = thread + .codex + .session + .clone_history() + .await + .raw_items() + .to_vec(); + let saw_envelope = history_contains_inter_agent_communication( + &history_items, + &InterAgentCommunication::new( + AgentPath::root(), + AgentPath::try_from("/root/worker").expect("agent path"), + Vec::new(), + "continue".to_string(), + false, + ), + ); + let saw_user_message = history_items.iter().any(|item| { + matches!( + item, + ResponseItem::Message { role, content, .. } + if role == "user" + && content.iter().any(|content_item| matches!( + content_item, + ContentItem::InputText { text } if text == "continue" + )) + ) + }); + if saw_envelope && !saw_user_message { + panic!("send_message should not materialize the envelope into history"); + } + if !saw_user_message { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("interrupting v2 send_message should queue the redirected message without a turn"); + + let _ = thread + .submit(Op::Shutdown {}) + .await + .expect("shutdown should submit"); +} + +#[tokio::test] +async fn multi_agent_v2_assign_task_interrupts_busy_child_without_losing_message() { + 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 thread = manager + .get_thread(agent_id) + .await + .expect("worker thread should exist"); + + let active_turn = thread.codex.session.new_default_turn().await; + thread + .codex + .session + .spawn_task( + Arc::clone(&active_turn), + vec![UserInput::Text { + text: "working".to_string(), + text_elements: Vec::new(), + }], + NeverEndingTask, + ) + .await; + + AssignTaskHandlerV2 + .handle(invocation( + session, + turn, + "assign_task", + function_payload(json!({ + "target": agent_id.to_string(), + "items": [{"type": "text", "text": "continue"}], + "interrupt": true + })), + )) + .await + .expect("interrupting v2 assign_task should succeed"); let ops = manager.captured_ops(); let ops_for_agent: Vec<&Op> = ops @@ -801,14 +931,6 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( && communication.content == "continue" ) })); - assert!(!ops_for_agent.iter().any(|op| matches!( - op, - Op::UserInput { items, .. } - if items.iter().any(|item| matches!( - item, - UserInput::Text { text, .. } if text == "continue" - )) - ))); timeout(Duration::from_secs(5), async { loop { @@ -826,6 +948,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( AgentPath::try_from("/root/worker").expect("agent path"), Vec::new(), "continue".to_string(), + true, ), ); let saw_user_message = history_items.iter().any(|item| { @@ -846,7 +969,7 @@ async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message( } }) .await - .expect("interrupting v2 send_input should preserve the redirected message"); + .expect("interrupting v2 assign_task should preserve the redirected message"); let _ = thread .submit(Op::Shutdown {}) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 6e0e517dd..00ee57976 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -30,12 +30,15 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +pub(crate) use assign_task::Handler as AssignTaskHandler; pub(crate) use list_agents::Handler as ListAgentsHandler; -pub(crate) use send_input::Handler as SendInputHandler; +pub(crate) use send_message::Handler as SendMessageHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; +mod assign_task; mod list_agents; -mod send_input; +mod message_tool; +mod send_message; mod spawn; pub(crate) mod wait; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs new file mode 100644 index 000000000..e55b65b31 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/assign_task.rs @@ -0,0 +1,23 @@ +use super::message_tool::MessageDeliveryMode; +use super::message_tool::MessageToolResult; +use super::message_tool::handle_message_tool; +use super::*; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = MessageToolResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_message_tool(invocation, MessageDeliveryMode::TriggerTurn).await + } +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs new file mode 100644 index 000000000..48c7615e7 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -0,0 +1,175 @@ +//! 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. + +use super::*; +use codex_protocol::protocol::InterAgentCommunication; + +#[derive(Clone, Copy)] +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 { + Self::QueueOnly => InterAgentCommunication { + trigger_turn: false, + ..communication + }, + Self::TriggerTurn => InterAgentCommunication { + trigger_turn: true, + ..communication + }, + } + } +} + +#[derive(Debug, Deserialize)] +/// Input shared by the MultiAgentV2 `send_message` and `assign_task` tools. +pub(crate) struct MessageToolArgs { + pub(crate) target: String, + pub(crate) items: Vec, + #[serde(default)] + pub(crate) interrupt: bool, +} + +#[derive(Debug, Serialize)] +/// Tool result shared by the MultiAgentV2 message-delivery tools. +pub(crate) struct MessageToolResult { + submission_id: String, +} + +impl ToolOutput for MessageToolResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "multi_agent_message") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "multi_agent_message") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "multi_agent_message") + } +} + +/// Validates that the tool input is non-empty text-only content and returns its preview string. +fn text_content( + items: &[UserInput], + mode: MessageDeliveryMode, +) -> Result { + if items.is_empty() { + return Err(FunctionCallError::RespondToModel( + "Items can't be empty".to_string(), + )); + } + if items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) + { + return Ok(input_preview(items)); + } + Err(FunctionCallError::RespondToModel( + mode.unsupported_items_error().to_string(), + )) +} + +/// Handles the shared MultiAgentV2 text-message flow for both `send_message` and `assign_task`. +pub(crate) async fn handle_message_tool( + invocation: ToolInvocation, + mode: MessageDeliveryMode, +) -> Result { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: MessageToolArgs = parse_arguments(&arguments)?; + let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?; + let prompt = text_content(&args.items, mode)?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); + if args.interrupt { + session + .services + .agent_control + .interrupt_agent(receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + } + session + .send_event( + &turn, + CollabAgentInteractionBeginEvent { + call_id: call_id.clone(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + prompt: prompt.clone(), + } + .into(), + ) + .await; + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) + })?; + let communication = InterAgentCommunication::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + receiver_agent_path, + Vec::new(), + prompt.clone(), + /*trigger_turn*/ true, + ); + let result = session + .services + .agent_control + .send_inter_agent_communication(receiver_thread_id, mode.apply(communication)) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err)); + let status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + session + .send_event( + &turn, + CollabAgentInteractionEndEvent { + call_id, + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + prompt, + status, + } + .into(), + ) + .await; + let submission_id = result?; + + Ok(MessageToolResult { submission_id }) +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs deleted file mode 100644 index c17e12cca..000000000 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_input.rs +++ /dev/null @@ -1,145 +0,0 @@ -use super::*; -use codex_protocol::protocol::InterAgentCommunication; - -pub(crate) struct Handler; - -fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool { - items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) -} - -#[async_trait] -impl ToolHandler for Handler { - type Output = SendInputResult; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SendInputArgs = parse_arguments(&arguments)?; - let receiver_thread_id = resolve_agent_target(&session, &turn, &args.target).await?; - let input_items = parse_collab_input(args.message, args.items)?; - let prompt = input_preview(&input_items); - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); - if args.interrupt { - session - .services - .agent_control - .interrupt_agent(receiver_thread_id) - .await - .map_err(|err| collab_agent_error(receiver_thread_id, err))?; - } - session - .send_event( - &turn, - CollabAgentInteractionBeginEvent { - call_id: call_id.clone(), - sender_thread_id: session.conversation_id, - receiver_thread_id, - prompt: prompt.clone(), - } - .into(), - ) - .await; - let result = if can_use_v2_inter_agent_instruction(&input_items) { - let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { - FunctionCallError::RespondToModel( - "target agent is missing an agent_path".to_string(), - ) - })?; - let communication = InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - ); - session - .services - .agent_control - .send_inter_agent_communication(receiver_thread_id, communication) - .await - } else { - session - .services - .agent_control - .send_input(receiver_thread_id, input_items) - .await - } - .map_err(|err| collab_agent_error(receiver_thread_id, err)); - let status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - session - .send_event( - &turn, - CollabAgentInteractionEndEvent { - call_id, - sender_thread_id: session.conversation_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - prompt, - status, - } - .into(), - ) - .await; - let submission_id = result?; - - Ok(SendInputResult { submission_id }) - } -} - -#[derive(Debug, Deserialize)] -struct SendInputArgs { - target: String, - message: Option, - items: Option>, - #[serde(default)] - interrupt: bool, -} - -#[derive(Debug, Serialize)] -pub(crate) struct SendInputResult { - submission_id: String, -} - -impl ToolOutput for SendInputResult { - fn log_preview(&self) -> String { - tool_output_json_text(self, "send_input") - } - - fn success_for_logging(&self) -> bool { - true - } - - fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { - tool_output_response_item(call_id, payload, self, Some(true), "send_input") - } - - fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { - tool_output_code_mode_result(self, "send_input") - } -} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs new file mode 100644 index 000000000..ed10b124a --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs @@ -0,0 +1,23 @@ +use super::message_tool::MessageDeliveryMode; +use super::message_tool::MessageToolResult; +use super::message_tool::handle_message_tool; +use super::*; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = MessageToolResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + handle_message_tool(invocation, MessageDeliveryMode::QueueOnly).await + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index fdbbfa1eb..d6b167ed9 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1448,6 +1448,80 @@ fn create_send_input_tool() -> ToolSpec { }) } +fn create_send_message_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "target".to_string(), + JsonSchema::String { + description: Some( + "Agent id or canonical task name to message (from spawn_agent).".to_string(), + ), + }, + ), + ("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. 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, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["target".to_string(), "items".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(send_input_output_schema()), + }) +} + +fn create_assign_task_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "target".to_string(), + JsonSchema::String { + description: Some( + "Agent id or canonical task name to message (from spawn_agent).".to_string(), + ), + }, + ), + ("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: "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." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["target".to_string(), "items".to_string()]), + additional_properties: Some(false.into()), + }, + output_schema: Some(send_input_output_schema()), + }) +} + fn create_resume_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -2695,8 +2769,9 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; + use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2; use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; - use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2; + use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use std::sync::Arc; @@ -3085,10 +3160,22 @@ pub(crate) fn build_specs_with_discoverable_tools( ); push_tool_spec( &mut builder, - create_send_input_tool(), + if config.multi_agent_v2 { + create_send_message_tool() + } else { + create_send_input_tool() + }, /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, ); + if config.multi_agent_v2 { + push_tool_spec( + &mut builder, + create_assign_task_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + } if !config.multi_agent_v2 { push_tool_spec( &mut builder, @@ -3122,7 +3209,8 @@ pub(crate) fn build_specs_with_discoverable_tools( config.code_mode_enabled, ); builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2)); - builder.register_handler("send_input", Arc::new(SendInputHandlerV2)); + builder.register_handler("send_message", Arc::new(SendMessageHandlerV2)); + builder.register_handler("assign_task", Arc::new(AssignTaskHandlerV2)); builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2)); builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2)); } else { diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 9daaab9c6..7550495c7 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -550,7 +550,8 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { &tools, &[ "spawn_agent", - "send_input", + "send_message", + "assign_task", "wait_agent", "close_agent", "list_agents", @@ -584,9 +585,9 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { json!(["agent_id", "task_name", "nickname"]) ); - let send_input = find_tool(&tools, "send_input"); - let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_input.spec else { - panic!("send_input should be a function tool"); + let send_message = find_tool(&tools, "send_message"); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_message.spec else { + panic!("send_message should be a function tool"); }; let JsonSchema::Object { properties, @@ -594,10 +595,33 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { .. } = parameters else { - panic!("send_input should use object params"); + panic!("send_message should use object params"); }; assert!(properties.contains_key("target")); - assert_eq!(required.as_ref(), Some(&vec!["target".to_string()])); + assert!(!properties.contains_key("message")); + assert_eq!( + required.as_ref(), + Some(&vec!["target".to_string(), "items".to_string()]) + ); + + let assign_task = find_tool(&tools, "assign_task"); + let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &assign_task.spec 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_eq!( + required.as_ref(), + Some(&vec!["target".to_string(), "items".to_string()]) + ); let wait_agent = find_tool(&tools, "wait_agent"); let ToolSpec::Function(ResponsesApiTool { @@ -652,6 +676,7 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { output_schema["properties"]["agents"]["items"]["required"], json!(["agent_name", "agent_status", "last_task_message"]) ); + assert_lacks_tool_name(&tools, "send_input"); assert_lacks_tool_name(&tools, "resume_agent"); } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 54a9990fa..fae5aa68d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -518,6 +518,7 @@ pub struct InterAgentCommunication { #[serde(default)] pub other_recipients: Vec, pub content: String, + pub trigger_turn: bool, } impl InterAgentCommunication { @@ -526,12 +527,14 @@ impl InterAgentCommunication { recipient: AgentPath, other_recipients: Vec, content: String, + trigger_turn: bool, ) -> Self { Self { author, recipient, other_recipients, content, + trigger_turn, } }