clark_agent_compaction/
episodic.rs1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct EpisodicCompactionConfig {
15 pub trigger_context_tokens: Option<usize>,
18 pub trigger_unfinished_turns: usize,
21 pub trigger_age_days: u64,
23 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#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub struct EpisodicCompactionPressure {
69 pub estimated_context_tokens: Option<usize>,
71 pub user_messages_since_boundary: usize,
74 pub run_completed_since_boundary: usize,
76 pub user_messages_after_latest_run_completed: usize,
79 pub conversation_age_days: u64,
81 pub total_user_messages: usize,
83}
84
85pub 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}