1use std::cell::RefCell;
15use std::collections::BTreeMap;
16use std::thread_local;
17
18use serde::{Deserialize, Serialize};
19
20use super::{compact_strategy_name, parse_compact_strategy, CompactStrategy, CompactionPolicy};
21use crate::value::VmValue;
22
23pub const DEFAULT_SAFETY_RATIO: f64 = 0.7;
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum PolicyStrategy {
34 Summarize,
36 SummarizeThenPrune,
38 HeadAndTail,
42 Window,
45 ObservationMask,
48 Custom,
50}
51
52impl PolicyStrategy {
53 pub fn as_str(self) -> &'static str {
54 match self {
55 Self::Summarize => "summarize",
56 Self::SummarizeThenPrune => "summarize-then-prune",
57 Self::HeadAndTail => "head+tail",
58 Self::Window => "window",
59 Self::ObservationMask => "observation_mask",
60 Self::Custom => "custom",
61 }
62 }
63
64 pub fn parse(value: &str) -> Result<Self, String> {
70 match value.trim() {
71 "summarize" | "llm" => Ok(Self::Summarize),
72 "summarize-then-prune" | "summarize_then_prune" => Ok(Self::SummarizeThenPrune),
73 "head+tail" | "head-tail" | "head_tail" => Ok(Self::HeadAndTail),
74 "window" | "truncate" => Ok(Self::Window),
75 "observation_mask" | "observation-mask" | "mask" => Ok(Self::ObservationMask),
76 "custom" => Ok(Self::Custom),
77 other => Err(format!(
78 "unknown compaction policy strategy '{other}' (expected one of: summarize, \
79 summarize-then-prune, head+tail, window, observation_mask, custom)"
80 )),
81 }
82 }
83
84 pub fn engine_strategy(self) -> CompactStrategy {
86 match self {
87 Self::Summarize | Self::SummarizeThenPrune => CompactStrategy::Llm,
88 Self::HeadAndTail | Self::Window => CompactStrategy::Truncate,
89 Self::ObservationMask => CompactStrategy::ObservationMask,
90 Self::Custom => CompactStrategy::Custom,
91 }
92 }
93
94 pub fn engine_fallback(self) -> Option<CompactStrategy> {
98 match self {
99 Self::SummarizeThenPrune => Some(CompactStrategy::Truncate),
100 _ => None,
101 }
102 }
103}
104
105#[derive(Clone, Debug)]
110pub struct CompactionPolicyDeclaration {
111 pub strategy: PolicyStrategy,
112 pub max_tokens: Option<usize>,
115 pub max_turns: Option<usize>,
118 pub context_window: Option<usize>,
122 pub safety_ratio: f64,
123 pub keep_last: usize,
125 pub keep_first: usize,
127 pub hard_limit_tokens: Option<usize>,
131 pub tool_output_max_chars: Option<usize>,
134 pub summarize_fn: Option<VmValue>,
136 pub summarize_prompt: Option<String>,
139 pub instructions: CompactionPolicy,
142}
143
144impl Default for CompactionPolicyDeclaration {
145 fn default() -> Self {
146 Self {
147 strategy: PolicyStrategy::SummarizeThenPrune,
148 max_tokens: None,
149 max_turns: None,
150 context_window: None,
151 safety_ratio: DEFAULT_SAFETY_RATIO,
152 keep_last: 12,
153 keep_first: 0,
154 hard_limit_tokens: None,
155 tool_output_max_chars: None,
156 summarize_fn: None,
157 summarize_prompt: None,
158 instructions: CompactionPolicy::default(),
159 }
160 }
161}
162
163impl CompactionPolicyDeclaration {
164 pub fn token_threshold(&self) -> Option<usize> {
169 let ratio_threshold = self.context_window.map(|window| {
170 let raw = (window as f64) * self.safety_ratio;
171 if raw.is_finite() && raw > 0.0 {
172 raw.floor() as usize
173 } else {
174 window
175 }
176 });
177 match (self.max_tokens, ratio_threshold) {
178 (Some(a), Some(b)) => Some(a.min(b)),
179 (Some(a), None) => Some(a),
180 (None, Some(b)) => Some(b),
181 (None, None) => None,
182 }
183 }
184
185 pub fn evaluate(&self, estimated_tokens: usize, message_count: usize) -> EvaluationContext {
189 let token_threshold = self.token_threshold();
190 let token_trigger = token_threshold.is_some_and(|cap| estimated_tokens > cap);
191 let turn_trigger = self
192 .max_turns
193 .is_some_and(|cap| cap > 0 && message_count > cap);
194 EvaluationContext {
195 token_threshold,
196 token_trigger,
197 turn_trigger,
198 estimated_tokens,
199 message_count,
200 strategy: self.strategy,
201 }
202 }
203
204 pub fn to_json(&self) -> serde_json::Value {
206 let mut map = serde_json::Map::new();
207 map.insert(
208 "strategy".to_string(),
209 serde_json::Value::String(self.strategy.as_str().to_string()),
210 );
211 map.insert(
212 "engine_strategy".to_string(),
213 serde_json::Value::String(
214 compact_strategy_name(&self.strategy.engine_strategy()).to_string(),
215 ),
216 );
217 if let Some(value) = self.max_tokens {
218 map.insert("max_tokens".to_string(), serde_json::json!(value));
219 }
220 if let Some(value) = self.max_turns {
221 map.insert("max_turns".to_string(), serde_json::json!(value));
222 }
223 if let Some(value) = self.context_window {
224 map.insert("context_window".to_string(), serde_json::json!(value));
225 }
226 map.insert(
227 "safety_ratio".to_string(),
228 serde_json::json!(self.safety_ratio),
229 );
230 map.insert("keep_last".to_string(), serde_json::json!(self.keep_last));
231 if self.keep_first > 0 {
232 map.insert("keep_first".to_string(), serde_json::json!(self.keep_first));
233 }
234 if let Some(value) = self.hard_limit_tokens {
235 map.insert("hard_limit_tokens".to_string(), serde_json::json!(value));
236 }
237 if let Some(value) = self.tool_output_max_chars {
238 map.insert(
239 "tool_output_max_chars".to_string(),
240 serde_json::json!(value),
241 );
242 }
243 if let Some(threshold) = self.token_threshold() {
244 map.insert("token_threshold".to_string(), serde_json::json!(threshold));
245 }
246 if let Some(policy_json) = self.instructions.metadata_json() {
247 map.insert("instructions".to_string(), policy_json);
248 }
249 serde_json::Value::Object(map)
250 }
251}
252
253#[derive(Clone, Debug)]
256pub struct EvaluationContext {
257 pub token_threshold: Option<usize>,
258 pub token_trigger: bool,
259 pub turn_trigger: bool,
260 pub estimated_tokens: usize,
261 pub message_count: usize,
262 pub strategy: PolicyStrategy,
263}
264
265impl EvaluationContext {
266 pub fn fires(&self) -> bool {
267 self.token_trigger || self.turn_trigger
268 }
269
270 pub fn trigger_label(&self) -> &'static str {
271 match (self.token_trigger, self.turn_trigger) {
272 (true, true) => "tokens_and_turns",
273 (true, false) => "tokens",
274 (false, true) => "turns",
275 (false, false) => "manual",
276 }
277 }
278}
279
280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283pub enum CompactionAction {
284 CompactNow,
285 Defer,
286 Abandon,
287}
288
289impl CompactionAction {
290 pub fn as_str(self) -> &'static str {
291 match self {
292 Self::CompactNow => "compact_now",
293 Self::Defer => "defer",
294 Self::Abandon => "abandon",
295 }
296 }
297}
298
299#[derive(Clone, Debug, Serialize, Deserialize)]
304pub struct CompactionDecision {
305 pub action: String,
306 pub session_id: String,
307 pub estimated_tokens: usize,
308 pub message_count: usize,
309 pub trigger: String,
310 pub strategy: String,
311 #[serde(skip_serializing_if = "Option::is_none")]
312 pub token_threshold: Option<usize>,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 pub turn_threshold: Option<usize>,
315 pub engine_strategy: String,
319 pub policy_inherited: bool,
322}
323
324const DEFAULT_POLICY_KEY: &str = "";
327
328thread_local! {
329 static POLICIES: RefCell<BTreeMap<String, CompactionPolicyDeclaration>> =
330 const { RefCell::new(BTreeMap::new()) };
331}
332
333pub fn set_policy(session_id: &str, policy: CompactionPolicyDeclaration) {
337 POLICIES.with(|cell| {
338 cell.borrow_mut().insert(session_id.to_string(), policy);
339 });
340}
341
342pub fn clear_policy(session_id: &str) -> Option<CompactionPolicyDeclaration> {
345 POLICIES.with(|cell| cell.borrow_mut().remove(session_id))
346}
347
348pub fn policy_for(session_id: &str) -> Option<(CompactionPolicyDeclaration, bool)> {
351 POLICIES.with(|cell| {
352 let borrow = cell.borrow();
353 if let Some(policy) = borrow.get(session_id) {
354 return Some((policy.clone(), false));
355 }
356 borrow
357 .get(DEFAULT_POLICY_KEY)
358 .map(|policy| (policy.clone(), true))
359 })
360}
361
362pub fn reset_registry() {
365 POLICIES.with(|cell| cell.borrow_mut().clear());
366}
367
368pub fn to_auto_compact_config(policy: &CompactionPolicyDeclaration) -> super::AutoCompactConfig {
372 let engine_strategy = policy.strategy.engine_strategy();
373 let mut cfg = super::AutoCompactConfig {
374 keep_last: policy.keep_last,
375 keep_first: policy.keep_first,
376 compact_strategy: engine_strategy.clone(),
377 hard_limit_strategy: engine_strategy,
378 fallback_strategy: policy.strategy.engine_fallback(),
379 summarize_prompt: policy.summarize_prompt.clone(),
380 custom_compactor: policy.summarize_fn.clone(),
381 policy: policy.instructions.clone(),
382 policy_strategy: policy.strategy.as_str().to_string(),
383 ..Default::default()
384 };
385 if let Some(threshold) = policy.token_threshold() {
386 cfg.token_threshold = threshold;
387 } else {
388 cfg.token_threshold = 0;
389 }
390 cfg.hard_limit_tokens = policy.hard_limit_tokens;
395 if let Some(value) = policy.tool_output_max_chars {
396 cfg.tool_output_max_chars = value;
397 }
398 cfg
399}
400
401pub fn parse_policy_dict(
404 builtin: &str,
405 dict: &BTreeMap<String, VmValue>,
406) -> Result<CompactionPolicyDeclaration, String> {
407 let mut policy = CompactionPolicyDeclaration::default();
408 if let Some(value) = dict.get("strategy") {
409 match value {
410 VmValue::String(text) => {
411 policy.strategy =
412 PolicyStrategy::parse(text).map_err(|e| format!("{builtin}: {e}"))?;
413 }
414 VmValue::Nil => {}
415 other => {
416 return Err(format!(
417 "{builtin}: `strategy` must be a string, got {}",
418 other.type_name()
419 ));
420 }
421 }
422 }
423 if let Some(value) = optional_usize(dict, "max_tokens", builtin)? {
424 policy.max_tokens = Some(value);
425 }
426 if let Some(value) = optional_usize(dict, "max_turns", builtin)? {
427 policy.max_turns = Some(value);
428 }
429 if let Some(value) = optional_usize(dict, "context_window", builtin)? {
430 policy.context_window = Some(value);
431 }
432 if let Some(value) = optional_f64(dict, "safety_ratio", builtin)? {
433 if !(0.0..=1.0).contains(&value) {
434 return Err(format!(
435 "{builtin}: `safety_ratio` must be between 0.0 and 1.0, got {value}"
436 ));
437 }
438 policy.safety_ratio = value;
439 }
440 if let Some(value) = optional_usize(dict, "keep_last", builtin)? {
441 policy.keep_last = value;
442 }
443 if let Some(value) = optional_usize(dict, "keep_first", builtin)? {
444 policy.keep_first = value;
445 }
446 if let Some(value) = optional_usize(dict, "hard_limit_tokens", builtin)? {
447 policy.hard_limit_tokens = Some(value);
448 }
449 if let Some(value) = optional_usize(dict, "tool_output_max_chars", builtin)? {
450 policy.tool_output_max_chars = Some(value);
451 }
452 if let Some(value) = dict.get("summarize_fn") {
453 match value {
454 VmValue::Closure(_) => {
455 policy.summarize_fn = Some(value.clone());
456 }
457 VmValue::Nil => {}
458 other => {
459 return Err(format!(
460 "{builtin}: `summarize_fn` must be a closure, got {}",
461 other.type_name()
462 ));
463 }
464 }
465 }
466 if let Some(value) = dict.get("summarize_prompt") {
467 match value {
468 VmValue::String(text) => {
469 let trimmed = text.trim();
470 if !trimmed.is_empty() {
471 policy.summarize_prompt = Some(trimmed.to_string());
472 }
473 }
474 VmValue::Nil => {}
475 other => {
476 return Err(format!(
477 "{builtin}: `summarize_prompt` must be a string, got {}",
478 other.type_name()
479 ));
480 }
481 }
482 }
483
484 policy.instructions = super::parse_compaction_policy_options(Some(dict), builtin)
488 .map_err(|error| format!("{builtin}: {}", display_vm_error(&error)))?;
489
490 if matches!(policy.strategy, PolicyStrategy::Custom) && policy.summarize_fn.is_none() {
491 return Err(format!(
492 "{builtin}: `summarize_fn` is required when strategy is 'custom'"
493 ));
494 }
495 if matches!(policy.strategy, PolicyStrategy::SummarizeThenPrune)
496 && parse_compact_strategy("truncate").is_err()
497 {
498 return Err(format!(
502 "{builtin}: summarize-then-prune fallback 'truncate' is no longer a known engine strategy"
503 ));
504 }
505 Ok(policy)
506}
507
508fn display_vm_error(error: &crate::value::VmError) -> String {
509 match error {
510 crate::value::VmError::Runtime(message) => message.clone(),
511 other => format!("{other:?}"),
512 }
513}
514
515fn optional_usize(
516 dict: &BTreeMap<String, VmValue>,
517 key: &str,
518 builtin: &str,
519) -> Result<Option<usize>, String> {
520 match dict.get(key) {
521 None | Some(VmValue::Nil) => Ok(None),
522 Some(VmValue::Int(value)) => {
523 if *value < 0 {
524 return Err(format!("{builtin}: `{key}` must be >= 0, got {value}"));
525 }
526 Ok(Some(*value as usize))
527 }
528 Some(other) => Err(format!(
529 "{builtin}: `{key}` must be an int, got {}",
530 other.type_name()
531 )),
532 }
533}
534
535fn optional_f64(
536 dict: &BTreeMap<String, VmValue>,
537 key: &str,
538 builtin: &str,
539) -> Result<Option<f64>, String> {
540 match dict.get(key) {
541 None | Some(VmValue::Nil) => Ok(None),
542 Some(VmValue::Float(value)) => Ok(Some(*value)),
543 Some(VmValue::Int(value)) => Ok(Some(*value as f64)),
544 Some(other) => Err(format!(
545 "{builtin}: `{key}` must be a number, got {}",
546 other.type_name()
547 )),
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn safety_ratio_picks_more_restrictive_cap() {
557 let policy = CompactionPolicyDeclaration {
558 max_tokens: Some(40_000),
559 context_window: Some(100_000),
560 safety_ratio: 0.5,
561 ..Default::default()
562 };
563 assert_eq!(policy.token_threshold(), Some(40_000));
565 }
566
567 #[test]
568 fn ratio_only_when_window_set() {
569 let policy = CompactionPolicyDeclaration {
570 context_window: Some(120_000),
571 safety_ratio: 0.7,
572 ..Default::default()
573 };
574 assert_eq!(policy.token_threshold(), Some(84_000));
575 }
576
577 #[test]
578 fn evaluate_marks_token_trigger() {
579 let policy = CompactionPolicyDeclaration {
580 max_tokens: Some(10_000),
581 ..Default::default()
582 };
583 let ctx = policy.evaluate(12_000, 5);
584 assert!(ctx.token_trigger);
585 assert!(ctx.fires());
586 assert_eq!(ctx.trigger_label(), "tokens");
587 }
588
589 #[test]
590 fn evaluate_marks_turn_trigger() {
591 let policy = CompactionPolicyDeclaration {
592 max_turns: Some(20),
593 ..Default::default()
594 };
595 let ctx = policy.evaluate(0, 25);
596 assert!(ctx.turn_trigger);
597 assert_eq!(ctx.trigger_label(), "turns");
598 }
599
600 #[test]
601 fn defer_when_no_thresholds_configured() {
602 let policy = CompactionPolicyDeclaration::default();
603 let ctx = policy.evaluate(1_000_000, 1_000_000);
604 assert!(!ctx.fires());
605 }
606
607 #[test]
608 fn default_policy_falls_back_to_session_lookup() {
609 reset_registry();
610 let default = CompactionPolicyDeclaration {
611 max_tokens: Some(50_000),
612 ..Default::default()
613 };
614 set_policy(DEFAULT_POLICY_KEY, default);
615 let (resolved, inherited) =
616 policy_for("session-without-explicit-policy").expect("default policy resolved");
617 assert!(inherited);
618 assert_eq!(resolved.max_tokens, Some(50_000));
619 reset_registry();
620 }
621
622 #[test]
623 fn session_specific_policy_takes_precedence() {
624 reset_registry();
625 set_policy(
626 "",
627 CompactionPolicyDeclaration {
628 max_tokens: Some(50_000),
629 ..Default::default()
630 },
631 );
632 set_policy(
633 "session-a",
634 CompactionPolicyDeclaration {
635 max_tokens: Some(80_000),
636 ..Default::default()
637 },
638 );
639 let (resolved, inherited) = policy_for("session-a").expect("session policy resolved");
640 assert!(!inherited);
641 assert_eq!(resolved.max_tokens, Some(80_000));
642 reset_registry();
643 }
644
645 #[test]
646 fn strategy_aliases_round_trip() {
647 assert_eq!(
648 PolicyStrategy::parse("summarize")
649 .unwrap()
650 .engine_strategy(),
651 CompactStrategy::Llm
652 );
653 assert_eq!(
654 PolicyStrategy::parse("summarize-then-prune")
655 .unwrap()
656 .engine_fallback(),
657 Some(CompactStrategy::Truncate)
658 );
659 assert_eq!(
660 PolicyStrategy::parse("window").unwrap().engine_strategy(),
661 CompactStrategy::Truncate
662 );
663 assert_eq!(
664 PolicyStrategy::parse("head+tail")
665 .unwrap()
666 .engine_strategy(),
667 CompactStrategy::Truncate
668 );
669 assert_eq!(
670 PolicyStrategy::parse("observation_mask")
671 .unwrap()
672 .engine_strategy(),
673 CompactStrategy::ObservationMask
674 );
675 assert!(PolicyStrategy::parse("unknown").is_err());
676 }
677}