Skip to main content

oxicode_sdk/
snapcompact_compactor.rs

1//! `SnapcompactCompactor` — `oxicode-ai::Compactor` implementation backed by
2//! the snapcompact PNG renderer in `oxicode-snapcompact`.
3//!
4//! Lives in the SDK layer (not `oxicode-ai`) per the foundation-layer
5//! invariant — `oxicode-ai` defines the `Compactor` trait and the
6//! `CompactionStrategy::Snapcompact` variant, but holds no dependency
7//! on `oxicode-snapcompact`. The concrete impl is here because the SDK
8//! is the "contract + reference impl" layer that already depends on
9//! both `oxicode-ai` and `oxicode-snapcompact`.
10//!
11//! See `docs/designs/2026-07-18-stub-completion.md` §4.5.
12
13use std::pin::Pin;
14use std::sync::Arc;
15
16use oxicode_ai::Message;
17use oxicode_ai::compaction::{CompactedContext, CompactionError, CompactionMetadata, Compactor};
18use oxicode_snapcompact::Shape;
19
20/// Bitmap-frame compactor that renders the discarded tail of a
21/// conversation as PNG frames via the snapcompact pipeline.
22///
23/// Unlike the LLM compactor, this compactor makes no LLM call — the
24/// local renderer is the only cost. Frames are addressed at
25/// vision-capable models (Anthropic, OpenAI, Google) that read the
26/// PNGs back directly.
27///
28/// ## Limits
29///
30/// Snapcompact always returns **real PNG bytes** for each frame or
31/// surfaces the failure via [`CompactionError::LlmError`]. There is no
32/// "no-op renderer" fallback: a rendering failure becomes a
33/// compaction failure (intentional, so the operator notices).
34pub struct SnapcompactCompactor {
35    /// Shape used to rasterize the discarded text. None → snapcompact
36    /// resolves a model-aware default per `Model::id`.
37    shape: Option<Shape>,
38    /// Per-frame source-text character limit. Frames longer than this
39    /// are chunked; shorter frames are still rendered (snapcompact
40    /// is happy with empty-ish frames).
41    frame_chars: usize,
42    /// Maximum frames per compaction.
43    max_frames: u32,
44}
45
46impl Default for SnapcompactCompactor {
47    fn default() -> Self {
48        Self {
49            shape: None,
50            frame_chars: 4000,
51            max_frames: 8,
52        }
53    }
54}
55
56impl SnapcompactCompactor {
57    /// Construct with snapcompact's model-aware shape resolution.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Pin a specific shape (overrides model-aware resolution).
63    pub fn with_shape(mut self, shape: Shape) -> Self {
64        self.shape = Some(shape);
65        self
66    }
67
68    /// Set the per-frame source-text character limit.
69    pub fn with_frame_chars(mut self, frame_chars: usize) -> Self {
70        self.frame_chars = frame_chars.max(64);
71        self
72    }
73
74    /// Set the maximum number of frames per compaction.
75    pub fn with_max_frames(mut self, max_frames: u32) -> Self {
76        self.max_frames = max_frames;
77        self
78    }
79
80    /// Borrow the configured shape (if pinned).
81    pub fn shape(&self) -> Option<&Shape> {
82        self.shape.as_ref()
83    }
84}
85
86impl Compactor for SnapcompactCompactor {
87    fn estimate_tokens(&self, messages: &[Message]) -> usize {
88        // Default text-based estimator (matches LlmCompactor's
89        // heuristic: `bytes / 4`). Snapcompact's true cost is
90        // image-charged, not text-charged, but consumers that go
91        // through the `dyn Compactor` surface want a single
92        // accounting path.
93        messages
94            .iter()
95            .map(|m| m.text_content().map(|t| t.len() / 4).unwrap_or(0))
96            .sum()
97    }
98
99    fn compact<'a>(
100        &'a self,
101        messages: &'a [Message],
102        instruction: Option<&'a str>,
103    ) -> Pin<
104        Box<
105            dyn Future<Output = std::result::Result<CompactedContext, CompactionError>> + Send + 'a,
106        >,
107    > {
108        Box::pin(async move {
109            if messages.is_empty() {
110                return Err(CompactionError::NoMessagesToCompact);
111            }
112
113            // Build the serializable text envelope from the messages.
114            // `serialize_conversation` from oxicode-snapcompact handles the
115            // role prefix + tool-output truncation; we then chunk into
116            // frames of `frame_chars` characters and render each frame
117            // through the PNG renderer.
118            let mut text = String::new();
119            if let Some(instr) = instruction {
120                text.push_str(&format!("[instruction] {instr}\n\n"));
121            }
122            text.push_str(&oxicode_snapcompact::serialize_conversation(
123                &flatten_messages(messages),
124                self.frame_chars,
125            ));
126
127            let frame_count = max_frame_count(text.chars().count(), self.frame_chars);
128            let frames_to_render = frame_count.min(self.max_frames as usize).max(1);
129
130            let original_tokens = self.estimate_tokens(messages);
131
132            // Render each chunk into a PNG frame via oxicode-snapcompact.
133            let mut frames: Vec<(u32, Vec<u8>)> = Vec::with_capacity(frames_to_render);
134            let mut idx: u32 = 0;
135            'outer: for chunk in chunks(&text, self.frame_chars).take(frames_to_render) {
136                let prep = oxicode_snapcompact::prepare(&chunk, 200_000, self.frame_chars);
137                let opts = oxicode_snapcompact::CompactOptions {
138                    shape: self.shape.clone(),
139                    model_id: String::new(),
140                    max_frames: 1,
141                };
142                let result = oxicode_snapcompact::compact(&prep, &opts);
143                if result.frames.is_empty() {
144                    return Err(CompactionError::LlmError(format!(
145                        "snapcompact produced no frames for chunk {idx}"
146                    )));
147                }
148                for f in result.frames {
149                    if f.bytes.is_empty() {
150                        return Err(CompactionError::LlmError(format!(
151                            "snapcompact frame {} returned empty bytes (render failure)",
152                            f.index
153                        )));
154                    }
155                    frames.push((idx, f.bytes));
156                    idx += 1;
157                    if idx as usize == self.max_frames as usize {
158                        break 'outer;
159                    }
160                }
161            }
162
163            // No message is "kept" in pure snapcompact mode — the
164            // rendered PNGs are the compacted context. The caller
165            // (agent loop) is expected to attach the PNGs as image
166            // content to a single assistant message.
167            let metadata = CompactionMetadata::new(
168                original_tokens,
169                estimate_frame_tokens(frames.len()),
170                frames.len(),
171                0,
172                0.0, // ratio is N/A for bitmap compaction
173            );
174
175            let mut context = CompactedContext::new(
176                format!("[snapcompact] {} frames", frames.len()),
177                Vec::new(),
178                frames.len(),
179                metadata,
180            );
181            context.frames = Some(Arc::new(frames));
182            Ok(context)
183        })
184    }
185}
186
187// ── helpers ────────────────────────────────────────────────────
188
189/// Heuristic: a PNG frame ≈ 1500 tokens (vision models typically
190/// charge per image, not per pixel; this is a conservative
191/// overestimate that errs on the side of "need to compact more").
192fn estimate_frame_tokens(frames: usize) -> usize {
193    frames.saturating_mul(1500)
194}
195
196fn max_frame_count(chars: usize, frame_chars: usize) -> usize {
197    if frame_chars == 0 {
198        return 0;
199    }
200    chars.div_ceil(frame_chars)
201}
202
203fn chunks(text: &str, frame_chars: usize) -> impl Iterator<Item = String> + '_ {
204    let mut remaining = text;
205    std::iter::from_fn(move || {
206        if remaining.is_empty() {
207            return None;
208        }
209        // Split on char boundary to avoid mid-codepoint cuts.
210        let n = remaining.chars().count().min(frame_chars);
211        let cut = remaining
212            .char_indices()
213            .nth(n)
214            .map(|(idx, _)| idx)
215            .unwrap_or(remaining.len());
216        let (head, tail) = remaining.split_at(cut);
217        remaining = tail;
218        Some(head.to_string())
219    })
220}
221
222/// Flatten a slice of [`Message`] into a single normalized text
223/// string suitable for `serialize_conversation`. Each message is
224/// prefixed with its role.
225fn flatten_messages(messages: &[Message]) -> String {
226    let mut out = String::new();
227    for m in messages {
228        let role = match m {
229            Message::User(_) => "user",
230            Message::Assistant(_) => "assistant",
231            Message::ToolResult(_) => "tool",
232        };
233        let text = m.text_content().unwrap_or_default();
234        if !text.is_empty() {
235            if !out.is_empty() {
236                out.push('\n');
237            }
238            out.push_str(role);
239            out.push_str(": ");
240            out.push_str(&text);
241        }
242    }
243    out
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn default_construction() {
252        let c = SnapcompactCompactor::new();
253        assert!(c.shape().is_none());
254        assert_eq!(c.frame_chars, 4000);
255        assert_eq!(c.max_frames, 8);
256    }
257
258    #[test]
259    fn shape_pinning_works() {
260        let shape = oxicode_snapcompact::SHAPES[0].clone();
261        let c = SnapcompactCompactor::new().with_shape(shape.clone());
262        assert!(c.shape().is_some());
263        assert_eq!(c.shape().unwrap().name, shape.name);
264    }
265
266    #[test]
267    fn frame_chars_floor() {
268        let c = SnapcompactCompactor::new().with_frame_chars(0);
269        // 0 is floored to 64 (avoid divide-by-zero / tiny frames).
270        assert_eq!(c.frame_chars, 64);
271    }
272
273    #[test]
274    fn empty_messages_returns_error() {
275        let rt = tokio::runtime::Builder::new_current_thread()
276            .enable_all()
277            .build()
278            .unwrap();
279        let compactor = SnapcompactCompactor::new();
280        let err = rt
281            .block_on(async { compactor.compact(&[], None).await })
282            .unwrap_err();
283        assert!(matches!(err, CompactionError::NoMessagesToCompact));
284    }
285
286    #[test]
287    fn compact_real_messages_produces_png_frames() {
288        let rt = tokio::runtime::Builder::new_current_thread()
289            .enable_all()
290            .build()
291            .unwrap();
292        let messages = vec![Message::user(
293            "Hello world. This is a test message that should be rendered as a PNG frame by snapcompact.",
294        )];
295        let compactor = SnapcompactCompactor::new();
296        let result = rt
297            .block_on(async { compactor.compact(&messages, None).await })
298            .expect("compaction should succeed");
299        let frames = result.frames.as_ref().expect("frames should be attached");
300        assert!(!frames.is_empty(), "should produce ≥1 frame");
301        for (idx, bytes) in frames.iter() {
302            assert!(!bytes.is_empty(), "frame {idx} must be non-empty");
303            // PNG magic header.
304            assert_eq!(
305                &bytes[..8],
306                &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
307                "frame {idx} must be PNG"
308            );
309        }
310    }
311}