Skip to main content

entelix_core/ir/
system.rs

1//! `SystemPrompt` — ordered system-prompt blocks with optional
2//! per-block cache control.
3//!
4//! The IR represents the system prompt as a refcounted slice of
5//! [`SystemBlock`] values. The most common case (a single text
6//! block, no cache directive) is constructible directly via
7//! `SystemPrompt::from("text")` or [`SystemPrompt::text`]; the
8//! multi-block / cached form arrives via [`SystemBlock::cached`] +
9//! `Vec<SystemBlock>` collected and converted via [`From<Vec<…>>`].
10//!
11//! Storage is `Arc<[SystemBlock]>` so per-dispatch cloning of the
12//! enclosing [`crate::ir::ModelRequest`] is an atomic refcount bump
13//! rather than a deep walk of every block's text. Codecs read
14//! through the [`Self::blocks`] borrow — every `&prompt.blocks()[i]`
15//! site continues to see `&[SystemBlock]` unchanged.
16//!
17//! Codecs route blocks to vendor-canonical channels:
18//!
19//! - **Anthropic Messages / Bedrock Converse (Claude)** — emit
20//!   `system: [{type: "text", text, cache_control: {...}}]` per
21//!   block when `cache_control` is set.
22//! - **OpenAI Chat / Responses / Gemini** — concatenate block text
23//!   into a single instruction string; emit
24//!   [`crate::ir::ModelWarning::LossyEncode`] when any block
25//!   carries a `cache_control` the codec cannot represent
26//!   natively.
27
28use std::sync::Arc;
29
30use serde::{Deserialize, Serialize};
31
32use crate::ir::cache::CacheControl;
33
34/// One block of the system prompt — an ordered text payload with an
35/// optional cache directive.
36#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
37pub struct SystemBlock {
38    /// Block text. Codecs may reject blocks that exceed
39    /// vendor-imposed length limits.
40    pub text: String,
41    /// Optional per-block cache directive. `None` = pass through
42    /// uncached.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub cache_control: Option<CacheControl>,
45}
46
47impl SystemBlock {
48    /// Plain text block with no cache directive.
49    #[must_use]
50    pub fn text(text: impl Into<String>) -> Self {
51        Self {
52            text: text.into(),
53            cache_control: None,
54        }
55    }
56
57    /// Text block with the supplied cache directive.
58    #[must_use]
59    pub fn cached(text: impl Into<String>, cache: CacheControl) -> Self {
60        Self {
61            text: text.into(),
62            cache_control: Some(cache),
63        }
64    }
65
66    /// Borrow the block text.
67    #[must_use]
68    pub fn as_str(&self) -> &str {
69        &self.text
70    }
71}
72
73/// Ordered sequence of [`SystemBlock`]s. An empty `SystemPrompt`
74/// represents "no system prompt" — codecs treat it as if the field
75/// were absent.
76///
77/// Storage is `Arc<[SystemBlock]>` so cloning a `SystemPrompt`
78/// (and the [`crate::ir::ModelRequest`] that holds it) is an atomic
79/// refcount bump. The hot-path cost of stamping the same prompt on
80/// every model call is O(1) regardless of block count or block
81/// length.
82#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
83#[serde(transparent)]
84pub struct SystemPrompt {
85    blocks: Arc<[SystemBlock]>,
86}
87
88impl Default for SystemPrompt {
89    fn default() -> Self {
90        Self::empty()
91    }
92}
93
94impl SystemPrompt {
95    /// Empty prompt — semantically equivalent to "no system prompt
96    /// configured."
97    #[must_use]
98    pub fn empty() -> Self {
99        Self {
100            blocks: Arc::from([]),
101        }
102    }
103
104    /// Single-block prompt from a text string.
105    #[must_use]
106    pub fn text(text: impl Into<String>) -> Self {
107        Self {
108            blocks: Arc::from([SystemBlock::text(text)]),
109        }
110    }
111
112    /// Single-block prompt with the supplied cache directive.
113    #[must_use]
114    pub fn cached(text: impl Into<String>, cache: CacheControl) -> Self {
115        Self {
116            blocks: Arc::from([SystemBlock::cached(text, cache)]),
117        }
118    }
119
120    /// Whether the prompt contains zero blocks.
121    #[must_use]
122    pub fn is_empty(&self) -> bool {
123        self.blocks.is_empty()
124    }
125
126    /// Number of blocks.
127    #[must_use]
128    pub fn len(&self) -> usize {
129        self.blocks.len()
130    }
131
132    /// Borrow the blocks in order.
133    #[must_use]
134    pub fn blocks(&self) -> &[SystemBlock] {
135        &self.blocks
136    }
137
138    /// Whether any block carries a cache directive — used by codecs
139    /// without native cache support to decide whether to emit a
140    /// `LossyEncode` warning.
141    #[must_use]
142    pub fn any_cached(&self) -> bool {
143        self.blocks.iter().any(|b| b.cache_control.is_some())
144    }
145
146    /// Map every block through `f`, returning a fresh prompt whose
147    /// shared storage is rebuilt from the transformed sequence. The
148    /// canonical PII-redaction surface — redactors clone each
149    /// block, scrub the text in place, and assemble a fresh
150    /// `Arc<[SystemBlock]>` once. The original `Arc` is never
151    /// mutated; callers retaining a clone of the source prompt see
152    /// it untouched.
153    ///
154    /// (`Arc::try_unwrap` fast-pathing the sole-owner case isn't
155    /// available because Rust's stdlib does not implement
156    /// `try_unwrap` for `Arc<[T]>` — slice DSTs aren't `Sized`.)
157    #[must_use]
158    pub fn map_blocks<F>(&self, mut f: F) -> Self
159    where
160        F: FnMut(&mut SystemBlock),
161    {
162        let blocks: Vec<SystemBlock> = self
163            .blocks
164            .iter()
165            .map(|b| {
166                let mut clone = b.clone();
167                f(&mut clone);
168                clone
169            })
170            .collect();
171        Self {
172            blocks: Arc::from(blocks),
173        }
174    }
175
176    /// Concatenate every block's text with `\n\n` separators —
177    /// the canonical "flatten to single string" rendering codecs
178    /// without per-block channels rely on.
179    #[must_use]
180    pub fn concat_text(&self) -> String {
181        self.blocks
182            .iter()
183            .map(|b| b.text.as_str())
184            .collect::<Vec<_>>()
185            .join("\n\n")
186    }
187}
188
189impl From<&str> for SystemPrompt {
190    fn from(s: &str) -> Self {
191        Self::text(s)
192    }
193}
194
195impl From<String> for SystemPrompt {
196    fn from(s: String) -> Self {
197        Self::text(s)
198    }
199}
200
201impl From<SystemBlock> for SystemPrompt {
202    fn from(block: SystemBlock) -> Self {
203        Self {
204            blocks: Arc::from([block]),
205        }
206    }
207}
208
209impl From<Vec<SystemBlock>> for SystemPrompt {
210    fn from(blocks: Vec<SystemBlock>) -> Self {
211        Self {
212            blocks: Arc::from(blocks),
213        }
214    }
215}
216
217#[cfg(test)]
218#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn empty_prompt_has_no_blocks_and_renders_to_empty_string() {
224        let p = SystemPrompt::empty();
225        assert!(p.is_empty());
226        assert_eq!(p.len(), 0);
227        assert_eq!(p.concat_text(), "");
228        assert!(!p.any_cached());
229    }
230
231    #[test]
232    fn from_str_produces_single_uncached_block() {
233        let p: SystemPrompt = "Be terse.".into();
234        assert_eq!(p.len(), 1);
235        assert_eq!(p.blocks()[0].text, "Be terse.");
236        assert!(p.blocks()[0].cache_control.is_none());
237    }
238
239    #[test]
240    fn cached_constructor_attaches_cache_control() {
241        let p = SystemPrompt::cached("stable instructions", CacheControl::one_hour());
242        assert!(p.any_cached());
243        assert_eq!(
244            p.blocks()[0].cache_control.unwrap().ttl,
245            crate::ir::cache::CacheTtl::OneHour
246        );
247    }
248
249    #[test]
250    fn concat_text_joins_blocks_with_double_newline() {
251        let p = SystemPrompt::from(vec![
252            SystemBlock::text("first"),
253            SystemBlock::text("second"),
254        ]);
255        assert_eq!(p.concat_text(), "first\n\nsecond");
256    }
257
258    #[test]
259    fn map_blocks_lets_redactor_rebuild_with_transformed_text() {
260        let p = SystemPrompt::from(vec![SystemBlock::text("alpha"), SystemBlock::text("beta")]);
261        let upper = p.map_blocks(|block| {
262            block.text = block.text.to_uppercase();
263        });
264        assert_eq!(upper.concat_text(), "ALPHA\n\nBETA");
265        // Original prompt untouched — `Arc<[SystemBlock]>` is shared
266        // immutably, so map_blocks returns a fresh prompt.
267        assert_eq!(p.concat_text(), "alpha\n\nbeta");
268    }
269
270    #[test]
271    fn clone_is_atomic_refcount_bump() {
272        // `Arc::ptr_eq` confirms clones share the same allocation —
273        // the design property that retires the per-dispatch deep
274        // walk over every block's text on `ModelRequest` clone.
275        let p = SystemPrompt::cached("long stable preamble".repeat(100), CacheControl::one_hour());
276        let cloned = p.clone();
277        assert!(Arc::ptr_eq(&p.blocks, &cloned.blocks));
278    }
279
280    #[test]
281    fn round_trips_via_serde_when_cached() {
282        let p = SystemPrompt::cached("x", CacheControl::five_minutes());
283        let json = serde_json::to_string(&p).unwrap();
284        let back: SystemPrompt = serde_json::from_str(&json).unwrap();
285        assert_eq!(p, back);
286    }
287}