Skip to main content

agent_message_window/
lib.rs

1/*!
2agent-message-window: sliding window of LLM conversation turns with
3paired tool_use / tool_result protection.
4
5The Anthropic API rejects conversations that contain a `tool_result`
6whose `tool_use` is no longer in the history. A naive "drop the oldest"
7window silently corrupts the conversation. This crate's eviction logic
8never drops a `tool_use` message without also evicting all `tool_result`
9messages that reference it.
10
11```rust
12use agent_message_window::MessageWindow;
13use serde_json::json;
14
15let mut win = MessageWindow::new(4, true);
16win.add(json!({"role": "user", "content": "hi"})).unwrap();
17win.add(json!({"role": "assistant", "content": [
18    {"type": "tool_use", "id": "u1", "name": "search", "input": {}}
19]})).unwrap();
20win.add(json!({"role": "user", "content": [
21    {"type": "tool_result", "tool_use_id": "u1", "content": "ok"}
22]})).unwrap();
23
24// Even with max_turns=4, paired protection applies.
25assert_eq!(win.len(), 3);
26```
27*/
28
29use serde_json::Value;
30use std::collections::{HashSet, VecDeque};
31
32// ---- error ----------------------------------------------------------------
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum WindowError {
36    NotAnObject,
37    MaxTurnsMustBePositive,
38}
39
40impl std::fmt::Display for WindowError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            WindowError::NotAnObject => write!(f, "message must be a JSON object"),
44            WindowError::MaxTurnsMustBePositive => write!(f, "max_turns must be >= 1"),
45        }
46    }
47}
48
49impl std::error::Error for WindowError {}
50
51// ---- helpers --------------------------------------------------------------
52
53/// Collect `tool_use` IDs from an assistant message's `content` array.
54fn tool_use_ids(message: &Value) -> HashSet<String> {
55    let mut out = HashSet::new();
56    if let Some(content) = message.get("content") {
57        if let Some(blocks) = content.as_array() {
58            for block in blocks {
59                if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
60                    if let Some(id) = block.get("id").and_then(|i| i.as_str()) {
61                        out.insert(id.to_owned());
62                    }
63                }
64            }
65        }
66    }
67    out
68}
69
70/// Collect `tool_use_id` references from a user message's `content` array.
71fn tool_result_refs(message: &Value) -> HashSet<String> {
72    let mut out = HashSet::new();
73    if let Some(content) = message.get("content") {
74        if let Some(blocks) = content.as_array() {
75            for block in blocks {
76                if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") {
77                    if let Some(id) = block.get("tool_use_id").and_then(|i| i.as_str()) {
78                        out.insert(id.to_owned());
79                    }
80                }
81            }
82        }
83    }
84    out
85}
86
87// ---- MessageWindow --------------------------------------------------------
88
89/// Sliding window over LLM conversation messages.
90///
91/// `max_turns` controls how many messages are kept. When adding a new
92/// message causes overflow, the oldest is evicted first. With
93/// `paired_protect = true` (the default), evicting a `tool_use` message
94/// also evicts any `tool_result` messages that reference its IDs.
95pub struct MessageWindow {
96    max_turns: usize,
97    paired_protect: bool,
98    buf: VecDeque<Value>,
99    evicted_count: usize,
100}
101
102impl MessageWindow {
103    /// Create a new window with `max_turns` capacity.
104    ///
105    /// `paired_protect = true` guards Anthropic tool-use / tool-result pairs.
106    pub fn new(max_turns: usize, paired_protect: bool) -> Self {
107        assert!(max_turns >= 1, "max_turns must be >= 1");
108        Self {
109            max_turns,
110            paired_protect,
111            buf: VecDeque::new(),
112            evicted_count: 0,
113        }
114    }
115
116    /// Try to create; returns Err if max_turns < 1.
117    pub fn try_new(max_turns: usize, paired_protect: bool) -> Result<Self, WindowError> {
118        if max_turns < 1 {
119            return Err(WindowError::MaxTurnsMustBePositive);
120        }
121        Ok(Self::new(max_turns, paired_protect))
122    }
123
124    pub fn max_turns(&self) -> usize {
125        self.max_turns
126    }
127
128    pub fn len(&self) -> usize {
129        self.buf.len()
130    }
131
132    pub fn is_empty(&self) -> bool {
133        self.buf.is_empty()
134    }
135
136    pub fn evicted_count(&self) -> usize {
137        self.evicted_count
138    }
139
140    /// Ordered slice of current messages (oldest → newest).
141    pub fn messages(&self) -> Vec<&Value> {
142        self.buf.iter().collect()
143    }
144
145    /// Cloned owned snapshot.
146    pub fn messages_owned(&self) -> Vec<Value> {
147        self.buf.iter().cloned().collect()
148    }
149
150    pub fn clear(&mut self) {
151        self.buf.clear();
152        self.evicted_count = 0;
153    }
154
155    /// Append a message and evict-to-fit.
156    pub fn add(&mut self, message: Value) -> Result<(), WindowError> {
157        if !message.is_object() {
158            return Err(WindowError::NotAnObject);
159        }
160        self.buf.push_back(message);
161        self.evict_to_fit();
162        Ok(())
163    }
164
165    /// Add multiple messages in order.
166    pub fn extend(&mut self, messages: impl IntoIterator<Item = Value>) -> Result<(), WindowError> {
167        for m in messages {
168            self.add(m)?;
169        }
170        Ok(())
171    }
172
173    // ---- eviction ---------------------------------------------------
174
175    fn evict_to_fit(&mut self) {
176        while self.buf.len() > self.max_turns {
177            let oldest = self.buf.pop_front().expect("buf non-empty");
178            self.evicted_count += 1;
179            if self.paired_protect {
180                let tu_ids = tool_use_ids(&oldest);
181                if !tu_ids.is_empty() {
182                    self.drop_orphan_tool_results(&tu_ids);
183                }
184            }
185        }
186    }
187
188    /// Remove any buffered messages whose tool_result references are in `tu_ids`.
189    fn drop_orphan_tool_results(&mut self, tu_ids: &HashSet<String>) {
190        let mut kept = VecDeque::with_capacity(self.buf.len());
191        for msg in self.buf.drain(..) {
192            let refs = tool_result_refs(&msg);
193            if refs.iter().any(|id| tu_ids.contains(id)) {
194                self.evicted_count += 1;
195            } else {
196                kept.push_back(msg);
197            }
198        }
199        self.buf = kept;
200    }
201}
202
203// ---- tests ----------------------------------------------------------------
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use serde_json::json;
209
210    fn user(text: &str) -> Value {
211        json!({"role": "user", "content": text})
212    }
213
214    fn assistant(text: &str) -> Value {
215        json!({"role": "assistant", "content": text})
216    }
217
218    fn tool_use_msg(id: &str, name: &str) -> Value {
219        json!({
220            "role": "assistant",
221            "content": [{"type": "tool_use", "id": id, "name": name, "input": {}}]
222        })
223    }
224
225    fn tool_result_msg(tool_use_id: &str) -> Value {
226        json!({
227            "role": "user",
228            "content": [{"type": "tool_result", "tool_use_id": tool_use_id, "content": "ok"}]
229        })
230    }
231
232    #[test]
233    fn basic_sliding_window() {
234        let mut win = MessageWindow::new(3, false);
235        win.add(user("a")).unwrap();
236        win.add(user("b")).unwrap();
237        win.add(user("c")).unwrap();
238        win.add(user("d")).unwrap();
239        assert_eq!(win.len(), 3);
240        assert_eq!(win.evicted_count(), 1);
241        assert_eq!(win.messages()[0]["content"], json!("b"));
242    }
243
244    #[test]
245    fn no_overflow_within_cap() {
246        let mut win = MessageWindow::new(5, true);
247        for i in 0..5 {
248            win.add(user(&i.to_string())).unwrap();
249        }
250        assert_eq!(win.len(), 5);
251        assert_eq!(win.evicted_count(), 0);
252    }
253
254    #[test]
255    fn paired_protect_drops_orphan_tool_result() {
256        let mut win = MessageWindow::new(2, true);
257        win.add(tool_use_msg("u1", "search")).unwrap();
258        win.add(tool_result_msg("u1")).unwrap();
259        // Now add two more; the tool_use will be evicted, which cascades to result
260        win.add(user("follow-up")).unwrap();
261        win.add(assistant("done")).unwrap();
262        // tool_use AND tool_result both gone; only last 2 remain
263        assert_eq!(win.len(), 2);
264        assert!(win.messages().iter().all(|m| {
265            m.get("content")
266                .and_then(|c| c.as_array())
267                .map(|arr| arr.iter().all(|b| b.get("type") != Some(&json!("tool_result"))))
268                .unwrap_or(true)
269        }));
270    }
271
272    #[test]
273    fn paired_protect_off_leaves_orphan() {
274        let mut win = MessageWindow::new(2, false);
275        win.add(tool_use_msg("u1", "search")).unwrap();
276        win.add(tool_result_msg("u1")).unwrap();
277        win.add(user("next")).unwrap();
278        win.add(assistant("done")).unwrap();
279        // Without protection, raw oldest is dropped; tool_result may still be present
280        assert_eq!(win.len(), 2);
281    }
282
283    #[test]
284    fn clear_resets_all() {
285        let mut win = MessageWindow::new(3, true);
286        win.add(user("a")).unwrap();
287        win.add(user("b")).unwrap();
288        win.clear();
289        assert_eq!(win.len(), 0);
290        assert_eq!(win.evicted_count(), 0);
291    }
292
293    #[test]
294    fn extend_adds_in_order() {
295        let mut win = MessageWindow::new(5, true);
296        win.extend(vec![user("a"), user("b"), user("c")]).unwrap();
297        assert_eq!(win.len(), 3);
298        assert_eq!(win.messages()[1]["content"], json!("b"));
299    }
300
301    #[test]
302    fn not_object_error() {
303        let mut win = MessageWindow::new(3, true);
304        let err = win.add(json!([1, 2, 3])).unwrap_err();
305        assert_eq!(err, WindowError::NotAnObject);
306    }
307
308    #[test]
309    fn try_new_zero_cap_errors() {
310        assert!(matches!(
311            MessageWindow::try_new(0, true),
312            Err(WindowError::MaxTurnsMustBePositive)
313        ));
314    }
315
316    #[test]
317    fn try_new_valid() {
318        assert!(MessageWindow::try_new(1, true).is_ok());
319    }
320
321    #[test]
322    fn evicted_count_increments_correctly() {
323        let mut win = MessageWindow::new(2, false);
324        for i in 0..5u32 {
325            win.add(user(&i.to_string())).unwrap();
326        }
327        assert_eq!(win.evicted_count(), 3);
328    }
329
330    #[test]
331    fn multiple_tool_use_ids_in_one_message() {
332        let mut win = MessageWindow::new(2, true);
333        // one assistant message with two tool_use blocks
334        win.add(json!({
335            "role": "assistant",
336            "content": [
337                {"type": "tool_use", "id": "a1", "name": "x", "input": {}},
338                {"type": "tool_use", "id": "a2", "name": "y", "input": {}}
339            ]
340        }))
341        .unwrap();
342        win.add(tool_result_msg("a1")).unwrap();
343        win.add(tool_result_msg("a2")).unwrap();
344        // window fills to 2, starts evicting
345        win.add(user("next")).unwrap();
346        // tool_use evicted → both results should be purged
347        for m in win.messages() {
348            let content = m.get("content");
349            if let Some(Value::Array(blocks)) = content {
350                for b in blocks {
351                    assert_ne!(b.get("type"), Some(&json!("tool_result")));
352                }
353            }
354        }
355    }
356
357    #[test]
358    fn messages_owned_clones() {
359        let mut win = MessageWindow::new(3, true);
360        win.add(user("hello")).unwrap();
361        let owned = win.messages_owned();
362        assert_eq!(owned[0]["content"], json!("hello"));
363    }
364
365    #[test]
366    fn single_capacity_window() {
367        let mut win = MessageWindow::new(1, true);
368        win.add(user("a")).unwrap();
369        win.add(user("b")).unwrap();
370        assert_eq!(win.len(), 1);
371        assert_eq!(win.messages()[0]["content"], json!("b"));
372        assert_eq!(win.evicted_count(), 1);
373    }
374
375    #[test]
376    fn tool_use_no_result_in_buffer_just_evicts_use() {
377        let mut win = MessageWindow::new(1, true);
378        win.add(tool_use_msg("u1", "search")).unwrap();
379        // no tool_result in buffer; evicting tool_use alone is fine
380        win.add(user("follow")).unwrap();
381        assert_eq!(win.len(), 1);
382        assert_eq!(win.messages()[0]["content"], json!("follow"));
383    }
384
385    #[test]
386    fn add_then_clear_then_add() {
387        let mut win = MessageWindow::new(3, true);
388        win.add(user("a")).unwrap();
389        win.clear();
390        win.add(user("b")).unwrap();
391        assert_eq!(win.len(), 1);
392        assert_eq!(win.messages()[0]["content"], json!("b"));
393    }
394
395    #[test]
396    fn is_empty_and_len() {
397        let mut win = MessageWindow::new(3, true);
398        assert!(win.is_empty());
399        win.add(user("x")).unwrap();
400        assert!(!win.is_empty());
401        assert_eq!(win.len(), 1);
402    }
403}