Skip to main content

clark_agent_compaction/
episodic.rs

1//! Storage-neutral policy for append-only episodic compaction.
2//!
3//! A durable store measures the pressure in its own event log and retains its
4//! own cutoff or watermark. This module decides whether that measured pressure
5//! warrants a new episode summary, so every product follows the same policy
6//! without coupling this crate to SQL, UUIDs, or a particular event schema.
7
8/// Default policy for long-lived, append-only conversation histories.
9///
10/// The context-pressure threshold is model-specific and therefore disabled by
11/// default. The unfinished-turn and age-based triggers provide safe fallback
12/// bounds for conversations whose provider budget is not known at the store.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct EpisodicCompactionConfig {
15    /// Trigger once the caller's provider-view token estimate reaches this
16    /// threshold. `None` disables this model-aware trigger.
17    pub trigger_context_tokens: Option<usize>,
18    /// Trigger after this many user messages followed the most recent clean
19    /// run completion in the current episode.
20    pub trigger_unfinished_turns: usize,
21    /// Require this conversation age, in whole days, for the long-tail trigger.
22    pub trigger_age_days: u64,
23    /// Require this many total user messages for the long-tail trigger.
24    pub trigger_age_turn_count: usize,
25}
26
27impl Default for EpisodicCompactionConfig {
28    fn default() -> Self {
29        Self {
30            trigger_context_tokens: None,
31            trigger_unfinished_turns: 8,
32            trigger_age_days: 30,
33            trigger_age_turn_count: 50,
34        }
35    }
36}
37
38/// Durable reason an episodic boundary was written.
39///
40/// Stores can persist [`Self::as_str`] for diagnostics without learning any
41/// policy details. `ManualRecovery` is never selected automatically; it is
42/// reserved for an operator-owned clean-slate boundary.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum EpisodicCompactionTrigger {
45    ContextPressure,
46    UnfinishedTurns,
47    AgeAndTurns,
48    ManualRecovery,
49}
50
51impl EpisodicCompactionTrigger {
52    pub const fn as_str(self) -> &'static str {
53        match self {
54            Self::ContextPressure => "context_pressure",
55            Self::UnfinishedTurns => "unfinished_turns",
56            Self::AgeAndTurns => "age_and_turns",
57            Self::ManualRecovery => "manual_recovery",
58        }
59    }
60}
61
62/// Storage-neutral observations for one append-only conversation episode.
63///
64/// The store calculates these fields relative to its latest summary boundary.
65/// It keeps any storage-specific pointer, such as an event sequence or record
66/// ID, outside this type and writes it only after this policy selects a trigger.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub struct EpisodicCompactionPressure {
69    /// Provider-view token estimate supplied by the runtime, if available.
70    pub estimated_context_tokens: Option<usize>,
71    /// User messages since the latest summary boundary. This is metadata for
72    /// the eventual summary record and does not itself decide a trigger.
73    pub user_messages_since_boundary: usize,
74    /// Clean runs completed since the latest summary boundary.
75    pub run_completed_since_boundary: usize,
76    /// User messages after the latest clean run completion, or after the
77    /// summary boundary when none completed.
78    pub user_messages_after_latest_run_completed: usize,
79    /// Conversation age in whole days.
80    pub conversation_age_days: u64,
81    /// Total user messages since conversation inception.
82    pub total_user_messages: usize,
83}
84
85/// Select the highest-priority automatic episodic-compaction trigger.
86///
87/// Context pressure wins because the next provider request may overflow.
88/// Unfinished turns then protect a conversation that has accumulated failed or
89/// stalled attempts. The age-and-turn count rule bounds healthy long-running
90/// conversations without compacting sparse older ones.
91pub fn episodic_compaction_trigger(
92    pressure: &EpisodicCompactionPressure,
93    config: &EpisodicCompactionConfig,
94) -> Option<EpisodicCompactionTrigger> {
95    if let (Some(estimated), Some(trigger)) = (
96        pressure.estimated_context_tokens,
97        config.trigger_context_tokens,
98    ) {
99        if estimated >= trigger {
100            return Some(EpisodicCompactionTrigger::ContextPressure);
101        }
102    }
103
104    if pressure.user_messages_after_latest_run_completed >= config.trigger_unfinished_turns {
105        return Some(EpisodicCompactionTrigger::UnfinishedTurns);
106    }
107
108    if pressure.conversation_age_days >= config.trigger_age_days
109        && pressure.total_user_messages >= config.trigger_age_turn_count
110    {
111        return Some(EpisodicCompactionTrigger::AgeAndTurns);
112    }
113
114    None
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn context_pressure_wins_before_a_provider_overflow() {
123        let pressure = EpisodicCompactionPressure {
124            estimated_context_tokens: Some(408_000),
125            user_messages_since_boundary: 80,
126            run_completed_since_boundary: 80,
127            conversation_age_days: 1,
128            total_user_messages: 80,
129            ..EpisodicCompactionPressure::default()
130        };
131        let config = EpisodicCompactionConfig {
132            trigger_context_tokens: Some(400_000),
133            ..EpisodicCompactionConfig::default()
134        };
135
136        assert_eq!(
137            episodic_compaction_trigger(&pressure, &config),
138            Some(EpisodicCompactionTrigger::ContextPressure)
139        );
140    }
141
142    #[test]
143    fn unfinished_turns_only_count_after_the_latest_clean_completion() {
144        let pressure = EpisodicCompactionPressure {
145            user_messages_since_boundary: 20,
146            run_completed_since_boundary: 1,
147            user_messages_after_latest_run_completed: 9,
148            conversation_age_days: 1,
149            total_user_messages: 20,
150            ..EpisodicCompactionPressure::default()
151        };
152
153        assert_eq!(
154            episodic_compaction_trigger(&pressure, &EpisodicCompactionConfig::default()),
155            Some(EpisodicCompactionTrigger::UnfinishedTurns)
156        );
157    }
158
159    #[test]
160    fn a_recent_clean_completion_prevents_the_unfinished_turn_trigger() {
161        let pressure = EpisodicCompactionPressure {
162            user_messages_since_boundary: 20,
163            run_completed_since_boundary: 1,
164            user_messages_after_latest_run_completed: 2,
165            conversation_age_days: 1,
166            total_user_messages: 20,
167            ..EpisodicCompactionPressure::default()
168        };
169
170        assert_eq!(
171            episodic_compaction_trigger(&pressure, &EpisodicCompactionConfig::default()),
172            None
173        );
174    }
175
176    #[test]
177    fn context_pressure_is_disabled_until_the_runtime_supplies_a_limit() {
178        let pressure = EpisodicCompactionPressure {
179            estimated_context_tokens: Some(900_000),
180            user_messages_since_boundary: 80,
181            run_completed_since_boundary: 80,
182            conversation_age_days: 1,
183            total_user_messages: 80,
184            ..EpisodicCompactionPressure::default()
185        };
186
187        assert_eq!(
188            episodic_compaction_trigger(&pressure, &EpisodicCompactionConfig::default()),
189            None
190        );
191    }
192
193    #[test]
194    fn age_trigger_requires_both_age_and_turn_count() {
195        let sparse = EpisodicCompactionPressure {
196            conversation_age_days: 60,
197            total_user_messages: 10,
198            ..EpisodicCompactionPressure::default()
199        };
200        assert_eq!(
201            episodic_compaction_trigger(&sparse, &EpisodicCompactionConfig::default()),
202            None
203        );
204
205        let old_busy = EpisodicCompactionPressure {
206            conversation_age_days: 60,
207            total_user_messages: 200,
208            ..EpisodicCompactionPressure::default()
209        };
210        assert_eq!(
211            episodic_compaction_trigger(&old_busy, &EpisodicCompactionConfig::default()),
212            Some(EpisodicCompactionTrigger::AgeAndTurns)
213        );
214    }
215
216    #[test]
217    fn trigger_names_are_stable_for_store_metadata() {
218        assert_eq!(
219            EpisodicCompactionTrigger::ContextPressure.as_str(),
220            "context_pressure"
221        );
222        assert_eq!(
223            EpisodicCompactionTrigger::ManualRecovery.as_str(),
224            "manual_recovery"
225        );
226    }
227}