ignatremizov · GitHub

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.

Current status (2026-07-22)

This remains unresolved on current upstream main:

  • spawn_agent, send_message, and followup_task still expose only an encrypted message field.
  • All three handlers still construct InterAgentCommunication::new_encrypted(), leaving readable content empty.
  • The structured communication log still substitutes encrypted_content into its content field when plaintext is absent.
  • Rollout-trace still uses encrypted delivery content as message_content.
  • list_agents no 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
  • encrypted preserves the current upstream opaque behavior.
  • encrypted_with_audit keeps encrypted recipient delivery and requires a separate readable task_message.
  • plaintext emits 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_message for live list_agents inspection 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 SubAgentActivity into the parent TUI instead of showing only Started /root/child;
  • projects child-side ResponseItem::AgentMessage records into typed app-server/TUI history with sender attribution, stable IDs, encrypted placeholders, resume/pagination support, and duplicate suppression;
  • covers spawn_agent, send_message, and followup_task, including encrypted-only, encrypted-with-audit, and plaintext modes.

Current implementation commits:

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:

  1. Make V2 communication readable under an explicit delivery policy and persist that representation.
  2. Carry readable parent activity prompts and child AgentMessage records 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:

  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.
  9. Add readable prompt content to V2 sub-agent activity items and render it with a bounded TUI preview.
  10. Convert persisted and live ResponseItem::AgentMessage records into normal typed thread items with sender attribution, stable IDs, and explicit encrypted-content redaction.
  11. 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, and followup_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.

Read the original on github.com ↗