mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: list agents for sub-agent v2 (#15621)
Add a `list_agents` for multi-agent v2, optionally path based This return the task and status of each agent in the matched path
This commit is contained in:
committed by
GitHub
Unverified
parent
567832c6fe
commit
38c088ba8d
@@ -5,8 +5,10 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::event_mapping::parse_turn_item;
|
||||
use crate::find_archived_thread_path_by_id_str;
|
||||
use crate::find_thread_path_by_id_str;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
@@ -18,7 +20,10 @@ use crate::thread_manager::ThreadManagerState;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
@@ -29,6 +34,7 @@ use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
@@ -51,6 +57,13 @@ pub(crate) struct LiveAgent {
|
||||
pub(crate) status: AgentStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub(crate) struct ListedAgent {
|
||||
pub(crate) agent_name: String,
|
||||
pub(crate) agent_status: AgentStatus,
|
||||
pub(crate) last_task_message: Option<String>,
|
||||
}
|
||||
|
||||
fn default_agent_nickname_list() -> Vec<&'static str> {
|
||||
AGENT_NAMES
|
||||
.lines()
|
||||
@@ -456,21 +469,28 @@ impl AgentControl {
|
||||
agent_id: ThreadId,
|
||||
items: Vec<UserInput>,
|
||||
) -> CodexResult<String> {
|
||||
let last_task_message = render_input_preview(&items);
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
let result = self
|
||||
.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(
|
||||
agent_id,
|
||||
Op::UserInput {
|
||||
items,
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
self.state
|
||||
.update_last_task_message(agent_id, last_task_message);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Append a prebuilt message to an existing agent thread outside the normal user-input path.
|
||||
@@ -494,15 +514,22 @@ impl AgentControl {
|
||||
agent_id: ThreadId,
|
||||
communication: InterAgentCommunication,
|
||||
) -> CodexResult<String> {
|
||||
let last_task_message = communication.content.clone();
|
||||
let state = self.upgrade()?;
|
||||
self.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(agent_id, Op::InterAgentCommunication { communication })
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
let result = self
|
||||
.handle_thread_request_result(
|
||||
agent_id,
|
||||
&state,
|
||||
state
|
||||
.send_op(agent_id, Op::InterAgentCommunication { communication })
|
||||
.await,
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() {
|
||||
self.state
|
||||
.update_last_task_message(agent_id, last_task_message);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Interrupt the current task for an existing agent thread.
|
||||
@@ -680,6 +707,69 @@ impl AgentControl {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub(crate) async fn list_agents(
|
||||
&self,
|
||||
current_session_source: &SessionSource,
|
||||
path_prefix: Option<&str>,
|
||||
) -> CodexResult<Vec<ListedAgent>> {
|
||||
let state = self.upgrade()?;
|
||||
let resolved_prefix = path_prefix
|
||||
.map(|prefix| {
|
||||
current_session_source
|
||||
.get_agent_path()
|
||||
.unwrap_or_else(AgentPath::root)
|
||||
.resolve(prefix)
|
||||
.map_err(CodexErr::UnsupportedOperation)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let mut live_agents = self.state.live_agents();
|
||||
live_agents.sort_by(|left, right| {
|
||||
left.agent_path
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.cmp(right.agent_path.as_deref().unwrap_or_default())
|
||||
.then_with(|| {
|
||||
left.agent_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default()
|
||||
.cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default())
|
||||
})
|
||||
});
|
||||
|
||||
let mut agents = Vec::with_capacity(live_agents.len());
|
||||
for metadata in live_agents {
|
||||
let Some(thread_id) = metadata.agent_id else {
|
||||
continue;
|
||||
};
|
||||
if resolved_prefix
|
||||
.as_ref()
|
||||
.is_some_and(|prefix| !agent_matches_prefix(metadata.agent_path.as_ref(), prefix))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(thread) = state.get_thread(thread_id).await else {
|
||||
continue;
|
||||
};
|
||||
let agent_name = metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| thread_id.to_string());
|
||||
agents.push(ListedAgent {
|
||||
agent_name,
|
||||
agent_status: thread.agent_status().await,
|
||||
last_task_message: metadata
|
||||
.last_task_message
|
||||
.clone()
|
||||
.or(last_task_message_for_thread(thread.as_ref()).await),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(agents)
|
||||
}
|
||||
|
||||
/// Starts a detached watcher for sub-agents spawned from another thread.
|
||||
///
|
||||
/// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and
|
||||
@@ -800,6 +890,7 @@ impl AgentControl {
|
||||
agent_path,
|
||||
agent_nickname,
|
||||
agent_role,
|
||||
last_task_message: None,
|
||||
};
|
||||
Ok((session_source, agent_metadata))
|
||||
}
|
||||
@@ -963,6 +1054,95 @@ fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option<Threa
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool {
|
||||
if prefix.is_root() {
|
||||
return true;
|
||||
}
|
||||
|
||||
agent_path.is_some_and(|agent_path| {
|
||||
agent_path == prefix
|
||||
|| agent_path
|
||||
.as_str()
|
||||
.strip_prefix(prefix.as_str())
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option<String> {
|
||||
let pending_input = thread.codex.session.pending_input_snapshot().await;
|
||||
if let Some(message) = pending_input
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(last_task_message_from_input_item)
|
||||
{
|
||||
return Some(message);
|
||||
}
|
||||
|
||||
let queued_input = thread
|
||||
.codex
|
||||
.session
|
||||
.queued_response_items_for_next_turn_snapshot()
|
||||
.await;
|
||||
if let Some(message) = queued_input
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(last_task_message_from_input_item)
|
||||
{
|
||||
return Some(message);
|
||||
}
|
||||
|
||||
let history = thread.codex.session.clone_history().await;
|
||||
history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(last_task_message_from_item)
|
||||
}
|
||||
|
||||
fn last_task_message_from_input_item(item: &ResponseInputItem) -> Option<String> {
|
||||
let response_item: ResponseItem = item.clone().into();
|
||||
last_task_message_from_item(&response_item)
|
||||
}
|
||||
|
||||
fn last_task_message_from_item(item: &ResponseItem) -> Option<String> {
|
||||
if !is_user_turn_boundary(item) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else {
|
||||
return None;
|
||||
};
|
||||
Some(render_input_preview(&message.content))
|
||||
}
|
||||
ResponseItem::Message { content, .. } => match content.as_slice() {
|
||||
[ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => {
|
||||
serde_json::from_str::<InterAgentCommunication>(text)
|
||||
.ok()
|
||||
.map(|communication| communication.content)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_input_preview(items: &[UserInput]) -> String {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
UserInput::Text { text, .. } => text.clone(),
|
||||
UserInput::Image { .. } => "[image]".to_string(),
|
||||
UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()),
|
||||
UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()),
|
||||
UserInput::Mention { name, path } => format!("[mention:${name}]({path})"),
|
||||
_ => "[input]".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn thread_spawn_depth(session_source: &SessionSource) -> Option<i32> {
|
||||
match session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth),
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(crate) struct AgentMetadata {
|
||||
pub(crate) agent_path: Option<AgentPath>,
|
||||
pub(crate) agent_nickname: Option<String>,
|
||||
pub(crate) agent_role: Option<String>,
|
||||
pub(crate) last_task_message: Option<String>,
|
||||
}
|
||||
|
||||
fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String {
|
||||
@@ -151,6 +152,34 @@ impl AgentRegistry {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn live_agents(&self) -> Vec<AgentMetadata> {
|
||||
self.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.agent_tree
|
||||
.values()
|
||||
.filter(|metadata| {
|
||||
metadata.agent_id.is_some()
|
||||
&& !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn update_last_task_message(&self, thread_id: ThreadId, last_task_message: String) {
|
||||
let mut active_agents = self
|
||||
.active_agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(metadata) = active_agents
|
||||
.agent_tree
|
||||
.values_mut()
|
||||
.find(|metadata| metadata.agent_id == Some(thread_id))
|
||||
{
|
||||
metadata.last_task_message = Some(last_task_message);
|
||||
}
|
||||
}
|
||||
|
||||
fn register_spawned_thread(&self, agent_metadata: AgentMetadata) {
|
||||
let Some(thread_id) = agent_metadata.agent_id else {
|
||||
return;
|
||||
|
||||
@@ -3956,6 +3956,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
ts.pending_input_snapshot()
|
||||
}
|
||||
None => Vec::with_capacity(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue response items to be injected into the next active turn created for this session.
|
||||
pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec<ResponseInputItem>) {
|
||||
if items.is_empty() {
|
||||
@@ -3970,6 +3981,12 @@ impl Session {
|
||||
std::mem::take(&mut *self.idle_pending_input.lock().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn queued_response_items_for_next_turn_snapshot(
|
||||
&self,
|
||||
) -> Vec<ResponseInputItem> {
|
||||
self.idle_pending_input.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool {
|
||||
!self.idle_pending_input.lock().await.is_empty()
|
||||
}
|
||||
|
||||
@@ -198,6 +198,10 @@ impl TurnState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pending_input_snapshot(&self) -> Vec<ResponseInputItem> {
|
||||
self.pending_input.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_input(&self) -> bool {
|
||||
!self.pending_input.is_empty()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
@@ -156,6 +157,18 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListAgentsResult {
|
||||
agents: Vec<ListedAgentResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListedAgentResult {
|
||||
agent_name: String,
|
||||
agent_status: serde_json::Value,
|
||||
last_task_message: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_rejects_non_function_payloads() {
|
||||
let (session, turn) = make_session_and_context().await;
|
||||
@@ -413,6 +426,226 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path(
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_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).clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let spawn_output = SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let _ = expect_text_output(spawn_output);
|
||||
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker path should resolve");
|
||||
let child_thread = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
.expect("child thread should exist");
|
||||
let child_turn = child_thread.codex.session.new_default_turn().await;
|
||||
child_thread
|
||||
.codex
|
||||
.session
|
||||
.send_event(
|
||||
child_turn.as_ref(),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: child_turn.sub_id.clone(),
|
||||
last_agent_message: Some("done".to_string()),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let output = ListAgentsHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"list_agents",
|
||||
function_payload(json!({})),
|
||||
))
|
||||
.await
|
||||
.expect("list_agents should succeed");
|
||||
let (content, success) = expect_text_output(output);
|
||||
let result: ListAgentsResult =
|
||||
serde_json::from_str(&content).expect("list_agents result should be json");
|
||||
|
||||
assert_eq!(result.agents.len(), 1);
|
||||
assert_eq!(result.agents[0].agent_name, "/root/worker");
|
||||
assert_eq!(result.agents[0].agent_status, json!({"completed": "done"}));
|
||||
assert_eq!(
|
||||
result.agents[0].last_task_message.as_deref(),
|
||||
Some("inspect this repo")
|
||||
);
|
||||
assert_eq!(success, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
|
||||
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).clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config.clone());
|
||||
|
||||
let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path");
|
||||
let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path");
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent_with_metadata(
|
||||
config.clone(),
|
||||
vec![UserInput::Text {
|
||||
text: "research".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(researcher_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
crate::agent::control::SpawnAgentOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("researcher agent should spawn");
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent_with_metadata(
|
||||
config,
|
||||
vec![UserInput::Text {
|
||||
text: "build".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 2,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
crate::agent::control::SpawnAgentOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("worker agent should spawn");
|
||||
|
||||
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(researcher_path),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
});
|
||||
|
||||
let output = ListAgentsHandlerV2
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"list_agents",
|
||||
function_payload(json!({
|
||||
"path_prefix": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("list_agents should succeed");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: ListAgentsResult =
|
||||
serde_json::from_str(&content).expect("list_agents result should be json");
|
||||
|
||||
assert_eq!(result.agents.len(), 1);
|
||||
assert_eq!(result.agents[0].agent_name, worker_path.as_str());
|
||||
assert_eq!(result.agents[0].last_task_message.as_deref(), Some("build"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_list_agents_omits_closed_agents() {
|
||||
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).clone();
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
turn.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let spawn_output = SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
let _ = expect_text_output(spawn_output);
|
||||
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.await
|
||||
.expect("worker path should resolve");
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.close_agent(agent_id)
|
||||
.await
|
||||
.expect("close_agent should succeed");
|
||||
|
||||
let output = ListAgentsHandlerV2
|
||||
.handle(invocation(
|
||||
session,
|
||||
turn,
|
||||
"list_agents",
|
||||
function_payload(json!({})),
|
||||
))
|
||||
.await
|
||||
.expect("list_agents should succeed");
|
||||
let (content, _) = expect_text_output(output);
|
||||
let result: ListAgentsResult =
|
||||
serde_json::from_str(&content).expect("list_agents result should be json");
|
||||
|
||||
assert!(result.agents.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_send_input_accepts_structured_items() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
|
||||
@@ -30,10 +30,12 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(crate) use list_agents::Handler as ListAgentsHandler;
|
||||
pub(crate) use send_input::Handler as SendInputHandler;
|
||||
pub(crate) use spawn::Handler as SpawnAgentHandler;
|
||||
pub(crate) use wait::Handler as WaitAgentHandler;
|
||||
|
||||
mod list_agents;
|
||||
mod send_input;
|
||||
mod spawn;
|
||||
pub(crate) mod wait;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
use crate::agent::control::ListedAgent;
|
||||
|
||||
pub(crate) struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for Handler {
|
||||
type Output = ListAgentsResult;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
let arguments = function_arguments(payload)?;
|
||||
let args: ListAgentsArgs = parse_arguments(&arguments)?;
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
.register_session_root(session.conversation_id, &turn.session_source);
|
||||
let agents = session
|
||||
.services
|
||||
.agent_control
|
||||
.list_agents(&turn.session_source, args.path_prefix.as_deref())
|
||||
.await
|
||||
.map_err(collab_spawn_error)?;
|
||||
|
||||
Ok(ListAgentsResult { agents })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListAgentsArgs {
|
||||
path_prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ListAgentsResult {
|
||||
agents: Vec<ListedAgent>,
|
||||
}
|
||||
|
||||
impl ToolOutput for ListAgentsResult {
|
||||
fn log_preview(&self) -> String {
|
||||
tool_output_json_text(self, "list_agents")
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
tool_output_response_item(call_id, payload, self, Some(true), "list_agents")
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
tool_output_code_mode_result(self, "list_agents")
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,39 @@ fn send_input_output_schema() -> JsonValue {
|
||||
})
|
||||
}
|
||||
|
||||
fn list_agents_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_name": {
|
||||
"type": "string",
|
||||
"description": "Canonical task name for the agent when available, otherwise the agent id."
|
||||
},
|
||||
"agent_status": {
|
||||
"description": "Last known status of the agent.",
|
||||
"allOf": [agent_status_output_schema()]
|
||||
},
|
||||
"last_task_message": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Most recent user or inter-agent instruction received by the agent, when available."
|
||||
}
|
||||
},
|
||||
"required": ["agent_name", "agent_status", "last_task_message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Live agents visible in the current root thread tree."
|
||||
}
|
||||
},
|
||||
"required": ["agents"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn resume_agent_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -1492,6 +1525,32 @@ fn create_wait_agent_tool_v2() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_list_agents_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([(
|
||||
"path_prefix".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
)]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "list_agents".to_string(),
|
||||
description: "List live agents in the current root thread tree. Optionally filter by task-path prefix."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(list_agents_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
fn create_request_user_input_tool(
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
) -> ToolSpec {
|
||||
@@ -2636,6 +2695,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::multi_agents::SendInputHandler;
|
||||
use crate::tools::handlers::multi_agents::SpawnAgentHandler;
|
||||
use crate::tools::handlers::multi_agents::WaitAgentHandler;
|
||||
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
@@ -3055,9 +3115,16 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
if config.multi_agent_v2 {
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_list_agents_tool(),
|
||||
/*supports_parallel_tool_calls*/ false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandlerV2));
|
||||
builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2));
|
||||
builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2));
|
||||
} else {
|
||||
builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler));
|
||||
builder.register_handler("send_input", Arc::new(SendInputHandler));
|
||||
|
||||
@@ -525,6 +525,7 @@ fn test_build_specs_collab_tools_enabled() {
|
||||
&["spawn_agent", "send_input", "wait_agent", "close_agent"],
|
||||
);
|
||||
assert_lacks_tool_name(&tools, "spawn_agents_on_csv");
|
||||
assert_lacks_tool_name(&tools, "list_agents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -545,6 +546,16 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(
|
||||
&tools,
|
||||
&[
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
"list_agents",
|
||||
],
|
||||
);
|
||||
|
||||
let spawn_agent = find_tool(&tools, "spawn_agent");
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
@@ -614,6 +625,33 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
||||
output_schema["properties"]["message"]["description"],
|
||||
json!("Brief wait summary without the agent's final content.")
|
||||
);
|
||||
|
||||
let list_agents = find_tool(&tools, "list_agents");
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
parameters,
|
||||
output_schema,
|
||||
..
|
||||
}) = &list_agents.spec
|
||||
else {
|
||||
panic!("list_agents should be a function tool");
|
||||
};
|
||||
let JsonSchema::Object {
|
||||
properties,
|
||||
required,
|
||||
..
|
||||
} = parameters
|
||||
else {
|
||||
panic!("list_agents should use object params");
|
||||
};
|
||||
assert!(properties.contains_key("path_prefix"));
|
||||
assert_eq!(required.as_ref(), None);
|
||||
let output_schema = output_schema
|
||||
.as_ref()
|
||||
.expect("list_agents should define output schema");
|
||||
assert_eq!(
|
||||
output_schema["properties"]["agents"]["items"]["required"],
|
||||
json!(["agent_name", "agent_status", "last_task_message"])
|
||||
);
|
||||
assert_lacks_tool_name(&tools, "resume_agent");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user