From d39a722865f5e6af210f752a6603329b2566f676 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 10 Apr 2026 15:31:32 +0100 Subject: [PATCH] feat: description multi-agent v2 (#17338) --- codex-rs/core/src/tools/spec_tests.rs | 14 ++++-- codex-rs/tools/src/agent_tool.rs | 62 +++++++++++++++++++------- codex-rs/tools/src/agent_tool_tests.rs | 46 +++++++++++++++++++ 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 9e519607a..14cef85b1 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -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*$ "#, diff --git a/codex-rs/tools/src/agent_tool.rs b/codex-rs/tools/src/agent_tool.rs index 6e75963ba..aa1699a4e 100644 --- a/codex-rs/tools/src/agent_tool.rs +++ b/codex-rs/tools/src/agent_tool.rs @@ -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, + include_usage_hint: bool, + usage_hint_text: Option, +) -> 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, ))), )]); diff --git a/codex-rs/tools/src/agent_tool_tests.rs b/codex-rs/tools/src/agent_tool_tests.rs index cb34fa4e6..e592e2b69 100644 --- a/codex-rs/tools/src/agent_tool_tests.rs +++ b/codex-rs/tools/src/agent_tool_tests.rs @@ -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"])