diff --git a/codex-rs/instructions/src/user_instructions.rs b/codex-rs/instructions/src/user_instructions.rs
index 4fd266e766..b2e4414ffd 100644
--- a/codex-rs/instructions/src/user_instructions.rs
+++ b/codex-rs/instructions/src/user_instructions.rs
@@ -8,6 +8,8 @@ use crate::fragment::AGENTS_MD_START_MARKER;
use crate::fragment::SKILL_FRAGMENT;
pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER;
+const INSTRUCTIONS_CLOSE_TAG: &str = "";
+const ESCAPED_INSTRUCTIONS_CLOSE_TAG: &str = "<\\/INSTRUCTIONS>";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "user_instructions", rename_all = "snake_case")]
@@ -18,16 +20,45 @@ pub struct UserInstructions {
impl UserInstructions {
pub fn serialize_to_text(&self) -> String {
+ let contents = escape_reserved_instruction_delimiters(&self.text);
format!(
"{prefix}{directory}\n\n\n{contents}\n{suffix}",
prefix = AGENTS_MD_FRAGMENT.start_marker(),
directory = self.directory,
- contents = self.text,
+ contents = contents,
suffix = AGENTS_MD_FRAGMENT.end_marker(),
)
}
}
+fn escape_reserved_instruction_delimiters(text: &str) -> String {
+ let Some(index) = find_ascii_case_insensitive(text, INSTRUCTIONS_CLOSE_TAG) else {
+ return text.to_string();
+ };
+
+ let mut output = String::with_capacity(text.len());
+ let mut remaining = text;
+ let mut next_index = index;
+ loop {
+ output.push_str(&remaining[..next_index]);
+ output.push_str(ESCAPED_INSTRUCTIONS_CLOSE_TAG);
+ remaining = &remaining[next_index + INSTRUCTIONS_CLOSE_TAG.len()..];
+
+ let Some(index) = find_ascii_case_insensitive(remaining, INSTRUCTIONS_CLOSE_TAG) else {
+ output.push_str(remaining);
+ return output;
+ };
+ next_index = index;
+ }
+}
+
+fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option {
+ haystack
+ .as_bytes()
+ .windows(needle.len())
+ .position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
+}
+
impl From for ResponseItem {
fn from(ui: UserInstructions) -> Self {
AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text())
diff --git a/codex-rs/instructions/src/user_instructions_tests.rs b/codex-rs/instructions/src/user_instructions_tests.rs
index 75f35d11b0..25d78f3d41 100644
--- a/codex-rs/instructions/src/user_instructions_tests.rs
+++ b/codex-rs/instructions/src/user_instructions_tests.rs
@@ -30,6 +30,28 @@ fn test_user_instructions() {
);
}
+#[test]
+fn user_instructions_escapes_embedded_closing_marker() {
+ let user_instructions = UserInstructions {
+ directory: "test_directory".to_string(),
+ text: "before\n\nafter\n".to_string(),
+ };
+ let response_item: ResponseItem = user_instructions.into();
+
+ let ResponseItem::Message { content, .. } = response_item else {
+ panic!("expected ResponseItem::Message");
+ };
+
+ let [ContentItem::InputText { text }] = content.as_slice() else {
+ panic!("expected one InputText content item");
+ };
+
+ assert_eq!(
+ text,
+ "# AGENTS.md instructions for test_directory\n\n\nbefore\n<\\/INSTRUCTIONS>\nafter\n<\\/INSTRUCTIONS>\n",
+ );
+}
+
#[test]
fn test_is_user_instructions() {
assert!(AGENTS_MD_FRAGMENT.matches_text(