enact_runner/
compaction.rs1use enact_core::callable::Callable;
10
11#[derive(Debug, Clone)]
13pub struct HistoryMessage {
14 pub role: String,
16 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
50pub fn needs_compaction(history: &[HistoryMessage], threshold: usize) -> bool {
52 history.len() > threshold
53}
54
55pub async fn compact_history(
68 history: &mut Vec<HistoryMessage>,
69 summarizer: &dyn Callable,
70 keep_recent: usize,
71) -> anyhow::Result<bool> {
72 if history.len() <= keep_recent + 2 {
74 return Ok(false);
75 }
76
77 let split_point = history.len() - keep_recent;
79
80 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 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 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 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(); let result = compact_history(&mut history, &MockSummarizer, 5)
211 .await
212 .unwrap();
213 assert!(result, "Should have compacted");
214
215 assert_eq!(history.len(), 7);
217 assert!(history.len() < original_len);
218
219 assert_eq!(history[0].role, "system");
221 assert_eq!(history[0].content, "Be helpful");
222
223 assert!(history[1].content.contains("[Context Summary"));
225 }
226}