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:
rhan-oai
2026-10-27 23:02:27 +00:00
committed by copyberry
parent cb66dbecf2
commit 37f199dab1
29 changed files with 2203 additions and 209 deletions
+1
View File
@@ -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)]
+27 -5
View File
@@ -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)
}
}
+11
View File
@@ -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;