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
+60 -22
View File
@@ -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()),