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.
This commit is contained in:
jif
2026-06-03 13:30:04 +02:00
committed by GitHub
Unverified
parent ac67905fc4
commit 51493157cd
2 changed files with 142 additions and 0 deletions
@@ -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;
@@ -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,