mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Support plaintext agent messages (#27830)
## Why Multi-agent v2 `send_message` deliveries already reach the receiving model as typed `agent_message` items with encrypted content. Child-completion notifications are generated by Codex itself, so their content is plaintext and previously fell back to a serialized JSON envelope inside an assistant message. With plaintext `input_text` supported for `agent_message`, both delivery paths can use the same model-visible type while preserving explicit author and recipient metadata. ## What changed - add plaintext `input_text` support to `AgentMessageInputContent` and regenerate the affected app-server schemas - preserve `InterAgentCommunication` as structured mailbox input instead of converting it to assistant text - record delivered communications as typed `agent_message` history items - persist a dedicated rollout item so local delivery metadata such as `trigger_turn` remains available without leaking into the Responses request - reconstruct typed agent messages on resume and preserve fork-turn truncation behavior - remove request-time assistant-content parsing - preserve plaintext and encrypted inter-agent deliveries in stage-one memory inputs - normalize and link plaintext and encrypted agent messages in rollout traces without treating inbound messages as child results - cover the real MultiAgent V2 child-completion path end to end with deterministic mailbox synchronization ## Verification - `just test -p codex-core plaintext_multi_agent_v2_completion_sends_agent_message` - `just test -p codex-core input_queue_drains_mailbox_in_delivery_order record_initial_history_reconstructs_typed_inter_agent_message fork_turn_positions_use_inter_agent_delivery_metadata` - `just test -p codex-memories-write serializes_inter_agent_communications_for_memory` - `just test -p codex-rollout-trace agent_messages_preserve_routing_and_content sub_agent_started_activity_creates_spawn_edge` - `just test -p codex-rollout-trace agent_result_edge_falls_back_to_child_thread_without_result_message` - `just test -p codex-protocol -p codex-rollout -p codex-app-server-protocol`
This commit is contained in:
@@ -55,6 +55,7 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item:
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other,
|
||||
) => false,
|
||||
RolloutItem::InterAgentCommunication(_) => false,
|
||||
// Full-history forks preserve the cached prompt prefix and can keep diffing
|
||||
// from the parent's durable baseline. Truncated forks drop part of that prompt,
|
||||
// so they must rebuild context on their first child turn.
|
||||
|
||||
@@ -5,7 +5,6 @@ use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_tools::ToolSpec;
|
||||
use futures::Stream;
|
||||
use serde_json::Value;
|
||||
@@ -55,30 +54,11 @@ impl Default for Prompt {
|
||||
}
|
||||
|
||||
impl Prompt {
|
||||
pub(crate) fn get_formatted_input(&self) -> Vec<ResponseItem> {
|
||||
self.input
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|item| {
|
||||
let ResponseItem::Message { role, content, .. } = &item else {
|
||||
return item;
|
||||
};
|
||||
if role != "assistant" {
|
||||
return item;
|
||||
}
|
||||
InterAgentCommunication::from_message_content(content)
|
||||
.filter(|communication| communication.encrypted_content.is_some())
|
||||
.map(|communication| communication.to_model_input_item())
|
||||
.unwrap_or(item)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn get_formatted_input_for_request(
|
||||
&self,
|
||||
use_responses_lite: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut input = self.get_formatted_input();
|
||||
let mut input = self.input.clone();
|
||||
if use_responses_lite {
|
||||
strip_image_details(&mut input);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_protocol::models::AgentMessageInputContent;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::GuardianRiskLevel;
|
||||
use codex_protocol::protocol::GuardianUserAuthorization;
|
||||
@@ -452,6 +453,22 @@ pub(crate) fn collect_guardian_transcript_entries(
|
||||
ResponseItem::Message { role, content, .. } if role == "assistant" => {
|
||||
content_entry(GuardianTranscriptEntryKind::Assistant, content)
|
||||
}
|
||||
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}"),
|
||||
})
|
||||
}
|
||||
ResponseItem::LocalShellCall { action, .. } => serialized_entry(
|
||||
GuardianTranscriptEntryKind::Tool("tool shell call".to_string()),
|
||||
serde_json::to_string(action).ok(),
|
||||
|
||||
@@ -528,6 +528,10 @@ pub(crate) async fn inspect_pending_input(
|
||||
should_stop: false,
|
||||
additional_contexts: Vec::new(),
|
||||
},
|
||||
TurnInput::InterAgentCommunication(_) => HookRuntimeOutcome {
|
||||
should_stop: false,
|
||||
additional_contexts: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +554,10 @@ pub(crate) async fn record_pending_input(
|
||||
sess.record_conversation_items(turn_context, std::slice::from_ref(&item))
|
||||
.await;
|
||||
}
|
||||
TurnInput::InterAgentCommunication(communication) => {
|
||||
sess.record_inter_agent_communication(turn_context, communication)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
record_additional_contexts(sess, turn_context, additional_contexts).await;
|
||||
}
|
||||
|
||||
@@ -98,5 +98,5 @@ pub(crate) async fn build_prompt_input_from_session(
|
||||
base_instructions,
|
||||
);
|
||||
|
||||
Ok(prompt.get_formatted_input())
|
||||
Ok(prompt.input)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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_thread_store::ListThreadsParams;
|
||||
use codex_thread_store::SortDirection;
|
||||
@@ -238,6 +239,23 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option<String> {
|
||||
}
|
||||
current_assistant.push(text);
|
||||
}
|
||||
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()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
current_assistant.push(format!("Agent message from {author}:\n{text}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ pub(super) async fn user_input_or_turn_inner(
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an inter-agent assistant envelope, then lets the shared pending-work scheduler
|
||||
/// Queues an inter-agent message, then lets the shared pending-work scheduler
|
||||
/// decide whether an idle session should start a regular turn.
|
||||
pub async fn inter_agent_communication(
|
||||
sess: &Arc<Session>,
|
||||
|
||||
@@ -16,6 +16,7 @@ pub(crate) enum TurnInput {
|
||||
client_id: Option<String>,
|
||||
},
|
||||
ResponseItem(ResponseItem),
|
||||
InterAgentCommunication(InterAgentCommunication),
|
||||
}
|
||||
|
||||
/// Turn-local pending input storage owned by the input queue flow.
|
||||
@@ -70,12 +71,12 @@ impl InputQueue {
|
||||
.any(|mail| mail.trigger_turn)
|
||||
}
|
||||
|
||||
pub(crate) async fn drain_mailbox_input_items(&self) -> Vec<ResponseItem> {
|
||||
pub(crate) async fn drain_mailbox_input_items(&self) -> Vec<TurnInput> {
|
||||
self.mailbox_pending_mails
|
||||
.lock()
|
||||
.await
|
||||
.drain(..)
|
||||
.map(|mail| ResponseItem::from(mail.to_response_input_item()))
|
||||
.map(TurnInput::InterAgentCommunication)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -189,11 +190,7 @@ impl InputQueue {
|
||||
if !accepts_mailbox_delivery {
|
||||
return pending_input;
|
||||
}
|
||||
let mailbox_items = self
|
||||
.drain_mailbox_input_items()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(TurnInput::ResponseItem);
|
||||
let mailbox_items = self.drain_mailbox_input_items().await.into_iter();
|
||||
if pending_input.is_empty() {
|
||||
mailbox_items.collect()
|
||||
} else {
|
||||
@@ -290,7 +287,7 @@ mod tests {
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
AgentPath::root(),
|
||||
"two",
|
||||
/*trigger_turn*/ false,
|
||||
/*trigger_turn*/ true,
|
||||
);
|
||||
|
||||
input_queue
|
||||
@@ -303,8 +300,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
input_queue.drain_mailbox_input_items().await,
|
||||
vec![
|
||||
ResponseItem::from(mail_one.to_response_input_item()),
|
||||
ResponseItem::from(mail_two.to_response_input_item())
|
||||
TurnInput::InterAgentCommunication(mail_one),
|
||||
TurnInput::InterAgentCommunication(mail_two)
|
||||
]
|
||||
);
|
||||
assert!(!input_queue.has_pending_mailbox_items().await);
|
||||
|
||||
@@ -2659,6 +2659,26 @@ impl Session {
|
||||
self.send_raw_response_items(turn_context, items).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn record_inter_agent_communication(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
communication: InterAgentCommunication,
|
||||
) {
|
||||
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 mut state = self.state.lock().await;
|
||||
state.record_items(items.iter(), turn_context.truncation_policy);
|
||||
}
|
||||
self.persist_rollout_items(&[RolloutItem::InterAgentCommunication(communication)])
|
||||
.await;
|
||||
self.send_raw_response_items(turn_context, items).await;
|
||||
}
|
||||
|
||||
async fn maybe_warn_on_server_model_mismatch(
|
||||
self: &Arc<Self>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
|
||||
@@ -220,6 +220,11 @@ impl Session {
|
||||
active_segment.get_or_insert_with(ActiveReplaySegment::default);
|
||||
active_segment.counts_as_user_turn |= is_user_turn_boundary(response_item);
|
||||
}
|
||||
RolloutItem::InterAgentCommunication(_) => {
|
||||
let active_segment =
|
||||
active_segment.get_or_insert_with(ActiveReplaySegment::default);
|
||||
active_segment.counts_as_user_turn = true;
|
||||
}
|
||||
RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => {}
|
||||
}
|
||||
|
||||
@@ -269,6 +274,13 @@ impl Session {
|
||||
turn_context.truncation_policy,
|
||||
);
|
||||
}
|
||||
RolloutItem::InterAgentCommunication(communication) => {
|
||||
let response_item = communication.to_model_input_item();
|
||||
history.record_items(
|
||||
std::iter::once(&response_item),
|
||||
turn_context.truncation_policy,
|
||||
);
|
||||
}
|
||||
RolloutItem::Compacted(compacted) => {
|
||||
if let Some(replacement_history) = &compacted.replacement_history {
|
||||
// This should actually never happen, because the reverse loop above (to build rollout_suffix)
|
||||
|
||||
@@ -52,6 +52,31 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_typed_inter_agent_message() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::root().join("worker").expect("worker path"),
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"child done".to_string(),
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: ThreadId::default(),
|
||||
history: vec![RolloutItem::InterAgentCommunication(communication.clone())],
|
||||
rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")),
|
||||
}))
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
session.state.lock().await.clone_history().raw_items(),
|
||||
&[communication.to_model_input_item()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previous_turn_settings()
|
||||
{
|
||||
|
||||
@@ -9222,9 +9222,7 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
|
||||
|
||||
assert_eq!(
|
||||
sess.input_queue.get_pending_input(&sess.active_turn).await,
|
||||
vec![TurnInput::ResponseItem(ResponseItem::from(
|
||||
communication.to_response_input_item()
|
||||
))],
|
||||
vec![TurnInput::InterAgentCommunication(communication)],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9313,7 +9311,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
|
||||
}],
|
||||
client_id: None
|
||||
},
|
||||
TurnInput::ResponseItem(ResponseItem::from(communication.to_response_input_item())),
|
||||
TurnInput::InterAgentCommunication(communication),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -9371,7 +9369,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
|
||||
}],
|
||||
client_id: None
|
||||
},
|
||||
TurnInput::ResponseItem(ResponseItem::from(communication.to_response_input_item())),
|
||||
TurnInput::InterAgentCommunication(communication),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -9426,9 +9424,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
|
||||
assert!(output.tool_future.is_some());
|
||||
assert_eq!(
|
||||
sess.input_queue.get_pending_input(&sess.active_turn).await,
|
||||
vec![TurnInput::ResponseItem(ResponseItem::from(
|
||||
communication.to_response_input_item()
|
||||
))],
|
||||
vec![TurnInput::InterAgentCommunication(communication)],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -466,7 +466,7 @@ async fn build_skills_and_plugins(
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
TurnInput::UserInput { content, .. } => Some(content.as_slice()),
|
||||
TurnInput::ResponseItem(_) => None,
|
||||
TurnInput::ResponseItem(_) | TurnInput::InterAgentCommunication(_) => None,
|
||||
})
|
||||
.flatten()
|
||||
.cloned()
|
||||
@@ -685,7 +685,7 @@ async fn track_turn_resolved_config_analytics(
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
TurnInput::UserInput { content, .. } => Some(content.as_slice()),
|
||||
TurnInput::ResponseItem(_) => None,
|
||||
TurnInput::ResponseItem(_) | TurnInput::InterAgentCommunication(_) => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter(|item| {
|
||||
|
||||
@@ -65,7 +65,7 @@ impl SessionTask for ReviewTask {
|
||||
for item in input {
|
||||
match item {
|
||||
TurnInput::UserInput { mut content, .. } => user_input.append(&mut content),
|
||||
TurnInput::ResponseItem(_) => {}
|
||||
TurnInput::ResponseItem(_) | TurnInput::InterAgentCommunication(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &Initia
|
||||
fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool {
|
||||
match item {
|
||||
RolloutItem::ResponseItem(item) => is_user_turn_boundary(item),
|
||||
RolloutItem::InterAgentCommunication(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -58,7 +59,8 @@ pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec<us
|
||||
///
|
||||
/// A fork-turn boundary is either:
|
||||
/// - a real user message boundary, or
|
||||
/// - an assistant inter-agent envelope whose parsed `trigger_turn` is `true`.
|
||||
/// - an inter-agent communication whose `trigger_turn` is `true`, or
|
||||
/// - a legacy assistant inter-agent envelope with the same flag.
|
||||
///
|
||||
/// Like `user_message_positions_in_rollout`, this applies `ThreadRolledBack` markers so indexing
|
||||
/// reflects the effective post-rollback history. Rollback counts instruction turns, so a rollback
|
||||
@@ -77,6 +79,12 @@ pub(crate) fn fork_turn_positions_in_rollout(items: &[RolloutItem]) -> Vec<usize
|
||||
fork_turn_positions.push(idx);
|
||||
}
|
||||
}
|
||||
RolloutItem::InterAgentCommunication(communication) => {
|
||||
rollback_turn_positions.push(idx);
|
||||
if communication.trigger_turn {
|
||||
fork_turn_positions.push(idx);
|
||||
}
|
||||
}
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
|
||||
let num_turns = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX);
|
||||
if num_turns == 0 {
|
||||
|
||||
@@ -51,6 +51,16 @@ fn inter_agent_msg(text: &str, trigger_turn: bool) -> ResponseItem {
|
||||
communication.to_response_input_item().into()
|
||||
}
|
||||
|
||||
fn inter_agent_communication(text: &str, trigger_turn: bool) -> RolloutItem {
|
||||
RolloutItem::InterAgentCommunication(InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
text.to_string(),
|
||||
trigger_turn,
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_rollout_from_start_before_nth_user_only() {
|
||||
let items = [
|
||||
@@ -208,6 +218,20 @@ fn truncates_rollout_to_last_n_fork_turns_counts_trigger_turn_messages() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_turn_positions_use_inter_agent_delivery_metadata() {
|
||||
let rollout = vec![
|
||||
RolloutItem::ResponseItem(user_msg("user task")),
|
||||
inter_agent_communication("queued during user turn", /*trigger_turn*/ false),
|
||||
RolloutItem::ResponseItem(assistant_msg("first answer")),
|
||||
inter_agent_communication("follow-up task", /*trigger_turn*/ true),
|
||||
RolloutItem::ResponseItem(assistant_msg("second answer")),
|
||||
RolloutItem::ResponseItem(user_msg("next user task")),
|
||||
];
|
||||
|
||||
assert_eq!(fork_turn_positions_in_rollout(&rollout), vec![0, 3, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_rollout_to_last_n_fork_turns_drops_startup_prefix_even_when_under_limit() {
|
||||
let rollout = vec![
|
||||
|
||||
Reference in New Issue
Block a user