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 13:50:04 -07:00
committed by GitHub
parent 3e2ee1da3f
commit 8f2d6416ce
44 changed files with 716 additions and 113 deletions
@@ -3,6 +3,7 @@ use serde::Serialize;
use crate::payload::RawPayloadId;
use super::AgentPath;
use super::AgentThreadId;
use super::CodeCellId;
use super::CodexTurnId;
@@ -32,6 +33,9 @@ pub struct ConversationItem {
/// Codex channel for assistant/tool content, when the item is channel-specific.
pub channel: Option<ConversationChannel>,
pub kind: ConversationItemKind,
/// Routing metadata carried by a Responses `agent_message` item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_message: Option<AgentMessageMetadata>,
pub body: ConversationBody,
/// Protocol/model `call_id` for function/custom tool call and output items.
pub call_id: Option<ModelVisibleCallId>,
@@ -39,6 +43,15 @@ pub struct ConversationItem {
pub produced_by: Vec<ProducerRef>,
}
/// Sender and destination identities attached to a model-visible agent message.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentMessageMetadata {
/// Agent path that authored the message.
pub author: AgentPath,
/// Agent path that received the message.
pub recipient: AgentPath,
}
/// Model-visible role assigned to a conversation item.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -322,6 +322,7 @@ impl TraceReducer {
role: ConversationRole::Assistant,
channel: None,
kind: ConversationItemKind::CompactionMarker,
agent_message: None,
// The summary is a separate model/provider-visible item. Keep the marker body
// empty so transcript renderers cannot mistake the boundary for prompt content.
body: ConversationBody { parts: Vec::new() },
@@ -419,6 +420,7 @@ impl TraceReducer {
role: item.role,
channel: item.channel,
kind: item.kind,
agent_message: item.agent_message,
body: item.body,
call_id: item.call_id,
produced_by,
@@ -579,6 +581,7 @@ fn conversation_item_matches(
item.role == normalized.role
&& item.channel == normalized.channel
&& item.kind == normalized.kind
&& item.agent_message == normalized.agent_message
&& body_matches
&& item.call_id == normalized.call_id
}
@@ -1,9 +1,13 @@
//! Normalization from Responses-shaped JSON items into conversation item data.
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_protocol::models::AgentMessageInputContent;
use codex_protocol::models::ResponseItem;
use serde_json::Value;
use crate::model::AgentMessageMetadata;
use crate::model::ConversationBody;
use crate::model::ConversationChannel;
use crate::model::ConversationItemKind;
@@ -22,6 +26,7 @@ pub(super) struct NormalizedConversationItem {
pub(super) role: ConversationRole,
pub(super) channel: Option<ConversationChannel>,
pub(super) kind: ConversationItemKind,
pub(super) agent_message: Option<AgentMessageMetadata>,
pub(super) body: ConversationBody,
pub(super) call_id: Option<String>,
}
@@ -58,11 +63,13 @@ fn normalize_model_item(
};
match item_type {
"message" => normalize_message_item(item, raw_payload),
"agent_message" => normalize_agent_message_item(item, raw_payload),
"reasoning" => normalize_reasoning_item(item, raw_payload),
"function_call" => Ok(NormalizedConversationItem {
role: ConversationRole::Assistant,
channel: Some(ConversationChannel::Commentary),
kind: ConversationItemKind::FunctionCall,
agent_message: None,
body: raw_text_or_json_body(item.get("arguments"), raw_payload),
call_id: item
.get("call_id")
@@ -73,6 +80,7 @@ fn normalize_model_item(
role: ConversationRole::Tool,
channel: Some(ConversationChannel::Commentary),
kind: ConversationItemKind::FunctionCallOutput,
agent_message: None,
body: tool_output_body(item.get("output"), raw_payload),
call_id: item
.get("call_id")
@@ -83,6 +91,7 @@ fn normalize_model_item(
role: ConversationRole::Assistant,
channel: Some(ConversationChannel::Commentary),
kind: ConversationItemKind::CustomToolCall,
agent_message: None,
body: custom_tool_call_body(item, raw_payload),
call_id: item
.get("call_id")
@@ -93,6 +102,7 @@ fn normalize_model_item(
role: ConversationRole::Tool,
channel: Some(ConversationChannel::Commentary),
kind: ConversationItemKind::CustomToolCallOutput,
agent_message: None,
body: tool_output_body(item.get("output"), raw_payload),
call_id: item
.get("call_id")
@@ -104,6 +114,7 @@ fn normalize_model_item(
role: ConversationRole::Assistant,
channel: Some(ConversationChannel::Commentary),
kind: ConversationItemKind::FunctionCall,
agent_message: None,
body: json_body(item, raw_payload),
call_id: item
.get("call_id")
@@ -115,6 +126,7 @@ fn normalize_model_item(
role: ConversationRole::Tool,
channel: Some(ConversationChannel::Commentary),
kind: ConversationItemKind::FunctionCallOutput,
agent_message: None,
body: json_body(item, raw_payload),
call_id: item
.get("call_id")
@@ -126,6 +138,7 @@ fn normalize_model_item(
role: ConversationRole::Assistant,
channel: Some(ConversationChannel::Summary),
kind: ConversationItemKind::Message,
agent_message: None,
body: compaction_body(item, raw_payload)?,
call_id: None,
})
@@ -160,6 +173,7 @@ fn normalize_message_item(
.and_then(Value::as_str)
.and_then(channel_from_phase),
kind: ConversationItemKind::Message,
agent_message: None,
body: ConversationBody {
parts: content_parts(item.get("content"), raw_payload),
},
@@ -167,6 +181,49 @@ fn normalize_message_item(
})
}
fn normalize_agent_message_item(
item: &Value,
raw_payload: &RawPayloadRef,
) -> Result<NormalizedConversationItem> {
let raw_payload_id = &raw_payload.raw_payload_id;
let response_item =
serde_json::from_value::<ResponseItem>(item.clone()).with_context(|| {
format!("failed to parse agent_message item in payload {raw_payload_id}")
})?;
let ResponseItem::AgentMessage {
author,
recipient,
content,
} = response_item
else {
bail!("item in payload {raw_payload_id} was not an agent_message");
};
let parts = content
.into_iter()
.map(|content| match content {
AgentMessageInputContent::InputText { text } => ConversationPart::Text { text },
AgentMessageInputContent::EncryptedContent { encrypted_content } => {
ConversationPart::Encoded {
label: "encrypted_content".to_string(),
value: encrypted_content,
}
}
})
.collect::<Vec<_>>();
if parts.is_empty() {
bail!("agent_message item in payload {raw_payload_id} contained no content");
}
Ok(NormalizedConversationItem {
role: ConversationRole::Assistant,
channel: Some(ConversationChannel::Analysis),
kind: ConversationItemKind::Message,
agent_message: Some(AgentMessageMetadata { author, recipient }),
body: ConversationBody { parts },
call_id: None,
})
}
fn normalize_reasoning_item(
item: &Value,
raw_payload: &RawPayloadRef,
@@ -217,6 +274,7 @@ fn normalize_reasoning_item(
role: ConversationRole::Assistant,
channel: Some(ConversationChannel::Analysis),
kind: ConversationItemKind::Reasoning,
agent_message: None,
body: ConversationBody { parts },
call_id: None,
})
@@ -2,9 +2,12 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use crate::model::AgentMessageMetadata;
use crate::model::ConversationBody;
use crate::model::ConversationChannel;
use crate::model::ConversationItemKind;
use crate::model::ConversationPart;
use crate::model::ConversationRole;
use crate::model::ExecutionStatus;
use crate::model::ProducerRef;
use crate::model::ToolCallKind;
@@ -107,6 +110,90 @@ fn response_outputs_enter_thread_conversation_on_completion() -> anyhow::Result<
Ok(())
}
#[test]
fn agent_messages_preserve_routing_and_content() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let writer = create_started_writer(&temp)?;
start_turn(&writer, "turn-1")?;
let request = writer.write_json_payload(
RawPayloadKind::InferenceRequest,
&json!({
"input": [
{
"type": "agent_message",
"author": "/root/worker",
"recipient": "/root",
"content": [{"type": "input_text", "text": "done"}]
},
{
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
"content": [{
"type": "encrypted_content",
"encrypted_content": "encrypted-task"
}]
}
]
}),
)?;
append_inference_start(&writer, "inference-1", "turn-1", request)?;
let rollout = replay_bundle(temp.path())?;
let actual = rollout.inference_calls["inference-1"]
.request_item_ids
.iter()
.map(|item_id| {
let item = &rollout.conversation_items[item_id];
(
item.role.clone(),
item.channel.clone(),
item.kind.clone(),
item.agent_message.clone(),
item.body.clone(),
)
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![
(
ConversationRole::Assistant,
Some(ConversationChannel::Analysis),
ConversationItemKind::Message,
Some(AgentMessageMetadata {
author: "/root/worker".to_string(),
recipient: "/root".to_string(),
}),
ConversationBody {
parts: vec![ConversationPart::Text {
text: "done".to_string(),
}],
},
),
(
ConversationRole::Assistant,
Some(ConversationChannel::Analysis),
ConversationItemKind::Message,
Some(AgentMessageMetadata {
author: "/root".to_string(),
recipient: "/root/worker".to_string(),
}),
ConversationBody {
parts: vec![ConversationPart::Encoded {
label: "encrypted_content".to_string(),
value: "encrypted-task".to_string(),
}],
},
),
]
);
Ok(())
}
#[test]
fn later_full_request_reuses_prior_json_tool_call_by_position() -> anyhow::Result<()> {
let temp = TempDir::new()?;
@@ -33,6 +33,7 @@ pub(in crate::reducer) struct PendingAgentInteractionEdge {
pub(in crate::reducer) kind: InteractionEdgeKind,
pub(in crate::reducer) source: TraceAnchor,
pub(in crate::reducer) target_thread_id: String,
pub(in crate::reducer) message_author: String,
pub(in crate::reducer) message_content: String,
/// Spawn-only fallback for children that fail before their task message is model-visible.
pub(in crate::reducer) unresolved_spawn_thread_id: Option<String>,
@@ -254,6 +255,7 @@ impl TraceReducer {
format!("agent activity referenced unknown tool call {tool_call_id}")
})?;
let started_at_unix_ms = tool_call.execution.started_at_unix_ms;
let message_author = self.agent_path_for_thread(&tool_call.thread_id)?;
let message_content = self.agent_message_content_from_invocation(tool_call_id)?;
let carried_raw_payload_ids = self.agent_tool_payload_ids(tool_call_id)?;
self.queue_or_resolve_agent_interaction_edge(PendingAgentInteractionEdge {
@@ -263,6 +265,7 @@ impl TraceReducer {
tool_call_id: tool_call_id.to_string(),
},
target_thread_id,
message_author,
message_content,
unresolved_spawn_thread_id,
started_at_unix_ms,
@@ -354,6 +357,7 @@ impl TraceReducer {
let tool_call = &self.rollout.tool_calls[tool_call_id];
let child_thread_id = child_thread_id.to_string();
let edge_id = spawn_edge_id(&payload.sender_thread_id.to_string(), &child_thread_id);
let message_author = self.agent_path_for_thread(&tool_call.thread_id)?;
self.queue_or_resolve_agent_interaction_edge(PendingAgentInteractionEdge {
edge_id,
@@ -362,6 +366,7 @@ impl TraceReducer {
tool_call_id: tool_call_id.to_string(),
},
target_thread_id: child_thread_id.clone(),
message_author,
message_content: payload.prompt.clone(),
unresolved_spawn_thread_id: Some(child_thread_id),
started_at_unix_ms: tool_call.execution.started_at_unix_ms,
@@ -395,6 +400,7 @@ impl TraceReducer {
ended_at_unix_ms: Option<i64>,
) -> Result<()> {
let tool_call = &self.rollout.tool_calls[tool_call_id];
let message_author = self.agent_path_for_thread(&tool_call.thread_id)?;
self.queue_or_resolve_agent_interaction_edge(PendingAgentInteractionEdge {
edge_id: tool_edge_id(tool_call_id),
kind,
@@ -402,6 +408,7 @@ impl TraceReducer {
tool_call_id: tool_call_id.to_string(),
},
target_thread_id,
message_author,
message_content,
unresolved_spawn_thread_id: None,
started_at_unix_ms: tool_call.execution.started_at_unix_ms,
@@ -469,6 +476,7 @@ impl TraceReducer {
&mut self,
observed: ObservedAgentResultEdge,
) -> Result<()> {
let message_author = self.agent_path_for_thread(&observed.child_thread_id)?;
let source = if let Some(source_item_id) = self.latest_assistant_message_item_for_turn(
&observed.child_thread_id,
&observed.child_codex_turn_id,
@@ -492,6 +500,7 @@ impl TraceReducer {
kind: InteractionEdgeKind::AgentResult,
source,
target_thread_id: observed.parent_thread_id,
message_author,
message_content: observed.message,
unresolved_spawn_thread_id: None,
started_at_unix_ms: observed.wall_time_unix_ms,
@@ -508,14 +517,21 @@ impl TraceReducer {
&mut self,
item_id: &str,
) -> Result<()> {
let Some((thread_id, message_content)) = self.inter_agent_message_item(item_id) else {
if self.is_interaction_edge_target_item(item_id) {
return Ok(());
}
let Some((thread_id, message_author, message_content)) =
self.inter_agent_message_item(item_id)
else {
return Ok(());
};
let Some(pending_index) = self
.pending_agent_interaction_edges
.iter()
.position(|pending| {
pending.target_thread_id == thread_id && pending.message_content == message_content
pending.target_thread_id == thread_id
&& pending.message_author == message_author
&& pending.message_content == message_content
})
else {
return Ok(());
@@ -530,6 +546,7 @@ impl TraceReducer {
) -> Result<()> {
if let Some(item_id) = self.find_unlinked_inter_agent_message_item(
&pending.target_thread_id,
&pending.message_author,
&pending.message_content,
) {
return self.upsert_agent_interaction_edge_for_item(pending, item_id);
@@ -543,6 +560,7 @@ impl TraceReducer {
if existing.kind != pending.kind
|| existing.source != pending.source
|| existing.target_thread_id != pending.target_thread_id
|| existing.message_author != pending.message_author
|| existing.message_content != pending.message_content
|| existing.unresolved_spawn_thread_id != pending.unresolved_spawn_thread_id
{
@@ -661,6 +679,7 @@ impl TraceReducer {
fn find_unlinked_inter_agent_message_item(
&self,
thread_id: &str,
message_author: &str,
message_content: &str,
) -> Option<String> {
self.rollout
@@ -672,19 +691,30 @@ impl TraceReducer {
!self.is_interaction_edge_target_item(item_id)
&& self
.inter_agent_message_item(item_id)
.is_some_and(|(_, content)| content == message_content)
.is_some_and(|(_, author, content)| {
author == message_author && content == message_content
})
})
.cloned()
}
fn inter_agent_message_item(&self, item_id: &str) -> Option<(String, String)> {
fn inter_agent_message_item(&self, item_id: &str) -> Option<(String, String, String)> {
let item = self.rollout.conversation_items.get(item_id)?;
let (recipient_agent_path, message_content) = inter_agent_message_fields(item)?;
let (author_agent_path, recipient_agent_path, message_content) =
inter_agent_message_fields(item)?;
let thread = self.rollout.threads.get(&item.thread_id)?;
if recipient_agent_path != thread.agent_path {
return None;
}
Some((item.thread_id.clone(), message_content))
Some((item.thread_id.clone(), author_agent_path, message_content))
}
fn agent_path_for_thread(&self, thread_id: &str) -> Result<String> {
self.rollout
.threads
.get(thread_id)
.map(|thread| thread.agent_path.clone())
.with_context(|| format!("agent edge referenced unknown thread {thread_id}"))
}
fn is_interaction_edge_target_item(&self, item_id: &str) -> bool {
@@ -707,6 +737,7 @@ impl TraceReducer {
&& item.codex_turn_id.as_deref() == Some(codex_turn_id)
&& item.role == ConversationRole::Assistant
&& item.kind == ConversationItemKind::Message
&& item.agent_message.is_none()
})
.max_by_key(|item| item.first_seen_at_unix_ms)
.map(|item| item.item_id.clone())
@@ -735,19 +766,36 @@ fn push_unique(items: &mut Vec<String>, item: &str) {
}
}
fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String)> {
// Multi-agent v2 injects mailbox deliveries as assistant messages whose
// text is serialized `InterAgentCommunication`. Treat only that exact
// transport shape as an edge target; ordinary assistant JSON must not be
// mistaken for cross-thread delivery.
fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String, String)> {
if item.role != ConversationRole::Assistant || item.kind != ConversationItemKind::Message {
return None;
}
if let Some(agent_message) = &item.agent_message {
let [content] = item.body.parts.as_slice() else {
return None;
};
let message_content = match content {
ConversationPart::Text { text } => text,
ConversationPart::Encoded { label, value } if label == "encrypted_content" => value,
_ => return None,
};
return Some((
agent_message.author.clone(),
agent_message.recipient.clone(),
message_content.clone(),
));
}
// Older traces store multi-agent v2 deliveries as assistant messages whose
// text is serialized `InterAgentCommunication`. Treat only that exact
// transport shape as an edge target; ordinary assistant JSON must not be
// mistaken for cross-thread delivery.
let [ConversationPart::Text { text }] = item.body.parts.as_slice() else {
return None;
};
let communication = serde_json::from_str::<InterAgentCommunication>(text).ok()?;
Some((
communication.author.to_string(),
communication.recipient.to_string(),
communication
.encrypted_content
@@ -246,18 +246,17 @@ fn sub_agent_started_activity_creates_spawn_edge() -> anyhow::Result<()> {
)?;
start_thread(&writer, child_thread_id, "/root/reviewer")?;
start_turn_for_thread(&writer, child_thread_id, "turn-child-1")?;
let delivered = inter_agent_message(
"/root",
"/root/reviewer",
"review this",
/*trigger_turn*/ true,
);
append_inference_request(
&writer,
child_thread_id,
"turn-child-1",
"inference-child-1",
vec![message("assistant", &delivered)],
vec![json!({
"type": "agent_message",
"author": "/root",
"recipient": "/root/reviewer",
"content": [{"type": "input_text", "text": "review this"}]
})],
)?;
let replayed = replay_bundle(temp.path())?;
@@ -773,10 +772,9 @@ fn agent_result_edge_falls_back_to_child_thread_without_result_message() -> anyh
let temp = TempDir::new()?;
let writer = create_started_agent_writer(&temp)?;
// The child thread and turn exist, but there is intentionally no completed
// assistant message for this turn. Failed child tasks can still notify the
// parent through AgentStatus, so the result edge must not require a final
// transcript item from the child.
// The child received its task but produced no assistant output. Failed
// child tasks can still notify the parent through AgentStatus, so the
// inbound task must not be mistaken for the child's result.
start_thread(
&writer,
"019d0000-0000-7000-8000-000000000002",
@@ -787,6 +785,18 @@ fn agent_result_edge_falls_back_to_child_thread_without_result_message() -> anyh
"019d0000-0000-7000-8000-000000000002",
"turn-child-1",
)?;
append_inference_request(
&writer,
"019d0000-0000-7000-8000-000000000002",
"turn-child-1",
"inference-child-1",
vec![json!({
"type": "agent_message",
"author": "/root",
"recipient": "/root/child",
"content": [{"type": "input_text", "text": "do the task"}]
})],
)?;
let notification = r#"<subagent_notification>{"agent_path":"/root/child","status":{"failed":"boom"}}</subagent_notification>"#;
let carried_payload = writer.write_json_payload(