From 51493157cd1fda08343e521ef47ee002009b8a40 Mon Sep 17 00:00:00 2001 From: jif Date: Wed, 3 Jun 2026 13:30:04 +0200 Subject: [PATCH] Reject MAv2 close_agent self-targets (#26144) ## Why `close_agent` is a parent-owned coordination tool: a worker should return its result, then let its parent decide when to close it. Before this change, if an MAv2 worker targeted itself, the resolved target could flow through the normal close path and ask the agent control layer to close the current conversation. ## What changed - Reject `close_agent` when the resolved target is the current session's `conversation_id`, returning a model-visible error that tells the worker to return its result instead. - Keep the guard after target resolution so it covers both thread-id targets and task-path targets. - Add coverage for self-targeting by thread id and by task name in `multi_agents_tests.rs`. Relevant code: - [`handle_close_agent`](https://github.com/openai/codex/blob/7c24e6641b693a3eed933dd376ce8f424ab6ea5f/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs#L39-L57) - [`multi_agent_v2_close_agent_rejects_self_target_by_id` / `multi_agent_v2_close_agent_rejects_self_target_by_task_name`](https://github.com/openai/codex/blob/7c24e6641b693a3eed933dd376ce8f424ab6ea5f/codex-rs/core/src/tools/handlers/multi_agents_tests.rs#L3936-L4070) ## Testing Not run locally. --- .../src/tools/handlers/multi_agents_tests.rs | 136 ++++++++++++++++++ .../handlers/multi_agents_v2/close_agent.rs | 6 + 2 files changed, 142 insertions(+) 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 e33d94234..3d8e8401b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3933,6 +3933,142 @@ async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { ); } +#[tokio::test] +async fn multi_agent_v2_close_agent_rejects_self_target_by_id() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); + 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 child_path = AgentPath::try_from("/root/worker").expect("agent path"); + let child_thread_id = session + .services + .agent_control + .spawn_agent_with_metadata( + (*turn.config).clone(), + vec![UserInput::Text { + text: "inspect this repo".to_string(), + text_elements: Vec::new(), + }] + .into(), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker spawn should succeed") + .thread_id; + session.conversation_id = child_thread_id; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path), + agent_nickname: None, + agent_role: None, + }); + + let err = CloseAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "close_agent", + function_payload(json!({"target": child_thread_id.to_string()})), + )) + .await + .err() + .expect("close_agent should reject self-target by id"); + assert_eq!( + err, + FunctionCallError::RespondToModel( + "an agent cannot close itself; return your result and let the parent close you if needed" + .to_string() + ) + ); +} + +#[tokio::test] +async fn multi_agent_v2_close_agent_rejects_self_target_by_task_name() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); + 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 child_path = AgentPath::try_from("/root/worker").expect("agent path"); + let child_thread_id = session + .services + .agent_control + .spawn_agent_with_metadata( + (*turn.config).clone(), + vec![UserInput::Text { + text: "inspect this repo".to_string(), + text_elements: Vec::new(), + }] + .into(), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker spawn should succeed") + .thread_id; + session.conversation_id = child_thread_id; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + }); + + let err = CloseAgentHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "close_agent", + function_payload(json!({"target": child_path.to_string()})), + )) + .await + .err() + .expect("close_agent should reject self-target by task name"); + assert_eq!( + err, + FunctionCallError::RespondToModel( + "an agent cannot close itself; return your result and let the parent close you if needed" + .to_string() + ) + ); +} + #[tokio::test] async fn close_agent_submits_shutdown_and_returns_previous_status() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 15502572a..0f7238cdb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -49,6 +49,12 @@ async fn handle_close_agent( "root is not a spawned agent".to_string(), )); } + if agent_id == session.conversation_id { + return Err(FunctionCallError::RespondToModel( + "an agent cannot close itself; return your result and let the parent close you if needed" + .to_string(), + )); + } session .send_event( &turn,