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.
Current status (2026-07-22)
This remains unresolved on current upstream main:
spawn_agent,send_message, andfollowup_taskstill expose only an encryptedmessagefield.- All three handlers still construct
InterAgentCommunication::new_encrypted(), leaving readablecontentempty. - The structured communication log still substitutes
encrypted_contentinto itscontentfield when plaintext is absent. - Rollout-trace still uses encrypted delivery content as
message_content. list_agentsno longer exposes the most recent task/message.
A complete fork implementation now covers all three tools and makes delivery policy explicit:
[features.multi_agent_v2] message_delivery = "plaintext" # encrypted | encrypted_with_audit | plaintext
encryptedpreserves the current upstream opaque behavior.encrypted_with_auditkeeps encrypted recipient delivery and requires a separate readabletask_message.plaintextemits and persists one readable message, avoiding duplicate model output.
The implementation:
- validates the selected schema/arguments before agent lookup or spawn;
- applies one combined 8 KiB hard limit to each communication payload;
- keeps the audit copy out of encrypted recipient-model input;
- persists readable content in parent tool arguments and
InterAgentCommunication.content; - prevents ciphertext from being presented as readable communication-log or rollout-trace content;
- restores
last_task_messagefor livelist_agentsinspection and serializes mailbox delivery with metadata updates; - preserves encrypted/plaintext communication representation through rollout resume and fork handling;
- carries readable V2 spawn/follow-up text through
SubAgentActivityinto the parent TUI instead of showing onlyStarted /root/child; - projects child-side
ResponseItem::AgentMessagerecords into typed app-server/TUI history with sender attribution, stable IDs, encrypted placeholders, resume/pagination support, and duplicate suppression; - covers
spawn_agent,send_message, andfollowup_task, including encrypted-only, encrypted-with-audit, and plaintext modes.
Current implementation commits:
- Configurable delivery policy:
ignatremizov@ 2ffe360 - All three V2 communication tools, audit persistence, and live inspection:
ignatremizov@ 3b523d7 - Rollout-trace audit retention and encrypted-delivery correlation:
ignatremizov@ ebb53b6 - Configurable multi-line task and response previews:
ignatremizov@ 7fd1514 - Parent-side V2 task propagation and child-history projection:
ignatremizov@ f6ddcea - Durable live/resumed inter-agent transcript delivery:
ignatremizov@ b60e626
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<ResponseItemId>, | |
| 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(&[ |
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" | |
| ); |
Why changing the V2 tool schema is necessary but not sufficient for the TUI
The tool-schema/handler change makes readable text exist locally again, but current upstream main still drops that text before the relevant parent and child transcript surfaces.
For the parent view, upstream's SubAgentActivityEvent carries only the event ID, child ID/path, and activity kind. It has no task/prompt field:
| #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] | |
| pub struct SubAgentActivityEvent { | |
| pub event_id: String, | |
| #[serde(default)] | |
| pub occurred_at_ms: i64, | |
| /// Thread ID of the affected sub-agent. | |
| pub agent_thread_id: ThreadId, | |
| /// Canonical v2 path of the affected sub-agent. | |
| pub agent_path: AgentPath, | |
| pub kind: SubAgentActivityKind, | |
| } |
The app-server ThreadItem::SubAgentActivity likewise exposes no prompt:
| #[serde(rename_all = "camelCase")] | |
| #[ts(rename_all = "camelCase")] | |
| SubAgentActivity { | |
| id: String, | |
| kind: SubAgentActivityKind, | |
| agent_thread_id: String, | |
| agent_path: String, | |
| }, |
The upstream TUI therefore renders only Started, Interacted with, or Interrupted plus the agent path, with an empty detail list:
| pub(crate) fn sub_agent_activity_history_cell(item: &ThreadItem) -> Option<PlainHistoryCell> { | |
| let ThreadItem::SubAgentActivity { | |
| kind, agent_path, .. | |
| } = item | |
| else { | |
| return None; | |
| }; | |
| Some(collab_event( | |
| sub_agent_activity_title(*kind, agent_path), | |
| Vec::new(), | |
| )) | |
| } | |
| pub(crate) fn sub_agent_activity_summary(kind: SubAgentActivityKind, agent_path: &str) -> String { | |
| match kind { | |
| SubAgentActivityKind::Started => format!("Started `{agent_path}`"), | |
| SubAgentActivityKind::Interacted => format!("Interacted with `{agent_path}`"), | |
| SubAgentActivityKind::Interrupted => format!("Interrupted `{agent_path}`"), |
For the child view, readable plaintext can already be present canonically as ResponseItem::AgentMessage, but upstream thread-history reconstruction ignores InterAgentCommunication records and its generic response-item handler accepts only hook-prompt user messages:
| pub fn handle_rollout_item(&mut self, item: &RolloutItem) { | |
| self.current_rollout_index = self.next_rollout_index; | |
| self.next_rollout_index += 1; | |
| match item { | |
| RolloutItem::EventMsg(event) => self.handle_event(event), | |
| RolloutItem::Compacted(payload) => self.handle_compacted(payload), | |
| RolloutItem::ResponseItem(item) => self.handle_response_item(item), | |
| RolloutItem::InterAgentCommunication(_) | |
| | RolloutItem::InterAgentCommunicationMetadata { .. } | |
| | RolloutItem::TurnContext(_) | |
| | RolloutItem::WorldState(_) | |
| | RolloutItem::SessionMeta(_) => {} | |
| } |
| fn handle_response_item(&mut self, item: &codex_protocol::models::ResponseItem) { | |
| let codex_protocol::models::ResponseItem::Message { | |
| role, content, id, .. | |
| } = item | |
| else { | |
| return; | |
| }; | |
| if role != "user" { | |
| return; |
Live raw response items are emitted only through the separate rawResponseItem/* notification path; that is not the normal typed thread-item stream consumed by the TUI and app-server clients:
| EventMsg::RawResponseItem(raw_response_item_event) => { | |
| maybe_emit_raw_response_item_completed( | |
| conversation_id, | |
| &event_turn_id, | |
| raw_response_item_event.item, | |
| &outgoing, | |
| ) | |
| .await; | |
| } |
| async fn maybe_emit_raw_response_item_completed( | |
| conversation_id: ThreadId, | |
| turn_id: &str, | |
| item: codex_protocol::models::ResponseItem, | |
| outgoing: &ThreadScopedOutgoingMessageSender, | |
| ) { | |
| let notification = RawResponseItemCompletedNotification { | |
| thread_id: conversation_id.to_string(), | |
| turn_id: turn_id.to_string(), | |
| item, | |
| }; | |
| outgoing | |
| .send_server_notification(ServerNotification::RawResponseItemCompleted(notification)) | |
| .await; |
So a complete local fix has two layers:
- Make V2 communication readable under an explicit delivery policy and persist that representation.
- Carry readable parent activity prompts and child
AgentMessagerecords through protocol conversion, app-server history/live notifications, resume/pagination, and TUI rendering.
The fork implements the second layer in the final three commits linked above. Plaintext messages render with sender attribution; encrypted-only messages render a fixed Input message encrypted placeholder rather than ciphertext. The durable projection is fork-owned and rebuildable from canonical plain or compressed rollouts so the same SQLite cache remains compatible with upstream Codex.
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.
- Add readable prompt content to V2 sub-agent activity items and render it with a bounded TUI preview.
- Convert persisted and live
ResponseItem::AgentMessagerecords into normal typed thread items with sender attribution, stable IDs, and explicit encrypted-content redaction. - Preserve ordering and suppress duplicates across thread read/resume, pagination, running-thread subscription handoff, and agent switching.
The fork implementation linked in the current-status section now applies this contract consistently to spawn_agent, send_message, and followup_task, including local inspection, communication logs, and rollout-trace reduction.
Acceptance criteria
- Parent rollout/history shows the readable text for v2
spawn_agent,send_message, andfollowup_task. - Parent TUI activity rows show the readable task/follow-up text rather than only the child path.
- Child app-server/TUI history shows the same readable instruction with sender attribution; encrypted-only delivery shows a non-sensitive placeholder, never ciphertext.
- 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.
- Live notifications,
thread/read, resume, pagination, and agent switching preserve one ordered copy of each communication. - Resume/replay preserves the audit copy without injecting it into the child model context, and remains compatible with canonical plain and compressed rollouts.
- 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, plus parent/child TUI rendering and live/resumed app-server history.