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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user