Skip to main content

clark_agent_compaction/
lib.rs

1//! Provider-agnostic context compaction primitives for agent transcripts.
2//!
3//! The crate owns the deterministic parts of compaction:
4//! checkpoint threshold checks, transcript rendering budgets, recent
5//! user-message retention, summary handoff finalization, and policy for
6//! append-only episodic histories. Callers own provider I/O, persistence,
7//! cancellation, telemetry, and conversion back into their typed message model.
8
9mod 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
31/// Runtime-owned transcript adapter.
32///
33/// Implement this trait for the caller's typed message enum. Rendering should
34/// be deterministic, concise, and model-readable.
35///
36/// If the source transcript carries private reasoning, render only readable
37/// text through [`append_private_reasoning_for_compaction`]. The compaction
38/// prompt directs the summarizer to retain durable findings, not raw
39/// chain-of-thought. Opaque, signed, or encrypted replay payloads must not be
40/// rendered.
41pub trait TranscriptMessage {
42    /// Render the message into the summarization transcript.
43    fn render_for_compaction(&self, out: &mut String);
44
45    /// Write the user-visible text for real user messages.
46    ///
47    /// Return `false` for non-user messages. Images or binary attachments should
48    /// be omitted or represented by safe caller-owned text.
49    fn user_text_for_compaction(&self, out: &mut String) -> bool;
50
51    /// Identify compaction-summary messages that should not be retained as real
52    /// recent user messages.
53    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/// Caller-owned model request and deterministic replacement plan.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct PreparedCompaction {
62    pub request: CompactionRequest,
63    pub plan: CompactionPlan,
64}
65
66/// Prompt to send to the caller's summarization model.
67#[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/// Deterministic data needed to install a model-produced summary.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct CompactionPlan {
78    pub recent_user_messages: Vec<String>,
79    pub summary_prefix: String,
80}
81
82/// Final model-visible compaction output.
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct CompactedTranscript {
85    pub summary_message: String,
86    pub recent_user_messages: Vec<String>,
87}
88
89/// Estimate the rendered transcript token count.
90pub 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
106/// Return true when compaction should run for this transcript.
107pub 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
116/// Build a compaction prompt plus deterministic finalization plan.
117pub 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
184/// Combine a model-produced summary with a previously prepared plan.
185pub 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}