What version of Codex CLI is running?
Upstream main after #26210 (Encrypt multi-agent v2 message payloads, merged 2026-06-05). This appears to affect versions that include that change and enable MultiAgentV2 (post-0.137.0).
What subscription do you have?
Not subscription-specific.
Which model were you using?
Not model-specific. This concerns MultiAgentV2 spawn_agent, send_message, and followup_task message handling.
What platform is your computer?
Not platform-specific.
What terminal emulator and version are you using (if applicable)?
Not terminal-specific.
Codex doctor report
Not applicable. The regression is visible from the merged code behavior in #26210 rather than from local environment state.
What issue are you seeing?
#26210 makes MultiAgentV2 agent task/message payloads opaque to Codex by marking the model-facing message parameter as encrypted, storing only InterAgentCommunication.encrypted_content, and leaving InterAgentCommunication.content empty.
The encrypted delivery path is understandable as privacy hardening, but it also removes the human-readable task/message text from local rollout history, trace reduction, and parent-side audit/debug surfaces. That makes it difficult to answer basic questions such as:
- What task did this
spawn_agentcall give the child agent? - What message was sent to a subagent?
- Why did a child thread exist when reviewing a rollout after the fact?
This is different from #26753, which reports request validation failures for encrypted tool schemas. This issue is about auditability and debuggability after the encrypted schema is accepted.
What steps can reproduce the bug?
- Use a build containing Encrypt multi-agent v2 message payloads #26210 with MultiAgentV2 enabled. (aka post-0.137.0)
- Have the model call
spawn_agent,send_message, orfollowup_task. - Inspect the parent rollout/history/trace for the subagent task.
- The task/message content is hidden behind ciphertext rather than being available as human-readable audit text.
What is the expected behavior?
Codex should preserve a human-readable, structured audit copy of the subagent task/message while still allowing encrypted delivery to the recipient model.
A possible shape is to keep the encrypted message field for model delivery, but add a separate non-encrypted audit field for the readable task text. The audit field should be persisted in rollout/history/trace metadata so users and maintainers can inspect what was delegated without needing to decrypt model-delivery ciphertext.
Additional information
Related PR/issues:
- Encryption change: Encrypt multi-agent v2 message payloads #26210
- Related but distinct schema-validation issue: MultiAgentV2 encrypted spawn_agent schema returns 400: model not configured for encrypted tool use #26753
The goal is not necessarily to revert encrypted delivery. The concern is that encrypted delivery should not fully remove local human auditability for subagent delegation.
Source analysis
Upstream InterAgentCommunication::new_encrypted() deliberately initializes content as an empty string and stores the payload only in encrypted_content:
| #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] | |
| pub struct InterAgentCommunication { | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| #[ts(optional)] | |
| pub id: Option<String>, | |
| pub author: AgentPath, | |
| pub recipient: AgentPath, | |
| #[serde(default)] | |
| pub other_recipients: Vec<AgentPath>, | |
| pub content: String, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| #[ts(optional)] | |
| pub encrypted_content: Option<String>, | |
| #[serde(default, skip_serializing_if = "Option::is_none")] | |
| #[ts(optional)] | |
| pub internal_chat_message_metadata_passthrough: Option<InternalChatMessageMetadataPassthrough>, | |
| pub trigger_turn: bool, | |
| } | |
| impl InterAgentCommunication { | |
| pub fn new( | |
| author: AgentPath, | |
| recipient: AgentPath, | |
| other_recipients: Vec<AgentPath>, | |
| content: String, | |
| trigger_turn: bool, | |
| ) -> Self { | |
| Self { | |
| id: None, | |
| author, | |
| recipient, | |
| other_recipients, | |
| content, | |
| encrypted_content: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| trigger_turn, | |
| } | |
| } | |
| pub fn new_encrypted( | |
| author: AgentPath, | |
| recipient: AgentPath, | |
| other_recipients: Vec<AgentPath>, | |
| encrypted_content: String, | |
| trigger_turn: bool, | |
| ) -> Self { | |
| Self { | |
| id: None, | |
| author, | |
| recipient, | |
| other_recipients, | |
| content: String::new(), | |
| encrypted_content: Some(encrypted_content), | |
| internal_chat_message_metadata_passthrough: None, | |
| trigger_turn, | |
| } | |
| } |
The conversion used for recipient history then emits only the encrypted payload whenever encrypted_content is present. Merely populating the runtime content field would therefore not create a readable persisted ResponseItem; the fix also needs an explicit local audit persistence path:
| pub fn to_model_input_item(&self) -> ResponseItem { | |
| let content = match &self.encrypted_content { | |
| 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 { | |
| id: self.id.clone(), | |
| author: self.author.to_string(), | |
| recipient: self.recipient.to_string(), | |
| content, | |
| internal_chat_message_metadata_passthrough: self | |
| .internal_chat_message_metadata_passthrough | |
| .clone(), | |
| } |
The current v2 message helper constructs encrypted communication with empty plaintext content:
| pub(super) fn communication_from_tool_message( | |
| author: AgentPath, | |
| recipient: AgentPath, | |
| message: String, | |
| ) -> InterAgentCommunication { | |
| InterAgentCommunication::new_encrypted( | |
| author, | |
| recipient, | |
| Vec::new(), | |
| message, | |
| /*trigger_turn*/ true, | |
| ) |
send_message and followup_task still deserialize only target plus the encrypted message, then pass that ciphertext directly through the shared helper. There is no plaintext companion available to persist:
| #[derive(Debug, Deserialize)] | |
| #[serde(deny_unknown_fields)] | |
| /// Input for the MultiAgentV2 `send_message` tool. | |
| pub(crate) struct SendMessageArgs { | |
| pub(crate) target: String, | |
| pub(crate) message: String, | |
| } | |
| #[derive(Debug, Deserialize)] | |
| #[serde(deny_unknown_fields)] | |
| /// Input for the MultiAgentV2 `followup_task` tool. | |
| pub(crate) struct FollowupTaskArgs { | |
| pub(crate) target: String, | |
| pub(crate) message: String, | |
| } | |
| pub(super) fn message_content(message: String) -> Result<String, FunctionCallError> { | |
| if message.trim().is_empty() { | |
| return Err(FunctionCallError::RespondToModel( | |
| "Empty message can't be sent to an agent".to_string(), | |
| )); | |
| } | |
| Ok(message) | |
| } | |
| /// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`. | |
| pub(crate) async fn handle_message_string_tool( | |
| invocation: ToolInvocation, | |
| mode: MessageDeliveryMode, | |
| target: String, | |
| message: String, | |
| ) -> Result<FunctionToolOutput, FunctionCallError> { | |
| let message = message_content(message)?; |
| let author = turn | |
| .session_source | |
| .get_agent_path() | |
| .unwrap_or_else(AgentPath::root); | |
| let communication = | |
| communication_from_tool_message(author, receiver_agent_path.clone(), message); | |
| let kind = match mode { | |
| MessageDeliveryMode::QueueOnly => AgentCommunicationKind::Message, | |
| MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup, | |
| }; | |
| let context = AgentCommunicationContext::new(kind, session.thread_id); | |
| let result = session | |
| .services | |
| .agent_control | |
| .send_inter_agent_communication(receiver_thread_id, mode.apply(communication), context) | |
| .await |
The receiver records the model-facing ResponseItem produced by to_model_input_item(). For encrypted communication that item contains the encrypted delivery payload, not readable audit text:
| pub(crate) async fn record_inter_agent_communication( | |
| &self, | |
| turn_context: &TurnContext, | |
| mut communication: InterAgentCommunication, | |
| ) { | |
| communication.set_turn_id_if_missing(&turn_context.sub_id); | |
| let response_item = communication.to_model_input_item(); | |
| let items = self.prepare_conversation_items_for_history( | |
| turn_context, | |
| std::slice::from_ref(&response_item), | |
| ); | |
| let items = items.as_ref(); | |
| let response_item = items[0].clone(); | |
| { | |
| let mut state = self.state.lock().await; | |
| state.current_time_reminder.note_recorded_items(items); | |
| state.record_items( | |
| items.iter(), | |
| turn_context.model_info.truncation_policy.into(), | |
| ); | |
| } | |
| self.persist_rollout_items(&[ | |
| RolloutItem::InterAgentCommunicationMetadata { | |
| trigger_turn: communication.trigger_turn, | |
| }, | |
| RolloutItem::ResponseItem(response_item), | |
| ]) | |
| .await; | |
| self.send_raw_response_items(turn_context, items).await; |
The structured communication log has the same fallback: when content is empty, it records encrypted_content as the event content:
| pub(crate) fn emit_agent_communication_send( | |
| communication_id: &str, | |
| context: &AgentCommunicationContext, | |
| communication: &InterAgentCommunication, | |
| receiver_thread_id: ThreadId, | |
| ) { | |
| tracing::info!( | |
| target: AGENT_COMMUNICATION_TARGET, | |
| { | |
| event.name = "codex.agent_communication", | |
| communication_id, | |
| kind = context.kind.as_str(), | |
| state = "send", | |
| sender_thread_id = %context.sender_thread_id, | |
| receiver_thread_id = %receiver_thread_id, | |
| content = if communication.content.is_empty() { | |
| communication.encrypted_content.as_deref().unwrap_or_default() | |
| } else { | |
| communication.content.as_str() | |
| }, | |
| }, | |
| "agent communication" | |
| ); |
Implementation / fix spec
A concrete implementation can preserve encrypted delivery and restore a local audit trail:
- Keep the existing encrypted
messagefield as the delivery payload. - Add a required, non-encrypted plaintext companion to each v2 communication tool:
spawn_agent:task_messagesend_messageandfollowup_task: a consistently named plaintext audit field, such astask_messageormessage_text
- Reject empty plaintext audit values at the handler boundary.
- Construct
InterAgentCommunicationwith both:encrypted_contentset to the encryptedmessagecontentset to the plaintext audit copy
- Keep
to_model_input_item()behavior unchanged so the recipient model still receives ciphertext, not the local audit copy. - Persist the plaintext companion in the parent tool invocation/rollout and retain it in structured trace edges and local communication logs.
- Match tool calls to delivered child items using ciphertext/IDs, not plaintext equality. The plaintext field is audit metadata and should not replace the encrypted delivery identity.
- Bound the plaintext audit field with the same hard size limit as the corresponding delegated message so the new rollout/context item cannot grow without limit.
The spawn_agent half of this shape is implemented in the following snapshot commit:
That prototype makes task_message required in the v2 spawn schema:
v2 spawn_agent schema and required task_message field
It validates the field and places it in InterAgentCommunication.content while leaving the encrypted delivery payload in encrypted_content:
dual plaintext audit and encrypted delivery construction
It also teaches rollout-trace reduction to keep readable audit content while using the encrypted value only to correlate the tool invocation with delivery:
separate audit content from delivery-match content
correlate delivery while applying readable audit content
The remaining implementation work is to apply the same dual-content contract to send_message and followup_task, and to ensure every user-facing history/replay/debug surface reads the audit copy rather than falling back to provider ciphertext.
Acceptance criteria
- Parent rollout/history shows the readable text for v2
spawn_agent,send_message, andfollowup_task. - The child model still receives only the encrypted delivery payload when encryption is enabled.
- Structured rollout-trace interaction edges carry bounded plaintext
message_content. - Communication logs use plaintext audit content when present and never substitute ciphertext into a field presented as readable message text.
- Resume/replay preserves the audit copy without injecting it into the child model context.
- Existing plaintext v1 communication behavior is unchanged.
- Regression tests cover all three v2 tools and assert both sides of the contract: readable local audit data and encrypted recipient-model input.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.