Move child completion routing into the agent controller (#46552)

## What changed

Have `Session` capture terminal child state in an `AgentTurnOutcome` and delegate delivery to `LocalAgentControl`. Move parent notification and completion activity routing into `agent/control/completion.rs`, so routing no longer takes the child's live `TurnContext`.

Preserve best-effort, queue-only parent delivery, completion activity for the initiating agent, and result tracing only after successful delivery.

GitOrigin-RevId: e06871d92c9ca8660de29f50356cf7c37f7e5199
This commit is contained in:
jif
2026-09-19 01:19:55 +00:00
committed by copyberry
parent bfb8daa3c9
commit 7a15548c6a
3 changed files with 149 additions and 127 deletions
+1
View File
@@ -70,6 +70,7 @@ pub(crate) use self::interrupt::AgentInterruptError;
pub(crate) use self::interrupt::AgentInterruptOutcome;
use self::residency::V2Residency;
mod completion;
mod delivery;
mod execution;
mod interrupt;
@@ -0,0 +1,131 @@
//! Delivers terminal child results and completion activity to the agent tree.
//!
//! Sessions capture terminal state; the controller owns routing and queue-only delivery.
//! Delivery remains best effort, with tracing recorded only after the parent accepts it.
use super::LocalAgentControl;
use crate::TurnStartOptions;
use crate::agent::api::AgentTurnOutcome;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::session_prefix::format_inter_agent_completion_message;
use codex_protocol::AgentPath;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentActivityKind;
use codex_protocol::protocol::SubAgentSource;
use codex_rollout_trace::AgentResultTracePayload;
use codex_rollout_trace::ThreadTraceContext;
use tracing::debug;
impl LocalAgentControl {
/// Routes a captured terminal outcome without retaining the child's live turn context.
pub(crate) async fn notify_parent_of_terminal_turn(
&self,
outcome: AgentTurnOutcome,
trace: &ThreadTraceContext,
) {
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
agent_path: Some(child_agent_path),
..
}) = &outcome.source
else {
return;
};
let parent_thread_id = *parent_thread_id;
let status = outcome.status;
let Some(parent_agent_path) = child_agent_path
.as_str()
.rsplit_once('/')
.and_then(|(parent, _)| AgentPath::try_from(parent).ok())
else {
return;
};
if matches!(status, AgentStatus::Completed(_))
&& let Some(parent_turn_id) = outcome.parent_turn_id
{
let initiating_thread_id = match outcome.initiating_agent_path.as_ref() {
Some(initiating_agent_path) if initiating_agent_path != &parent_agent_path => {
self.resolve_agent_reference(
outcome.thread_id,
&outcome.source,
initiating_agent_path.as_str(),
)
.await
.inspect_err(|err| {
debug!(
"failed to resolve completed activity initiator {initiating_agent_path}: {err}"
);
})
.ok()
}
_ => Some(parent_thread_id),
};
if let Some(initiating_thread_id) = initiating_thread_id
&& let Err(err) = self
.emit_sub_agent_activity(
initiating_thread_id,
parent_turn_id,
SubAgentActivityItem {
id: format!("subagent-completed-{}", outcome.turn_id),
kind: SubAgentActivityKind::Completed,
agent_thread_id: outcome.thread_id,
agent_path: child_agent_path.clone(),
},
)
.await
{
debug!(
"failed to emit completed activity to initiating thread {initiating_thread_id}: {err}"
);
}
}
let Some(message) = format_inter_agent_completion_message(
parent_agent_path.clone(),
child_agent_path.clone(),
&status,
) else {
return;
};
// `communication` owns the message. Keep a second copy only when the
// recorder will actually need it after parent delivery succeeds.
let trace_message = trace.is_enabled().then(|| message.clone());
let communication = InterAgentCommunication::new(
child_agent_path.clone(),
parent_agent_path,
Vec::new(),
message,
/*trigger_turn*/ false,
);
let context =
AgentCommunicationContext::new(AgentCommunicationKind::Result, outcome.thread_id);
if let Err(err) = self
.send_inter_agent_communication(
parent_thread_id,
communication,
context,
TurnStartOptions::default(),
)
.await
{
debug!("failed to notify parent thread {parent_thread_id}: {err}");
return;
}
if let Some(message) = trace_message {
trace.record_agent_result_interaction(
outcome.turn_id.as_str(),
parent_thread_id,
&AgentResultTracePayload {
child_agent_path: child_agent_path.as_str(),
message: &message,
status: &status,
},
);
}
}
}
+17 -127
View File
@@ -13,9 +13,8 @@ use std::time::UNIX_EPOCH;
use crate::agent::AgentStatus;
use crate::agent::LocalAgentControl;
use crate::agent::agent_status_from_event;
use crate::agent::api::AgentTurnOutcome;
use crate::agent::status::is_final;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::agents_md_manager::SessionInstructions;
use crate::attestation::AttestationProvider;
use crate::compact;
@@ -47,7 +46,6 @@ use crate::session::step_context::StepContext;
use crate::session::step_settings::ResolvedStepSettings;
use crate::session::step_settings::StepSettings;
use crate::session::turn_context::TurnEnvironment;
use crate::session_prefix::format_inter_agent_completion_message;
use crate::shell_snapshot::SnapshotCredentialBrokerState;
use crate::skills_load_input_from_config;
use crate::stream_events_utils::mark_thread_memory_mode_polluted_if_external_context;
@@ -113,7 +111,6 @@ use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::items::EnteredReviewModeItem;
use codex_protocol::items::ModelInvocationContext;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::models::ActivePermissionProfile;
@@ -141,7 +138,6 @@ use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentActivityKind;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadSettingsOverrides;
@@ -163,7 +159,6 @@ use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_rmcp_client::ElicitationResponse;
use codex_rollout::state_db;
use codex_rollout_trace::AgentResultTracePayload;
use codex_rollout_trace::ThreadStartedTraceMetadata;
use codex_rollout_trace::ThreadTraceContext;
use codex_sandboxing::SandboxType;
@@ -2399,8 +2394,7 @@ impl Session {
}
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
agent_path: Some(child_agent_path),
agent_path: Some(_),
..
}) = &turn_context.session_source
else {
@@ -2424,127 +2418,23 @@ impl Session {
return;
}
self.forward_child_completion_to_parent(
turn_context,
*parent_thread_id,
child_agent_path,
status,
)
.await;
}
/// Sends the standard completion envelope from a spawned MultiAgentV2 child to its parent.
async fn forward_child_completion_to_parent(
&self,
turn_context: &TurnContext,
parent_thread_id: ThreadId,
child_agent_path: &codex_protocol::AgentPath,
status: AgentStatus,
) {
let Some(parent_agent_path) = child_agent_path
.as_str()
.rsplit_once('/')
.and_then(|(parent, _)| codex_protocol::AgentPath::try_from(parent).ok())
else {
return;
};
if matches!(status, AgentStatus::Completed(_))
&& let Some(parent_turn_id) = turn_context.turn_metadata_state.parent_turn_id()
{
let initiating_thread_id = match turn_context
.turn_metadata_state
.initiating_agent_path()
{
Some(initiating_agent_path) if initiating_agent_path != &parent_agent_path => self
.services
.agent_control
.resolve_agent_reference(
self.thread_id,
&turn_context.session_source,
initiating_agent_path.as_str(),
)
.await
.inspect_err(|err| {
debug!(
"failed to resolve completed activity initiator {initiating_agent_path}: {err}"
);
})
.ok(),
_ => Some(parent_thread_id),
};
if let Some(initiating_thread_id) = initiating_thread_id
&& let Err(err) = self
.services
.agent_control
.emit_sub_agent_activity(
initiating_thread_id,
parent_turn_id,
SubAgentActivityItem {
id: format!("subagent-completed-{}", turn_context.sub_id),
kind: SubAgentActivityKind::Completed,
agent_thread_id: self.thread_id,
agent_path: child_agent_path.clone(),
},
)
.await
{
debug!(
"failed to emit completed activity to initiating thread {initiating_thread_id}: {err}"
);
}
}
let Some(message) = format_inter_agent_completion_message(
parent_agent_path.clone(),
child_agent_path.clone(),
&status,
) else {
return;
};
// `communication` owns the message. Keep a second copy only when the
// recorder will actually need it after parent delivery succeeds.
let trace_message = self
.services
.rollout_thread_trace
.is_enabled()
.then(|| message.clone());
let communication = InterAgentCommunication::new(
child_agent_path.clone(),
parent_agent_path,
Vec::new(),
message,
/*trigger_turn*/ false,
);
let context =
AgentCommunicationContext::new(AgentCommunicationKind::Result, self.thread_id);
if let Err(err) = self
.services
self.services
.agent_control
.send_inter_agent_communication(
parent_thread_id,
communication,
context,
TurnStartOptions::default(),
.notify_parent_of_terminal_turn(
AgentTurnOutcome {
thread_id: self.thread_id,
turn_id: turn_context.sub_id.clone(),
source: turn_context.session_source.clone(),
parent_turn_id: turn_context.turn_metadata_state.parent_turn_id(),
initiating_agent_path: turn_context
.turn_metadata_state
.initiating_agent_path()
.cloned(),
status,
},
&self.services.rollout_thread_trace,
)
.await
{
debug!("failed to notify parent thread {parent_thread_id}: {err}");
return;
}
if let Some(message) = trace_message {
self.services
.rollout_thread_trace
.record_agent_result_interaction(
turn_context.sub_id.as_str(),
parent_thread_id,
&AgentResultTracePayload {
child_agent_path: child_agent_path.as_str(),
message: &message,
status: &status,
},
);
}
.await;
}
async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) {