From 40f01fbe08694bbca9814e87dd04febce77b5a61 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 15 Sep 2026 11:03:23 +0000 Subject: [PATCH] Move spawned-agent interruption rules into `AgentControl` (#45676) ## What changed Extract V2 interruption validation and dispatch into `AgentControl::interrupt_spawned_agent`, returning the agent path and previous status with typed validation and runtime errors. Update the `interrupt_agent` tool handler to delegate to it while retaining tool error mapping and activity emission. Preserve rejection of root and self targets and successful handling of unloaded or already-dead runtimes without reloading them. GitOrigin-RevId: 893bbc4f464596d0fee032a71702a4a4f6becf5c --- codex-rs/core/src/agent/control.rs | 3 + codex-rs/core/src/agent/control/interrupt.rs | 68 +++++++++++++++++++ .../src/tools/handlers/multi_agents_v2.rs | 1 - .../multi_agents_v2/interrupt_agent.rs | 59 ++++------------ 4 files changed, 86 insertions(+), 45 deletions(-) create mode 100644 codex-rs/core/src/agent/control/interrupt.rs diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 926afbe79e..33bd450b4c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -72,10 +72,13 @@ pub(crate) use self::delivery::MessageDeliveryError; pub(crate) use self::delivery::MessageDeliveryMode; pub(crate) use self::execution::AgentExecutionGuard; use self::execution::AgentExecutionLimiter; +pub(crate) use self::interrupt::AgentInterruptError; +pub(crate) use self::interrupt::AgentInterruptOutcome; use self::residency::V2Residency; mod delivery; mod execution; +mod interrupt; mod legacy; mod residency; mod service_tier; diff --git a/codex-rs/core/src/agent/control/interrupt.rs b/codex-rs/core/src/agent/control/interrupt.rs new file mode 100644 index 0000000000..975a592003 --- /dev/null +++ b/codex-rs/core/src/agent/control/interrupt.rs @@ -0,0 +1,68 @@ +//! Applies V2 interruption rules to a registered agent without loading its runtime. +//! +//! Root and self targets are rejected. An unloaded or already-dead runtime is a successful +//! interruption; this operation never reloads it. + +use super::AgentControl; +use crate::agent::AgentStatus; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; +use codex_protocol::error::CodexErrorDetails; + +pub(crate) struct AgentInterruptOutcome { + pub(crate) agent_path: AgentPath, + pub(crate) previous_status: AgentStatus, +} + +/// Keeps request validation distinct from runtime failures for the tool adapter's error mapping. +#[derive(Debug)] +pub(crate) enum AgentInterruptError { + InvalidRequest(String), + Agent(CodexErr), +} + +impl AgentControl { + /// Interrupts a spawned agent's current task, preserving the status observed before dispatch. + pub(crate) async fn interrupt_spawned_agent( + &self, + caller: ThreadId, + target: ThreadId, + ) -> Result { + let receiver_agent = self + .ensure_agent_known(target) + .map_err(AgentInterruptError::Agent)?; + if receiver_agent + .agent_path + .as_ref() + .is_some_and(AgentPath::is_root) + { + return Err(AgentInterruptError::InvalidRequest( + "root is not a spawned agent".to_string(), + )); + } + if target == caller { + return Err(AgentInterruptError::InvalidRequest( + "an agent cannot interrupt itself; return your result and let the parent interrupt you if needed" + .to_string(), + )); + } + let agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + AgentInterruptError::InvalidRequest("target agent is missing an agent_path".to_string()) + })?; + let previous_status = self.get_status(target).await; + match self.interrupt_agent(target).await { + Ok(_) => {} + Err(err) + if matches!( + err.details(), + CodexErrorDetails::ThreadNotFound(_) | CodexErrorDetails::InternalAgentDied + ) => {} + Err(err) => return Err(AgentInterruptError::Agent(err)), + } + Ok(AgentInterruptOutcome { + agent_path, + previous_status, + }) + } +} 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 9f4176617f..40aecee0a9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -12,7 +12,6 @@ use crate::tools::handlers::multi_agents_common::*; use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -use codex_protocol::AgentPath; use codex_protocol::items::CollabAgentTool; use codex_protocol::items::CollabAgentToolCallItem; use codex_protocol::items::CollabAgentToolCallStatus; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs index b6eb267d9c..db16be7396 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs @@ -1,7 +1,8 @@ use super::analytics::ToolCallAnalytics; use super::*; +use crate::agent::control::AgentInterruptError; +use crate::agent::control::AgentInterruptOutcome; use crate::tools::handlers::multi_agents_spec::create_interrupt_agent_tool_v2; -use codex_protocol::error::CodexErrorDetails; use codex_tools::ToolSpec; pub(crate) struct Handler; @@ -44,63 +45,33 @@ async fn handle_interrupt_agent( let args: InterruptAgentArgs = parse_arguments(&arguments)?; let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; analytics.set_receiver(agent_id); - let receiver_agent = session + let AgentInterruptOutcome { + agent_path, + previous_status, + } = session .services .agent_control - .ensure_agent_known(agent_id) - .map_err(|err| collab_agent_error(agent_id, err))?; - if receiver_agent - .agent_path - .as_ref() - .is_some_and(AgentPath::is_root) - { - return Err(FunctionCallError::RespondToModel( - "root is not a spawned agent".to_string(), - )); - } - if agent_id == session.thread_id { - return Err(FunctionCallError::RespondToModel( - "an agent cannot interrupt itself; return your result and let the parent interrupt you if needed" - .to_string(), - )); - } - let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { - FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) - })?; - let status = session.services.agent_control.get_status(agent_id).await; - let result = match session - .services - .agent_control - .interrupt_agent(agent_id) + .interrupt_spawned_agent(session.thread_id, agent_id) .await - { - Ok(_) => Ok(()), - Err(err) - if matches!( - err.details(), - CodexErrorDetails::ThreadNotFound(_) | CodexErrorDetails::InternalAgentDied - ) => - { - Ok(()) - } - Err(err) => Err(collab_agent_error(agent_id, err)), - }; - result?; + .map_err(|err| match err { + AgentInterruptError::InvalidRequest(message) => { + FunctionCallError::RespondToModel(message) + } + AgentInterruptError::Agent(err) => collab_agent_error(agent_id, err), + })?; emit_sub_agent_activity( &session, &turn, SubAgentActivityItem { id: call_id, agent_thread_id: agent_id, - agent_path: receiver_agent_path, + agent_path, kind: SubAgentActivityKind::Interrupted, }, ) .await; - Ok(InterruptAgentResult { - previous_status: status, - }) + Ok(InterruptAgentResult { previous_status }) } impl CoreToolRuntime for Handler {