diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 5175c675ef..d40c289a6b 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server/src/external_agent_migration/session_importer.rs b/codex-rs/app-server/src/external_agent_migration/session_importer.rs index caaef00904..88e1a9ad16 100644 --- a/codex-rs/app-server/src/external_agent_migration/session_importer.rs +++ b/codex-rs/app-server/src/external_agent_migration/session_importer.rs @@ -26,6 +26,7 @@ use codex_external_agent_migration::sessions::record_completed_session_imports; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::RolloutItem; @@ -448,6 +449,17 @@ impl ExternalAgentSessionImporter { .base_instructions .clone() .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + provenance: Some(config.base_instructions_provenance.clone().unwrap_or_else( + || { + if config.base_instructions.is_some() { + BaseInstructionsProvenance::Custom + } else { + BaseInstructionsProvenance::Model { + model: model_info.slug.clone(), + } + } + }, + )), }, dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 216f3b8b43..f085fa51c6 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -624,6 +624,7 @@ mod tests { cwd: tempdir.path().to_path_buf(), base_instructions: Some(codex_protocol::models::BaseInstructions { text: "actual developer prompt".to_string(), + provenance: None, }), ..Default::default() }, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index b5b44d6adf..397abfe233 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -45,6 +45,7 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -516,6 +517,10 @@ pub struct ConfigLockfileToml { pub version: u32, pub codex_version: String, + /// Origin of the effective base instructions captured in the lockfile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions_provenance: Option, + /// Replayable effective config captured in the lockfile. pub config: ConfigToml, } diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 69fe8bcdc3..1da0b68478 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -18,6 +18,8 @@ use codex_config::ConfigLayerStack; use codex_config::config_toml::ConfigToml; use codex_config::loader::resolve_relative_paths_in_config_toml; use codex_exec_server::LOCAL_FS; +use codex_features::Feature; +use codex_protocol::models::BaseInstructionsProvenance; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::path::Path; @@ -175,6 +177,8 @@ mod reload { let preserve_current_model = role_layer_toml.get("model").is_none(); let preserve_current_reasoning_effort = role_layer_toml.get("model_reasoning_effort").is_none(); + let preserve_current_base_instructions = role_layer_toml.get("instructions").is_none() + && role_layer_toml.get("model_instructions_file").is_none(); let mut overrides = reload_overrides( config, preserve_current_model, @@ -206,6 +210,24 @@ mod reload { .model_reasoning_effort .clone_from(&config.model_reasoning_effort); } + if preserve_current_base_instructions { + let personality_changed = config.personality != next_config.personality + || config.features.enabled(Feature::Personality) + != next_config.features.enabled(Feature::Personality); + if personality_changed + && matches!( + config.base_instructions_provenance, + Some(BaseInstructionsProvenance::Model { .. }) + ) + { + next_config.base_instructions = None; + next_config.base_instructions_provenance = None; + } else { + next_config.base_instructions = config.base_instructions.clone(); + next_config.base_instructions_provenance = + config.base_instructions_provenance.clone(); + } + } Ok(next_config) } diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index ea0087171d..3cc1b2b31d 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -4,6 +4,7 @@ use crate::config::ConfigBuilder; use crate::plugins::plugins_manager_for_config; use crate::skills_load_input_from_config; use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; @@ -179,6 +180,12 @@ async fn apply_role_preserves_unspecified_keys() { config.model = Some("spawn-model".to_string()); config.model_reasoning_effort = Some(ReasoningEffort::Low); + config.base_instructions = Some("inherited model instructions".to_string()); + config.base_instructions_provenance = Some(BaseInstructionsProvenance::Model { + model: "parent-model".to_string(), + }); + let base_instructions = config.base_instructions.clone(); + let provenance = config.base_instructions_provenance.clone(); apply_role_to_config(&mut config, Some("custom")) .await @@ -197,6 +204,66 @@ async fn apply_role_preserves_unspecified_keys() { Some(PathBuf::from("/tmp/codex-execve-wrapper")) ); assert!(config.psp); + assert_eq!(config.base_instructions, base_instructions); + assert_eq!(config.base_instructions_provenance, provenance); +} + +#[tokio::test] +async fn apply_role_regenerates_model_instructions_when_personality_changes() { + for (role_contents, provenance) in [ + ( + "personality = \"none\"", + BaseInstructionsProvenance::Model { + model: "parent-model".to_string(), + }, + ), + ( + "[features]\npersonality = false", + BaseInstructionsProvenance::Model { + model: "parent-model".to_string(), + }, + ), + ("personality = \"none\"", BaseInstructionsProvenance::Custom), + ] { + let (home, mut config) = test_config_with_cli_overrides(vec![ + ( + "personality".to_string(), + TomlValue::String("friendly".to_string()), + ), + ("features.personality".to_string(), TomlValue::Boolean(true)), + ]) + .await; + let role_path = write_role_config(&home, "personality-role.toml", role_contents).await; + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: None, + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + config.base_instructions = Some("inherited instructions".to_string()); + config.base_instructions_provenance = Some(provenance.clone()); + + apply_role_to_config(&mut config, Some("custom")) + .await + .expect("custom role should apply"); + + let expected = match provenance { + BaseInstructionsProvenance::Model { .. } => (None, None), + BaseInstructionsProvenance::Custom => ( + Some("inherited instructions".to_string()), + Some(BaseInstructionsProvenance::Custom), + ), + }; + assert_eq!( + ( + config.base_instructions, + config.base_instructions_provenance + ), + expected + ); + } } #[tokio::test] diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index ff7c087cd5..b0f5219793 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -165,6 +165,7 @@ async fn compact_uses_bearer_after_agent_identity_session_fallback() -> anyhow:: }], base_instructions: BaseInstructions { text: "base instructions".to_string(), + provenance: None, }, ..Default::default() }; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 62c42a2f30..91f33a70ce 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -104,6 +104,7 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -689,6 +690,9 @@ pub struct Config { /// Base instructions override. pub base_instructions: Option, + /// Origin of the configured base instructions when supplied by another session or lockfile. + pub base_instructions_provenance: Option, + /// Developer instructions override injected as a separate message. pub developer_instructions: Option, @@ -1539,6 +1543,9 @@ impl ConfigBuilder { lock_config_layer_stack, ) .await?; + if let Some(provenance) = lockfile_toml.base_instructions_provenance { + config.base_instructions_provenance = Some(provenance); + } config.config_lock_toml = Some(Arc::new(expected_lock_config)); config.config_lock_allow_codex_version_mismatch = allow_codex_version_mismatch; config.config_lock_save_fields_resolved_from_model_catalog = @@ -1641,7 +1648,12 @@ impl Config { model_context_window: self.model_context_window, model_auto_compact_token_limit: self.model_auto_compact_token_limit, tool_output_token_limit: self.tool_output_token_limit, - base_instructions: self.base_instructions.clone(), + base_instructions: self.base_instructions.clone().filter(|_| { + !matches!( + self.base_instructions_provenance, + Some(BaseInstructionsProvenance::Model { .. }) + ) + }), personality_enabled: self.features.enabled(Feature::Personality), personality: self.personality, model_catalog: self.model_catalog.clone(), @@ -3922,6 +3934,9 @@ impl Config { let base_instructions = base_instructions .or(file_base_instructions) .or(cfg.instructions.clone()); + let base_instructions_provenance = base_instructions + .as_ref() + .map(|_| BaseInstructionsProvenance::Custom); let developer_instructions = developer_instructions.or(cfg.developer_instructions); let include_permissions_instructions = cfg.include_permissions_instructions.unwrap_or(true); let include_apps_instructions = cfg.include_apps_instructions.unwrap_or(true); @@ -4128,6 +4143,7 @@ impl Config { enforce_residency: enforce_residency.value, notify: cfg.notify, base_instructions, + base_instructions_provenance, personality, developer_instructions, compact_prompt, diff --git a/codex-rs/core/src/config_lock.rs b/codex-rs/core/src/config_lock.rs index f99ded0bf8..73f57f94d6 100644 --- a/codex-rs/core/src/config_lock.rs +++ b/codex-rs/core/src/config_lock.rs @@ -39,6 +39,7 @@ pub(crate) fn config_lockfile(config: ConfigToml) -> ConfigLockfileToml { ConfigLockfileToml { version: CONFIG_LOCK_VERSION, codex_version: env!("CARGO_PKG_VERSION").to_string(), + base_instructions_provenance: None, config, } } @@ -61,7 +62,10 @@ pub(crate) fn validate_config_lock_replay( } let expected_lock = config_lock_for_comparison(expected_lock, options); - let actual_lock = config_lock_for_comparison(actual_lock, options); + let mut actual_lock = config_lock_for_comparison(actual_lock, options); + if expected_lock.base_instructions_provenance.is_none() { + actual_lock.base_instructions_provenance = None; + } if expected_lock != actual_lock { let diff = compact_diff("config", &expected_lock, &actual_lock) .unwrap_or_else(|err| format!("failed to build config lock diff: {err}")); diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index d900ec9813..df59522da5 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -168,6 +168,7 @@ impl ContextManager { let personality = turn_context.personality.or(turn_context.config.personality); let base_instructions = BaseInstructions { text: model_info.get_model_instructions(personality), + provenance: None, }; self.estimate_token_count_with_base_instructions(&base_instructions) } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 2f3ea18237..825161a15c 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -828,9 +828,11 @@ fn estimate_token_count_with_base_instructions_uses_provided_text() { let history = create_history_with_items(vec![assistant_msg("hello from history")]); let short_base = BaseInstructions { text: "short".to_string(), + provenance: None, }; let long_base = BaseInstructions { text: "x".repeat(1_000), + provenance: None, }; let short_estimate = history diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 42e696c97d..f84a1d8c65 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -13,6 +13,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelMessages; @@ -1028,6 +1029,7 @@ pub(crate) fn build_guardian_review_session_config( tenant_policy_config, policy_template, )); + guardian_config.base_instructions_provenance = Some(BaseInstructionsProvenance::Custom); guardian_config.notify = None; guardian_config.developer_instructions = None; guardian_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 4c12c3e406..c6a23cc2f8 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -13,6 +13,7 @@ use codex_features::RolloutBudgetConfigToml; use codex_features::TokenBudgetConfigToml; use codex_features::ToolRegistryConfigToml; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructionsProvenance; use crate::config::Config; use crate::config_lock::ConfigLockReplayOptions; @@ -25,6 +26,7 @@ use super::SessionConfiguration; pub(crate) async fn validate_config_lock_if_configured( session_configuration: &SessionConfiguration, + base_instructions_provenance: Option<&BaseInstructionsProvenance>, ) -> anyhow::Result<()> { if session_configuration.session_source.is_non_root_agent() { return Ok(()); @@ -36,7 +38,10 @@ pub(crate) async fn validate_config_lock_if_configured( else { return Ok(()); }; - let actual = session_configuration.to_config_lockfile_toml()?; + let mut actual = session_configuration.to_config_lockfile_toml()?; + if actual.config.instructions.is_some() { + actual.base_instructions_provenance = base_instructions_provenance.cloned(); + } let config = session_configuration.original_config_do_not_use.as_ref(); let options = ConfigLockReplayOptions { allow_codex_version_mismatch: config.config_lock_allow_codex_version_mismatch, @@ -49,13 +54,17 @@ pub(crate) async fn validate_config_lock_if_configured( pub(crate) async fn export_config_lock_if_configured( session_configuration: &SessionConfiguration, conversation_id: ThreadId, + base_instructions_provenance: Option<&BaseInstructionsProvenance>, ) -> anyhow::Result<()> { let config = session_configuration.original_config_do_not_use.as_ref(); let Some(export_dir) = config.config_lock_export_dir.as_ref() else { return Ok(()); }; - let lock = session_configuration.to_config_lockfile_toml()?; + let mut lock = session_configuration.to_config_lockfile_toml()?; + if lock.config.instructions.is_some() { + lock.base_instructions_provenance = base_instructions_provenance.cloned(); + } let lock = toml::to_string_pretty(&lock).context("failed to serialize config lock")?; let path = export_dir.join(format!("{conversation_id}.config.lock.toml")); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index de33ce20d0..36acaa914a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -103,6 +103,7 @@ use codex_protocol::items::UserMessageItem; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::BaseInstructions; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelInfo; @@ -1263,6 +1264,7 @@ impl Session { let state = self.state.lock().await; BaseInstructions { text: state.session_configuration.base_instructions.clone(), + provenance: state.base_instructions_provenance.clone(), } } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index b043a83593..138db94f31 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -546,6 +546,27 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); + let base_instructions_provenance = if config.base_instructions.is_some() { + Some( + config + .base_instructions_provenance + .clone() + .unwrap_or(BaseInstructionsProvenance::Custom), + ) + } else if let Some(inherited_base_instructions) = initial_history.get_base_instructions() { + let BaseInstructions { text, provenance } = inherited_base_instructions; + provenance.or_else(|| { + (text == model_info.get_model_instructions(config.personality)).then(|| { + BaseInstructionsProvenance::Model { + model: model_info.slug.clone(), + } + }) + }) + } else { + Some(BaseInstructionsProvenance::Model { + model: model_info.slug.clone(), + }) + }; let forked_from_id = session_configuration .forked_from_thread_id .or_else(|| initial_history.forked_from_id()); @@ -660,6 +681,7 @@ impl Session { originator: session_configuration.originator.clone(), base_instructions: BaseInstructions { text: session_configuration.base_instructions.clone(), + provenance: base_instructions_provenance.clone(), }, dynamic_tools: session_configuration.dynamic_tools.clone(), selected_capability_roots: selected_capability_roots.clone(), @@ -1012,16 +1034,22 @@ impl Session { ); } session_configuration.thread_name = thread_name.clone(); - validate_config_lock_if_configured(&session_configuration).await?; - export_config_lock_if_configured(&session_configuration, thread_id).await?; + validate_config_lock_if_configured( + &session_configuration, + base_instructions_provenance.as_ref(), + ) + .await?; + export_config_lock_if_configured( + &session_configuration, + thread_id, + base_instructions_provenance.as_ref(), + ) + .await?; let mut state = SessionState::new_with_auto_compact_window_ids( session_configuration.clone(), initial_auto_compact_window_ids, ); - state.base_instructions_model = (config.base_instructions.is_none() - && session_configuration.base_instructions - == model_info.get_model_instructions(config.personality)) - .then(|| model_info.slug.clone()); + state.base_instructions_provenance = base_instructions_provenance.clone(); let managed_network_requirements_configured = config .config_layer_stack .requirements_toml() diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 0dd5d28216..1a77322630 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2647,6 +2647,7 @@ async fn recompute_token_usage_uses_session_base_instructions() { let history = session.clone_history().await; let session_base_instructions = BaseInstructions { text: override_instructions, + provenance: None, }; let expected_tokens = history .estimate_token_count_with_base_instructions(&session_base_instructions) diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 60f1829f40..2800edca11 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -26,6 +26,7 @@ use codex_extension_api::WorldStateContributionInput; use codex_features::Feature; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; +use codex_protocol::models::BaseInstructionsProvenance; impl Session { #[tracing::instrument(name = "world_state.build", level = "info", skip_all)] @@ -50,8 +51,12 @@ impl Session { .map(|previous| previous.model) .or_else(|| { state - .base_instructions_model + .base_instructions_provenance .as_ref() + .and_then(|provenance| match provenance { + BaseInstructionsProvenance::Model { model } => Some(model), + BaseInstructionsProvenance::Custom => None, + }) .filter(|_| base_instructions != model_instructions) .cloned() }), diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index abffe19313..305c9c51ba 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -295,6 +295,7 @@ async fn schedule_startup_prewarm_inner( startup_turn_context.as_ref(), BaseInstructions { text: base_instructions, + provenance: None, }, ); startup_turn_context.session_telemetry.record_startup_phase( diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index f0b4e9ccc6..4dd3b6b940 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -1,6 +1,7 @@ //! Session-wide mutable state. use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::ResponseItem; use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; @@ -25,8 +26,8 @@ use codex_utils_output_truncation::TruncationPolicy; /// Persistent, session-scoped state previously stored directly on `Session`. pub(crate) struct SessionState { pub(crate) session_configuration: SessionConfiguration, - /// Model that generated the base instructions, when their provenance is known. - pub(crate) base_instructions_model: Option, + /// Persisted origin of the session base instructions, when known. + pub(crate) base_instructions_provenance: Option, pub(crate) history: ContextManager, pub(crate) latest_rate_limits: Option, pub(crate) server_reasoning_included: bool, @@ -64,7 +65,7 @@ impl SessionState { let history = ContextManager::new(); Self { session_configuration, - base_instructions_model: None, + base_instructions_provenance: None, history, latest_rate_limits: None, server_reasoning_included: false, diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 4a0c1c6855..d244cd0c44 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -6,6 +6,7 @@ use codex_protocol::ResponseItemId; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::ExitedReviewModeItem; use codex_protocol::items::TurnItem; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageContentDeltaEvent; @@ -113,6 +114,7 @@ async fn start_review_conversation( // Set explicit review rubric for the sub-agent sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string()); + sub_agent_config.base_instructions_provenance = Some(BaseInstructionsProvenance::Custom); sub_agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); let model = config diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 4d833c348a..4e4acb7410 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -182,6 +182,7 @@ pub(crate) fn build_agent_spawn_config( ) -> Result { let mut config = build_agent_shared_config(turn, environment)?; config.base_instructions = Some(base_instructions.text.clone()); + config.base_instructions_provenance = base_instructions.provenance.clone(); Ok(config) } @@ -192,6 +193,7 @@ pub(crate) fn build_agent_resume_config( let mut config = build_agent_shared_config(turn, environment)?; // For resume, keep base instructions sourced from rollout/session metadata. config.base_instructions = None; + config.base_instructions_provenance = None; Ok(config) } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 353338faee..6e18dfe971 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -34,6 +34,7 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::mcp::ClientMcpExtensions; use codex_protocol::models::BaseInstructions; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::PermissionProfile; @@ -4442,6 +4443,9 @@ async fn build_agent_spawn_config_uses_turn_context_values() { let (_session, mut turn) = make_session_and_context().await; let base_instructions = BaseInstructions { text: "base".to_string(), + provenance: Some(BaseInstructionsProvenance::Model { + model: turn.model_info.slug.clone(), + }), }; turn.developer_instructions = Some("dev".to_string()); let mut config = (*turn.config).clone(); @@ -4486,6 +4490,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { let config = build_agent_spawn_config(&base_instructions, &turn, turn.environments.primary()) .expect("spawn config"); let mut expected = (*turn.config).clone(); + expected.base_instructions_provenance = base_instructions.provenance.clone(); expected.base_instructions = Some(base_instructions.text); expected.model = Some(turn.model_info.slug.clone()); expected.model_provider = turn.provider.info().clone(); @@ -4513,6 +4518,9 @@ async fn build_agent_resume_config_clears_base_instructions() { let (_session, mut turn) = make_session_and_context().await; let mut base_config = (*turn.config).clone(); base_config.base_instructions = Some("caller-base".to_string()); + base_config.base_instructions_provenance = Some(BaseInstructionsProvenance::Model { + model: turn.model_info.slug.clone(), + }); turn.config = Arc::new(base_config); Arc::make_mut(&mut turn.config) .permissions @@ -4541,6 +4549,7 @@ async fn build_agent_resume_config_clears_base_instructions() { let mut expected = (*turn.config).clone(); expected.base_instructions = None; + expected.base_instructions_provenance = None; expected.model = Some(turn.model_info.slug.clone()); expected.model_provider = turn.provider.info().clone(); expected.model_reasoning_effort = turn.reasoning_effort.clone(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index d35f15af7b..400282e476 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2363,6 +2363,7 @@ fn prompt_with_input_and_instructions(input: Vec, instructions: &s let mut prompt = prompt_with_input(input); prompt.base_instructions = BaseInstructions { text: instructions.to_string(), + provenance: None, }; prompt } diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 4155f253ac..941a2a04b2 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -9,6 +9,7 @@ use codex_models_manager::manager::RefreshStrategy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -202,11 +203,14 @@ async fn first_turn_model_change_appends_model_instructions_developer_message( Ok(()) } -#[test_case(None; "model-generated base instructions")] -#[test_case(Some("inherited custom base instructions"); "custom base instructions")] +#[test_case(None, "gpt-5.2"; "model-generated base instructions and original model")] +#[test_case(None, "gpt-5.4"; "model-generated base instructions and fork model")] +#[test_case(Some("inherited custom base instructions"), "gpt-5.2"; "custom base instructions and original model")] +#[test_case(Some("inherited custom base instructions"), "gpt-5.4"; "custom base instructions and fork model")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn first_turn_after_empty_prefix_fork_preserves_inherited_base_instructions( custom_base_instructions: Option<&'static str>, + turn_model: &'static str, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -222,6 +226,21 @@ async fn first_turn_after_empty_prefix_fork_preserves_inherited_base_instruction let test = builder.build_with_auto_env(&server).await?; test.codex.ensure_rollout_materialized().await; test.codex.flush_rollout().await?; + let source_rollout_path = test.codex.rollout_path().expect("rollout path"); + let source_history = + codex_rollout::RolloutRecorder::get_rollout_history(&source_rollout_path).await?; + let expected_provenance = match custom_base_instructions { + Some(_) => BaseInstructionsProvenance::Custom, + None => BaseInstructionsProvenance::Model { + model: initial_model.to_string(), + }, + }; + assert_eq!( + source_history + .get_base_instructions() + .and_then(|instructions| instructions.provenance), + Some(expected_provenance) + ); let mut fork_config = test.config.clone(); fork_config.model = Some("gpt-5.4".to_string()); @@ -231,29 +250,27 @@ async fn first_turn_after_empty_prefix_fork_preserves_inherited_base_instruction .fork_thread( ForkSnapshot::TruncateBeforeNthUserMessage(0), fork_config, - test.codex.rollout_path().expect("rollout path"), + source_rollout_path, /*thread_source*/ None, /*parent_trace*/ None, ) .await?; - submit_model_turn( - &fork.thread, - initial_model, - ThreadSettingsOverrides::default(), - ) - .await?; + submit_model_turn(&fork.thread, turn_model, ThreadSettingsOverrides::default()).await?; let request = resp_mock.single_request(); - assert_eq!(request.body_json()["model"], initial_model); + assert_eq!(request.body_json()["model"], turn_model); if let Some(instructions) = custom_base_instructions { assert_eq!(request.instructions_text(), instructions); } - assert!( - request - .message_input_texts("developer") - .iter() - .all(|text| !text.contains("")), - "the inherited base instructions must not be replaced by catalog instructions" + let model_switch_count = request + .message_input_texts("developer") + .iter() + .filter(|text| text.contains("")) + .count(); + assert_eq!( + model_switch_count, + usize::from(custom_base_instructions.is_none() && turn_model != initial_model), + "only inherited model-generated instructions should change models" ); Ok(()) @@ -304,7 +321,7 @@ async fn rollback_first_turn_model_change_removes_its_instructions( let test = match followup { RollbackFollowup::ColdResume => { - let mut resume_builder = test_codex().with_model(initial_model); + let mut resume_builder = test_codex().with_model(switched_model); resume_builder.restart(&server, &test).await? } RollbackFollowup::StartupModel | RollbackFollowup::SwitchedModel => test, diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 43c11a193e..a1c05edd34 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -470,18 +470,34 @@ async fn token_budget_model_defaults_survive_config_lock_replay() -> Result<()> }) .build_with_auto_env(&server) .await?; + core_test_support::submit_thread_settings( + &replay.codex, + ThreadSettingsOverrides { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }, + ) + .await?; replay - .submit_turn("inspect guidance after lock replay") + .submit_text_turn("inspect guidance after lock replay and model switch") .await?; let requests = responses.requests(); assert_eq!(requests.len(), 2); - for request in requests { + for request in &requests { assert!( request.body_contains_text("Use the model-owned context-window guidance."), "exporting and replaying a config lock must preserve model-owned guidance" ); } + assert_eq!(requests[1].body_json()["model"], "gpt-5.4"); + assert!( + requests[1] + .message_input_texts("developer") + .iter() + .any(|text| text.contains("")), + "replaying model-owned instructions must not override the new model's template" + ); Ok(()) } diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 2ba4ef28bb..f54646dd11 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -307,6 +307,7 @@ mod job { }]; prompt.base_instructions = BaseInstructions { text: crate::stage_one::PROMPT.to_string(), + provenance: None, }; prompt.output_schema = Some(output_schema()); prompt.output_schema_strict = true; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 99d6bf7f50..4ea9cd1c23 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1272,17 +1272,33 @@ impl ResponseItem { pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md"); +/// Describes whether persisted base instructions were supplied by the user or generated for a model. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +pub enum BaseInstructionsProvenance { + /// The instructions were explicitly configured and must survive model changes unchanged. + Custom, + /// The instructions were generated from this model's instruction template. + Model { model: String }, +} + /// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(rename = "base_instructions", rename_all = "snake_case")] pub struct BaseInstructions { pub text: String, + /// Missing on rollouts written before base-instruction provenance was persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub provenance: Option, } impl Default for BaseInstructions { fn default() -> Self { Self { text: BASE_INSTRUCTIONS_DEFAULT.to_string(), + provenance: None, } } } @@ -2230,6 +2246,31 @@ mod tests { 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; + #[test] + fn base_instructions_preserve_provenance_and_accept_legacy_rollouts() -> Result<()> { + let legacy: BaseInstructions = + serde_json::from_value(serde_json::json!({ "text": "legacy instructions" }))?; + assert_eq!(legacy.provenance, None); + + for provenance in [ + BaseInstructionsProvenance::Custom, + BaseInstructionsProvenance::Model { + model: "gpt-5.2".to_string(), + }, + ] { + let instructions = BaseInstructions { + text: "persisted instructions".to_string(), + provenance: Some(provenance), + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&instructions)?)?, + instructions + ); + } + + Ok(()) + } + #[test] fn plaintext_agent_message_content_rejects_mixed_encrypted_content() { let content = vec![ diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 1e7f8d7ba6..a231102479 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -208,6 +208,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R hide_agent_reasoning: false, show_raw_agent_reasoning: false, base_instructions: None, + base_instructions_provenance: None, developer_instructions: None, guardian_policy_config: None, include_permissions_instructions: false, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index fb007c653a..b3754d764c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -120,6 +120,7 @@ use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BaseInstructionsProvenance; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelAvailabilityNux; @@ -1808,7 +1809,12 @@ fn thread_fork_params_from_config( sandbox, permissions, config: config_request_overrides_from_config(&config), - base_instructions: config.base_instructions.clone(), + base_instructions: config.base_instructions.clone().filter(|_| { + !matches!( + config.base_instructions_provenance, + Some(BaseInstructionsProvenance::Model { .. }) + ) + }), developer_instructions: with_terminal_visualization_instructions( &config, config.developer_instructions.clone(), @@ -2918,11 +2924,12 @@ mod tests { let temp_dir = tempfile::tempdir().expect("tempdir"); let mut config = build_config(&temp_dir).await; config.base_instructions = Some("Base override.".to_string()); + config.base_instructions_provenance = Some(BaseInstructionsProvenance::Custom); config.developer_instructions = Some("Developer override.".to_string()); let thread_id = ThreadId::new(); let params = thread_fork_params_from_config( - config, + config.clone(), thread_id, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, @@ -2933,6 +2940,18 @@ mod tests { params.developer_instructions.as_deref(), Some("Developer override.") ); + + config.base_instructions_provenance = Some(BaseInstructionsProvenance::Model { + model: "gpt-5.2".to_string(), + }); + let params = thread_fork_params_from_config( + config, + thread_id, + ThreadParamsMode::Remote, + /*remote_cwd_override*/ None, + ); + + assert_eq!(params.base_instructions, None); } #[tokio::test]