Track model and personality in world state (#35530)

## What changed

- Add model and personality sections to the persisted world-state snapshot.
- Generate model-switch and personality instructions through world-state diffs, including when prior state must be inferred during replay.
- Keep those instruction fragments in standalone developer messages instead of merging them with adjacent context updates.

## Testing

- Cover initial, unchanged, and changed model and personality states.
- Verify that model changes are recorded in rollout world-state items.

GitOrigin-RevId: 497e29cf38cac08e992e546df900f6f8b01120f9
This commit is contained in:
sayan-oai
2026-07-26 23:36:33 +00:00
committed by copyberry
parent 0896bf6fc0
commit 18f50c9e62
13 changed files with 421 additions and 148 deletions
@@ -46,6 +46,11 @@ impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationPro
pub trait ContextualUserFragment {
fn role(&self) -> &'static str;
/// Whether this fragment must be recorded as its own response item.
fn requires_separate_message(&self) -> bool {
false
}
fn markers(&self) -> (&'static str, &'static str);
fn body(&self) -> String;
@@ -18,6 +18,10 @@ impl ContextualUserFragment for ModelSwitchInstructions {
"developer"
}
fn requires_separate_message(&self) -> bool {
true
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
@@ -16,6 +16,10 @@ impl ContextualUserFragment for PersonalitySpecInstructions {
"developer"
}
fn requires_separate_message(&self) -> bool {
true
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
@@ -3,8 +3,10 @@ mod apps_instructions;
mod collaboration_mode;
mod environment;
mod environments_instructions;
mod model;
mod multi_agent_mode;
mod permissions;
mod personality;
mod plugins_instructions;
mod realtime;
#[cfg(test)]
@@ -32,8 +34,10 @@ pub(crate) use apps_instructions::AppsInstructionsState;
pub(crate) use collaboration_mode::CollaborationModeState;
pub(crate) use environment::EnvironmentsState;
pub(crate) use environments_instructions::EnvironmentsInstructionsState;
pub(crate) use model::ModelInstructionsState;
pub(crate) use multi_agent_mode::MultiAgentModeState;
pub(crate) use permissions::PermissionsState;
pub(crate) use personality::PersonalityState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
pub(crate) use realtime::RealtimeState;
pub(crate) use tools::ToolsState;
@@ -0,0 +1,65 @@
use super::PreviousSectionState;
use super::WorldStateSection;
use crate::context::ContextualUserFragment;
use crate::context::ModelSwitchInstructions;
/// Model identity and the instructions needed when that identity changes.
#[derive(Clone, Debug)]
pub(crate) struct ModelInstructionsState {
model: String,
previous_model: Option<String>,
instructions: String,
}
impl ModelInstructionsState {
pub(crate) fn new(model: &str, previous_model: Option<&str>, instructions: String) -> Self {
Self {
model: model.to_string(),
previous_model: previous_model.map(str::to_string),
instructions,
}
}
}
impl WorldStateSection for ModelInstructionsState {
const ID: &'static str = "model";
type Snapshot = String;
fn snapshot(&self) -> Self::Snapshot {
self.model.clone()
}
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
role == "developer" && ModelSwitchInstructions::matches_text(text)
}
fn has_retained_fragment_matcher() -> bool {
true
}
fn matches_retained_fragment(role: &str, text: &str) -> bool {
Self::matches_legacy_fragment(role, text)
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
let model_changed = match previous {
PreviousSectionState::Known(previous) => previous != &self.model,
PreviousSectionState::Unknown | PreviousSectionState::Absent => self
.previous_model
.as_deref()
.is_some_and(|previous| previous != self.model),
};
(model_changed && !self.instructions.is_empty()).then(|| {
Box::new(ModelSwitchInstructions::new(self.instructions.clone()))
as Box<dyn ContextualUserFragment>
})
}
}
#[cfg(test)]
#[path = "model_tests.rs"]
mod tests;
@@ -0,0 +1,35 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn model_change_renders_when_persisted_or_inferred_from_previous_turn() {
let state = ModelInstructionsState::new("gpt-new", Some("gpt-old"), "instructions".into());
let previous = "gpt-old".to_string();
for previous in [
PreviousSectionState::Known(&previous),
PreviousSectionState::Unknown,
PreviousSectionState::Absent,
] {
assert_eq!(
state
.render_diff(previous)
.expect("model change should render")
.markers(),
ModelSwitchInstructions::type_markers()
);
}
}
#[test]
fn unchanged_model_does_not_render() {
let state = ModelInstructionsState::new("gpt-test", Some("gpt-test"), "instructions".into());
let previous = "gpt-test".to_string();
assert!(
state
.render_diff(PreviousSectionState::Known(&previous))
.is_none()
);
assert!(state.render_diff(PreviousSectionState::Absent).is_none());
}
@@ -0,0 +1,101 @@
use super::PreviousSectionState;
use super::WorldStateSection;
use crate::context::ContextualUserFragment;
use crate::context::PersonalitySpecInstructions;
use codex_protocol::config_types::Personality;
use serde::Deserialize;
use serde::Serialize;
/// Personality instructions currently visible to the model.
#[derive(Clone, Debug)]
pub(crate) struct PersonalityState {
snapshot: PersonalitySnapshot,
previous: Option<PersonalitySnapshot>,
instructions: Option<String>,
personality_is_baked: bool,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub(crate) struct PersonalitySnapshot {
model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
personality: Option<Personality>,
}
impl PersonalityState {
pub(crate) fn new(
model: &str,
personality: Option<Personality>,
previous_model: Option<&str>,
previous_personality: Option<Personality>,
instructions: Option<String>,
personality_is_baked: bool,
) -> Self {
Self {
snapshot: PersonalitySnapshot {
model: model.to_string(),
personality,
},
previous: previous_model.map(|model| PersonalitySnapshot {
model: model.to_string(),
personality: previous_personality,
}),
instructions,
personality_is_baked,
}
}
fn render_change(
&self,
previous: &PersonalitySnapshot,
) -> Option<Box<dyn ContextualUserFragment>> {
(previous.model == self.snapshot.model && previous.personality != self.snapshot.personality)
.then_some(self.instructions.as_ref())
.flatten()
.map(|instructions| {
Box::new(PersonalitySpecInstructions::new(instructions.clone()))
as Box<dyn ContextualUserFragment>
})
}
}
impl WorldStateSection for PersonalityState {
const ID: &'static str = "personality";
type Snapshot = PersonalitySnapshot;
fn snapshot(&self) -> Self::Snapshot {
self.snapshot.clone()
}
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
role == "developer" && PersonalitySpecInstructions::matches_text(text)
}
fn render_diff(
&self,
previous: PreviousSectionState<'_, Self::Snapshot>,
) -> Option<Box<dyn ContextualUserFragment>> {
match previous {
PreviousSectionState::Known(previous) => self.render_change(previous),
PreviousSectionState::Unknown => self
.previous
.as_ref()
.and_then(|previous| self.render_change(previous)),
PreviousSectionState::Absent => (!self.personality_is_baked
&& self
.previous
.as_ref()
.is_none_or(|previous| previous.model == self.snapshot.model))
.then_some(self.instructions.as_ref())
.flatten()
.map(|instructions| {
Box::new(PersonalitySpecInstructions::new(instructions.clone()))
as Box<dyn ContextualUserFragment>
}),
}
}
}
#[cfg(test)]
#[path = "personality_tests.rs"]
mod tests;
@@ -0,0 +1,106 @@
use super::*;
use crate::context::world_state::WorldState;
use pretty_assertions::assert_eq;
fn state(
model: &str,
personality: Option<Personality>,
previous: Option<(&str, Option<Personality>)>,
personality_is_baked: bool,
) -> PersonalityState {
PersonalityState::new(
model,
personality,
previous.map(|(model, _)| model),
previous.and_then(|(_, personality)| personality),
personality.map(|personality| format!("instructions for {personality:?}")),
personality_is_baked,
)
}
#[test]
fn initial_personality_renders_only_when_missing_from_base_instructions() {
let separate = state(
"gpt-test",
Some(Personality::Friendly),
/*previous*/ None,
/*personality_is_baked*/ false,
);
let baked = state(
"gpt-test",
Some(Personality::Friendly),
/*previous*/ None,
/*personality_is_baked*/ true,
);
assert_eq!(
separate
.render_diff(PreviousSectionState::Absent)
.expect("separate personality should render")
.markers(),
PersonalitySpecInstructions::type_markers()
);
assert!(baked.render_diff(PreviousSectionState::Absent).is_none());
}
#[test]
fn personality_changes_render_without_repeating_model_changes() {
let previous = PersonalitySnapshot {
model: "gpt-test".to_string(),
personality: Some(Personality::Friendly),
};
let changed = state(
"gpt-test",
Some(Personality::Pragmatic),
Some(("gpt-test", Some(Personality::Friendly))),
/*personality_is_baked*/ true,
);
let model_changed = state(
"gpt-next",
Some(Personality::Pragmatic),
Some(("gpt-test", Some(Personality::Friendly))),
/*personality_is_baked*/ true,
);
assert_eq!(
changed
.render_diff(PreviousSectionState::Known(&previous))
.expect("changed personality should render")
.markers(),
PersonalitySpecInstructions::type_markers()
);
assert!(
model_changed
.render_diff(PreviousSectionState::Known(&previous))
.is_none()
);
assert!(
model_changed
.render_diff(PreviousSectionState::Unknown)
.is_none()
);
assert!(
model_changed
.render_diff(PreviousSectionState::Absent)
.is_none()
);
}
#[test]
fn persisted_personality_does_not_require_a_retained_update() {
let state = state(
"gpt-test",
Some(Personality::Friendly),
Some(("gpt-test", Some(Personality::Friendly))),
/*personality_is_baked*/ false,
);
let mut world_state = WorldState::default();
world_state.add_section(state);
let snapshot = world_state.snapshot();
assert!(
world_state
.render_history_diff(Some(&snapshot), &[])
.is_empty()
);
}
+17 -86
View File
@@ -1,64 +1,11 @@
use crate::context::ContextualUserFragment;
use crate::context::ModelSwitchInstructions;
use crate::context::PersonalitySpecInstructions;
use crate::session::PreviousTurnSettings;
use crate::session::turn_context::TurnContext;
use codex_protocol::config_types::Personality;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::TurnContextItem;
fn build_personality_update_item(
previous: Option<&TurnContextItem>,
next: &TurnContext,
personality_feature_enabled: bool,
) -> Option<String> {
if !personality_feature_enabled {
return None;
}
let previous = previous?;
if next.model_info.slug != previous.model {
return None;
}
if let Some(personality) = next.personality
&& next.personality != previous.personality
{
let model_info = &next.model_info;
let personality_message = personality_message_for(model_info, personality);
personality_message.map(|message| PersonalitySpecInstructions::new(message).render())
} else {
None
}
}
pub(crate) fn personality_message_for(
model_info: &ModelInfo,
personality: Personality,
) -> Option<String> {
model_info
.model_messages
.as_ref()
.and_then(|spec| spec.get_personality_message(Some(personality)))
.filter(|message| !message.is_empty())
}
pub(crate) fn build_model_instructions_update_item(
previous_turn_settings: Option<&PreviousTurnSettings>,
next: &TurnContext,
) -> Option<String> {
let previous_turn_settings = previous_turn_settings?;
if previous_turn_settings.model == next.model_info.slug {
return None;
}
let model_instructions = next.model_info.get_model_instructions(next.personality);
if model_instructions.is_empty() {
return None;
}
Some(ModelSwitchInstructions::new(model_instructions).render())
#[derive(Clone, Copy, PartialEq, Eq)]
enum MessageGroup {
Standalone,
Mergeable,
}
pub(crate) fn build_developer_update_item(text_sections: Vec<String>) -> Option<ResponseItem> {
@@ -72,20 +19,29 @@ pub(crate) fn build_contextual_user_message(text_sections: Vec<String>) -> Optio
pub(crate) fn merge_contextual_fragments(
fragments: Vec<Box<dyn ContextualUserFragment>>,
) -> Vec<ResponseItem> {
let mut messages: Vec<(&str, Vec<String>)> = Vec::with_capacity(fragments.len());
let mut messages: Vec<(&str, MessageGroup, Vec<String>)> = Vec::with_capacity(fragments.len());
for fragment in fragments {
let role = fragment.role();
let group = if fragment.requires_separate_message() {
MessageGroup::Standalone
} else {
MessageGroup::Mergeable
};
let text = fragment.render();
match messages.last_mut() {
Some((previous_role, text_sections)) if *previous_role == role => {
Some((previous_role, previous_group, text_sections))
if *previous_role == role
&& *previous_group == MessageGroup::Mergeable
&& group == MessageGroup::Mergeable =>
{
text_sections.push(text);
}
_ => messages.push((role, vec![text])),
_ => messages.push((role, group, vec![text])),
}
}
messages
.into_iter()
.filter_map(|(role, text_sections)| build_text_message(role, text_sections))
.filter_map(|(role, _, text_sections)| build_text_message(role, text_sections))
.collect()
}
@@ -107,28 +63,3 @@ fn build_text_message(role: &str, text_sections: Vec<String>) -> Option<Response
internal_chat_message_metadata_passthrough: None,
})
}
pub(crate) fn build_settings_update_items(
previous: Option<&TurnContextItem>,
previous_turn_settings: Option<&PreviousTurnSettings>,
next: &TurnContext,
personality_feature_enabled: bool,
) -> Vec<ResponseItem> {
// TODO(ccunningham): build_settings_update_items still does not cover every
// model-visible item emitted by build_initial_context. Persist the remaining
// inputs or add explicit replay events so fork/resume can diff everything
// deterministically.
let developer_update_sections = [
// Keep model-switch instructions first so model-specific guidance is read before
// any other context diffs on this turn.
build_model_instructions_update_item(previous_turn_settings, next),
build_personality_update_item(previous, next, personality_feature_enabled),
]
.into_iter()
.flatten()
.collect();
build_developer_update_item(developer_update_sections)
.into_iter()
.collect()
}
+9 -57
View File
@@ -26,8 +26,8 @@ use crate::config::resolve_tool_suggest_config_from_layer_stack;
use crate::context::ApprovedCommandPrefixSaved;
use crate::context::AvailableSkillsInstructions;
use crate::context::ContextualUserFragment;
use crate::context::ModelSwitchInstructions;
use crate::context::NetworkRuleSaved;
use crate::context::PersonalitySpecInstructions;
use crate::context::RecommendedPluginsInstructions;
use crate::context::world_state::WorldState;
use crate::current_time::TimeProvider;
@@ -1807,27 +1807,6 @@ impl Session {
self.refresh_runtime_config(next_config).await;
}
async fn build_settings_update_items(
&self,
reference_context_item: Option<&TurnContextItem>,
current_context: &TurnContext,
) -> Vec<ResponseItem> {
// TODO: Make context updates a pure diff of persisted previous/current TurnContextItem
// state so replay/backtracking is deterministic. Runtime inputs that affect model-visible
// context (feature gates and the previous-turn bridge) should be persisted
// state or explicit non-state replay events.
let previous_turn_settings = {
let state = self.state.lock().await;
state.previous_turn_settings()
};
crate::context_manager::updates::build_settings_update_items(
reference_context_item,
previous_turn_settings.as_ref(),
current_context,
self.features.enabled(Feature::Personality),
)
}
/// Record a terminal CodexErr before the app-server completion notification is reduced.
pub(crate) fn track_turn_codex_error(&self, turn_context: &TurnContext, error: &CodexErr) {
self.services
@@ -3351,23 +3330,13 @@ impl Session {
let mut developer_sections = Vec::<String>::with_capacity(8);
let mut contextual_user_sections = Vec::<String>::with_capacity(2);
let mut separate_developer_sections = Vec::<String>::new();
let (previous_turn_settings, base_instructions, session_source, auto_compact_window_ids) = {
let (session_source, auto_compact_window_ids) = {
let state = self.state.lock().await;
(
state.previous_turn_settings(),
state.session_configuration.base_instructions.clone(),
state.session_configuration.session_source.clone(),
state.auto_compact_window_ids(),
)
};
if let Some(model_switch_message) =
crate::context_manager::updates::build_model_instructions_update_item(
previous_turn_settings.as_ref(),
turn_context,
)
{
developer_sections.push(model_switch_message);
}
let separate_guardian_developer_message =
crate::guardian::is_guardian_reviewer_source(&session_source);
// Keep the guardian policy prompt out of the aggregated developer bundle so it
@@ -3378,23 +3347,6 @@ impl Session {
{
developer_sections.push(developer_instructions.to_string());
}
if self.features.enabled(Feature::Personality)
&& let Some(personality) = turn_context.personality
{
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));
if !has_baked_personality
&& let Some(personality_message) =
crate::context_manager::updates::personality_message_for(
&model_info,
personality,
)
{
developer_sections
.push(PersonalitySpecInstructions::new(personality_message).render());
}
}
if turn_context.config.include_skill_instructions {
let host_catalog_in_world_state = turn_context
.extension_data
@@ -3546,6 +3498,12 @@ impl Session {
let mut initial_multi_agent_mode = None;
for fragment in world_state.render_full() {
match fragment.role() {
"developer"
if fragment.markers().0 == ModelSwitchInstructions::type_markers().0 =>
{
// New-model instructions must precede the rest of the developer context.
developer_sections.insert(0, fragment.render());
}
"developer" if fragment.markers().0 == MULTI_AGENT_MODE_OPEN_TAG => {
initial_multi_agent_mode = Some(fragment);
}
@@ -3720,11 +3678,6 @@ impl Session {
Some(WorldStateItem::full(snapshot.into_value())),
)
} else {
// Steady-state path: append only built-in context diffs here; turn-scoped extension
// context is added below.
let mut context_items = self
.build_settings_update_items(reference_context_item.as_ref(), turn_context)
.await;
let (world_state_items, world_state_item) = {
let mut state = self.state.lock().await;
let (fragments, rollout_item) =
@@ -3734,8 +3687,7 @@ impl Session {
rollout_item,
)
};
context_items.extend(world_state_items);
(context_items, world_state_item)
(world_state_items, world_state_item)
};
if !should_inject_full_context && turn_context_changed {
context_items.extend(
-5
View File
@@ -9359,11 +9359,6 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
}
let rollout_path = attach_thread_persistence(&mut session).await;
let update_items = session
.build_settings_update_items(Some(&previous_context_item), &turn_context)
.await;
assert_eq!(update_items, Vec::new());
let turn_context = Arc::new(turn_context);
let step_context = StepContext::for_test(Arc::clone(&turn_context));
session
+44
View File
@@ -7,8 +7,10 @@ use crate::context::world_state::AppsInstructionsState;
use crate::context::world_state::CollaborationModeState;
use crate::context::world_state::EnvironmentsInstructionsState;
use crate::context::world_state::EnvironmentsState;
use crate::context::world_state::ModelInstructionsState;
use crate::context::world_state::MultiAgentModeState;
use crate::context::world_state::PermissionsState;
use crate::context::world_state::PersonalityState;
use crate::context::world_state::PluginsInstructionsState;
use crate::context::world_state::RealtimeState;
use crate::context::world_state::ToolsState;
@@ -27,6 +29,21 @@ impl Session {
selected_capability_root_count = step_context.selected_capability_roots.len(),
"building step world state"
);
let (previous_model, previous_context, base_instructions) = {
let state = self.state.lock().await;
(
state
.previous_turn_settings()
.map(|previous| previous.model),
state.reference_context_item(),
state.session_configuration.base_instructions.clone(),
)
};
let model_instructions = turn_context
.model_info
.get_model_instructions(turn_context.personality);
let personality_is_baked = turn_context.model_info.supports_personality()
&& base_instructions == model_instructions;
let environment_subagents = if turn_context.config.include_environment_context {
self.services
.agent_control
@@ -36,6 +53,33 @@ impl Session {
String::new()
};
let mut world_state = WorldState::default();
world_state.add_section(ModelInstructionsState::new(
&turn_context.model_info.slug,
previous_model.as_deref(),
model_instructions,
));
if self.features.enabled(Feature::Personality) {
let personality_instructions = turn_context.personality.and_then(|personality| {
turn_context
.model_info
.model_messages
.as_ref()
.and_then(|messages| messages.get_personality_message(Some(personality)))
.filter(|message| !message.is_empty())
});
world_state.add_section(PersonalityState::new(
&turn_context.model_info.slug,
turn_context.personality,
previous_context
.as_ref()
.map(|previous| previous.model.as_str()),
previous_context
.as_ref()
.and_then(|previous| previous.personality),
personality_instructions,
personality_is_baked,
));
}
world_state.add_section(RealtimeState::new(
turn_context.realtime_active,
turn_context
@@ -20,6 +20,8 @@ use codex_protocol::openai_models::default_input_modalities;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::ev_image_generation_call;
@@ -179,6 +181,31 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
"expected model switch preamble, got: {model_switch_text:?}"
);
test.codex.ensure_rollout_materialized().await;
test.codex.flush_rollout().await?;
let rollout_path = test.codex.rollout_path().expect("rollout path");
let model_states = std::fs::read_to_string(rollout_path)?
.lines()
.map(serde_json::from_str::<RolloutLine>)
.collect::<serde_json::Result<Vec<_>>>()?
.into_iter()
.filter_map(|line| match line.item {
RolloutItem::WorldState(item) => item
.state
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
model_states,
vec![
test.session_configured.model.clone(),
next_model.to_string()
]
);
Ok(())
}