use crate::agent::AgentStatus; use crate::codex::TurnContext; use crate::config::Config; use crate::error::CodexErr; use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use async_trait::async_trait; use codex_protocol::ThreadId; use serde::Deserialize; use serde::Serialize; pub struct CollabHandler; pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 300_000; #[derive(Debug, Deserialize)] struct CloseAgentArgs { id: String, } #[async_trait] impl ToolHandler for CollabHandler { 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, tool_name, payload, .. } = invocation; let arguments = match payload { ToolPayload::Function { arguments } => arguments, _ => { return Err(FunctionCallError::RespondToModel( "collab handler received unsupported payload".to_string(), )); } }; match tool_name.as_str() { "spawn_agent" => spawn::handle(session, turn, arguments).await, "send_input" => send_input::handle(session, arguments).await, "wait" => wait::handle(session, arguments).await, "close_agent" => close_agent::handle(session, arguments).await, other => Err(FunctionCallError::RespondToModel(format!( "unsupported collab tool {other}" ))), } } } mod spawn { use super::*; use crate::codex::Session; use std::sync::Arc; #[derive(Debug, Deserialize)] struct SpawnAgentArgs { message: String, } #[derive(Debug, Serialize)] struct SpawnAgentResult { agent_id: String, } pub async fn handle( session: Arc, turn: Arc, arguments: String, ) -> Result { let args: SpawnAgentArgs = parse_arguments(&arguments)?; if args.message.trim().is_empty() { return Err(FunctionCallError::RespondToModel( "Empty message can't be sent to an agent".to_string(), )); } let config = build_agent_spawn_config(turn.as_ref())?; let result = session .services .agent_control .spawn_agent(config, args.message, true) .await .map_err(collab_spawn_error)?; let content = serde_json::to_string(&SpawnAgentResult { agent_id: result.to_string(), }) .map_err(|err| { FunctionCallError::Fatal(format!("failed to serialize spawn_agent result: {err}")) })?; Ok(ToolOutput::Function { content, success: Some(true), content_items: None, }) } } mod send_input { use super::*; use crate::codex::Session; use std::sync::Arc; #[derive(Debug, Deserialize)] struct SendInputArgs { id: String, message: String, } #[derive(Debug, Serialize)] struct SendInputResult { submission_id: String, } pub async fn handle( session: Arc, arguments: String, ) -> Result { let args: SendInputArgs = parse_arguments(&arguments)?; let agent_id = agent_id(&args.id)?; if args.message.trim().is_empty() { return Err(FunctionCallError::RespondToModel( "Empty message can't be sent to an agent".to_string(), )); } let agent_id_for_err = agent_id; let submission_id = session .services .agent_control .send_prompt(agent_id, args.message) .await .map_err(|err| collab_agent_error(agent_id_for_err, err))?; let content = serde_json::to_string(&SendInputResult { submission_id }).map_err(|err| { FunctionCallError::Fatal(format!("failed to serialize send_input result: {err}")) })?; Ok(ToolOutput::Function { content, success: Some(true), content_items: None, }) } } mod wait { use super::*; use crate::agent::status::is_final; use crate::codex::Session; use std::sync::Arc; use std::time::Duration; use tokio::time::Instant; use tokio::time::timeout_at; #[derive(Debug, Deserialize)] struct WaitArgs { id: String, timeout_ms: Option, } #[derive(Debug, Serialize)] struct WaitResult { status: AgentStatus, timed_out: bool, } pub async fn handle( session: Arc, arguments: String, ) -> Result { let args: WaitArgs = parse_arguments(&arguments)?; let agent_id = agent_id(&args.id)?; // Validate timeout. let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS); let timeout_ms = match timeout_ms { ms if ms <= 0 => { return Err(FunctionCallError::RespondToModel( "timeout_ms must be greater than zero".to_owned(), )); } ms => ms.min(MAX_WAIT_TIMEOUT_MS), }; let agent_id_for_err = agent_id; let mut status_rx = session .services .agent_control .subscribe_status(agent_id) .await .map_err(|err| collab_agent_error(agent_id_for_err, err))?; // Get last known status. let mut status = status_rx.borrow_and_update().clone(); let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); let timed_out = loop { if is_final(&status) { break false; } match timeout_at(deadline, status_rx.changed()).await { Ok(Ok(())) => status = status_rx.borrow().clone(), Ok(Err(_)) => { let last_status = session.services.agent_control.get_status(agent_id).await; if last_status != AgentStatus::NotFound { // On-purpose we keep the last known status if the agent gets dropped. This // event is not supposed to happen. status = last_status; } break false; } Err(_) => break true, } }; if matches!(status, AgentStatus::NotFound) { return Err(FunctionCallError::RespondToModel(format!( "agent with id {agent_id} not found" ))); } let result = WaitResult { status, timed_out }; let content = serde_json::to_string(&result).map_err(|err| { FunctionCallError::Fatal(format!("failed to serialize wait result: {err}")) })?; let success = !result.timed_out && !matches!(result.status, AgentStatus::Errored(_)); Ok(ToolOutput::Function { content, success: Some(success), content_items: None, }) } } pub mod close_agent { use super::*; use crate::codex::Session; use std::sync::Arc; #[derive(Debug, Deserialize, Serialize)] pub(super) struct CloseAgentResult { pub(super) status: AgentStatus, } pub async fn handle( session: Arc, arguments: String, ) -> Result { let args: CloseAgentArgs = parse_arguments(&arguments)?; let agent_id = agent_id(&args.id)?; let agent_id_for_err = agent_id; let mut status_rx = session .services .agent_control .subscribe_status(agent_id) .await .map_err(|err| collab_agent_error(agent_id_for_err, err))?; let status = status_rx.borrow_and_update().clone(); if !matches!(status, AgentStatus::Shutdown) { let agent_id_for_err = agent_id; let _ = session .services .agent_control .shutdown_agent(agent_id) .await .map_err(|err| collab_agent_error(agent_id_for_err, err))?; } let content = serde_json::to_string(&CloseAgentResult { status }).map_err(|err| { FunctionCallError::Fatal(format!("failed to serialize close_agent result: {err}")) })?; Ok(ToolOutput::Function { content, success: Some(true), content_items: None, }) } } fn agent_id(id: &str) -> Result { ThreadId::from_string(id) .map_err(|e| FunctionCallError::RespondToModel(format!("invalid agent id {id}: {e:?}"))) } fn collab_spawn_error(err: CodexErr) -> FunctionCallError { match err { CodexErr::UnsupportedOperation(_) => { FunctionCallError::RespondToModel("collab manager unavailable".to_string()) } err => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")), } } fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError { match err { CodexErr::ThreadNotFound(id) => { FunctionCallError::RespondToModel(format!("agent with id {id} not found")) } CodexErr::InternalAgentDied => { FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed")) } CodexErr::UnsupportedOperation(_) => { FunctionCallError::RespondToModel("collab manager unavailable".to_string()) } err => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")), } } fn build_agent_spawn_config(turn: &TurnContext) -> Result { let base_config = turn.client.config(); let mut config = (*base_config).clone(); config.model = Some(turn.client.get_model()); config.model_provider = turn.client.get_provider(); config.model_reasoning_effort = turn.client.get_reasoning_effort(); config.model_reasoning_summary = turn.client.get_reasoning_summary(); config.developer_instructions = turn.developer_instructions.clone(); config.base_instructions = turn.base_instructions.clone(); config.compact_prompt = turn.compact_prompt.clone(); config.user_instructions = turn.user_instructions.clone(); config.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); config.cwd = turn.cwd.clone(); config .approval_policy .set(turn.approval_policy) .map_err(|err| { FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) })?; config .sandbox_policy .set(turn.sandbox_policy.clone()) .map_err(|err| { FunctionCallError::RespondToModel(format!("sandbox_policy is invalid: {err}")) })?; Ok(config) } #[cfg(test)] mod tests { use super::*; use crate::CodexAuth; use crate::ThreadManager; use crate::built_in_model_providers; use crate::codex::make_session_and_context; use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; use crate::protocol::AskForApproval; use crate::protocol::Op; use crate::protocol::SandboxPolicy; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use tokio::time::timeout; fn invocation( session: Arc, turn: Arc, tool_name: &str, payload: ToolPayload, ) -> ToolInvocation { ToolInvocation { session, turn, tracker: Arc::new(Mutex::new(TurnDiffTracker::default())), call_id: "call-1".to_string(), tool_name: tool_name.to_string(), payload, } } fn function_payload(args: serde_json::Value) -> ToolPayload { ToolPayload::Function { arguments: args.to_string(), } } fn thread_manager() -> ThreadManager { ThreadManager::with_models_provider( CodexAuth::from_api_key("dummy"), built_in_model_providers()["openai"].clone(), ) } #[tokio::test] async fn handler_rejects_non_function_payloads() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "spawn_agent", ToolPayload::Custom { input: "hello".to_string(), }, ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("payload should be rejected"); }; assert_eq!( err, FunctionCallError::RespondToModel( "collab handler received unsupported payload".to_string() ) ); } #[tokio::test] async fn handler_rejects_unknown_tool() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "unknown_tool", function_payload(json!({})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("tool should be rejected"); }; assert_eq!( err, FunctionCallError::RespondToModel("unsupported collab tool unknown_tool".to_string()) ); } #[tokio::test] async fn spawn_agent_rejects_empty_message() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "spawn_agent", function_payload(json!({"message": " "})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("empty message should be rejected"); }; assert_eq!( err, FunctionCallError::RespondToModel( "Empty message can't be sent to an agent".to_string() ) ); } #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "spawn_agent", function_payload(json!({"message": "hello"})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("spawn should fail without a manager"); }; assert_eq!( err, FunctionCallError::RespondToModel("collab manager unavailable".to_string()) ); } #[tokio::test] async fn send_input_rejects_empty_message() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "send_input", function_payload(json!({"id": ThreadId::new().to_string(), "message": ""})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("empty message should be rejected"); }; assert_eq!( err, FunctionCallError::RespondToModel( "Empty message can't be sent to an agent".to_string() ) ); } #[tokio::test] async fn send_input_rejects_invalid_id() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "send_input", function_payload(json!({"id": "not-a-uuid", "message": "hi"})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("invalid id should be rejected"); }; let FunctionCallError::RespondToModel(msg) = err else { panic!("expected respond-to-model error"); }; assert!(msg.starts_with("invalid agent id not-a-uuid:")); } #[tokio::test] async fn send_input_reports_missing_agent() { let (mut session, turn) = make_session_and_context().await; let manager = thread_manager(); session.services.agent_control = manager.agent_control(); let agent_id = ThreadId::new(); let invocation = invocation( Arc::new(session), Arc::new(turn), "send_input", function_payload(json!({"id": agent_id.to_string(), "message": "hi"})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("missing agent should be reported"); }; assert_eq!( err, FunctionCallError::RespondToModel(format!("agent with id {agent_id} not found")) ); } #[tokio::test] async fn wait_rejects_non_positive_timeout() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "wait", function_payload(json!({"id": ThreadId::new().to_string(), "timeout_ms": 0})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("non-positive timeout should be rejected"); }; assert_eq!( err, FunctionCallError::RespondToModel("timeout_ms must be greater than zero".to_string()) ); } #[tokio::test] async fn wait_rejects_invalid_id() { let (session, turn) = make_session_and_context().await; let invocation = invocation( Arc::new(session), Arc::new(turn), "wait", function_payload(json!({"id": "invalid"})), ); let Err(err) = CollabHandler.handle(invocation).await else { panic!("invalid id should be rejected"); }; let FunctionCallError::RespondToModel(msg) = err else { panic!("expected respond-to-model error"); }; assert!(msg.starts_with("invalid agent id invalid:")); } #[tokio::test] async fn wait_times_out_when_status_is_not_final() { let (mut session, turn) = make_session_and_context().await; let manager = thread_manager(); session.services.agent_control = manager.agent_control(); let config = turn.client.config().as_ref().clone(); let thread = manager.start_thread(config).await.expect("start thread"); let agent_id = thread.thread_id; let invocation = invocation( Arc::new(session), Arc::new(turn), "wait", function_payload(json!({"id": agent_id.to_string(), "timeout_ms": 10})), ); let output = CollabHandler .handle(invocation) .await .expect("wait should succeed"); let ToolOutput::Function { content, success, .. } = output else { panic!("expected function output"); }; assert_eq!(content, r#"{"status":"pending_init","timed_out":true}"#); assert_eq!(success, Some(false)); let _ = thread .thread .submit(Op::Shutdown {}) .await .expect("shutdown should submit"); } #[tokio::test] async fn wait_returns_final_status_without_timeout() { let (mut session, turn) = make_session_and_context().await; let manager = thread_manager(); session.services.agent_control = manager.agent_control(); let config = turn.client.config().as_ref().clone(); let thread = manager.start_thread(config).await.expect("start thread"); let agent_id = thread.thread_id; let mut status_rx = manager .agent_control() .subscribe_status(agent_id) .await .expect("subscribe should succeed"); let _ = thread .thread .submit(Op::Shutdown {}) .await .expect("shutdown should submit"); let _ = timeout(Duration::from_secs(1), status_rx.changed()) .await .expect("shutdown status should arrive"); let invocation = invocation( Arc::new(session), Arc::new(turn), "wait", function_payload(json!({"id": agent_id.to_string(), "timeout_ms": 1000})), ); let output = CollabHandler .handle(invocation) .await .expect("wait should succeed"); let ToolOutput::Function { content, success, .. } = output else { panic!("expected function output"); }; assert_eq!(content, r#"{"status":"shutdown","timed_out":false}"#); assert_eq!(success, Some(true)); } #[tokio::test] async fn close_agent_submits_shutdown_and_returns_status() { let (mut session, turn) = make_session_and_context().await; let manager = thread_manager(); session.services.agent_control = manager.agent_control(); let config = turn.client.config().as_ref().clone(); let thread = manager.start_thread(config).await.expect("start thread"); let agent_id = thread.thread_id; let status_before = manager.agent_control().get_status(agent_id).await; let invocation = invocation( Arc::new(session), Arc::new(turn), "close_agent", function_payload(json!({"id": agent_id.to_string()})), ); let output = CollabHandler .handle(invocation) .await .expect("close_agent should succeed"); let ToolOutput::Function { content, success, .. } = output else { panic!("expected function output"); }; let result: close_agent::CloseAgentResult = serde_json::from_str(&content).expect("close_agent result should be json"); assert_eq!(result.status, status_before); assert_eq!(success, Some(true)); let ops = manager.captured_ops(); let submitted_shutdown = ops .iter() .any(|(id, op)| *id == agent_id && matches!(op, Op::Shutdown)); assert_eq!(submitted_shutdown, true); let status_after = manager.agent_control().get_status(agent_id).await; assert_eq!(status_after, AgentStatus::NotFound); } #[tokio::test] async fn build_agent_spawn_config_uses_turn_context_values() { let (_session, mut turn) = make_session_and_context().await; turn.developer_instructions = Some("dev".to_string()); turn.base_instructions = Some("base".to_string()); turn.compact_prompt = Some("compact".to_string()); turn.user_instructions = Some("user".to_string()); turn.shell_environment_policy = ShellEnvironmentPolicy { use_profile: true, ..ShellEnvironmentPolicy::default() }; let temp_dir = tempfile::tempdir().expect("temp dir"); turn.cwd = temp_dir.path().to_path_buf(); turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); turn.approval_policy = AskForApproval::Never; turn.sandbox_policy = SandboxPolicy::DangerFullAccess; let config = build_agent_spawn_config(&turn).expect("spawn config"); let mut expected = (*turn.client.config()).clone(); expected.model = Some(turn.client.get_model()); expected.model_provider = turn.client.get_provider(); expected.model_reasoning_effort = turn.client.get_reasoning_effort(); expected.model_reasoning_summary = turn.client.get_reasoning_summary(); expected.developer_instructions = turn.developer_instructions.clone(); expected.base_instructions = turn.base_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); expected.user_instructions = turn.user_instructions.clone(); expected.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); expected.cwd = turn.cwd.clone(); expected .approval_policy .set(turn.approval_policy) .expect("approval policy set"); expected .sandbox_policy .set(turn.sandbox_policy) .expect("sandbox policy set"); assert_eq!(config, expected); } }