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
@@ -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,
+6 -2
View File
@@ -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 (
+22 -2
View File
@@ -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 = "<managed_developer_instructions>\nParent developer instructions.\n</managed_developer_instructions>";
let persistent_fragment =
"<persistent_mode>\nParent developer instructions.\n</persistent_mode>";
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 =
"<persistent_mode>\nParent persistent instructions.\n</persistent_mode>";
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"
@@ -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,
})]
);
}
}
@@ -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()
@@ -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: "<persistent_mode>\nFollow up on the completed task.\n</persistent_mode>"
.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()
+1
View File
@@ -42,6 +42,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
APPROVED_COMMAND_PREFIX_SAVED_MESSAGE_PREFIX,
"<model_switch>",
"<managed_developer_instructions>",
"<persistent_mode>",
APPS_INSTRUCTIONS_OPEN_TAG,
COLLABORATION_MODE_OPEN_TAG,
"<multi_agent_role>",
@@ -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,
@@ -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("<persistent_mode>")),
"guardian context must not inherit persistent-mode proactivity"
);
assert!(developer_messages.len() >= 2);
assert_eq!(
developer_messages.last(),
+21
View File
@@ -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
@@ -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.
@@ -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,
@@ -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,
@@ -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,
@@ -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),
+2
View File
@@ -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,
+51 -2
View File
@@ -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("<persistent_mode>"))
.collect::<Vec<_>>();
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!(
"<persistent_mode>\n{instructions}\n</persistent_mode>"
)]
);
} else {
assert_eq!(persistent_instructions.len(), 1);
assert!(persistent_instructions[0].starts_with(
"<persistent_mode>\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,
@@ -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("<persistent_mode>"))
.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<()> {
@@ -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!("<persistent_mode>\nPersistent instructions for {MODEL_A}.\n</persistent_mode>");
let update = format!(
"<persistent_mode>\nThese persistent-mode instructions replace all previously provided persistent-mode instructions.\n\nPersistent instructions for {MODEL_B}.\n</persistent_mode>"
);
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("<persistent_mode>"))
.collect::<Vec<_>>();
json!({"model": request.body_json()["model"], "instructions": instructions})
})
.collect::<Vec<_>>(),
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(()));
@@ -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,
@@ -23,6 +23,7 @@ fn config_with_personality(personality: Option<Personality>) -> 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,
+19 -3
View File
@@ -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<String>,
pub instructions_template: Option<String>,
pub instructions_variables: Option<ModelInstructionsVariables>,
pub approvals: Option<ApprovalMessages>,
@@ -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,