1use super::*;
4
5pub(crate) fn compaction_summary_request(
23 model: &str,
24 history: &[Message],
25 instructions: &str,
26 max_output_tokens: Option<u32>,
27 reasoning_effort: Option<ReasoningEffortLevel>,
28 verbosity: Option<VerbosityLevel>,
29 supports_turn_scoped: bool,
30 parent: Option<&CompactionParentContext>,
31) -> LLMRequest {
32 let mut forked = build_cache_safe_compaction_history(history, instructions);
33 if !supports_turn_scoped {
34 for message in forked.iter_mut() {
35 if message.clear_at.is_some() {
36 message.clear_at = None;
37 }
38 }
39 }
40 LLMRequest {
41 messages: Arc::new(forked),
42 model: model.to_string(),
43 system_prompt: parent.and_then(|parent| parent.system_prompt.clone()),
44 tools: parent.and_then(|parent| parent.tools.clone()).filter(|tools| !tools.is_empty()),
45 tool_choice: Some(ToolChoice::none()),
46 max_tokens: max_output_tokens,
47 reasoning_effort,
48 verbosity,
49 ..Default::default()
50 }
51}
52
53#[cfg_attr(feature = "profiling", hotpath::measure)]
55pub async fn compact_history(
56 provider: &dyn LLMProvider,
57 model: &str,
58 history: &[Message],
59 config: &CompactionConfig,
60) -> Result<Vec<Message>> {
61 compact_history_with_budget(provider, model, history, config, None).await
62}
63
64pub async fn compact_history_with_budget(
68 provider: &dyn LLMProvider,
69 model: &str,
70 history: &[Message],
71 config: &CompactionConfig,
72 context_budget: Option<usize>,
73) -> Result<Vec<Message>> {
74 if history.is_empty() {
75 return Ok(Vec::new());
76 }
77
78 if !config.always_summarize && history.len() <= config.keep_last_messages {
79 return Ok(bound_compacted_history_to_context(history.to_vec(), provider, model, context_budget));
83 }
84
85 if !config.always_summarize && provider.supports_manual_openai_compaction(model) {
91 let native_source = bound_history_for_native_compaction(
92 history,
93 "",
94 compaction_history_budget(provider, model, context_budget),
95 );
96 let compacted = provider
97 .compact_history(model, &native_source)
98 .await
99 .context("Failed to compact history via Responses compact endpoint")?;
100 return Ok(compacted);
105 }
106
107 let effective_config = context_bounded_compaction_config(provider, model, history, config, context_budget);
108 let history_budget = compaction_history_budget(provider, model, context_budget);
118 let tail_target = route_tail_target_tokens(provider, model, context_budget);
119 let pruned_history = prune_oversized_tool_outputs(history);
122 let summary_source =
123 bound_history_for_summarization(&pruned_history, &effective_config.summary_prompt, history_budget);
124 let summary = generate_local_summary_with_retry(
125 provider,
126 model,
127 &pruned_history,
128 &summary_source,
129 &effective_config.summary_prompt,
130 history_budget,
131 &ManualCompactionOptions::default(),
132 None,
133 )
134 .await?;
135
136 Ok(bound_compacted_history_to_context(
137 build_local_compacted_history(
138 history,
139 &summary,
140 effective_config.retained_user_message_tokens,
141 effective_config.retained_user_messages,
142 true,
145 tail_target,
146 ),
147 provider,
148 model,
149 context_budget,
150 ))
151}
152
153pub(crate) async fn summarize_locally(
175 provider: &dyn LLMProvider,
176 model: &str,
177 history: &[Message],
178 config: &CompactionConfig,
179 options: &ManualCompactionOptions,
180 context_budget: Option<usize>,
181 parent: Option<&CompactionParentContext>,
182) -> Result<Vec<Message>> {
183 if config.hierarchical {
184 return summarize_locally_hierarchical(provider, model, history, config, options, context_budget, parent).await;
185 }
186
187 let effective_config = context_bounded_compaction_config(
188 provider,
189 model,
190 history,
191 &config.clone().with_manual_overrides(options),
192 context_budget,
193 );
194 let history_budget = compaction_history_budget(provider, model, context_budget);
195 let tail_target = route_tail_target_tokens(provider, model, context_budget);
196 let pruned_history = prune_oversized_tool_outputs(history);
202 let summary_source =
203 bound_history_for_summarization(&pruned_history, &effective_config.summary_prompt, history_budget);
204 let summary = generate_local_summary_with_retry(
205 provider,
206 model,
207 &pruned_history,
208 &summary_source,
209 &effective_config.summary_prompt,
210 history_budget,
211 options,
212 parent,
213 )
214 .await?;
215
216 Ok(bound_compacted_history_to_context(
217 build_summary_compacted_history(history, summary, &effective_config, true, tail_target),
218 provider,
219 model,
220 context_budget,
221 ))
222}
223
224pub(crate) async fn generate_summary_with_capacity_retry(
234 provider: &dyn LLMProvider,
235 model: &str,
236 history: &[Message],
237 instructions: &str,
238 first_source: &[Message],
239 history_budget: Option<usize>,
240 max_overflow_retries: u32,
241 error_context: &'static str,
242 make_request: impl Fn(&[Message]) -> LLMRequest,
243) -> Result<String> {
244 let first_tokens = first_source.iter().map(Message::estimate_tokens).sum::<usize>();
245 let mut current_source: &[Message] = first_source;
246 let mut current_tokens = first_tokens;
247 let mut current_budget = history_budget;
248 let mut remaining_retries = max_overflow_retries;
249 let mut retry_storage: Vec<Message>;
250 loop {
251 match collect_single_response(provider, make_request(current_source)).await {
252 Ok(response) => {
253 let summary = response.content.unwrap_or_default().trim().to_string();
254 if summary.is_empty() {
255 return Err(anyhow::anyhow!(
256 "{error_context} for {} / {}: provider returned an empty summary (input_tokens={current_tokens}, budget={current_budget:?})",
257 provider.name(),
258 model,
259 ));
260 }
261 return Ok(summary);
262 }
263 Err(error) if is_context_capacity_message(&error.to_string()) && remaining_retries > 0 => {
264 current_budget = match current_budget {
268 Some(budget) => Some((budget / 2).max(4)),
269 None => Some((current_tokens / 2).max(4)),
270 };
271 retry_storage = bound_history_for_summarization(history, instructions, current_budget);
272 let retry_tokens = retry_storage.iter().map(Message::estimate_tokens).sum::<usize>();
276 if retry_tokens >= current_tokens {
277 return Err(anyhow::Error::from(error).context(format!(
278 "{error_context} for {} / {} (input_tokens={current_tokens}, budget={current_budget:?})",
279 provider.name(),
280 model,
281 )));
282 }
283 tracing::warn!(
284 provider = provider.name(),
285 model,
286 input_tokens = current_tokens,
287 retry_tokens,
288 budget = ?current_budget,
289 error = ?error,
290 "{error_context}: input exceeded context capacity; retrying with a halved input budget"
291 );
292 current_source = &retry_storage;
293 current_tokens = retry_tokens;
294 remaining_retries -= 1;
295 }
296 Err(error) => {
297 return Err(anyhow::Error::from(error).context(format!(
298 "{error_context} for {} / {} (input_tokens={current_tokens}, budget={current_budget:?})",
299 provider.name(),
300 model,
301 )));
302 }
303 }
304 }
305}
306
307async fn generate_local_summary_with_retry(
309 provider: &dyn LLMProvider,
310 model: &str,
311 history: &[Message],
312 summary_source: &[Message],
313 instructions: &str,
314 history_budget: Option<usize>,
315 options: &ManualCompactionOptions,
316 parent: Option<&CompactionParentContext>,
317) -> Result<String> {
318 let supports_turn_scoped = provider.supports_turn_scoped_system_messages(model);
319 generate_summary_with_capacity_retry(
320 provider,
321 model,
322 history,
323 instructions,
324 summary_source,
325 history_budget,
326 CompactionRoutePolicy::resolve(provider.name(), model).max_overflow_retries,
327 "Failed to generate compaction summary",
328 |source| {
329 compaction_summary_request(
330 model,
331 source,
332 instructions,
333 options.max_output_tokens,
334 options.reasoning_effort,
335 options.verbosity,
336 supports_turn_scoped,
337 parent,
338 )
339 },
340 )
341 .await
342}
343
344const ABSTRACT_BAND_INTRO: &str =
346 "In 1-2 sentences, what was the overall goal and major progress in this portion of the conversation?\n\n";
347
348fn band_summary_request(
351 model: &str,
352 prompt: String,
353 max_tokens: Option<u32>,
354 options: &ManualCompactionOptions,
355 parent: Option<&CompactionParentContext>,
356) -> LLMRequest {
357 LLMRequest {
358 messages: Arc::new(vec![Message::user(prompt)]),
359 model: model.to_string(),
360 system_prompt: parent.and_then(|parent| parent.system_prompt.clone()),
361 tools: parent.and_then(|parent| parent.tools.clone()).filter(|tools| !tools.is_empty()),
362 tool_choice: Some(ToolChoice::none()),
363 max_tokens,
364 reasoning_effort: options.reasoning_effort,
365 verbosity: options.verbosity,
366 ..Default::default()
367 }
368}
369async fn summarize_locally_hierarchical(
386 provider: &dyn LLMProvider,
387 model: &str,
388 history: &[Message],
389 config: &CompactionConfig,
390 options: &ManualCompactionOptions,
391 context_budget: Option<usize>,
392 parent: Option<&CompactionParentContext>,
393) -> Result<Vec<Message>> {
394 let effective_config = context_bounded_compaction_config(
395 provider,
396 model,
397 history,
398 &config.clone().with_manual_overrides(options),
399 context_budget,
400 );
401 let (summary_history, _) =
402 split_continuity_history_with_target(history, route_tail_target_tokens(provider, model, context_budget));
403
404 let total = summary_history.len();
410 let band_size = total / 3;
411 let abstract_end = band_size;
412 let detail_end = band_size * 2;
413 let history_budget = compaction_history_budget(provider, model, context_budget);
414 let max_overflow_retries = CompactionRoutePolicy::resolve(provider.name(), model).max_overflow_retries;
415
416 let pruned_abstract = prune_oversized_tool_outputs(&summary_history[..abstract_end]);
418 let abstract_band = bound_history_for_summarization(&pruned_abstract, "", history_budget);
419 let abstract_summary = generate_summary_with_capacity_retry(
420 provider,
421 model,
422 &pruned_abstract,
423 "",
424 &abstract_band,
425 history_budget,
426 max_overflow_retries,
427 "Failed to generate abstract summary",
428 |band| {
429 band_summary_request(
430 model,
431 format!("{ABSTRACT_BAND_INTRO}{}", build_summary_prompt(band, "")),
432 Some(150),
433 options,
434 parent,
435 )
436 },
437 )
438 .await?;
439
440 let pruned_detail = prune_oversized_tool_outputs(&summary_history[abstract_end..detail_end]);
442 let detail_band = bound_history_for_summarization(&pruned_detail, &effective_config.summary_prompt, history_budget);
443 let detail_summary = generate_summary_with_capacity_retry(
444 provider,
445 model,
446 &pruned_detail,
447 &effective_config.summary_prompt,
448 &detail_band,
449 history_budget,
450 max_overflow_retries,
451 "Failed to generate detail summary",
452 |band| {
453 band_summary_request(
454 model,
455 build_summary_prompt(band, &effective_config.summary_prompt),
456 options.max_output_tokens,
457 options,
458 parent,
459 )
460 },
461 )
462 .await?;
463
464 let recent_band = &summary_history[detail_end..];
466 let retained = collect_retained_user_messages(
467 recent_band,
468 effective_config.retained_user_message_tokens,
469 effective_config.retained_user_messages,
470 );
471
472 let mut new_history = Vec::with_capacity(2 + retained.len());
474 new_history.push(Message::system(format!("{ABSTRACT_PREFIX}{abstract_summary}")));
475 new_history.push(Message::system(format!("{DETAIL_PREFIX}{detail_summary}")));
476 new_history.extend(retained);
477 for message in continuity_tail_with_target(history, route_tail_target_tokens(provider, model, context_budget)) {
479 new_history.push(message.clone());
480 }
481 Ok(bound_compacted_history_to_context(new_history, provider, model, context_budget))
482}