Skip to main content

codex_context_fragments/
fragment.rs

1use codex_protocol::models::ContentItem;
2use codex_protocol::models::ResponseInputItem;
3use codex_protocol::models::ResponseItem;
4
5/// Type-erased registration for a contextual user fragment.
6///
7/// Implementations are used by context filtering code to recognize injected
8/// fragments without constructing the concrete context payload.
9pub trait FragmentRegistration: Sync {
10    fn matches_text(&self, text: &str) -> bool;
11}
12
13pub struct FragmentRegistrationProxy<T> {
14    _marker: std::marker::PhantomData<fn() -> T>,
15}
16
17impl<T> FragmentRegistrationProxy<T> {
18    pub const fn new() -> Self {
19        Self {
20            _marker: std::marker::PhantomData,
21        }
22    }
23}
24
25impl<T> Default for FragmentRegistrationProxy<T> {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl<T: ContextualUserFragment> FragmentRegistration for FragmentRegistrationProxy<T> {
32    fn matches_text(&self, text: &str) -> bool {
33        T::matches_text(text)
34    }
35}
36
37/// Context payload that is injected as a message fragment.
38///
39/// Implementations own the response role and provide the exact fragment body.
40/// Marked fragments also provide start/end markers used to recognize injected
41/// context later. `render()` concatenates markers and body without adding
42/// separators, so implementations should include any whitespace they need
43/// between tags in `body()`. Unmarked fragments should leave both markers empty,
44/// in which case the default helpers render only the body and never match
45/// arbitrary text.
46pub trait ContextualUserFragment {
47    fn role(&self) -> &'static str;
48
49    fn markers(&self) -> (&'static str, &'static str);
50
51    fn body(&self) -> String;
52
53    fn type_markers() -> (&'static str, &'static str)
54    where
55        Self: Sized;
56
57    fn matches_text(text: &str) -> bool
58    where
59        Self: Sized,
60    {
61        let (start_marker, end_marker) = Self::type_markers();
62        matches_marked_text(start_marker, end_marker, text)
63    }
64
65    fn render(&self) -> String {
66        let (start_marker, end_marker) = self.markers();
67        let body = self.body();
68        if start_marker.is_empty() && end_marker.is_empty() {
69            return body;
70        }
71
72        format!("{start_marker}{body}{end_marker}")
73    }
74
75    fn into(self) -> ResponseItem
76    where
77        Self: Sized,
78    {
79        ResponseItem::Message {
80            id: None,
81            role: self.role().to_string(),
82            content: vec![ContentItem::InputText {
83                text: self.render(),
84            }],
85            phase: None,
86            internal_chat_message_metadata_passthrough: None,
87        }
88    }
89
90    fn into_boxed_response_item(self: Box<Self>) -> ResponseItem {
91        ResponseItem::Message {
92            id: None,
93            role: self.role().to_string(),
94            content: vec![ContentItem::InputText {
95                text: self.render(),
96            }],
97            phase: None,
98            internal_chat_message_metadata_passthrough: None,
99        }
100    }
101
102    fn into_response_input_item(self) -> ResponseInputItem
103    where
104        Self: Sized,
105    {
106        ResponseInputItem::Message {
107            role: self.role().to_string(),
108            content: vec![ContentItem::InputText {
109                text: self.render(),
110            }],
111            phase: None,
112        }
113    }
114}
115
116pub(crate) fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
117    if start_marker.is_empty() || end_marker.is_empty() {
118        return false;
119    }
120
121    let trimmed = text.trim_start();
122    let starts_with_marker = trimmed
123        .get(..start_marker.len())
124        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker));
125    let trimmed = trimmed.trim_end();
126    let ends_with_marker = trimmed
127        .get(trimmed.len().saturating_sub(end_marker.len())..)
128        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker));
129    starts_with_marker && ends_with_marker
130}