1use std::path::PathBuf;
2
3use crate::errors::TokenFoldError;
4use crate::input::{CompressionInput, InputFormat};
5use crate::token_estimator::TokenEstimator;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct CompressionPolicy {
9 pub target_tokens: Option<usize>,
10 pub reserve_output_tokens: usize,
11 pub mode: CompressionMode,
12 pub task_scope: TaskScope,
13 pub cache_boundary: Option<CacheBoundary>,
14 pub preserve_latest_user_message: bool,
15 pub disabled: Vec<String>,
16 pub unsafe_disable_redaction: bool,
17 pub experimental: bool,
21 pub enable: Vec<String>,
25 pub store_originals: bool,
29 pub retrieval_namespace: String,
32 pub retrieval_ttl_seconds: Option<u64>,
36 pub retrieval_backend: String,
40 pub retrieval_store_path: Option<PathBuf>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CompressionMode {
47 Conservative,
48 Balanced,
49 Aggressive,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum TaskScope {
54 All,
55 General,
56 CodeReview,
57 ChangeSummary,
58 Debugging,
59 Generation,
60 ApiOverview,
61 RetrievalQa,
62 AgentHistory,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum CacheBoundary {
67 ByteOffset(usize),
68 TurnIndex(usize),
69}
70
71impl CompressionPolicy {
72 pub fn builder() -> CompressionPolicyBuilder {
73 CompressionPolicyBuilder::default()
74 }
75}
76
77#[derive(Debug, Clone, Default)]
78pub struct CompressionPolicyBuilder {
79 target_tokens: Option<usize>,
80 reserve_output_tokens: Option<usize>,
81 mode: Option<CompressionMode>,
82 task_scope: Option<TaskScope>,
83 cache_boundary: Option<CacheBoundary>,
84 preserve_latest_user_message: Option<bool>,
85 disabled: Vec<String>,
86 unsafe_disable_redaction: bool,
87 experimental: bool,
88 enable: Vec<String>,
89 store_originals: bool,
90 retrieval_namespace: Option<String>,
91 retrieval_ttl_seconds: Option<u64>,
92 retrieval_backend: Option<String>,
93 retrieval_store_path: Option<PathBuf>,
94}
95
96impl CompressionPolicyBuilder {
97 pub fn target_tokens(mut self, target_tokens: usize) -> Self {
98 self.target_tokens = Some(target_tokens);
99 self
100 }
101
102 pub fn reserve_output_tokens(mut self, reserve_output_tokens: usize) -> Self {
103 self.reserve_output_tokens = Some(reserve_output_tokens);
104 self
105 }
106
107 pub fn mode(mut self, mode: CompressionMode) -> Self {
108 self.mode = Some(mode);
109 self
110 }
111
112 pub fn task_scope(mut self, task_scope: TaskScope) -> Self {
113 self.task_scope = Some(task_scope);
114 self
115 }
116
117 pub fn cache_boundary(mut self, cache_boundary: CacheBoundary) -> Self {
118 self.cache_boundary = Some(cache_boundary);
119 self
120 }
121
122 pub fn preserve_latest_user_message(mut self, preserve: bool) -> Self {
123 self.preserve_latest_user_message = Some(preserve);
124 self
125 }
126
127 pub fn disable(mut self, transform_id: impl Into<String>) -> Self {
128 self.disabled.push(transform_id.into());
129 self
130 }
131
132 pub fn unsafe_disable_redaction(mut self, unsafe_disable: bool) -> Self {
133 self.unsafe_disable_redaction = unsafe_disable;
134 self
135 }
136
137 pub fn experimental(mut self, experimental: bool) -> Self {
138 self.experimental = experimental;
139 self
140 }
141
142 pub fn enable(mut self, transform_id: impl Into<String>) -> Self {
143 self.enable.push(transform_id.into());
144 self
145 }
146
147 pub fn store_originals(mut self, store_originals: bool) -> Self {
148 self.store_originals = store_originals;
149 self
150 }
151
152 pub fn retrieval_namespace(mut self, namespace: impl Into<String>) -> Self {
153 self.retrieval_namespace = Some(namespace.into());
154 self
155 }
156
157 pub fn retrieval_ttl_seconds(mut self, ttl_seconds: Option<u64>) -> Self {
158 self.retrieval_ttl_seconds = ttl_seconds;
159 self
160 }
161
162 pub fn retrieval_backend(mut self, backend: impl Into<String>) -> Self {
163 self.retrieval_backend = Some(backend.into());
164 self
165 }
166
167 pub fn retrieval_store_path(mut self, store_path: Option<PathBuf>) -> Self {
168 self.retrieval_store_path = store_path;
169 self
170 }
171
172 pub fn build(self) -> Result<CompressionPolicy, TokenFoldError> {
173 if self.disabled.iter().any(|id| id == "secret_redaction") {
174 return Err(TokenFoldError::ConfigError(
175 "secret_redaction cannot be disabled via CompressionPolicy.disabled".to_string(),
176 ));
177 }
178 Ok(CompressionPolicy {
179 target_tokens: self.target_tokens,
180 reserve_output_tokens: self.reserve_output_tokens.unwrap_or(0),
181 mode: self.mode.unwrap_or(CompressionMode::Balanced),
182 task_scope: self.task_scope.unwrap_or(TaskScope::All),
183 cache_boundary: self.cache_boundary,
184 preserve_latest_user_message: self.preserve_latest_user_message.unwrap_or(true),
185 disabled: self.disabled,
186 unsafe_disable_redaction: self.unsafe_disable_redaction,
187 experimental: self.experimental,
188 enable: self.enable,
189 store_originals: self.store_originals,
190 retrieval_namespace: self
191 .retrieval_namespace
192 .unwrap_or_else(|| "default".to_string()),
193 retrieval_ttl_seconds: self.retrieval_ttl_seconds,
194 retrieval_backend: self
195 .retrieval_backend
196 .unwrap_or_else(|| "filesystem".to_string()),
197 retrieval_store_path: self.retrieval_store_path,
198 })
199 }
200}
201
202pub fn protected_floor(
204 input: &CompressionInput,
205 policy: &CompressionPolicy,
206 estimator: &dyn TokenEstimator,
207) -> usize {
208 estimator.count_bytes(&protected_segments(input, policy).concat())
209}
210
211pub fn protected_segments(input: &CompressionInput, policy: &CompressionPolicy) -> Vec<Vec<u8>> {
217 match input.format {
218 InputFormat::OpenAiJson => extract_openai_protected(&input.bytes, policy),
219 InputFormat::AnthropicJson => extract_anthropic_protected(&input.bytes, policy),
220 InputFormat::GitDiff => extract_diff_protected(&input.bytes),
221 InputFormat::PlainText
226 | InputFormat::CommandOutput
227 | InputFormat::Json
228 | InputFormat::Auto => Vec::new(),
229 }
230}
231
232fn extract_openai_protected(bytes: &[u8], policy: &CompressionPolicy) -> Vec<Vec<u8>> {
233 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
234 return Vec::new();
235 };
236 let Some(messages) = value.get("messages").and_then(|m| m.as_array()) else {
237 return Vec::new();
238 };
239
240 let mut segments = Vec::new();
241 for message in messages {
242 if message.get("role").and_then(|r| r.as_str()) == Some("system")
243 && let Some(bytes) = message_content_bytes(message)
244 {
245 segments.push(bytes);
246 }
247 }
248 if policy.preserve_latest_user_message
249 && let Some(last_user) = messages
250 .iter()
251 .rev()
252 .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
253 && let Some(bytes) = message_content_bytes(last_user)
254 {
255 segments.push(bytes);
256 }
257 segments
258}
259
260fn extract_anthropic_protected(bytes: &[u8], policy: &CompressionPolicy) -> Vec<Vec<u8>> {
261 let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
262 return Vec::new();
263 };
264
265 let mut segments = Vec::new();
266 if let Some(system) = value.get("system").and_then(|s| s.as_str()) {
267 segments.push(system.as_bytes().to_vec());
268 }
269 if policy.preserve_latest_user_message
270 && let Some(last_user) =
271 value
272 .get("messages")
273 .and_then(|m| m.as_array())
274 .and_then(|messages| {
275 messages
276 .iter()
277 .rev()
278 .find(|m| m.get("role").and_then(|r| r.as_str()) == Some("user"))
279 })
280 && let Some(bytes) = message_content_bytes(last_user)
281 {
282 segments.push(bytes);
283 }
284 segments
285}
286
287fn message_content_bytes(message: &serde_json::Value) -> Option<Vec<u8>> {
288 match message.get("content") {
289 Some(serde_json::Value::String(text)) => Some(text.as_bytes().to_vec()),
290 Some(structured) => serde_json::to_vec(structured).ok(),
291 None => None,
292 }
293}
294
295fn extract_diff_protected(bytes: &[u8]) -> Vec<Vec<u8>> {
298 let text = String::from_utf8_lossy(bytes);
299 let mut segments = Vec::new();
300 for line in text.lines() {
301 if line.starts_with("diff --git")
302 || line.starts_with("--- ")
303 || line.starts_with("+++ ")
304 || line.starts_with("@@")
305 {
306 let mut segment = line.as_bytes().to_vec();
307 segment.push(b'\n');
308 segments.push(segment);
309 }
310 }
311 segments
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::token_estimator::ByteHeuristicEstimator;
318
319 #[test]
320 fn default_mode_is_balanced() {
321 let policy = CompressionPolicy::builder().build().unwrap();
322 assert_eq!(policy.mode, CompressionMode::Balanced);
323 }
324
325 #[test]
326 fn store_originals_defaults_to_false_with_a_default_namespace() {
327 let policy = CompressionPolicy::builder().build().unwrap();
328 assert!(!policy.store_originals);
329 assert_eq!(policy.retrieval_namespace, "default");
330 }
331
332 #[test]
333 fn store_originals_and_namespace_are_settable_via_the_builder() {
334 let policy = CompressionPolicy::builder()
335 .store_originals(true)
336 .retrieval_namespace("project-x")
337 .retrieval_ttl_seconds(Some(60))
338 .retrieval_backend("memory")
339 .retrieval_store_path(Some(std::path::PathBuf::from("/tmp/custom")))
340 .build()
341 .unwrap();
342 assert!(policy.store_originals);
343 assert_eq!(policy.retrieval_namespace, "project-x");
344 assert_eq!(policy.retrieval_ttl_seconds, Some(60));
345 assert_eq!(policy.retrieval_backend, "memory");
346 assert_eq!(
347 policy.retrieval_store_path,
348 Some(std::path::PathBuf::from("/tmp/custom"))
349 );
350 }
351
352 #[test]
353 fn retrieval_defaults_are_none_ttl_and_filesystem_backend() {
354 let policy = CompressionPolicy::builder().build().unwrap();
355 assert_eq!(policy.retrieval_ttl_seconds, None);
356 assert_eq!(policy.retrieval_backend, "filesystem");
357 assert_eq!(policy.retrieval_store_path, None);
358 }
359
360 #[test]
361 fn secret_redaction_cannot_be_disabled_through_policy() {
362 let err = CompressionPolicy::builder()
363 .disable("secret_redaction")
364 .build()
365 .unwrap_err();
366 assert!(matches!(err, TokenFoldError::ConfigError(_)));
367 }
368
369 #[test]
370 fn disabling_other_transforms_is_allowed() {
371 let policy = CompressionPolicy::builder()
372 .disable("json_minify")
373 .build()
374 .unwrap();
375 assert_eq!(policy.disabled, vec!["json_minify".to_string()]);
376 }
377
378 #[test]
379 fn floor_is_zero_for_plain_text() {
380 let input = CompressionInput::plain_text(b"just some plain text".to_vec());
381 let policy = CompressionPolicy::builder().build().unwrap();
382 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
383 assert_eq!(floor, 0);
384 }
385
386 #[test]
387 fn floor_covers_system_and_latest_user_message_for_openai_json() {
388 let payload = serde_json::json!({
389 "model": "gpt-4",
390 "messages": [
391 {"role": "system", "content": "You are a helpful assistant."},
392 {"role": "user", "content": "first question"},
393 {"role": "assistant", "content": "first answer"},
394 {"role": "user", "content": "second question"},
395 ]
396 });
397 let input = CompressionInput::openai_json(serde_json::to_vec(&payload).unwrap());
398 let policy = CompressionPolicy::builder().build().unwrap();
399 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
400
401 let expected_bytes = "You are a helpful assistant.".len() + "second question".len();
402 assert_eq!(
403 floor,
404 ByteHeuristicEstimator.count_bytes(&vec![0u8; expected_bytes])
405 );
406 assert!(floor < ByteHeuristicEstimator.count_bytes(input.bytes.as_slice()));
408 }
409
410 #[test]
411 fn floor_excludes_latest_user_message_when_policy_disables_preservation() {
412 let payload = serde_json::json!({
413 "messages": [
414 {"role": "system", "content": "system prompt"},
415 {"role": "user", "content": "question"},
416 ]
417 });
418 let input = CompressionInput::openai_json(serde_json::to_vec(&payload).unwrap());
419 let policy = CompressionPolicy::builder()
420 .preserve_latest_user_message(false)
421 .build()
422 .unwrap();
423 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
424 assert_eq!(floor, ByteHeuristicEstimator.count_bytes(b"system prompt"));
425 }
426
427 #[test]
428 fn floor_covers_system_and_latest_user_message_for_anthropic_json() {
429 let payload = serde_json::json!({
430 "system": "system prompt",
431 "messages": [
432 {"role": "user", "content": "first"},
433 {"role": "assistant", "content": "reply"},
434 {"role": "user", "content": "second"},
435 ]
436 });
437 let input = CompressionInput::anthropic_json(serde_json::to_vec(&payload).unwrap());
438 let policy = CompressionPolicy::builder().build().unwrap();
439 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
440 let expected_bytes = "system prompt".len() + "second".len();
441 assert_eq!(
442 floor,
443 ByteHeuristicEstimator.count_bytes(&vec![0u8; expected_bytes])
444 );
445 }
446
447 #[test]
448 fn floor_keeps_diff_headers_and_hunk_markers_only() {
449 let diff =
450 b"diff --git a/f.rs b/f.rs\n--- a/f.rs\n+++ b/f.rs\n@@ -1,2 +1,2 @@\n-old\n+new\n";
451 let input = CompressionInput::git_diff(diff.to_vec());
452 let policy = CompressionPolicy::builder().build().unwrap();
453 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
454 assert!(floor > 0);
455 assert!(floor < ByteHeuristicEstimator.count_bytes(diff));
456 }
457
458 #[test]
459 fn malformed_json_never_panics_and_yields_zero_floor() {
460 let input = CompressionInput::openai_json(b"{not json".to_vec());
461 let policy = CompressionPolicy::builder().build().unwrap();
462 let floor = protected_floor(&input, &policy, &ByteHeuristicEstimator);
463 assert_eq!(floor, 0);
464 }
465}