Skip to content

Commit 2a17549

Browse files
committed
fix: preserve project instruction wrappers
Co-authored-by: Codex noreply@openai.com
1 parent 76709d3 commit 2a17549

2 files changed

Lines changed: 54 additions & 1 deletion

File tree

codex-rs/instructions/src/user_instructions.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ use crate::fragment::AGENTS_MD_START_MARKER;
88
use crate::fragment::SKILL_FRAGMENT;
99

1010
pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER;
11+
const INSTRUCTIONS_CLOSE_TAG: &str = "</INSTRUCTIONS>";
12+
const ESCAPED_INSTRUCTIONS_CLOSE_TAG: &str = "<\\/INSTRUCTIONS>";
1113

1214
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1315
#[serde(rename = "user_instructions", rename_all = "snake_case")]
@@ -18,16 +20,45 @@ pub struct UserInstructions {
1820

1921
impl UserInstructions {
2022
pub fn serialize_to_text(&self) -> String {
23+
let contents = escape_reserved_instruction_delimiters(&self.text);
2124
format!(
2225
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
2326
prefix = AGENTS_MD_FRAGMENT.start_marker(),
2427
directory = self.directory,
25-
contents = self.text,
28+
contents = contents,
2629
suffix = AGENTS_MD_FRAGMENT.end_marker(),
2730
)
2831
}
2932
}
3033

34+
fn escape_reserved_instruction_delimiters(text: &str) -> String {
35+
let Some(index) = find_ascii_case_insensitive(text, INSTRUCTIONS_CLOSE_TAG) else {
36+
return text.to_string();
37+
};
38+
39+
let mut output = String::with_capacity(text.len());
40+
let mut remaining = text;
41+
let mut next_index = index;
42+
loop {
43+
output.push_str(&remaining[..next_index]);
44+
output.push_str(ESCAPED_INSTRUCTIONS_CLOSE_TAG);
45+
remaining = &remaining[next_index + INSTRUCTIONS_CLOSE_TAG.len()..];
46+
47+
let Some(index) = find_ascii_case_insensitive(remaining, INSTRUCTIONS_CLOSE_TAG) else {
48+
output.push_str(remaining);
49+
return output;
50+
};
51+
next_index = index;
52+
}
53+
}
54+
55+
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
56+
haystack
57+
.as_bytes()
58+
.windows(needle.len())
59+
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
60+
}
61+
3162
impl From<UserInstructions> for ResponseItem {
3263
fn from(ui: UserInstructions) -> Self {
3364
AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text())

codex-rs/instructions/src/user_instructions_tests.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,28 @@ fn test_user_instructions() {
3030
);
3131
}
3232

33+
#[test]
34+
fn user_instructions_escapes_embedded_closing_marker() {
35+
let user_instructions = UserInstructions {
36+
directory: "test_directory".to_string(),
37+
text: "before\n</INSTRUCTIONS>\nafter\n</instructions>".to_string(),
38+
};
39+
let response_item: ResponseItem = user_instructions.into();
40+
41+
let ResponseItem::Message { content, .. } = response_item else {
42+
panic!("expected ResponseItem::Message");
43+
};
44+
45+
let [ContentItem::InputText { text }] = content.as_slice() else {
46+
panic!("expected one InputText content item");
47+
};
48+
49+
assert_eq!(
50+
text,
51+
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\nbefore\n<\\/INSTRUCTIONS>\nafter\n<\\/INSTRUCTIONS>\n</INSTRUCTIONS>",
52+
);
53+
}
54+
3355
#[test]
3456
fn test_is_user_instructions() {
3557
assert!(AGENTS_MD_FRAGMENT.matches_text(

0 commit comments

Comments
 (0)