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 authored and copyberry committed 2026-08-27 04:58:21 +00:00
1 parent 72c96598c6
commit f1433fc71f
24 files changed
+565 -9

No files matched your search

@@ -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(()));