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:
jif-oai
2026-03-24 11:24:08 +00:00
committed by GitHub
parent 567832c6fe
commit 38c088ba8d
9 changed files with 660 additions and 22 deletions
@@ -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")
}
}
+67
View File
@@ -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));
+38
View File
@@ -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");
}