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
+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!(