feat(core) ModelInfo.model_instructions_template (#9597)

## Summary
#9555 is the start of a rename, so I'm starting to standardize here.
Sets up `model_instructions` templating with a strongly-typed object for
injecting a personality block into the model instructions.

## Testing
- [x] Added tests
- [x] Ran locally
This commit is contained in:
Dylan Hurd
2026-01-21 18:11:18 -08:00
committed by GitHub
Unverified
parent a489b64cb5
commit 96a72828be
15 changed files with 232 additions and 42 deletions
@@ -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,
@@ -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,
+1
View File
@@ -0,0 +1 @@
danger-unless-trusted
+5 -5
View File
@@ -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(),
+1 -1
View File
@@ -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;
+1 -8
View File
@@ -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::*;
+36
View File
@@ -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()?;
+3 -2
View File
@@ -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<i64> {
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 {
+23 -15
View File
@@ -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!(
+1
View File
@@ -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;
@@ -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,
+22 -11
View File
@@ -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"
);
}
@@ -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,
+21
View File
@@ -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,
)]
+112
View File
@@ -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<ModelInfoUpgrade>,
pub base_instructions: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_instructions_template: Option<ModelInstructionsTemplate>,
pub supports_reasoning_summaries: bool,
pub support_verbosity: bool,
pub default_verbosity: Option<Verbosity>,
@@ -206,8 +213,49 @@ impl ModelInfo {
.map(|context_window| (context_window * 9) / 10)
})
}
pub fn get_model_instructions(&self, personality: Option<Personality>) -> 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<PersonalityMessages>,
}
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<Personality, String>);
#[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<ModelInstructionsTemplate>) -> 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");
}
}