Skip to content

Commit ae4d2a1

Browse files
[codex] add developer instructions
1 parent 1d76ba5 commit ae4d2a1

File tree

12 files changed

+190
-6
lines changed

12 files changed

+190
-6
lines changed

codex-rs/app-server-protocol/src/protocol.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,10 @@ pub struct NewConversationParams {
317317
#[serde(skip_serializing_if = "Option::is_none")]
318318
pub base_instructions: Option<String>,
319319

320+
/// Developer instructions that will be sent as a `developer` role message.
321+
#[serde(skip_serializing_if = "Option::is_none")]
322+
pub developer_instructions: Option<String>,
323+
320324
/// Whether to include the apply patch tool in the conversation.
321325
#[serde(skip_serializing_if = "Option::is_none")]
322326
pub include_apply_patch_tool: Option<bool>,
@@ -1091,6 +1095,7 @@ mod tests {
10911095
sandbox: None,
10921096
config: None,
10931097
base_instructions: None,
1098+
developer_instructions: None,
10941099
include_apply_patch_tool: None,
10951100
},
10961101
};

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,6 +1716,7 @@ async fn derive_config_from_params(
17161716
sandbox: sandbox_mode,
17171717
config: cli_overrides,
17181718
base_instructions,
1719+
developer_instructions,
17191720
include_apply_patch_tool,
17201721
} = params;
17211722
let overrides = ConfigOverrides {
@@ -1728,6 +1729,7 @@ async fn derive_config_from_params(
17281729
model_provider,
17291730
codex_linux_sandbox_exe,
17301731
base_instructions,
1732+
developer_instructions,
17311733
include_apply_patch_tool,
17321734
include_view_image_tool: None,
17331735
show_raw_agent_reasoning: None,

codex-rs/app-server/tests/suite/send_message.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ async fn test_send_message_success() -> Result<()> {
4343

4444
// Start a conversation using the new wire API.
4545
let new_conv_id = mcp
46-
.send_new_conversation_request(NewConversationParams::default())
46+
.send_new_conversation_request(NewConversationParams {
47+
..Default::default()
48+
})
4749
.await?;
4850
let new_conv_resp: JSONRPCResponse = timeout(
4951
DEFAULT_READ_TIMEOUT,
@@ -142,7 +144,10 @@ async fn test_send_message_raw_notifications_opt_in() -> Result<()> {
142144
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
143145

144146
let new_conv_id = mcp
145-
.send_new_conversation_request(NewConversationParams::default())
147+
.send_new_conversation_request(NewConversationParams {
148+
developer_instructions: Some("Use the test harness tools.".to_string()),
149+
..Default::default()
150+
})
146151
.await?;
147152
let new_conv_resp: JSONRPCResponse = timeout(
148153
DEFAULT_READ_TIMEOUT,
@@ -176,6 +181,9 @@ async fn test_send_message_raw_notifications_opt_in() -> Result<()> {
176181
})
177182
.await?;
178183

184+
let developer = read_raw_response_item(&mut mcp, conversation_id).await;
185+
assert_developer_message(&developer);
186+
179187
let instructions = read_raw_response_item(&mut mcp, conversation_id).await;
180188
assert_instructions_message(&instructions);
181189

@@ -313,6 +321,22 @@ fn assert_instructions_message(item: &ResponseItem) {
313321
}
314322
}
315323

324+
fn assert_developer_message(item: &ResponseItem) {
325+
match item {
326+
ResponseItem::Message { role, content, .. } => {
327+
assert_eq!(role, "developer");
328+
let texts = content_texts(content);
329+
assert!(
330+
texts
331+
.iter()
332+
.any(|text| text.contains("<developer_instructions>")),
333+
"expected developer instructions message, got {texts:?}"
334+
);
335+
}
336+
other => panic!("expected developer instructions message, got {other:?}"),
337+
}
338+
}
339+
316340
fn assert_environment_message(item: &ResponseItem) {
317341
match item {
318342
ResponseItem::Message { role, content, .. } => {

codex-rs/core/src/codex.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ use crate::tools::spec::ToolsConfig;
114114
use crate::tools::spec::ToolsConfigParams;
115115
use crate::turn_diff_tracker::TurnDiffTracker;
116116
use crate::unified_exec::UnifiedExecSessionManager;
117+
use crate::user_instructions::DeveloperInstructions;
117118
use crate::user_instructions::UserInstructions;
118119
use crate::user_notification::UserNotification;
119120
use crate::util::backoff;
@@ -173,6 +174,7 @@ impl Codex {
173174
model: config.model.clone(),
174175
model_reasoning_effort: config.model_reasoning_effort,
175176
model_reasoning_summary: config.model_reasoning_summary,
177+
developer_instructions: config.developer_instructions.clone(),
176178
user_instructions,
177179
base_instructions: config.base_instructions.clone(),
178180
approval_policy: config.approval_policy,
@@ -264,6 +266,7 @@ pub(crate) struct TurnContext {
264266
/// the model as well as sandbox policies are resolved against this path
265267
/// instead of `std::env::current_dir()`.
266268
pub(crate) cwd: PathBuf,
269+
pub(crate) developer_instructions: Option<String>,
267270
pub(crate) base_instructions: Option<String>,
268271
pub(crate) user_instructions: Option<String>,
269272
pub(crate) approval_policy: AskForApproval,
@@ -295,6 +298,9 @@ pub(crate) struct SessionConfiguration {
295298
model_reasoning_effort: Option<ReasoningEffortConfig>,
296299
model_reasoning_summary: ReasoningSummaryConfig,
297300

301+
/// Developer instructions that supplement the base instructions.
302+
developer_instructions: Option<String>,
303+
298304
/// Model instructions that are appended to the base instructions.
299305
user_instructions: Option<String>,
300306

@@ -403,6 +409,7 @@ impl Session {
403409
sub_id,
404410
client,
405411
cwd: session_configuration.cwd.clone(),
412+
developer_instructions: session_configuration.developer_instructions.clone(),
406413
base_instructions: session_configuration.base_instructions.clone(),
407414
user_instructions: session_configuration.user_instructions.clone(),
408415
approval_policy: session_configuration.approval_policy,
@@ -976,7 +983,10 @@ impl Session {
976983
}
977984

978985
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
979-
let mut items = Vec::<ResponseItem>::with_capacity(2);
986+
let mut items = Vec::<ResponseItem>::with_capacity(3);
987+
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
988+
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
989+
}
980990
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
981991
items.push(UserInstructions::new(user_instructions.to_string()).into());
982992
}
@@ -1634,6 +1644,7 @@ async fn spawn_review_thread(
16341644
sub_id: sub_id.to_string(),
16351645
client,
16361646
tools_config,
1647+
developer_instructions: None,
16371648
user_instructions: None,
16381649
base_instructions: Some(base_instructions.clone()),
16391650
approval_policy: parent_turn_context.approval_policy,
@@ -2612,6 +2623,7 @@ mod tests {
26122623
model: config.model.clone(),
26132624
model_reasoning_effort: config.model_reasoning_effort,
26142625
model_reasoning_summary: config.model_reasoning_summary,
2626+
developer_instructions: config.developer_instructions.clone(),
26152627
user_instructions: config.user_instructions.clone(),
26162628
base_instructions: config.base_instructions.clone(),
26172629
approval_policy: config.approval_policy,
@@ -2685,6 +2697,7 @@ mod tests {
26852697
model: config.model.clone(),
26862698
model_reasoning_effort: config.model_reasoning_effort,
26872699
model_reasoning_summary: config.model_reasoning_summary,
2700+
developer_instructions: config.developer_instructions.clone(),
26882701
user_instructions: config.user_instructions.clone(),
26892702
base_instructions: config.base_instructions.clone(),
26902703
approval_policy: config.approval_policy,

codex-rs/core/src/config.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ pub struct Config {
130130
/// Base instructions override.
131131
pub base_instructions: Option<String>,
132132

133+
/// Developer instructions override injected as a separate message.
134+
pub developer_instructions: Option<String>,
135+
133136
/// Optional external notifier command. When set, Codex will spawn this
134137
/// program after each completed *turn* (i.e. when the agent finishes
135138
/// processing a user submission). The value must be the full command
@@ -869,8 +872,9 @@ pub struct ConfigToml {
869872
#[serde(default)]
870873
pub notify: Option<Vec<String>>,
871874

872-
/// System instructions.
873-
pub instructions: Option<String>,
875+
/// Developer instructions inserted as a `developer` role message.
876+
#[serde(default, alias = "instructions")]
877+
pub developer_instructions: Option<String>,
874878

875879
/// When set, restricts ChatGPT login to a specific workspace identifier.
876880
#[serde(default)]
@@ -1155,6 +1159,7 @@ pub struct ConfigOverrides {
11551159
pub config_profile: Option<String>,
11561160
pub codex_linux_sandbox_exe: Option<PathBuf>,
11571161
pub base_instructions: Option<String>,
1162+
pub developer_instructions: Option<String>,
11581163
pub include_apply_patch_tool: Option<bool>,
11591164
pub include_view_image_tool: Option<bool>,
11601165
pub show_raw_agent_reasoning: Option<bool>,
@@ -1185,6 +1190,7 @@ impl Config {
11851190
config_profile: config_profile_key,
11861191
codex_linux_sandbox_exe,
11871192
base_instructions,
1193+
developer_instructions,
11881194
include_apply_patch_tool: include_apply_patch_tool_override,
11891195
include_view_image_tool: include_view_image_tool_override,
11901196
show_raw_agent_reasoning,
@@ -1371,6 +1377,7 @@ impl Config {
13711377
let file_base_instructions =
13721378
Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
13731379
let base_instructions = base_instructions.or(file_base_instructions);
1380+
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
13741381

13751382
// Default review model when not set in config; allow CLI override to take precedence.
13761383
let review_model = override_review_model
@@ -1395,6 +1402,7 @@ impl Config {
13951402
notify: cfg.notify,
13961403
user_instructions,
13971404
base_instructions,
1405+
developer_instructions,
13981406
// The config.toml omits "_mode" because it's a config file. However, "_mode"
13991407
// is important in code to differentiate the mode from the store implementation.
14001408
cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(),
@@ -3100,6 +3108,7 @@ model_verbosity = "high"
31003108
model_verbosity: None,
31013109
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
31023110
base_instructions: None,
3111+
developer_instructions: None,
31033112
forced_chatgpt_workspace_id: None,
31043113
forced_login_method: None,
31053114
include_apply_patch_tool: false,
@@ -3171,6 +3180,7 @@ model_verbosity = "high"
31713180
model_verbosity: None,
31723181
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
31733182
base_instructions: None,
3183+
developer_instructions: None,
31743184
forced_chatgpt_workspace_id: None,
31753185
forced_login_method: None,
31763186
include_apply_patch_tool: false,
@@ -3257,6 +3267,7 @@ model_verbosity = "high"
32573267
model_verbosity: None,
32583268
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
32593269
base_instructions: None,
3270+
developer_instructions: None,
32603271
forced_chatgpt_workspace_id: None,
32613272
forced_login_method: None,
32623273
include_apply_patch_tool: false,
@@ -3329,6 +3340,7 @@ model_verbosity = "high"
33293340
model_verbosity: Some(Verbosity::High),
33303341
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
33313342
base_instructions: None,
3343+
developer_instructions: None,
33323344
forced_chatgpt_workspace_id: None,
33333345
forced_login_method: None,
33343346
include_apply_patch_tool: false,

codex-rs/core/src/user_instructions.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use serde::Serialize;
33

44
use codex_protocol::models::ContentItem;
55
use codex_protocol::models::ResponseItem;
6+
use codex_protocol::protocol::DEVELOPER_INSTRUCTIONS_CLOSE_TAG;
7+
use codex_protocol::protocol::DEVELOPER_INSTRUCTIONS_OPEN_TAG;
68
use codex_protocol::protocol::USER_INSTRUCTIONS_CLOSE_TAG;
79
use codex_protocol::protocol::USER_INSTRUCTIONS_OPEN_TAG;
810

@@ -40,3 +42,34 @@ impl From<UserInstructions> for ResponseItem {
4042
}
4143
}
4244
}
45+
46+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47+
#[serde(rename = "developer_instructions", rename_all = "snake_case")]
48+
pub(crate) struct DeveloperInstructions {
49+
text: String,
50+
}
51+
52+
impl DeveloperInstructions {
53+
pub fn new<T: Into<String>>(text: T) -> Self {
54+
Self { text: text.into() }
55+
}
56+
57+
pub fn serialize_to_xml(self) -> String {
58+
format!(
59+
"{DEVELOPER_INSTRUCTIONS_OPEN_TAG}\n\n{}\n\n{DEVELOPER_INSTRUCTIONS_CLOSE_TAG}",
60+
self.text
61+
)
62+
}
63+
}
64+
65+
impl From<DeveloperInstructions> for ResponseItem {
66+
fn from(di: DeveloperInstructions) -> Self {
67+
ResponseItem::Message {
68+
id: None,
69+
role: "developer".to_string(),
70+
content: vec![ContentItem::InputText {
71+
text: di.serialize_to_xml(),
72+
}],
73+
}
74+
}
75+
}

codex-rs/core/tests/suite/client.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,65 @@ async fn includes_user_instructions_message_in_request() {
608608
assert_message_ends_with(&request_body["input"][1], "</environment_context>");
609609
}
610610

611+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
612+
async fn includes_developer_instructions_message_in_request() {
613+
skip_if_no_network!();
614+
let server = MockServer::start().await;
615+
616+
let resp_mock =
617+
responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1"))
618+
.await;
619+
620+
let model_provider = ModelProviderInfo {
621+
base_url: Some(format!("{}/v1", server.uri())),
622+
..built_in_model_providers()["openai"].clone()
623+
};
624+
625+
let codex_home = TempDir::new().unwrap();
626+
let mut config = load_default_config_for_test(&codex_home);
627+
config.model_provider = model_provider;
628+
config.user_instructions = Some("be nice".to_string());
629+
config.developer_instructions = Some("be useful".to_string());
630+
631+
let conversation_manager =
632+
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
633+
let codex = conversation_manager
634+
.new_conversation(config)
635+
.await
636+
.expect("create new conversation")
637+
.conversation;
638+
639+
codex
640+
.submit(Op::UserInput {
641+
items: vec![UserInput::Text {
642+
text: "hello".into(),
643+
}],
644+
})
645+
.await
646+
.unwrap();
647+
648+
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
649+
650+
let request = resp_mock.single_request();
651+
let request_body = request.body_json();
652+
653+
assert!(
654+
!request_body["instructions"]
655+
.as_str()
656+
.unwrap()
657+
.contains("be nice")
658+
);
659+
assert_message_role(&request_body["input"][0], "developer");
660+
assert_message_starts_with(&request_body["input"][0], "<developer_instructions>");
661+
assert_message_ends_with(&request_body["input"][0], "</developer_instructions>");
662+
assert_message_role(&request_body["input"][1], "user");
663+
assert_message_starts_with(&request_body["input"][1], "<user_instructions>");
664+
assert_message_ends_with(&request_body["input"][1], "</user_instructions>");
665+
assert_message_role(&request_body["input"][2], "user");
666+
assert_message_starts_with(&request_body["input"][2], "<environment_context>");
667+
assert_message_ends_with(&request_body["input"][2], "</environment_context>");
668+
}
669+
611670
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
612671
async fn azure_responses_request_includes_store_and_reasoning_ids() {
613672
skip_if_no_network!();

codex-rs/exec/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
174174
model_provider,
175175
codex_linux_sandbox_exe,
176176
base_instructions: None,
177+
developer_instructions: None,
177178
include_apply_patch_tool: None,
178179
include_view_image_tool: None,
179180
show_raw_agent_reasoning: oss.then_some(true),

0 commit comments

Comments
 (0)