mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +00:00
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:
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
Reference in new issue
Block a user