Skip to main content

roder_core/
compaction_runtime.rs

1use std::collections::HashMap;
2
3use futures::StreamExt;
4use roder_api::events::{ThreadId, TurnId};
5use roder_api::inference::{
6    AgentInferenceRequest, InferenceEvent, InferenceTurnContext, InstructionBundle, MessageDelta,
7    ModelSelection, OutputConfig, ReasoningConfig, RuntimeHints, RuntimeProfile,
8};
9use roder_api::tools::ToolChoice;
10use roder_api::transcript::{TranscriptItem, UserMessage};
11use std::sync::Mutex;
12
13use crate::compaction::{
14    CompactionOptions, accept_llm_compaction_summary, build_compaction_summary_prompt,
15    build_compaction_verify_prompt, estimate_prompt_tokens,
16};
17use crate::runtime::Runtime;
18
19#[derive(Default)]
20pub(crate) struct CompactionState {
21    tokens: u32,
22    generation: u64,
23}
24
25impl Runtime {
26    pub(crate) fn record_compaction_hysteresis(&self, thread_id: &ThreadId, trigger_tokens: u32) {
27        if let Ok(mut state) = self.compaction_hysteresis.lock() {
28            let state = state.entry(thread_id.clone()).or_default();
29            state.tokens = trigger_tokens;
30            state.generation = state.generation.wrapping_add(1);
31        }
32    }
33
34    pub(crate) fn compaction_hysteresis_baseline(&self, thread_id: &ThreadId) -> Option<u32> {
35        self.compaction_hysteresis
36            .lock()
37            .ok()
38            .and_then(|state| state.get(thread_id).map(|state| state.tokens))
39    }
40
41    pub(crate) fn compaction_generation(&self, thread_id: &ThreadId) -> u64 {
42        self.compaction_hysteresis
43            .lock()
44            .ok()
45            .and_then(|state| state.get(thread_id).map(|state| state.generation))
46            .unwrap_or(0)
47    }
48
49    pub(crate) fn compaction_options_for_turn(
50        &self,
51        thread_id: &ThreadId,
52        allow_repeat: bool,
53    ) -> CompactionOptions {
54        CompactionOptions {
55            allow_repeat,
56            force: false,
57            hysteresis_baseline: self.compaction_hysteresis_baseline(thread_id),
58            preserve_hint: None,
59        }
60    }
61
62    pub async fn force_compact_thread(
63        &self,
64        thread_id: &ThreadId,
65        turn_id: &TurnId,
66        preserve_hint: Option<String>,
67    ) -> anyhow::Result<ForceCompactOutcome> {
68        let _thread_admission = self.thread_admission(thread_id).await;
69        let cfg = self.status().await;
70        let selection = self
71            .parent_model_selection_for_subagents(thread_id, turn_id)
72            .await;
73        let provider = selection
74            .as_ref()
75            .map(|selection| selection.provider.clone())
76            .unwrap_or(cfg.default_provider.clone());
77        let model = selection
78            .as_ref()
79            .map(|selection| selection.model.clone())
80            .unwrap_or(cfg.default_model.clone());
81        let transcript = self.transcript_for_force_compact(thread_id).await?;
82        if transcript.is_empty() {
83            return Ok(ForceCompactOutcome {
84                compacted: false,
85                reason: Some("empty_transcript".to_string()),
86                estimated_tokens_before: 0,
87                estimated_tokens_after: 0,
88            });
89        }
90        let images = self.tool_images(&provider, &model);
91        let estimated_before = estimate_prompt_tokens(&transcript, images);
92        // Manual compaction runs only on an idle task. A second sampling
93        // request against a snapshot taken during tool execution can omit
94        // effects that complete before the boundary is committed.
95        if self.active_turn_for_thread(thread_id).await.is_some() {
96            return Ok(ForceCompactOutcome {
97                compacted: false,
98                reason: Some("turn_active".into()),
99                estimated_tokens_before: estimated_before,
100                estimated_tokens_after: estimated_before,
101            });
102        }
103
104        let compacted = self
105            .compact_transcript_if_needed(
106                thread_id,
107                turn_id,
108                &provider,
109                &model,
110                transcript,
111                CompactionOptions {
112                    allow_repeat: true,
113                    force: true,
114                    hysteresis_baseline: None,
115                    preserve_hint: preserve_hint.filter(|text| !text.trim().is_empty()),
116                },
117            )
118            .await?;
119        let estimated_after = estimate_prompt_tokens(&compacted, images);
120        Ok(ForceCompactOutcome {
121            compacted: estimated_after < estimated_before
122                || compacted
123                    .iter()
124                    .any(crate::compaction::is_compaction_boundary),
125            reason: None,
126            estimated_tokens_before: estimated_before,
127            estimated_tokens_after: estimated_after,
128        })
129    }
130
131    async fn transcript_for_force_compact(
132        &self,
133        thread_id: &ThreadId,
134    ) -> anyhow::Result<Vec<TranscriptItem>> {
135        let Some(store) = &self.thread_store else {
136            return Ok(Vec::new());
137        };
138        let Some(snapshot) = store.load_thread(thread_id).await? else {
139            return Ok(Vec::new());
140        };
141        let mut out = Vec::new();
142        for turn in snapshot.turns {
143            out.extend(turn.items);
144        }
145        Ok(crate::compaction::trim_to_last_compaction_boundary(out))
146    }
147
148    pub(crate) async fn summarize_compaction_head(
149        &self,
150        thread_id: &ThreadId,
151        turn_id: &TurnId,
152        provider: &str,
153        model: &str,
154        head: &[TranscriptItem],
155        preserve_hint: Option<&str>,
156    ) -> anyhow::Result<Option<String>> {
157        if head.is_empty() {
158            return Ok(None);
159        }
160        // Prefer a full-head LLM snapshot; if the summary request itself hits the
161        // provider prompt limit, shrink the head and retry once, then fall back
162        // to deterministic summarization.
163        let mut summary_head = head.to_vec();
164        let draft = loop {
165            match self
166                .run_compaction_summary_inference(
167                    thread_id,
168                    turn_id,
169                    provider,
170                    model,
171                    build_compaction_summary_prompt(&summary_head, preserve_hint),
172                )
173                .await
174            {
175                Ok(draft) => break draft,
176                Err(err)
177                    if crate::compaction::is_context_limit_failure_message(&err.to_string()) =>
178                {
179                    if summary_head.len() <= 1 {
180                        return Ok(None);
181                    }
182                    // Drop the oldest half so the summary prompt fits.
183                    let keep_from = summary_head.len() / 2;
184                    summary_head = summary_head.split_off(keep_from);
185                    continue;
186                }
187                Err(_) => {
188                    // Non-context failures already fall through to deterministic
189                    // summaries via Ok(None) below when the provider returns empty.
190                    return Ok(None);
191                }
192            }
193        };
194        let Some(draft) = draft else {
195            return Ok(None);
196        };
197        let images = self.tool_images(provider, model);
198        if !accept_llm_compaction_summary(head, &draft, images) {
199            return Ok(None);
200        }
201        let verified = match self
202            .run_compaction_summary_inference(
203                thread_id,
204                turn_id,
205                provider,
206                model,
207                build_compaction_verify_prompt(&draft),
208            )
209            .await
210        {
211            Ok(Some(text)) => text,
212            Ok(None) | Err(_) => draft.clone(),
213        };
214        if accept_llm_compaction_summary(head, &verified, images) {
215            Ok(Some(verified))
216        } else if accept_llm_compaction_summary(head, &draft, images) {
217            Ok(Some(draft))
218        } else {
219            Ok(None)
220        }
221    }
222
223    async fn run_compaction_summary_inference(
224        &self,
225        thread_id: &ThreadId,
226        turn_id: &TurnId,
227        provider: &str,
228        model: &str,
229        prompt: String,
230    ) -> anyhow::Result<Option<String>> {
231        let engine = self.engine_for(provider)?;
232        let request = AgentInferenceRequest {
233            model: ModelSelection {
234                provider: provider.to_string(),
235                model: model.to_string(),
236            },
237            instructions: InstructionBundle {
238                system: Some(
239                    "You compress conversation history into durable state snapshots.".to_string(),
240                ),
241                developer: None,
242                developer_context: None,
243            },
244            transcript: vec![TranscriptItem::UserMessage(UserMessage::text(prompt))],
245            tools: Vec::new(),
246            tool_choice: ToolChoice::None,
247            reasoning: ReasoningConfig::default(),
248            output: OutputConfig::default(),
249            runtime: RuntimeHints {
250                profile: RuntimeProfile::Interactive,
251                ..RuntimeHints::default()
252            },
253            metadata: serde_json::json!({ "roderCompactionSummary": true }),
254        };
255        let ctx = InferenceTurnContext {
256            thread_id,
257            turn_id,
258            tool_executor: None,
259        };
260        let mut stream = engine.stream_turn(ctx, request).await?;
261        let mut text = String::new();
262        let mut completed = false;
263        while let Some(event) = stream.next().await {
264            match event? {
265                InferenceEvent::MessageDelta(MessageDelta { text: delta, .. }) => {
266                    text.push_str(&delta)
267                }
268                InferenceEvent::Failed(failure) => {
269                    if crate::compaction::is_context_limit_failure_message(&failure.message) {
270                        anyhow::bail!("{}", failure.message);
271                    }
272                    // Non-context provider failures should not abort the parent
273                    // turn compaction path — fall back to deterministic summary.
274                    return Ok(None);
275                }
276                InferenceEvent::Completed(_) => {
277                    completed = true;
278                    break;
279                }
280                _ => {}
281            }
282        }
283        if !completed || text.trim().is_empty() {
284            Ok(None)
285        } else {
286            Ok(Some(text.trim().to_string()))
287        }
288    }
289}
290
291#[derive(Debug, Clone)]
292pub struct ForceCompactOutcome {
293    pub compacted: bool,
294    pub reason: Option<String>,
295    pub estimated_tokens_before: u32,
296    pub estimated_tokens_after: u32,
297}
298
299pub(crate) fn compaction_hysteresis_state() -> Mutex<HashMap<ThreadId, CompactionState>> {
300    Mutex::new(HashMap::new())
301}