mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
Centralize child agent configuration in the agent module (#45669)
## What changed Move shared spawn and resume configuration helpers into `agent::child_config` and route both multi-agent versions through `prepare_agent_spawn_config`. Preserve each version's role and model precedence, full-history inheritance, service-tier selection, runtime policy, and default-role metadata for reloads. Return configuration errors as strings and convert them to `FunctionCallError::RespondToModel` at tool-handler boundaries. GitOrigin-RevId: 9d539050aa44f94043a6487f1f6f1037de1d15ca
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
//! Prepares child configuration from the invoking turn and requested overrides.
|
||||
//!
|
||||
//! Spawn and reload share live runtime policy; role and model precedence, full-history
|
||||
//! inheritance, and validation messages remain the same for each multi-agent version.
|
||||
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
use crate::config::Config;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
|
||||
pub(crate) const MAX_SPAWN_AGENT_MODEL_OVERRIDES: usize = 5;
|
||||
|
||||
pub(crate) fn model_supports_multi_agent_backend(
|
||||
model: &ModelPreset,
|
||||
multi_agent_version: MultiAgentVersion,
|
||||
) -> bool {
|
||||
multi_agent_version != MultiAgentVersion::V2
|
||||
|| model.multi_agent_version != Some(MultiAgentVersion::Disabled)
|
||||
}
|
||||
|
||||
/// Selects the existing spawn tool's role-inheritance rules.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SpawnConfigVersion {
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
|
||||
pub(crate) struct SpawnConfigOptions<'a> {
|
||||
pub(crate) version: SpawnConfigVersion,
|
||||
pub(crate) full_history_fork: bool,
|
||||
pub(crate) role_name: Option<&'a str>,
|
||||
pub(crate) model: Option<&'a str>,
|
||||
pub(crate) reasoning_effort: Option<ReasoningEffort>,
|
||||
}
|
||||
|
||||
pub(crate) struct PreparedSpawnConfig {
|
||||
pub(crate) config: Config,
|
||||
pub(crate) role_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolves child settings before starting the thread, retaining the invoking tool's precedence.
|
||||
pub(crate) async fn prepare_agent_spawn_config(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
options: SpawnConfigOptions<'_>,
|
||||
) -> Result<PreparedSpawnConfig, String> {
|
||||
let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn)?;
|
||||
if options.version == SpawnConfigVersion::V1 && options.full_history_fork {
|
||||
reject_full_fork_agent_type_override(options.role_name)?;
|
||||
}
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
session,
|
||||
turn,
|
||||
&mut config,
|
||||
options.model,
|
||||
options.reasoning_effort,
|
||||
)
|
||||
.await?;
|
||||
if !options.full_history_fork
|
||||
|| (options.version == SpawnConfigVersion::V2 && options.role_name.is_some())
|
||||
{
|
||||
apply_spawn_agent_role(session, &mut config, options.role_name).await?;
|
||||
if options.version == SpawnConfigVersion::V2
|
||||
&& options.full_history_fork
|
||||
&& config.developer_instructions.is_none()
|
||||
{
|
||||
config
|
||||
.developer_instructions
|
||||
.clone_from(&turn.developer_instructions);
|
||||
}
|
||||
}
|
||||
apply_spawn_agent_service_tier(session, &mut config).await?;
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn)?;
|
||||
|
||||
// Remember an applied configured default so cold reload reapplies its restrictions.
|
||||
let role_name = options
|
||||
.role_name
|
||||
.or_else(|| {
|
||||
(options.version == SpawnConfigVersion::V2
|
||||
&& !options.full_history_fork
|
||||
&& config
|
||||
.agent_roles
|
||||
.get(DEFAULT_ROLE_NAME)
|
||||
.is_some_and(|role| role.config_file.is_some()))
|
||||
.then_some(DEFAULT_ROLE_NAME)
|
||||
})
|
||||
.map(str::to_owned);
|
||||
Ok(PreparedSpawnConfig { config, role_name })
|
||||
}
|
||||
|
||||
/// Builds the base config snapshot for a newly spawned sub-agent.
|
||||
///
|
||||
/// The returned config starts from the parent's effective config and then refreshes the
|
||||
/// runtime-owned fields carried by the turn, including model selection, reasoning settings,
|
||||
/// approval policy, sandbox, and cwd. Role-specific overrides are layered
|
||||
/// after this step; skipping this helper and cloning stale config state directly can send the child
|
||||
/// agent out with the wrong provider or runtime policy.
|
||||
pub(crate) fn build_agent_spawn_config(
|
||||
base_instructions: &BaseInstructions,
|
||||
turn: &TurnContext,
|
||||
) -> Result<Config, String> {
|
||||
let mut config = build_agent_shared_config(turn)?;
|
||||
config.base_instructions = Some(base_instructions.text.clone());
|
||||
config.base_instructions_provenance = base_instructions.provenance.clone();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result<Config, String> {
|
||||
let mut config = build_agent_shared_config(turn)?;
|
||||
// For resume, keep base instructions sourced from rollout/session metadata.
|
||||
config.base_instructions = None;
|
||||
config.base_instructions_provenance = None;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, String> {
|
||||
let base_config = turn.config.clone();
|
||||
let mut config = (*base_config).clone();
|
||||
// Preserve activation for history forks without freezing the parent's model-owned prompts.
|
||||
// Fresh child startup restores configured preferences from the retained snapshot.
|
||||
config.token_budget = turn.configured_token_budget.clone();
|
||||
config.model = Some(turn.model_info().slug.clone());
|
||||
config.model_provider = turn.provider.info().clone();
|
||||
config.model_reasoning_effort = turn
|
||||
.reasoning_effort()
|
||||
.or(turn.model_info().default_reasoning_level.as_ref())
|
||||
.cloned();
|
||||
config.model_reasoning_summary = Some(turn.reasoning_summary());
|
||||
config.developer_instructions = turn.developer_instructions.clone();
|
||||
if turn.multi_agent_version == MultiAgentVersion::V2
|
||||
&& let Some(developer_instructions) = turn
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.subagent_developer_instructions
|
||||
.clone()
|
||||
{
|
||||
config.developer_instructions = Some(developer_instructions);
|
||||
}
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn reject_full_fork_agent_type_override(agent_type: Option<&str>) -> Result<(), String> {
|
||||
if agent_type.is_some() {
|
||||
return Err(
|
||||
"Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`.
|
||||
///
|
||||
/// These values are chosen by the live turn rather than persisted config, so leaving them stale can
|
||||
/// make a child agent disagree with its parent about approval policy, cwd, or sandboxing.
|
||||
fn apply_spawn_agent_runtime_overrides(
|
||||
config: &mut Config,
|
||||
turn: &TurnContext,
|
||||
) -> Result<(), String> {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(turn.approval_policy())
|
||||
.map_err(|err| format!("approval_policy is invalid: {err}"))?;
|
||||
config.approvals_reviewer = turn.config.approvals_reviewer;
|
||||
#[allow(deprecated)]
|
||||
let turn_cwd = turn.cwd.clone();
|
||||
config.cwd = turn_cwd;
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile_from_session_snapshot(
|
||||
turn.config
|
||||
.permissions
|
||||
.permission_profile_state()
|
||||
.snapshot(),
|
||||
)
|
||||
.map_err(|err| format!("permission_profile is invalid: {err}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_requested_spawn_agent_model_overrides(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
config: &mut Config,
|
||||
requested_model: Option<&str>,
|
||||
requested_reasoning_effort: Option<ReasoningEffort>,
|
||||
) -> Result<(), String> {
|
||||
let requested_model = requested_model.or(turn.config.agent_default_subagent_model.as_deref());
|
||||
let requested_reasoning_effort = requested_reasoning_effort
|
||||
.or_else(|| turn.config.agent_default_subagent_reasoning_effort.clone());
|
||||
if requested_model.is_none() && requested_reasoning_effort.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(requested_model) = requested_model {
|
||||
let available_models = session
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::Offline, config.http_client_factory())
|
||||
.await;
|
||||
let selected_model_name = find_spawn_agent_model_name(
|
||||
&available_models,
|
||||
requested_model,
|
||||
turn.multi_agent_version,
|
||||
)?;
|
||||
let selected_model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&selected_model_name, &config.to_models_manager_config())
|
||||
.await;
|
||||
|
||||
config.model = Some(selected_model_name.clone());
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&selected_model_name,
|
||||
&selected_model_info.supported_reasoning_levels,
|
||||
&reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
} else {
|
||||
config.model_reasoning_effort = selected_model_info.default_reasoning_level;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&turn.model_info().slug,
|
||||
&turn.model_info().supported_reasoning_levels,
|
||||
&reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_spawn_agent_service_tier(
|
||||
session: &Session,
|
||||
config: &mut Config,
|
||||
) -> Result<(), String> {
|
||||
let Some(service_tier) = session.services.agent_control.root_service_tier() else {
|
||||
config.service_tier = None;
|
||||
return Ok(());
|
||||
};
|
||||
if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE {
|
||||
config.service_tier = Some(service_tier);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let model = config.model.clone().ok_or_else(|| {
|
||||
"spawn_agent could not resolve the child model for service tier validation".to_string()
|
||||
})?;
|
||||
let model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
||||
.await;
|
||||
|
||||
config.service_tier = model_info
|
||||
.supports_service_tier(service_tier.as_str())
|
||||
.then_some(service_tier);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_spawn_agent_role(
|
||||
session: &Session,
|
||||
config: &mut Config,
|
||||
role_name: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let previous_model = config.model.clone();
|
||||
let previous_reasoning_effort = config.model_reasoning_effort.clone();
|
||||
apply_role_to_config(config, role_name).await?;
|
||||
if config.model == previous_model && config.model_reasoning_effort == previous_reasoning_effort
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(reasoning_effort) = config.model_reasoning_effort.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let model = config.model.clone().ok_or_else(|| {
|
||||
"spawn_agent could not resolve the child model for reasoning effort validation".to_string()
|
||||
})?;
|
||||
let model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&model, &config.to_models_manager_config())
|
||||
.await;
|
||||
if model_info.used_fallback_model_metadata {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&model,
|
||||
&model_info.supported_reasoning_levels,
|
||||
&reasoning_effort,
|
||||
)
|
||||
}
|
||||
|
||||
fn find_spawn_agent_model_name(
|
||||
available_models: &[ModelPreset],
|
||||
requested_model: &str,
|
||||
multi_agent_version: MultiAgentVersion,
|
||||
) -> Result<String, String> {
|
||||
available_models
|
||||
.iter()
|
||||
.find(|model| {
|
||||
model.model == requested_model
|
||||
&& model_supports_multi_agent_backend(model, multi_agent_version)
|
||||
})
|
||||
.map(|model| model.model.clone())
|
||||
.ok_or_else(|| {
|
||||
let available = available_models
|
||||
.iter()
|
||||
.filter(|model| model.show_in_picker)
|
||||
.filter(|model| model_supports_multi_agent_backend(model, multi_agent_version))
|
||||
.take(MAX_SPAWN_AGENT_MODEL_OVERRIDES)
|
||||
.map(|model| model.model.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(
|
||||
"Unknown model `{requested_model}` for spawn_agent. Available models: {available}"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_spawn_agent_reasoning_effort(
|
||||
model: &str,
|
||||
supported_reasoning_levels: &[ReasoningEffortPreset],
|
||||
requested_reasoning_effort: &ReasoningEffort,
|
||||
) -> Result<(), String> {
|
||||
if supported_reasoning_levels
|
||||
.iter()
|
||||
.any(|preset| &preset.effort == requested_reasoning_effort)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let supported = supported_reasoning_levels
|
||||
.iter()
|
||||
.map(|preset| preset.effort.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(format!(
|
||||
"Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}"
|
||||
))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::residency::is_v2_resident_session_source;
|
||||
use super::*;
|
||||
use crate::agent::child_config::build_agent_resume_config;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
use crate::codex_thread::CodexThread;
|
||||
use crate::config::PermissionProfileSnapshot;
|
||||
@@ -12,7 +13,6 @@ use crate::context::MultiAgentModeInstructions;
|
||||
use crate::context::MultiAgentRoleInstructions;
|
||||
use crate::context::world_state::PersistentModeState;
|
||||
use crate::session::multi_agents::resolve_usage_hints;
|
||||
use crate::tools::handlers::multi_agents_common::build_agent_resume_config;
|
||||
use codex_context_fragments::set_annotated_content;
|
||||
use codex_context_fragments::to_annotated_content;
|
||||
use codex_extension_api::ExtensionDataInit;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod agent_resolver;
|
||||
pub(crate) mod child_config;
|
||||
pub(crate) mod control;
|
||||
mod registry;
|
||||
pub(crate) mod role;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::agent::child_config::build_agent_resume_config;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
@@ -194,7 +195,8 @@ async fn try_resume_closed_agent(
|
||||
receiver_thread_id: ThreadId,
|
||||
child_depth: i32,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let config = build_agent_resume_config(turn.as_ref())?;
|
||||
let config =
|
||||
build_agent_resume_config(turn.as_ref()).map_err(FunctionCallError::RespondToModel)?;
|
||||
Box::pin(session.services.agent_control.resume_agent_from_rollout(
|
||||
config,
|
||||
receiver_thread_id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::agent::child_config::build_agent_resume_config;
|
||||
use crate::agent::control::render_input_preview;
|
||||
use crate::tools::handlers::multi_agents_spec::create_send_input_tool_v1;
|
||||
use codex_tools::ToolSpec;
|
||||
@@ -51,7 +52,8 @@ impl Handler {
|
||||
.agent_control
|
||||
.get_agent_metadata(receiver_thread_id);
|
||||
if receiver_agent.is_some() {
|
||||
let resume_config = build_agent_resume_config(turn.as_ref())?;
|
||||
let resume_config = build_agent_resume_config(turn.as_ref())
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use crate::agent::child_config::SpawnConfigOptions;
|
||||
use crate::agent::child_config::SpawnConfigVersion;
|
||||
use crate::agent::child_config::prepare_agent_spawn_config;
|
||||
use crate::agent::control::SpawnAgentForkMode;
|
||||
use crate::agent::control::SpawnAgentOptions;
|
||||
use crate::agent::control::render_input_preview;
|
||||
@@ -89,25 +92,20 @@ async fn handle_spawn_agent(
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let mut config =
|
||||
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
|
||||
if args.fork_context {
|
||||
reject_full_fork_agent_type_override(role_name)?;
|
||||
}
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
let prepared = prepare_agent_spawn_config(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort.clone(),
|
||||
SpawnConfigOptions {
|
||||
version: SpawnConfigVersion::V1,
|
||||
full_history_fork: args.fork_context,
|
||||
role_name,
|
||||
model: args.model.as_deref(),
|
||||
reasoning_effort: args.reasoning_effort.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if !args.fork_context {
|
||||
apply_spawn_agent_role(&session, &mut config, role_name).await?;
|
||||
}
|
||||
apply_spawn_agent_service_tier(&session, &mut config).await?;
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
|
||||
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let config = prepared.config;
|
||||
let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata(
|
||||
config,
|
||||
input_items,
|
||||
@@ -115,7 +113,7 @@ async fn handle_spawn_agent(
|
||||
session.thread_id,
|
||||
&turn.session_source,
|
||||
child_depth,
|
||||
role_name,
|
||||
prepared.role_name.as_deref(),
|
||||
/*task_name*/ None,
|
||||
)?),
|
||||
SpawnAgentOptions {
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
use crate::config::Config;
|
||||
use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::CodexErrorDetails;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -30,16 +19,6 @@ use serde_json::Value as JsonValue;
|
||||
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
|
||||
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
|
||||
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS;
|
||||
pub(crate) const MAX_SPAWN_AGENT_MODEL_OVERRIDES: usize = 5;
|
||||
|
||||
pub(crate) fn model_supports_multi_agent_backend(
|
||||
model: &ModelPreset,
|
||||
multi_agent_version: MultiAgentVersion,
|
||||
) -> bool {
|
||||
multi_agent_version != MultiAgentVersion::V2
|
||||
|| model.multi_agent_version != Some(MultiAgentVersion::Disabled)
|
||||
}
|
||||
|
||||
pub(crate) fn function_arguments(payload: ToolPayload) -> Result<String, FunctionCallError> {
|
||||
match payload {
|
||||
ToolPayload::Function { arguments } => Ok(arguments),
|
||||
@@ -166,277 +145,3 @@ pub(crate) fn parse_collab_input(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the base config snapshot for a newly spawned sub-agent.
|
||||
///
|
||||
/// The returned config starts from the parent's effective config and then refreshes the
|
||||
/// runtime-owned fields carried by the turn, including model selection, reasoning settings,
|
||||
/// approval policy, sandbox, and cwd. Role-specific overrides are layered
|
||||
/// after this step; skipping this helper and cloning stale config state directly can send the child
|
||||
/// agent out with the wrong provider or runtime policy.
|
||||
pub(crate) fn build_agent_spawn_config(
|
||||
base_instructions: &BaseInstructions,
|
||||
turn: &TurnContext,
|
||||
) -> Result<Config, FunctionCallError> {
|
||||
let mut config = build_agent_shared_config(turn)?;
|
||||
config.base_instructions = Some(base_instructions.text.clone());
|
||||
config.base_instructions_provenance = base_instructions.provenance.clone();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result<Config, FunctionCallError> {
|
||||
let mut config = build_agent_shared_config(turn)?;
|
||||
// For resume, keep base instructions sourced from rollout/session metadata.
|
||||
config.base_instructions = None;
|
||||
config.base_instructions_provenance = None;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallError> {
|
||||
let base_config = turn.config.clone();
|
||||
let mut config = (*base_config).clone();
|
||||
// Preserve activation for history forks without freezing the parent's model-owned prompts.
|
||||
// Fresh child startup restores configured preferences from the retained snapshot.
|
||||
config.token_budget = turn.configured_token_budget.clone();
|
||||
config.model = Some(turn.model_info().slug.clone());
|
||||
config.model_provider = turn.provider.info().clone();
|
||||
config.model_reasoning_effort = turn
|
||||
.reasoning_effort()
|
||||
.or(turn.model_info().default_reasoning_level.as_ref())
|
||||
.cloned();
|
||||
config.model_reasoning_summary = Some(turn.reasoning_summary());
|
||||
config.developer_instructions = turn.developer_instructions.clone();
|
||||
if turn.multi_agent_version == MultiAgentVersion::V2
|
||||
&& let Some(developer_instructions) = turn
|
||||
.config
|
||||
.multi_agent_v2
|
||||
.subagent_developer_instructions
|
||||
.clone()
|
||||
{
|
||||
config.developer_instructions = Some(developer_instructions);
|
||||
}
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) fn reject_full_fork_agent_type_override(
|
||||
agent_type: Option<&str>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if agent_type.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`.
|
||||
///
|
||||
/// These values are chosen by the live turn rather than persisted config, so leaving them stale can
|
||||
/// make a child agent disagree with its parent about approval policy, cwd, or sandboxing.
|
||||
pub(crate) fn apply_spawn_agent_runtime_overrides(
|
||||
config: &mut Config,
|
||||
turn: &TurnContext,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
config
|
||||
.permissions
|
||||
.approval_policy
|
||||
.set(turn.approval_policy())
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}"))
|
||||
})?;
|
||||
config.approvals_reviewer = turn.config.approvals_reviewer;
|
||||
#[allow(deprecated)]
|
||||
let turn_cwd = turn.cwd.clone();
|
||||
config.cwd = turn_cwd;
|
||||
config
|
||||
.permissions
|
||||
.set_permission_profile_from_session_snapshot(
|
||||
turn.config
|
||||
.permissions
|
||||
.permission_profile_state()
|
||||
.snapshot(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("permission_profile is invalid: {err}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_requested_spawn_agent_model_overrides(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
config: &mut Config,
|
||||
requested_model: Option<&str>,
|
||||
requested_reasoning_effort: Option<ReasoningEffort>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let requested_model = requested_model.or(turn.config.agent_default_subagent_model.as_deref());
|
||||
let requested_reasoning_effort = requested_reasoning_effort
|
||||
.or_else(|| turn.config.agent_default_subagent_reasoning_effort.clone());
|
||||
if requested_model.is_none() && requested_reasoning_effort.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(requested_model) = requested_model {
|
||||
let available_models = session
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::Offline, config.http_client_factory())
|
||||
.await;
|
||||
let selected_model_name = find_spawn_agent_model_name(
|
||||
&available_models,
|
||||
requested_model,
|
||||
turn.multi_agent_version,
|
||||
)?;
|
||||
let selected_model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&selected_model_name, &config.to_models_manager_config())
|
||||
.await;
|
||||
|
||||
config.model = Some(selected_model_name.clone());
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&selected_model_name,
|
||||
&selected_model_info.supported_reasoning_levels,
|
||||
&reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
} else {
|
||||
config.model_reasoning_effort = selected_model_info.default_reasoning_level;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&turn.model_info().slug,
|
||||
&turn.model_info().supported_reasoning_levels,
|
||||
&reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_spawn_agent_service_tier(
|
||||
session: &Session,
|
||||
config: &mut Config,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let Some(service_tier) = session.services.agent_control.root_service_tier() else {
|
||||
config.service_tier = None;
|
||||
return Ok(());
|
||||
};
|
||||
if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE {
|
||||
config.service_tier = Some(service_tier);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let model = config.model.clone().ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"spawn_agent could not resolve the child model for service tier validation".to_string(),
|
||||
)
|
||||
})?;
|
||||
let model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
||||
.await;
|
||||
|
||||
config.service_tier = model_info
|
||||
.supports_service_tier(service_tier.as_str())
|
||||
.then_some(service_tier);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_spawn_agent_role(
|
||||
session: &Session,
|
||||
config: &mut Config,
|
||||
role_name: Option<&str>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let previous_model = config.model.clone();
|
||||
let previous_reasoning_effort = config.model_reasoning_effort.clone();
|
||||
apply_role_to_config(config, role_name)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
if config.model == previous_model && config.model_reasoning_effort == previous_reasoning_effort
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(reasoning_effort) = config.model_reasoning_effort.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let model = config.model.clone().ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"spawn_agent could not resolve the child model for reasoning effort validation"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&model, &config.to_models_manager_config())
|
||||
.await;
|
||||
if model_info.used_fallback_model_metadata {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&model,
|
||||
&model_info.supported_reasoning_levels,
|
||||
&reasoning_effort,
|
||||
)
|
||||
}
|
||||
|
||||
fn find_spawn_agent_model_name(
|
||||
available_models: &[ModelPreset],
|
||||
requested_model: &str,
|
||||
multi_agent_version: MultiAgentVersion,
|
||||
) -> Result<String, FunctionCallError> {
|
||||
available_models
|
||||
.iter()
|
||||
.find(|model| {
|
||||
model.model == requested_model
|
||||
&& model_supports_multi_agent_backend(model, multi_agent_version)
|
||||
})
|
||||
.map(|model| model.model.clone())
|
||||
.ok_or_else(|| {
|
||||
let available = available_models
|
||||
.iter()
|
||||
.filter(|model| model.show_in_picker)
|
||||
.filter(|model| model_supports_multi_agent_backend(model, multi_agent_version))
|
||||
.take(MAX_SPAWN_AGENT_MODEL_OVERRIDES)
|
||||
.map(|model| model.model.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"Unknown model `{requested_model}` for spawn_agent. Available models: {available}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_spawn_agent_reasoning_effort(
|
||||
model: &str,
|
||||
supported_reasoning_levels: &[ReasoningEffortPreset],
|
||||
requested_reasoning_effort: &ReasoningEffort,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if supported_reasoning_levels
|
||||
.iter()
|
||||
.any(|preset| &preset.effort == requested_reasoning_effort)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let supported = supported_reasoning_levels
|
||||
.iter()
|
||||
.map(|preset| preset.effort.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}"
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::multi_agents_common::MAX_SPAWN_AGENT_MODEL_OVERRIDES;
|
||||
use super::multi_agents_common::model_supports_multi_agent_backend;
|
||||
use crate::agent::child_config::MAX_SPAWN_AGENT_MODEL_OVERRIDES;
|
||||
use crate::agent::child_config::model_supports_multi_agent_backend;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_tools::JsonSchema;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::*;
|
||||
use crate::StartThreadOptions;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::child_config::apply_spawn_agent_service_tier;
|
||||
use crate::agent::child_config::build_agent_resume_config;
|
||||
use crate::agent::child_config::build_agent_spawn_config;
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
|
||||
use crate::config::PermissionProfileSnapshot;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use super::analytics::ToolCallAnalytics;
|
||||
use super::*;
|
||||
use crate::agent::child_config::build_agent_resume_config;
|
||||
use crate::agent_communication::AgentCommunicationContext;
|
||||
use crate::agent_communication::AgentCommunicationKind;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
@@ -85,7 +86,8 @@ pub(super) async fn handle_message_string_tool(
|
||||
let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
|
||||
})?;
|
||||
let resume_config = build_agent_resume_config(turn.as_ref())?;
|
||||
let resume_config =
|
||||
build_agent_resume_config(turn.as_ref()).map_err(FunctionCallError::RespondToModel)?;
|
||||
session
|
||||
.services
|
||||
.agent_control
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use crate::agent::child_config::SpawnConfigOptions;
|
||||
use crate::agent::child_config::SpawnConfigVersion;
|
||||
use crate::agent::child_config::prepare_agent_spawn_config;
|
||||
use crate::agent::control::SpawnAgentForkMode;
|
||||
use crate::agent::control::SpawnAgentOptions;
|
||||
use crate::agent::next_thread_spawn_depth;
|
||||
@@ -122,42 +125,26 @@ async fn handle_spawn_agent(
|
||||
|
||||
let session_source = turn.session_source.clone();
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
let mut config =
|
||||
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
|
||||
let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory));
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
let prepared = prepare_agent_spawn_config(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort.clone(),
|
||||
SpawnConfigOptions {
|
||||
version: SpawnConfigVersion::V2,
|
||||
full_history_fork: matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)),
|
||||
role_name,
|
||||
model: args.model.as_deref(),
|
||||
reasoning_effort: args.reasoning_effort.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if !is_full_history_fork || role_name.is_some() {
|
||||
apply_spawn_agent_role(&session, &mut config, role_name).await?;
|
||||
if is_full_history_fork && config.developer_instructions.is_none() {
|
||||
config
|
||||
.developer_instructions
|
||||
.clone_from(&turn.developer_instructions);
|
||||
}
|
||||
}
|
||||
apply_spawn_agent_service_tier(&session, &mut config).await?;
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
|
||||
|
||||
// Remember an applied configured default so cold reload reapplies its restrictions.
|
||||
let persisted_role_name = role_name.or_else(|| {
|
||||
(!is_full_history_fork
|
||||
&& config
|
||||
.agent_roles
|
||||
.get(DEFAULT_ROLE_NAME)
|
||||
.is_some_and(|role| role.config_file.is_some()))
|
||||
.then_some(DEFAULT_ROLE_NAME)
|
||||
});
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let config = prepared.config;
|
||||
let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory));
|
||||
let spawn_source = thread_spawn_source(
|
||||
session.thread_id,
|
||||
&turn.session_source,
|
||||
child_depth,
|
||||
persisted_role_name,
|
||||
prepared.role_name.as_deref(),
|
||||
Some(args.task_name.clone()),
|
||||
)?;
|
||||
let new_agent_path = spawn_source.get_agent_path().ok_or_else(|| {
|
||||
|
||||
Reference in New Issue
Block a user