use std::sync::Arc; use std::time::Instant; use tokio::sync::RwLock; use tokio::task::JoinError; use tokio_util::either::Either; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use tracing::Instrument; use tracing::instrument; use tracing::trace_span; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::tools::context::AbortedToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; use crate::tools::registry::AnyToolResult; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; use crate::tools::router::ToolRouter; use codex_protocol::error::CodexErr; use codex_protocol::models::ResponseInputItem; use codex_tools::ToolSpec; #[derive(Clone)] pub(crate) struct ToolCallRuntime { router: Arc, session: Arc, turn_context: Arc, tracker: SharedTurnDiffTracker, parallel_execution: Arc>, } impl ToolCallRuntime { pub(crate) fn new( router: Arc, session: Arc, turn_context: Arc, tracker: SharedTurnDiffTracker, ) -> Self { Self { router, session, turn_context, tracker, parallel_execution: Arc::new(RwLock::new(())), } } pub(crate) fn find_spec(&self, tool_name: &str) -> Option { self.router.find_spec(tool_name) } #[instrument(level = "trace", skip_all)] pub(crate) fn handle_tool_call( self, call: ToolCall, cancellation_token: CancellationToken, ) -> impl std::future::Future> { let error_call = call.clone(); let future = self.handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token); async move { match future.await { Ok(response) => Ok(response.into_response()), Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)), Err(other) => Ok(Self::failure_response(error_call, other)), } } .in_current_span() } #[instrument(level = "trace", skip_all)] pub(crate) fn handle_tool_call_with_source( self, call: ToolCall, source: ToolCallSource, cancellation_token: CancellationToken, ) -> impl std::future::Future> { let supports_parallel = self.router.tool_supports_parallel(&call.tool_name); let router = Arc::clone(&self.router); let session = Arc::clone(&self.session); let turn = Arc::clone(&self.turn_context); let tracker = Arc::clone(&self.tracker); let lock = Arc::clone(&self.parallel_execution); let started = Instant::now(); let join_error_call = call.clone(); let dispatch_span = trace_span!( "dispatch_tool_call_with_code_mode_result", otel.name = call.tool_name.as_str(), tool_name = call.tool_name.as_str(), call_id = call.call_id.as_str(), aborted = false, ); let handle: AbortOnDropHandle> = AbortOnDropHandle::new(tokio::spawn(async move { tokio::select! { _ = cancellation_token.cancelled() => { let secs = started.elapsed().as_secs_f32().max(0.1); dispatch_span.record("aborted", true); Ok(Self::aborted_response(&call, secs)) }, res = async { let _guard = if supports_parallel { Either::Left(lock.read().await) } else { Either::Right(lock.write().await) }; router .dispatch_tool_call_with_code_mode_result( session, turn, tracker, call.clone(), source, ) .instrument(dispatch_span.clone()) .await } => res, } })); async move { handle.await.map_err(|err| { tool_task_join_error_to_function_call_error( err, &join_error_call, started.elapsed(), ) })? } .in_current_span() } } fn tool_task_join_error_to_function_call_error( err: JoinError, call: &ToolCall, elapsed: std::time::Duration, ) -> FunctionCallError { if err.is_cancelled() { let secs = elapsed.as_secs_f32().max(0.1); tracing::warn!( tool_name = call.tool_name, call_id = call.call_id, elapsed_seconds = secs, "tool task was cancelled before delivering a response" ); return FunctionCallError::RespondToModel(format!( "tool execution interrupted after {secs:.1}s" )); } FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}")) } impl ToolCallRuntime { fn failure_response(call: ToolCall, err: FunctionCallError) -> ResponseInputItem { let message = err.to_string(); match call.payload { ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput { call_id: call.call_id, status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), }, ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput { call_id: call.call_id, name: None, output: codex_protocol::models::FunctionCallOutputPayload { body: codex_protocol::models::FunctionCallOutputBody::Text(message), success: Some(false), }, }, _ => ResponseInputItem::FunctionCallOutput { call_id: call.call_id, output: codex_protocol::models::FunctionCallOutputPayload { body: codex_protocol::models::FunctionCallOutputBody::Text(message), success: Some(false), }, }, } } fn aborted_response(call: &ToolCall, secs: f32) -> AnyToolResult { AnyToolResult { call_id: call.call_id.clone(), payload: call.payload.clone(), result: Box::new(AbortedToolOutput { message: Self::abort_message(call, secs), }), } } fn abort_message(call: &ToolCall, secs: f32) -> String { match call.tool_name.as_str() { "shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec" => { format!("Wall time: {secs:.1} seconds\naborted by user") } _ => format!("aborted by user after {secs:.1}s"), } } } #[cfg(test)] mod tests { use super::*; use crate::tools::context::ToolPayload; use pretty_assertions::assert_eq; use serde_json::json; fn test_tool_call() -> ToolCall { ToolCall { tool_name: "spawn_agents_on_csv".to_string(), call_id: "call-1".to_string(), tool_namespace: None, payload: ToolPayload::Function { arguments: json!({"csv_path":"in.csv","instruction":"test"}).to_string(), }, } } #[tokio::test] async fn cancelled_tool_task_join_error_becomes_model_response() { let handle = tokio::spawn(async { tokio::time::sleep(std::time::Duration::from_secs(60)).await; }); handle.abort(); let err = handle.await.expect_err("task should be cancelled"); let mapped = tool_task_join_error_to_function_call_error( err, &test_tool_call(), std::time::Duration::from_millis(200), ); assert_eq!( mapped, FunctionCallError::RespondToModel("tool execution interrupted after 0.2s".to_string()) ); } #[tokio::test] async fn panicked_tool_task_join_error_stays_fatal() { let handle = tokio::spawn(async { panic!("boom"); }); let err = handle.await.expect_err("task should panic"); let mapped = tool_task_join_error_to_function_call_error( err, &test_tool_call(), std::time::Duration::from_millis(200), ); let FunctionCallError::Fatal(message) = mapped else { panic!("panic join errors should remain fatal"); }; assert!(message.contains("tool task failed to receive")); } }