Add developer instructions for persistent mode (#41050)

## What changed

- Add bundled proactivity and follow-up guidance when `ReasoningEffort::Persistent` is selected.
- Allow model metadata to override or disable the guidance with `persistent_instructions`.
- Track the instructions as world state so effort and model changes replace or retire stale guidance without duplicating unchanged context.
- Tailor approval guidance to `send_user_message_async` availability, rebuild it for forked agents and rolled-back history, and exclude Guardian sessions.

## Testing

- Cover default, overridden, disabled, replaced, and retired instructions.
- Cover model changes, history transitions, agent forks, Guardian sessions, and asynchronous-message tool availability.

GitOrigin-RevId: 78e05d0335a70b385bd069b45edcd4ba570cef91
This commit is contained in:
brianz-openai
2026-08-27 04:58:21 +00:00
committed by copyberry
parent 72c96598c6
commit f1433fc71f
24 changed files with 565 additions and 9 deletions
@@ -10,6 +10,7 @@ mod model;
mod multi_agent_mode;
mod multi_agent_usage_hint;
mod permissions;
mod persistent_mode;
mod personality;
mod plugins_instructions;
mod realtime;
@@ -49,6 +50,7 @@ pub(crate) use model::ModelInstructionsState;
pub(crate) use multi_agent_mode::MultiAgentModeState;
pub(crate) use multi_agent_usage_hint::MultiAgentUsageHintState;
pub(crate) use permissions::PermissionsState;
pub(crate) use persistent_mode::PersistentModeState;
pub(crate) use personality::PersonalityState;
pub(crate) use plugins_instructions::PluginsInstructionsState;
pub(crate) use realtime::RealtimeState;
@@ -0,0 +1,124 @@
//! Keeps persistent-mode developer instructions current without repeating unchanged context.
//! Mode changes retire prior instructions; missing catalog values use the bundled default.
use super::PreviousSectionState;
use super::WorldStateHash;
use super::WorldStateSection;
use crate::context::ContextualUserFragment;
use codex_protocol::models::ContentItemKind;
use codex_protocol::openai_models::ReasoningEffort;
use serde::Deserialize;
use serde::Serialize;
const DEFAULT_INSTRUCTIONS: &str = include_str!("../../../templates/persistent_mode.md");
const REPLACEMENT_NOTICE: &str = "These persistent-mode instructions replace all previously provided persistent-mode instructions.";
const REMOVAL_NOTICE: &str =
"The previously provided persistent-mode instructions no longer apply.";
#[derive(Clone, Debug)]
pub(crate) struct PersistentModeState {
instructions: String,
}
impl ContextualUserFragment for PersistentModeState {
fn content_kind(&self) -> ContentItemKind {
ContentItemKind("persistent_mode.instructions".to_string())
}
fn role(&self) -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("<persistent_mode>", "</persistent_mode>")
}
fn body(&self) -> String {
format!("\n{}\n", self.instructions)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PersistentModeSnapshot {
// Keep an object: JSON null would delete the section and lose its disabled state.
instructions: Option<WorldStateHash>,
}
impl PersistentModeState {
pub(crate) fn new(
reasoning_effort: Option<&ReasoningEffort>,
catalog_instructions: Option<&str>,
send_user_message_async_available: bool,
) -> Self {
let instructions = if reasoning_effort == Some(&ReasoningEffort::Persistent) {
catalog_instructions
.unwrap_or(DEFAULT_INSTRUCTIONS)
.trim()
.replace(
"{{ approval_request_channel }}",
if send_user_message_async_available {
" via functions.send_user_message_async"
} else {
""
},
)
} else {
String::new()
};
Self { instructions }
}
}
impl WorldStateSection for PersistentModeState {
const ID: &'static str = "persistent_mode";
type Snapshot = PersistentModeSnapshot;
fn snapshot(&self) -> Self::Snapshot {
PersistentModeSnapshot {
instructions: (!self.instructions.is_empty())
.then(|| WorldStateHash::from_fragment(self)),
}
}
fn matches_legacy_fragment(role: &str, text: &str) -> bool {
role == "developer" && Self::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>> {
if matches!(previous, PreviousSectionState::Known(previous) if previous == &self.snapshot())
{
return None;
}
let previous_had_instructions = match previous {
PreviousSectionState::Absent => false,
PreviousSectionState::Unknown => true,
PreviousSectionState::Known(previous) => previous.instructions.is_some(),
};
let instructions = match (self.instructions.as_str(), previous_had_instructions) {
("", false) => return None,
("", true) => REMOVAL_NOTICE.to_string(),
(instructions, true) => format!("{REPLACEMENT_NOTICE}\n\n{instructions}"),
(instructions, false) => instructions.to_string(),
};
Some(Box::new(Self { instructions }))
}
}
#[cfg(test)]
#[path = "persistent_mode_tests.rs"]
mod tests;
@@ -0,0 +1,90 @@
//! Covers effort selection, catalog overrides, and persistent-context transitions.
use super::*;
use crate::context::world_state::WorldState;
use pretty_assertions::assert_eq;
#[test]
fn persistent_instructions_follow_effort_and_catalog_updates_without_duplicates() {
let mut history = Vec::new();
let mut previous = None;
let persistent = Some(ReasoningEffort::Persistent);
let medium = Some(ReasoningEffort::Medium);
let replacement = format!("{REPLACEMENT_NOTICE}\n\nupdated instructions");
for (effort, instructions, expected) in [
(None, None, None),
(
persistent.clone(),
Some("instructions"),
Some("instructions"),
),
(persistent.clone(), Some("instructions"), None),
(
persistent.clone(),
Some("updated instructions"),
Some(replacement.as_str()),
),
(persistent.clone(), Some(""), Some(REMOVAL_NOTICE)),
(persistent.clone(), Some(""), None),
(persistent, Some("instructions"), Some("instructions")),
(medium.clone(), None, Some(REMOVAL_NOTICE)),
(medium, None, None),
] {
let mut world_state = WorldState::default();
world_state.add_section(PersistentModeState::new(
effort.as_ref(),
instructions,
/*send_user_message_async_available*/ false,
));
let updates = world_state
.render_history_diff(previous.as_ref(), &history)
.into_iter()
.map(ContextualUserFragment::into_boxed_response_item)
.collect::<Vec<_>>();
assert_eq!(
updates,
expected
.map(|instructions| {
ContextualUserFragment::into(PersistentModeState {
instructions: instructions.to_string(),
})
})
.into_iter()
.collect::<Vec<_>>()
);
history.extend(updates);
previous = Some(world_state.snapshot());
}
}
#[test]
fn retained_persistent_instructions_are_replaced_or_retired_without_a_snapshot() {
let retained = ContextualUserFragment::into(PersistentModeState {
instructions: "previous instructions".to_string(),
});
for (effort, expected) in [
(
ReasoningEffort::Persistent,
format!("{REPLACEMENT_NOTICE}\n\ncurrent instructions"),
),
(ReasoningEffort::Medium, REMOVAL_NOTICE.to_string()),
] {
let mut world_state = WorldState::default();
world_state.add_section(PersistentModeState::new(
Some(&effort),
Some("current instructions"),
/*send_user_message_async_available*/ false,
));
assert_eq!(
world_state
.render_history_diff(/*previous*/ None, std::slice::from_ref(&retained))
.into_iter()
.map(ContextualUserFragment::into_boxed_response_item)
.collect::<Vec<_>>(),
vec![ContextualUserFragment::into(PersistentModeState {
instructions: expected,
})]
);
}
}