[codex] Rename multi-agent v2 assign_task to followup_task (#25636)

## Summary

Renames the MultiAgentV2 turn-triggering tool from `assign_task` to
`followup_task` so the exposed tool name better describes sending an
additional task to an existing agent.

This updates the tool spec, handler/module names, registry wiring,
default multi-agent v2 usage hints, and tests. Rollout trace
classification keeps accepting legacy `assign_task` events so older
traces still reduce correctly, while docs show the new tool name.

## Test plan

- `just test -p codex-core followup_task`
- `just test -p codex-core -E
'test(multi_agent_feature_selects_one_agent_tool_family) |
test(multi_agent_v2_can_use_configured_tool_namespace) |
test(code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools)'`
- `just test -p codex-rollout-trace`
- `just fix -p codex-core`
- `just fix -p codex-rollout-trace`

Notes: `just fmt` ran `cargo fmt` but failed in the Python ruff phase
because the local environment could not resolve `hatchling>=1.27.0` from
the configured internal registry. A full `just test -p codex-core` also
hit unrelated environment-sensitive integration failures involving
missing spawned test binaries/sandbox behavior; the changed multi-agent
spec/handler tests passed in the filtered runs above.
This commit is contained in:
jif-oai
2026-06-01 19:57:11 +02:00
committed by GitHub
Unverified
parent fb94703b21
commit 6ddb747e76
11 changed files with 69 additions and 44 deletions
+2 -2
View File
@@ -195,7 +195,7 @@ At the start of your turn, you are the active agent.
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents.
All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `assign_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn.
Child agents can also spawn their own sub-agents.
You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter.
@@ -212,7 +212,7 @@ const DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT: &str = r#"You are an agen
You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools.
You can use `spawn_agent` to create a new agent, `assign_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent.
You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent.
Child agents can also spawn their own sub-agents.
When you provide a response in the final channel, that content is immediately delivered back to your parent agent.
@@ -179,12 +179,13 @@ pub fn create_send_message_tool() -> ToolSpec {
})
}
pub fn create_assign_task_tool() -> ToolSpec {
pub fn create_followup_task_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::string(Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
"Agent id or canonical task name to send a follow-up task to (from spawn_agent)."
.to_string(),
)),
),
(
@@ -196,8 +197,8 @@ pub fn create_assign_task_tool() -> ToolSpec {
]);
ToolSpec::Function(ResponsesApiTool {
name: "assign_task".to_string(),
description: "Send a message to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes."
name: "followup_task".to_string(),
description: "Send a follow-up task to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes."
.to_string(),
strict: false,
defer_loading: None,
@@ -247,17 +247,17 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
}
#[test]
fn assign_task_tool_requires_message_and_has_no_output_schema() {
fn followup_task_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
name,
parameters,
output_schema,
..
}) = create_assign_task_tool()
}) = create_followup_task_tool()
else {
panic!("assign_task should be a function tool");
panic!("followup_task should be a function tool");
};
assert_eq!(name, "assign_task");
assert_eq!(name, "followup_task");
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
@@ -265,7 +265,7 @@ fn assign_task_tool_requires_message_and_has_no_output_schema() {
let properties = parameters
.properties
.as_ref()
.expect("assign_task should use object params");
.expect("followup_task should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("items"));
@@ -8,8 +8,8 @@ use crate::session::tests::make_session_and_context;
use crate::session_prefix::format_subagent_notification_message;
use crate::thread_manager::thread_store_from_config;
use crate::tools::context::ToolOutput;
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
@@ -1413,7 +1413,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
}
#[tokio::test]
async fn multi_agent_v2_assign_task_rejects_root_target_from_child() {
async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -1461,11 +1461,11 @@ async fn multi_agent_v2_assign_task_rejects_root_target_from_child() {
agent_role: None,
});
let Err(err) = AssignTaskHandlerV2
let Err(err) = FollowupTaskHandlerV2
.handle(invocation(
Arc::new(session),
Arc::new(turn),
"assign_task",
"followup_task",
function_payload(json!({
"target": "/root",
"message": "run this",
@@ -1473,12 +1473,14 @@ async fn multi_agent_v2_assign_task_rejects_root_target_from_child() {
))
.await
else {
panic!("assign_task should reject the root target");
panic!("followup_task should reject the root target");
};
assert_eq!(
err,
FunctionCallError::RespondToModel("Tasks can't be assigned to the root agent".to_string())
FunctionCallError::RespondToModel(
"Follow-up tasks can't target the root agent".to_string()
)
);
let root_ops = manager
.captured_ops()
@@ -1868,7 +1870,7 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
}
#[tokio::test]
async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -1923,18 +1925,18 @@ async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
)
.await;
AssignTaskHandlerV2
FollowupTaskHandlerV2
.handle(invocation(
session,
turn,
"assign_task",
"followup_task",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
})),
))
.await
.expect("assign_task should succeed");
.expect("followup_task should succeed");
let second_turn = thread.codex.session.new_default_turn().await;
thread
@@ -2003,7 +2005,7 @@ async fn multi_agent_v2_assign_task_completion_notifies_parent_on_every_turn() {
}
#[tokio::test]
async fn multi_agent_v2_assign_task_rejects_legacy_items_field() {
async fn multi_agent_v2_followup_task_rejects_legacy_items_field() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
@@ -2039,14 +2041,14 @@ async fn multi_agent_v2_assign_task_rejects_legacy_items_field() {
let invocation = invocation(
session,
turn,
"assign_task",
"followup_task",
function_payload(json!({
"target": agent_id.to_string(),
"items": [{"type": "text", "text": "continue"}],
})),
);
let Err(err) = AssignTaskHandlerV2.handle(invocation).await else {
let Err(err) = FollowupTaskHandlerV2.handle(invocation).await else {
panic!("legacy items field should be rejected in v2");
};
let FunctionCallError::RespondToModel(message) = err else {
@@ -28,15 +28,15 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
pub(crate) use assign_task::Handler as AssignTaskHandler;
pub(crate) use close_agent::Handler as CloseAgentHandler;
pub(crate) use followup_task::Handler as FollowupTaskHandler;
pub(crate) use list_agents::Handler as ListAgentsHandler;
pub(crate) use send_message::Handler as SendMessageHandler;
pub(crate) use spawn::Handler as SpawnAgentHandler;
pub(crate) use wait::Handler as WaitAgentHandler;
mod assign_task;
mod close_agent;
mod followup_task;
mod list_agents;
mod message_tool;
mod send_message;
@@ -1,8 +1,8 @@
use super::message_tool::AssignTaskArgs;
use super::message_tool::FollowupTaskArgs;
use super::message_tool::MessageDeliveryMode;
use super::message_tool::handle_message_string_tool;
use super::*;
use crate::tools::handlers::multi_agents_spec::create_assign_task_tool;
use crate::tools::handlers::multi_agents_spec::create_followup_task_tool;
use codex_tools::ToolSpec;
pub(crate) struct Handler;
@@ -10,11 +10,11 @@ pub(crate) struct Handler;
#[async_trait::async_trait]
impl ToolExecutor<ToolInvocation> for Handler {
fn tool_name(&self) -> ToolName {
ToolName::plain("assign_task")
ToolName::plain("followup_task")
}
fn spec(&self) -> ToolSpec {
create_assign_task_tool()
create_followup_task_tool()
}
async fn handle(
@@ -22,7 +22,7 @@ impl ToolExecutor<ToolInvocation> for Handler {
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let arguments = function_arguments(invocation.payload.clone())?;
let args: AssignTaskArgs = parse_arguments(&arguments)?;
let args: FollowupTaskArgs = parse_arguments(&arguments)?;
handle_message_string_tool(
invocation,
MessageDeliveryMode::TriggerTurn,
@@ -1,6 +1,6 @@
//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools.
//!
//! `send_message` and `assign_task` share the same submission path and differ only in whether the
//! `send_message` and `followup_task` share the same submission path and differ only in whether the
//! resulting `InterAgentCommunication` should wake the target immediately.
use super::*;
@@ -40,8 +40,8 @@ pub(crate) struct SendMessageArgs {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
/// Input for the MultiAgentV2 `assign_task` tool.
pub(crate) struct AssignTaskArgs {
/// Input for the MultiAgentV2 `followup_task` tool.
pub(crate) struct FollowupTaskArgs {
pub(crate) target: String,
pub(crate) message: String,
}
@@ -55,7 +55,7 @@ fn message_content(message: String) -> Result<String, FunctionCallError> {
Ok(message)
}
/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `assign_task`.
/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `followup_task`.
pub(crate) async fn handle_message_string_tool(
invocation: ToolInvocation,
mode: MessageDeliveryMode,
@@ -82,7 +82,7 @@ pub(crate) async fn handle_message_string_tool(
.is_some_and(AgentPath::is_root)
{
return Err(FunctionCallError::RespondToModel(
"Tasks can't be assigned to the root agent".to_string(),
"Follow-up tasks can't target the root agent".to_string(),
));
}
session
+2 -2
View File
@@ -38,8 +38,8 @@ use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
@@ -686,7 +686,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
exposure,
));
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(AssignTaskHandlerV2, tool_namespace),
multi_agent_v2_handler(FollowupTaskHandlerV2, tool_namespace),
exposure,
));
planned_tools.add_arc(override_tool_exposure(
+26 -4
View File
@@ -766,6 +766,7 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
"wait_agent",
"close_agent",
"send_message",
"followup_task",
"assign_task",
"list_agents",
]);
@@ -790,12 +791,12 @@ async fn multi_agent_feature_selects_one_agent_tool_family() {
v2.assert_visible_contains(&[
"spawn_agent",
"send_message",
"assign_task",
"followup_task",
"wait_agent",
"close_agent",
"list_agents",
]);
v2.assert_visible_lacks(&["send_input", "resume_agent"]);
v2.assert_visible_lacks(&["send_input", "resume_agent", "assign_task"]);
let spawn_agent_description = match v2.visible_spec("spawn_agent") {
ToolSpec::Function(tool) => tool.description.as_str(),
other => panic!("expected spawn_agent function spec, got {other:?}"),
@@ -892,10 +893,24 @@ async fn multi_agent_v2_can_use_configured_tool_namespace() {
.await;
namespaced.assert_visible_contains(&["agents"]);
namespaced.assert_visible_lacks(&["assign_task"]);
assert!(
!namespaced
.registered_names
.contains(&ToolName::namespaced("agents", "assign_task").to_string()),
"expected no namespaced runtime for assign_task"
);
assert!(
!namespaced
.namespace_function_names("agents")
.iter()
.any(|name| name == "assign_task"),
"expected assign_task to be absent from agents namespace"
);
for tool_name in [
"spawn_agent",
"send_message",
"assign_task",
"followup_task",
"wait_agent",
"close_agent",
"list_agents",
@@ -966,10 +981,17 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() {
.await;
assert_eq!(plan.visible_names, vec!["exec", "wait", "agents"]);
assert!(
!plan
.namespace_function_names("agents")
.iter()
.any(|name| name == "assign_task"),
"expected assign_task to be absent from agents namespace"
);
for tool_name in [
"spawn_agent",
"send_message",
"assign_task",
"followup_task",
"wait_agent",
"close_agent",
"list_agents",
+1 -1
View File
@@ -177,7 +177,7 @@ the edges between them.
```mermaid
flowchart LR
RootTool["root ToolCall\nspawn_agent / assign_task / send_message"]
RootTool["root ToolCall\nspawn_agent / followup_task / send_message"]
ChildInput["child ConversationItem\ninjected task/message"]
ChildThread["child AgentThread"]
ChildResult["child assistant ConversationItem\nresult message"]
+1 -1
View File
@@ -267,7 +267,7 @@ fn dispatched_tool_kind(tool_name: &str, _payload: &ToolDispatchPayload) -> Tool
"image_generation" | "image_query" => ToolCallKind::ImageGeneration,
"spawn_agent" => ToolCallKind::SpawnAgent,
"send_message" => ToolCallKind::SendMessage,
"assign_task" | "followup_task" => ToolCallKind::AssignAgentTask,
"followup_task" | "assign_task" => ToolCallKind::AssignAgentTask,
"wait_agent" => ToolCallKind::WaitAgent,
"close_agent" => ToolCallKind::CloseAgent,
other => ToolCallKind::Other {