Skip to main content

behest_runtime/compaction/
prune.rs

1//! Tool output pruning.
2//!
3//! When conversation history grows long, older tool call outputs are
4//! marked as "pruned" — their content is replaced with a placeholder
5//! string to free context tokens without permanently deleting data.
6//!
7//! Pruning is distinct from compaction: pruning operates on individual
8//! tool output messages (cheap, no LLM call), while compaction
9//! summarises entire conversation segments (expensive, LLM call).
10//!
11//! Ported from OpenCode V1: `packages/opencode/src/session/compaction.ts` prune().
12
13use crate::token::estimate_record_tokens;
14use behest_store::MessageRecord;
15
16/// Minimum number of tokens that must be pruned to make it worthwhile.
17/// Pruning fewer tokens than this is skipped.
18const PRUNE_MINIMUM: usize = 20_000;
19
20/// Number of recent tokens to protect from pruning.
21const PRUNE_PROTECT: usize = 40_000;
22
23/// Tool names whose outputs are never pruned.
24const PRUNE_PROTECTED_TOOLS: &[&str] = &["skill"];
25
26/// Placeholder text replacing pruned tool outputs.
27pub const PRUNED_PLACEHOLDER: &str = "[Old tool result content cleared]";
28
29/// Result of a pruning pass.
30#[derive(Debug, Clone)]
31pub struct PruneResult {
32    /// Number of tool output messages marked as pruned.
33    pub pruned_count: usize,
34    /// Estimated number of tokens freed.
35    pub tokens_freed: usize,
36}
37
38/// Indices of messages to prune, collected before mutation to ensure
39/// atomicity with respect to the [`PRUNE_MINIMUM`] threshold.
40#[derive(Debug, Clone, Copy)]
41struct PruneTarget {
42    index: usize,
43}
44
45/// Marks old tool output messages for pruning.
46///
47/// Two-pass strategy: first pass collects indices of messages that
48/// *would* be pruned and computes the total token savings. Only if
49/// savings exceed `PRUNE_MINIMUM` does the second pass apply the
50/// in-place mutations. This prevents partial data corruption when
51/// the minimum threshold is not met.
52///
53/// Walks backward through messages, protecting the most recent
54/// `PRUNE_PROTECT` tokens worth of tool outputs. Older tool outputs
55/// with sufficient total token savings are marked as pruned.
56///
57/// # Arguments
58/// * `messages` - Mutable slice of messages (in chronological order).
59/// * `skip_turns` - Number of recent turns to skip entirely (default 2).
60///
61/// # Returns
62/// The number of messages marked for pruning and estimated tokens freed.
63#[must_use]
64pub fn prune_tool_outputs(messages: &mut [MessageRecord], skip_turns: usize) -> PruneResult {
65    if messages.is_empty() {
66        return PruneResult {
67            pruned_count: 0,
68            tokens_freed: 0,
69        };
70    }
71
72    // Count turns from the end to skip
73    let mut turn_count = 0usize;
74    let mut skip_until: Option<usize> = None;
75
76    for (i, msg) in messages.iter().enumerate().rev() {
77        if !msg.is_compaction && msg.role == behest_store::MessageRole::User {
78            turn_count += 1;
79            if turn_count >= skip_turns {
80                skip_until = Some(i);
81                break;
82            }
83        }
84    }
85
86    // --- Pass 1: collect targets, check threshold ---
87    let mut protected = 0usize;
88    let mut targets: Vec<PruneTarget> = Vec::new();
89    let mut estimated_freed = 0usize;
90
91    for (idx, msg) in messages.iter().enumerate().rev() {
92        // Stop if we hit a compaction summary
93        if msg.is_summary {
94            break;
95        }
96
97        // Skip protected recent turns
98        if skip_until.is_some() && protected < PRUNE_PROTECT {
99            protected += estimate_record_tokens(msg);
100            continue;
101        }
102
103        // Only prune tool messages
104        if msg.role != behest_store::MessageRole::Tool {
105            continue;
106        }
107
108        // Never prune protected tools
109        if let Some(ref tool_name) = msg.tool_name
110            && PRUNE_PROTECTED_TOOLS.contains(&tool_name.as_str())
111        {
112            continue;
113        }
114
115        // Skip already pruned messages
116        if msg.is_summary {
117            continue;
118        }
119
120        let tokens = estimate_record_tokens(msg);
121
122        if protected < PRUNE_PROTECT {
123            protected += tokens;
124            continue;
125        }
126
127        targets.push(PruneTarget { index: idx });
128        estimated_freed += tokens;
129    }
130
131    if estimated_freed < PRUNE_MINIMUM {
132        return PruneResult {
133            pruned_count: 0,
134            tokens_freed: 0,
135        };
136    }
137
138    // --- Pass 2: apply mutations ---
139    for target in &targets {
140        let msg = &mut messages[target.index];
141        msg.is_summary = true;
142        msg.content = vec![behest_provider::ContentPart::text(PRUNED_PLACEHOLDER)];
143    }
144
145    PruneResult {
146        pruned_count: targets.len(),
147        tokens_freed: estimated_freed,
148    }
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used)]
153mod tests {
154    use super::*;
155    use behest_provider::ContentPart;
156    use uuid::Uuid;
157
158    fn make_tool_msg(name: &str, output: &str) -> MessageRecord {
159        MessageRecord {
160            id: Uuid::now_v7(),
161            session_id: Uuid::now_v7(),
162            role: behest_store::MessageRole::Tool,
163            content: vec![ContentPart::text(output)],
164            tool_calls: Vec::new(),
165            tool_call_id: Some("call_1".to_owned()),
166            tool_name: Some(name.to_owned()),
167            usage: None,
168            created_at: chrono::Utc::now(),
169            is_compaction: false,
170            is_summary: false,
171            compaction_meta: None,
172        }
173    }
174
175    #[test]
176    fn empty_messages() {
177        let mut messages = Vec::new();
178        let result = prune_tool_outputs(&mut messages, 2);
179        assert_eq!(result.pruned_count, 0);
180    }
181
182    #[test]
183    fn skill_tools_are_protected() {
184        let mut messages = vec![make_tool_msg("skill", "important skill output")];
185        let result = prune_tool_outputs(&mut messages, 0);
186        assert_eq!(result.pruned_count, 0);
187    }
188
189    #[test]
190    fn small_output_not_pruned() {
191        let mut messages = vec![make_tool_msg("read_file", "short")];
192        let result = prune_tool_outputs(&mut messages, 0);
193        // PRUNE_MINIMUM is 20_000 — a single short tool output won't reach it
194        assert_eq!(result.pruned_count, 0);
195    }
196}