From f1433fc71f2062ae3c007a03d7ff549bc582d386 Mon Sep 17 00:00:00 2001
From: brianz-openai <165065123+brianz-openai@users.noreply.github.com>
Date: Thu, 27 Aug 2026 04:52:45 +0000
Subject: [PATCH] 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
---
.../app-server/tests/common/models_cache.rs | 1 +
codex-rs/core/src/agent/control/spawn.rs | 8 +-
codex-rs/core/src/agent/control_tests.rs | 24 +++-
codex-rs/core/src/context/world_state/mod.rs | 2 +
.../context/world_state/persistent_mode.rs | 124 ++++++++++++++++++
.../world_state/persistent_mode_tests.rs | 90 +++++++++++++
codex-rs/core/src/context_manager/history.rs | 3 +
.../core/src/context_manager/history_tests.rs | 5 +
codex-rs/core/src/event_mapping.rs | 1 +
codex-rs/core/src/guardian/review_session.rs | 3 +
.../core/src/session/tests/guardian_tests.rs | 12 ++
codex-rs/core/src/session/world_state.rs | 21 +++
codex-rs/core/templates/persistent_mode.md | 13 ++
.../suite/catalog_permission_messages.rs | 1 +
.../tests/suite/collaboration_instructions.rs | 1 +
codex-rs/core/tests/suite/models_cache_ttl.rs | 1 +
.../core/tests/suite/permissions_messages.rs | 3 +
codex-rs/core/tests/suite/personality.rs | 2 +
codex-rs/core/tests/suite/remote_models.rs | 53 +++++++-
.../tests/suite/send_user_message_async.rs | 88 +++++++++++++
codex-rs/core/tests/suite/step_settings.rs | 86 ++++++++++++
codex-rs/models-manager/src/model_info.rs | 3 +
.../models-manager/src/model_info_tests.rs | 7 +
codex-rs/protocol/src/openai_models.rs | 22 +++-
24 files changed, 565 insertions(+), 9 deletions(-)
create mode 100644 codex-rs/core/src/context/world_state/persistent_mode.rs
create mode 100644 codex-rs/core/src/context/world_state/persistent_mode_tests.rs
create mode 100644 codex-rs/core/templates/persistent_mode.md
diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs
index 2bcb5a6d52..59a520c304 100644
--- a/codex-rs/app-server/tests/common/models_cache.rs
+++ b/codex-rs/app-server/tests/common/models_cache.rs
@@ -34,6 +34,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
default_service_tier: preset.default_service_tier.clone(),
upgrade: preset.upgrade.as_ref().map(Into::into),
model_messages: Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("base instructions".to_string()),
instructions_variables: None,
approvals: None,
diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs
index b4dcbc81e6..24a9e2692c 100644
--- a/codex-rs/core/src/agent/control/spawn.rs
+++ b/codex-rs/core/src/agent/control/spawn.rs
@@ -9,6 +9,7 @@ use crate::context::DeveloperInstructions;
use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentModeInstructions;
use crate::context::MultiAgentRoleInstructions;
+use crate::context::world_state::PersistentModeState;
use crate::session::multi_agents::resolve_usage_hints;
use crate::tools::handlers::multi_agents_common::build_agent_resume_config;
use codex_context_fragments::set_annotated_content;
@@ -928,9 +929,12 @@ impl AgentControl {
let ContentItem::InputText { text } = content_item.content_mut() else {
return true;
};
- if ManagedDeveloperInstructions::matches_text(text) {
+ if ManagedDeveloperInstructions::matches_text(text)
+ || PersistentModeState::matches_text(text)
+ {
// If the child will rebuild its initial context, drop the inherited
- // managed instructions; startup will add the current requirements once.
+ // instructions; startup will add the current requirements and effort
+ // instructions once.
return preserve_reference_context_item;
}
let (
diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs
index 0c0d2c0bb4..ca6f75b2bf 100644
--- a/codex-rs/core/src/agent/control_tests.rs
+++ b/codex-rs/core/src/agent/control_tests.rs
@@ -1461,6 +1461,8 @@ async fn spawn_agent_numeric_fork_from_compacted_paginated_parent_clamps_to_prov
#[tokio::test]
async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
let managed_fragment = "\nParent developer instructions.\n";
+ let persistent_fragment =
+ "\nParent developer instructions.\n";
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
@@ -1558,6 +1560,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
ContentItem::InputText {
text: managed_fragment.to_string(),
},
+ ContentItem::InputText {
+ text: persistent_fragment.to_string(),
+ },
],
phase: None,
internal_chat_message_metadata_passthrough: Some(
@@ -1567,6 +1572,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
ContentItemKind("multi_agent.mode_instructions".to_string()),
ContentItemKind("generic.developer_policy".to_string()),
ContentItemKind("managed_config.developer_instructions".to_string()),
+ ContentItemKind("persistent_mode.instructions".to_string()),
]),
..Default::default()
},
@@ -1677,6 +1683,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
ContentItem::InputText {
text: managed_fragment.to_string(),
},
+ ContentItem::InputText {
+ text: persistent_fragment.to_string(),
+ },
],
phase: None,
internal_chat_message_metadata_passthrough: Some(
@@ -1685,6 +1694,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
ContentItemKind("generic.developer_instructions".to_string()),
ContentItemKind("generic.developer_policy".to_string()),
ContentItemKind("managed_config.developer_instructions".to_string()),
+ ContentItemKind("persistent_mode.instructions".to_string()),
]),
..Default::default()
},
@@ -1769,8 +1779,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
"empty child developer instructions should remove parent developer instructions"
);
assert!(
- history_contains_text(no_hint_history.raw_items(), managed_fragment),
- "clearing child instructions must preserve an overlapping managed policy"
+ history_contains_text(no_hint_history.raw_items(), managed_fragment)
+ && history_contains_text(no_hint_history.raw_items(), persistent_fragment),
+ "clearing child instructions must preserve overlapping managed and persistent instructions"
);
assert!(
history_contains_text(
@@ -2660,6 +2671,8 @@ async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_li
#[tokio::test]
async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
+ let persistent_fragment =
+ "\nParent persistent instructions.\n";
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
@@ -2710,6 +2723,9 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
ContentItem::InputText {
text: "Preserved bounded developer context.".to_string(),
},
+ ContentItem::InputText {
+ text: persistent_fragment.to_string(),
+ },
],
phase: None,
internal_chat_message_metadata_passthrough: None,
@@ -2772,6 +2788,10 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
!history_contains_text(history.raw_items(), "Child developer instructions."),
"bounded fork should not inject child instructions before its canonical context rebuild"
);
+ assert!(
+ !history_contains_text(history.raw_items(), persistent_fragment),
+ "bounded fork should remove persistent instructions before rebuilding context for the child's effort"
+ );
assert!(
history_contains_text(history.raw_items(), "Preserved bounded developer context."),
"bounded fork should preserve unrelated developer fragments"
diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs
index 2ebd0ee450..249e4c80f8 100644
--- a/codex-rs/core/src/context/world_state/mod.rs
+++ b/codex-rs/core/src/context/world_state/mod.rs
@@ -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;
diff --git a/codex-rs/core/src/context/world_state/persistent_mode.rs b/codex-rs/core/src/context/world_state/persistent_mode.rs
new file mode 100644
index 0000000000..b2d5284cce
--- /dev/null
+++ b/codex-rs/core/src/context/world_state/persistent_mode.rs
@@ -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) {
+ ("", "")
+ }
+
+ 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,
+}
+
+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> {
+ 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;
diff --git a/codex-rs/core/src/context/world_state/persistent_mode_tests.rs b/codex-rs/core/src/context/world_state/persistent_mode_tests.rs
new file mode 100644
index 0000000000..59a6c98470
--- /dev/null
+++ b/codex-rs/core/src/context/world_state/persistent_mode_tests.rs
@@ -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::>();
+ assert_eq!(
+ updates,
+ expected
+ .map(|instructions| {
+ ContextualUserFragment::into(PersistentModeState {
+ instructions: instructions.to_string(),
+ })
+ })
+ .into_iter()
+ .collect::>()
+ );
+ 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![ContextualUserFragment::into(PersistentModeState {
+ instructions: expected,
+ })]
+ );
+ }
+}
diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs
index 2d9b647321..954e185283 100644
--- a/codex-rs/core/src/context_manager/history.rs
+++ b/codex-rs/core/src/context_manager/history.rs
@@ -1,5 +1,6 @@
use crate::context::ContextualUserFragment;
use crate::context::ModelSwitchInstructions;
+use crate::context::world_state::PersistentModeState;
use crate::context::world_state::WorldState;
use crate::context::world_state::WorldStateSnapshot;
use crate::context_manager::normalize;
@@ -356,10 +357,12 @@ impl ContextManager {
return false;
};
content.retain(|content| {
+ // Rebuild these from the next step's model and effort after rollback.
!matches!(
content.content(),
ContentItem::InputText { text }
if ModelSwitchInstructions::matches_text(text)
+ || PersistentModeState::matches_text(text)
)
});
!content.is_empty() && set_annotated_content(&mut item.item, content).is_some()
diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs
index 190f617bda..cdf5b3da97 100644
--- a/codex-rs/core/src/context_manager/history_tests.rs
+++ b/codex-rs/core/src/context_manager/history_tests.rs
@@ -1265,6 +1265,10 @@ fn drop_last_n_user_turns_preserves_annotations_for_surviving_developer_fragment
text: "persistent developer instructions".to_string(),
},
ContentItem::InputText { text: model_switch },
+ ContentItem::InputText {
+ text: "\nFollow up on the completed task.\n"
+ .to_string(),
+ },
ContentItem::InputText {
text: "persistent environment instructions".to_string(),
},
@@ -1275,6 +1279,7 @@ fn drop_last_n_user_turns_preserves_annotations_for_surviving_developer_fragment
content_item_kinds: Some(vec![
ContentItemKind("generic.developer_instructions".to_string()),
ContentItemKind("model_switch.instructions".to_string()),
+ ContentItemKind("persistent_mode.instructions".to_string()),
ContentItemKind("environments.instructions".to_string()),
]),
..Default::default()
diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs
index 7682c219eb..f661c21baa 100644
--- a/codex-rs/core/src/event_mapping.rs
+++ b/codex-rs/core/src/event_mapping.rs
@@ -42,6 +42,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
APPROVED_COMMAND_PREFIX_SAVED_MESSAGE_PREFIX,
"",
"",
+ "",
APPS_INSTRUCTIONS_OPEN_TAG,
COLLABORATION_MODE_OPEN_TAG,
"",
diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs
index 9ce3976537..1838d612b4 100644
--- a/codex-rs/core/src/guardian/review_session.rs
+++ b/codex-rs/core/src/guardian/review_session.rs
@@ -1907,6 +1907,7 @@ mod tests {
let catalog_template = "Catalog Guardian template:\n{{ tenant_policy_config }}";
parent_config.guardian_policy_config = Some(managed_policy.to_string());
let model_messages = ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -1945,6 +1946,7 @@ mod tests {
async fn guardian_review_session_config_preserves_explicit_empty_catalog_policy() {
let parent_config = crate::config::test_config().await;
let model_messages = ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -1991,6 +1993,7 @@ mod tests {
let parent_config = crate::config::test_config().await;
let catalog_policy = "Use the catalog Guardian policy.";
let model_messages = ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs
index c2a04bf531..f3e2970814 100644
--- a/codex-rs/core/src/session/tests/guardian_tests.rs
+++ b/codex-rs/core/src/session/tests/guardian_tests.rs
@@ -1051,6 +1051,12 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
#[tokio::test]
async fn process_compacted_history_preserves_separate_guardian_developer_message() {
let (session, mut turn_context) = make_session_and_context().await;
+ update_turn_settings_for_test(&mut turn_context, |settings| {
+ update_selected_settings_for_test(settings, |selected| {
+ selected.collaboration_mode.settings.reasoning_effort =
+ Some(ReasoningEffortConfig::Persistent);
+ });
+ });
let guardian_policy = "guardian policy".to_string();
let guardian_source =
SessionSource::SubAgent(SubAgentSource::Other(GUARDIAN_REVIEWER_NAME.to_string()));
@@ -1121,6 +1127,12 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message
.iter()
.any(|(message, _)| message.contains("stale developer message"))
);
+ assert!(
+ !developer_messages
+ .iter()
+ .any(|(message, _)| message.contains("")),
+ "guardian context must not inherit persistent-mode proactivity"
+ );
assert!(developer_messages.len() >= 2);
assert_eq!(
developer_messages.last(),
diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs
index ca21831c1e..ec1a382a78 100644
--- a/codex-rs/core/src/session/world_state.rs
+++ b/codex-rs/core/src/session/world_state.rs
@@ -17,6 +17,7 @@ use crate::context::world_state::ModelInstructionsState;
use crate::context::world_state::MultiAgentModeState;
use crate::context::world_state::MultiAgentUsageHintState;
use crate::context::world_state::PermissionsState;
+use crate::context::world_state::PersistentModeState;
use crate::context::world_state::PersonalityState;
use crate::context::world_state::PluginsInstructionsState;
use crate::context::world_state::RealtimeState;
@@ -189,6 +190,26 @@ impl Session {
.and_then(|messages| messages.collaboration_modes.as_ref()),
));
}
+ if !crate::guardian::is_basic_session_source(&turn_context.session_source) {
+ let send_user_message_async_available =
+ !turn_context.session_source.is_non_root_agent()
+ && step_context
+ .settings
+ .model_info
+ .experimental_supported_tools
+ .iter()
+ .any(|tool| tool == "send_user_message_async");
+ world_state.add_section(PersistentModeState::new(
+ step_context.settings.effective_reasoning_effort().as_ref(),
+ step_context
+ .settings
+ .model_info
+ .model_messages
+ .as_ref()
+ .and_then(|messages| messages.persistent_instructions.as_deref()),
+ send_user_message_async_available,
+ ));
+ }
if turn_context.config.include_environment_context {
let current_date = self
.services
diff --git a/codex-rs/core/templates/persistent_mode.md b/codex-rs/core/templates/persistent_mode.md
new file mode 100644
index 0000000000..7cfac234df
--- /dev/null
+++ b/codex-rs/core/templates/persistent_mode.md
@@ -0,0 +1,13 @@
+## Proactivity
+
+After you've completed the user task and delivered the final answer, if you are sampled again without a new user request, look for useful follow-ups that directly support the completed work. Favor closing a known open loop, establishing an awaited result, or verifying that a change took effect over inventing unrelated work. Use past user instructions and your knowledge of the user to prioritize follow-ups, not to infer new authorization.
+
+Avoid duplicate user-visible messages within a turn or across turns. For a simple greeting, thanks, or acknowledgment, one brief response or reaction is enough; do not send equivalent text through both `functions.send_user_message_async` and `final`. Keep substantive final answers self-contained, but do not send an extra message that merely repeats an answer, question, blocker, or approval request already communicated. Repeat one only when the user asks again, new information materially changes it, or a requested reminder or reply is due. Being sampled again or receiving environment-only context is not a new user request and does not itself warrant a message. Keep unanswered questions pending; continue useful authorized work that does not depend on the answer, or wait quietly.
+
+Before starting a follow-up, identify its scope, the outcome you want to establish, the evidence needed, and a stopping condition justified by the original task or external process. Once started, treat it as active ongoing work across sleeps and automatic continuations until the outcome is established, the user cancels or replaces it, it is no longer relevant, a relevant observation window ends, or progress genuinely requires user input or additional authorization. Bound a follow-up by its purpose, scope, and outcome, not an arbitrary number of checks. A pending, running, inconclusive, or unchanged result is not by itself completion. Never invent an early stopping point for monitoring the user explicitly asked to continue.
+
+Prefer an existing completion notification or product-provided wait mechanism. Otherwise, schedule the next useful check according to the expected rate of progress. Preserve a user-specified cadence; absent one, use short, proportionate waits, often 1–3 minutes for active near-term work, and back off when slower progress justifies it. Do not switch to a long idle sleep while a useful earlier check is still due. Keep the target, last known state, stopping condition, and next check in the available task/checkpoint state so the follow-up survives sleep and context resets. Continue quietly between meaningful changes, and surface the outcome, a genuine blocker, or anything that requires the user's attention.
+
+Make these updates feel like a natural continuation of the conversation. Lead with the useful finding, result, or decision; avoid announcing a "follow-up task," declaring "the follow-up is complete," narrating internal task bookkeeping, or adding unnecessary disclaimers about actions you are not taking.
+
+You may perform safe, non-mutating follow-ups that remain within the user-authorized scope. Persistence does not broaden that scope. For follow-ups or next actions that require new authority, materially expand scope, or make external state changes not already authorized, describe the proposed action{{ approval_request_channel }} and obtain approval before executing it.
diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs
index bed7e51574..8de8349d15 100644
--- a/codex-rs/core/tests/suite/catalog_permission_messages.rs
+++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs
@@ -31,6 +31,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul
let model_slug = "remote-catalog-permissions-model";
let mut model = model_info_from_slug(model_slug);
model.model_messages = Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs
index c7a2081f66..84f4ab82fb 100644
--- a/codex-rs/core/tests/suite/collaboration_instructions.rs
+++ b/codex-rs/core/tests/suite/collaboration_instructions.rs
@@ -75,6 +75,7 @@ fn model_with_collaboration_messages(
) -> codex_protocol::openai_models::ModelInfo {
let mut model = model_info_from_slug(slug);
let model_messages = model.model_messages.get_or_insert(ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs
index 7dd00b8122..38561b71a2 100644
--- a/codex-rs/core/tests/suite/models_cache_ttl.rs
+++ b/codex-rs/core/tests/suite/models_cache_ttl.rs
@@ -504,6 +504,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
default_service_tier: None,
upgrade: None,
model_messages: Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("base instructions".to_string()),
instructions_variables: None,
approvals: None,
diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs
index 18ae69d0ed..a4dea05346 100644
--- a/codex-rs/core/tests/suite/permissions_messages.rs
+++ b/codex-rs/core/tests/suite/permissions_messages.rs
@@ -47,6 +47,7 @@ fn model_with_approval_messages(
) -> codex_protocol::openai_models::ModelInfo {
let mut model = model_info_from_slug(slug);
model.model_messages = Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: Some(ApprovalMessages {
@@ -71,6 +72,7 @@ fn model_with_permission_messages(
) -> codex_protocol::openai_models::ModelInfo {
let mut model = model_info_from_slug(slug);
model.model_messages = Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -225,6 +227,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission
let model_slug = "catalog-non-on-request-approvals-model";
let mut model = model_info_from_slug(model_slug);
model.model_messages = Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: Some(approvals),
diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs
index 33d91ffd89..43d01c68cf 100644
--- a/codex-rs/core/tests/suite/personality.rs
+++ b/codex-rs/core/tests/suite/personality.rs
@@ -633,6 +633,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
default_service_tier: None,
upgrade: None,
model_messages: Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some(default_personality_message.to_string()),
@@ -759,6 +760,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
default_service_tier: None,
upgrade: None,
model_messages: Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("Base instructions\n{{ personality }}\n".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs
index 94ac7cb192..fe20970855 100644
--- a/codex-rs/core/tests/suite/remote_models.rs
+++ b/codex-rs/core/tests/suite/remote_models.rs
@@ -115,6 +115,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
let specific = ModelInfo {
display_name: "GPT 5.3 Codex".to_string(),
model_messages: Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("use specific prefix".to_string()),
instructions_variables: None,
approvals: None,
@@ -130,6 +131,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<(
let generic = ModelInfo {
display_name: "GPT 5.3".to_string(),
model_messages: Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("use generic prefix".to_string()),
instructions_variables: None,
approvals: None,
@@ -366,12 +368,15 @@ async fn remote_models_use_context_window_when_config_override_is_absent() -> Re
Ok(())
}
-#[test_case(ReasoningEffort::Custom("future".to_string()), "future"; "custom")]
-#[test_case(ReasoningEffort::Persistent, "disabled"; "persistent")]
+#[test_case(ReasoningEffort::Custom("future".to_string()), "future", Some("Catalog follow-up instructions."); "custom")]
+#[test_case(ReasoningEffort::Persistent, "disabled", None; "persistent")]
+#[test_case(ReasoningEffort::Persistent, "disabled", Some("Catalog follow-up instructions."); "persistent override")]
+#[test_case(ReasoningEffort::Persistent, "disabled", Some(""); "empty persistent override")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_models_long_model_slug_is_sent_with_supported_reasoning(
effort: ReasoningEffort,
expected_wire_effort: &str,
+ catalog_instructions: Option<&str>,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -379,6 +384,8 @@ async fn remote_models_long_model_slug_is_sent_with_supported_reasoning(
let server = MockServer::start().await;
let requested_model = "gpt-5.3-codex-test";
let prefix_model = "gpt-5.3-codex";
+ let base_instructions = "Keep the catalog base instructions.";
+ let developer_instructions = "Keep the configured developer instructions.";
let mut remote_model = test_remote_model_with_policy(
prefix_model,
ModelVisibility::List,
@@ -397,6 +404,18 @@ async fn remote_models_long_model_slug_is_sent_with_supported_reasoning(
},
];
remote_model.default_reasoning_summary = ReasoningSummary::Detailed;
+ remote_model.model_messages = Some(ModelMessages {
+ persistent_instructions: catalog_instructions.map(str::to_string),
+ instructions_template: Some(base_instructions.to_string()),
+ instructions_variables: None,
+ approvals: None,
+ collaboration_modes: None,
+ auto_review: None,
+ permissions: None,
+ multi_agent: None,
+ token_budget: None,
+ guardian_v2: None,
+ });
mount_models_once(
&server,
ModelsResponse {
@@ -415,6 +434,7 @@ async fn remote_models_long_model_slug_is_sent_with_supported_reasoning(
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
config.model = Some(requested_model.to_string());
+ config.developer_instructions = Some(developer_instructions.to_string());
})
.build_with_auto_env(&server)
.await?;
@@ -441,6 +461,34 @@ async fn remote_models_long_model_slug_is_sent_with_supported_reasoning(
assert_eq!(body["model"].as_str(), Some(requested_model));
assert_eq!(reasoning_effort, Some(expected_wire_effort));
assert_eq!(reasoning_summary, Some("detailed"));
+ assert_eq!(request.instructions_text(), base_instructions);
+ assert!(
+ request
+ .message_input_texts("developer")
+ .iter()
+ .any(|text| text.contains(developer_instructions))
+ );
+
+ let persistent_instructions = request
+ .message_input_texts("developer")
+ .into_iter()
+ .filter(|text| text.starts_with(""))
+ .collect::>();
+ if effort != ReasoningEffort::Persistent || catalog_instructions == Some("") {
+ assert!(persistent_instructions.is_empty());
+ } else if let Some(instructions) = catalog_instructions {
+ assert_eq!(
+ persistent_instructions,
+ vec![format!(
+ "\n{instructions}\n"
+ )]
+ );
+ } else {
+ assert_eq!(persistent_instructions.len(), 1);
+ assert!(persistent_instructions[0].starts_with(
+ "\n## Proactivity\n\nAfter you've completed the user task and delivered the final answer,"
+ ));
+ }
Ok(())
}
@@ -797,6 +845,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> {
default_service_tier: None,
upgrade: None,
model_messages: Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some(remote_instructions.to_string()),
instructions_variables: None,
approvals: None,
diff --git a/codex-rs/core/tests/suite/send_user_message_async.rs b/codex-rs/core/tests/suite/send_user_message_async.rs
index 11f78111f2..a610fd6efb 100644
--- a/codex-rs/core/tests/suite/send_user_message_async.rs
+++ b/codex-rs/core/tests/suite/send_user_message_async.rs
@@ -1,17 +1,23 @@
use anyhow::Result;
+use codex_core::StartThreadOptions;
use codex_core::TurnInputRequest;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageDelivery;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::MessagePhase;
+use codex_protocol::openai_models::ReasoningEffort;
+use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::EventMsg;
+use codex_protocol::protocol::SessionSource;
+use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
+use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -21,6 +27,88 @@ use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use pretty_assertions::assert_eq;
use serde_json::json;
+use test_case::test_case;
+
+#[test_case(SessionSource::Exec, false, false; "root_without_tool")]
+#[test_case(SessionSource::Exec, true, true; "root_with_tool")]
+#[test_case(SessionSource::SubAgent(SubAgentSource::Other("test".to_string())), true, false; "subagent")]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn persistent_async_message_guidance_follows_tool_availability(
+ session_source: SessionSource,
+ model_supports_tool: bool,
+ expect_tool_guidance: bool,
+) -> Result<()> {
+ skip_if_no_network!(Ok(()));
+
+ let server = start_mock_server().await;
+ let responses = mount_sse_once(
+ &server,
+ sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
+ )
+ .await;
+ let test = test_codex()
+ .with_model_info_override("gpt-5.2", move |model| {
+ model.tool_mode = Some(ToolMode::CodeModeOnly);
+ model
+ .experimental_supported_tools
+ .retain(|tool| tool != "send_user_message_async");
+ if model_supports_tool {
+ model
+ .experimental_supported_tools
+ .push("send_user_message_async".to_string());
+ }
+ model
+ .supported_reasoning_levels
+ .push(ReasoningEffortPreset {
+ effort: ReasoningEffort::Persistent,
+ description: ReasoningEffort::Persistent.to_string(),
+ });
+ if let Some(messages) = model.model_messages.as_mut() {
+ messages.persistent_instructions = None;
+ }
+ })
+ .with_config(|config| {
+ config.model_reasoning_effort = Some(ReasoningEffort::Persistent);
+ })
+ .build_with_auto_env(&server)
+ .await?;
+ let thread = test
+ .thread_manager
+ .start_thread(StartThreadOptions {
+ session_source: Some(session_source),
+ ..StartThreadOptions::new(test.config.clone())
+ })
+ .await?
+ .thread;
+ thread
+ .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
+ text: "Report progress.".to_string(),
+ text_elements: Vec::new(),
+ }]))
+ .await?;
+ wait_for_event(&thread, |event| matches!(event, EventMsg::TurnComplete(_))).await;
+
+ let request = responses.single_request();
+ let persistent_instructions = request
+ .message_input_texts("developer")
+ .into_iter()
+ .find(|text| text.starts_with(""))
+ .expect("persistent guidance should remain available to every agent");
+ assert_eq!(
+ persistent_instructions.contains("via functions.send_user_message_async"),
+ expect_tool_guidance,
+ );
+ assert_eq!(
+ request.body_json()["tools"]
+ .as_array()
+ .expect("request tools")
+ .iter()
+ .any(|tool| tool["name"] == "send_user_message_async"),
+ expect_tool_guidance,
+ );
+
+ Ok(())
+}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn send_user_message_async_emits_item_and_does_not_end_the_turn() -> Result<()> {
diff --git a/codex-rs/core/tests/suite/step_settings.rs b/codex-rs/core/tests/suite/step_settings.rs
index 8ab26c4bea..bdba8f664e 100644
--- a/codex-rs/core/tests/suite/step_settings.rs
+++ b/codex-rs/core/tests/suite/step_settings.rs
@@ -12,6 +12,7 @@ use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
+use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
@@ -730,6 +731,91 @@ async fn model_activation_uses_destination_metadata_defaults(
Ok(())
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn persistent_instructions_follow_mid_turn_model_changes() -> Result<()> {
+ skip_if_no_network!(Ok(()));
+
+ let server = start_mock_server().await;
+ let response_mock = mount_sse_sequence(
+ &server,
+ vec![
+ paused_response("resp-1", "pause-before-persistent-model-change"),
+ sse_completed("resp-2"),
+ ],
+ )
+ .await;
+ let test = step_settings_test()
+ .with_config(|config| {
+ config.model_reasoning_effort = Some(ReasoningEffort::Persistent);
+ for model in &mut config
+ .model_catalog
+ .as_mut()
+ .expect("controlled model catalog")
+ .models
+ {
+ model
+ .supported_reasoning_levels
+ .push(ReasoningEffortPreset {
+ effort: ReasoningEffort::Persistent,
+ description: ReasoningEffort::Persistent.to_string(),
+ });
+ model
+ .model_messages
+ .as_mut()
+ .expect("model instruction metadata")
+ .persistent_instructions =
+ Some(format!("Persistent instructions for {}.", model.slug));
+ }
+ })
+ .build_with_auto_env(&server)
+ .await?;
+ let paused = start_paused_turn(&test.codex).await?;
+ assert_eq!(
+ submit_turn_settings(
+ &test.codex,
+ &paused.turn_id,
+ TurnSettingsUpdate {
+ model: Some(MODEL_B.to_string()),
+ ..Default::default()
+ }
+ )
+ .await?,
+ TurnSettingsUpdateOutcome::Applied
+ );
+ answer_paused_turn(&test.codex, &paused.turn_id).await?;
+ wait_for_event(&test.codex, |event| match event {
+ EventMsg::Error(error) => panic!("model activation failed: {}", error.message),
+ EventMsg::TurnComplete(_) => true,
+ _ => false,
+ })
+ .await;
+
+ let initial =
+ format!("\nPersistent instructions for {MODEL_A}.\n");
+ let update = format!(
+ "\nThese persistent-mode instructions replace all previously provided persistent-mode instructions.\n\nPersistent instructions for {MODEL_B}.\n"
+ );
+ let requests = response_mock.requests();
+ assert_eq!(
+ requests
+ .iter()
+ .map(|request| {
+ let instructions = request
+ .message_input_texts("developer")
+ .into_iter()
+ .filter(|text| text.starts_with(""))
+ .collect::>();
+ json!({"model": request.body_json()["model"], "instructions": instructions})
+ })
+ .collect::>(),
+ vec![
+ json!({"model": MODEL_A, "instructions": [initial]}),
+ json!({"model": MODEL_B, "instructions": [initial, update]}),
+ ]
+ );
+ Ok(())
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn turn_settings_rejection_preserves_independent_future_settings() -> Result<()> {
skip_if_no_network!(Ok(()));
diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs
index 0f41823b31..2519f1f04b 100644
--- a/codex-rs/models-manager/src/model_info.rs
+++ b/codex-rs/models-manager/src/model_info.rs
@@ -51,6 +51,7 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig)
if let Some(base_instructions) = &config.base_instructions {
let model_messages = model.model_messages.get_or_insert(ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -188,6 +189,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo {
fn local_model_messages_for_slug(slug: &str) -> ModelMessages {
match slug {
"gpt-5.2-codex" | "exp-codex-personality" => ModelMessages {
+ persistent_instructions: None,
instructions_template: Some(format!(
"{DEFAULT_PERSONALITY_HEADER}\n\n{PERSONALITY_PLACEHOLDER}\n\n{BASE_INSTRUCTIONS}"
)),
@@ -205,6 +207,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages {
guardian_v2: None,
},
_ => ModelMessages {
+ persistent_instructions: None,
instructions_template: Some(BASE_INSTRUCTIONS.to_string()),
instructions_variables: None,
approvals: None,
diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs
index 4dd87e605d..202cdc1a53 100644
--- a/codex-rs/models-manager/src/model_info_tests.rs
+++ b/codex-rs/models-manager/src/model_info_tests.rs
@@ -23,6 +23,7 @@ fn config_with_personality(personality: Option) -> ModelsManagerCon
#[test]
fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
let override_instructions = "override {{ personality }}";
+ let persistent_instructions = "Follow up on the active task.";
let mut model = model_info_from_slug("unknown-model");
let approvals = ApprovalMessages {
on_request: Some("user approvals".to_string()),
@@ -67,6 +68,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
..Default::default()
};
model.model_messages = Some(ModelMessages {
+ persistent_instructions: Some(persistent_instructions.to_string()),
instructions_template: Some("template".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
@@ -91,6 +93,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() {
assert_eq!(
updated.model_messages,
Some(ModelMessages {
+ persistent_instructions: Some(persistent_instructions.to_string()),
instructions_template: Some(override_instructions.to_string()),
instructions_variables: None,
approvals: Some(approvals),
@@ -118,6 +121,7 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages()
unless_trusted: None,
};
model.model_messages = Some(ModelMessages {
+ persistent_instructions: Some(String::new()),
instructions_template: Some("before {{ personality }} after".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
@@ -142,6 +146,7 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages()
assert_eq!(
updated.model_messages,
Some(ModelMessages {
+ persistent_instructions: Some(String::new()),
instructions_template: Some("before default after".to_string()),
instructions_variables: None,
approvals: Some(approvals),
@@ -169,6 +174,7 @@ fn disabled_personality_uses_plain_base_instructions_for_local_personality_model
assert_eq!(
updated.model_messages,
Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some(BASE_INSTRUCTIONS.to_string()),
instructions_variables: None,
approvals: None,
@@ -210,6 +216,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() {
for (instructions, expected) in cases {
let mut model = model_info_from_slug("unknown-model");
model.model_messages = Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some(instructions.to_string()),
instructions_variables: None,
approvals: None,
diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs
index 55ac2153d1..158f972c89 100644
--- a/codex-rs/protocol/src/openai_models.rs
+++ b/codex-rs/protocol/src/openai_models.rs
@@ -532,6 +532,10 @@ impl ModelInfo {
/// When variables are present but incomplete, missing values render as empty strings.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelMessages {
+ /// Additional developer instructions for persistent mode. Missing or null uses the built-in
+ /// instructions; an empty string disables them.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub persistent_instructions: Option,
pub instructions_template: Option,
pub instructions_variables: Option,
pub approvals: Option,
@@ -761,6 +765,7 @@ where
.is_none()
{
let messages = model.model_messages.get_or_insert(ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -959,13 +964,15 @@ mod tests {
#[test]
fn model_messages_deserialize_without_optional_sections() {
- let messages: ModelMessages =
- from_str(r#"{"instructions_template":null,"instructions_variables":null}"#)
- .expect("model messages should deserialize");
+ let messages: ModelMessages = from_str(
+ r#"{"instructions_template":null,"instructions_variables":null,"persistent_instructions":null}"#,
+ )
+ .expect("model messages should deserialize");
assert_eq!(
messages,
ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -1111,6 +1118,7 @@ mod tests {
assert_eq!(
messages,
ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
@@ -1202,6 +1210,7 @@ mod tests {
#[test]
fn get_model_instructions_uses_template_when_placeholder_present() {
let model = test_model(Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("Hello {{ personality }}".to_string()),
instructions_variables: Some(personality_variables()),
approvals: None,
@@ -1221,6 +1230,7 @@ mod tests {
#[test]
fn get_model_instructions_strips_placeholder_with_incomplete_variables() {
let model = test_model(Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("Hello\n{{ personality }}".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
@@ -1245,6 +1255,7 @@ mod tests {
);
let model_no_personality = test_model(Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("Hello\n{{ personality }}".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
@@ -1280,6 +1291,7 @@ mod tests {
#[test]
fn get_model_instructions_is_empty_when_template_is_missing() {
let model = test_model(Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: None,
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
@@ -1325,6 +1337,7 @@ mod tests {
assert_eq!(
model.model_messages,
Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("legacy instructions".to_string()),
instructions_variables: None,
approvals: None,
@@ -1373,6 +1386,7 @@ mod tests {
fn models_response_serializes_rendered_legacy_base_instructions() {
let response = ModelsResponse {
models: vec![test_model(Some(ModelMessages {
+ persistent_instructions: None,
instructions_template: Some("before {{ personality }} after".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
@@ -1400,6 +1414,7 @@ mod tests {
#[test]
fn models_response_prefers_template_and_preserves_message_siblings() {
let messages = ModelMessages {
+ persistent_instructions: Some("Persistent catalog instructions".to_string()),
instructions_template: None,
instructions_variables: None,
approvals: Some(ApprovalMessages {
@@ -1450,6 +1465,7 @@ mod tests {
assert_eq!(response.models[0].model_messages, Some(expected_messages));
let canonical_messages = ModelMessages {
+ persistent_instructions: Some(String::new()),
instructions_template: Some("canonical instructions".to_string()),
instructions_variables: None,
approvals: None,