Skip to content

Commit 61b881d

Browse files
authored
fix: agent instructions were not being included when ~/.codex/instructions.md was empty (#908)
I had seen issues where `codex-rs` would not always write files without me pressuring it to do so, and between that and the report of #900, I decided to look into this further. I found two serious issues with agent instructions: (1) We were only sending agent instructions on the first turn, but looking at the TypeScript code, we should be sending them on every turn. (2) There was a serious issue where the agent instructions were frequently lost: * The TypeScript CLI appears to keep writing `~/.codex/instructions.md`: https://github.com/openai/codex/blob/55142e3e6caddd1e613b71bcb89385ce5cc708bf/codex-cli/src/utils/config.ts#L586 * If `instructions.md` is present, the Rust CLI uses the contents of it INSTEAD OF the default prompt, even if `instructions.md` is empty: https://github.com/openai/codex/blob/55142e3e6caddd1e613b71bcb89385ce5cc708bf/codex-rs/core/src/config.rs#L202-L203 The combination of these two things means that I have been using `codex-rs` without these key instructions: https://github.com/openai/codex/blob/main/codex-rs/core/prompt.md Looking at the TypeScript code, it appears we should be concatenating these three items every time (if they exist): * `prompt.md` * `~/.codex/instructions.md` * nearest `AGENTS.md` This PR fixes things so that: * `Config.instructions` is `None` if `instructions.md` is empty * `Payload.instructions` is now `&'a str` instead of `Option<&'a String>` because we should always have _something_ to send * `Prompt` now has a `get_full_instructions()` helper that returns a `Cow<str>` that will always include the agent instructions first.
1 parent 55142e3 commit 61b881d

File tree

4 files changed

+34
-17
lines changed

4 files changed

+34
-17
lines changed

codex-rs/core/src/chat_completions.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,8 @@ pub(crate) async fn stream_chat_completions(
3838
// Build messages array
3939
let mut messages = Vec::<serde_json::Value>::new();
4040

41-
if let Some(instr) = &prompt.instructions {
42-
messages.push(json!({"role": "system", "content": instr}));
43-
}
41+
let full_instructions = prompt.get_full_instructions();
42+
messages.push(json!({"role": "system", "content": full_instructions}));
4443

4544
for item in &prompt.input {
4645
if let ResponseItem::Message { role, content } = item {

codex-rs/core/src/client.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,10 @@ impl ModelClient {
166166

167167
debug!("tools_json: {}", serde_json::to_string_pretty(&tools_json)?);
168168

169+
let full_instructions = prompt.get_full_instructions();
169170
let payload = Payload {
170171
model: &self.model,
171-
instructions: prompt.instructions.as_ref(),
172+
instructions: &full_instructions,
172173
input: &prompt.input,
173174
tools: &tools_json,
174175
tool_choice: "auto",

codex-rs/core/src/client_common.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,26 @@ use crate::error::Result;
22
use crate::models::ResponseItem;
33
use futures::Stream;
44
use serde::Serialize;
5+
use std::borrow::Cow;
56
use std::collections::HashMap;
67
use std::pin::Pin;
78
use std::task::Context;
89
use std::task::Poll;
910
use tokio::sync::mpsc;
1011

12+
/// The `instructions` field in the payload sent to a model should always start
13+
/// with this content.
14+
const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md");
15+
1116
/// API request payload for a single model turn.
1217
#[derive(Default, Debug, Clone)]
1318
pub struct Prompt {
1419
/// Conversation context input items.
1520
pub input: Vec<ResponseItem>,
1621
/// Optional previous response ID (when storage is enabled).
1722
pub prev_id: Option<String>,
18-
/// Optional initial instructions (only sent on first turn).
23+
/// Optional instructions from the user to amend to the built-in agent
24+
/// instructions.
1925
pub instructions: Option<String>,
2026
/// Whether to store response on server side (disable_response_storage = !store).
2127
pub store: bool,
@@ -26,6 +32,18 @@ pub struct Prompt {
2632
pub extra_tools: HashMap<String, mcp_types::Tool>,
2733
}
2834

35+
impl Prompt {
36+
pub(crate) fn get_full_instructions(&self) -> Cow<str> {
37+
match &self.instructions {
38+
Some(instructions) => {
39+
let instructions = format!("{BASE_INSTRUCTIONS}\n{instructions}");
40+
Cow::Owned(instructions)
41+
}
42+
None => Cow::Borrowed(BASE_INSTRUCTIONS),
43+
}
44+
}
45+
}
46+
2947
#[derive(Debug)]
3048
pub enum ResponseEvent {
3149
OutputItemDone(ResponseItem),
@@ -54,8 +72,7 @@ pub(crate) enum Summary {
5472
#[derive(Debug, Serialize)]
5573
pub(crate) struct Payload<'a> {
5674
pub(crate) model: &'a str,
57-
#[serde(skip_serializing_if = "Option::is_none")]
58-
pub(crate) instructions: Option<&'a String>,
75+
pub(crate) instructions: &'a str,
5976
// TODO(mbolin): ResponseItem::Other should not be serialized. Currently,
6077
// we code defensively to avoid this case, but perhaps we should use a
6178
// separate enum for serialization.

codex-rs/core/src/config.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ use serde::Deserialize;
1010
use std::collections::HashMap;
1111
use std::path::PathBuf;
1212

13-
/// Embedded fallback instructions that mirror the TypeScript CLI’s default
14-
/// system prompt. These are compiled into the binary so a clean install behaves
15-
/// correctly even if the user has not created `~/.codex/instructions.md`.
16-
const EMBEDDED_INSTRUCTIONS: &str = include_str!("../prompt.md");
17-
1813
/// Maximum number of bytes of the documentation that will be embedded. Larger
1914
/// files are *silently truncated* to this size so we do not take up too much of
2015
/// the context window.
@@ -42,7 +37,7 @@ pub struct Config {
4237
/// who have opted into Zero Data Retention (ZDR).
4338
pub disable_response_storage: bool,
4439

45-
/// System instructions.
40+
/// User-provided instructions from instructions.md.
4641
pub instructions: Option<String>,
4742

4843
/// Optional external notifier command. When set, Codex will spawn this
@@ -198,9 +193,7 @@ impl Config {
198193
cfg: ConfigToml,
199194
overrides: ConfigOverrides,
200195
) -> std::io::Result<Self> {
201-
// Instructions: user-provided instructions.md > embedded default.
202-
let instructions =
203-
Self::load_instructions().or_else(|| Some(EMBEDDED_INSTRUCTIONS.to_string()));
196+
let instructions = Self::load_instructions();
204197

205198
// Destructure ConfigOverrides fully to ensure all overrides are applied.
206199
let ConfigOverrides {
@@ -289,7 +282,14 @@ impl Config {
289282
fn load_instructions() -> Option<String> {
290283
let mut p = codex_dir().ok()?;
291284
p.push("instructions.md");
292-
std::fs::read_to_string(&p).ok()
285+
std::fs::read_to_string(&p).ok().and_then(|s| {
286+
let s = s.trim();
287+
if s.is_empty() {
288+
None
289+
} else {
290+
Some(s.to_string())
291+
}
292+
})
293293
}
294294

295295
/// Meant to be used exclusively for tests: `load_with_overrides()` should

0 commit comments

Comments
 (0)