feat: description multi-agent v2 (#17338)

This commit is contained in:
jif-oai
2026-04-10 15:31:32 +01:00
committed by GitHub
Unverified
parent 8d58899297
commit d39a722865
3 changed files with 101 additions and 21 deletions
+10 -4
View File
@@ -661,8 +661,11 @@ fn spawn_agent_description_omits_usage_hint_when_disabled() {
r#"(?sx)
^\s*
No\ picker-visible\ models\ are\ currently\ loaded\.
\s+Spawn\ a\ sub-agent\ for\ a\ well-scoped\ task\.
\s+Returns\ the\ canonical\ task\ name\ for\ the\ spawned\ agent,\ plus\ the\ user-facing\ nickname\ when\ available\.
\s+Spawns\ an\ agent\ to\ work\ on\ the\ specified\ task\.\ If\ your\ current\ task\ is\ `/root/task1`\ and\ you\ spawn_agent\ with\ task_name\ "task_3"\ the\ agent\ will\ have\ canonical\ task\ name\ `/root/task1/task_3`\.
\s+You\ are\ then\ able\ to\ refer\ to\ this\ agent\ as\ `task_3`\ or\ `/root/task1/task_3`\ interchangeably\.\ However\ an\ agent\ `/root/task2/task_3`\ would\ only\ be\ able\ to\ communicate\ with\ this\ agent\ via\ its\ canonical\ name\ `/root/task1/task_3`\.
\s+The\ spawned\ agent\ will\ have\ the\ same\ tools\ as\ you\ and\ the\ ability\ to\ spawn\ its\ own\ subagents\.
\s+It\ will\ be\ able\ to\ send\ you\ and\ other\ running\ agents\ messages,\ and\ its\ final\ answer\ will\ be\ provided\ to\ you\ when\ it\ finishes\.
\s+The\ new\ agent's\ canonical\ task\ name\ will\ be\ provided\ to\ it\ along\ with\ the\ message\.
\s*$
"#,
&description,
@@ -680,8 +683,11 @@ fn spawn_agent_description_uses_configured_usage_hint_text() {
r#"(?sx)
^\s*
No\ picker-visible\ models\ are\ currently\ loaded\.
\s+Spawn\ a\ sub-agent\ for\ a\ well-scoped\ task\.
\s+Returns\ the\ canonical\ task\ name\ for\ the\ spawned\ agent,\ plus\ the\ user-facing\ nickname\ when\ available\.
\s+Spawns\ an\ agent\ to\ work\ on\ the\ specified\ task\.\ If\ your\ current\ task\ is\ `/root/task1`\ and\ you\ spawn_agent\ with\ task_name\ "task_3"\ the\ agent\ will\ have\ canonical\ task\ name\ `/root/task1/task_3`\.
\s+You\ are\ then\ able\ to\ refer\ to\ this\ agent\ as\ `task_3`\ or\ `/root/task1/task_3`\ interchangeably\.\ However\ an\ agent\ `/root/task2/task_3`\ would\ only\ be\ able\ to\ communicate\ with\ this\ agent\ via\ its\ canonical\ name\ `/root/task1/task_3`\.
\s+The\ spawned\ agent\ will\ have\ the\ same\ tools\ as\ you\ and\ the\ ability\ to\ spawn\ its\ own\ subagents\.
\s+It\ will\ be\ able\ to\ send\ you\ and\ other\ running\ agents\ messages,\ and\ its\ final\ answer\ will\ be\ provided\ to\ you\ when\ it\ finishes\.
\s+The\ new\ agent's\ canonical\ task\ name\ will\ be\ provided\ to\ it\ along\ with\ the\ message\.
\s+Custom\ delegation\ guidance\ only\.
\s*$
"#,
+45 -17
View File
@@ -50,11 +50,6 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions<'_>) -> ToolSpe
pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpec {
let available_models_description = (!options.hide_agent_type_model_reasoning)
.then(|| spawn_agent_models_description(options.available_models));
let return_value_description = if options.hide_agent_type_model_reasoning {
"Returns the canonical task name for the spawned agent."
} else {
"Returns the canonical task name for the spawned agent, plus the user-facing nickname when available."
};
let mut properties = spawn_agent_common_properties_v2(&options.agent_type_description);
if options.hide_agent_type_model_reasoning {
hide_spawn_agent_metadata_options(&mut properties);
@@ -69,9 +64,8 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpe
ToolSpec::Function(ResponsesApiTool {
name: "spawn_agent".to_string(),
description: spawn_agent_tool_description(
description: spawn_agent_tool_description_v2(
available_models_description.as_deref(),
return_value_description,
options.include_usage_hint,
options.usage_hint_text,
),
@@ -127,7 +121,7 @@ pub fn create_send_message_tool() -> ToolSpec {
(
"target".to_string(),
JsonSchema::string(Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
"Relative or canonical task name to message (from spawn_agent).".to_string(),
)),
),
(
@@ -140,11 +134,15 @@ pub fn create_send_message_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Add a message to an existing agent without triggering a new turn. In MultiAgentV2, this tool currently supports text content only."
description: "Send a string message to an existing agent without triggering a new turn."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, Some(vec!["target".to_string(), "message".to_string()]), Some(false.into())),
parameters: JsonSchema::object(
properties,
Some(vec!["target".to_string(), "message".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
@@ -166,7 +164,7 @@ pub fn create_followup_task_tool() -> ToolSpec {
(
"interrupt".to_string(),
JsonSchema::boolean(Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message; if the target is already running, it starts the target's next turn after the current turn completes."
.to_string(),
)),
),
@@ -174,7 +172,7 @@ pub fn create_followup_task_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "followup_task".to_string(),
description: "Add a message to an existing non-root agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. In MultiAgentV2, this tool currently supports text content only."
description: "Send a string message to an existing non-root agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. If interrupt=false and the target's turn has not completed, the message is queued and starts the target's next turn after the current turn completes."
.to_string(),
strict: false,
defer_loading: None,
@@ -216,7 +214,7 @@ pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec {
pub fn create_wait_agent_tool_v2(options: WaitAgentTimeoutOptions) -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "wait_agent".to_string(),
description: "Wait for a mailbox update from any live agent, including queued messages and final-status notifications. Returns a brief wait summary instead of agent content, or a timeout summary if no mailbox update arrives before the deadline."
description: "Wait for a mailbox update from any live agent, including queued messages and final-status notifications. Does not return the content; returns either a summary of which agents have updates (if any), or a timeout summary if no mailbox update arrives before the deadline."
.to_string(),
strict: false,
defer_loading: None,
@@ -229,7 +227,7 @@ pub fn create_list_agents_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"path_prefix".to_string(),
JsonSchema::string(Some(
"Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets."
"Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax."
.to_string(),
)),
)]);
@@ -448,7 +446,7 @@ fn wait_output_schema_v2() -> Value {
},
"timed_out": {
"type": "boolean",
"description": "Whether the wait call returned due to timeout before any agent reached a final status."
"description": "Whether the wait call returned because no mailbox update arrived before the timeout."
}
},
"required": ["message", "timed_out"],
@@ -556,7 +554,7 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
(
"fork_turns".to_string(),
JsonSchema::string(Some(
"Optional MultiAgentV2 fork mode. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns."
"Optional number of turns to fork. Defaults to `all`. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns."
.to_string(),
)),
),
@@ -652,6 +650,36 @@ Requests for depth, thoroughness, research, investigation, or detailed codebase
)
}
fn spawn_agent_tool_description_v2(
available_models_description: Option<&str>,
include_usage_hint: bool,
usage_hint_text: Option<String>,
) -> String {
let agent_role_guidance = available_models_description.unwrap_or_default();
let tool_description = format!(
r#"
{agent_role_guidance}
Spawns an agent to work on the specified task. If your current task is `/root/task1` and you spawn_agent with task_name "task_3" the agent will have canonical task name `/root/task1/task_3`.
You are then able to refer to this agent as `task_3` or `/root/task1/task_3` interchangeably. However an agent `/root/task2/task_3` would only be able to communicate with this agent via its canonical name `/root/task1/task_3`.
The spawned agent will have the same tools as you and the ability to spawn its own subagents.
It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes.
The new agent's canonical task name will be provided to it along with the message."#
);
if !include_usage_hint {
return tool_description;
}
if let Some(usage_hint_text) = usage_hint_text {
return format!(
r#"
{tool_description}
{usage_hint_text}"#
);
}
tool_description
}
fn spawn_agent_models_description(models: &[ModelPreset]) -> String {
let visible_models: Vec<&ModelPreset> =
models.iter().filter(|model| model.show_in_picker).collect();
@@ -713,7 +741,7 @@ fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema
let properties = BTreeMap::from([(
"timeout_ms".to_string(),
JsonSchema::number(Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
))),
)]);
+46
View File
@@ -59,6 +59,8 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
.properties
.as_ref()
.expect("spawn_agent should use object params");
assert!(description.contains("Spawns an agent to work on the specified task."));
assert!(description.contains("The spawned agent will have the same tools as you"));
assert!(description.contains("visible display (`visible-model`)"));
assert!(!description.contains("hidden display (`hidden-model`)"));
assert!(properties.contains_key("task_name"));
@@ -109,6 +111,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
#[test]
fn send_message_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
@@ -128,6 +131,16 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
description,
"Send a string message to an existing agent without triggering a new turn."
);
assert_eq!(
properties
.get("target")
.and_then(|schema| schema.description.as_deref()),
Some("Relative or canonical task name to message (from spawn_agent).")
);
assert_eq!(
parameters.required.as_ref(),
Some(&vec!["target".to_string(), "message".to_string()])
@@ -138,6 +151,7 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
#[test]
fn followup_task_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
@@ -157,6 +171,20 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() {
assert!(properties.contains_key("message"));
assert!(properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert!(description.contains(
"Send a string message to an existing non-root agent and trigger a turn in the target."
));
assert!(description.contains(
"If interrupt=false and the target's turn has not completed, the message is queued"
));
assert_eq!(
properties
.get("interrupt")
.and_then(|schema| schema.description.as_deref()),
Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message; if the target is already running, it starts the target's next turn after the current turn completes."
)
);
assert_eq!(
parameters.required.as_ref(),
Some(&vec!["target".to_string(), "message".to_string()])
@@ -167,6 +195,7 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() {
#[test]
fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
@@ -188,6 +217,15 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
.expect("wait_agent should use object params");
assert!(!properties.contains_key("targets"));
assert!(properties.contains_key("timeout_ms"));
assert!(description.contains(
"Does not return the content; returns either a summary of which agents have updates (if any)"
));
assert_eq!(
properties
.get("timeout_ms")
.and_then(|schema| schema.description.as_deref()),
Some("Optional timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.")
);
assert_eq!(parameters.required.as_ref(), None);
assert_eq!(
output_schema.expect("wait output schema")["properties"]["message"]["description"],
@@ -214,6 +252,14 @@ fn list_agents_tool_includes_path_prefix_and_agent_fields() {
.as_ref()
.expect("list_agents should use object params");
assert!(properties.contains_key("path_prefix"));
assert_eq!(
properties
.get("path_prefix")
.and_then(|schema| schema.description.as_deref()),
Some(
"Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax."
)
);
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["required"],
json!(["agent_name", "agent_status", "last_task_message"])