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
This commit is contained in:
jif
2026-09-15 11:03:23 +00:00
committed by copyberry
parent a113f3e063
commit 40f01fbe08
4 changed files with 86 additions and 45 deletions

View File

@@ -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;

View File

@@ -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<AgentInterruptOutcome, AgentInterruptError> {
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,
})
}
}

View File

@@ -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;

View File

@@ -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 {