diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 55c907ef2..7a093dc7f 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -27,6 +27,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { priority, upgrade: preset.upgrade.as_ref().map(|u| u.into()), base_instructions: "base instructions".to_string(), + model_instructions_template: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 46e177309..b1c4f060b 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -77,6 +77,7 @@ async fn models_client_hits_models_endpoint() { priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), + model_instructions_template: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, diff --git a/codex-rs/core/dfa_unless_trusted_5_1.txt b/codex-rs/core/dfa_unless_trusted_5_1.txt new file mode 100644 index 000000000..72b7d8ecd --- /dev/null +++ b/codex-rs/core/dfa_unless_trusted_5_1.txt @@ -0,0 +1 @@ +danger-unless-trusted \ No newline at end of file diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a3b3aba9a..2a1cccb6f 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -285,7 +285,7 @@ impl Codex { .base_instructions .clone() .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) - .unwrap_or_else(|| model_info.base_instructions.clone()); + .unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)); // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode // to avoid extracting these fields separately and constructing CollaborationMode here. @@ -3741,7 +3741,7 @@ mod tests { base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.base_instructions.clone()), + .unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -3816,7 +3816,7 @@ mod tests { base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.base_instructions.clone()), + .unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -4075,7 +4075,7 @@ mod tests { base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.base_instructions.clone()), + .unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), @@ -4179,7 +4179,7 @@ mod tests { base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.base_instructions.clone()), + .unwrap_or_else(|| model_info.get_model_instructions(config.model_personality)), compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy.clone(), sandbox_policy: config.sandbox_policy.clone(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9d06eca30..4dde7e1ec 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -11,7 +11,6 @@ use crate::config::types::Notifications; use crate::config::types::OtelConfig; use crate::config::types::OtelConfigToml; use crate::config::types::OtelExporterKind; -use crate::config::types::Personality; use crate::config::types::SandboxWorkspaceWrite; use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; @@ -43,6 +42,7 @@ use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 3e83e586e..350ae49a5 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -5,6 +5,7 @@ use crate::config_loader::RequirementSource; pub use codex_protocol::config_types::AltScreenMode; +pub use codex_protocol::config_types::Personality; pub use codex_protocol::config_types::WebSearchMode; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::BTreeMap; @@ -628,14 +629,6 @@ impl Default for ShellEnvironmentPolicy { } } -#[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "kebab-case")] -pub enum Personality { - Friendly, - #[default] - Pragmatic, -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 365fcdc59..51132e54f 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -477,6 +477,42 @@ model_instructions_file = "child.txt" Ok(()) } +#[tokio::test] +async fn cli_override_model_instructions_file_sets_base_instructions() -> std::io::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), "").await?; + + let cwd = tmp.path().join("work"); + tokio::fs::create_dir_all(&cwd).await?; + + let instructions_path = tmp.path().join("instr.md"); + tokio::fs::write(&instructions_path, "cli override instructions").await?; + + let cli_overrides = vec![( + "model_instructions_file".to_string(), + TomlValue::String(instructions_path.to_string_lossy().to_string()), + )]; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .cli_overrides(cli_overrides) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd), + ..ConfigOverrides::default() + }) + .build() + .await?; + + assert_eq!( + config.base_instructions.as_deref(), + Some("cli override instructions") + ); + + Ok(()) +} + #[tokio::test] async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> { let tmp = tempdir()?; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 354b3c2d1..ff1a65cf4 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -86,8 +86,9 @@ impl ContextManager { // This is a coarse lower bound, not a tokenizer-accurate count. pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option { let model_info = turn_context.client.get_model_info(); - let base_instructions = model_info.base_instructions.as_str(); - let base_tokens = i64::try_from(approx_token_count(base_instructions)).unwrap_or(i64::MAX); + let personality = turn_context.client.config().model_personality; + let base_instructions = model_info.get_model_instructions(personality); + let base_tokens = i64::try_from(approx_token_count(&base_instructions)).unwrap_or(i64::MAX); let items_tokens = self.items.iter().fold(0i64, |acc, item| { acc + match item { diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index b2b0b0a90..8fb77e29e 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -1,15 +1,19 @@ +use std::collections::BTreeMap; + +use codex_protocol::config_types::Personality; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelInstructionsTemplate; use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::PersonalityMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationMode; use codex_protocol::openai_models::TruncationPolicyConfig; use crate::config::Config; -use crate::config::types::Personality; use crate::truncate::approx_bytes_for_tokens; use tracing::warn; @@ -21,7 +25,12 @@ const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt_5_codex_prompt.md const GPT_5_1_INSTRUCTIONS: &str = include_str!("../../gpt_5_1_prompt.md"); const GPT_5_2_INSTRUCTIONS: &str = include_str!("../../gpt_5_2_prompt.md"); const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-max_prompt.md"); + const GPT_5_2_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt-5.2-codex_prompt.md"); +const GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE: &str = + include_str!("../../templates/model_instructions/gpt-5.2-codex_instructions_template.md"); +const PERSONALITY_FRIENDLY: &str = include_str!("../../templates/personalities/friendly.md"); +const PERSONALITY_PRAGMATIC: &str = include_str!("../../templates/personalities/pragmatic.md"); pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; @@ -45,6 +54,7 @@ macro_rules! model_info { priority: 99, upgrade: None, base_instructions: BASE_INSTRUCTIONS.to_string(), + model_instructions_template: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, @@ -87,22 +97,10 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo } }; } + if let Some(base_instructions) = &config.base_instructions { model.base_instructions = base_instructions.clone(); - } else if model.slug.contains("gpt-5.2-codex") - && let Some(personality) = &config.model_personality - { - let template = include_str!( - "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" - ); - let personality_message = match personality { - Personality::Friendly => include_str!("../../templates/personalities/friendly.md"), - Personality::Pragmatic => { - include_str!("../../templates/personalities/pragmatic.md") - } - }; - let instructions = template.replace("{{ personality_message }}", personality_message); - model.base_instructions = instructions; + model.model_instructions_template = None; } model } @@ -205,6 +203,16 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo { truncation_policy: TruncationPolicyConfig::tokens(10_000), context_window: Some(CONTEXT_WINDOW_272K), supported_reasoning_levels: supported_reasoning_level_low_medium_high_xhigh(), + model_instructions_template: Some(ModelInstructionsTemplate { + template: GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string(), + personality_messages: Some(PersonalityMessages(BTreeMap::from([( + Personality::Friendly, + PERSONALITY_FRIENDLY.to_string(), + ), ( + Personality::Pragmatic, + PERSONALITY_PRAGMATIC.to_string(), + )]))), + }), ) } else if slug.starts_with("gpt-5.1-codex-max") { model_info!( diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5cdc440c5..e0da93a69 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -48,6 +48,7 @@ mod models_etag_responses; mod otel; mod pending_input; mod permissions_messages; +mod personality; mod prompt_caching; mod quota_exceeded; mod read_file; diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 5bcb0ccc0..82fbe05cb 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -174,6 +174,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { priority, upgrade: None, base_instructions: "base instructions".to_string(), + model_instructions_template: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 9c0f6e3f4..cca15cd8f 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -4,20 +4,31 @@ use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; use tempfile::TempDir; -const BASE_INSTRUCTIONS_TEMPLATE: &str = include_str!( - "../../templates/model_instructions/gpt-5.2-codex_instructions_template.md" -); -const FRIENDLY_PERSONALITY: &str = include_str!("../../templates/personalities/friendly.md"); - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn model_personality_updates_base_instructions() { +async fn model_personality_does_not_mutate_base_instructions_without_template() { let codex_home = TempDir::new().expect("create temp dir"); let mut config = load_default_config_for_test(&codex_home).await; config.model_personality = Some(Personality::Friendly); - let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config); - let expected = - BASE_INSTRUCTIONS_TEMPLATE.replace("{{ personality_message }}", FRIENDLY_PERSONALITY); - - assert_eq!(model_info.base_instructions, expected); + let model_info = ModelsManager::construct_model_info_offline("gpt-5.1", &config); + assert_eq!( + model_info.get_model_instructions(config.model_personality), + model_info.base_instructions + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn base_instructions_override_disables_personality_template() { + let codex_home = TempDir::new().expect("create temp dir"); + let mut config = load_default_config_for_test(&codex_home).await; + config.model_personality = Some(Personality::Friendly); + config.base_instructions = Some("override instructions".to_string()); + + let model_info = ModelsManager::construct_model_info_offline("gpt-5.2-codex", &config); + + assert_eq!(model_info.base_instructions, "override instructions"); + assert_eq!( + model_info.get_model_instructions(config.model_personality), + "override instructions" + ); } diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 3b45989d8..e77861f84 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -79,6 +79,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { priority: 1, upgrade: None, base_instructions: "base instructions".to_string(), + model_instructions_template: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, @@ -312,6 +313,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { priority: 1, upgrade: None, base_instructions: remote_base.to_string(), + model_instructions_template: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, @@ -782,6 +784,7 @@ fn test_remote_model_with_policy( priority, upgrade: None, base_instructions: "base instructions".to_string(), + model_instructions_template: None, supports_reasoning_summaries: false, support_verbosity: false, default_verbosity: None, diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 868217b3b..c04479719 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -65,6 +65,27 @@ pub enum SandboxMode { DangerFullAccess, } +#[derive( + Debug, + Serialize, + Deserialize, + Clone, + Copy, + PartialEq, + Eq, + Display, + JsonSchema, + TS, + PartialOrd, + Ord, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Personality { + Friendly, + Pragmatic, +} + #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, )] diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 6088fe277..ac030a303 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; @@ -7,10 +8,14 @@ use serde::Serialize; use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; +use tracing::warn; use ts_rs::TS; +use crate::config_types::Personality; use crate::config_types::Verbosity; +const PERSONALITY_PLACEHOLDER: &str = "{{ personality_message }}"; + /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning #[derive( Debug, @@ -180,6 +185,8 @@ pub struct ModelInfo { pub priority: i32, pub upgrade: Option, pub base_instructions: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_instructions_template: Option, pub supports_reasoning_summaries: bool, pub support_verbosity: bool, pub default_verbosity: Option, @@ -206,8 +213,49 @@ impl ModelInfo { .map(|context_window| (context_window * 9) / 10) }) } + + pub fn get_model_instructions(&self, personality: Option) -> String { + if let Some(personality) = personality + && let Some(template) = &self.model_instructions_template + && template.has_personality_placeholder() + && let Some(personality_messages) = &template.personality_messages + && let Some(personality_message) = personality_messages.0.get(&personality) + { + template + .template + .replace(PERSONALITY_PLACEHOLDER, personality_message.as_str()) + } else if let Some(personality) = personality { + warn!( + model = %self.slug, + %personality, + "Model personality requested but model_instructions_template is invalid, falling back to base instructions." + ); + self.base_instructions.clone() + } else { + self.base_instructions.clone() + } + } } +/// A strongly-typed template for assembling model instructions. If populated and valid, will override +/// base_instructions. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct ModelInstructionsTemplate { + pub template: String, + pub personality_messages: Option, +} + +impl ModelInstructionsTemplate { + fn has_personality_placeholder(&self) -> bool { + self.template.contains(PERSONALITY_PLACEHOLDER) + } +} + +// serializes as a dictionary from personality to message +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS, JsonSchema)] +#[serde(transparent)] +pub struct PersonalityMessages(pub BTreeMap); + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelInfoUpgrade { pub model: String, @@ -335,3 +383,67 @@ fn nearest_effort(target: ReasoningEffort, supported: &[ReasoningEffort]) -> Rea .min_by_key(|candidate| (effort_rank(*candidate) - target_rank).abs()) .unwrap_or(target) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn test_model(template: Option) -> ModelInfo { + ModelInfo { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + description: None, + default_reasoning_level: None, + supported_reasoning_levels: vec![], + shell_type: ConfigShellToolType::ShellCommand, + visibility: ModelVisibility::List, + supported_in_api: true, + priority: 1, + upgrade: None, + base_instructions: "base".to_string(), + model_instructions_template: template, + supports_reasoning_summaries: false, + support_verbosity: false, + default_verbosity: None, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(10_000), + supports_parallel_tool_calls: false, + context_window: None, + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: vec![], + } + } + + fn personality_messages() -> PersonalityMessages { + PersonalityMessages(BTreeMap::from([( + Personality::Friendly, + "friendly".to_string(), + )])) + } + + #[test] + fn get_model_instructions_uses_template_when_placeholder_present() { + let model = test_model(Some(ModelInstructionsTemplate { + template: "Hello {{ personality_message }}".to_string(), + personality_messages: Some(personality_messages()), + })); + + let instructions = model.get_model_instructions(Some(Personality::Friendly)); + + assert_eq!(instructions, "Hello friendly"); + } + + #[test] + fn get_model_instructions_falls_back_when_placeholder_missing() { + let model = test_model(Some(ModelInstructionsTemplate { + template: "Hello there".to_string(), + personality_messages: Some(personality_messages()), + })); + + let instructions = model.get_model_instructions(Some(Personality::Friendly)); + + assert_eq!(instructions, "base"); + } +}