mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user