Add file-backed personality support and listing

This commit is contained in:
Eric Traut
2026-03-31 17:06:45 -06:00
parent ae057e0bb9
commit 8e00fec700
61 changed files with 1741 additions and 366 deletions
+20 -14
View File
@@ -33,6 +33,7 @@ use crate::models_manager::manager::RefreshStrategy;
use crate::parse_command::parse_command;
use crate::parse_turn_item;
use crate::path_utils::normalize_for_native_workdir;
use crate::personalities::catalog_for_config;
use crate::realtime_conversation::RealtimeConversationManager;
use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio;
use crate::realtime_conversation::handle_close as handle_realtime_conversation_close;
@@ -572,7 +573,7 @@ impl Codex {
.base_instructions
.clone()
.or_else(|| conversation_history.get_base_instructions().map(|s| s.text))
.unwrap_or_else(|| model_info.get_model_instructions(config.personality));
.unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone()));
// Respect thread-start tools. When missing (resumed/forked threads), read from the db
// first, then fall back to rollout-file tools.
@@ -618,7 +619,7 @@ impl Codex {
service_tier: config.service_tier,
developer_instructions: config.developer_instructions.clone(),
user_instructions,
personality: config.personality,
personality: config.personality.clone(),
base_instructions,
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
@@ -970,7 +971,7 @@ impl TurnContext {
compact_prompt: self.compact_prompt.clone(),
user_instructions: self.user_instructions.clone(),
collaboration_mode,
personality: self.personality,
personality: self.personality.clone(),
approval_policy: self.approval_policy.clone(),
sandbox_policy: self.sandbox_policy.clone(),
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
@@ -1017,7 +1018,7 @@ impl TurnContext {
sandbox_policy: self.sandbox_policy.get().clone(),
network: self.turn_context_network_item(),
model: self.model_info.slug.clone(),
personality: self.personality,
personality: self.personality.clone(),
collaboration_mode: Some(self.collaboration_mode.clone()),
realtime_active: Some(self.realtime_active),
effort: self.reasoning_effort,
@@ -1133,7 +1134,7 @@ impl SessionConfiguration {
cwd: self.cwd.to_path_buf(),
ephemeral: self.original_config_do_not_use.ephemeral,
reasoning_effort: self.collaboration_mode.reasoning_effort(),
personality: self.personality,
personality: self.personality.clone(),
session_source: self.session_source.clone(),
}
}
@@ -1154,7 +1155,7 @@ impl SessionConfiguration {
if let Some(service_tier) = updates.service_tier {
next_configuration.service_tier = service_tier;
}
if let Some(personality) = updates.personality {
if let Some(personality) = updates.personality.clone() {
next_configuration.personality = Some(personality);
}
if let Some(approval_policy) = updates.approval_policy {
@@ -1297,7 +1298,7 @@ impl Session {
session_configuration.collaboration_mode.reasoning_effort();
per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary;
per_turn_config.service_tier = session_configuration.service_tier;
per_turn_config.personality = session_configuration.personality;
per_turn_config.personality = session_configuration.personality.clone();
per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer;
let resolved_web_search_mode = resolve_web_search_mode_for_turn(
&per_turn_config.web_search_mode,
@@ -1436,7 +1437,7 @@ impl Session {
compact_prompt: session_configuration.compact_prompt.clone(),
user_instructions: session_configuration.user_instructions.clone(),
collaboration_mode: session_configuration.collaboration_mode.clone(),
personality: session_configuration.personality,
personality: session_configuration.personality.clone(),
approval_policy: session_configuration.approval_policy.clone(),
sandbox_policy: session_configuration.sandbox_policy.clone(),
file_system_sandbox_policy: session_configuration.file_system_sandbox_policy.clone(),
@@ -1884,6 +1885,7 @@ impl Session {
auth_manager: Arc::clone(&auth_manager),
session_telemetry,
models_manager: Arc::clone(&models_manager),
personality_catalog: Arc::new(catalog_for_config(config.as_ref())),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills_manager,
plugins_manager: Arc::clone(&plugins_manager),
@@ -2628,6 +2630,7 @@ impl Session {
current_context,
shell.as_ref(),
exec_policy.as_ref(),
self.services.personality_catalog.as_ref(),
self.features.enabled(Feature::Personality),
)
}
@@ -3613,16 +3616,19 @@ impl Session {
developer_sections.push(realtime_update.into_text());
}
if self.features.enabled(Feature::Personality)
&& let Some(personality) = turn_context.personality
&& let Some(personality) = turn_context.personality.clone()
{
let model_info = turn_context.model_info.clone();
let has_baked_personality = model_info.supports_personality()
&& base_instructions == model_info.get_model_instructions(Some(personality));
let has_baked_personality = personality.is_builtin()
&& model_info.supports_personality()
&& base_instructions
== model_info.get_model_instructions(Some(personality.clone()));
if !has_baked_personality
&& let Some(personality_message) =
crate::context_manager::updates::personality_message_for(
&model_info,
personality,
&self.services.personality_catalog,
&personality,
)
{
developer_sections.push(
@@ -5541,7 +5547,7 @@ async fn spawn_review_thread(
user_instructions: None,
compact_prompt: parent_turn_context.compact_prompt.clone(),
collaboration_mode: parent_turn_context.collaboration_mode.clone(),
personality: parent_turn_context.personality,
personality: parent_turn_context.personality.clone(),
approval_policy: parent_turn_context.approval_policy.clone(),
sandbox_policy: parent_turn_context.sandbox_policy.clone(),
file_system_sandbox_policy: parent_turn_context.file_system_sandbox_policy.clone(),
@@ -6418,7 +6424,7 @@ pub(crate) fn build_prompt(
tools,
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
base_instructions,
personality: turn_context.personality,
personality: turn_context.personality.clone(),
output_schema: turn_context.final_output_json_schema.clone(),
}
}
@@ -69,7 +69,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: previous_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
@@ -108,7 +108,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: previous_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
@@ -858,7 +858,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: previous_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
@@ -930,7 +930,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: previous_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
@@ -959,7 +959,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: previous_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
@@ -1065,7 +1065,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: current_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
@@ -1167,7 +1167,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: previous_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
@@ -1311,7 +1311,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: previous_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
+21 -16
View File
@@ -1268,7 +1268,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
sandbox_policy: turn_context.sandbox_policy.get().clone(),
network: None,
model: previous_model.to_string(),
personality: turn_context.personality,
personality: turn_context.personality.clone(),
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
realtime_active: Some(turn_context.realtime_active),
effort: turn_context.reasoning_effort,
@@ -1801,11 +1801,11 @@ async fn set_rate_limits_retains_previous_credits() {
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
personality: config.personality.clone(),
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -1899,11 +1899,11 @@ async fn set_rate_limits_updates_plan_type_when_present() {
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
personality: config.personality.clone(),
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -2243,11 +2243,11 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
personality: config.personality.clone(),
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -2505,11 +2505,11 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
personality: config.personality.clone(),
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -2602,11 +2602,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
personality: config.personality.clone(),
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -2682,6 +2682,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
auth_manager: auth_manager.clone(),
session_telemetry: session_telemetry.clone(),
models_manager: Arc::clone(&models_manager),
personality_catalog: Arc::new(crate::personalities::catalog_for_config(config.as_ref())),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills_manager,
plugins_manager,
@@ -3104,7 +3105,7 @@ async fn user_turn_updates_approvals_reviewer() {
service_tier: None,
final_output_json_schema: None,
collaboration_mode: None,
personality: config.personality,
personality: config.personality.clone(),
},
)
.await;
@@ -3445,11 +3446,11 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
service_tier: None,
personality: config.personality,
personality: config.personality.clone(),
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
.unwrap_or_else(|| model_info.get_model_instructions(config.personality.clone())),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
@@ -3525,6 +3526,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
auth_manager: Arc::clone(&auth_manager),
session_telemetry: session_telemetry.clone(),
models_manager: Arc::clone(&models_manager),
personality_catalog: Arc::new(crate::personalities::catalog_for_config(config.as_ref())),
tool_approvals: Mutex::new(ApprovalStore::default()),
skills_manager,
plugins_manager,
@@ -4944,13 +4946,16 @@ async fn sample_rollout(
&& content.iter().any(|c| {
matches!(c, ContentItem::InputText { text } if text.contains("<personality_spec>"))
}))
}) && let Some(p) = reconstruction_turn.personality
}) && let Some(p) = reconstruction_turn.personality.as_ref()
&& session.features.enabled(Feature::Personality)
&& let Some(personality_message) = reconstruction_turn
.model_info
.model_messages
.as_ref()
.and_then(|m| m.get_personality_message(Some(p)).filter(|s| !s.is_empty()))
.and_then(|m| {
m.get_personality_message(Some(p.clone()))
.filter(|s| !s.is_empty())
})
{
let msg = DeveloperInstructions::personality_spec_message(personality_message).into();
let insert_at = initial_context
+1 -1
View File
@@ -122,7 +122,7 @@ async fn run_compact_task_inner(
let prompt = Prompt {
input: turn_input,
base_instructions: sess.get_base_instructions().await,
personality: turn_context.personality,
personality: turn_context.personality.clone(),
..Default::default()
};
let turn_metadata_header = turn_context.turn_metadata_state.current_header_value();
+1 -1
View File
@@ -110,7 +110,7 @@ async fn run_remote_compact_task_inner_impl(
tools: tool_router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
base_instructions,
personality: turn_context.personality,
personality: turn_context.personality.clone(),
output_schema: None,
};
+4 -4
View File
@@ -4457,7 +4457,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
personality: Some(Personality::pragmatic()),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
@@ -4599,7 +4599,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
personality: Some(Personality::pragmatic()),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
@@ -4739,7 +4739,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: None,
personality: Some(Personality::Pragmatic),
personality: Some(Personality::pragmatic()),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
@@ -4865,7 +4865,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
model_supports_reasoning_summaries: None,
model_catalog: None,
model_verbosity: Some(Verbosity::High),
personality: Some(Personality::Pragmatic),
personality: Some(Personality::pragmatic()),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
+3 -1
View File
@@ -383,7 +383,9 @@ impl ConfigDocument {
)),
ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value(
&["personality"],
personality.map(|personality| value(personality.to_string())),
personality
.clone()
.map(|personality| value(personality.to_string())),
)),
ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(
Scope::Global,
+1 -1
View File
@@ -2431,7 +2431,7 @@ impl Config {
.or_else(|| {
features
.enabled(Feature::Personality)
.then_some(Personality::Pragmatic)
.then_some(Personality::pragmatic())
});
let experimental_compact_prompt_path = config_profile
+4 -1
View File
@@ -130,7 +130,10 @@ 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.model_info;
let personality = turn_context.personality.or(turn_context.config.personality);
let personality = turn_context
.personality
.clone()
.or(turn_context.config.personality.clone());
let base_instructions = BaseInstructions {
text: model_info.get_model_instructions(personality),
};
+28 -8
View File
@@ -1,6 +1,7 @@
use crate::codex::PreviousTurnSettings;
use crate::codex::TurnContext;
use crate::environment_context::EnvironmentContext;
use crate::personalities::PersonalityCatalog;
use crate::shell::Shell;
use codex_execpolicy::Policy;
use codex_features::Feature;
@@ -107,6 +108,7 @@ pub(crate) fn build_initial_realtime_item(
fn build_personality_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
personality_catalog: &PersonalityCatalog,
personality_feature_enabled: bool,
) -> Option<DeveloperInstructions> {
if !personality_feature_enabled {
@@ -117,11 +119,12 @@ fn build_personality_update_item(
return None;
}
if let Some(personality) = next.personality
if let Some(personality) = next.personality.clone()
&& next.personality != previous.personality
{
let model_info = &next.model_info;
let personality_message = personality_message_for(model_info, personality);
let personality_message =
personality_message_for(model_info, personality_catalog, &personality);
personality_message.map(DeveloperInstructions::personality_spec_message)
} else {
None
@@ -130,13 +133,22 @@ fn build_personality_update_item(
pub(crate) fn personality_message_for(
model_info: &ModelInfo,
personality: Personality,
personality_catalog: &PersonalityCatalog,
personality: &Personality,
) -> Option<String> {
model_info
let builtin_message = model_info
.model_messages
.as_ref()
.and_then(|spec| spec.get_personality_message(Some(personality)))
.filter(|message| !message.is_empty())
.and_then(|spec| spec.get_personality_message(Some(personality.clone())))
.filter(|message| !message.is_empty());
if builtin_message.is_some() {
builtin_message
} else {
personality_catalog
.instructions_for(personality)
.map(ToOwned::to_owned)
.filter(|message| !message.is_empty())
}
}
pub(crate) fn build_model_instructions_update_item(
@@ -148,7 +160,9 @@ pub(crate) fn build_model_instructions_update_item(
return None;
}
let model_instructions = next.model_info.get_model_instructions(next.personality);
let model_instructions = next
.model_info
.get_model_instructions(next.personality.clone());
if model_instructions.is_empty() {
return None;
}
@@ -191,6 +205,7 @@ pub(crate) fn build_settings_update_items(
next: &TurnContext,
shell: &Shell,
exec_policy: &Policy,
personality_catalog: &PersonalityCatalog,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
// TODO(ccunningham): build_settings_update_items still does not cover every
@@ -205,7 +220,12 @@ pub(crate) fn build_settings_update_items(
build_permissions_update_item(previous, next, exec_policy),
build_collaboration_mode_update_item(previous, next),
build_realtime_update_item(previous, previous_turn_settings, next),
build_personality_update_item(previous, next, personality_feature_enabled),
build_personality_update_item(
previous,
next,
personality_catalog,
personality_feature_enabled,
),
]
.into_iter()
.flatten()
+1 -1
View File
@@ -331,7 +331,7 @@ pub(super) async fn run_guardian_review_session(
model: guardian_model,
reasoning_effort: guardian_reasoning_effort,
reasoning_summary: turn.reasoning_summary,
personality: turn.personality,
personality: turn.personality.clone(),
external_cancel,
})
.await
+1 -1
View File
@@ -522,7 +522,7 @@ async fn run_review_on_session(
service_tier: None,
final_output_json_schema: Some(params.schema.clone()),
collaboration_mode: None,
personality: params.personality,
personality: params.personality.clone(),
})
.await
}),
+1
View File
@@ -63,6 +63,7 @@ pub mod message_history;
mod model_provider_info;
pub mod utils;
pub use utils::path_utils;
pub mod personalities;
pub mod personality_migration;
pub mod plugins;
mod provider_auth;
+487
View File
@@ -0,0 +1,487 @@
/*!
Loads built-in and file-backed personality definitions, resolves precedence
between repo and user scopes, and exposes the catalog used by config, prompt
construction, and the app-server personality picker.
*/
use crate::config::Config;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_protocol::config_types::Personality;
use serde::Deserialize;
use std::collections::HashMap;
use std::collections::HashSet;
use std::error::Error;
use std::fmt;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use tracing::warn;
const PERSONALITIES_DIR_NAME: &str = "personalities";
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
const BUILTIN_FRIENDLY_INSTRUCTIONS: &str =
include_str!("../templates/personalities/gpt-5.2-codex_friendly.md");
const BUILTIN_PRAGMATIC_INSTRUCTIONS: &str =
include_str!("../templates/personalities/gpt-5.2-codex_pragmatic.md");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PersonalityScope {
Builtin,
User,
Repo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersonalityDefinition {
pub name: Personality,
pub description: String,
pub instructions: Option<String>,
pub scope: PersonalityScope,
pub is_builtin: bool,
}
#[derive(Debug, Clone, Default)]
pub struct PersonalityCatalog {
personalities: Vec<PersonalityDefinition>,
by_name: HashMap<Personality, PersonalityDefinition>,
}
impl PersonalityCatalog {
pub fn personalities(&self) -> &[PersonalityDefinition] {
self.personalities.as_slice()
}
pub fn get(&self, personality: &Personality) -> Option<&PersonalityDefinition> {
self.by_name.get(personality)
}
pub fn instructions_for(&self, personality: &Personality) -> Option<&str> {
self.get(personality)
.and_then(|definition| definition.instructions.as_deref())
}
}
#[derive(Debug, Default)]
struct PersonalityLoadOutcome {
personalities: Vec<PersonalityDefinition>,
errors: Vec<PersonalityError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PersonalityError {
path: PathBuf,
message: String,
}
#[derive(Debug, Deserialize)]
struct PersonalityFrontmatter {
name: String,
description: String,
}
#[derive(Debug)]
enum PersonalityParseError {
Read(std::io::Error),
MissingFrontmatter,
InvalidYaml(serde_yaml::Error),
MissingBody,
InvalidName(String),
InvalidField { field: &'static str, reason: String },
}
impl fmt::Display for PersonalityParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PersonalityParseError::Read(error) => write!(f, "failed to read file: {error}"),
PersonalityParseError::MissingFrontmatter => {
write!(f, "missing YAML frontmatter delimited by ---")
}
PersonalityParseError::InvalidYaml(error) => write!(f, "invalid YAML: {error}"),
PersonalityParseError::MissingBody => write!(f, "missing markdown body"),
PersonalityParseError::InvalidName(reason) => write!(f, "invalid name: {reason}"),
PersonalityParseError::InvalidField { field, reason } => {
write!(f, "invalid {field}: {reason}")
}
}
}
}
impl Error for PersonalityParseError {}
pub fn catalog_for_config(config: &Config) -> PersonalityCatalog {
catalog_from_layer_stack(&config.config_layer_stack)
}
pub fn catalog_from_layer_stack(config_layer_stack: &ConfigLayerStack) -> PersonalityCatalog {
let mut outcome = load_personalities_from_layer_stack(config_layer_stack);
for error in &outcome.errors {
warn!(
path = %error.path.display(),
error = %error.message,
"ignoring invalid personality definition"
);
}
outcome.personalities.sort_by(|left, right| {
left.is_builtin
.cmp(&right.is_builtin)
.reverse()
.then_with(|| {
left.name
.as_str()
.to_ascii_lowercase()
.cmp(&right.name.as_str().to_ascii_lowercase())
})
.then_with(|| left.name.as_str().cmp(right.name.as_str()))
});
let mut by_name = HashMap::new();
for personality in outcome.personalities.iter().cloned() {
by_name.insert(personality.name.clone(), personality);
}
PersonalityCatalog {
personalities: outcome.personalities,
by_name,
}
}
fn load_personalities_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
) -> PersonalityLoadOutcome {
let mut outcome = PersonalityLoadOutcome {
personalities: builtin_personalities(),
..Default::default()
};
let mut seen: HashSet<Personality> = outcome
.personalities
.iter()
.map(|personality| personality.name.clone())
.collect();
for (root, scope) in personality_roots(config_layer_stack) {
discover_personalities_under_root(root.as_path(), scope, &mut seen, &mut outcome);
}
outcome
}
fn builtin_personalities() -> Vec<PersonalityDefinition> {
vec![
PersonalityDefinition {
name: Personality::none(),
description: "No personality instructions.".to_string(),
instructions: Some(String::new()),
scope: PersonalityScope::Builtin,
is_builtin: true,
},
PersonalityDefinition {
name: Personality::friendly(),
description: "Warm, collaborative, and helpful.".to_string(),
instructions: Some(BUILTIN_FRIENDLY_INSTRUCTIONS.to_string()),
scope: PersonalityScope::Builtin,
is_builtin: true,
},
PersonalityDefinition {
name: Personality::pragmatic(),
description: "Concise, task-focused, and direct.".to_string(),
instructions: Some(BUILTIN_PRAGMATIC_INSTRUCTIONS.to_string()),
scope: PersonalityScope::Builtin,
is_builtin: true,
},
]
}
fn personality_roots(config_layer_stack: &ConfigLayerStack) -> Vec<(PathBuf, PersonalityScope)> {
let mut roots = Vec::new();
for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
) {
let Some(config_folder) = layer.config_folder() else {
continue;
};
let scope = match &layer.name {
ConfigLayerSource::Project { .. } => PersonalityScope::Repo,
ConfigLayerSource::User { .. } => PersonalityScope::User,
_ => continue,
};
roots.push((config_folder.as_path().join(PERSONALITIES_DIR_NAME), scope));
}
let mut seen = HashSet::new();
roots.retain(|(path, _)| seen.insert(path.clone()));
roots
}
fn discover_personalities_under_root(
root: &Path,
scope: PersonalityScope,
seen: &mut HashSet<Personality>,
outcome: &mut PersonalityLoadOutcome,
) {
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
Err(error) => {
outcome.errors.push(PersonalityError {
path: root.to_path_buf(),
message: error.to_string(),
});
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file()
|| !path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
{
continue;
}
match parse_personality_file(path.as_path(), scope) {
Ok(personality) => {
if seen.insert(personality.name.clone()) {
outcome.personalities.push(personality);
}
}
Err(error) => {
outcome.errors.push(PersonalityError {
path,
message: error.to_string(),
});
}
}
}
}
fn parse_personality_file(
path: &Path,
scope: PersonalityScope,
) -> Result<PersonalityDefinition, PersonalityParseError> {
let contents = fs::read_to_string(path).map_err(PersonalityParseError::Read)?;
let (frontmatter, body) =
extract_frontmatter_and_body(&contents).ok_or(PersonalityParseError::MissingFrontmatter)?;
let parsed: PersonalityFrontmatter =
serde_yaml::from_str(frontmatter.as_str()).map_err(PersonalityParseError::InvalidYaml)?;
let name = sanitize_single_line(parsed.name.as_str());
validate_name(name.as_str())?;
let description = sanitize_single_line(parsed.description.as_str());
validate_len(description.as_str(), MAX_DESCRIPTION_LEN, "description")?;
let instructions = body.trim();
if instructions.is_empty() {
return Err(PersonalityParseError::MissingBody);
}
Ok(PersonalityDefinition {
name: Personality::from(name),
description,
instructions: Some(instructions.to_string()),
scope,
is_builtin: false,
})
}
fn validate_name(name: &str) -> Result<(), PersonalityParseError> {
validate_len(name, MAX_NAME_LEN, "name")?;
let normalized = name.to_ascii_lowercase();
if Personality::builtin_names().contains(&normalized.as_str()) {
return Err(PersonalityParseError::InvalidName(format!(
"`{name}` is reserved for a built-in personality"
)));
}
Ok(())
}
fn validate_len(
value: &str,
max_len: usize,
field_name: &'static str,
) -> Result<(), PersonalityParseError> {
if value.is_empty() {
return Err(PersonalityParseError::InvalidField {
field: field_name,
reason: "value is empty".to_string(),
});
}
if value.chars().count() > max_len {
return Err(PersonalityParseError::InvalidField {
field: field_name,
reason: format!("exceeds maximum length of {max_len} characters"),
});
}
Ok(())
}
fn sanitize_single_line(raw: &str) -> String {
raw.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn extract_frontmatter_and_body(contents: &str) -> Option<(String, String)> {
let mut lines = contents.lines();
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
return None;
}
let mut frontmatter_lines = Vec::new();
let mut found_closing = false;
for line in lines.by_ref() {
if line.trim() == "---" {
found_closing = true;
break;
}
frontmatter_lines.push(line);
}
if frontmatter_lines.is_empty() || !found_closing {
return None;
}
let body = lines.collect::<Vec<_>>().join("\n");
Some((frontmatter_lines.join("\n"), body))
}
#[cfg(test)]
mod tests {
use super::catalog_from_layer_stack;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use codex_protocol::config_types::Personality;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::TempDir;
fn write_personality(root: &std::path::Path, name: &str, description: &str, body: &str) {
fs::create_dir_all(root).unwrap();
let path = root.join(format!("{name}.md"));
fs::write(
path,
format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"),
)
.unwrap();
}
#[tokio::test]
async fn repo_personality_overrides_user_personality_name() {
let temp = TempDir::new().unwrap();
let codex_home = temp.path().join(".codex-home");
let repo_root = temp.path().join("repo");
fs::create_dir_all(repo_root.join(".codex")).unwrap();
fs::create_dir_all(&codex_home).unwrap();
write_personality(
codex_home.join("personalities").as_path(),
"night-owl",
"User personality",
"user body",
);
write_personality(
repo_root.join(".codex/personalities").as_path(),
"night-owl",
"Repo personality",
"repo body",
);
let stack = load_config_layers_state(
&codex_home,
Some(repo_root.clone().try_into().unwrap()),
&[],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
)
.await
.unwrap();
let catalog = catalog_from_layer_stack(&stack);
let personality = catalog.get(&Personality::from("night-owl")).unwrap();
assert_eq!(personality.description, "Repo personality");
assert_eq!(personality.instructions.as_deref(), Some("repo body"));
}
#[tokio::test]
async fn invalid_personality_is_ignored() {
let temp = TempDir::new().unwrap();
let codex_home = temp.path().join(".codex-home");
let repo_root = temp.path().join("repo");
fs::create_dir_all(repo_root.join(".codex/personalities")).unwrap();
fs::create_dir_all(&codex_home).unwrap();
fs::write(
repo_root.join(".codex/personalities/bad.md"),
"---\nname: friendly\ndescription: bad\n---\n\nbody\n",
)
.unwrap();
let stack = load_config_layers_state(
&codex_home,
Some(repo_root.clone().try_into().unwrap()),
&[],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
)
.await
.unwrap();
let catalog = catalog_from_layer_stack(&stack);
assert!(
catalog
.get(&Personality::from("friendly"))
.unwrap()
.is_builtin
);
assert!(catalog.get(&Personality::from("bad")).is_none());
}
#[tokio::test]
async fn personalities_are_sorted_with_builtins_first_then_name() {
let temp = TempDir::new().unwrap();
let codex_home = temp.path().join(".codex-home");
let repo_root = temp.path().join("repo");
fs::create_dir_all(repo_root.join(".codex")).unwrap();
fs::create_dir_all(&codex_home).unwrap();
write_personality(
codex_home.join("personalities").as_path(),
"zebra",
"User personality",
"zebra body",
);
write_personality(
repo_root.join(".codex/personalities").as_path(),
"alpha",
"Repo personality",
"alpha body",
);
let stack = load_config_layers_state(
&codex_home,
Some(repo_root.clone().try_into().unwrap()),
&[],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
)
.await
.unwrap();
let catalog = catalog_from_layer_stack(&stack);
let names = catalog
.personalities()
.iter()
.map(|personality| personality.name.as_str())
.collect::<Vec<_>>();
assert_eq!(
names,
vec!["friendly", "none", "pragmatic", "alpha", "zebra"]
);
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ pub async fn maybe_migrate_personality(
}
ConfigEditsBuilder::new(codex_home)
.set_personality(Some(Personality::Pragmatic))
.set_personality(Some(Personality::pragmatic()))
.apply()
.await
.map_err(|err| {
@@ -81,7 +81,7 @@ async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> {
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
let persisted = read_config_toml(temp.path()).await?;
assert_eq!(persisted.personality, Some(Personality::Pragmatic));
assert_eq!(persisted.personality, Some(Personality::pragmatic()));
Ok(())
}
@@ -102,7 +102,7 @@ async fn skips_when_marker_exists() -> io::Result<()> {
async fn skips_when_personality_explicit() -> io::Result<()> {
let temp = TempDir::new()?;
ConfigEditsBuilder::new(temp.path())
.set_personality(Some(Personality::Friendly))
.set_personality(Some(Personality::friendly()))
.apply()
.await
.map_err(|err| io::Error::other(format!("failed to write config: {err}")))?;
@@ -117,7 +117,7 @@ async fn skips_when_personality_explicit() -> io::Result<()> {
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
let persisted = read_config_toml(temp.path()).await?;
assert_eq!(persisted.personality, Some(Personality::Friendly));
assert_eq!(persisted.personality, Some(Personality::friendly()));
Ok(())
}
+2
View File
@@ -10,6 +10,7 @@ use crate::exec_policy::ExecPolicyManager;
use crate::mcp::McpManager;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::models_manager::manager::ModelsManager;
use crate::personalities::PersonalityCatalog;
use crate::plugins::PluginsManager;
use crate::skills_watcher::SkillsWatcher;
use crate::state_db::StateDbHandle;
@@ -44,6 +45,7 @@ pub(crate) struct SessionServices {
pub(crate) exec_policy: Arc<ExecPolicyManager>,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) models_manager: Arc<ModelsManager>,
pub(crate) personality_catalog: Arc<PersonalityCatalog>,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) tool_approvals: Mutex<ApprovalStore>,
pub(crate) skills_manager: Arc<SkillsManager>,