mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Add host-reserved V2 child creation APIs (#57586)
## What changed - Add `CodexThread::create_child` to create an idle child from a live tool invocation using a host-reserved thread ID and controller. - Add `prepare_child` and `ThreadManager::create_prepared_child` to capture inherited settings and fresh or forked history, then start the child on another manager even after the parent shuts down. Reject detached preparation when live bindings cannot be transferred. - Add opt-in `HostToolInvocationLookup` to track live calls and reject duplicate active call IDs within a turn. Revoke invocation access when dispatch ends and clean up provisional children when startup fails or is cancelled. ## Testing Add tests for invocation lifetime and call ID reservation, inherited policy and live bindings, detached startup with fresh and full history, and cancellation cleanup. GitOrigin-RevId: 71f17ecb4cd7636517e675df59289a8e8c7105b8
This commit is contained in:
@@ -15,7 +15,6 @@ use crate::config::RolloutBudgetConfig;
|
||||
use crate::context::ContextualUserFragment;
|
||||
use crate::context::SubagentNotification;
|
||||
use crate::environment_selection::TurnEnvironmentSnapshot;
|
||||
use crate::session::emit_subagent_session_started;
|
||||
use crate::session_prefix::format_inter_agent_completion_message;
|
||||
use crate::thread_manager::ResumeThreadWithHistoryOptions;
|
||||
use crate::thread_manager::ThreadIdGenerator;
|
||||
@@ -62,6 +61,10 @@ pub(crate) use self::runtime::AgentTreeShutdownState;
|
||||
pub(crate) use self::runtime::AgentTreeTeardownGuard;
|
||||
pub(crate) use self::runtime::LocalAgentRuntime;
|
||||
pub(crate) use self::watch::StatusSubscription;
|
||||
pub(in crate::agent) use spawn::effective_spawn_config;
|
||||
pub(in crate::agent) use spawn::fresh_child_history_and_tools;
|
||||
pub(in crate::agent) use spawn_guard::PendingSpawn;
|
||||
pub(in crate::agent) use spawn_guard::SpawnOwner;
|
||||
|
||||
mod api;
|
||||
mod budget;
|
||||
@@ -626,14 +629,8 @@ impl LocalAgentControl {
|
||||
state: &Arc<ThreadManagerState>,
|
||||
session_source: Option<&SessionSource>,
|
||||
) -> Option<TurnEnvironmentSnapshot> {
|
||||
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
})) = session_source
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let parent_thread = state.get_thread(*parent_thread_id).await.ok()?;
|
||||
let parent_thread_id = session_source?.parent_thread_id()?;
|
||||
let parent_thread = state.get_thread(parent_thread_id).await.ok()?;
|
||||
Some(parent_thread.session.inherited_environment_snapshot().await)
|
||||
}
|
||||
|
||||
@@ -674,14 +671,15 @@ async fn inherited_exec_policy_for_source(
|
||||
session_source: Option<&SessionSource>,
|
||||
child_config: &Config,
|
||||
) -> Option<Arc<crate::exec_policy::ExecPolicyManager>> {
|
||||
let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
})) = session_source
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let parent_thread_id = session_source?.parent_thread_id()?;
|
||||
let parent_thread = state.get_thread(parent_thread_id).await.ok()?;
|
||||
inherited_exec_policy_for_parent(&parent_thread, child_config).await
|
||||
}
|
||||
|
||||
let parent_thread = state.get_thread(*parent_thread_id).await.ok()?;
|
||||
pub(in crate::agent) async fn inherited_exec_policy_for_parent(
|
||||
parent_thread: &crate::CodexThread,
|
||||
child_config: &Config,
|
||||
) -> Option<Arc<crate::exec_policy::ExecPolicyManager>> {
|
||||
let parent_config = parent_thread.session.get_config().await;
|
||||
if !crate::exec_policy::child_uses_parent_exec_policy(&parent_config, child_config) {
|
||||
return None;
|
||||
|
||||
@@ -130,7 +130,7 @@ impl LocalAgentRuntime {
|
||||
Ok(descendants)
|
||||
}
|
||||
|
||||
pub(super) fn upgrade(&self) -> CodexResult<Arc<ThreadManagerState>> {
|
||||
pub(in crate::agent) fn upgrade(&self) -> CodexResult<Arc<ThreadManagerState>> {
|
||||
self.manager.upgrade().ok_or_else(|| {
|
||||
CodexErr::UnsupportedOperation("thread manager dropped".to_string())
|
||||
.with_agent_context(AgentErrorContext::ManagerUnavailable)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::residency::is_v2_resident_session_source;
|
||||
use super::spawn_guard::PendingSpawn;
|
||||
use super::spawn_guard::SpawnOwner;
|
||||
use super::spawn_telemetry::SpawnMeasurements;
|
||||
use super::spawn_telemetry::record_spawn_success;
|
||||
use super::*;
|
||||
@@ -16,10 +17,10 @@ use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::config::PermissionProfileSnapshot;
|
||||
use crate::thread_manager::StartThreadOptions;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::error::AgentErrorContext;
|
||||
use codex_protocol::intersect_effective_permission_profiles;
|
||||
use codex_protocol::protocol::EnvironmentConfigState;
|
||||
use codex_thread_store::PersistContext;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use futures::StreamExt;
|
||||
use futures::stream;
|
||||
@@ -39,7 +40,9 @@ pub(super) enum SpawnInitialInput {
|
||||
InterAgentCommunication(InterAgentCommunication, AgentCommunicationContext),
|
||||
}
|
||||
|
||||
async fn effective_spawn_config(thread: &CodexThread) -> CodexResult<ThreadConfigSnapshot> {
|
||||
pub(in crate::agent) async fn effective_spawn_config(
|
||||
thread: &CodexThread,
|
||||
) -> CodexResult<ThreadConfigSnapshot> {
|
||||
let mut config = thread.config_snapshot().await;
|
||||
let model_info = thread
|
||||
.thread_extension_data()
|
||||
@@ -74,6 +77,26 @@ pub(super) fn agent_nickname_candidates(config: &Config, role_name: Option<&str>
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(in crate::agent) async fn fresh_child_history_and_tools(
|
||||
parent: &CodexThread,
|
||||
config: &Config,
|
||||
version: MultiAgentVersion,
|
||||
) -> (Option<ThreadHistoryMode>, Vec<DynamicToolSpec>) {
|
||||
let history_mode = matches!(
|
||||
parent.config_snapshot().await.history_mode,
|
||||
ThreadHistoryMode::Paginated
|
||||
)
|
||||
.then_some(ThreadHistoryMode::Paginated);
|
||||
let tools = if version == MultiAgentVersion::V2
|
||||
&& config.features.enabled(Feature::MultiAgentV2DynamicTools)
|
||||
{
|
||||
parent.session.dynamic_tools().await.to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(history_mode, tools)
|
||||
}
|
||||
|
||||
impl LocalAgentControl {
|
||||
/// Restore persisted V2 agent identities without reopening their runtimes.
|
||||
pub(crate) async fn restore_v2_agent_metadata(
|
||||
@@ -511,15 +534,11 @@ impl LocalAgentControl {
|
||||
})
|
||||
};
|
||||
let subagent_analytics =
|
||||
if let SessionSource::SubAgent(source @ SubAgentSource::ThreadSpawn { .. }) =
|
||||
&session_source
|
||||
if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) = &session_source
|
||||
&& let Some(parent_thread_id) = parent_thread_id
|
||||
&& let Ok(parent) = state.get_thread(parent_thread_id).await
|
||||
{
|
||||
Some((
|
||||
parent.session.app_server_client_metadata().await,
|
||||
source.clone(),
|
||||
))
|
||||
Some(parent.session.app_server_client_metadata().await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -559,22 +578,14 @@ impl LocalAgentControl {
|
||||
runtime.registry.clear_evicted_environments(thread_id);
|
||||
residency_slot.commit(reloaded_thread.thread_id);
|
||||
// Register before listeners can forward events from the resumed thread.
|
||||
if let Some((client_metadata, source)) = subagent_analytics {
|
||||
let thread_config = reloaded_thread.thread.config_snapshot().await;
|
||||
emit_subagent_session_started(
|
||||
&reloaded_thread
|
||||
.thread
|
||||
.session
|
||||
.services
|
||||
.analytics_events_client,
|
||||
client_metadata,
|
||||
reloaded_thread.thread.session.session_id(),
|
||||
reloaded_thread.thread_id,
|
||||
thread_config.parent_thread_id,
|
||||
thread_config,
|
||||
source,
|
||||
/*resumed_created_at*/ Some(stored_thread.created_at),
|
||||
);
|
||||
if let Some(client_metadata) = subagent_analytics {
|
||||
reloaded_thread
|
||||
.thread
|
||||
.emit_subagent_session_started(
|
||||
client_metadata,
|
||||
Some(stored_thread.created_at),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
state.notify_thread_created(reloaded_thread.thread_id);
|
||||
Ok(residency_pin)
|
||||
@@ -734,17 +745,9 @@ impl LocalAgentControl {
|
||||
if let Some(parent_thread_id) = parent_thread_id
|
||||
&& let Ok(parent_thread) = state.get_thread(parent_thread_id).await
|
||||
{
|
||||
start_options.history_mode = matches!(
|
||||
parent_thread.config_snapshot().await.history_mode,
|
||||
ThreadHistoryMode::Paginated
|
||||
)
|
||||
.then_some(ThreadHistoryMode::Paginated);
|
||||
if multi_agent_version == MultiAgentVersion::V2
|
||||
&& config.features.enabled(Feature::MultiAgentV2DynamicTools)
|
||||
{
|
||||
start_options.dynamic_tools =
|
||||
parent_thread.session.dynamic_tools().await.to_vec();
|
||||
}
|
||||
(start_options.history_mode, start_options.dynamic_tools) =
|
||||
fresh_child_history_and_tools(&parent_thread, &config, multi_agent_version)
|
||||
.await;
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -762,14 +765,16 @@ impl LocalAgentControl {
|
||||
.map_err(|err| err.with_agent_context(AgentErrorContext::ChildStartup))?;
|
||||
let child_create = child_create_started_at.elapsed();
|
||||
agent_metadata.agent_id = Some(new_thread.thread_id);
|
||||
let mut pending_spawn =
|
||||
PendingSpawn::new(Arc::clone(&state), new_thread.thread_id, membership);
|
||||
let mut pending_spawn = PendingSpawn::new(
|
||||
Arc::clone(&state),
|
||||
new_thread.thread_id,
|
||||
membership,
|
||||
SpawnOwner::Local,
|
||||
);
|
||||
|
||||
if let Some(SessionSource::SubAgent(
|
||||
subagent_source @ SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
},
|
||||
)) = notification_source.as_ref()
|
||||
if let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
})) = notification_source.as_ref()
|
||||
{
|
||||
let client_metadata = match state.get_thread(*parent_thread_id).await {
|
||||
Ok(parent_thread) => parent_thread.session.app_server_client_metadata().await,
|
||||
@@ -785,18 +790,10 @@ impl LocalAgentControl {
|
||||
}
|
||||
}
|
||||
};
|
||||
let thread_config = new_thread.thread.config_snapshot().await;
|
||||
let parent_thread_id = thread_config.parent_thread_id;
|
||||
emit_subagent_session_started(
|
||||
&new_thread.thread.session.services.analytics_events_client,
|
||||
client_metadata,
|
||||
new_thread.thread.session.session_id(),
|
||||
new_thread.thread_id,
|
||||
parent_thread_id,
|
||||
thread_config,
|
||||
subagent_source.clone(),
|
||||
/*resumed_created_at*/ None,
|
||||
);
|
||||
new_thread
|
||||
.thread
|
||||
.emit_subagent_session_started(client_metadata, /*resumed_created_at*/ None)
|
||||
.await;
|
||||
}
|
||||
|
||||
let control = self.clone();
|
||||
@@ -815,10 +812,7 @@ impl LocalAgentControl {
|
||||
let durability_wait_started_at = Instant::now();
|
||||
if options.fork_mode.is_some() {
|
||||
tokio::join!(
|
||||
new_thread
|
||||
.thread
|
||||
.session
|
||||
.ensure_rollout_materialized(PersistContext::Standard),
|
||||
new_thread.thread.ensure_rollout_materialized(),
|
||||
pending_spawn.wait_for_edge(),
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -12,22 +12,30 @@ use std::sync::Arc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::warn;
|
||||
|
||||
pub(super) struct PendingSpawn {
|
||||
pub(in crate::agent) enum SpawnOwner {
|
||||
Local,
|
||||
Host,
|
||||
}
|
||||
|
||||
pub(in crate::agent) struct PendingSpawn {
|
||||
state: Arc<ThreadManagerState>,
|
||||
pending: Option<(ThreadId, AgentTreeMembership)>,
|
||||
edge_write: Option<JoinHandle<()>>,
|
||||
owner: SpawnOwner,
|
||||
}
|
||||
|
||||
impl PendingSpawn {
|
||||
pub(super) fn new(
|
||||
pub(in crate::agent) fn new(
|
||||
state: Arc<ThreadManagerState>,
|
||||
child: ThreadId,
|
||||
membership: AgentTreeMembership,
|
||||
owner: SpawnOwner,
|
||||
) -> Self {
|
||||
Self {
|
||||
state,
|
||||
pending: Some((child, membership)),
|
||||
edge_write: None,
|
||||
owner,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +53,7 @@ impl PendingSpawn {
|
||||
self.edge_write = None;
|
||||
}
|
||||
|
||||
pub(super) fn disarm(mut self) -> AgentTreeMembership {
|
||||
pub(in crate::agent) fn disarm(mut self) -> AgentTreeMembership {
|
||||
let Some((_child, membership)) = self.pending.take() else {
|
||||
unreachable!("pending spawn must own agent-tree membership");
|
||||
};
|
||||
@@ -61,6 +69,7 @@ impl Drop for PendingSpawn {
|
||||
let state = Arc::clone(&self.state);
|
||||
let teardown = membership.into_teardown_guard("child_spawn_cleanup", Some(child));
|
||||
let edge_write = self.edge_write.take();
|
||||
let local_owner = matches!(self.owner, SpawnOwner::Local);
|
||||
drop(tokio::spawn(async move {
|
||||
if let Some(thread) = state.remove_thread(&child).await {
|
||||
if let Err(error) = thread.shutdown_and_wait().await {
|
||||
@@ -85,7 +94,8 @@ impl Drop for PendingSpawn {
|
||||
if let Some(edge_write) = edge_write {
|
||||
let _ = edge_write.await;
|
||||
}
|
||||
if let Some(store) = state.agent_graph_store()
|
||||
if local_owner
|
||||
&& let Some(store) = state.agent_graph_store()
|
||||
&& let Err(error) = store
|
||||
.set_thread_spawn_edge_status(child, ThreadSpawnEdgeStatus::Closed)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Bind host child preparation and startup to a live tool invocation.
|
||||
|
||||
use super::spawn::SpawnDestination;
|
||||
use super::spawn::prepare_spawn;
|
||||
use super::spawn::start_prepared;
|
||||
use super::types::HostThreadSpawnReservation;
|
||||
use super::types::PreparedThreadSpawn;
|
||||
use crate::codex_thread::CodexThread;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::thread_spawn::ChildSpawnSettings;
|
||||
use crate::thread_spawn::ThreadCreateRequest;
|
||||
use crate::thread_spawn::ThreadSpawnError;
|
||||
use crate::thread_spawn::ThreadSpawnResult;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use futures::TryFutureExt;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::MutexGuard;
|
||||
use std::sync::PoisonError;
|
||||
use std::sync::Weak;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
struct InvocationContext {
|
||||
step: Weak<StepContext>,
|
||||
finished: Mutex<CancellationToken>,
|
||||
}
|
||||
|
||||
impl InvocationContext {
|
||||
fn finished(&self) -> MutexGuard<'_, CancellationToken> {
|
||||
self.finished.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
/// Install in thread extension data to enable live V2 tool lookup and require unique active
|
||||
/// call IDs within each turn. Host-spawned children inherit this opt-in.
|
||||
pub struct HostToolInvocationLookup;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InvocationContexts(Mutex<HashMap<(String, String), Weak<InvocationContext>>>);
|
||||
|
||||
/// A handle to one tool call, valid only on its issuing runtime until dispatch ends.
|
||||
/// It holds the runtime and step weakly. Child preparation captures history and policy.
|
||||
pub struct ToolInvocationContext {
|
||||
session: Weak<Session>,
|
||||
call_id: String,
|
||||
context: Arc<InvocationContext>,
|
||||
}
|
||||
|
||||
impl ToolInvocationContext {
|
||||
/// Order publication with revocation of the issuing tool call.
|
||||
pub(super) fn lock_active(
|
||||
&self,
|
||||
) -> Result<MutexGuard<'_, CancellationToken>, ThreadSpawnError> {
|
||||
let finished = self.context.finished();
|
||||
if finished.is_cancelled() {
|
||||
return Err(ThreadSpawnError::Preparation(
|
||||
"spawning tool call finished".into(),
|
||||
));
|
||||
}
|
||||
Ok(finished)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tool_invocation(
|
||||
thread: &CodexThread,
|
||||
turn_id: &str,
|
||||
call_id: &str,
|
||||
) -> CodexResult<ToolInvocationContext> {
|
||||
let context = thread
|
||||
.session
|
||||
.services
|
||||
.thread_extension_data
|
||||
.get::<InvocationContexts>()
|
||||
.and_then(|contexts| {
|
||||
contexts
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get(&(turn_id.to_owned(), call_id.to_owned()))
|
||||
.and_then(Weak::upgrade)
|
||||
})
|
||||
.filter(|context| !context.finished().is_cancelled())
|
||||
.ok_or_else(|| CodexErr::InvalidRequest("tool call is not active".into()))?;
|
||||
Ok(ToolInvocationContext {
|
||||
session: Arc::downgrade(&thread.session),
|
||||
call_id: call_id.to_owned(),
|
||||
context,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct ReservedInvocation {
|
||||
contexts: Arc<InvocationContexts>,
|
||||
key: (String, String),
|
||||
}
|
||||
|
||||
/// Keeps the dispatch's ID reserved until its terminal callback returns.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct InvocationReservation(Mutex<Option<ReservedInvocation>>);
|
||||
|
||||
impl InvocationReservation {
|
||||
pub(crate) fn reserve(
|
||||
&self,
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
call_id: &str,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if turn.multi_agent_version != MultiAgentVersion::V2
|
||||
|| session
|
||||
.services
|
||||
.thread_extension_data
|
||||
.get::<HostToolInvocationLookup>()
|
||||
.is_none()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let contexts = session
|
||||
.services
|
||||
.thread_extension_data
|
||||
.get_or_init(InvocationContexts::default);
|
||||
let key = (turn.sub_id.clone(), call_id.to_owned());
|
||||
match contexts
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.entry(key.clone())
|
||||
{
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(Weak::new());
|
||||
}
|
||||
Entry::Occupied(_) => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"tool call ID {call_id} is already active in this turn"
|
||||
)));
|
||||
}
|
||||
}
|
||||
*self.0.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some(ReservedInvocation { contexts, key });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn activate(&self, invocation: &ToolInvocation) -> Option<InvocationGuard> {
|
||||
let reservation = self.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let reservation = reservation.as_ref()?;
|
||||
let context = Arc::new(InvocationContext {
|
||||
step: Arc::downgrade(&invocation.step_context),
|
||||
finished: Mutex::new(invocation.cancellation_token.child_token()),
|
||||
});
|
||||
let mut contexts = reservation
|
||||
.contexts
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
*contexts.get_mut(&reservation.key)? = Arc::downgrade(&context);
|
||||
Some(InvocationGuard(context))
|
||||
}
|
||||
|
||||
pub(crate) fn take(&self) -> Option<ReservedInvocation> {
|
||||
self.0.lock().unwrap_or_else(PoisonError::into_inner).take()
|
||||
}
|
||||
}
|
||||
|
||||
/// Revokes host access when execution ends, including unwinding or cancellation.
|
||||
pub(crate) struct InvocationGuard(Arc<InvocationContext>);
|
||||
|
||||
impl Drop for InvocationGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.finished().cancel();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReservedInvocation {
|
||||
fn drop(&mut self) {
|
||||
self.contexts
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(&self.key);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create(
|
||||
thread: &CodexThread,
|
||||
invocation: &ToolInvocationContext,
|
||||
request: ThreadCreateRequest,
|
||||
reservation: HostThreadSpawnReservation,
|
||||
) -> Result<ThreadSpawnResult, ThreadSpawnError> {
|
||||
with_spawn_request(
|
||||
thread,
|
||||
invocation,
|
||||
request,
|
||||
|request, task_name| async move {
|
||||
let prepared = Box::pin(prepare_spawn(
|
||||
thread,
|
||||
request,
|
||||
task_name,
|
||||
SpawnDestination::SameManager,
|
||||
))
|
||||
.await?;
|
||||
Box::pin(start_prepared(
|
||||
&thread.session.services.local_agent_runtime,
|
||||
prepared,
|
||||
reservation,
|
||||
Some(invocation),
|
||||
))
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare(
|
||||
thread: &CodexThread,
|
||||
invocation: &ToolInvocationContext,
|
||||
request: ThreadCreateRequest,
|
||||
) -> Result<PreparedThreadSpawn, ThreadSpawnError> {
|
||||
with_spawn_request(thread, invocation, request, |request, task_name| {
|
||||
prepare_spawn(thread, request, task_name, SpawnDestination::Detached)
|
||||
.map_err(ThreadSpawnError::from)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn with_spawn_request<T, F, Fut>(
|
||||
thread: &CodexThread,
|
||||
invocation: &ToolInvocationContext,
|
||||
request: ThreadCreateRequest,
|
||||
operation: F,
|
||||
) -> Result<T, ThreadSpawnError>
|
||||
where
|
||||
F: FnOnce(ChildSpawnSettings, AgentPath) -> Fut,
|
||||
Fut: Future<Output = Result<T, ThreadSpawnError>>,
|
||||
{
|
||||
let session = &thread.session;
|
||||
if !invocation.session.ptr_eq(&Arc::downgrade(session)) {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"tool invocation belongs to a different runtime".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let context = &invocation.context;
|
||||
let finished = context.finished().clone();
|
||||
let step = context.step.upgrade().ok_or_else(|| {
|
||||
ThreadSpawnError::Preparation("spawning step is no longer available".into())
|
||||
})?;
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = finished.cancelled() => Err(ThreadSpawnError::Preparation("spawning tool call finished".into())),
|
||||
result = async {
|
||||
let _caller_pin = Arc::clone(&thread.residency_gate).read_owned().await;
|
||||
if !thread.is_running() {
|
||||
return Err(CodexErr::ThreadNotFound(session.thread_id).into());
|
||||
}
|
||||
let (request, task_name) = crate::thread_spawn::prepare(session, &step, &invocation.call_id, request).await?;
|
||||
operation(request, task_name).await
|
||||
} => result,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "invocation_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,161 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use super::HostToolInvocationLookup;
|
||||
use super::InvocationContexts;
|
||||
use crate::session::step_context::StepContext;
|
||||
use crate::tools::context::ToolCallSource;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::ListMcpResourcesHandler;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolRouter;
|
||||
use codex_extension_api::ToolFinishInput;
|
||||
use codex_extension_api::ToolLifecycleContributor;
|
||||
use codex_extension_api::ToolLifecycleFuture;
|
||||
use codex_extension_api::ToolStartInput;
|
||||
use codex_protocol::openai_models::ToolMode;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_tools::ToolName;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Default)]
|
||||
struct CallbackGate {
|
||||
entered: Notify,
|
||||
release: Notify,
|
||||
}
|
||||
|
||||
impl ToolLifecycleContributor for CallbackGate {
|
||||
fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
|
||||
Box::pin(async move {
|
||||
self.entered.notify_one();
|
||||
self.release.notified().await;
|
||||
if matches!(
|
||||
input.source,
|
||||
codex_extension_api::ToolCallSource::CodeMode { .. }
|
||||
) {
|
||||
panic!("dispatch unwound");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn on_tool_finish<'a>(&'a self, _input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> {
|
||||
Box::pin(async {
|
||||
self.entered.notify_one();
|
||||
self.release.notified().await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dispatch_revokes_access_and_holds_call_id_through_callbacks() -> anyhow::Result<()> {
|
||||
let (mut session, mut turn, _events) =
|
||||
crate::session::tests::make_session_and_context_with_rx().await;
|
||||
Arc::get_mut(&mut turn).unwrap().multi_agent_version = MultiAgentVersion::V2;
|
||||
session
|
||||
.services
|
||||
.thread_extension_data
|
||||
.insert(HostToolInvocationLookup);
|
||||
let gate = Arc::new(CallbackGate::default());
|
||||
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
|
||||
extensions.tool_lifecycle_contributor(gate.clone());
|
||||
Arc::get_mut(&mut session).unwrap().services.extensions = Arc::new(extensions.build());
|
||||
let mut registry = ToolRegistry::default();
|
||||
registry.add(ListMcpResourcesHandler::new(/*messages*/ None));
|
||||
let router = ToolRouter::from_parts(
|
||||
registry,
|
||||
vec![],
|
||||
ToolMode::Direct,
|
||||
Default::default(),
|
||||
/*tool_namespaces_info*/ None,
|
||||
&[],
|
||||
);
|
||||
let step = StepContext::for_test(turn.clone()).with_tool_router_for_test(Arc::new(router));
|
||||
let runtime = ToolCallRuntime::new(session.clone(), step.clone(), Default::default());
|
||||
let dispatch = |source, token, args: &str| {
|
||||
runtime.clone().handle_tool_call_with_source(
|
||||
step.clone(),
|
||||
ToolCall {
|
||||
tool_name: ToolName::plain("list_mcp_resources"),
|
||||
call_id: "shared".into(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: args.into(),
|
||||
},
|
||||
encrypted_function_args: None,
|
||||
},
|
||||
source,
|
||||
token,
|
||||
Default::default(),
|
||||
)
|
||||
};
|
||||
let wait_limit = Duration::from_secs(5);
|
||||
let duplicate = async |args: &str| {
|
||||
let result = tokio::time::timeout(
|
||||
wait_limit,
|
||||
dispatch(ToolCallSource::Direct, CancellationToken::new(), args),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(result, Err(crate::function_tool::FunctionCallError::RespondToModel(msg)) if msg.contains("already active"))
|
||||
);
|
||||
};
|
||||
let key = (turn.sub_id.clone(), String::from("shared"));
|
||||
let at_callback = || tokio::time::timeout(wait_limit, gate.entered.notified());
|
||||
let contexts = session
|
||||
.services
|
||||
.thread_extension_data
|
||||
.get_or_init(InvocationContexts::default);
|
||||
for (source, abort) in [
|
||||
(
|
||||
ToolCallSource::CodeMode {
|
||||
cell_id: "cell".into(),
|
||||
runtime_tool_call_id: "nested".into(),
|
||||
},
|
||||
false,
|
||||
),
|
||||
(ToolCallSource::Direct, true),
|
||||
(ToolCallSource::Direct, false),
|
||||
] {
|
||||
let panics = matches!(source, ToolCallSource::CodeMode { .. });
|
||||
let token = CancellationToken::new();
|
||||
let owner = tokio::spawn(dispatch(source, token.clone(), "{}"));
|
||||
at_callback()
|
||||
.await
|
||||
.expect("owner entered its start callback");
|
||||
let captured = contexts.0.lock().unwrap()[&key].upgrade().unwrap();
|
||||
duplicate("invalid json").await;
|
||||
assert!(Arc::ptr_eq(
|
||||
&captured,
|
||||
&contexts.0.lock().unwrap()[&key].upgrade().unwrap()
|
||||
));
|
||||
assert!(!captured.finished().is_cancelled());
|
||||
if abort {
|
||||
token.cancel();
|
||||
} else {
|
||||
gate.release.notify_one();
|
||||
}
|
||||
if !panics {
|
||||
at_callback()
|
||||
.await
|
||||
.expect("owner entered its terminal callback");
|
||||
assert!(captured.finished().is_cancelled());
|
||||
duplicate("{}").await;
|
||||
gate.release.notify_one();
|
||||
}
|
||||
let outcome = tokio::time::timeout(wait_limit, owner).await??;
|
||||
if panics {
|
||||
assert!(
|
||||
matches!(outcome, Err(crate::function_tool::FunctionCallError::Fatal(message)) if message.contains("dispatch unwound"))
|
||||
);
|
||||
} else {
|
||||
outcome?;
|
||||
}
|
||||
assert!(captured.finished().is_cancelled());
|
||||
assert!(!contexts.0.lock().unwrap().contains_key(&key));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
mod invocation;
|
||||
mod spawn;
|
||||
mod types;
|
||||
|
||||
pub use invocation::HostToolInvocationLookup;
|
||||
pub(crate) use invocation::InvocationReservation;
|
||||
pub use invocation::ToolInvocationContext;
|
||||
pub(crate) use invocation::create;
|
||||
pub(crate) use invocation::prepare;
|
||||
pub(crate) use invocation::tool_invocation;
|
||||
pub(crate) use spawn::start_prepared;
|
||||
pub use types::HostThreadSpawnReservation;
|
||||
pub(crate) use types::PreparedChildInheritance;
|
||||
pub use types::PreparedThreadSpawn;
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Capture child startup inputs on the source and start the reserved child at the destination.
|
||||
|
||||
use super::invocation::HostToolInvocationLookup;
|
||||
use super::invocation::ToolInvocationContext;
|
||||
use super::types::HostThreadSpawnReservation;
|
||||
use super::types::PreparedChildInheritance;
|
||||
use super::types::PreparedThreadSpawn;
|
||||
use crate::agent::control::AgentControlInit;
|
||||
use crate::agent::control::LocalAgentRuntime;
|
||||
use crate::agent::control::PendingSpawn;
|
||||
use crate::agent::control::SpawnOwner;
|
||||
use crate::agent::control::effective_spawn_config;
|
||||
use crate::agent::control::fresh_child_history_and_tools;
|
||||
use crate::agent::control::inherited_exec_policy_for_parent;
|
||||
use crate::agent::history::prepare_forked_history;
|
||||
use crate::agent::types::AgentMetadata;
|
||||
use crate::agent::types::LiveAgent;
|
||||
use crate::codex_thread::CodexThread;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::thread_manager::StartThreadOptions;
|
||||
use crate::thread_spawn::ChildSpawnSettings;
|
||||
use crate::thread_spawn::ThreadSpawnError;
|
||||
use crate::thread_spawn::ThreadSpawnResult;
|
||||
use codex_config::McpServerAuth;
|
||||
use codex_features::Feature;
|
||||
use codex_history::InitialHistory;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::error::AgentErrorContext;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::protocol::EnvironmentConfigState;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::ThreadSource;
|
||||
use codex_thread_store::ListThreadAttachmentsParams;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) enum SpawnDestination {
|
||||
SameManager,
|
||||
Detached,
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_spawn(
|
||||
parent: &CodexThread,
|
||||
request: ChildSpawnSettings,
|
||||
agent_path: AgentPath,
|
||||
destination: SpawnDestination,
|
||||
) -> Result<PreparedThreadSpawn> {
|
||||
if matches!(destination, SpawnDestination::Detached)
|
||||
&& (request.config.application_network_policy.is_managed()
|
||||
|| request.config.application_auth_route_config.is_some()
|
||||
|| request
|
||||
.config
|
||||
.config_layer_stack
|
||||
.cloud_config_binding()
|
||||
.is_some())
|
||||
{
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"prepared spawn requires destination binding for host-managed application authority"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let runtime = &parent.session.services.local_agent_runtime;
|
||||
let _membership = runtime.admit_start()?;
|
||||
let state = runtime.upgrade()?;
|
||||
let ChildSpawnSettings {
|
||||
config,
|
||||
source,
|
||||
options,
|
||||
} = request;
|
||||
if matches!(destination, SpawnDestination::Detached)
|
||||
&& config.features.enabled(Feature::UseXaa)
|
||||
&& config.mcp_enterprise_managed_auth.is_some()
|
||||
&& config.mcp_servers.get().values().any(|server| {
|
||||
server.enabled
|
||||
&& matches!(server.auth, McpServerAuth::EmaAuth)
|
||||
&& server.validate_ema_auth_transport().is_ok()
|
||||
})
|
||||
{
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"prepared spawn requires destination binding for EMA-authenticated MCP servers".into(),
|
||||
));
|
||||
}
|
||||
let caller = parent.session.thread_id;
|
||||
let instructions = parent.session.inherited_instructions().await;
|
||||
if matches!(destination, SpawnDestination::Detached)
|
||||
&& (instructions.thread_provider.is_some() || instructions.user_provider.is_some())
|
||||
{
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"prepared spawn requires destination binding for inherited instruction providers"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let store = &parent.session.services.thread_store;
|
||||
if matches!(destination, SpawnDestination::Detached) && store.supports_thread_attachments() {
|
||||
let page = store
|
||||
.list_thread_attachments(ListThreadAttachmentsParams {
|
||||
thread_id: caller,
|
||||
cursor: None,
|
||||
limit: 1,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
CodexErr::InvalidRequest(format!("cannot prepare child attachments: {error}"))
|
||||
})?;
|
||||
if !page.attachments.is_empty() {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"prepared spawn requires a host handoff for thread attachments".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let snapshot = options.environments.as_ref().ok_or_else(|| {
|
||||
CodexErr::InvalidRequest("prepared spawn requires captured environments".into())
|
||||
})?;
|
||||
let environments = snapshot.inheritable_requests();
|
||||
if matches!(destination, SpawnDestination::Detached)
|
||||
&& environments
|
||||
.iter()
|
||||
.any(|environment| matches!(environment.config, EnvironmentConfigState::Pending))
|
||||
{
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"prepared spawn requires resolved environment configuration".into(),
|
||||
));
|
||||
}
|
||||
let (initial_history, history_mode, dynamic_tools) = if options.fork_mode.is_some() {
|
||||
let (history, mode) =
|
||||
prepare_forked_history(&state, &source, &options, &config, MultiAgentVersion::V2)
|
||||
.await
|
||||
.map_err(|error| error.with_agent_context(AgentErrorContext::ForkHistory))?;
|
||||
(history, mode, Vec::new())
|
||||
} else {
|
||||
let (mode, tools) =
|
||||
fresh_child_history_and_tools(parent, &config, MultiAgentVersion::V2).await;
|
||||
(InitialHistory::New, mode, tools)
|
||||
};
|
||||
if !std::ptr::eq(state.get_thread(caller).await?.as_ref(), parent) {
|
||||
return Err(CodexErr::ThreadNotFound(caller));
|
||||
}
|
||||
let inherited_policy = inherited_exec_policy_for_parent(parent, &config).await;
|
||||
let mut thread_options = StartThreadOptions {
|
||||
session_source: Some(source),
|
||||
thread_source: Some(ThreadSource::Subagent),
|
||||
initial_history,
|
||||
history_mode,
|
||||
dynamic_tools,
|
||||
disabled_plugin_ids: options.disabled_plugin_ids,
|
||||
client_mcp_extensions: parent.session.services.client_mcp_extensions.clone(),
|
||||
prompt_cache_key: parent
|
||||
.session
|
||||
.services
|
||||
.model_client
|
||||
.prompt_cache_key_policy(),
|
||||
..StartThreadOptions::new(config)
|
||||
};
|
||||
thread_options
|
||||
.thread_extension_init
|
||||
.insert(snapshot.selected_capability_roots());
|
||||
thread_options
|
||||
.thread_extension_init
|
||||
.insert(HostToolInvocationLookup);
|
||||
let exec_policy = match destination {
|
||||
SpawnDestination::SameManager => {
|
||||
thread_options.inherited_environments = options.environments;
|
||||
inherited_policy
|
||||
}
|
||||
SpawnDestination::Detached => {
|
||||
thread_options.environments = Some(environments);
|
||||
Some(Arc::new(match inherited_policy {
|
||||
Some(policy) => ExecPolicyManager::new(policy.current()),
|
||||
None => ExecPolicyManager::load(&thread_options.config.config_layer_stack)
|
||||
.await
|
||||
.map_err(|error| CodexErr::InvalidRequest(error.to_string()))?,
|
||||
}))
|
||||
}
|
||||
};
|
||||
Ok(PreparedThreadSpawn {
|
||||
caller,
|
||||
session_id: parent.session.session_id(),
|
||||
agent_path,
|
||||
client_metadata: parent.session.app_server_client_metadata().await,
|
||||
thread_options,
|
||||
inheritance: PreparedChildInheritance {
|
||||
instructions,
|
||||
exec_policy,
|
||||
originator: parent.config_snapshot().await.originator,
|
||||
attachment_source: (matches!(destination, SpawnDestination::SameManager)
|
||||
&& options.fork_mode.is_some())
|
||||
.then_some(caller),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn start_prepared(
|
||||
runtime: &LocalAgentRuntime,
|
||||
prepared: PreparedThreadSpawn,
|
||||
reservation: HostThreadSpawnReservation,
|
||||
invocation: Option<&ToolInvocationContext>,
|
||||
) -> std::result::Result<ThreadSpawnResult, ThreadSpawnError> {
|
||||
if reservation.control.identity() != prepared.session_id
|
||||
|| reservation.thread_id == prepared.caller
|
||||
|| SessionId::from(reservation.thread_id) == prepared.session_id
|
||||
{
|
||||
return Err(ThreadSpawnError::Preparation(
|
||||
"reservation does not match prepared child".into(),
|
||||
));
|
||||
}
|
||||
let membership = runtime.admit_start()?;
|
||||
let state = runtime.upgrade()?;
|
||||
if state
|
||||
.threads
|
||||
.read()
|
||||
.await
|
||||
.contains_key(&reservation.thread_id)
|
||||
{
|
||||
return Err(CodexErr::InvalidRequest(format!(
|
||||
"thread {} is already running",
|
||||
reservation.thread_id,
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let task_name = prepared.agent_path;
|
||||
let mut options = prepared.thread_options;
|
||||
options.reserved_thread_id = Some(reservation.thread_id);
|
||||
let child = state
|
||||
.spawn_prepared_child_thread(
|
||||
options,
|
||||
AgentControlInit::Provided {
|
||||
control: reservation.control,
|
||||
runtime: runtime.clone(),
|
||||
},
|
||||
prepared.caller,
|
||||
prepared.inheritance,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.with_agent_context(AgentErrorContext::ChildStartup))?;
|
||||
let pending = PendingSpawn::new(
|
||||
Arc::clone(&state),
|
||||
child.thread_id,
|
||||
membership,
|
||||
SpawnOwner::Host,
|
||||
);
|
||||
child.thread.emit_thread_ready_lifecycle().await;
|
||||
child
|
||||
.thread
|
||||
.emit_subagent_session_started(prepared.client_metadata, /*resumed_created_at*/ None)
|
||||
.await;
|
||||
if child.session_configured.forked_from_id.is_some() {
|
||||
child.thread.ensure_rollout_materialized().await;
|
||||
}
|
||||
let config = effective_spawn_config(&child.thread).await?;
|
||||
let status = child.thread.io.agent_status.borrow().clone();
|
||||
let _active = invocation
|
||||
.map(ToolInvocationContext::lock_active)
|
||||
.transpose()?;
|
||||
let _membership = pending.disarm();
|
||||
state.notify_thread_created(child.thread_id);
|
||||
Ok(ThreadSpawnResult {
|
||||
agent: LiveAgent {
|
||||
thread_id: child.thread_id,
|
||||
metadata: AgentMetadata {
|
||||
agent_id: Some(child.thread_id),
|
||||
agent_path: Some(task_name.clone()),
|
||||
agent_nickname: child.thread.session_source.get_nickname(),
|
||||
agent_role: child.thread.session_source.get_agent_role(),
|
||||
},
|
||||
status,
|
||||
},
|
||||
config,
|
||||
task_name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "spawn_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,254 @@
|
||||
#![cfg(test)]
|
||||
|
||||
//! Tests live child bindings and the capture boundary for detached child authority.
|
||||
|
||||
use super::SpawnDestination;
|
||||
use super::prepare_spawn;
|
||||
use crate::HostThreadSpawnReservation;
|
||||
use crate::HostToolInvocationLookup;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::host::InvocationReservation;
|
||||
use crate::agent::types::SpawnAgentForkMode;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::thread_manager::StartThreadOptions;
|
||||
use crate::thread_spawn::ThreadCreateRequest;
|
||||
use crate::thread_spawn::ThreadSpawnError;
|
||||
use crate::tools::context::ToolCallSource;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use codex_config::ScopedSkillsConfig;
|
||||
use codex_exec_server::ExecServerError;
|
||||
use codex_exec_server::RemoteEnvironmentOptions;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::protocol::EnvironmentConfigState;
|
||||
use codex_tools::ToolName;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::future::poll_fn;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Arc;
|
||||
use std::task::Poll;
|
||||
use std::time::Duration;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
async fn spawn_fixture() -> anyhow::Result<(tempfile::TempDir, Config, ThreadManager)> {
|
||||
let home = tempfile::tempdir()?;
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
home.path().to_path_buf(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
|
||||
);
|
||||
Ok((home, config, manager))
|
||||
}
|
||||
|
||||
fn child_request(fork_mode: Option<SpawnAgentForkMode>) -> ThreadCreateRequest {
|
||||
ThreadCreateRequest {
|
||||
task_name: "worker".into(),
|
||||
fork_mode,
|
||||
agent_type: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_manager_creation_retains_step_executor_and_shares_exec_policy() -> anyhow::Result<()>
|
||||
{
|
||||
let (_home, mut config, manager) = spawn_fixture().await?;
|
||||
config.features.enable(Feature::MultiAgentV2)?;
|
||||
config.features.enable(Feature::DeferredExecutor)?;
|
||||
let environments = manager.environment_manager();
|
||||
// Leave the handshake pending so the registration stays attached throughout the spawn.
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))?;
|
||||
let url = format!("ws://{}", listener.local_addr()?);
|
||||
environments.upsert_environment_with_options(
|
||||
"workspace".into(),
|
||||
RemoteEnvironmentOptions {
|
||||
exec_server_url: url.clone(),
|
||||
connect_timeout: Some(Duration::from_secs(300)),
|
||||
http_headers: Default::default(),
|
||||
},
|
||||
ScopedSkillsConfig {
|
||||
required: vec!["original-skill".into()],
|
||||
},
|
||||
)?;
|
||||
let mut requests = manager.default_environment_requests(&config.cwd, &config.workspace_roots);
|
||||
requests
|
||||
.iter_mut()
|
||||
.find(|request| request.environment_id == "workspace")
|
||||
.expect("registered workspace")
|
||||
.config = EnvironmentConfigState::Pending;
|
||||
let parent = manager
|
||||
.start_thread(StartThreadOptions {
|
||||
environments: Some(requests),
|
||||
..StartThreadOptions::new(config)
|
||||
})
|
||||
.await?
|
||||
.thread;
|
||||
parent
|
||||
.thread_extension_data()
|
||||
.insert(HostToolInvocationLookup);
|
||||
let session = &parent.session;
|
||||
let step = session
|
||||
.capture_step_context(session.new_default_turn().await, &CancellationToken::new())
|
||||
.await?;
|
||||
let call = ToolInvocation {
|
||||
session: Arc::clone(session),
|
||||
turn: Arc::clone(&step.turn),
|
||||
step_context: step,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
tracker: Default::default(),
|
||||
call_id: "spawn-call".into(),
|
||||
tool_name: ToolName::plain("host_spawn"),
|
||||
source: ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
environments: Default::default(),
|
||||
};
|
||||
let reservation = InvocationReservation::default();
|
||||
reservation.reserve(session, &call.turn, &call.call_id)?;
|
||||
assert!(
|
||||
parent
|
||||
.tool_invocation(&call.turn.sub_id, &call.call_id)
|
||||
.is_err()
|
||||
);
|
||||
let guard = reservation.activate(&call);
|
||||
let invocation = parent.tool_invocation(&call.turn.sub_id, &call.call_id)?;
|
||||
environments.upsert_environment("workspace".into(), url, /*connect_timeout*/ None)?;
|
||||
assert!(matches!(
|
||||
parent.prepare_child(&invocation, child_request(/*fork_mode*/ None)).await,
|
||||
Err(ThreadSpawnError::Backend(error)) if error.to_string().contains("resolved environment configuration")
|
||||
));
|
||||
let child_id = ThreadId::new();
|
||||
parent
|
||||
.create_child(
|
||||
&invocation,
|
||||
child_request(/*fork_mode*/ None),
|
||||
HostThreadSpawnReservation {
|
||||
thread_id: child_id,
|
||||
control: Arc::clone(&session.services.agent_control),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let child = manager.get_thread(child_id).await?;
|
||||
assert!(
|
||||
child
|
||||
.thread_extension_data()
|
||||
.get::<HostToolInvocationLookup>()
|
||||
.is_some()
|
||||
);
|
||||
let snapshot = child.session.services.turn_environments.snapshot_now();
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.required_skills()
|
||||
.find(|required| required.environment_id == "workspace")
|
||||
.expect("child retained the original executor registration")
|
||||
.skill_names,
|
||||
["original-skill"]
|
||||
);
|
||||
let pending = snapshot
|
||||
.starting()
|
||||
.find(|starting| starting.selection.environment_id == "workspace")
|
||||
.expect("child awaits the workspace");
|
||||
let failure = "owner failed to configure workspace";
|
||||
parent
|
||||
.environment_failed(&pending.selection, failure.into())
|
||||
.await?;
|
||||
let error = tokio::time::timeout(Duration::from_secs(5), pending.wait_until_ready())
|
||||
.await?
|
||||
.expect_err("child should receive the owner's failure");
|
||||
assert!(matches!(error.as_ref(), ExecServerError::Protocol(message) if message == failure));
|
||||
assert!(Arc::ptr_eq(
|
||||
&session.services.exec_policy,
|
||||
&child.session.services.exec_policy,
|
||||
));
|
||||
drop(guard);
|
||||
assert!(
|
||||
parent
|
||||
.tool_invocation(&call.turn.sub_id, &call.call_id)
|
||||
.is_err()
|
||||
);
|
||||
drop(reservation);
|
||||
let next = InvocationReservation::default();
|
||||
next.reserve(session, &call.turn, &call.call_id)?;
|
||||
let _next_guard = next.activate(&call);
|
||||
parent.tool_invocation(&call.turn.sub_id, &call.call_id)?;
|
||||
assert!(matches!(
|
||||
parent.prepare_child(&invocation, child_request(/*fork_mode*/ None)).await,
|
||||
Err(ThreadSpawnError::Preparation(message)) if message == "spawning tool call finished"
|
||||
));
|
||||
child.shutdown_and_wait().await?;
|
||||
parent.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepared_spawn_captures_policy_after_fork_history() -> anyhow::Result<()> {
|
||||
let (home, config, manager) = spawn_fixture().await?;
|
||||
let parent = manager
|
||||
.start_thread(StartThreadOptions::new(config))
|
||||
.await?
|
||||
.thread;
|
||||
let session = &parent.session;
|
||||
let step = session
|
||||
.capture_step_context(session.new_default_turn().await, &CancellationToken::new())
|
||||
.await?;
|
||||
let (request, path) = crate::thread_spawn::prepare(
|
||||
session,
|
||||
&step,
|
||||
"fork-call",
|
||||
child_request(Some(SpawnAgentForkMode::FullHistory)),
|
||||
)
|
||||
.await?;
|
||||
let rollout = parent
|
||||
.rollout_path()
|
||||
.expect("local parent has a rollout path");
|
||||
assert!(!rollout.try_exists()?);
|
||||
let mut preparation = std::pin::pin!(prepare_spawn(
|
||||
&parent,
|
||||
request,
|
||||
path,
|
||||
SpawnDestination::Detached,
|
||||
));
|
||||
// Pause the fork as soon as it materializes the parent's rollout, then amend the policy.
|
||||
let materialized = poll_fn(|cx| {
|
||||
if rollout.exists() {
|
||||
return Poll::Ready(());
|
||||
}
|
||||
assert!(
|
||||
preparation.as_mut().poll(cx).is_pending(),
|
||||
"fork finished before materializing history"
|
||||
);
|
||||
Poll::Pending
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), materialized).await?;
|
||||
let command = vec!["late_fork_policy_test".to_owned()];
|
||||
let parent_policy = &session.services.exec_policy;
|
||||
parent_policy
|
||||
.append_amendment_and_update(home.path(), &ExecPolicyAmendment::new(command.clone()))
|
||||
.await?;
|
||||
let prepared = tokio::time::timeout(Duration::from_secs(10), preparation).await??;
|
||||
let later = vec!["after_prepare_policy_test".to_owned()];
|
||||
parent_policy
|
||||
.append_amendment_and_update(home.path(), &ExecPolicyAmendment::new(later.clone()))
|
||||
.await?;
|
||||
let prefixes = prepared
|
||||
.inheritance
|
||||
.exec_policy
|
||||
.expect("detached policy")
|
||||
.current()
|
||||
.get_allowed_prefixes();
|
||||
assert!(prefixes.contains(&command));
|
||||
assert!(!prefixes.contains(&later));
|
||||
parent.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Prepared inputs and host reservations for native child startup.
|
||||
|
||||
use crate::agent::api::AgentControl;
|
||||
use crate::agents_md_manager::SessionInstructions;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::session::session::AppServerClientMetadata;
|
||||
use crate::thread_manager::StartThreadOptions;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::ThreadId;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A fresh child ID reserved in the host's tree.
|
||||
/// The host commits membership, serializes startup attempts and deduplicates retries.
|
||||
/// Core derives parent, session and path from [`PreparedThreadSpawn`]; the controller
|
||||
/// supplies runtime coordination, and its `spawn` method is not called.
|
||||
pub struct HostThreadSpawnReservation {
|
||||
pub thread_id: ThreadId,
|
||||
pub control: Arc<dyn AgentControl>,
|
||||
}
|
||||
|
||||
/// Opaque, in-process snapshot for starting a child without its source runtime.
|
||||
/// Settings and authority reflect preparation time. The destination must supply compatible
|
||||
/// services and register the captured environment IDs with matching working directories and
|
||||
/// capability mappings. Detached preparation rejects thread attachments, live instruction providers,
|
||||
/// enabled EMA-authenticated MCP servers, and unresolved environment configuration.
|
||||
/// The child inherits user and thread instructions and discovers repository instructions
|
||||
/// in its destination environment.
|
||||
pub struct PreparedThreadSpawn {
|
||||
pub(super) caller: ThreadId,
|
||||
pub(super) session_id: SessionId,
|
||||
pub(super) agent_path: AgentPath,
|
||||
pub(super) client_metadata: AppServerClientMetadata,
|
||||
pub(super) thread_options: StartThreadOptions,
|
||||
pub(super) inheritance: PreparedChildInheritance,
|
||||
}
|
||||
|
||||
impl PreparedThreadSpawn {
|
||||
/// Canonical path of the child in the agent tree.
|
||||
pub fn agent_path(&self) -> &AgentPath {
|
||||
&self.agent_path
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PreparedChildInheritance {
|
||||
pub(crate) instructions: SessionInstructions,
|
||||
pub(crate) exec_policy: Option<Arc<ExecPolicyManager>>,
|
||||
pub(crate) originator: String,
|
||||
pub(crate) attachment_source: Option<ThreadId>,
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub(crate) mod api;
|
||||
pub(crate) mod child_config;
|
||||
pub(crate) mod control;
|
||||
mod history;
|
||||
pub(crate) mod host;
|
||||
mod registry;
|
||||
pub(crate) mod role;
|
||||
pub(crate) mod status;
|
||||
|
||||
@@ -8,11 +8,15 @@ use crate::elicitation::ElicitationRegistration;
|
||||
use crate::session::SessionIo;
|
||||
use crate::session::SessionSettingsUpdate;
|
||||
use crate::session::Submission;
|
||||
use crate::session::emit_subagent_session_started;
|
||||
use crate::session::new_submission_id;
|
||||
use crate::session::session::AppServerClientMetadata;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::startup_prewarm::PrewarmInput;
|
||||
use crate::session::step_settings::StepSettingsUpdate;
|
||||
use crate::thread_startup_metadata::ThreadStartupMetadata;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_diagnostics::Gauge;
|
||||
use codex_diagnostics::GaugeGuard;
|
||||
use codex_exec_server::SelectedCapabilityRootsStatus;
|
||||
@@ -45,6 +49,7 @@ use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
@@ -289,6 +294,39 @@ impl CodexThread {
|
||||
self.session.services.analytics_events_client.is_enabled()
|
||||
}
|
||||
|
||||
/// Looks up a live V2 tool invocation on a runtime with [`crate::HostToolInvocationLookup`].
|
||||
pub fn tool_invocation(
|
||||
&self,
|
||||
turn_id: &str,
|
||||
call_id: &str,
|
||||
) -> CodexResult<crate::ToolInvocationContext> {
|
||||
crate::agent::host::tool_invocation(self, turn_id, call_id)
|
||||
}
|
||||
|
||||
/// Creates an idle V2 child here with the invoking step's environments and live application
|
||||
/// bindings. Compatible children share the parent's exec policy.
|
||||
/// Ending the invocation or dropping this future aborts pending startup.
|
||||
/// [`crate::ThreadManager::create_prepared_child`] defines acceptance and cleanup.
|
||||
pub async fn create_child(
|
||||
&self,
|
||||
invocation: &crate::ToolInvocationContext,
|
||||
request: crate::ThreadCreateRequest,
|
||||
reservation: crate::HostThreadSpawnReservation,
|
||||
) -> Result<crate::ThreadSpawnResult, crate::ThreadSpawnError> {
|
||||
crate::agent::host::create(self, invocation, request, reservation).await
|
||||
}
|
||||
|
||||
/// Captures inputs from a live invocation for [`crate::ThreadManager::create_prepared_child`].
|
||||
/// The host owns cancellation and snapshot freshness after this returns. Detached preparation
|
||||
/// rejects managed application network, auth, and cloud config bindings.
|
||||
pub async fn prepare_child(
|
||||
&self,
|
||||
invocation: &crate::ToolInvocationContext,
|
||||
request: crate::ThreadCreateRequest,
|
||||
) -> Result<crate::PreparedThreadSpawn, crate::ThreadSpawnError> {
|
||||
crate::agent::host::prepare(self, invocation, request).await
|
||||
}
|
||||
|
||||
/// Returns extension-owned data attached to this thread runtime.
|
||||
pub fn thread_extension_data(&self) -> &codex_extension_api::ExtensionData {
|
||||
&self.session.services.thread_extension_data
|
||||
@@ -338,6 +376,29 @@ impl CodexThread {
|
||||
self.io.session_loop_termination.clone().await;
|
||||
}
|
||||
|
||||
pub(crate) async fn emit_subagent_session_started(
|
||||
&self,
|
||||
client_metadata: AppServerClientMetadata,
|
||||
resumed_created_at: Option<DateTime<Utc>>,
|
||||
) {
|
||||
let SessionSource::SubAgent(source @ SubAgentSource::ThreadSpawn { .. }) =
|
||||
&self.session_source
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let config = self.config_snapshot().await;
|
||||
emit_subagent_session_started(
|
||||
&self.session.services.analytics_events_client,
|
||||
client_metadata,
|
||||
self.session.session_id(),
|
||||
self.session.thread_id(),
|
||||
config.parent_thread_id,
|
||||
config,
|
||||
source.clone(),
|
||||
resumed_created_at,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) async fn emit_thread_ready_lifecycle(&self) {
|
||||
let contributors = self
|
||||
.session
|
||||
|
||||
@@ -152,6 +152,13 @@ mod event_mapping;
|
||||
pub use codex_prompts as review_prompts;
|
||||
mod thread_manager;
|
||||
mod thread_spawn;
|
||||
pub use agent::host::HostThreadSpawnReservation;
|
||||
pub use agent::host::HostToolInvocationLookup;
|
||||
pub use agent::host::PreparedThreadSpawn;
|
||||
pub use agent::host::ToolInvocationContext;
|
||||
pub use thread_spawn::ThreadCreateRequest;
|
||||
pub use thread_spawn::ThreadSpawnError;
|
||||
pub use thread_spawn::ThreadSpawnResult;
|
||||
pub(crate) mod web_search;
|
||||
pub(crate) mod windows_sandbox_read_grants;
|
||||
pub use agent::control::ThreadEvictionOutcome;
|
||||
|
||||
@@ -356,7 +356,14 @@ impl StartThreadOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parent dependencies resolved before the shared session startup.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ThreadReadyNotification {
|
||||
Emit,
|
||||
// Caller installs cleanup before running ready callbacks.
|
||||
Defer,
|
||||
}
|
||||
|
||||
/// Parent dependencies resolved from live Local state or a prepared snapshot.
|
||||
struct ThreadStartupContext {
|
||||
history_initialization: HistoryInitialization,
|
||||
instructions: SessionInstructions,
|
||||
@@ -367,13 +374,21 @@ struct ThreadStartupContext {
|
||||
attachment_source: Option<ThreadId>,
|
||||
}
|
||||
|
||||
struct PreparedChildOrigin {
|
||||
history_initialization: HistoryInitialization,
|
||||
originator: String,
|
||||
attachment_source: Option<ThreadId>,
|
||||
}
|
||||
|
||||
struct ThreadSpawnRequest {
|
||||
ready_notification: ThreadReadyNotification,
|
||||
startup: Option<Arc<crate::session::startup::SessionStartup>>,
|
||||
options: StartThreadOptions,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
agent_control: AgentControlInit,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
parent_originator: Option<String>,
|
||||
prepared_child: Option<PreparedChildOrigin>,
|
||||
forked_from_thread_id: Option<ThreadId>,
|
||||
fork_persistence: ForkPersistence,
|
||||
inherited_environments: Option<TurnEnvironmentSnapshot>,
|
||||
@@ -389,12 +404,14 @@ impl ThreadSpawnRequest {
|
||||
agent_control: impl Into<AgentControlInit>,
|
||||
) -> Self {
|
||||
Self {
|
||||
ready_notification: ThreadReadyNotification::Emit,
|
||||
startup: None,
|
||||
options,
|
||||
auth_manager,
|
||||
agent_control: agent_control.into(),
|
||||
parent_thread_id: None,
|
||||
parent_originator: None,
|
||||
prepared_child: None,
|
||||
forked_from_thread_id: None,
|
||||
fork_persistence: ForkPersistence::Copied,
|
||||
inherited_environments: None,
|
||||
@@ -1139,6 +1156,27 @@ impl ThreadManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Creates a [prepared child](crate::PreparedThreadSpawn) using this manager's services.
|
||||
///
|
||||
/// On `Ok`, the child is loaded and idle, its creation has been published, and the host
|
||||
/// owns it. The host chooses whether to submit work and handles any later delivery failure.
|
||||
///
|
||||
/// An error or a dropped future schedules asynchronous cleanup of any provisional child.
|
||||
/// Already-durable history may remain.
|
||||
pub async fn create_prepared_child(
|
||||
&self,
|
||||
prepared: crate::agent::host::PreparedThreadSpawn,
|
||||
reservation: crate::agent::host::HostThreadSpawnReservation,
|
||||
) -> Result<crate::ThreadSpawnResult, crate::ThreadSpawnError> {
|
||||
crate::agent::host::start_prepared(
|
||||
&self.agent_control().runtime,
|
||||
prepared,
|
||||
reservation,
|
||||
/*invocation*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Starts a fresh internal session associated with an existing parent thread.
|
||||
pub async fn spawn_internal_session(
|
||||
&self,
|
||||
@@ -2059,6 +2097,52 @@ impl ThreadManagerState {
|
||||
Box::pin(self.spawn_thread(request)).await
|
||||
}
|
||||
|
||||
/// Starts from captured inheritance without requiring a live parent runtime.
|
||||
pub(crate) async fn spawn_prepared_child_thread(
|
||||
&self,
|
||||
options: StartThreadOptions,
|
||||
agent_control: AgentControlInit,
|
||||
parent_thread_id: ThreadId,
|
||||
inheritance: crate::agent::host::PreparedChildInheritance,
|
||||
) -> CodexResult<NewThread> {
|
||||
if let Some(environments) = &options.environments {
|
||||
crate::environment_selection::validate_environment_ids_and_cwds(
|
||||
&self.environment_manager,
|
||||
environments
|
||||
.iter()
|
||||
.map(|environment| (environment.environment_id.as_str(), &environment.cwd)),
|
||||
)?;
|
||||
}
|
||||
options.reserved_thread_id.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest("prepared child startup requires a reserved thread ID".into())
|
||||
})?;
|
||||
let mut request =
|
||||
ThreadSpawnRequest::new(options, Arc::clone(&self.auth_manager), agent_control);
|
||||
request.ready_notification = ThreadReadyNotification::Defer;
|
||||
request.parent_thread_id = Some(parent_thread_id);
|
||||
let history_initialization = match &request.options.initial_history {
|
||||
InitialHistory::Forked(_) => {
|
||||
request.forked_from_thread_id = Some(parent_thread_id);
|
||||
request.fork_persistence = ForkPersistence::CopiedDeferred;
|
||||
HistoryInitialization::WarmFork
|
||||
}
|
||||
InitialHistory::New => HistoryInitialization::New,
|
||||
InitialHistory::Resumed(_) | InitialHistory::Cleared => {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"prepared spawn requires new or forked history".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
request.inherited_instructions = Some(inheritance.instructions);
|
||||
request.inherited_exec_policy = inheritance.exec_policy;
|
||||
request.prepared_child = Some(PreparedChildOrigin {
|
||||
history_initialization,
|
||||
originator: inheritance.originator,
|
||||
attachment_source: inheritance.attachment_source,
|
||||
});
|
||||
Box::pin(self.spawn_thread(request)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resume_thread_with_history_with_source(
|
||||
&self,
|
||||
options: ResumeThreadWithHistoryOptions,
|
||||
@@ -2151,12 +2235,14 @@ impl ThreadManagerState {
|
||||
let startup_guard =
|
||||
owns_startup.then(|| SessionStartupGuard::new(Arc::clone(&startup_state)));
|
||||
let ThreadSpawnRequest {
|
||||
ready_notification,
|
||||
startup,
|
||||
options,
|
||||
auth_manager,
|
||||
agent_control,
|
||||
parent_thread_id,
|
||||
parent_originator,
|
||||
prepared_child,
|
||||
forked_from_thread_id,
|
||||
fork_persistence,
|
||||
inherited_environments,
|
||||
@@ -2164,7 +2250,9 @@ impl ThreadManagerState {
|
||||
inherited_exec_policy,
|
||||
user_shell_override,
|
||||
} = request;
|
||||
let history_initialization = if matches!(
|
||||
let history_initialization = if let Some(child) = &prepared_child {
|
||||
child.history_initialization
|
||||
} else if matches!(
|
||||
&options.initial_history,
|
||||
InitialHistory::New | InitialHistory::Forked(_)
|
||||
) && let Some(source_thread_id) = forked_from_thread_id
|
||||
@@ -2345,6 +2433,9 @@ impl ThreadManagerState {
|
||||
}
|
||||
let (exec_policy, multi_agent_version) = match isolation {
|
||||
SessionIsolation::Isolated => (None, Some(MultiAgentVersion::Disabled)),
|
||||
SessionIsolation::Inherit if prepared_child.is_some() => {
|
||||
(inherited_exec_policy, Some(MultiAgentVersion::V2))
|
||||
}
|
||||
SessionIsolation::Inherit => (
|
||||
inherited_exec_policy,
|
||||
self.initial_multi_agent_version_for_spawn(
|
||||
@@ -2356,16 +2447,10 @@ impl ThreadManagerState {
|
||||
.await,
|
||||
),
|
||||
};
|
||||
let startup_context = ThreadStartupContext {
|
||||
history_initialization,
|
||||
instructions,
|
||||
exec_policy,
|
||||
multi_agent_version,
|
||||
parent_rollout_thread_trace: self
|
||||
.parent_rollout_thread_trace_for_source(&session_source, &initial_history)
|
||||
.await,
|
||||
originator: self
|
||||
.effective_originator(
|
||||
let (originator, attachment_source) = match prepared_child {
|
||||
Some(child) => (child.originator, child.attachment_source),
|
||||
None => (
|
||||
self.effective_originator(
|
||||
&initial_history,
|
||||
metrics_service_name.as_deref(),
|
||||
&session_source,
|
||||
@@ -2374,8 +2459,20 @@ impl ThreadManagerState {
|
||||
parent_originator,
|
||||
)
|
||||
.await,
|
||||
attachment_source: forked_from_thread_id
|
||||
.filter(|_| matches!(&initial_history, InitialHistory::Forked(_))),
|
||||
forked_from_thread_id
|
||||
.filter(|_| matches!(&initial_history, InitialHistory::Forked(_))),
|
||||
),
|
||||
};
|
||||
let startup_context = ThreadStartupContext {
|
||||
history_initialization,
|
||||
instructions,
|
||||
exec_policy,
|
||||
multi_agent_version,
|
||||
parent_rollout_thread_trace: self
|
||||
.parent_rollout_thread_trace_for_source(&session_source, &initial_history)
|
||||
.await,
|
||||
originator,
|
||||
attachment_source,
|
||||
};
|
||||
let (extensions, mcp_manager) = if isolation == SessionIsolation::Isolated {
|
||||
let extensions = thread_extension_init
|
||||
@@ -2526,7 +2623,9 @@ impl ThreadManagerState {
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
new_thread.thread.emit_thread_ready_lifecycle().await;
|
||||
if matches!(ready_notification, ThreadReadyNotification::Emit) {
|
||||
new_thread.thread.emit_thread_ready_lifecycle().await;
|
||||
}
|
||||
if source_changed_during_startup.load(Ordering::Acquire) {
|
||||
new_thread.thread.session.request_mcp_runtime_refresh();
|
||||
}
|
||||
@@ -2622,13 +2721,12 @@ impl ThreadManagerState {
|
||||
if matches!(initial_history, InitialHistory::Resumed(_)) {
|
||||
return codex_rollout_trace::ThreadTraceContext::disabled();
|
||||
}
|
||||
// Parent lookup can fail if the parent was closed or released between
|
||||
// spawn preparation and session construction. Tracing is diagnostic, so
|
||||
// that race should not block child creation; the child simply starts
|
||||
// without a parent rollout trace.
|
||||
// A stopped parent or one on another manager cannot supply a local trace.
|
||||
// Tracing is best-effort and never blocks child creation.
|
||||
self.get_thread(*parent_thread_id)
|
||||
.await
|
||||
.ok()
|
||||
.filter(|thread| thread.is_running())
|
||||
.map(|thread| thread.session.services.rollout_thread_trace.clone())
|
||||
.unwrap_or_else(codex_rollout_trace::ThreadTraceContext::disabled)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::agent::types::MessageDeliveryMode;
|
||||
use crate::agent::types::SpawnAgentForkMode;
|
||||
use crate::agent::types::SpawnAgentOptions;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::config::Config;
|
||||
use crate::session::multi_agents::resolve_usage_hints;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::step_context::StepContext;
|
||||
@@ -26,6 +27,15 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
|
||||
/// Child creation options; omitted overrides retain native MAv2 inheritance and role rules.
|
||||
pub struct ThreadCreateRequest {
|
||||
pub task_name: String,
|
||||
/// `None` starts fresh; `FullHistory` uses native fork preparation.
|
||||
pub fork_mode: Option<SpawnAgentForkMode>,
|
||||
pub agent_type: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
}
|
||||
|
||||
pub(crate) struct ThreadSpawnRequest {
|
||||
pub task_name: String,
|
||||
/// Explicit encoding: hosts normally provide plaintext; native tools can forward ciphertext.
|
||||
@@ -37,8 +47,39 @@ pub(crate) struct ThreadSpawnRequest {
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
}
|
||||
|
||||
/// Accepted child and its effective configuration. Acceptance does not mean the turn completed.
|
||||
pub(crate) struct ThreadSpawnResult {
|
||||
impl ThreadSpawnRequest {
|
||||
pub(crate) fn into_parts(
|
||||
self,
|
||||
) -> Result<(ThreadCreateRequest, AgentMessage), ThreadSpawnError> {
|
||||
let message_text = match &self.message {
|
||||
AgentMessage::Plaintext(text) | AgentMessage::Encrypted(text) => text,
|
||||
};
|
||||
if message_text.trim().is_empty() {
|
||||
return Err(ThreadSpawnError::Preparation(
|
||||
"Empty message can't be sent to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
ThreadCreateRequest {
|
||||
task_name: self.task_name,
|
||||
fork_mode: self.fork_mode,
|
||||
agent_type: self.agent_type,
|
||||
model: self.model,
|
||||
reasoning_effort: self.reasoning_effort,
|
||||
},
|
||||
self.message,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ChildSpawnSettings {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) source: SessionSource,
|
||||
pub(crate) options: SpawnAgentOptions,
|
||||
}
|
||||
|
||||
/// Child runtime and its effective configuration.
|
||||
pub struct ThreadSpawnResult {
|
||||
pub agent: LiveAgent,
|
||||
pub config: ThreadConfigSnapshot,
|
||||
pub task_name: AgentPath,
|
||||
@@ -46,7 +87,7 @@ pub(crate) struct ThreadSpawnResult {
|
||||
|
||||
/// Separates preparation errors from backend failures to preserve native tool error reporting.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ThreadSpawnError {
|
||||
pub enum ThreadSpawnError {
|
||||
#[error("{0}")]
|
||||
Preparation(String),
|
||||
#[error(transparent)]
|
||||
@@ -59,7 +100,18 @@ pub(crate) async fn spawn(
|
||||
call_id: &str,
|
||||
request: ThreadSpawnRequest,
|
||||
) -> Result<ThreadSpawnResult, ThreadSpawnError> {
|
||||
let (request, task_name) = prepare(session, step_context, call_id, request).await?;
|
||||
let (request, message) = request.into_parts()?;
|
||||
let (settings, task_name) = prepare(session, step_context, call_id, request).await?;
|
||||
let request = SpawnRequest {
|
||||
caller: session.thread_id,
|
||||
config: settings.config,
|
||||
input: AgentInput::Message {
|
||||
message,
|
||||
mode: MessageDeliveryMode::TriggerTurn,
|
||||
},
|
||||
source: settings.source,
|
||||
options: settings.options,
|
||||
};
|
||||
let (agent, config) = session.services.agent_control.spawn(request).await?;
|
||||
Ok(ThreadSpawnResult {
|
||||
agent,
|
||||
@@ -73,25 +125,16 @@ pub(crate) async fn prepare(
|
||||
session: &Session,
|
||||
step_context: &StepContext,
|
||||
call_id: &str,
|
||||
request: ThreadSpawnRequest,
|
||||
) -> Result<(SpawnRequest, AgentPath), ThreadSpawnError> {
|
||||
request: ThreadCreateRequest,
|
||||
) -> Result<(ChildSpawnSettings, AgentPath), ThreadSpawnError> {
|
||||
let turn = &step_context.turn;
|
||||
let ThreadSpawnRequest {
|
||||
let ThreadCreateRequest {
|
||||
task_name,
|
||||
message,
|
||||
fork_mode,
|
||||
agent_type,
|
||||
model,
|
||||
reasoning_effort,
|
||||
} = request;
|
||||
let message_text = match &message {
|
||||
AgentMessage::Plaintext(text) | AgentMessage::Encrypted(text) => text,
|
||||
};
|
||||
if message_text.trim().is_empty() {
|
||||
return Err(ThreadSpawnError::Preparation(
|
||||
"Empty message can't be sent to an agent".to_string(),
|
||||
));
|
||||
}
|
||||
let role_name = agent_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
@@ -149,13 +192,8 @@ pub(crate) async fn prepare(
|
||||
None
|
||||
};
|
||||
Ok((
|
||||
SpawnRequest {
|
||||
caller: session.thread_id,
|
||||
ChildSpawnSettings {
|
||||
config,
|
||||
input: AgentInput::Message {
|
||||
message,
|
||||
mode: MessageDeliveryMode::TriggerTurn,
|
||||
},
|
||||
source: spawn_source,
|
||||
options: SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.to_string()),
|
||||
|
||||
@@ -48,6 +48,7 @@ pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;
|
||||
pub(crate) struct ToolCallState {
|
||||
pub(crate) terminal_outcome_reached: AtomicBool,
|
||||
pub(crate) delivered_assistant_message: OnceLock<String>,
|
||||
pub(crate) invocation: crate::agent::host::InvocationReservation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
|
||||
@@ -160,6 +160,13 @@ impl ToolCallRuntime {
|
||||
cancellation_token: CancellationToken,
|
||||
call_state: Arc<ToolCallState>,
|
||||
) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
|
||||
if let Err(err) =
|
||||
call_state
|
||||
.invocation
|
||||
.reserve(&self.session, &step_context.turn, &call.call_id)
|
||||
{
|
||||
return Either::Left(std::future::ready(Err(err)));
|
||||
}
|
||||
let message_admission =
|
||||
super::user_messaging::admit_code_mode_send(&self.session, &source, &call.tool_name);
|
||||
self.session
|
||||
@@ -320,7 +327,7 @@ impl ToolCallRuntime {
|
||||
tracker,
|
||||
dispatch_call,
|
||||
source,
|
||||
dispatch_call_state,
|
||||
Arc::clone(&dispatch_call_state),
|
||||
)
|
||||
.await;
|
||||
drop(_admitted_timing);
|
||||
@@ -339,22 +346,34 @@ impl ToolCallRuntime {
|
||||
started.elapsed(),
|
||||
);
|
||||
}
|
||||
// Finished calls release their IDs even if their results have not been collected.
|
||||
// Otherwise the outer task may still need the ID for its abort callback.
|
||||
if dispatch_call_state
|
||||
.terminal_outcome_reached
|
||||
.load(Ordering::Acquire)
|
||||
{
|
||||
drop(dispatch_call_state.invocation.take());
|
||||
}
|
||||
result
|
||||
}
|
||||
.instrument(dispatch_span.clone().or_current()),
|
||||
));
|
||||
|
||||
async move {
|
||||
let completion = async move {
|
||||
let mut tool_call_timing_guard = tool_call_timing_guard;
|
||||
let join_error = |err| {
|
||||
drop(call_state.invocation.take());
|
||||
Self::tool_task_join_error(err)
|
||||
};
|
||||
let mut result = tokio::select! {
|
||||
res = &mut dispatch_handle => res.map_err(Self::tool_task_join_error)?,
|
||||
res = &mut dispatch_handle => res.map_err(join_error)?,
|
||||
_ = cancellation_token.cancelled() => {
|
||||
// Dynamic handlers observe the token and publish their terminal item themselves.
|
||||
if finishes_on_cancellation
|
||||
|| call_state.terminal_outcome_reached.load(Ordering::Acquire)
|
||||
|| dispatch_handle.is_finished()
|
||||
{
|
||||
dispatch_handle.await.map_err(Self::tool_task_join_error)?
|
||||
dispatch_handle.await.map_err(join_error)?
|
||||
} else {
|
||||
let secs = started.elapsed().as_secs_f32().max(0.1);
|
||||
abort_dispatch_span.record("aborted", true);
|
||||
@@ -362,6 +381,7 @@ impl ToolCallRuntime {
|
||||
match dispatch_handle.await {
|
||||
Ok(result) => result,
|
||||
Err(err) if err.is_cancelled() => {
|
||||
let _reservation = call_state.invocation.take();
|
||||
let response = Self::aborted_response(&call, Some(secs));
|
||||
call_trace::result_ready(
|
||||
thread_id,
|
||||
@@ -387,6 +407,7 @@ impl ToolCallRuntime {
|
||||
}
|
||||
},
|
||||
};
|
||||
let _reservation = call_state.invocation.take();
|
||||
// Cancellation outside the handler can return before the registry reports an outcome.
|
||||
if cancellation_token.is_cancelled()
|
||||
&& !call_state
|
||||
@@ -416,7 +437,8 @@ impl ToolCallRuntime {
|
||||
}
|
||||
result
|
||||
}
|
||||
.instrument(dispatch_span.or_current())
|
||||
.instrument(dispatch_span.or_current());
|
||||
Either::Right(completion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -711,6 +711,15 @@ impl ToolRegistry {
|
||||
mut invocation: ToolInvocation,
|
||||
call_state: Option<Arc<ToolCallState>>,
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
// Standalone dispatch has no outer task to reserve the ID for it.
|
||||
let standalone;
|
||||
let reservation = if let Some(state) = &call_state {
|
||||
&state.invocation
|
||||
} else {
|
||||
standalone = crate::agent::host::InvocationReservation::default();
|
||||
standalone.reserve(&invocation.session, &invocation.turn, &invocation.call_id)?;
|
||||
&standalone
|
||||
};
|
||||
let cancellation_token = invocation.cancellation_token.clone();
|
||||
let tool_name = invocation.tool_name.clone();
|
||||
let call_id_owned = invocation.call_id.clone();
|
||||
@@ -879,6 +888,7 @@ impl ToolRegistry {
|
||||
return Err(fail_before_execution(&invocation, err).await);
|
||||
}
|
||||
|
||||
let invocation_guard = reservation.activate(&invocation);
|
||||
if tool.mcp_server_name().is_none() {
|
||||
notify_tool_start(&invocation, /*mcp_tool*/ None)
|
||||
.or_cancel_tool(&cancellation_token)
|
||||
@@ -978,6 +988,7 @@ impl ToolRegistry {
|
||||
handler_executed: true,
|
||||
},
|
||||
};
|
||||
drop(invocation_guard);
|
||||
notify_tool_finish_if_unclaimed(&invocation, call_state.as_deref(), lifecycle_outcome)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -273,6 +273,16 @@ where
|
||||
wait_for_event_with_timeout(codex, predicate, Duration::from_secs(1)).await
|
||||
}
|
||||
|
||||
pub async fn wait_for_successful_turn(codex: &CodexThread) {
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
|
||||
let event = wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
assert!(
|
||||
matches!(&event, EventMsg::TurnComplete(completed) if completed.error.is_none()),
|
||||
"turn failed: {event:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Waits for a configured MCP server to finish startup and requires it to be ready.
|
||||
pub async fn wait_for_mcp_server(codex: &CodexThread, server_name: &str) -> anyhow::Result<()> {
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
|
||||
@@ -105,6 +105,19 @@ fn assert_turn_id(body: &Value, key: &str, expected: Option<&str>) -> Result<()>
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResponsesRequest(wiremock::Request);
|
||||
|
||||
pub async fn received_analytics_events(server: &MockServer, event_type: &str) -> Vec<Value> {
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
||||
.filter_map(|request| serde_json::from_slice::<Value>(&request.body).ok())
|
||||
.flat_map(|payload| payload["events"].as_array().cloned().unwrap_or_default())
|
||||
.filter(|event| event["event_type"] == event_type)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn received_responses_requests(server: &MockServer) -> Vec<ResponsesRequest> {
|
||||
server
|
||||
.received_requests()
|
||||
|
||||
@@ -51,7 +51,7 @@ use serde_json::json;
|
||||
use test_case::test_case;
|
||||
use wiremock::MockServer;
|
||||
|
||||
struct TestAgentControl {
|
||||
pub(super) struct TestAgentControl {
|
||||
thread_id: ThreadId,
|
||||
service_tier: Mutex<Option<String>>,
|
||||
mail: Mutex<Vec<PendingAgentMessage>>,
|
||||
@@ -60,7 +60,7 @@ struct TestAgentControl {
|
||||
}
|
||||
|
||||
impl TestAgentControl {
|
||||
fn new(thread_id: ThreadId) -> Self {
|
||||
pub(super) fn new(thread_id: ThreadId) -> Self {
|
||||
Self {
|
||||
thread_id,
|
||||
service_tier: Mutex::default(),
|
||||
|
||||
@@ -57,6 +57,7 @@ use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_successful_turn;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::from_slice;
|
||||
@@ -439,14 +440,6 @@ async fn wait_for_turn_complete(codex: &CodexThread) {
|
||||
wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
}
|
||||
|
||||
async fn wait_for_successful_turn(codex: &CodexThread) {
|
||||
let event = wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
assert!(
|
||||
matches!(&event, EventMsg::TurnComplete(completed) if completed.error.is_none()),
|
||||
"turn failed: {event:?}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_sleep_item_started(codex: &CodexThread, call_id: &str, duration_ms: u64) {
|
||||
let event = wait_for_event(codex, |event| {
|
||||
matches!(
|
||||
|
||||
@@ -141,6 +141,9 @@ mod mailbox_preemption;
|
||||
#[path = "scenarios_agent_eviction_tests.rs"]
|
||||
mod agent_eviction;
|
||||
|
||||
#[path = "scenarios_reserved_spawn.rs"]
|
||||
mod reserved_spawn;
|
||||
|
||||
#[path = "scenarios_partial_answers.rs"]
|
||||
mod partial_answers;
|
||||
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
//! Exercises host-reserved child startup with fresh and inherited history.
|
||||
|
||||
use super::super::agent_control::TestAgentControl;
|
||||
use super::super::agents_md::RecordingThreadInstructionsProvider;
|
||||
use super::*;
|
||||
use anyhow::Context;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_core::AgentControl;
|
||||
use codex_core::CodexThread;
|
||||
use codex_core::HostThreadSpawnReservation;
|
||||
use codex_core::HostToolInvocationLookup;
|
||||
use codex_core::PromptCacheKey;
|
||||
use codex_core::SpawnAgentForkMode;
|
||||
use codex_core::StartIfIdleSubmission;
|
||||
use codex_core::ThreadCreateRequest;
|
||||
use codex_core::ThreadSpawnError;
|
||||
use codex_extension_api::ExtensionFuture;
|
||||
use codex_extension_api::Instructions;
|
||||
use codex_extension_api::ThreadInstructionsProvider;
|
||||
use codex_extension_api::ThreadLifecycleContributor;
|
||||
use codex_extension_api::ThreadReadyInput;
|
||||
use codex_http_client::DestinationPolicy;
|
||||
use codex_http_client::NetworkPolicy;
|
||||
use codex_http_client::NetworkPolicyController;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::input_text;
|
||||
use codex_protocol::protocol::ThreadHistoryMode;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_thread_store::AddThreadAttachmentParams;
|
||||
use codex_thread_store::ListThreadAttachmentsParams;
|
||||
use codex_thread_store::LoadThreadHistoryParams;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::submit_thread_settings;
|
||||
use core_test_support::test_codex::TestCodexBuilder;
|
||||
use core_test_support::wait_for_successful_turn;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::sync::Notify;
|
||||
use wiremock::MockServer;
|
||||
|
||||
async fn host_spawn_fixture(
|
||||
server: &MockServer,
|
||||
builder: TestCodexBuilder,
|
||||
application_network_policy: NetworkPolicy,
|
||||
prompt_cache_key: PromptCacheKey,
|
||||
thread_instructions_provider: Option<Arc<dyn ThreadInstructionsProvider>>,
|
||||
) -> Result<(TestCodex, Arc<CodexThread>, Arc<TestAgentControl>)> {
|
||||
let root_id = ThreadId::new();
|
||||
// This controller rejects every AgentControl::spawn call. Successful startup therefore
|
||||
// demonstrates the direct host hook, while runtime calls still use the host controller.
|
||||
let controller = Arc::new(TestAgentControl::new(root_id));
|
||||
let mut test = builder
|
||||
.with_model("gpt-6-astra")
|
||||
.with_config(move |config| {
|
||||
configure_scenario_catalog(config);
|
||||
config.application_network_policy = application_network_policy;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2)
|
||||
.expect("enable multi-agent V2");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::MultiAgentV2DynamicTools)
|
||||
.expect("enable dynamic tool inheritance");
|
||||
config.multi_agent_v2.disable_native_tools = true;
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile(PermissionProfile::read_only())
|
||||
.expect("configure read-only permissions");
|
||||
})
|
||||
.with_thread_manager({
|
||||
let controller = Arc::clone(&controller);
|
||||
move |manager| {
|
||||
manager.with_agent_control_factory(move |thread_id| {
|
||||
let control: Arc<dyn AgentControl> = if thread_id == root_id {
|
||||
controller.clone()
|
||||
} else {
|
||||
Arc::new(TestAgentControl::new(thread_id))
|
||||
};
|
||||
async move { Ok(control) }
|
||||
})
|
||||
}
|
||||
})
|
||||
.build_with_auto_env(server)
|
||||
.await?;
|
||||
let mut options = test.start_thread_options().await;
|
||||
options
|
||||
.thread_extension_init
|
||||
.insert(HostToolInvocationLookup);
|
||||
options.history_mode = Some(ThreadHistoryMode::Legacy);
|
||||
options.prompt_cache_key = prompt_cache_key;
|
||||
options.thread_instructions_provider = thread_instructions_provider;
|
||||
options.reserved_thread_id = Some(root_id);
|
||||
options.disabled_plugin_ids = Some(vec!["captured-plugin@local".into()]);
|
||||
options.dynamic_tools = vec![DynamicToolSpec::Function(DynamicToolFunctionSpec {
|
||||
name: "host_spawn".into(),
|
||||
description: "Ask the host to start a child from this invocation.".into(),
|
||||
input_schema: json!({"type": "object", "properties": {}}),
|
||||
defer_loading: false,
|
||||
})];
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
test.thread_manager
|
||||
.remove_thread(&test.session_configured.thread_id)
|
||||
.await;
|
||||
let root = test.thread_manager.start_thread(options).await?;
|
||||
test.codex = Arc::clone(&root.thread);
|
||||
test.session_configured = root.session_configured;
|
||||
let parent = root.thread;
|
||||
Ok((test, parent, controller))
|
||||
}
|
||||
|
||||
async fn host_spawn_invocation(
|
||||
server: &MockServer,
|
||||
parent: &Arc<CodexThread>,
|
||||
call_id: &str,
|
||||
message: &str,
|
||||
) -> Result<DynamicToolCallRequest> {
|
||||
responses::mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
responses::ev_function_call(call_id, "host_spawn", "{}"),
|
||||
ev_completed(call_id),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
parent
|
||||
.start_or_steer_turn(TurnInputRequest::user_input(vec![text(message)]))
|
||||
.await?;
|
||||
Ok(wait_for_event_match(parent, |event| match event {
|
||||
EventMsg::DynamicToolCallRequest(call) => Some(call.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await)
|
||||
}
|
||||
|
||||
async fn finish_spawn_tool(
|
||||
server: &MockServer,
|
||||
parent: &CodexThread,
|
||||
invocation: &DynamicToolCallRequest,
|
||||
) -> Result<()> {
|
||||
responses::mount_sse_once(server, sse(vec![ev_completed("parent-done")])).await;
|
||||
parent
|
||||
.submit(Op::DynamicToolResponse {
|
||||
id: invocation.call_id.clone(),
|
||||
response: DynamicToolResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: "Done.".into(),
|
||||
}],
|
||||
success: true,
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(parent, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
assert!(
|
||||
parent
|
||||
.tool_invocation(&invocation.turn_id, &invocation.call_id)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_request(name: &str, fork_history: bool) -> ThreadCreateRequest {
|
||||
ThreadCreateRequest {
|
||||
task_name: name.into(),
|
||||
fork_mode: fork_history.then_some(SpawnAgentForkMode::FullHistory),
|
||||
agent_type: None,
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_first_turn(child: &CodexThread, prompt: &str) -> Result<()> {
|
||||
let submission = child
|
||||
.start_turn_if_idle(TurnInputRequest::user_input(vec![text(prompt)]))
|
||||
.await?;
|
||||
assert!(
|
||||
matches!(submission, StartIfIdleSubmission::Started { .. }),
|
||||
"expected the new child to be idle, got {submission:?}"
|
||||
);
|
||||
wait_for_successful_turn(child).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case(false; "fresh")]
|
||||
#[test_case::test_case(true; "forked")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn same_manager_creation_preserves_live_bindings_and_attachments(
|
||||
fork_history: bool,
|
||||
) -> Result<()> {
|
||||
const PR: &str = "openai/codex#123";
|
||||
const UPDATED_RULE: &str = "Updated shared worker instructions.";
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let network_controller = NetworkPolicyController::default();
|
||||
let network_policy = network_controller.policy();
|
||||
assert!(network_controller.publish(network_policy.revision(), DestinationPolicy::Unrestricted));
|
||||
let provider =
|
||||
Arc::new(RecordingThreadInstructionsProvider::with_text("Initial rules.").shared());
|
||||
let (test, parent, controller) = host_spawn_fixture(
|
||||
&server,
|
||||
test_codex(),
|
||||
network_policy.clone(),
|
||||
PromptCacheKey::SessionDerived,
|
||||
Some(provider.clone()),
|
||||
)
|
||||
.await?;
|
||||
let root_id = test.session_configured.thread_id;
|
||||
let captured = parent.config_snapshot().await;
|
||||
let invocation =
|
||||
host_spawn_invocation(&server, &parent, "spawn-worker", "Start the worker.").await?;
|
||||
let context = parent.tool_invocation(&invocation.turn_id, &invocation.call_id)?;
|
||||
test.thread_store
|
||||
.add_thread_attachment(AddThreadAttachmentParams {
|
||||
thread_id: root_id,
|
||||
attachment_type: "pull_request".into(),
|
||||
identity_key: PR.into(),
|
||||
payload: json!({"number": 123}),
|
||||
})
|
||||
.await?;
|
||||
// Changing current thread settings must not change the invocation's captured settings.
|
||||
submit_thread_settings(
|
||||
&parent,
|
||||
ThreadSettingsOverrides {
|
||||
disabled_plugin_ids: Some(vec!["later-plugin@local".into()]),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let child_id = ThreadId::new();
|
||||
let request = || create_request("worker", fork_history);
|
||||
let reservation = || HostThreadSpawnReservation {
|
||||
thread_id: child_id,
|
||||
control: controller.clone(),
|
||||
};
|
||||
// A detached snapshot cannot carry the source's live application policy.
|
||||
assert!(matches!(
|
||||
parent
|
||||
.prepare_child(&context, request())
|
||||
.await,
|
||||
Err(ThreadSpawnError::Backend(error))
|
||||
if error.to_string().contains("host-managed application authority")
|
||||
));
|
||||
let mut mismatched = reservation();
|
||||
mismatched.control = Arc::new(TestAgentControl::new(ThreadId::new()));
|
||||
assert!(matches!(
|
||||
parent.create_child(&context, request(), mismatched).await,
|
||||
Err(ThreadSpawnError::Preparation(_))
|
||||
));
|
||||
assert!(test.thread_manager.get_thread(child_id).await.is_err());
|
||||
|
||||
provider.set_instructions(Some(Instructions {
|
||||
text: UPDATED_RULE.into(),
|
||||
source: None,
|
||||
}));
|
||||
let spawned = parent
|
||||
.create_child(&context, request(), reservation())
|
||||
.await?;
|
||||
assert_eq!(spawned.config.approval_policy, captured.approval_policy);
|
||||
assert_eq!(spawned.config.environments, captured.environments);
|
||||
assert_eq!(
|
||||
spawned.config.disabled_plugin_ids,
|
||||
captured.disabled_plugin_ids
|
||||
);
|
||||
let worker = test.thread_manager.get_thread(child_id).await?;
|
||||
// A handle cannot be used through a different source runtime.
|
||||
assert!(matches!(
|
||||
worker.prepare_child(&context, request()).await,
|
||||
Err(ThreadSpawnError::Backend(error))
|
||||
if error.to_string().contains("tool invocation belongs to a different runtime")
|
||||
));
|
||||
let child_network_policy = worker.config().await.application_network_policy.clone();
|
||||
assert_eq!(child_network_policy, network_policy);
|
||||
assert_eq!(
|
||||
worker.rollout_path().context("child rollout")?.is_file(),
|
||||
fork_history,
|
||||
"only copied history should be materialized at creation"
|
||||
);
|
||||
// Once creation returns, ending the spawning invocation leaves the idle child available.
|
||||
finish_spawn_tool(&server, &parent, &invocation).await?;
|
||||
let child_request =
|
||||
responses::mount_sse_once(&server, responses::sse_completed("worker-done")).await;
|
||||
start_first_turn(&worker, "Report your status.").await?;
|
||||
assert!(
|
||||
child_request
|
||||
.single_request()
|
||||
.body_contains_text(UPDATED_RULE)
|
||||
);
|
||||
let page = test
|
||||
.thread_store
|
||||
.list_thread_attachments(ListThreadAttachmentsParams {
|
||||
thread_id: child_id,
|
||||
cursor: None,
|
||||
limit: 1,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(
|
||||
page.attachments
|
||||
.first()
|
||||
.map(|attachment| attachment.identity_key.as_str()),
|
||||
fork_history.then_some(PR)
|
||||
);
|
||||
|
||||
worker.shutdown_and_wait().await?;
|
||||
parent.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case(false, "inherited instruction providers"; "thread instructions")]
|
||||
#[test_case::test_case(true, "EMA-authenticated MCP servers"; "enterprise MCP")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prepare_child_rejects_live_bindings(ema_enabled: bool, expected: &str) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"features.use_xaa = true
|
||||
mcp_enterprise_managed_auth.idp = {{ issuer = "https://idp.example", client_id = "test" }}
|
||||
mcp_servers.enterprise = {{ url = "https://mcp.example/mcp", auth = "ema_auth", enabled = {ema_enabled} }}"#
|
||||
),
|
||||
)?;
|
||||
let (test, parent, controller) = host_spawn_fixture(
|
||||
&server,
|
||||
test_codex().with_home(home),
|
||||
NetworkPolicy::unmanaged(),
|
||||
PromptCacheKey::SessionDerived,
|
||||
(!ema_enabled).then(|| {
|
||||
Arc::new(RecordingThreadInstructionsProvider::with_text("Shared rules").shared())
|
||||
as Arc<dyn ThreadInstructionsProvider>
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let call = host_spawn_invocation(&server, &parent, "prepare-worker", "Start a worker.").await?;
|
||||
let context = parent.tool_invocation(&call.turn_id, &call.call_id)?;
|
||||
let request = || create_request("worker", /*fork_history*/ false);
|
||||
let Err(ThreadSpawnError::Backend(error)) = parent.prepare_child(&context, request()).await
|
||||
else {
|
||||
anyhow::bail!("expected detached preparation to reject {expected}");
|
||||
};
|
||||
assert!(matches!(
|
||||
error.details(),
|
||||
CodexErrorDetails::InvalidRequest(message) if message.contains(expected)
|
||||
));
|
||||
let child_id = ThreadId::new();
|
||||
parent
|
||||
.create_child(
|
||||
&context,
|
||||
request(),
|
||||
HostThreadSpawnReservation {
|
||||
thread_id: child_id,
|
||||
control: controller,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
test.thread_manager
|
||||
.get_thread(child_id)
|
||||
.await?
|
||||
.shutdown_and_wait()
|
||||
.await?;
|
||||
parent.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case::test_case(true, None; "full_history")]
|
||||
#[test_case::test_case(false, Some("host-cache-key"); "fresh_history")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prepared_spawn_starts_on_an_independent_destination_after_parent_shutdown(
|
||||
fork_history: bool,
|
||||
cache_key: Option<&str>,
|
||||
) -> Result<()> {
|
||||
const PROJECT_RULE: &str = "Run the destination repository checks.";
|
||||
const EARLIER_INPUT: &str = "Remember PRIOR_CONTEXT_5241.";
|
||||
const SPAWN_INPUT: &str =
|
||||
"Remember TRANSFERRED_CONTEXT_7391 and ask the host to start a worker.";
|
||||
const TASK: &str = "Report TRANSFERRED_TASK_8812 and the remembered context.";
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let (source, parent, source_control) = host_spawn_fixture(
|
||||
&server,
|
||||
test_codex(),
|
||||
NetworkPolicy::unmanaged(),
|
||||
cache_key.map_or(PromptCacheKey::Omit, |key| {
|
||||
PromptCacheKey::Custom(key.into())
|
||||
}),
|
||||
/*thread_instructions_provider*/ None,
|
||||
)
|
||||
.await?;
|
||||
let root_id = source.session_configured.thread_id;
|
||||
let session_id = source.session_configured.session_id;
|
||||
parent
|
||||
.set_app_server_client_info(
|
||||
Some("source-client".into()),
|
||||
Some("1.0".into()),
|
||||
/*mcp_elicitations_auto_deny*/ false,
|
||||
)
|
||||
.await?;
|
||||
let captured = parent.config_snapshot().await;
|
||||
responses::mount_sse_once(&server, sse(vec![ev_completed("remembered")])).await;
|
||||
source.submit_text_turn(EARLIER_INPUT).await?;
|
||||
let invocation = host_spawn_invocation(&server, &parent, "prepare-worker", SPAWN_INPUT).await?;
|
||||
let context = parent.tool_invocation(&invocation.turn_id, &invocation.call_id)?;
|
||||
let prepared = parent
|
||||
.prepare_child(&context, create_request("remote_worker", fork_history))
|
||||
.await?;
|
||||
let child_path = prepared.agent_path().clone();
|
||||
assert_eq!(child_path.as_str(), "/root/remote_worker");
|
||||
let agents_md = source.workspace_path_uri("AGENTS.md")?;
|
||||
|
||||
// Keep only durable storage and referenced paths; startup cannot consult A's runtime,
|
||||
// manager, controller, or environment services after this point.
|
||||
let source_store = Arc::clone(&source.thread_store);
|
||||
let _source_home = Arc::clone(&source.home);
|
||||
let _source_cwd = Arc::clone(&source.cwd);
|
||||
let source_manager = Arc::downgrade(&source.thread_manager);
|
||||
let source_parent = Arc::downgrade(&parent);
|
||||
parent.shutdown_and_wait().await?;
|
||||
source.thread_manager.remove_thread(&root_id).await;
|
||||
drop(parent);
|
||||
drop(source);
|
||||
drop(source_control);
|
||||
assert!(source_manager.upgrade().is_none());
|
||||
assert!(source_parent.upgrade().is_none());
|
||||
|
||||
// Independent runtimes may share durable storage.
|
||||
wiremock::Mock::given(wiremock::matchers::path("/codex/analytics-events/events"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(/*status*/ 200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let analytics = AnalyticsEventsClient::new(
|
||||
codex_core::test_support::auth_manager_from_auth(
|
||||
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
),
|
||||
server.uri(),
|
||||
/*analytics_enabled*/ Some(true),
|
||||
);
|
||||
let destination = test_codex()
|
||||
.with_analytics_events_client(analytics.clone())
|
||||
.with_thread_store(source_store)
|
||||
.with_model("gpt-6-astra")
|
||||
.with_config(configure_scenario_catalog)
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
let manager = &destination.thread_manager;
|
||||
destination.codex.shutdown_and_wait().await?;
|
||||
manager
|
||||
.remove_thread(&destination.session_configured.thread_id)
|
||||
.await;
|
||||
destination
|
||||
.fs()
|
||||
.write_file(
|
||||
&agents_md,
|
||||
PROJECT_RULE.as_bytes().to_vec(),
|
||||
Default::default(),
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let child_id = ThreadId::new();
|
||||
// The destination controller shares the tree identity but has no entry for the child path.
|
||||
let destination_control = Arc::new(TestAgentControl::new(root_id));
|
||||
let child_request =
|
||||
responses::mount_sse_once(&server, responses::sse_completed("remote-done")).await;
|
||||
let spawned = manager
|
||||
.create_prepared_child(
|
||||
prepared,
|
||||
HostThreadSpawnReservation {
|
||||
thread_id: child_id,
|
||||
control: destination_control,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
(
|
||||
spawned.agent.thread_id,
|
||||
&spawned.task_name,
|
||||
spawned.config.parent_thread_id,
|
||||
),
|
||||
(child_id, &child_path, Some(root_id))
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
spawned.config.permission_profile,
|
||||
spawned.config.disabled_plugin_ids,
|
||||
),
|
||||
(captured.permission_profile, captured.disabled_plugin_ids),
|
||||
);
|
||||
let worker = manager.get_thread(child_id).await?;
|
||||
start_first_turn(&worker, TASK).await?;
|
||||
analytics.flush().await;
|
||||
let events = responses::received_analytics_events(&server, "codex_thread_initialized").await;
|
||||
let child_starts: Vec<_> = events
|
||||
.iter()
|
||||
.map(|event| &event["event_params"])
|
||||
.filter(|params| params["thread_id"] == json!(child_id))
|
||||
.collect();
|
||||
let [start] = child_starts.as_slice() else {
|
||||
anyhow::bail!("expected one initialization for {child_id}, got {child_starts:?}");
|
||||
};
|
||||
assert_eq!(start["parent_thread_id"], json!(root_id));
|
||||
assert_eq!(start["app_server_client"]["client_name"], "source-client");
|
||||
assert_eq!(start["app_server_client"]["client_version"], "1.0");
|
||||
let requests = responses::received_responses_requests(&server).await;
|
||||
let history = context_snapshot::format_request_history_snapshot(
|
||||
"A prepares a child. After A and its manager shut down, a second manager creates the idle child and then starts its first turn.",
|
||||
&requests,
|
||||
&ContextSnapshotOptions::default().rewrite_known_segments(),
|
||||
);
|
||||
let snapshot_name = if fork_history {
|
||||
"detached_spawn_full_history"
|
||||
} else {
|
||||
"detached_spawn_fresh_history"
|
||||
};
|
||||
insta::assert_snapshot!(snapshot_name, history);
|
||||
let child_request = child_request.single_request();
|
||||
assert_eq!(
|
||||
child_request.body_json().get("prompt_cache_key").cloned(),
|
||||
cache_key.map(|key| json!(key)),
|
||||
);
|
||||
assert!(
|
||||
child_request
|
||||
.inputs_of_type("additional_tools")
|
||||
.iter()
|
||||
.any(|item| {
|
||||
responses::namespace_child_tool(item, "functions", "exec")
|
||||
.and_then(|tool| tool["description"].as_str())
|
||||
.is_some_and(|description| description.contains("host_spawn(args:"))
|
||||
})
|
||||
);
|
||||
let user_messages = child_request.message_input_texts("user");
|
||||
assert!(user_messages.iter().any(|text| text.contains(PROJECT_RULE)));
|
||||
worker.ensure_rollout_materialized().await;
|
||||
worker.flush_rollout().await?;
|
||||
let stored = destination
|
||||
.thread_store
|
||||
.load_latest_model_context(LoadThreadHistoryParams {
|
||||
thread_id: child_id,
|
||||
include_archived: false,
|
||||
})
|
||||
.await?;
|
||||
assert!(stored.items.iter().any(|item| matches!(item,
|
||||
RolloutItem::SessionMeta(meta) if meta.meta.session_id == session_id
|
||||
&& meta.meta.parent_thread_id == Some(root_id)
|
||||
)));
|
||||
for input in [EARLIER_INPUT, SPAWN_INPUT] {
|
||||
let span = input_text(input);
|
||||
let inherited = stored.items.iter().find_map(|item| match item {
|
||||
RolloutItem::ResponseItem(envelope)
|
||||
if matches!(&envelope.item, ResponseItem::Message { role, content, .. }
|
||||
if role == "user" && content.contains(&span)) =>
|
||||
{
|
||||
Some(
|
||||
envelope
|
||||
.metadata
|
||||
.as_ref()
|
||||
.is_some_and(|meta| meta.inherited_user_message),
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(inherited, fork_history.then_some(true), "{input}");
|
||||
}
|
||||
assert!(manager.get_thread(root_id).await.is_err());
|
||||
worker.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ChildStartupGate {
|
||||
child_id: ThreadId,
|
||||
entered: Notify,
|
||||
}
|
||||
|
||||
impl ThreadLifecycleContributor<Config> for ChildStartupGate {
|
||||
fn on_thread_ready<'a>(
|
||||
&'a self,
|
||||
input: ThreadReadyInput<'a, Config>,
|
||||
) -> ExtensionFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if input.thread_store.level_id() == self.child_id.to_string() {
|
||||
self.entered.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cancelled_creation_cleans_registered_child() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let child_id = ThreadId::new();
|
||||
let gate = Arc::new(ChildStartupGate {
|
||||
child_id,
|
||||
entered: Notify::new(),
|
||||
});
|
||||
let mut extensions = ExtensionRegistryBuilder::new();
|
||||
extensions.thread_lifecycle_contributor(gate.clone());
|
||||
let (test, parent, controller) = host_spawn_fixture(
|
||||
&server,
|
||||
test_codex().with_extensions(Arc::new(extensions.build())),
|
||||
NetworkPolicy::unmanaged(),
|
||||
PromptCacheKey::SessionDerived,
|
||||
/*thread_instructions_provider*/ None,
|
||||
)
|
||||
.await?;
|
||||
let invocation =
|
||||
host_spawn_invocation(&server, &parent, "cancel-spawn", "Start a worker.").await?;
|
||||
let context = parent.tool_invocation(&invocation.turn_id, &invocation.call_id)?;
|
||||
let spawn = tokio::spawn({
|
||||
let parent = Arc::clone(&parent);
|
||||
async move {
|
||||
parent
|
||||
.create_child(
|
||||
&context,
|
||||
create_request("cancelled", /*fork_history*/ true),
|
||||
HostThreadSpawnReservation {
|
||||
thread_id: child_id,
|
||||
control: controller,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), gate.entered.notified())
|
||||
.await
|
||||
.context("child never reached the startup gate")?;
|
||||
let child = test.thread_manager.get_thread(child_id).await?;
|
||||
parent.submit(Op::Interrupt).await?;
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_secs(10), spawn)
|
||||
.await
|
||||
.context("host spawn did not observe invocation revocation")??,
|
||||
Err(ThreadSpawnError::Preparation(_))
|
||||
));
|
||||
tokio::time::timeout(Duration::from_secs(10), child.wait_until_terminated())
|
||||
.await
|
||||
.context("cancelled child's runtime did not terminate")?;
|
||||
assert!(test.thread_manager.get_thread(child_id).await.is_err());
|
||||
parent.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -384,16 +384,7 @@ async fn wait_for_analytics_events(
|
||||
) -> Vec<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let events = server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
||||
.filter_map(|request| serde_json::from_slice::<Value>(&request.body).ok())
|
||||
.flat_map(|payload| payload["events"].as_array().cloned().unwrap_or_default())
|
||||
.filter(|event| event["event_type"] == event_type)
|
||||
.collect::<Vec<_>>();
|
||||
let events = responses::received_analytics_events(server, event_type).await;
|
||||
if events.len() >= expected_count {
|
||||
return events;
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_reserved_spawn.rs
|
||||
expression: history
|
||||
---
|
||||
Scenario: A prepares a child. After A and its manager shut down, a second manager creates the idle child and then starts its first turn.
|
||||
|
||||
## Window 1
|
||||
-- request 1 (turn) --
|
||||
00:additional_tools/developer (2; hash=e03063c2cb6ece3a):
|
||||
- namespace/functions; hash=e58195dea5c51dea
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/request_user_input
|
||||
- function/request_user_input_async
|
||||
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
|
||||
- function/sleep
|
||||
01:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
02:message/developer[2]:
|
||||
[01] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <COLLABORATION_MODE>
|
||||
03:message/developer:
|
||||
<MULTI_AGENT_ROLE>
|
||||
04:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
05:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
06:message/user:
|
||||
Remember PRIOR_CONTEXT_5241.
|
||||
07:configuration_update:reasoning={"effort":"low"}
|
||||
-- request 2 (turn) --
|
||||
08:message/user:
|
||||
Remember TRANSFERRED_CONTEXT_7391 and ask the host to start a worker.
|
||||
|
||||
## Window 2 (after request 2: input diverged at item 00)
|
||||
-- request 3 (turn) --
|
||||
00:additional_tools/developer (2; hash=adec8e1bf249cb8f):
|
||||
- namespace/functions; hash=00aa6cf7871d56b9
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/request_user_input
|
||||
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
|
||||
- function/sleep
|
||||
01:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
02:message/developer[2]:
|
||||
[01] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <COLLABORATION_MODE>
|
||||
03:message/developer:
|
||||
<MULTI_AGENT_ROLE>
|
||||
04:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
05:message/user[2]:
|
||||
[01] <AGENTS_MD>
|
||||
[02] <ENVIRONMENT_CONTEXT>
|
||||
06:message/user:
|
||||
Report TRANSFERRED_TASK_8812 and the remembered context.
|
||||
07:configuration_update:reasoning={"effort":"low"}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
---
|
||||
source: core/tests/suite/scenarios_reserved_spawn.rs
|
||||
expression: history
|
||||
---
|
||||
Scenario: A prepares a child. After A and its manager shut down, a second manager creates the idle child and then starts its first turn.
|
||||
|
||||
## Window 1
|
||||
-- request 1 (turn) --
|
||||
00:additional_tools/developer (2; hash=e03063c2cb6ece3a):
|
||||
- namespace/functions; hash=e58195dea5c51dea
|
||||
- custom/exec
|
||||
- function/wait
|
||||
- function/request_user_input
|
||||
- function/request_user_input_async
|
||||
- namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317
|
||||
- function/sleep
|
||||
01:message/developer:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
02:message/developer[2]:
|
||||
[01] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <COLLABORATION_MODE>
|
||||
03:message/developer:
|
||||
<MULTI_AGENT_ROLE>
|
||||
04:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
05:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
06:message/user:
|
||||
Remember PRIOR_CONTEXT_5241.
|
||||
07:configuration_update:reasoning={"effort":"low"}
|
||||
-- request 2 (turn) --
|
||||
08:message/user:
|
||||
Remember TRANSFERRED_CONTEXT_7391 and ask the host to start a worker.
|
||||
-- request 3 (turn) --
|
||||
09:function_call/host_spawn:{}
|
||||
10:function_call_output:aborted
|
||||
11:message/developer:
|
||||
You are now a newly spawned child agent. The preceding conversation is inherited parent context. Follow ...are not inherited. Report your result to your parent. [hash=0e586a5d405d39db]
|
||||
12:message/developer:
|
||||
<MULTI_AGENT_ROLE>
|
||||
13:message/developer:
|
||||
The following tools are no longer available. Do not call them:
|
||||
- functions.request_user_input_async
|
||||
14:message/user:
|
||||
<AGENTS_MD>
|
||||
15:message/developer:
|
||||
<MULTI_AGENT_MODE>
|
||||
16:message/user:
|
||||
Report TRANSFERRED_TASK_8812 and the remembered context.
|
||||
17:configuration_update:reasoning={"effort":"low"}
|
||||
@@ -2,6 +2,7 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_features::Feature;
|
||||
use core_test_support::test_codex::local_requests;
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
@@ -70,84 +71,45 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_turn_and_measure(test: &TestCodex, prompt: &str) -> anyhow::Result<Duration> {
|
||||
let start = Instant::now();
|
||||
run_turn(test, prompt).await?;
|
||||
Ok(start.elapsed())
|
||||
}
|
||||
|
||||
async fn build_codex_with_test_tool(server: &wiremock::MockServer) -> anyhow::Result<TestCodex> {
|
||||
let mut builder = test_codex().with_model("test-gpt-5.1-codex");
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
fn assert_parallel_duration(actual: Duration) {
|
||||
// Allow headroom for slow CI scheduling; barrier synchronization already enforces overlap.
|
||||
assert!(
|
||||
actual < Duration::from_millis(1_600),
|
||||
"expected parallel execution to finish quickly, got {actual:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn read_file_tools_run_in_parallel() -> anyhow::Result<()> {
|
||||
async fn native_v2_tool_calls_with_the_same_id_run_in_parallel() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = build_codex_with_test_tool(&server).await?;
|
||||
|
||||
let warmup_args = json!({
|
||||
"sleep_after_ms": 10,
|
||||
"barrier": {
|
||||
"id": "parallel-test-sync-warmup",
|
||||
"participants": 2,
|
||||
"timeout_ms": 1_000,
|
||||
}
|
||||
let test = test_codex()
|
||||
.with_model("test-gpt-5.1-codex")
|
||||
.with_config(|config| {
|
||||
config.features.enable(Feature::MultiAgentV2).unwrap();
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
let args = json!({
|
||||
"barrier": { "id": "parallel-test-sync", "participants": 2, "timeout_ms": 5_000 }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let parallel_args = json!({
|
||||
"sleep_after_ms": 300,
|
||||
"barrier": {
|
||||
"id": "parallel-test-sync",
|
||||
"participants": 2,
|
||||
"timeout_ms": 1_000,
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let warmup_first = sse(vec![
|
||||
json!({"type": "response.created", "response": {"id": "resp-warm-1"}}),
|
||||
ev_function_call("warm-call-1", "test_sync_tool", &warmup_args),
|
||||
ev_function_call("warm-call-2", "test_sync_tool", &warmup_args),
|
||||
ev_completed("resp-warm-1"),
|
||||
]);
|
||||
let warmup_second = sse(vec![
|
||||
ev_assistant_message("warm-msg-1", "warmup complete"),
|
||||
ev_completed("resp-warm-2"),
|
||||
]);
|
||||
|
||||
let first_response = sse(vec![
|
||||
json!({"type": "response.created", "response": {"id": "resp-1"}}),
|
||||
ev_function_call("call-1", "test_sync_tool", ¶llel_args),
|
||||
ev_function_call("call-2", "test_sync_tool", ¶llel_args),
|
||||
let calls = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call("shared", "test_sync_tool", &args),
|
||||
ev_function_call("shared", "test_sync_tool", &args),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
let second_response = sse(vec![
|
||||
let done = sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
mount_sse_sequence(
|
||||
&server,
|
||||
vec![warmup_first, warmup_second, first_response, second_response],
|
||||
)
|
||||
.await;
|
||||
|
||||
run_turn(&test, "warm up parallel tool").await?;
|
||||
|
||||
let duration = run_turn_and_measure(&test, "exercise sync tool").await?;
|
||||
assert_parallel_duration(duration);
|
||||
let responses = mount_sse_sequence(&server, vec![calls, done]).await;
|
||||
|
||||
run_turn(&test, "exercise sync tool").await?;
|
||||
// Either call can return "ok" only after both handlers reach the barrier.
|
||||
assert_eq!(
|
||||
responses.function_call_output_text("shared").as_deref(),
|
||||
Some("ok")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -240,8 +202,13 @@ async fn mixed_parallel_tools_run_in_parallel() -> anyhow::Result<()> {
|
||||
]);
|
||||
mount_sse_sequence(&server, vec![first_response, second_response]).await;
|
||||
|
||||
let duration = run_turn_and_measure(&test, "mix tools").await?;
|
||||
assert_parallel_duration(duration);
|
||||
let start = Instant::now();
|
||||
run_turn(&test, "mix tools").await?;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(1_600),
|
||||
"expected parallel execution to finish quickly, got {elapsed:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct MessageLifecycleInput {
|
||||
pub data: ExtensionData,
|
||||
}
|
||||
|
||||
/// Observes messages published through local agent control, where the sender is known.
|
||||
/// Observes messages from local agent control and the initial task of a host-spawned child.
|
||||
/// Callbacks are synchronous and must not block. Replaying history or submitting raw
|
||||
/// protocol input does not create a publication.
|
||||
pub trait MessageLifecycleContributor: Send + Sync {
|
||||
|
||||
Reference in New Issue
Block a user