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 11:46:59 +02:00
committed by GitHub
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 {