clark_agent_compaction/
lib.rs1mod config;
10mod episodic;
11mod plain;
12mod reasoning;
13mod truncate;
14
15pub use config::{
16 CharHeuristic, CompactionConfig, TokenEstimator, DEFAULT_AUTO_COMPACT_TOKEN_LIMIT,
17 DEFAULT_COMPACTION_PROMPT, DEFAULT_COMPACT_REQUEST_TOKEN_LIMIT,
18 DEFAULT_PRIVATE_REASONING_SUMMARY_GUIDANCE, DEFAULT_RECENT_USER_TOKEN_BUDGET,
19 DEFAULT_SUMMARY_PREFIX, with_private_reasoning_summary_guidance,
20};
21pub use episodic::{
22 EpisodicCompactionConfig, EpisodicCompactionPressure, EpisodicCompactionTrigger,
23 episodic_compaction_trigger,
24};
25pub use plain::{PlainMessage, PlainToolCall};
26pub use reasoning::{
27 append_private_reasoning_for_compaction, DEFAULT_PRIVATE_REASONING_EXCERPT_CHARS,
28};
29pub use truncate::{truncate_to_token_budget, truncate_to_token_budget_with_estimator};
30
31pub trait TranscriptMessage {
42 fn render_for_compaction(&self, out: &mut String);
44
45 fn user_text_for_compaction(&self, out: &mut String) -> bool;
50
51 fn is_compaction_summary(&self, summary_prefix: &str) -> bool {
54 let mut text = String::new();
55 self.user_text_for_compaction(&mut text) && text.starts_with(summary_prefix)
56 }
57}
58
59#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct PreparedCompaction {
62 pub request: CompactionRequest,
63 pub plan: CompactionPlan,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct CompactionRequest {
69 pub prompt: String,
70 pub omitted_messages: usize,
71 pub estimated_transcript_tokens: usize,
72 pub estimated_request_tokens: usize,
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct CompactionPlan {
78 pub recent_user_messages: Vec<String>,
79 pub summary_prefix: String,
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct CompactedTranscript {
85 pub summary_message: String,
86 pub recent_user_messages: Vec<String>,
87}
88
89pub fn estimate_transcript_tokens<M, E>(messages: &[M], estimator: &E) -> usize
91where
92 M: TranscriptMessage,
93 E: TokenEstimator + ?Sized,
94{
95 let mut rendered = String::new();
96 messages
97 .iter()
98 .map(|message| {
99 rendered.clear();
100 message.render_for_compaction(&mut rendered);
101 estimator.estimate(&rendered)
102 })
103 .sum()
104}
105
106pub fn should_compact<M, E>(messages: &[M], config: &CompactionConfig, estimator: &E) -> bool
108where
109 M: TranscriptMessage,
110 E: TokenEstimator + ?Sized,
111{
112 config.enabled()
113 && estimate_transcript_tokens(messages, estimator) >= config.auto_compact_token_limit
114}
115
116pub fn prepare_compaction<M, E>(
118 messages: &[M],
119 config: &CompactionConfig,
120 estimator: &E,
121) -> Option<PreparedCompaction>
122where
123 M: TranscriptMessage,
124 E: TokenEstimator + ?Sized,
125{
126 let estimated_transcript_tokens = estimate_transcript_tokens(messages, estimator);
127 if !config.enabled() || estimated_transcript_tokens < config.auto_compact_token_limit {
128 return None;
129 }
130
131 let mut rendered_messages = Vec::new();
132 let mut token_total = estimator.estimate(&config.compaction_prompt);
133 let mut scratch = String::new();
134 let mut omitted_messages = 0usize;
135
136 for idx in (0..messages.len()).rev() {
137 scratch.clear();
138 messages[idx].render_for_compaction(&mut scratch);
139 let tokens = estimator.estimate(&scratch);
140 if token_total.saturating_add(tokens) > config.compact_request_token_limit
141 && !rendered_messages.is_empty()
142 {
143 omitted_messages = idx + 1;
144 break;
145 }
146 token_total = token_total.saturating_add(tokens);
147 rendered_messages.push(scratch.clone());
148 }
149 rendered_messages.reverse();
150
151 if rendered_messages.is_empty() {
152 return None;
153 }
154
155 let mut prompt = String::new();
156 prompt.push_str(&config.compaction_prompt);
157 prompt.push_str("\n\nConversation transcript:\n\n");
158 prompt.push_str(&rendered_messages.join("\n\n"));
159 if omitted_messages > 0 {
160 prompt.push_str("\n\nNote: ");
161 prompt.push_str(&omitted_messages.to_string());
162 prompt.push_str(
163 " oldest message(s) were omitted because the compaction request was too large.",
164 );
165 }
166
167 let plan = CompactionPlan {
168 recent_user_messages: recent_user_messages(messages, config, estimator),
169 summary_prefix: config.summary_prefix.clone(),
170 };
171 let estimated_request_tokens = estimator.estimate(&prompt);
172
173 Some(PreparedCompaction {
174 request: CompactionRequest {
175 prompt,
176 omitted_messages,
177 estimated_transcript_tokens,
178 estimated_request_tokens,
179 },
180 plan,
181 })
182}
183
184pub fn finalize_compaction(plan: &CompactionPlan, summary: &str) -> CompactedTranscript {
186 let summary = summary.trim();
187 let summary = if summary.is_empty() {
188 "(no summary was produced)"
189 } else {
190 summary
191 };
192
193 let summary_message = if plan.summary_prefix.trim().is_empty() {
194 summary.to_string()
195 } else {
196 format!("{}\n{}", plan.summary_prefix.trim_end(), summary)
197 };
198
199 CompactedTranscript {
200 summary_message,
201 recent_user_messages: plan.recent_user_messages.clone(),
202 }
203}
204
205fn recent_user_messages<M, E>(
206 messages: &[M],
207 config: &CompactionConfig,
208 estimator: &E,
209) -> Vec<String>
210where
211 M: TranscriptMessage,
212 E: TokenEstimator + ?Sized,
213{
214 if config.recent_user_token_budget == 0 {
215 return Vec::new();
216 }
217
218 let mut selected = Vec::new();
219 let mut remaining = config.recent_user_token_budget;
220 let mut scratch = String::new();
221
222 for message in messages.iter().rev() {
223 scratch.clear();
224 if !message.user_text_for_compaction(&mut scratch) || scratch.is_empty() {
225 continue;
226 }
227 if message.is_compaction_summary(&config.summary_prefix) {
228 continue;
229 }
230
231 let tokens = estimator.estimate(&scratch);
232 if tokens <= remaining {
233 selected.push(scratch.clone());
234 remaining = remaining.saturating_sub(tokens);
235 continue;
236 }
237
238 if remaining > 0 {
239 selected.push(truncate_to_token_budget_with_estimator(
240 &scratch, remaining, estimator,
241 ));
242 }
243 break;
244 }
245
246 selected.reverse();
247 selected
248}