Skip to main content

enact_runner/
compaction.rs

1//! Context compaction for long-running agent loops
2//!
3//! When message history exceeds a threshold, older messages are summarized
4//! into a single context message to prevent context window overflow.
5//!
6//! Ported from zeroclaw's `auto_compact_history` which uses the LLM itself
7//! to generate a summary of older conversation history.
8
9use enact_core::callable::Callable;
10
11/// A message in the conversation history.
12#[derive(Debug, Clone)]
13pub struct HistoryMessage {
14    /// Role: "system", "user", "assistant", "tool"
15    pub role: String,
16    /// Message content
17    pub content: String,
18}
19
20impl HistoryMessage {
21    pub fn system(content: impl Into<String>) -> Self {
22        Self {
23            role: "system".to_string(),
24            content: content.into(),
25        }
26    }
27
28    pub fn user(content: impl Into<String>) -> Self {
29        Self {
30            role: "user".to_string(),
31            content: content.into(),
32        }
33    }
34
35    pub fn assistant(content: impl Into<String>) -> Self {
36        Self {
37            role: "assistant".to_string(),
38            content: content.into(),
39        }
40    }
41
42    pub fn tool_result(name: &str, content: impl Into<String>) -> Self {
43        Self {
44            role: "tool".to_string(),
45            content: format!("[{}]: {}", name, content.into()),
46        }
47    }
48}
49
50/// Check if compaction is needed based on message count.
51pub fn needs_compaction(history: &[HistoryMessage], threshold: usize) -> bool {
52    history.len() > threshold
53}
54
55/// Compact the conversation history by summarizing older messages.
56///
57/// Keeps the system message (index 0) and the `keep_recent` most recent messages.
58/// Everything in between is summarized into a single "context summary" message.
59///
60/// # Arguments
61/// * `history` — mutable reference to the message history
62/// * `summarizer` — a Callable used to generate the summary (typically the LLM itself)
63/// * `keep_recent` — how many recent messages to preserve verbatim
64///
65/// # Returns
66/// `true` if compaction was performed, `false` if history was too short.
67pub async fn compact_history(
68    history: &mut Vec<HistoryMessage>,
69    summarizer: &dyn Callable,
70    keep_recent: usize,
71) -> anyhow::Result<bool> {
72    // Need at least: system + some old messages + keep_recent
73    if history.len() <= keep_recent + 2 {
74        return Ok(false);
75    }
76
77    // Split: [system] [old messages to summarize] [recent messages to keep]
78    let split_point = history.len() - keep_recent;
79
80    // Build a transcript of the old messages (skip system at index 0)
81    let old_messages = &history[1..split_point];
82
83    if old_messages.is_empty() {
84        return Ok(false);
85    }
86
87    let transcript = old_messages
88        .iter()
89        .map(|m| format!("{}: {}", m.role, m.content))
90        .collect::<Vec<_>>()
91        .join("\n");
92
93    // Ask the LLM to summarize the old conversation
94    let summary_prompt = format!(
95        "Summarize the following conversation history into a concise context summary. \
96         Preserve key facts, decisions, tool results, and any state that would be needed \
97         to continue the conversation. Be concise but complete.\n\n\
98         CONVERSATION:\n{}\n\n\
99         SUMMARY:",
100        transcript
101    );
102
103    let summary = summarizer.run(&summary_prompt).await?;
104
105    tracing::info!(
106        old_messages = old_messages.len(),
107        summary_len = summary.len(),
108        "Compacted conversation history"
109    );
110
111    // Reconstruct history: [system] [summary] [recent messages]
112    let system_msg = history[0].clone();
113    let recent_messages: Vec<HistoryMessage> = history[split_point..].to_vec();
114
115    history.clear();
116    history.push(system_msg);
117    history.push(HistoryMessage {
118        role: "system".to_string(),
119        content: format!("[Context Summary from earlier conversation]\n{}", summary),
120    });
121    history.extend(recent_messages);
122
123    Ok(true)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn test_needs_compaction() {
132        let history: Vec<HistoryMessage> = (0..50)
133            .map(|i| HistoryMessage::user(format!("msg {}", i)))
134            .collect();
135
136        assert!(needs_compaction(&history, 40));
137        assert!(!needs_compaction(&history, 50));
138        assert!(!needs_compaction(&history, 100));
139    }
140
141    #[test]
142    fn test_history_message_constructors() {
143        let sys = HistoryMessage::system("You are helpful");
144        assert_eq!(sys.role, "system");
145
146        let user = HistoryMessage::user("Hello");
147        assert_eq!(user.role, "user");
148
149        let asst = HistoryMessage::assistant("Hi there");
150        assert_eq!(asst.role, "assistant");
151
152        let tool = HistoryMessage::tool_result("search", "found 5 results");
153        assert_eq!(tool.role, "tool");
154        assert!(tool.content.contains("[search]"));
155    }
156
157    #[tokio::test]
158    async fn test_compact_short_history_is_noop() {
159        use async_trait::async_trait;
160
161        struct MockSummarizer;
162
163        #[async_trait]
164        impl Callable for MockSummarizer {
165            fn name(&self) -> &str {
166                "mock"
167            }
168            async fn run(&self, _input: &str) -> anyhow::Result<String> {
169                Ok("summary".to_string())
170            }
171        }
172
173        let mut history = vec![
174            HistoryMessage::system("system"),
175            HistoryMessage::user("hello"),
176        ];
177
178        let result = compact_history(&mut history, &MockSummarizer, 10)
179            .await
180            .unwrap();
181        assert!(!result, "Should not compact when history is short");
182        assert_eq!(history.len(), 2);
183    }
184
185    #[tokio::test]
186    async fn test_compact_long_history() {
187        use async_trait::async_trait;
188
189        struct MockSummarizer;
190
191        #[async_trait]
192        impl Callable for MockSummarizer {
193            fn name(&self) -> &str {
194                "mock"
195            }
196            async fn run(&self, _input: &str) -> anyhow::Result<String> {
197                Ok("Summarized: user asked about Rust, assistant explained ownership.".to_string())
198            }
199        }
200
201        let mut history = vec![HistoryMessage::system("Be helpful")];
202        // Add 30 old messages
203        for i in 0..30 {
204            history.push(HistoryMessage::user(format!("question {}", i)));
205            history.push(HistoryMessage::assistant(format!("answer {}", i)));
206        }
207
208        let original_len = history.len(); // 61
209
210        let result = compact_history(&mut history, &MockSummarizer, 5)
211            .await
212            .unwrap();
213        assert!(result, "Should have compacted");
214
215        // Should now be: system + summary + 5 recent = 7
216        assert_eq!(history.len(), 7);
217        assert!(history.len() < original_len);
218
219        // First should still be system
220        assert_eq!(history[0].role, "system");
221        assert_eq!(history[0].content, "Be helpful");
222
223        // Second should be the context summary
224        assert!(history[1].content.contains("[Context Summary"));
225    }
226}