Codex starts encrypting sub-agent prompts

hackernews

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_agent call 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?

  1. Use a build containing Encrypt multi-agent v2 message payloads #26210 with MultiAgentV2 enabled. (aka post-0.137.0)
  2. Have the model call spawn_agent, send_message, or followup_task.
  3. Inspect the parent rollout/history/trace for the subagent task.
  4. 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:

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:

  1. Keep the existing encrypted message field as the delivery payload.
  2. Add a required, non-encrypted plaintext companion to each v2 communication tool:
    • spawn_agent: task_message
    • send_message and followup_task: a consistently named plaintext audit field, such as task_message or message_text
  3. Reject empty plaintext audit values at the handler boundary.
  4. Construct InterAgentCommunication with both:
    • encrypted_content set to the encrypted message
    • content set to the plaintext audit copy
  5. Keep to_model_input_item() behavior unchanged so the recipient model still receives ciphertext, not the local audit copy.
  6. Persist the plaintext companion in the parent tool invocation/rollout and retain it in structured trace edges and local communication logs.
  7. 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.
  8. 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:

ignatremizov@df9a7c4

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:

plaintext audit validation

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, and followup_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.

Source: hackernews

arrow_back Back to News