diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index e57491762..7f1f96656 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -321,6 +321,40 @@ async fn spawn_agent_returns_agent_id_without_task_name() { assert_eq!(success, Some(true)); } +#[tokio::test] +async fn multi_agent_v2_spawn_requires_task_name() { + 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(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo" + })), + ); + let Err(err) = SpawnAgentHandlerV2.handle(invocation).await else { + panic!("missing task_name should be rejected"); + }; + let FunctionCallError::RespondToModel(message) = err else { + panic!("missing task_name should surface as a model-facing error"); + }; + assert!(message.contains("missing field `task_name`")); +} + #[tokio::test] async fn spawn_agent_errors_when_manager_dropped() { let (session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index d67be936e..6cdad3c1c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -83,7 +83,7 @@ impl ToolHandler for Handler { &turn.session_source, child_depth, role_name, - args.task_name.clone(), + Some(args.task_name.clone()), )?), SpawnAgentOptions { fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), @@ -132,7 +132,6 @@ impl ToolHandler for Handler { .and_then(|snapshot| snapshot.reasoning_effort) .unwrap_or(args.reasoning_effort.unwrap_or_default()); let nickname = new_agent_nickname.clone(); - let task_name = new_agent_path.clone(); session .send_event( &turn, @@ -150,16 +149,21 @@ impl ToolHandler for Handler { .into(), ) .await; - let new_thread_id = result?.thread_id; + let _ = result?; let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); turn.session_telemetry.counter( "codex.multi_agent.spawn", /*inc*/ 1, &[("role", role_tag)], ); + let task_name = new_agent_path.ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawned agent is missing a canonical task name".to_string(), + ) + })?; Ok(SpawnAgentResult { - agent_id: task_name.is_none().then(|| new_thread_id.to_string()), + agent_id: None, task_name, nickname, }) @@ -170,7 +174,7 @@ impl ToolHandler for Handler { struct SpawnAgentArgs { message: Option, items: Option>, - task_name: Option, + task_name: String, agent_type: Option, model: Option, reasoning_effort: Option, @@ -181,7 +185,7 @@ struct SpawnAgentArgs { #[derive(Debug, Serialize)] pub(crate) struct SpawnAgentResult { agent_id: Option, - task_name: Option, + task_name: String, nickname: Option, } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index ea85eaacd..1a2f16b5c 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -157,10 +157,10 @@ fn spawn_agent_output_schema_v2() -> JsonValue { "properties": { "agent_id": { "type": ["string", "null"], - "description": "Thread identifier for the spawned agent when no task name was assigned." + "description": "Legacy thread identifier for the spawned agent." }, "task_name": { - "type": ["string", "null"], + "type": "string", "description": "Canonical task name for the spawned agent." }, "nickname": { @@ -1215,13 +1215,13 @@ fn create_spawn_agent_tool_v1(config: &ToolsConfig) -> ToolSpec { fn create_spawn_agent_tool_v2(config: &ToolsConfig) -> ToolSpec { let available_models_description = spawn_agent_models_description(&config.available_models); - let return_value_description = "Returns the canonical task name when the spawned agent was named, otherwise the agent id, plus the user-facing nickname when available."; + let return_value_description = "Returns the canonical task name for the spawned agent, plus the user-facing nickname when available."; let mut properties = spawn_agent_common_properties(config); properties.insert( "task_name".to_string(), JsonSchema::String { description: Some( - "Optional task name for the new agent. Use lowercase letters, digits, and underscores." + "Task name for the new agent. Use lowercase letters, digits, and underscores." .to_string(), ), }, @@ -1237,7 +1237,7 @@ fn create_spawn_agent_tool_v2(config: &ToolsConfig) -> ToolSpec { defer_loading: None, parameters: JsonSchema::Object { properties, - required: None, + required: Some(vec!["task_name".to_string()]), additional_properties: Some(false.into()), }, output_schema: Some(spawn_agent_output_schema_v2()), diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 5b9016ec6..b7b9f001e 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -454,7 +454,7 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { panic!("spawn_agent should use object params"); }; assert!(properties.contains_key("task_name")); - assert_eq!(required.as_ref(), None); + assert_eq!(required.as_ref(), Some(&vec!["task_name".to_string()])); let output_schema = output_schema .as_ref() .expect("spawn_agent should define output schema");