Skip to main content

ralph/
compaction.rs

1use crate::errors::Result;
2use crate::prompts::compaction_prompt;
3use crate::providers::{LlmProvider, Message};
4use std::sync::OnceLock;
5use tiktoken_rs::CoreBPE;
6
7// ── Token counting ─────────────────────────────────────────────────────────────
8
9/// Get the cl100k_base BPE, initialised once.
10fn bpe() -> &'static CoreBPE {
11    static BPE: OnceLock<CoreBPE> = OnceLock::new();
12    BPE.get_or_init(|| tiktoken_rs::cl100k_base().expect("tiktoken cl100k_base failed to load"))
13}
14
15/// Count tokens in a single string using cl100k_base.
16pub fn count_tokens(text: &str) -> u64 {
17    bpe().encode_ordinary(text).len() as u64
18}
19
20/// Extract all text from a message, including tool use inputs and tool results.
21/// `as_text()` only returns `ContentPart::Text` nodes, missing the bulk of
22/// tool-heavy conversations (file reads, command output, etc.).
23fn message_text(msg: &Message) -> String {
24    use crate::providers::{ContentPart, MessageContent};
25    match &msg.content {
26        MessageContent::Text(s) => s.clone(),
27        MessageContent::Parts(parts) => {
28            let mut buf = String::new();
29            for part in parts {
30                match part {
31                    ContentPart::Text { text } => buf.push_str(text),
32                    ContentPart::ToolUse { name, input, .. } => {
33                        buf.push_str(name);
34                        buf.push_str(&input.to_string());
35                    }
36                    ContentPart::ToolResult { content, .. } => {
37                        buf.push_str(content);
38                    }
39                }
40            }
41            buf
42        }
43    }
44}
45
46/// Count total tokens across all messages.
47pub fn estimate_tokens(messages: &[Message]) -> u64 {
48    // 4 overhead tokens per message (role tag etc.) + content tokens
49    messages
50        .iter()
51        .map(|m| count_tokens(&message_text(m)) + 4)
52        .sum()
53}
54
55/// Returns true if compaction should fire.
56/// Fires when `estimate_tokens(messages) + tool_overhead_tokens >= compact_at_tokens`.
57pub fn should_compact(
58    messages: &[Message],
59    compact_at_tokens: u64,
60    tool_overhead_tokens: u64,
61) -> bool {
62    if compact_at_tokens == 0 {
63        return true;
64    }
65    let used = estimate_tokens(messages) + tool_overhead_tokens;
66    used >= compact_at_tokens
67}
68
69// ── Helpers ───────────────────────────────────────────────────────────────────
70
71/// Trim `window` from position 1 onward (preserving index 0, the summary
72/// message) until the total token count is at or below `target_tokens`.
73fn trim_to_fit(window: &mut Vec<Message>, target_tokens: u64) {
74    while window.len() > 1 && estimate_tokens(window) > target_tokens {
75        window.remove(1);
76    }
77}
78
79/// Summarize a slice of messages into a single string using the LLM.
80/// The slice is front-trimmed to fit within the model's context window
81/// before the compaction prompt is appended.
82async fn summarize_chunk(
83    chunk: &[Message],
84    provider: &dyn LlmProvider,
85    context_window: u64,
86) -> Result<String> {
87    let prompt_tokens = count_tokens(compaction_prompt()) + 4;
88    let available = (context_window * 90 / 100).saturating_sub(prompt_tokens);
89
90    let mut start = 0usize;
91    while start < chunk.len() {
92        let tokens: u64 = chunk[start..]
93            .iter()
94            .map(|m| count_tokens(&message_text(m)) + 4)
95            .sum();
96        if tokens <= available {
97            break;
98        }
99        start += 1;
100    }
101
102    let mut msgs = chunk[start..].to_vec();
103    msgs.push(Message::user(compaction_prompt()));
104
105    let response = provider.chat(&msgs, &[]).await?;
106    Ok(response
107        .text
108        .unwrap_or_else(|| "Summary unavailable.".to_string()))
109}
110
111// ── Primary compaction ────────────────────────────────────────────────────────
112
113/// Compact the message history via an LLM-generated summary.
114///
115/// Strategy:
116/// 1. Front-trim messages so the compaction request fits in the context window.
117/// 2. Summarise with the LLM.
118/// 3. Build new_window = [summary] + last `keep_recent_turns` turns verbatim.
119/// 4. Trim new_window to ≤ `compact_to_tokens` so future turns have room.
120///
121/// Returns: (new_window, original_archive)
122pub async fn compact(
123    messages: &[Message],
124    provider: &dyn LlmProvider,
125    keep_recent_turns: usize,
126    compact_to_tokens: u64,
127) -> Result<(Vec<Message>, Vec<Message>)> {
128    let context_window = provider.context_window();
129    let summary_text = summarize_chunk(messages, provider, context_window).await?;
130
131    let recent_start = messages.len().saturating_sub(keep_recent_turns * 2);
132    let mut new_window = Vec::new();
133    new_window.push(Message::user(format!(
134        "## Compacted Session Summary\n\n{}\n\n---\nContinuing from where we left off.",
135        summary_text
136    )));
137    new_window.extend_from_slice(&messages[recent_start..]);
138
139    trim_to_fit(&mut new_window, compact_to_tokens);
140
141    Ok((new_window, messages.to_vec()))
142}
143
144// ── Fallback: split-and-double-compact ───────────────────────────────────────
145
146/// Hierarchical compaction used when primary `compact()` fails.
147///
148/// Strategy:
149/// 1. Isolate the recent verbatim tail (last `keep_recent_turns` turns).
150/// 2. Split the remaining older messages into two halves.
151/// 3. Summarise each half independently with the LLM (two API calls).
152/// 4. Combine both summaries + recent tail into the new window.
153/// 5. Trim new_window to ≤ 50 % of context_window.
154///
155/// Because each half is at most ~50 % of the full history, the summarisation
156/// requests are well within the context window even when the full history
157/// is not.
158pub async fn compact_split(
159    messages: &[Message],
160    provider: &dyn LlmProvider,
161    keep_recent_turns: usize,
162    compact_to_tokens: u64,
163) -> Result<(Vec<Message>, Vec<Message>)> {
164    let context_window = provider.context_window();
165    let recent_start = messages.len().saturating_sub(keep_recent_turns * 2);
166    let recent = &messages[recent_start..];
167    let old = &messages[..recent_start];
168
169    // If there is nothing to split, fall through to a single-pass summary.
170    let (summary_1, summary_2) = if old.len() < 2 {
171        let s = summarize_chunk(messages, provider, context_window).await?;
172        (s, String::new())
173    } else {
174        let mid = old.len() / 2;
175        let (s1, s2) = tokio::try_join!(
176            summarize_chunk(&old[..mid], provider, context_window),
177            summarize_chunk(&old[mid..], provider, context_window),
178        )?;
179        (s1, s2)
180    };
181
182    let combined = if summary_2.is_empty() {
183        summary_1
184    } else {
185        format!(
186            "## Part 1 (earliest history)\n\n{}\n\n## Part 2 (continued)\n\n{}",
187            summary_1, summary_2
188        )
189    };
190
191    let mut new_window = Vec::new();
192    new_window.push(Message::user(format!(
193        "## Compacted Session Summary\n\n{}\n\n---\nContinuing from where we left off.",
194        combined
195    )));
196    new_window.extend_from_slice(recent);
197
198    trim_to_fit(&mut new_window, compact_to_tokens);
199
200    Ok((new_window, messages.to_vec()))
201}
202
203// ── Archive ───────────────────────────────────────────────────────────────────
204
205/// Append the pre-compaction history to the archive on disk.
206pub fn archive(session_dir: &std::path::Path, original: &[Message]) -> Result<()> {
207    let archive_path = session_dir.join("compacted.json");
208    let mut existing: Vec<Vec<Message>> = if archive_path.exists() {
209        let s = std::fs::read_to_string(&archive_path).unwrap_or_default();
210        serde_json::from_str(&s).unwrap_or_default()
211    } else {
212        Vec::new()
213    };
214    existing.push(original.to_vec());
215    let content = serde_json::to_string_pretty(&existing)?;
216    std::fs::write(&archive_path, content)?;
217    Ok(())
218}