mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
## Why
Multi-agent v2 messages need a consistent, model-visible envelope that
identifies what kind of interaction occurred, who sent it, and which
agent it targets. Previously, encrypted deliveries exposed only
`encrypted_content`, while child completion used the legacy
`<subagent_notification>` shape. That meant the client could not
consistently present `NEW_TASK`, `MESSAGE`, and `FINAL_ANSWER` using the
same format.
This change adds the routing envelope as plaintext while keeping task
and message payloads encrypted. No new Responses API field is required:
an encrypted delivery is represented as an `input_text` header
immediately followed by its existing `encrypted_content` item.
Every envelope now follows this shape:
```text
Message Type: <NEW_TASK | MESSAGE | FINAL_ANSWER>
Task name: <recipient agent path>
Sender: <author agent path>
Payload:
<message payload>
```
## Message types
### `NEW_TASK`
`NEW_TASK` is used when the recipient should begin a new turn, including
an initial `spawn_agent` task and a later `followup_task`.
For a root agent spawning `/root/worker`, the request contains a
plaintext envelope followed by the encrypted task:
```json
{
"type": "agent_message",
"author": "/root",
"recipient": "/root/worker",
"content": [
{
"type": "input_text",
"text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n"
},
{
"type": "encrypted_content",
"encrypted_content": "<encrypted task payload>"
}
]
}
```
Conceptually, the model receives:
```text
Message Type: NEW_TASK
Task name: /root/worker
Sender: /root
Payload:
Review the authentication changes and report any regressions.
```
### `MESSAGE`
`MESSAGE` is used for a queued `send_message` delivery. It communicates
with an existing agent without starting a new turn.
For `/root/worker` reporting progress to the root agent, the request
contains:
```json
{
"type": "agent_message",
"author": "/root/worker",
"recipient": "/root",
"content": [
{
"type": "input_text",
"text": "Message Type: MESSAGE\nTask name: /root\nSender: /root/worker\nPayload:\n"
},
{
"type": "encrypted_content",
"encrypted_content": "<encrypted message payload>"
}
]
}
```
Conceptually, the model receives:
```text
Message Type: MESSAGE
Task name: /root
Sender: /root/worker
Payload:
The protocol tests pass; I am checking the resume path now.
```
### `FINAL_ANSWER`
`FINAL_ANSWER` is emitted when a child agent reaches a terminal state
and reports its result to its parent. Completion payloads are already
available locally, so the complete envelope is represented as plaintext
rather than as a plaintext header plus encrypted content.
For `/root/worker` completing work for the root agent, the request
contains:
```json
{
"type": "agent_message",
"author": "/root/worker",
"recipient": "/root",
"content": [
{
"type": "input_text",
"text": "Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/worker\nPayload:\nNo regressions found."
}
]
}
```
The model-visible form is:
```text
Message Type: FINAL_ANSWER
Task name: /root
Sender: /root/worker
Payload:
No regressions found.
```
Errored, shut down, and missing agents also use `FINAL_ANSWER`, with a
terminal-status description in the payload.
## What changed
- Render `NEW_TASK` or `MESSAGE` in
`InterAgentCommunication::to_model_input_item`, based on whether the
encrypted delivery starts a turn.
- Replace the multi-agent v2 `<subagent_notification>` completion
payload with a model-visible `FINAL_ANSWER` envelope.
- Document `Task name`, `Sender`, and `Payload` consistently in the
multi-agent developer instructions.
- Prevent local-only history projections from treating an encrypted
message's plaintext header as the complete assistant message.
- Preserve rollout-trace interaction edges when an agent message
contains both plaintext and encrypted content.
Legacy multi-agent behavior remains unchanged.
## Verification
- `just test -p codex-protocol`
- `just test -p codex-rollout-trace`
- `just test -p codex-web-search-extension`
- `just test -p codex-core
encrypted_multi_agent_v2_spawn_sends_agent_message_to_child`
- `just test -p codex-core
plaintext_multi_agent_v2_completion_sends_agent_message`
- `just test -p codex-core
multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn`
- `just test -p codex-core
multi_agent_v2_completion_queues_message_for_direct_parent`
195 lines
6.3 KiB
Rust
195 lines
6.3 KiB
Rust
use codex_api::SearchInput;
|
|
use codex_core::parse_turn_item;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::models::plaintext_agent_message_content;
|
|
use codex_tools::retain_tail_from_last_n_user_messages;
|
|
use codex_tools::truncate_assistant_output_text_to_token_budget;
|
|
|
|
const ASSISTANT_CONTEXT_TOKEN_LIMIT: usize = 1_000;
|
|
const ASSISTANT_ROLE: &str = "assistant";
|
|
const USER_ROLE: &str = "user";
|
|
|
|
/// Builds the conversation tail for standalone web search.
|
|
///
|
|
/// The tail keeps the previous user text message, up to 1k tokens of assistant
|
|
/// text that followed it, and the current user text message.
|
|
pub(crate) fn recent_input(items: &[ResponseItem]) -> Option<SearchInput> {
|
|
let mut messages = Vec::new();
|
|
for item in items {
|
|
push_visible_message(&mut messages, item);
|
|
}
|
|
|
|
retain_tail_from_last_n_user_messages(&mut messages, /*user_message_count*/ 2);
|
|
truncate_assistant_output_text_to_token_budget(&mut messages, ASSISTANT_CONTEXT_TOKEN_LIMIT);
|
|
(!messages.is_empty()).then_some(SearchInput::Items(messages))
|
|
}
|
|
|
|
fn push_visible_message(messages: &mut Vec<ResponseItem>, item: &ResponseItem) {
|
|
match item {
|
|
ResponseItem::Message { role, .. } if role == ASSISTANT_ROLE => {
|
|
messages.push(item.clone());
|
|
}
|
|
ResponseItem::AgentMessage {
|
|
author,
|
|
content,
|
|
metadata,
|
|
..
|
|
} => {
|
|
if let Some(text) = plaintext_agent_message_content(content) {
|
|
messages.push(ResponseItem::Message {
|
|
id: None,
|
|
role: ASSISTANT_ROLE.to_string(),
|
|
content: vec![ContentItem::OutputText {
|
|
text: format!("Agent message from {author}:\n{text}"),
|
|
}],
|
|
phase: None,
|
|
metadata: metadata.clone(),
|
|
});
|
|
}
|
|
}
|
|
ResponseItem::Message {
|
|
id,
|
|
role,
|
|
content,
|
|
phase,
|
|
metadata,
|
|
} if role == USER_ROLE
|
|
&& matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_))) =>
|
|
{
|
|
let content = content
|
|
.iter()
|
|
.filter(|item| matches!(item, ContentItem::InputText { .. }))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
if !content.is_empty() {
|
|
messages.push(ResponseItem::Message {
|
|
id: id.clone(),
|
|
role: role.clone(),
|
|
content,
|
|
phase: phase.clone(),
|
|
metadata: metadata.clone(),
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use codex_api::SearchInput;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use super::ASSISTANT_ROLE;
|
|
use super::USER_ROLE;
|
|
use super::recent_input;
|
|
|
|
fn message(role: &str, text: &str) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: role.to_string(),
|
|
content: vec![if role == ASSISTANT_ROLE {
|
|
ContentItem::OutputText {
|
|
text: text.to_string(),
|
|
}
|
|
} else {
|
|
ContentItem::InputText {
|
|
text: text.to_string(),
|
|
}
|
|
}],
|
|
phase: None,
|
|
metadata: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_current_user_and_previous_visible_turn() {
|
|
let items = vec![
|
|
message("system", "system"),
|
|
message(USER_ROLE, "old user"),
|
|
message(ASSISTANT_ROLE, "old assistant"),
|
|
message(USER_ROLE, "previous user"),
|
|
ResponseItem::FunctionCall {
|
|
id: None,
|
|
name: "tool".to_string(),
|
|
namespace: None,
|
|
arguments: "{}".to_string(),
|
|
call_id: "call-1".to_string(),
|
|
metadata: None,
|
|
},
|
|
message(ASSISTANT_ROLE, "previous assistant"),
|
|
message("developer", "developer"),
|
|
message(USER_ROLE, "current user"),
|
|
message(ASSISTANT_ROLE, "current commentary"),
|
|
];
|
|
|
|
assert_eq!(
|
|
recent_input(&items),
|
|
Some(SearchInput::Items(vec![
|
|
message(USER_ROLE, "previous user"),
|
|
message(ASSISTANT_ROLE, "previous assistant"),
|
|
message(USER_ROLE, "current user"),
|
|
]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_only_text_from_recent_user_messages() {
|
|
let previous_user = ResponseItem::Message {
|
|
id: None,
|
|
role: USER_ROLE.to_string(),
|
|
content: vec![
|
|
ContentItem::InputText {
|
|
text: "previous user".to_string(),
|
|
},
|
|
ContentItem::InputImage {
|
|
image_url: "data:image/png;base64,image".to_string(),
|
|
detail: None,
|
|
},
|
|
],
|
|
phase: None,
|
|
metadata: None,
|
|
};
|
|
let items = vec![
|
|
previous_user,
|
|
message(ASSISTANT_ROLE, "previous assistant"),
|
|
message(USER_ROLE, "current user"),
|
|
];
|
|
|
|
assert_eq!(
|
|
recent_input(&items),
|
|
Some(SearchInput::Items(vec![
|
|
message(USER_ROLE, "previous user"),
|
|
message(ASSISTANT_ROLE, "previous assistant"),
|
|
message(USER_ROLE, "current user"),
|
|
]))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_contextual_user_messages_when_selecting_recent_turns() {
|
|
let items = vec![
|
|
message(USER_ROLE, "previous user"),
|
|
message(ASSISTANT_ROLE, "previous assistant"),
|
|
message(
|
|
USER_ROLE,
|
|
"<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>",
|
|
),
|
|
message(USER_ROLE, "current user"),
|
|
];
|
|
|
|
assert_eq!(
|
|
recent_input(&items),
|
|
Some(SearchInput::Items(vec![
|
|
message(USER_ROLE, "previous user"),
|
|
message(ASSISTANT_ROLE, "previous assistant"),
|
|
message(USER_ROLE, "current user"),
|
|
]))
|
|
);
|
|
}
|
|
}
|