behest_runtime/compaction/
prune.rs1use crate::token::estimate_record_tokens;
14use behest_store::MessageRecord;
15
16const PRUNE_MINIMUM: usize = 20_000;
19
20const PRUNE_PROTECT: usize = 40_000;
22
23const PRUNE_PROTECTED_TOOLS: &[&str] = &["skill"];
25
26pub const PRUNED_PLACEHOLDER: &str = "[Old tool result content cleared]";
28
29#[derive(Debug, Clone)]
31pub struct PruneResult {
32 pub pruned_count: usize,
34 pub tokens_freed: usize,
36}
37
38#[derive(Debug, Clone, Copy)]
41struct PruneTarget {
42 index: usize,
43}
44
45#[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 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 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 if msg.is_summary {
94 break;
95 }
96
97 if skip_until.is_some() && protected < PRUNE_PROTECT {
99 protected += estimate_record_tokens(msg);
100 continue;
101 }
102
103 if msg.role != behest_store::MessageRole::Tool {
105 continue;
106 }
107
108 if let Some(ref tool_name) = msg.tool_name
110 && PRUNE_PROTECTED_TOOLS.contains(&tool_name.as_str())
111 {
112 continue;
113 }
114
115 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 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 assert_eq!(result.pruned_count, 0);
195 }
196}