mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +00:00
Reload Multi-Agent V2 children through their parent (#40477)
## Why Multi-Agent V2 children are owned by their parent at runtime. Resuming an unloaded child directly could rebuild it from caller-provided settings instead of the parent's current authority. ## What changed - Route child reloads through their loaded immediate parent and reject direct cold resume when that parent is unavailable. - Preserve the child's recorded model, provider, reasoning effort, and role while inheriting the parent's execution policy and MCP extensions. - Validate cached environments against the parent, intersect local permission profiles, and reject stale remote or workspace-root configurations. - Keep paginated cold-resume token usage attribution when attaching to the reloaded child. ## Testing Add coverage for owner validation, nested-parent reloads, permission and environment changes, role instruction restoration, paginated usage replay, and app-server child resume behavior. GitOrigin-RevId: 84014762bcdb57f94fc23974c9c1af55dc1babf1
This commit is contained in:
1 parent
3a211471d2
commit
d21794d6ba
15 files changed
+1074
-143
No files matched your search
@@ -3,6 +3,7 @@ use crate::CodexThread;
|
||||
use crate::StateDbHandle;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::config::AgentRoleConfig;
|
||||
@@ -14,6 +15,7 @@ use crate::context::MultiAgentRoleInstructions;
|
||||
use crate::context::SubagentNotification;
|
||||
use crate::init_state_db;
|
||||
use crate::thread_manager::StartThreadOptions;
|
||||
use crate::tools::handlers::multi_agents_common::thread_spawn_source;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_extension_api::ExtensionDataInit;
|
||||
use codex_extension_api::empty_extension_registry;
|
||||
@@ -34,6 +36,8 @@ use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ContentItemKind;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
@@ -42,6 +46,7 @@ use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EnvironmentConfigState;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
@@ -706,34 +711,99 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
|
||||
let (home, mut config) = test_config().await;
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
let _ = config.features.enable(Feature::Sqlite);
|
||||
config.model = Some("gpt-5.6-sol".to_string());
|
||||
let harness = AgentControlHarness::new_with_config(home, config).await;
|
||||
let (parent_thread_id, _parent_thread) = harness.start_paginated_thread().await;
|
||||
let agent_path = AgentPath::try_from("/root/worker").expect("agent path");
|
||||
let mut child_config = harness.config.clone();
|
||||
child_config.model = Some("gpt-5.6-luna".to_string());
|
||||
let spawned_agent = harness
|
||||
.control
|
||||
check_v2_agent_reload(V2ReloadRoute::Sender).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_v2_child_loaded_preserves_evicted_parent_authority() {
|
||||
check_v2_agent_reload(V2ReloadRoute::NestedParent).await;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum V2ReloadRoute {
|
||||
Sender,
|
||||
NestedParent,
|
||||
}
|
||||
|
||||
async fn spawn_v2_reload_test_child(
|
||||
control: &AgentControl,
|
||||
config: Config,
|
||||
parent: &CodexThread,
|
||||
task_name: &str,
|
||||
) -> LiveAgent {
|
||||
let source = thread_spawn_source(
|
||||
parent.session.thread_id,
|
||||
&parent.session_source,
|
||||
next_thread_spawn_depth(&parent.session_source),
|
||||
/*agent_role*/ None,
|
||||
Some(task_name.to_string()),
|
||||
)
|
||||
.expect("child source");
|
||||
control
|
||||
.spawn_agent_with_metadata(
|
||||
child_config,
|
||||
config,
|
||||
text_input("hello child"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(agent_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
Some(source),
|
||||
SpawnAgentOptions {
|
||||
parent_thread_id: Some(parent_thread_id),
|
||||
parent_thread_id: Some(parent.session.thread_id),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("spawn_agent should succeed");
|
||||
.expect("spawn_agent should succeed")
|
||||
}
|
||||
|
||||
async fn check_v2_agent_reload(route: V2ReloadRoute) {
|
||||
let (home, mut config) = test_config().await;
|
||||
let _ = config.features.enable(Feature::MultiAgentV2);
|
||||
let _ = config.features.enable(Feature::Sqlite);
|
||||
config.model = Some("gpt-5.6-sol".to_string());
|
||||
config.multi_agent_v2.max_concurrent_threads_per_session = 3;
|
||||
config.permissions.allow_login_shell = true;
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::read_only())
|
||||
.expect("read-only parent profile");
|
||||
let harness = AgentControlHarness::new_with_config(home, config).await;
|
||||
let client_mcp_extensions =
|
||||
ClientMcpExtensions::new([(OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({}))]);
|
||||
let root = harness
|
||||
.manager
|
||||
.start_thread(StartThreadOptions {
|
||||
history_mode: Some(ThreadHistoryMode::Paginated),
|
||||
client_mcp_extensions: client_mcp_extensions.clone(),
|
||||
..StartThreadOptions::new(harness.config.clone())
|
||||
})
|
||||
.await
|
||||
.expect("start root thread");
|
||||
let control = root.thread.session.services.agent_control.clone();
|
||||
let parent_thread = match route {
|
||||
V2ReloadRoute::Sender => root.thread,
|
||||
V2ReloadRoute::NestedParent => {
|
||||
let parent = spawn_v2_reload_test_child(
|
||||
&control,
|
||||
harness.config.clone(),
|
||||
&root.thread,
|
||||
"parent",
|
||||
)
|
||||
.await;
|
||||
harness
|
||||
.manager
|
||||
.get_thread(parent.thread_id)
|
||||
.await
|
||||
.expect("nested parent should exist")
|
||||
}
|
||||
};
|
||||
let parent_thread_id = parent_thread.session.thread_id;
|
||||
let mut child_config = harness.config.clone();
|
||||
child_config.model = Some("gpt-5.6-luna".to_string());
|
||||
let spawned_agent =
|
||||
spawn_v2_reload_test_child(&control, child_config, &parent_thread, "worker").await;
|
||||
let agent_path = spawned_agent
|
||||
.metadata
|
||||
.agent_path
|
||||
.clone()
|
||||
.expect("agent path");
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(spawned_agent.thread_id)
|
||||
@@ -781,16 +851,68 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
|
||||
.cloned()
|
||||
.expect("ollama provider should be configured");
|
||||
|
||||
harness
|
||||
.control
|
||||
.ensure_v2_agent_loaded(sender_config, spawned_agent.thread_id)
|
||||
.await
|
||||
.expect("known v2 agent should reload");
|
||||
let mut parent_turn = parent_thread.session.new_default_turn().await;
|
||||
match route {
|
||||
V2ReloadRoute::Sender => control
|
||||
.ensure_v2_agent_loaded(sender_config, spawned_agent.thread_id, /*parent*/ None)
|
||||
.await
|
||||
.expect("known v2 agent should reload"),
|
||||
V2ReloadRoute::NestedParent => {
|
||||
let environment = parent_turn
|
||||
.environments
|
||||
.primary()
|
||||
.expect("parent environment");
|
||||
let thread_config = environment.config().clone();
|
||||
let mut owner_config = thread_config.clone();
|
||||
owner_config.allow_login_shell = false;
|
||||
let mut selection = environment.selection();
|
||||
selection.config = EnvironmentConfigState::Ready(owner_config);
|
||||
parent_thread
|
||||
.session
|
||||
.services
|
||||
.turn_environments
|
||||
.update_selections(std::slice::from_ref(&selection), &thread_config);
|
||||
parent_turn = parent_thread.session.new_default_turn().await;
|
||||
parent_thread.session.mark_interrupted();
|
||||
// The fixture has no task runner to finish the turn or consume child results.
|
||||
*parent_thread.session.active_turn.lock().await = None;
|
||||
let _ = parent_thread
|
||||
.session
|
||||
.input_queue
|
||||
.drain_mailbox_input_items()
|
||||
.await;
|
||||
harness
|
||||
.manager
|
||||
.ensure_multi_agent_v2_child_loaded(spawned_agent.thread_id)
|
||||
.await
|
||||
.expect("known child should reload through its parent");
|
||||
assert!(harness.manager.get_thread(parent_thread_id).await.is_err());
|
||||
}
|
||||
}
|
||||
let reloaded_child = harness
|
||||
.manager
|
||||
.get_thread(spawned_agent.thread_id)
|
||||
.await
|
||||
.expect("reloaded child thread should exist");
|
||||
if matches!(route, V2ReloadRoute::NestedParent) {
|
||||
let reloaded_turn = reloaded_child.session.new_default_turn().await;
|
||||
assert_eq!(
|
||||
(
|
||||
reloaded_turn.environments.to_selections(),
|
||||
reloaded_turn.permission_profile(),
|
||||
reloaded_child.client_mcp_extensions(),
|
||||
),
|
||||
(
|
||||
parent_turn.environments.to_selections(),
|
||||
parent_turn.permission_profile(),
|
||||
client_mcp_extensions,
|
||||
),
|
||||
);
|
||||
assert!(Arc::ptr_eq(
|
||||
&reloaded_child.session.services.exec_policy,
|
||||
&parent_thread.session.services.exec_policy,
|
||||
));
|
||||
}
|
||||
assert_eq!(
|
||||
reloaded_child.config_snapshot().await.model,
|
||||
"gpt-5.6-luna",
|
||||
@@ -821,8 +943,7 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
|
||||
"hello after reload".to_string(),
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
harness
|
||||
.control
|
||||
control
|
||||
.send_inter_agent_communication(
|
||||
spawned_agent.thread_id,
|
||||
communication.clone(),
|
||||
|
||||
Reference in new issue
Block a user