Skip to main content

oxicode_agent/agent_loop/
append_only.rs

1/// Append-only context for stable prefix caching.
2///
3/// Separates immutable message history from pending tool results so that
4/// the byte prefix sent to the LLM never changes between turns. This
5/// maximizes provider-side KV cache / prompt caching.
6///
7/// # Semantics
8///
9/// - `messages` — immutable history. Never mutated after append.
10/// - `pending_tool_results` — queued tool results for the current turn.
11///   Folded into history at the next turn boundary.
12///
13/// When building context for the LLM, always use `history + pending`.
14use oxicode_ai::Message;
15
16/// Append-only context manager for the agent loop.
17///
18/// Separates immutable message history from pending tool results so that
19/// the byte prefix sent to the LLM never changes between turns. This
20/// maximizes provider-side KV cache / prompt caching.
21#[derive(Debug, Clone)]
22pub struct AppendOnlyContext {
23    /// Immutable message history. Once appended, never removed or mutated.
24    messages: Vec<Message>,
25    /// Pending tool results queued for the next LLM turn.
26    pending_tool_results: Vec<Message>,
27}
28
29impl AppendOnlyContext {
30    /// Create a new append-only context from existing messages.
31    pub fn new(messages: Vec<Message>) -> Self {
32        Self {
33            messages,
34            pending_tool_results: Vec::new(),
35        }
36    }
37
38    /// Create an empty append-only context.
39    pub fn empty() -> Self {
40        Self::new(Vec::new())
41    }
42
43    /// Append a message to the immutable history.
44    pub fn append(&mut self, msg: Message) {
45        self.messages.push(msg);
46    }
47
48    /// Queue a tool result for the next LLM turn.
49    pub fn queue_tool_result(&mut self, msg: Message) {
50        self.pending_tool_results.push(msg);
51    }
52
53    /// Fold pending tool results into the immutable history.
54    /// Called at turn boundaries (after the LLM responds, before the next turn).
55    pub fn finalize_turn(&mut self) {
56        self.messages.append(&mut self.pending_tool_results);
57    }
58
59    /// Replace the entire history (used after compaction replaces messages).
60    /// Pending tool results are discarded since compaction already folded them.
61    pub fn replace_history(&mut self, new_history: Vec<Message>) {
62        self.messages = new_history;
63        self.pending_tool_results.clear();
64    }
65
66    /// Build the full message list for the LLM: history + pending tool results.
67    pub fn build_messages(&self) -> Vec<Message> {
68        let mut all = self.messages.clone();
69        all.extend(self.pending_tool_results.iter().cloned());
70        all
71    }
72
73    /// Get a reference to the immutable history.
74    pub fn history(&self) -> &[Message] {
75        &self.messages
76    }
77
78    /// Get a reference to the pending tool results.
79    pub fn pending_results(&self) -> &[Message] {
80        &self.pending_tool_results
81    }
82
83    /// Get the total message count (history + pending).
84    pub fn len(&self) -> usize {
85        self.messages.len() + self.pending_tool_results.len()
86    }
87
88    /// Returns true if no messages exist.
89    pub fn is_empty(&self) -> bool {
90        self.messages.is_empty() && self.pending_tool_results.is_empty()
91    }
92
93    /// Sync from an external message list, appending only new messages.
94    ///
95    /// This is needed when external code (e.g. the agent state) has a
96    /// different view of the message list. Only messages beyond the
97    /// current history length are appended.
98    ///
99    /// Returns the number of newly appended messages.
100    pub fn sync_from(&mut self, external: &[Message]) -> usize {
101        if external.len() <= self.messages.len() {
102            return 0;
103        }
104        let new_count = external.len() - self.messages.len();
105        for msg in &external[self.messages.len()..] {
106            self.messages.push(msg.clone());
107        }
108        new_count
109    }
110
111    /// Consume self and return the underlying history vec.
112    pub fn into_messages(self) -> Vec<Message> {
113        let mut all = self.messages;
114        all.extend(self.pending_tool_results);
115        all
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use oxicode_ai::{ContentBlock, TextContent};
123
124    #[test]
125    fn test_empty_context() {
126        let ctx = AppendOnlyContext::empty();
127        assert!(ctx.is_empty());
128        assert_eq!(ctx.len(), 0);
129    }
130
131    #[test]
132    fn test_append_and_build() {
133        let mut ctx = AppendOnlyContext::empty();
134        ctx.append(Message::user("Hello"));
135        ctx.append(Message::user("World"));
136
137        assert_eq!(ctx.len(), 2);
138        assert!(!ctx.is_empty());
139
140        let built = ctx.build_messages();
141        assert_eq!(built.len(), 2);
142    }
143
144    #[test]
145    fn test_tool_result_queue() {
146        let mut ctx = AppendOnlyContext::empty();
147        ctx.append(Message::user("Turn 1"));
148
149        ctx.queue_tool_result(Message::ToolResult(oxicode_ai::ToolResultMessage::new(
150            "call-1",
151            "echo",
152            vec![ContentBlock::Text(TextContent::new("Result of tool"))],
153        )));
154
155        // Build should include both history and pending
156        let built = ctx.build_messages();
157        assert_eq!(built.len(), 2);
158
159        // Finalize turn: fold pending into history
160        ctx.finalize_turn();
161        assert_eq!(ctx.len(), 2);
162        assert!(ctx.pending_results().is_empty());
163    }
164
165    #[test]
166    fn test_sync_from_appends_only_new() {
167        let mut ctx = AppendOnlyContext::empty();
168        ctx.append(Message::user("A"));
169        ctx.append(Message::user("B"));
170
171        // External has same prefix + one more
172        let external = vec![Message::user("A"), Message::user("B"), Message::user("C")];
173
174        let count = ctx.sync_from(&external);
175        assert_eq!(count, 1); // Only "C" was new
176        assert_eq!(ctx.len(), 3);
177
178        // Sync again with no new messages
179        let count2 = ctx.sync_from(&external);
180        assert_eq!(count2, 0);
181    }
182
183    #[test]
184    fn test_into_messages_flattens_all() {
185        let mut ctx = AppendOnlyContext::empty();
186        ctx.append(Message::user("A"));
187        ctx.queue_tool_result(Message::ToolResult(oxicode_ai::ToolResultMessage::new(
188            "call-1",
189            "echo",
190            vec![ContentBlock::Text(TextContent::new("result"))],
191        )));
192
193        let all = ctx.into_messages();
194        assert_eq!(all.len(), 2);
195    }
196
197    #[test]
198    fn test_prefix_stability() {
199        let mut ctx = AppendOnlyContext::empty();
200        ctx.append(Message::user("Turn 1 prompt"));
201
202        // Queue a tool result: build shows both, but prefix (index 0) unchanged
203        ctx.queue_tool_result(Message::ToolResult(oxicode_ai::ToolResultMessage::new(
204            "call-1",
205            "echo",
206            vec![ContentBlock::Text(TextContent::new("tool result"))],
207        )));
208
209        let before = ctx.build_messages();
210        assert_eq!(before.len(), 2);
211
212        // Finalize turn, append next prompt
213        ctx.finalize_turn();
214        ctx.append(Message::user("Turn 2 prompt"));
215
216        let after = ctx.build_messages();
217        // Prefix still at index 0 (Turn 1 prompt)
218        assert_eq!(after.len(), 3);
219        // Verify length stability of prefix position
220        assert!(after.len() > before.len());
221    }
222}