mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: structured multi-agent output (#15515)
Send input now sends messages as assistant message and with this format: ``` author: /root/worker_a recipient: /root/worker_a/tester other_recipients: [] Content: bla bla bla. Actual content. Only text for now ```
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
use super::*;
|
||||
use crate::agent::inter_agent_instruction::InterAgentDelivery;
|
||||
use crate::agent::inter_agent_instruction::InterAgentInstruction;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
fn can_use_v2_inter_agent_instruction(items: &[UserInput]) -> bool {
|
||||
items
|
||||
.iter()
|
||||
.all(|item| matches!(item, UserInput::Text { .. }))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = SendInputResult;
|
||||
@@ -52,12 +60,40 @@ impl ToolHandler for Handler {
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.send_input(receiver_thread_id, input_items)
|
||||
.await
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err));
|
||||
let agent_control = session.services.agent_control.clone();
|
||||
let result = if turn.config.features.enabled(Feature::MultiAgentV2)
|
||||
&& can_use_v2_inter_agent_instruction(&input_items)
|
||||
{
|
||||
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"target agent is missing an agent_path".to_string(),
|
||||
)
|
||||
})?;
|
||||
let instruction = InterAgentInstruction::new(
|
||||
turn.session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root),
|
||||
receiver_agent_path,
|
||||
Vec::new(),
|
||||
prompt.clone(),
|
||||
);
|
||||
agent_control
|
||||
.deliver_inter_agent_instruction(
|
||||
receiver_thread_id,
|
||||
instruction,
|
||||
if args.interrupt {
|
||||
InterAgentDelivery::NextTurn
|
||||
} else {
|
||||
InterAgentDelivery::CurrentTurn
|
||||
},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
agent_control
|
||||
.send_input(receiver_thread_id, input_items)
|
||||
.await
|
||||
}
|
||||
.map_err(|err| collab_agent_error(receiver_thread_id, err));
|
||||
let status = session
|
||||
.services
|
||||
.agent_control
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::codex::make_session_and_context;
|
||||
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::AgentStatus;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::FileSystemSandboxPolicy;
|
||||
use crate::protocol::NetworkSandboxPolicy;
|
||||
@@ -14,6 +15,9 @@ use crate::protocol::Op;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::SessionSource;
|
||||
use crate::protocol::SubAgentSource;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_features::Feature;
|
||||
@@ -24,6 +28,7 @@ use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
@@ -33,6 +38,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn invocation(
|
||||
session: Arc<crate::codex::Session>,
|
||||
@@ -68,6 +74,31 @@ fn thread_manager() -> ThreadManager {
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NeverEndingTask;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionTask for NeverEndingTask {
|
||||
fn kind(&self) -> TaskKind {
|
||||
TaskKind::Regular
|
||||
}
|
||||
|
||||
fn span_name(&self) -> &'static str {
|
||||
"session_task.multi_agent_never_ending"
|
||||
}
|
||||
|
||||
async fn run(
|
||||
self: Arc<Self>,
|
||||
_session: Arc<SessionTaskContext>,
|
||||
_ctx: Arc<TurnContext>,
|
||||
_input: Vec<UserInput>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Option<String> {
|
||||
cancellation_token.cancelled().await;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_text_output<T>(output: T) -> (String, Option<bool>)
|
||||
where
|
||||
T: ToolOutput,
|
||||
@@ -337,6 +368,307 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
))
|
||||
.await
|
||||
.expect("send_input should accept v2 path");
|
||||
|
||||
let child_thread = manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should exist");
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = child_thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/test_process\nother_recipients: []\nContent: continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == "continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
if recorded && !saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("v2 send_input should record assistant envelope");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = turn.config.as_ref().clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config);
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
SpawnAgentHandler
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "boot worker",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn worker");
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker should resolve");
|
||||
let thread = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
.expect("worker thread should exist");
|
||||
let invocation = invocation(
|
||||
session,
|
||||
turn,
|
||||
"send_input",
|
||||
function_payload(json!({
|
||||
"target": agent_id.to_string(),
|
||||
"items": [
|
||||
{"type": "mention", "name": "drive", "path": "app://google_drive"},
|
||||
{"type": "text", "text": "read the folder"}
|
||||
]
|
||||
})),
|
||||
);
|
||||
|
||||
SendInputHandler
|
||||
.handle(invocation)
|
||||
.await
|
||||
.expect("structured items should be accepted in v2");
|
||||
|
||||
let expected = Op::UserInput {
|
||||
items: vec![
|
||||
UserInput::Mention {
|
||||
name: "drive".to_string(),
|
||||
path: "app://google_drive".to_string(),
|
||||
},
|
||||
UserInput::Text {
|
||||
text: "read the folder".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
],
|
||||
final_output_json_schema: None,
|
||||
};
|
||||
let captured = manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|(id, op)| *id == agent_id && *op == expected);
|
||||
assert_eq!(captured, Some((agent_id, expected)));
|
||||
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let recorded_assistant_envelope = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: [mention:$drive](app://google_drive)\nread the folder"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text }
|
||||
if text == "read the folder"
|
||||
|| text == "[mention:$drive](app://google_drive)\nread the folder"
|
||||
))
|
||||
)
|
||||
});
|
||||
if !recorded_assistant_envelope && saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("structured items should stay on the legacy user-input path");
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_send_input_interrupts_busy_child_without_losing_message() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.conversation_id = root.thread_id;
|
||||
let mut config = turn.config.as_ref().clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config);
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
SpawnAgentHandler
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "boot worker",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn worker");
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker should resolve");
|
||||
let thread = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
.expect("worker thread should exist");
|
||||
|
||||
let active_turn = thread.codex.session.new_default_turn().await;
|
||||
thread
|
||||
.codex
|
||||
.session
|
||||
.spawn_task(
|
||||
Arc::clone(&active_turn),
|
||||
vec![UserInput::Text {
|
||||
text: "working".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
NeverEndingTask,
|
||||
)
|
||||
.await;
|
||||
|
||||
SendInputHandler
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"send_input",
|
||||
function_payload(json!({
|
||||
"target": agent_id.to_string(),
|
||||
"message": "continue",
|
||||
"interrupt": true
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("interrupting v2 send_input should succeed");
|
||||
|
||||
let ops = manager.captured_ops();
|
||||
let ops_for_agent: Vec<&Op> = ops
|
||||
.iter()
|
||||
.filter_map(|(id, op)| (*id == agent_id).then_some(op))
|
||||
.collect();
|
||||
assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt)));
|
||||
assert!(!ops_for_agent.iter().any(|op| matches!(
|
||||
op,
|
||||
Op::UserInput { items, .. }
|
||||
if items.iter().any(|item| matches!(
|
||||
item,
|
||||
UserInput::Text { text, .. } if text == "continue"
|
||||
))
|
||||
)));
|
||||
|
||||
timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let history_items = thread
|
||||
.codex
|
||||
.session
|
||||
.clone_history()
|
||||
.await
|
||||
.raw_items()
|
||||
.to_vec();
|
||||
let saw_envelope = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "assistant"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::OutputText { text }
|
||||
if text
|
||||
== "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
let saw_user_message = history_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::Message { role, content, .. }
|
||||
if role == "user"
|
||||
&& content.iter().any(|content_item| matches!(
|
||||
content_item,
|
||||
ContentItem::InputText { text } if text == "continue"
|
||||
))
|
||||
)
|
||||
});
|
||||
if saw_envelope && !saw_user_message {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("interrupting v2 send_input should preserve the redirected message");
|
||||
|
||||
let _ = thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user