Skip to content

Commit d994802

Browse files
[codex] add developer instructions
1 parent f4f9695 commit d994802

File tree

11 files changed

+192
-4
lines changed

11 files changed

+192
-4
lines changed

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

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

324+
/// Developer instructions that will be sent as a `developer` role message.
325+
#[serde(skip_serializing_if = "Option::is_none")]
326+
pub developer_instructions: Option<String>,
327+
324328
/// Prompt used during conversation compaction.
325329
#[serde(skip_serializing_if = "Option::is_none")]
326330
pub compact_prompt: Option<String>,
@@ -1129,6 +1133,7 @@ mod tests {
11291133
sandbox: None,
11301134
config: None,
11311135
base_instructions: None,
1136+
developer_instructions: None,
11321137
compact_prompt: None,
11331138
include_apply_patch_tool: None,
11341139
},

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,6 +1760,7 @@ async fn derive_config_from_params(
17601760
sandbox: sandbox_mode,
17611761
config: cli_overrides,
17621762
base_instructions,
1763+
developer_instructions,
17631764
compact_prompt,
17641765
include_apply_patch_tool,
17651766
} = params;
@@ -1773,6 +1774,7 @@ async fn derive_config_from_params(
17731774
model_provider,
17741775
codex_linux_sandbox_exe,
17751776
base_instructions,
1777+
developer_instructions,
17761778
compact_prompt,
17771779
include_apply_patch_tool,
17781780
include_view_image_tool: None,

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

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

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

145147
let new_conv_id = mcp
146-
.send_new_conversation_request(NewConversationParams::default())
148+
.send_new_conversation_request(NewConversationParams {
149+
developer_instructions: Some("Use the test harness tools.".to_string()),
150+
..Default::default()
151+
})
147152
.await?;
148153
let new_conv_resp: JSONRPCResponse = timeout(
149154
DEFAULT_READ_TIMEOUT,
@@ -177,6 +182,9 @@ async fn test_send_message_raw_notifications_opt_in() -> Result<()> {
177182
})
178183
.await?;
179184

185+
let developer = read_raw_response_item(&mut mcp, conversation_id).await;
186+
assert_developer_message(&developer, "Use the test harness tools.");
187+
180188
let instructions = read_raw_response_item(&mut mcp, conversation_id).await;
181189
assert_instructions_message(&instructions);
182190

@@ -316,6 +324,21 @@ fn assert_instructions_message(item: &ResponseItem) {
316324
}
317325
}
318326

327+
fn assert_developer_message(item: &ResponseItem, expected_text: &str) {
328+
match item {
329+
ResponseItem::Message { role, content, .. } => {
330+
assert_eq!(role, "developer");
331+
let texts = content_texts(content);
332+
assert_eq!(
333+
texts,
334+
vec![expected_text],
335+
"expected developer instructions message, got {texts:?}"
336+
);
337+
}
338+
other => panic!("expected developer instructions message, got {other:?}"),
339+
}
340+
}
341+
319342
fn assert_environment_message(item: &ResponseItem) {
320343
match item {
321344
ResponseItem::Message { role, content, .. } => {

codex-rs/core/src/codex.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ use crate::tools::spec::ToolsConfig;
112112
use crate::tools::spec::ToolsConfigParams;
113113
use crate::turn_diff_tracker::TurnDiffTracker;
114114
use crate::unified_exec::UnifiedExecSessionManager;
115+
use crate::user_instructions::DeveloperInstructions;
115116
use crate::user_instructions::UserInstructions;
116117
use crate::user_notification::UserNotification;
117118
use crate::util::backoff;
@@ -171,6 +172,7 @@ impl Codex {
171172
model: config.model.clone(),
172173
model_reasoning_effort: config.model_reasoning_effort,
173174
model_reasoning_summary: config.model_reasoning_summary,
175+
developer_instructions: config.developer_instructions.clone(),
174176
user_instructions,
175177
base_instructions: config.base_instructions.clone(),
176178
compact_prompt: config.compact_prompt.clone(),
@@ -265,6 +267,7 @@ pub(crate) struct TurnContext {
265267
/// the model as well as sandbox policies are resolved against this path
266268
/// instead of `std::env::current_dir()`.
267269
pub(crate) cwd: PathBuf,
270+
pub(crate) developer_instructions: Option<String>,
268271
pub(crate) base_instructions: Option<String>,
269272
pub(crate) compact_prompt: Option<String>,
270273
pub(crate) user_instructions: Option<String>,
@@ -303,6 +306,9 @@ pub(crate) struct SessionConfiguration {
303306
model_reasoning_effort: Option<ReasoningEffortConfig>,
304307
model_reasoning_summary: ReasoningSummaryConfig,
305308

309+
/// Developer instructions that supplement the base instructions.
310+
developer_instructions: Option<String>,
311+
306312
/// Model instructions that are appended to the base instructions.
307313
user_instructions: Option<String>,
308314

@@ -417,6 +423,7 @@ impl Session {
417423
sub_id,
418424
client,
419425
cwd: session_configuration.cwd.clone(),
426+
developer_instructions: session_configuration.developer_instructions.clone(),
420427
base_instructions: session_configuration.base_instructions.clone(),
421428
compact_prompt: session_configuration.compact_prompt.clone(),
422429
user_instructions: session_configuration.user_instructions.clone(),
@@ -991,7 +998,10 @@ impl Session {
991998
}
992999

9931000
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
994-
let mut items = Vec::<ResponseItem>::with_capacity(2);
1001+
let mut items = Vec::<ResponseItem>::with_capacity(3);
1002+
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
1003+
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
1004+
}
9951005
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
9961006
items.push(UserInstructions::new(user_instructions.to_string()).into());
9971007
}
@@ -1674,6 +1684,7 @@ async fn spawn_review_thread(
16741684
sub_id: sub_id.to_string(),
16751685
client,
16761686
tools_config,
1687+
developer_instructions: None,
16771688
user_instructions: None,
16781689
base_instructions: Some(base_instructions.clone()),
16791690
compact_prompt: parent_turn_context.compact_prompt.clone(),
@@ -2511,6 +2522,7 @@ mod tests {
25112522
model: config.model.clone(),
25122523
model_reasoning_effort: config.model_reasoning_effort,
25132524
model_reasoning_summary: config.model_reasoning_summary,
2525+
developer_instructions: config.developer_instructions.clone(),
25142526
user_instructions: config.user_instructions.clone(),
25152527
base_instructions: config.base_instructions.clone(),
25162528
compact_prompt: config.compact_prompt.clone(),
@@ -2586,6 +2598,7 @@ mod tests {
25862598
model: config.model.clone(),
25872599
model_reasoning_effort: config.model_reasoning_effort,
25882600
model_reasoning_summary: config.model_reasoning_summary,
2601+
developer_instructions: config.developer_instructions.clone(),
25892602
user_instructions: config.user_instructions.clone(),
25902603
base_instructions: config.base_instructions.clone(),
25912604
compact_prompt: config.compact_prompt.clone(),

codex-rs/core/src/config/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ pub struct Config {
128128
/// Base instructions override.
129129
pub base_instructions: Option<String>,
130130

131+
/// Developer instructions override injected as a separate message.
132+
pub developer_instructions: Option<String>,
133+
131134
/// Compact prompt override.
132135
pub compact_prompt: Option<String>,
133136

@@ -543,6 +546,11 @@ pub struct ConfigToml {
543546

544547
/// System instructions.
545548
pub instructions: Option<String>,
549+
550+
/// Developer instructions inserted as a `developer` role message.
551+
#[serde(default)]
552+
pub developer_instructions: Option<String>,
553+
546554
/// Compact prompt used for history compaction.
547555
pub compact_prompt: Option<String>,
548556

@@ -830,6 +838,7 @@ pub struct ConfigOverrides {
830838
pub config_profile: Option<String>,
831839
pub codex_linux_sandbox_exe: Option<PathBuf>,
832840
pub base_instructions: Option<String>,
841+
pub developer_instructions: Option<String>,
833842
pub compact_prompt: Option<String>,
834843
pub include_apply_patch_tool: Option<bool>,
835844
pub include_view_image_tool: Option<bool>,
@@ -861,6 +870,7 @@ impl Config {
861870
config_profile: config_profile_key,
862871
codex_linux_sandbox_exe,
863872
base_instructions,
873+
developer_instructions,
864874
compact_prompt,
865875
include_apply_patch_tool: include_apply_patch_tool_override,
866876
include_view_image_tool: include_view_image_tool_override,
@@ -1060,6 +1070,7 @@ impl Config {
10601070
"experimental instructions file",
10611071
)?;
10621072
let base_instructions = base_instructions.or(file_base_instructions);
1073+
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
10631074

10641075
let experimental_compact_prompt_path = config_profile
10651076
.experimental_compact_prompt_file
@@ -1095,6 +1106,7 @@ impl Config {
10951106
notify: cfg.notify,
10961107
user_instructions,
10971108
base_instructions,
1109+
developer_instructions,
10981110
compact_prompt,
10991111
// The config.toml omits "_mode" because it's a config file. However, "_mode"
11001112
// is important in code to differentiate the mode from the store implementation.
@@ -2886,6 +2898,7 @@ model_verbosity = "high"
28862898
model_verbosity: None,
28872899
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
28882900
base_instructions: None,
2901+
developer_instructions: None,
28892902
compact_prompt: None,
28902903
forced_chatgpt_workspace_id: None,
28912904
forced_login_method: None,
@@ -2958,6 +2971,7 @@ model_verbosity = "high"
29582971
model_verbosity: None,
29592972
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
29602973
base_instructions: None,
2974+
developer_instructions: None,
29612975
compact_prompt: None,
29622976
forced_chatgpt_workspace_id: None,
29632977
forced_login_method: None,
@@ -3045,6 +3059,7 @@ model_verbosity = "high"
30453059
model_verbosity: None,
30463060
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
30473061
base_instructions: None,
3062+
developer_instructions: None,
30483063
compact_prompt: None,
30493064
forced_chatgpt_workspace_id: None,
30503065
forced_login_method: None,
@@ -3118,6 +3133,7 @@ model_verbosity = "high"
31183133
model_verbosity: Some(Verbosity::High),
31193134
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
31203135
base_instructions: None,
3136+
developer_instructions: None,
31213137
compact_prompt: None,
31223138
forced_chatgpt_workspace_id: None,
31233139
forced_login_method: None,

codex-rs/core/src/user_instructions.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,31 @@ impl From<UserInstructions> for ResponseItem {
4040
}
4141
}
4242
}
43+
44+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45+
#[serde(rename = "developer_instructions", rename_all = "snake_case")]
46+
pub(crate) struct DeveloperInstructions {
47+
text: String,
48+
}
49+
50+
impl DeveloperInstructions {
51+
pub fn new<T: Into<String>>(text: T) -> Self {
52+
Self { text: text.into() }
53+
}
54+
55+
pub fn into_text(self) -> String {
56+
self.text
57+
}
58+
}
59+
60+
impl From<DeveloperInstructions> for ResponseItem {
61+
fn from(di: DeveloperInstructions) -> Self {
62+
ResponseItem::Message {
63+
id: None,
64+
role: "developer".to_string(),
65+
content: vec![ContentItem::InputText {
66+
text: di.into_text(),
67+
}],
68+
}
69+
}
70+
}

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ fn assert_message_role(request_body: &serde_json::Value, role: &str) {
5858
assert_eq!(request_body["role"].as_str().unwrap(), role);
5959
}
6060

61+
#[expect(clippy::expect_used)]
62+
fn assert_message_equals(request_body: &serde_json::Value, text: &str) {
63+
let content = request_body["content"][0]["text"]
64+
.as_str()
65+
.expect("invalid message content");
66+
67+
assert_eq!(
68+
content, text,
69+
"expected message content '{content}' to equal '{text}'"
70+
);
71+
}
72+
6173
#[expect(clippy::expect_used)]
6274
fn assert_message_starts_with(request_body: &serde_json::Value, text: &str) {
6375
let content = request_body["content"][0]["text"]
@@ -608,6 +620,64 @@ async fn includes_user_instructions_message_in_request() {
608620
assert_message_ends_with(&request_body["input"][1], "</environment_context>");
609621
}
610622

623+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
624+
async fn includes_developer_instructions_message_in_request() {
625+
skip_if_no_network!();
626+
let server = MockServer::start().await;
627+
628+
let resp_mock =
629+
responses::mount_sse_once_match(&server, path("/v1/responses"), sse_completed("resp1"))
630+
.await;
631+
632+
let model_provider = ModelProviderInfo {
633+
base_url: Some(format!("{}/v1", server.uri())),
634+
..built_in_model_providers()["openai"].clone()
635+
};
636+
637+
let codex_home = TempDir::new().unwrap();
638+
let mut config = load_default_config_for_test(&codex_home);
639+
config.model_provider = model_provider;
640+
config.user_instructions = Some("be nice".to_string());
641+
config.developer_instructions = Some("be useful".to_string());
642+
643+
let conversation_manager =
644+
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
645+
let codex = conversation_manager
646+
.new_conversation(config)
647+
.await
648+
.expect("create new conversation")
649+
.conversation;
650+
651+
codex
652+
.submit(Op::UserInput {
653+
items: vec![UserInput::Text {
654+
text: "hello".into(),
655+
}],
656+
})
657+
.await
658+
.unwrap();
659+
660+
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
661+
662+
let request = resp_mock.single_request();
663+
let request_body = request.body_json();
664+
665+
assert!(
666+
!request_body["instructions"]
667+
.as_str()
668+
.unwrap()
669+
.contains("be nice")
670+
);
671+
assert_message_role(&request_body["input"][0], "developer");
672+
assert_message_equals(&request_body["input"][0], "be useful");
673+
assert_message_role(&request_body["input"][1], "user");
674+
assert_message_starts_with(&request_body["input"][1], "<user_instructions>");
675+
assert_message_ends_with(&request_body["input"][1], "</user_instructions>");
676+
assert_message_role(&request_body["input"][2], "user");
677+
assert_message_starts_with(&request_body["input"][2], "<environment_context>");
678+
assert_message_ends_with(&request_body["input"][2], "</environment_context>");
679+
}
680+
611681
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
612682
async fn azure_responses_request_includes_store_and_reasoning_ids() {
613683
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
compact_prompt: None,
178179
include_apply_patch_tool: None,
179180
include_view_image_tool: None,

0 commit comments

Comments
 (0)