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 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 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 let keep_from = summary_head.len() / 2;
184 summary_head = summary_head.split_off(keep_from);
185 continue;
186 }
187 Err(_) => {
188 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 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}