feat: render typed envelopes for multi-agent v2 messages (#28368)

## Why

Multi-agent v2 messages need a consistent, model-visible envelope that
identifies what kind of interaction occurred, who sent it, and which
agent it targets. Previously, encrypted deliveries exposed only
`encrypted_content`, while child completion used the legacy
`<subagent_notification>` shape. That meant the client could not
consistently present `NEW_TASK`, `MESSAGE`, and `FINAL_ANSWER` using the
same format.

This change adds the routing envelope as plaintext while keeping task
and message payloads encrypted. No new Responses API field is required:
an encrypted delivery is represented as an `input_text` header
immediately followed by its existing `encrypted_content` item.

Every envelope now follows this shape:

```text
Message Type: <NEW_TASK | MESSAGE | FINAL_ANSWER>
Task name: <recipient agent path>
Sender: <author agent path>
Payload:
<message payload>
```

## Message types

### `NEW_TASK`

`NEW_TASK` is used when the recipient should begin a new turn, including
an initial `spawn_agent` task and a later `followup_task`.

For a root agent spawning `/root/worker`, the request contains a
plaintext envelope followed by the encrypted task:

```json
{
  "type": "agent_message",
  "author": "/root",
  "recipient": "/root/worker",
  "content": [
    {
      "type": "input_text",
      "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n"
    },
    {
      "type": "encrypted_content",
      "encrypted_content": "<encrypted task payload>"
    }
  ]
}
```

Conceptually, the model receives:

```text
Message Type: NEW_TASK
Task name: /root/worker
Sender: /root
Payload:
Review the authentication changes and report any regressions.
```

### `MESSAGE`

`MESSAGE` is used for a queued `send_message` delivery. It communicates
with an existing agent without starting a new turn.

For `/root/worker` reporting progress to the root agent, the request
contains:

```json
{
  "type": "agent_message",
  "author": "/root/worker",
  "recipient": "/root",
  "content": [
    {
      "type": "input_text",
      "text": "Message Type: MESSAGE\nTask name: /root\nSender: /root/worker\nPayload:\n"
    },
    {
      "type": "encrypted_content",
      "encrypted_content": "<encrypted message payload>"
    }
  ]
}
```

Conceptually, the model receives:

```text
Message Type: MESSAGE
Task name: /root
Sender: /root/worker
Payload:
The protocol tests pass; I am checking the resume path now.
```

### `FINAL_ANSWER`

`FINAL_ANSWER` is emitted when a child agent reaches a terminal state
and reports its result to its parent. Completion payloads are already
available locally, so the complete envelope is represented as plaintext
rather than as a plaintext header plus encrypted content.

For `/root/worker` completing work for the root agent, the request
contains:

```json
{
  "type": "agent_message",
  "author": "/root/worker",
  "recipient": "/root",
  "content": [
    {
      "type": "input_text",
      "text": "Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/worker\nPayload:\nNo regressions found."
    }
  ]
}
```

The model-visible form is:

```text
Message Type: FINAL_ANSWER
Task name: /root
Sender: /root/worker
Payload:
No regressions found.
```

Errored, shut down, and missing agents also use `FINAL_ANSWER`, with a
terminal-status description in the payload.

## What changed

- Render `NEW_TASK` or `MESSAGE` in
`InterAgentCommunication::to_model_input_item`, based on whether the
encrypted delivery starts a turn.
- Replace the multi-agent v2 `<subagent_notification>` completion
payload with a model-visible `FINAL_ANSWER` envelope.
- Document `Task name`, `Sender`, and `Payload` consistently in the
multi-agent developer instructions.
- Prevent local-only history projections from treating an encrypted
message's plaintext header as the complete assistant message.
- Preserve rollout-trace interaction edges when an agent message
contains both plaintext and encrypted content.

Legacy multi-agent behavior remains unchanged.

## Verification

- `just test -p codex-protocol`
- `just test -p codex-rollout-trace`
- `just test -p codex-web-search-extension`
- `just test -p codex-core
encrypted_multi_agent_v2_spawn_sends_agent_message_to_child`
- `just test -p codex-core
plaintext_multi_agent_v2_completion_sends_agent_message`
- `just test -p codex-core
multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn`
- `just test -p codex-core
multi_agent_v2_completion_queues_message_for_direct_parent`
This commit is contained in:
jif
2026-06-16 10:46:59 +01:00
committed by GitHub
Unverified
parent 352d2fed1f
commit 5b22a8e5b1
16 changed files with 215 additions and 71 deletions
+9 -1
View File
@@ -8,6 +8,7 @@ use crate::codex_thread::ThreadConfigSnapshot;
use crate::config::Config;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::session::emit_subagent_session_started;
use crate::session_prefix::format_inter_agent_completion_message;
use crate::session_prefix::format_subagent_context_line;
use crate::session_prefix::format_subagent_notification_message;
use crate::thread_manager::ResumeThreadWithHistoryOptions;
@@ -434,7 +435,6 @@ impl AgentControl {
return;
};
let child_thread = state.get_thread(child_thread_id).await.ok();
let message = format_subagent_notification_message(child_reference.as_str(), &status);
let child_uses_multi_agent_v2 = match child_thread.as_ref() {
Some(child_thread) => {
child_thread.multi_agent_version() == Some(MultiAgentVersion::V2)
@@ -452,6 +452,13 @@ impl AgentControl {
else {
return;
};
let Some(message) = format_inter_agent_completion_message(
parent_agent_path.clone(),
child_agent_path.clone(),
&status,
) else {
return;
};
let communication = InterAgentCommunication::new(
child_agent_path,
parent_agent_path,
@@ -464,6 +471,7 @@ impl AgentControl {
.await;
return;
}
let message = format_subagent_notification_message(child_reference.as_str(), &status);
let Ok(parent_thread) = state.get_thread(parent_thread_id).await else {
return;
};
+5 -3
View File
@@ -2000,10 +2000,12 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
)
.await;
let expected_message = crate::session_prefix::format_subagent_notification_message(
tester_path.as_str(),
let expected_message = crate::session_prefix::format_inter_agent_completion_message(
worker_path.clone(),
tester_path.clone(),
&AgentStatus::Completed(Some("done".to_string())),
);
)
.expect("completed status should render");
let expected = (
worker_thread_id,
Op::InterAgentCommunication {
+2 -1
View File
@@ -210,6 +210,7 @@ You can decide how much context you want to propagate to your sub-agents with th
You will receive messages in the analysis channel in the form:
```
Message Type: MESSAGE | FINAL_ANSWER
Task name: <recipient>
Sender: <author>
Payload:
<payload text>
@@ -228,7 +229,7 @@ When you provide a response in the final channel, that content is immediately de
You will receive messages in the analysis channel in the form:
```
Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER
Task name: <recipient> # only for NEW_TASK -- this determines your identity
Task name: <recipient>
Sender: <author>
Payload:
<payload text>
@@ -0,0 +1,41 @@
use codex_protocol::AgentPath;
use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InterAgentCompletionMessage {
task_name: AgentPath,
sender: AgentPath,
payload: String,
}
impl InterAgentCompletionMessage {
pub(crate) fn new(task_name: AgentPath, sender: AgentPath, payload: impl Into<String>) -> Self {
Self {
task_name,
sender,
payload: payload.into(),
}
}
}
impl ContextualUserFragment for InterAgentCompletionMessage {
fn role(&self) -> &'static str {
"assistant"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
format!(
"Message Type: FINAL_ANSWER\nTask name: {}\nSender: {}\nPayload:\n{}",
self.task_name, self.sender, self.payload,
)
}
}
+2
View File
@@ -10,6 +10,7 @@ mod environment_context;
mod guardian_followup_review_reminder;
mod hook_additional_context;
mod image_generation_instructions;
mod inter_agent_completion_message;
mod internal_model_context;
mod legacy_apply_patch_exec_command_warning;
mod legacy_model_mismatch_warning;
@@ -46,6 +47,7 @@ pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
pub use image_generation_instructions::extension_image_generation_output_hint;
pub(crate) use inter_agent_completion_message::InterAgentCompletionMessage;
pub use internal_model_context::InternalContextSource;
pub use internal_model_context::InternalModelContextFragment;
pub use internal_model_context::InvalidInternalContextSource;
+5 -15
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use codex_protocol::models::AgentMessageInputContent;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::plaintext_agent_message_content;
use codex_protocol::protocol::GuardianRiskLevel;
use codex_protocol::protocol::GuardianUserAuthorization;
use codex_protocol::user_input::UserInput;
@@ -455,20 +455,10 @@ pub(crate) fn collect_guardian_transcript_entries(
}
ResponseItem::AgentMessage {
author, content, ..
} => {
let text = content
.iter()
.filter_map(|content| match content {
AgentMessageInputContent::InputText { text } => Some(text.as_str()),
AgentMessageInputContent::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
(!text.trim().is_empty()).then(|| GuardianTranscriptEntry {
kind: GuardianTranscriptEntryKind::Assistant,
text: format!("Agent message from {author}:\n{text}"),
})
}
} => plaintext_agent_message_content(content).map(|text| GuardianTranscriptEntry {
kind: GuardianTranscriptEntryKind::Assistant,
text: format!("Agent message from {author}:\n{text}"),
}),
ResponseItem::LocalShellCall { action, .. } => serialized_entry(
GuardianTranscriptEntryKind::Tool("tool shell call".to_string()),
serde_json::to_string(action).ok(),
+5 -11
View File
@@ -4,8 +4,8 @@ use crate::session::session::Session;
use chrono::Utc;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::models::AgentMessageInputContent;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::plaintext_agent_message_content;
use codex_thread_store::ListThreadsParams;
use codex_thread_store::SortDirection;
use codex_thread_store::StoredThread;
@@ -243,16 +243,10 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option<String> {
ResponseItem::AgentMessage {
author, content, ..
} => {
let text = content
.iter()
.filter_map(|content| match content {
AgentMessageInputContent::InputText { text } => Some(text.as_str()),
AgentMessageInputContent::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
if text.trim().is_empty() || current_user.is_empty() && current_assistant.is_empty()
{
let Some(text) = plaintext_agent_message_content(content) else {
continue;
};
if current_user.is_empty() && current_assistant.is_empty() {
continue;
}
current_assistant.push(format!("Agent message from {author}:\n{text}"));
+8 -2
View File
@@ -37,7 +37,7 @@ use crate::image_preparation::prepare_response_items;
use crate::parse_turn_item;
use crate::realtime_conversation::RealtimeConversationManager;
use crate::session::turn_context::TurnEnvironment;
use crate::session_prefix::format_subagent_notification_message;
use crate::session_prefix::format_inter_agent_completion_message;
use crate::skills::SkillRenderSideEffects;
use crate::skills_load_input_from_config;
use crate::turn_metadata::TurnMetadataState;
@@ -1737,7 +1737,13 @@ impl Session {
return;
};
let message = format_subagent_notification_message(child_agent_path.as_str(), &status);
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
+18
View File
@@ -1,6 +1,8 @@
use codex_protocol::AgentPath;
use codex_protocol::protocol::AgentStatus;
use crate::context::ContextualUserFragment;
use crate::context::InterAgentCompletionMessage;
use crate::context::SubagentNotification;
// Helpers for model-visible session state markers that are stored in user-role
@@ -14,6 +16,22 @@ pub(crate) fn format_subagent_notification_message(
SubagentNotification::new(agent_reference, status.clone()).render()
}
pub(crate) fn format_inter_agent_completion_message(
task_name: AgentPath,
sender: AgentPath,
status: &AgentStatus,
) -> Option<String> {
let payload = match status {
AgentStatus::Completed(Some(message)) => message.clone(),
AgentStatus::Completed(None) => String::new(),
AgentStatus::Errored(error) => format!("Agent errored: {error}"),
AgentStatus::Shutdown => "Agent shut down.".to_string(),
AgentStatus::NotFound => "Agent was not found.".to_string(),
AgentStatus::PendingInit | AgentStatus::Running | AgentStatus::Interrupted => return None,
};
Some(InterAgentCompletionMessage::new(task_name, sender, payload).render())
}
pub(crate) fn format_subagent_context_line(
agent_reference: &str,
agent_nickname: Option<&str>,
@@ -5,7 +5,7 @@ use crate::config::DEFAULT_AGENT_MAX_DEPTH;
use crate::function_tool::FunctionCallError;
use crate::init_state_db;
use crate::session::tests::make_session_and_context;
use crate::session_prefix::format_subagent_notification_message;
use crate::session_prefix::format_inter_agent_completion_message;
use crate::thread_manager::thread_store_from_config;
use crate::tools::context::ToolOutput;
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
@@ -2043,14 +2043,18 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn()
)
.await;
let first_notification = format_subagent_notification_message(
worker_path.as_str(),
let first_notification = format_inter_agent_completion_message(
AgentPath::root(),
worker_path.clone(),
&AgentStatus::Completed(Some("first done".to_string())),
);
let second_notification = format_subagent_notification_message(
worker_path.as_str(),
)
.expect("completed status should render");
let second_notification = format_inter_agent_completion_message(
AgentPath::root(),
worker_path.clone(),
&AgentStatus::Completed(Some("second done".to_string())),
);
)
.expect("completed status should render");
let notifications = timeout(Duration::from_secs(5), async {
loop {
@@ -1087,10 +1087,16 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
"content": [{
"type": "encrypted_content",
"encrypted_content": encrypted_message,
}],
"content": [
{
"type": "input_text",
"text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n",
},
{
"type": "encrypted_content",
"encrypted_content": encrypted_message,
},
],
})]
);
@@ -1128,7 +1134,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()>
mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "<subagent_notification>")
body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "Message Type: FINAL_ANSWER")
},
sse(vec![
ev_response_created("resp-parent-2"),
@@ -1137,14 +1143,15 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()>
]),
)
.await;
let notification = "<subagent_notification>\n{\"agent_path\":\"/root/worker\",\"status\":{\"completed\":\"child done\"}}\n</subagent_notification>";
let notification =
"Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/worker\nPayload:\nchild done";
// If the child is still running when the parent turn starts, wait_agent blocks
// until mailbox delivery. The follow-up request must then contain that delivery.
mount_sse_once_match(
&server,
|req: &wiremock::Request| {
body_contains(req, TURN_2_NO_WAIT_PROMPT)
&& !body_contains(req, "<subagent_notification>")
&& !body_contains(req, "Message Type: FINAL_ANSWER")
},
sse(vec![
ev_response_created("resp-parent-3"),
@@ -1157,7 +1164,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message() -> Result<()>
&server,
|req: &wiremock::Request| {
body_contains(req, TURN_2_NO_WAIT_PROMPT)
&& body_contains(req, "<subagent_notification>")
&& body_contains(req, "Message Type: FINAL_ANSWER")
},
sse(vec![
ev_response_created("resp-parent-4"),
+2 -10
View File
@@ -1,9 +1,9 @@
use codex_api::SearchInput;
use codex_core::parse_turn_item;
use codex_protocol::items::TurnItem;
use codex_protocol::models::AgentMessageInputContent;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::plaintext_agent_message_content;
use codex_tools::retain_tail_from_last_n_user_messages;
use codex_tools::truncate_assistant_output_text_to_token_budget;
@@ -37,15 +37,7 @@ fn push_visible_message(messages: &mut Vec<ResponseItem>, item: &ResponseItem) {
metadata,
..
} => {
let text = content
.iter()
.filter_map(|content| match content {
AgentMessageInputContent::InputText { text } => Some(text.as_str()),
AgentMessageInputContent::EncryptedContent { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
if !text.trim().is_empty() {
if let Some(text) = plaintext_agent_message_content(content) {
messages.push(ResponseItem::Message {
id: None,
role: ASSISTANT_ROLE.to_string(),
+28
View File
@@ -863,6 +863,20 @@ pub enum AgentMessageInputContent {
EncryptedContent { encrypted_content: String },
}
/// Returns the locally readable text when an agent message is entirely plaintext.
pub fn plaintext_agent_message_content(content: &[AgentMessageInputContent]) -> Option<String> {
let mut text_parts = Vec::with_capacity(content.len());
for part in content {
match part {
AgentMessageInputContent::InputText { text } => text_parts.push(text.as_str()),
AgentMessageInputContent::EncryptedContent { .. } => return None,
}
}
let text = text_parts.join("\n");
(!text.trim().is_empty()).then_some(text)
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
pub enum ImageDetail {
@@ -1970,6 +1984,20 @@ mod tests {
1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];
#[test]
fn plaintext_agent_message_content_rejects_mixed_encrypted_content() {
let content = vec![
AgentMessageInputContent::InputText {
text: "Message Type: MESSAGE\nPayload:\n".to_string(),
},
AgentMessageInputContent::EncryptedContent {
encrypted_content: "encrypted-payload".to_string(),
},
];
assert_eq!(plaintext_agent_message_content(&content), None);
}
#[test]
fn response_input_message_conversion_preserves_phase() {
let item = ResponseItem::from(ResponseInputItem::Message {
+50 -6
View File
@@ -739,17 +739,32 @@ impl InterAgentCommunication {
pub fn to_model_input_item(&self) -> ResponseItem {
let content = match &self.encrypted_content {
Some(encrypted_content) => AgentMessageInputContent::EncryptedContent {
encrypted_content: encrypted_content.clone(),
},
None => AgentMessageInputContent::InputText {
Some(encrypted_content) => {
let message_type = if self.trigger_turn {
"NEW_TASK"
} else {
"MESSAGE"
};
vec![
AgentMessageInputContent::InputText {
text: format!(
"Message Type: {message_type}\nTask name: {}\nSender: {}\nPayload:\n",
self.recipient, self.author
),
},
AgentMessageInputContent::EncryptedContent {
encrypted_content: encrypted_content.clone(),
},
]
}
None => vec![AgentMessageInputContent::InputText {
text: self.content.clone(),
},
}],
};
ResponseItem::AgentMessage {
author: self.author.to_string(),
recipient: self.recipient.to_string(),
content: vec![content],
content,
metadata: self.metadata.clone(),
}
}
@@ -4256,6 +4271,35 @@ mod tests {
);
}
#[test]
fn queued_encrypted_inter_agent_communication_renders_message_envelope() {
let communication = InterAgentCommunication::new_encrypted(
AgentPath::root().join("worker").expect("author path"),
AgentPath::root(),
Vec::new(),
"encrypted payload".to_string(),
/*trigger_turn*/ false,
);
assert_eq!(
communication.to_model_input_item(),
ResponseItem::AgentMessage {
author: "/root/worker".to_string(),
recipient: "/root".to_string(),
content: vec![
AgentMessageInputContent::InputText {
text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/worker\nPayload:\n"
.to_string(),
},
AgentMessageInputContent::EncryptedContent {
encrypted_content: "encrypted payload".to_string(),
},
],
metadata: None,
}
);
}
#[test]
fn session_source_from_startup_arg_normalizes_custom_values() {
assert_eq!(
@@ -771,12 +771,13 @@ fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String
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,
let message_content = match item.body.parts.as_slice() {
[ConversationPart::Text { text }] => text,
[ConversationPart::Encoded { label, value }] if label == "encrypted_content" => value,
[
ConversationPart::Text { .. },
ConversationPart::Encoded { label, value },
] if label == "encrypted_content" => value,
_ => return None,
};
return Some((
@@ -255,7 +255,13 @@ fn sub_agent_started_activity_creates_spawn_edge() -> anyhow::Result<()> {
"type": "agent_message",
"author": "/root",
"recipient": "/root/reviewer",
"content": [{"type": "input_text", "text": "review this"}]
"content": [
{
"type": "input_text",
"text": "Message Type: NEW_TASK\nTask name: /root/reviewer\nSender: /root\nPayload:\n"
},
{"type": "encrypted_content", "encrypted_content": "review this"}
]
})],
)?;