Support plaintext agent messages (#27830)

## Why

Multi-agent v2 `send_message` deliveries already reach the receiving
model as typed `agent_message` items with encrypted content.
Child-completion notifications are generated by Codex itself, so their
content is plaintext and previously fell back to a serialized JSON
envelope inside an assistant message.

With plaintext `input_text` supported for `agent_message`, both delivery
paths can use the same model-visible type while preserving explicit
author and recipient metadata.

## What changed

- add plaintext `input_text` support to `AgentMessageInputContent` and
regenerate the affected app-server schemas
- preserve `InterAgentCommunication` as structured mailbox input instead
of converting it to assistant text
- record delivered communications as typed `agent_message` history items
- persist a dedicated rollout item so local delivery metadata such as
`trigger_turn` remains available without leaking into the Responses
request
- reconstruct typed agent messages on resume and preserve fork-turn
truncation behavior
- remove request-time assistant-content parsing
- preserve plaintext and encrypted inter-agent deliveries in stage-one
memory inputs
- normalize and link plaintext and encrypted agent messages in rollout
traces without treating inbound messages as child results
- cover the real MultiAgent V2 child-completion path end to end with
deterministic mailbox synchronization

## Verification

- `just test -p codex-core
plaintext_multi_agent_v2_completion_sends_agent_message`
- `just test -p codex-core input_queue_drains_mailbox_in_delivery_order
record_initial_history_reconstructs_typed_inter_agent_message
fork_turn_positions_use_inter_agent_delivery_metadata`
- `just test -p codex-memories-write
serializes_inter_agent_communications_for_memory`
- `just test -p codex-rollout-trace
agent_messages_preserve_routing_and_content
sub_agent_started_activity_creates_spawn_edge`
- `just test -p codex-rollout-trace
agent_result_edge_falls_back_to_child_thread_without_result_message`
- `just test -p codex-protocol -p codex-rollout -p
codex-app-server-protocol`
This commit is contained in:
jif
2026-06-12 21:50:04 +01:00
committed by GitHub
Unverified
parent 3e2ee1da3f
commit 8f2d6416ce
44 changed files with 716 additions and 113 deletions
+1 -1
View File
@@ -273,7 +273,7 @@ pub(super) async fn user_input_or_turn_inner(
}
}
/// Records an inter-agent assistant envelope, then lets the shared pending-work scheduler
/// Queues an inter-agent message, then lets the shared pending-work scheduler
/// decide whether an idle session should start a regular turn.
pub async fn inter_agent_communication(
sess: &Arc<Session>,
+7 -10
View File
@@ -16,6 +16,7 @@ pub(crate) enum TurnInput {
client_id: Option<String>,
},
ResponseItem(ResponseItem),
InterAgentCommunication(InterAgentCommunication),
}
/// Turn-local pending input storage owned by the input queue flow.
@@ -70,12 +71,12 @@ impl InputQueue {
.any(|mail| mail.trigger_turn)
}
pub(crate) async fn drain_mailbox_input_items(&self) -> Vec<ResponseItem> {
pub(crate) async fn drain_mailbox_input_items(&self) -> Vec<TurnInput> {
self.mailbox_pending_mails
.lock()
.await
.drain(..)
.map(|mail| ResponseItem::from(mail.to_response_input_item()))
.map(TurnInput::InterAgentCommunication)
.collect()
}
@@ -189,11 +190,7 @@ impl InputQueue {
if !accepts_mailbox_delivery {
return pending_input;
}
let mailbox_items = self
.drain_mailbox_input_items()
.await
.into_iter()
.map(TurnInput::ResponseItem);
let mailbox_items = self.drain_mailbox_input_items().await.into_iter();
if pending_input.is_empty() {
mailbox_items.collect()
} else {
@@ -290,7 +287,7 @@ mod tests {
AgentPath::try_from("/root/worker").expect("agent path"),
AgentPath::root(),
"two",
/*trigger_turn*/ false,
/*trigger_turn*/ true,
);
input_queue
@@ -303,8 +300,8 @@ mod tests {
assert_eq!(
input_queue.drain_mailbox_input_items().await,
vec![
ResponseItem::from(mail_one.to_response_input_item()),
ResponseItem::from(mail_two.to_response_input_item())
TurnInput::InterAgentCommunication(mail_one),
TurnInput::InterAgentCommunication(mail_two)
]
);
assert!(!input_queue.has_pending_mailbox_items().await);
+20
View File
@@ -2659,6 +2659,26 @@ impl Session {
self.send_raw_response_items(turn_context, items).await;
}
pub(crate) async fn record_inter_agent_communication(
&self,
turn_context: &TurnContext,
communication: InterAgentCommunication,
) {
let response_item = communication.to_model_input_item();
let items = self.prepare_conversation_items_for_history(
turn_context,
std::slice::from_ref(&response_item),
);
let items = items.as_ref();
{
let mut state = self.state.lock().await;
state.record_items(items.iter(), turn_context.truncation_policy);
}
self.persist_rollout_items(&[RolloutItem::InterAgentCommunication(communication)])
.await;
self.send_raw_response_items(turn_context, items).await;
}
async fn maybe_warn_on_server_model_mismatch(
self: &Arc<Self>,
turn_context: &Arc<TurnContext>,
@@ -220,6 +220,11 @@ impl Session {
active_segment.get_or_insert_with(ActiveReplaySegment::default);
active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item);
}
RolloutItem::InterAgentCommunication(_) => {
let active_segment =
active_segment.get_or_insert_with(ActiveReplaySegment::default);
active_segment.counts_as_user_turn = true;
}
RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {}
}
@@ -269,6 +274,13 @@ impl Session {
turn_context.truncation_policy,
);
}
RolloutItem::InterAgentCommunication(communication) => {
let response_item = communication.to_model_input_item();
history.record_items(
std::iter::once(&response_item),
turn_context.truncation_policy,
);
}
RolloutItem::Compacted(compacted) => {
if let Some(replacement_history) = &compacted.replacement_history {
// This should actually never happen, because the reverse loop above (to build rollout_suffix)
@@ -52,6 +52,31 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem {
}
}
#[tokio::test]
async fn record_initial_history_reconstructs_typed_inter_agent_message() {
let (session, _turn_context) = make_session_and_context().await;
let communication = InterAgentCommunication::new(
AgentPath::root().join("worker").expect("worker path"),
AgentPath::root(),
Vec::new(),
"child done".to_string(),
/*trigger_turn*/ false,
);
session
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
conversation_id: ThreadId::default(),
history: vec![RolloutItem::InterAgentCommunication(communication.clone())],
rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")),
}))
.await;
assert_eq!(
session.state.lock().await.clone_history().raw_items(),
&[communication.to_model_input_item()]
);
}
#[tokio::test]
async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings()
{
+4 -8
View File
@@ -9222,9 +9222,7 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
assert_eq!(
sess.input_queue.get_pending_input(&sess.active_turn).await,
vec![TurnInput::ResponseItem(ResponseItem::from(
communication.to_response_input_item()
))],
vec![TurnInput::InterAgentCommunication(communication)],
);
}
@@ -9313,7 +9311,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
}],
client_id: None
},
TurnInput::ResponseItem(ResponseItem::from(communication.to_response_input_item())),
TurnInput::InterAgentCommunication(communication),
],
);
}
@@ -9371,7 +9369,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
}],
client_id: None
},
TurnInput::ResponseItem(ResponseItem::from(communication.to_response_input_item())),
TurnInput::InterAgentCommunication(communication),
],
);
}
@@ -9426,9 +9424,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
assert!(output.tool_future.is_some());
assert_eq!(
sess.input_queue.get_pending_input(&sess.active_turn).await,
vec![TurnInput::ResponseItem(ResponseItem::from(
communication.to_response_input_item()
))],
vec![TurnInput::InterAgentCommunication(communication)],
);
}
+2 -2
View File
@@ -466,7 +466,7 @@ async fn build_skills_and_plugins(
.iter()
.filter_map(|item| match item {
TurnInput::UserInput { content, .. } => Some(content.as_slice()),
TurnInput::ResponseItem(_) => None,
TurnInput::ResponseItem(_) | TurnInput::InterAgentCommunication(_) => None,
})
.flatten()
.cloned()
@@ -685,7 +685,7 @@ async fn track_turn_resolved_config_analytics(
.iter()
.filter_map(|item| match item {
TurnInput::UserInput { content, .. } => Some(content.as_slice()),
TurnInput::ResponseItem(_) => None,
TurnInput::ResponseItem(_) | TurnInput::InterAgentCommunication(_) => None,
})
.flatten()
.filter(|item| {