diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 5fc804f2f2..11ac60c33b 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -1940,6 +1940,7 @@ dependencies = [
"strum_macros 0.27.2",
"sys-locale",
"tempfile",
+ "thiserror 2.0.18",
"tracing",
"ts-rs",
"uuid",
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index d6d4d93b57..ecd87e8f7e 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -201,6 +201,7 @@ use crate::util::backoff;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_async_utils::OrCancelExt;
use codex_otel::OtelManager;
+use codex_protocol::artificial_messages::ArtificialMessage;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -1802,15 +1803,10 @@ impl Session {
self.services
.otel_manager
.counter("codex.model_warning", 1, &[]);
- let item = ResponseItem::Message {
- id: None,
- role: "user".to_string(),
- content: vec![ContentItem::InputText {
- text: format!("Warning: {}", message.into()),
- }],
- end_turn: None,
- phase: None,
+ let item = ArtificialMessage::ModelWarning {
+ body: message.into(),
};
+ .to_response_item();
self.record_conversation_items(ctx, &[item]).await;
}
@@ -5573,10 +5569,14 @@ mod tests {
match last {
ResponseItem::Message { role, content, .. } => {
assert_eq!(role, "user");
+ let expected_warning = ArtificialMessage::ModelWarning {
+ body: "too many unified exec processes".to_string(),
+ }
+ .render();
assert_eq!(
content,
&vec![ContentItem::InputText {
- text: "Warning: too many unified exec processes".to_string(),
+ text: expected_warning,
}]
);
}
diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs
index a6eba62f1a..69b4bee814 100644
--- a/codex-rs/core/src/context_manager/history_tests.rs
+++ b/codex-rs/core/src/context_manager/history_tests.rs
@@ -2,6 +2,7 @@ use super::*;
use crate::truncate;
use crate::truncate::TruncationPolicy;
use codex_git::GhostCommit;
+use codex_protocol::artificial_messages::ArtificialMessage;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
@@ -60,6 +61,15 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
}
}
+fn demo_skill_message() -> String {
+ ArtificialMessage::Skill {
+ name: "demo".to_string(),
+ path: "skills/demo/SKILL.md".to_string(),
+ body: "body".to_string(),
+ }
+ .render()
+}
+
fn function_call_output(call_id: &str, content: &str) -> ResponseItem {
ResponseItem::FunctionCallOutput {
call_id: call_id.to_string(),
@@ -445,9 +455,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n\ntest_text\n",
),
- user_input_text_msg(
- "\ndemo\nskills/demo/SKILL.md\nbody\n",
- ),
+ user_input_text_msg(&demo_skill_message()),
user_input_text_msg("echo 42"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
@@ -464,9 +472,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n\ntest_text\n",
),
- user_input_text_msg(
- "\ndemo\nskills/demo/SKILL.md\nbody\n",
- ),
+ user_input_text_msg(&demo_skill_message()),
user_input_text_msg("echo 42"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
@@ -480,9 +486,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n\ntest_text\n",
),
- user_input_text_msg(
- "\ndemo\nskills/demo/SKILL.md\nbody\n",
- ),
+ user_input_text_msg(&demo_skill_message()),
user_input_text_msg("echo 42"),
];
@@ -492,9 +496,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n\ntest_text\n",
),
- user_input_text_msg(
- "\ndemo\nskills/demo/SKILL.md\nbody\n",
- ),
+ user_input_text_msg(&demo_skill_message()),
user_input_text_msg("echo 42"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
@@ -510,9 +512,7 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
user_input_text_msg(
"# AGENTS.md instructions for test_directory\n\n\ntest_text\n",
),
- user_input_text_msg(
- "\ndemo\nskills/demo/SKILL.md\nbody\n",
- ),
+ user_input_text_msg(&demo_skill_message()),
user_input_text_msg("echo 42"),
user_input_text_msg("turn 1 user"),
assistant_msg("turn 1 assistant"),
diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs
index 2ad19d3df5..d9b4adafe9 100644
--- a/codex-rs/core/src/event_mapping.rs
+++ b/codex-rs/core/src/event_mapping.rs
@@ -146,6 +146,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option {
#[cfg(test)]
mod tests {
use super::parse_turn_item;
+ use codex_protocol::artificial_messages::ArtificialMessage;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::TurnItem;
use codex_protocol::items::WebSearchItem;
@@ -284,6 +285,12 @@ mod tests {
#[test]
fn skips_user_instructions_and_env() {
+ let skill_message = ArtificialMessage::Skill {
+ name: "demo".to_string(),
+ path: "skills/demo/SKILL.md".to_string(),
+ body: "body".to_string(),
+ }
+ .render();
let items = vec![
ResponseItem::Message {
id: None,
@@ -316,8 +323,7 @@ mod tests {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
- text: "\ndemo\nskills/demo/SKILL.md\nbody\n"
- .to_string(),
+ text: skill_message,
}],
end_turn: None,
phase: None,
diff --git a/codex-rs/core/src/instructions/user_instructions.rs b/codex-rs/core/src/instructions/user_instructions.rs
index 525834847e..bb7bea5c69 100644
--- a/codex-rs/core/src/instructions/user_instructions.rs
+++ b/codex-rs/core/src/instructions/user_instructions.rs
@@ -1,3 +1,4 @@
+use codex_protocol::artificial_messages::ArtificialMessage;
use serde::Deserialize;
use serde::Serialize;
@@ -64,18 +65,12 @@ impl SkillInstructions {
impl From for ResponseItem {
fn from(si: SkillInstructions) -> Self {
- ResponseItem::Message {
- id: None,
- role: "user".to_string(),
- content: vec![ContentItem::InputText {
- text: format!(
- "\n{}\n{}\n{}\n",
- si.name, si.path, si.contents
- ),
- }],
- end_turn: None,
- phase: None,
+ ArtificialMessage::Skill {
+ name: si.name,
+ path: si.path,
+ body: si.contents,
}
+ .to_response_item()
}
}
@@ -84,6 +79,15 @@ mod tests {
use super::*;
use pretty_assertions::assert_eq;
+ fn demo_skill_message() -> String {
+ ArtificialMessage::Skill {
+ name: "demo-skill".to_string(),
+ path: "skills/demo/SKILL.md".to_string(),
+ body: "body".to_string(),
+ }
+ .render()
+ }
+
#[test]
fn test_user_instructions() {
let user_instructions = UserInstructions {
@@ -146,18 +150,15 @@ mod tests {
panic!("expected one InputText content item");
};
- assert_eq!(
- text,
- "\ndemo-skill\nskills/demo/SKILL.md\nbody\n",
- );
+ let expected = demo_skill_message();
+ assert_eq!(text, &expected);
}
#[test]
fn test_is_skill_instructions() {
assert!(SkillInstructions::is_skill_instructions(&[
ContentItem::InputText {
- text: "\ndemo-skill\nskills/demo/SKILL.md\nbody\n"
- .to_string(),
+ text: demo_skill_message(),
}
]));
assert!(!SkillInstructions::is_skill_instructions(&[
diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs
index d0f2ba1ae7..d815135f07 100644
--- a/codex-rs/core/tests/suite/client.rs
+++ b/codex-rs/core/tests/suite/client.rs
@@ -23,6 +23,7 @@ use codex_core::protocol::Op;
use codex_core::protocol::SessionSource;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
+use codex_protocol::artificial_messages::ArtificialMessage;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ReasoningSummary;
@@ -343,7 +344,13 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
);
let pos_permissions = messages
.iter()
- .position(|(role, text)| role == "developer" && text.contains(""))
+ .position(|(role, text)| {
+ role == "developer"
+ && matches!(
+ ArtificialMessage::parse(text),
+ Ok(ArtificialMessage::Permission { .. })
+ )
+ })
.expect("permissions message");
let pos_user_instructions = messages
.iter()
diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs
index 9dcecdf383..90b399de11 100644
--- a/codex-rs/core/tests/suite/collaboration_instructions.rs
+++ b/codex-rs/core/tests/suite/collaboration_instructions.rs
@@ -3,6 +3,7 @@ use codex_core::protocol::COLLABORATION_MODE_CLOSE_TAG;
use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
+use codex_protocol::artificial_messages::ArtificialMessage;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
@@ -90,7 +91,10 @@ async fn no_collaboration_instructions_by_default() -> Result<()> {
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
assert_eq!(dev_texts.len(), 1);
- assert!(dev_texts[0].contains(""));
+ assert!(matches!(
+ ArtificialMessage::parse(&dev_texts[0]),
+ Ok(ArtificialMessage::Permission { .. })
+ ));
Ok(())
}
diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs
index c54203b4f1..f32b952594 100644
--- a/codex-rs/core/tests/suite/permissions_messages.rs
+++ b/codex-rs/core/tests/suite/permissions_messages.rs
@@ -5,6 +5,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_execpolicy::Policy;
+use codex_protocol::artificial_messages::ArtificialMessage;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -34,7 +35,10 @@ fn permissions_texts(input: &[serde_json::Value]) -> Vec {
.first()?
.get("text")?
.as_str()?;
- if text.contains("") {
+ if matches!(
+ ArtificialMessage::parse(text),
+ Ok(ArtificialMessage::Permission { .. })
+ ) {
Some(text.to_string())
} else {
None
diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs
index 54cdba4d21..eea89c5f71 100644
--- a/codex-rs/core/tests/suite/skills.rs
+++ b/codex-rs/core/tests/suite/skills.rs
@@ -5,6 +5,7 @@ use anyhow::Result;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
+use codex_protocol::artificial_messages::ArtificialMessage;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -91,13 +92,15 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
let request = mock.single_request();
let user_texts = request.message_input_texts("user");
let skill_path_str = skill_path.to_string_lossy();
+ let skill_contents = fs::read_to_string(&skill_path)?;
+ let expected_skill_text = ArtificialMessage::Skill {
+ name: "demo".to_string(),
+ path: skill_path_str.to_string(),
+ body: skill_contents,
+ }
+ .render();
assert!(
- user_texts.iter().any(|text| {
- text.contains("\ndemo")
- && text.contains("")
- && text.contains(skill_body)
- && text.contains(skill_path_str.as_ref())
- }),
+ user_texts.iter().any(|text| text == &expected_skill_text),
"expected skill instructions in user input, got {user_texts:?}"
);
diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml
index b58393936b..da28849dbb 100644
--- a/codex-rs/protocol/Cargo.toml
+++ b/codex-rs/protocol/Cargo.toml
@@ -27,6 +27,7 @@ serde_with = { workspace = true, features = ["macros", "base64"] }
strum = { workspace = true }
strum_macros = { workspace = true }
sys-locale = { workspace = true }
+thiserror = { workspace = true }
tracing = { workspace = true }
ts-rs = { workspace = true, features = [
"uuid-impl",
diff --git a/codex-rs/protocol/src/artificial_messages.rs b/codex-rs/protocol/src/artificial_messages.rs
new file mode 100644
index 0000000000..3b43462bda
--- /dev/null
+++ b/codex-rs/protocol/src/artificial_messages.rs
@@ -0,0 +1,151 @@
+//! Artificial message helpers shared between prompt construction and protocol parsing.
+//!
+//! The `artificial_messages!` macro invocation in this module defines:
+//!
+//! - The `ArtificialMessage` enum variants declared below (`Skill`, `ModelWarning`,
+//! `Permission`) with their configured string fields.
+//! - `ArtificialMessage::tag(&self) -> &'static str`: returns the top-level XML tag for
+//! the current variant (for example `skill`).
+//! - `ArtificialMessage::role(&self) -> &'static str`: returns the role that should be
+//! used when this message is emitted as a `ResponseItem`.
+//! - `ArtificialMessage::render(&self) -> String`: serializes the message to the tagged
+//! XML-like envelope.
+//! - `ArtificialMessage::parse(input: &str) -> Result`:
+//! parses an envelope back into an enum variant.
+//! - `ArtificialMessage::detect_tag(input: &str) -> Option<&'static str>`: returns the
+//! known artificial-message tag if the input is a recognized envelope.
+//! - `ArtificialMessage::is_artificial(input: &str) -> bool`: convenience boolean check
+//! built on top of `detect_tag`.
+//! - `ArtificialMessage::to_response_item(&self) -> ResponseItem`: converts the rendered
+//! content into a message-shaped `ResponseItem`.
+//!
+//! This module also provides `impl From for ResponseItem` as a
+//! convenience wrapper around `to_response_item`.
+//!
+use crate::models::ContentItem;
+use crate::models::ResponseItem;
+use thiserror::Error;
+
+#[macro_use]
+mod macros;
+
+pub const TAG_SKILL: &str = "skill";
+pub const TAG_MODEL_WARNING: &str = "warning";
+pub const TAG_USER_SHELL_COMMAND: &str = "user_shell_cmd";
+pub const TAG_PERMISSION: &str = "permissions_instructions";
+pub const TAG_COLLABORATION_MODE: &str = "collaboration_mode";
+pub const TAG_SPEC: &str = "personality_spec";
+pub const TAG_EXEC_POLICY_AMENDMENT: &str = "exec_policy_amendment";
+
+artificial_messages! {
+ Skill {
+ tag: TAG_SKILL,
+ role: "user",
+ fields: {
+ tagged(name, "name"),
+ tagged(path, "path"),
+ raw(body)
+ }
+ },
+ ModelWarning {
+ tag: TAG_MODEL_WARNING,
+ role: "user",
+ fields: {
+ raw(body)
+ }
+ },
+ Permission {
+ tag: TAG_PERMISSION,
+ role: "developer",
+ fields: {
+ raw(body)
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Error)]
+pub enum ArtificialMessageParseError {
+ #[error("invalid artificial message envelope")]
+ InvalidEnvelope,
+ #[error("unknown artificial message tag: {0}")]
+ UnknownTopLevelTag(String),
+ #[error("missing field <{field_tag}> in message <{message_tag}>")]
+ MissingField {
+ message_tag: &'static str,
+ field_tag: &'static str,
+ },
+}
+
+impl From for ResponseItem {
+ fn from(value: ArtificialMessage) -> Self {
+ value.to_response_item()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+
+ #[test]
+ fn render_and_parse_skill_round_trip() {
+ let message = ArtificialMessage::Skill {
+ name: "demo".to_string(),
+ path: "skills/demo/SKILL.md".to_string(),
+ body: "body".to_string(),
+ };
+
+ let rendered = message.render();
+ assert_eq!(
+ rendered,
+ "\ndemo\nskills/demo/SKILL.md\nbody\n"
+ );
+
+ let parsed = ArtificialMessage::parse(&rendered).expect("parse skill");
+ assert_eq!(parsed, message);
+ }
+
+ #[test]
+ fn render_and_parse_warning_round_trip() {
+ let message = ArtificialMessage::ModelWarning {
+ body: "be careful".to_string(),
+ };
+ let rendered = message.render();
+ assert_eq!(rendered, "be careful");
+
+ let parsed = ArtificialMessage::parse(&rendered).expect("parse warning");
+ assert_eq!(parsed, message);
+ }
+
+ #[test]
+ fn render_and_parse_permission_round_trip() {
+ let message = ArtificialMessage::Permission {
+ body: "policy text".to_string(),
+ };
+ let rendered = message.render();
+ assert_eq!(
+ rendered,
+ "policy text"
+ );
+
+ let parsed = ArtificialMessage::parse(&rendered).expect("parse permission");
+ assert_eq!(parsed, message);
+ }
+
+ #[test]
+ fn detect_tag_returns_known_tag() {
+ let text = "abc";
+ assert_eq!(ArtificialMessage::detect_tag(text), Some(TAG_PERMISSION));
+ assert!(ArtificialMessage::is_artificial(text));
+ }
+
+ #[test]
+ fn parse_rejects_unknown_tag() {
+ let text = "abc";
+ let err = ArtificialMessage::parse(text).expect_err("unknown tag should fail");
+ assert_eq!(
+ err,
+ ArtificialMessageParseError::UnknownTopLevelTag("unknown".to_string())
+ );
+ }
+}
diff --git a/codex-rs/protocol/src/artificial_messages/macros.rs b/codex-rs/protocol/src/artificial_messages/macros.rs
new file mode 100644
index 0000000000..f500bbc737
--- /dev/null
+++ b/codex-rs/protocol/src/artificial_messages/macros.rs
@@ -0,0 +1,206 @@
+use crate::artificial_messages::ArtificialMessageParseError;
+
+macro_rules! render_field {
+ ($tagged:ident, $raw:ident, tagged, $value:ident, $field_tag:literal) => {{
+ $tagged.push_str(&$crate::artificial_messages::macros::xml(
+ $field_tag, $value,
+ ));
+ $tagged.push('\n');
+ }};
+ ($tagged:ident, $raw:ident, raw, $value:ident) => {{
+ $raw = Some($value.as_str());
+ }};
+}
+
+macro_rules! render_fields {
+ ($($kind:ident($value:ident $(, $field_tag:literal)?)),* $(,)?) => {{
+ #[allow(unused_mut, unused_assignments)]
+ let mut tagged = String::new();
+ #[allow(unused_mut, unused_assignments)]
+ let mut raw = None;
+ $(
+ render_field!(tagged, raw, $kind, $value $(, $field_tag)?);
+ )*
+
+ if tagged.is_empty() {
+ raw.unwrap_or_default().to_string()
+ } else {
+ let mut out = String::new();
+ out.push('\n');
+ out.push_str(&tagged);
+ if let Some(raw) = raw {
+ out.push_str(raw);
+ out.push('\n');
+ }
+ out
+ }
+ }};
+}
+
+macro_rules! parse_field {
+ ($remaining:ident, $consumed_tagged:ident, $msg_tag:expr, tagged, $field:ident, $field_tag:literal) => {
+ let tagged_input = $remaining.strip_prefix('\n').unwrap_or($remaining);
+ let ($field, rest) =
+ $crate::artificial_messages::macros::extract_prefixed_tag(tagged_input, $field_tag)
+ .ok_or_else(|| ArtificialMessageParseError::MissingField {
+ message_tag: $msg_tag,
+ field_tag: $field_tag,
+ })?;
+ let $field = $field.to_string();
+ $remaining = rest;
+ if !$consumed_tagged {
+ $consumed_tagged = true;
+ }
+ };
+ ($remaining:ident, $consumed_tagged:ident, $msg_tag:expr, raw, $field:ident) => {
+ let mut value = $remaining;
+ if $consumed_tagged {
+ value = value.strip_prefix('\n').unwrap_or(value);
+ value = value.strip_suffix('\n').unwrap_or(value);
+ }
+ let $field = value.to_string();
+ $remaining = "";
+ };
+}
+
+macro_rules! parse_variant {
+ ($variant:ident, $inner:expr, $msg_tag:expr, $($kind:ident($field:ident $(, $field_tag:literal)?)),* $(,)?) => {{
+ #[allow(unused_mut, unused_assignments)]
+ let mut remaining = $inner;
+ #[allow(unused_mut, unused_assignments)]
+ let mut consumed_tagged = false;
+ $(
+ parse_field!(
+ remaining,
+ consumed_tagged,
+ $msg_tag,
+ $kind,
+ $field
+ $(, $field_tag)?
+ );
+ )*
+ let _ = &remaining;
+ let _ = consumed_tagged;
+ Ok(ArtificialMessage::$variant { $( $field ),* })
+ }};
+}
+
+macro_rules! artificial_messages {
+ (
+ $(
+ $variant:ident {
+ tag: $tag:path,
+ role: $role:literal,
+ fields: { $($kind:ident($field:ident $(, $field_tag:literal)?)),* $(,)? }
+ }
+ ),* $(,)?
+ ) => {
+ #[derive(Debug, Clone, PartialEq, Eq)]
+ pub enum ArtificialMessage {
+ $(
+ $variant { $( $field: String ),* }
+ ),*
+ }
+
+ impl ArtificialMessage {
+ pub fn tag(&self) -> &'static str {
+ match self {
+ $( Self::$variant { .. } => $tag ),*
+ }
+ }
+
+ pub fn role(&self) -> &'static str {
+ match self {
+ $( Self::$variant { .. } => $role ),*
+ }
+ }
+
+ pub fn render(&self) -> String {
+ match self {
+ $(
+ Self::$variant { $( $field ),* } => {
+ let payload = render_fields!($($kind($field $(, $field_tag)?)),*);
+ $crate::artificial_messages::macros::xml($tag, payload)
+ }
+ ),*
+ }
+ }
+
+ pub fn parse(input: &str) -> Result {
+ let (tag, inner) = $crate::artificial_messages::macros::split_outer_tag(input)?;
+ match tag {
+ $(
+ $tag => parse_variant!($variant, inner, $tag, $($kind($field $(, $field_tag)?)),*),
+ )*
+ unknown => Err(ArtificialMessageParseError::UnknownTopLevelTag(
+ unknown.to_string(),
+ )),
+ }
+ }
+
+ pub fn detect_tag(input: &str) -> Option<&'static str> {
+ let (tag, _) = $crate::artificial_messages::macros::split_outer_tag(input).ok()?;
+ match tag {
+ $( $tag => Some($tag), )*
+ _ => None,
+ }
+ }
+
+ pub fn is_artificial(input: &str) -> bool {
+ Self::detect_tag(input).is_some()
+ }
+
+ pub fn to_response_item(&self) -> ResponseItem {
+ ResponseItem::Message {
+ role: self.role().to_string(),
+ id: None,
+ content: vec![ContentItem::InputText {
+ text: self.render(),
+ }],
+ end_turn: None,
+ phase: None,
+ }
+ }
+ }
+ };
+}
+
+pub(crate) fn split_outer_tag(input: &str) -> Result<(&str, &str), ArtificialMessageParseError> {
+ let text = input.trim();
+ if !text.starts_with('<') {
+ return Err(ArtificialMessageParseError::InvalidEnvelope);
+ }
+
+ let Some(open_end) = text.find('>') else {
+ return Err(ArtificialMessageParseError::InvalidEnvelope);
+ };
+
+ let tag = &text[1..open_end];
+ if tag.is_empty() || tag.starts_with('/') || tag.chars().any(char::is_whitespace) {
+ return Err(ArtificialMessageParseError::InvalidEnvelope);
+ }
+
+ let close = format!("{tag}>");
+ let Some(inner) = text[open_end + 1..].strip_suffix(&close) else {
+ return Err(ArtificialMessageParseError::InvalidEnvelope);
+ };
+
+ Ok((tag, inner))
+}
+
+pub(crate) fn extract_prefixed_tag<'a>(
+ text: &'a str,
+ field_tag: &'static str,
+) -> Option<(&'a str, &'a str)> {
+ let open = format!("<{field_tag}>");
+ let close = format!("{field_tag}>");
+ let after_open = text.strip_prefix(&open)?;
+ let end = after_open.find(&close)?;
+ let value = &after_open[..end];
+ let rest = &after_open[end + close.len()..];
+ Some((value, rest))
+}
+
+pub(crate) fn xml(tag: &str, payload: impl std::fmt::Display) -> String {
+ format!("<{tag}>{payload}{tag}>")
+}
diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs
index 5841b1187e..c5cc919560 100644
--- a/codex-rs/protocol/src/lib.rs
+++ b/codex-rs/protocol/src/lib.rs
@@ -1,4 +1,5 @@
pub mod account;
+pub mod artificial_messages;
mod thread_id;
pub use thread_id::ThreadId;
pub mod approvals;
diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs
index fe63666e38..686e379850 100644
--- a/codex-rs/protocol/src/models.rs
+++ b/codex-rs/protocol/src/models.rs
@@ -8,6 +8,7 @@ use serde::Serialize;
use serde::ser::Serializer;
use ts_rs::TS;
+use crate::artificial_messages::ArtificialMessage;
use crate::config_types::CollaborationMode;
use crate::config_types::SandboxMode;
use crate::protocol::AskForApproval;
@@ -336,20 +337,16 @@ impl DeveloperInstructions {
request_rule_enabled: bool,
writable_roots: Option>,
) -> Self {
- let start_tag = DeveloperInstructions::new("");
- let end_tag = DeveloperInstructions::new("");
- start_tag
- .concat(DeveloperInstructions::sandbox_text(
- sandbox_mode,
- network_access,
- ))
+ let body = DeveloperInstructions::sandbox_text(sandbox_mode, network_access)
.concat(DeveloperInstructions::from(
approval_policy,
exec_policy,
request_rule_enabled,
))
.concat(DeveloperInstructions::from_writable_roots(writable_roots))
- .concat(end_tag)
+ .into_text();
+
+ DeveloperInstructions::new(ArtificialMessage::Permission { body }.render())
}
fn from_writable_roots(writable_roots: Option>) -> Self {