1use super::*;
4
5fn local_summary_history_budget(
8 provider: &dyn LLMProvider,
9 model: &str,
10 context_budget: Option<usize>,
11 instructions: &str,
12 max_output_tokens: Option<u32>,
13 parent: Option<&CompactionParentContext>,
14) -> Result<Option<usize>> {
15 let context_size =
16 effective_session_context_budget(provider.effective_context_size(model), context_budget.unwrap_or(0));
17 if context_size == 0 {
18 return Ok(None);
19 }
20 let prefix_tokens = parent.map_or(0, |parent| {
21 let system_tokens = parent
22 .system_prompt
23 .as_ref()
24 .map_or(0, |prompt| crate::prompts::system::estimate_token_count(prompt) as usize);
25 let tool_tokens = parent
26 .tools
27 .as_ref()
28 .map_or(0, |tools| crate::llm::usage_cost::estimate_tool_definition_tokens(tools) as usize);
29 system_tokens.saturating_add(tool_tokens)
30 });
31 let output_reserve = max_output_tokens.map_or(0, |tokens| tokens as usize);
32 let history_budget = compaction_history_budget(provider, model, Some(context_size))
33 .unwrap_or(0)
34 .saturating_sub(prefix_tokens)
35 .saturating_sub(output_reserve);
36 let minimum_tokens = Message::user(instructions.to_owned()).estimate_tokens().saturating_add(4);
40 anyhow::ensure!(
41 history_budget >= minimum_tokens,
42 "local summary request budget exhausted for {} / {} (context_size={context_size}, prefix_tokens={prefix_tokens}, output_reserve={output_reserve}, minimum_tokens={minimum_tokens})",
43 provider.name(),
44 model,
45 );
46 Ok(Some(history_budget))
47}
48
49fn bound_local_summary_history(history: &[Message], instructions: &str, budget: Option<usize>) -> Result<Vec<Message>> {
50 let bounded = bound_history_for_summarization(history, instructions, budget);
51 if let Some(budget) = budget {
52 let request_tokens = bounded
53 .iter()
54 .map(Message::estimate_tokens)
55 .fold(Message::user(instructions.to_owned()).estimate_tokens(), usize::saturating_add);
56 anyhow::ensure!(
57 request_tokens <= budget,
58 "local summary request budget exhausted by minimum protocol history (request_tokens={request_tokens}, budget={budget})",
59 );
60 }
61 Ok(bounded)
62}
63
64pub(crate) fn compaction_summary_request(
74 model: &str,
75 history: &[Message],
76 instructions: &str,
77 max_output_tokens: Option<u32>,
78 reasoning_effort: Option<ReasoningEffortLevel>,
79 verbosity: Option<VerbosityLevel>,
80 supports_turn_scoped: bool,
81 parent: Option<&CompactionParentContext>,
82) -> LLMRequest {
83 let mut forked = build_cache_safe_compaction_history(history, instructions);
84 if !supports_turn_scoped {
85 for message in forked.iter_mut() {
86 if message.clear_at.is_some() {
87 message.clear_at = None;
88 }
89 }
90 }
91 LLMRequest {
92 messages: Arc::new(forked),
93 model: model.to_string(),
94 system_prompt: parent.and_then(|parent| parent.system_prompt.clone()),
95 tools: parent.and_then(|parent| parent.tools.clone()).filter(|tools| !tools.is_empty()),
96 tool_choice: Some(ToolChoice::none()),
97 max_tokens: max_output_tokens,
98 reasoning_effort,
99 verbosity,
100 ..Default::default()
101 }
102}
103
104#[cfg_attr(feature = "profiling", hotpath::measure)]
106pub async fn compact_history(
107 provider: &dyn LLMProvider,
108 model: &str,
109 history: &[Message],
110 config: &CompactionConfig,
111) -> Result<Vec<Message>> {
112 compact_history_with_budget(provider, model, history, config, None).await
113}
114
115pub async fn compact_history_with_budget(
119 provider: &dyn LLMProvider,
120 model: &str,
121 history: &[Message],
122 config: &CompactionConfig,
123 context_budget: Option<usize>,
124) -> Result<Vec<Message>> {
125 if history.is_empty() {
126 return Ok(Vec::new());
127 }
128
129 if !config.always_summarize && history.len() <= config.keep_last_messages {
130 return Ok(bound_compacted_history_to_context(history.to_vec(), provider, model, context_budget));
134 }
135
136 if !config.always_summarize && provider.supports_manual_openai_compaction(model) {
142 let native_source = bound_history_for_native_compaction(
143 history,
144 "",
145 compaction_history_budget(provider, model, context_budget),
146 );
147 let compacted = provider
148 .compact_history(model, &native_source)
149 .await
150 .context("Failed to compact history via Responses compact endpoint")?;
151 return Ok(compacted);
156 }
157
158 let effective_config = context_bounded_compaction_config(provider, model, history, config, context_budget);
159 let history_budget =
169 local_summary_history_budget(provider, model, context_budget, &effective_config.summary_prompt, None, None)?;
170 let tail_target = route_tail_target_tokens(provider, model, context_budget);
171 let pruned_history = prune_oversized_tool_outputs(history);
174 let summary_source =
175 bound_local_summary_history(&pruned_history, &effective_config.summary_prompt, history_budget)?;
176 let summary = generate_local_summary_with_retry(
177 provider,
178 model,
179 &pruned_history,
180 &summary_source,
181 &effective_config.summary_prompt,
182 history_budget,
183 &ManualCompactionOptions::default(),
184 None,
185 )
186 .await?;
187
188 Ok(bound_compacted_history_to_context(
189 build_local_compacted_history(
190 history,
191 &summary,
192 effective_config.retained_user_message_tokens,
193 effective_config.retained_user_messages,
194 true,
197 tail_target,
198 ),
199 provider,
200 model,
201 context_budget,
202 ))
203}
204
205pub(crate) async fn summarize_locally(
227 provider: &dyn LLMProvider,
228 model: &str,
229 history: &[Message],
230 config: &CompactionConfig,
231 options: &ManualCompactionOptions,
232 context_budget: Option<usize>,
233 parent: Option<&CompactionParentContext>,
234) -> Result<Vec<Message>> {
235 if config.hierarchical {
236 return summarize_locally_hierarchical(provider, model, history, config, options, context_budget, parent).await;
237 }
238
239 let effective_config = context_bounded_compaction_config(
240 provider,
241 model,
242 history,
243 &config.clone().with_manual_overrides(options),
244 context_budget,
245 );
246 let history_budget = local_summary_history_budget(
247 provider,
248 model,
249 context_budget,
250 &effective_config.summary_prompt,
251 options.max_output_tokens,
252 parent,
253 )?;
254 let tail_target = route_tail_target_tokens(provider, model, context_budget);
255 let pruned_history = prune_oversized_tool_outputs(history);
261 let summary_source =
262 bound_local_summary_history(&pruned_history, &effective_config.summary_prompt, history_budget)?;
263 let summary = generate_local_summary_with_retry(
264 provider,
265 model,
266 &pruned_history,
267 &summary_source,
268 &effective_config.summary_prompt,
269 history_budget,
270 options,
271 parent,
272 )
273 .await?;
274
275 Ok(bound_compacted_history_to_context(
276 build_summary_compacted_history(history, summary, &effective_config, true, tail_target),
277 provider,
278 model,
279 context_budget,
280 ))
281}
282
283pub(crate) async fn generate_summary_with_capacity_retry(
293 provider: &dyn LLMProvider,
294 model: &str,
295 history: &[Message],
296 instructions: &str,
297 first_source: &[Message],
298 history_budget: Option<usize>,
299 max_overflow_retries: u32,
300 error_context: &'static str,
301 make_request: impl Fn(&[Message]) -> LLMRequest,
302) -> Result<String> {
303 let first_tokens = first_source.iter().map(Message::estimate_tokens).sum::<usize>();
304 let mut current_source: &[Message] = first_source;
305 let mut current_tokens = first_tokens;
306 let mut current_budget = history_budget;
307 let mut remaining_retries = max_overflow_retries;
308 let mut retry_storage: Vec<Message>;
309 loop {
310 match collect_single_response(provider, make_request(current_source)).await {
311 Ok(response) => {
312 let summary = response.content.unwrap_or_default().trim().to_string();
313 if summary.is_empty() {
314 return Err(anyhow::anyhow!(
315 "{error_context} for {} / {}: provider returned an empty summary (input_tokens={current_tokens}, budget={current_budget:?})",
316 provider.name(),
317 model,
318 ));
319 }
320 return Ok(summary);
321 }
322 Err(error) if is_context_capacity_message(&error.to_string()) && remaining_retries > 0 => {
323 current_budget = match current_budget {
327 Some(budget) => Some((budget / 2).max(4)),
328 None => Some((current_tokens / 2).max(4)),
329 };
330 retry_storage = bound_history_for_summarization(history, instructions, current_budget);
331 let retry_tokens = retry_storage.iter().map(Message::estimate_tokens).sum::<usize>();
335 if retry_tokens >= current_tokens {
336 return Err(anyhow::Error::from(error).context(format!(
337 "{error_context} for {} / {} (input_tokens={current_tokens}, budget={current_budget:?})",
338 provider.name(),
339 model,
340 )));
341 }
342 tracing::warn!(
343 provider = provider.name(),
344 model,
345 input_tokens = current_tokens,
346 retry_tokens,
347 budget = ?current_budget,
348 error = ?error,
349 "{error_context}: input exceeded context capacity; retrying with a halved input budget"
350 );
351 current_source = &retry_storage;
352 current_tokens = retry_tokens;
353 remaining_retries -= 1;
354 }
355 Err(error) => {
356 return Err(anyhow::Error::from(error).context(format!(
357 "{error_context} for {} / {} (input_tokens={current_tokens}, budget={current_budget:?})",
358 provider.name(),
359 model,
360 )));
361 }
362 }
363 }
364}
365
366async fn generate_local_summary_with_retry(
368 provider: &dyn LLMProvider,
369 model: &str,
370 history: &[Message],
371 summary_source: &[Message],
372 instructions: &str,
373 history_budget: Option<usize>,
374 options: &ManualCompactionOptions,
375 parent: Option<&CompactionParentContext>,
376) -> Result<String> {
377 let supports_turn_scoped = provider.supports_turn_scoped_system_messages(model);
378 generate_summary_with_capacity_retry(
379 provider,
380 model,
381 history,
382 instructions,
383 summary_source,
384 history_budget,
385 CompactionRoutePolicy::resolve(provider.name(), model).max_overflow_retries,
386 "Failed to generate compaction summary",
387 |source| {
388 compaction_summary_request(
389 model,
390 source,
391 instructions,
392 options.max_output_tokens,
393 options.reasoning_effort,
394 options.verbosity,
395 supports_turn_scoped,
396 parent,
397 )
398 },
399 )
400 .await
401}
402
403const ABSTRACT_BAND_INTRO: &str =
405 "In 1-2 sentences, what was the overall goal and major progress in this portion of the conversation?\n\n";
406
407fn band_summary_request(
410 model: &str,
411 prompt: String,
412 max_tokens: Option<u32>,
413 options: &ManualCompactionOptions,
414 parent: Option<&CompactionParentContext>,
415) -> LLMRequest {
416 LLMRequest {
417 messages: Arc::new(vec![Message::user(prompt)]),
418 model: model.to_string(),
419 system_prompt: parent.and_then(|parent| parent.system_prompt.clone()),
420 tools: parent.and_then(|parent| parent.tools.clone()).filter(|tools| !tools.is_empty()),
421 tool_choice: Some(ToolChoice::none()),
422 max_tokens,
423 reasoning_effort: options.reasoning_effort,
424 verbosity: options.verbosity,
425 ..Default::default()
426 }
427}
428async fn summarize_locally_hierarchical(
445 provider: &dyn LLMProvider,
446 model: &str,
447 history: &[Message],
448 config: &CompactionConfig,
449 options: &ManualCompactionOptions,
450 context_budget: Option<usize>,
451 parent: Option<&CompactionParentContext>,
452) -> Result<Vec<Message>> {
453 let effective_config = context_bounded_compaction_config(
454 provider,
455 model,
456 history,
457 &config.clone().with_manual_overrides(options),
458 context_budget,
459 );
460 let (summary_history, _) =
461 split_continuity_history_with_target(history, route_tail_target_tokens(provider, model, context_budget));
462
463 let total = summary_history.len();
469 let band_size = total / 3;
470 let abstract_end = band_size;
471 let detail_end = band_size * 2;
472 let max_overflow_retries = CompactionRoutePolicy::resolve(provider.name(), model).max_overflow_retries;
473
474 let abstract_budget =
476 local_summary_history_budget(provider, model, context_budget, ABSTRACT_BAND_INTRO, Some(150), parent)?;
477 let detail_budget = local_summary_history_budget(
479 provider,
480 model,
481 context_budget,
482 &effective_config.summary_prompt,
483 options.max_output_tokens,
484 parent,
485 )?;
486 let pruned_abstract = prune_oversized_tool_outputs(&summary_history[..abstract_end]);
487 let abstract_band = bound_local_summary_history(&pruned_abstract, ABSTRACT_BAND_INTRO, abstract_budget)?;
488 let pruned_detail = prune_oversized_tool_outputs(&summary_history[abstract_end..detail_end]);
489 let detail_band = bound_local_summary_history(&pruned_detail, &effective_config.summary_prompt, detail_budget)?;
490 let abstract_summary = generate_summary_with_capacity_retry(
491 provider,
492 model,
493 &pruned_abstract,
494 ABSTRACT_BAND_INTRO,
495 &abstract_band,
496 abstract_budget,
497 max_overflow_retries,
498 "Failed to generate abstract summary",
499 |band| {
500 band_summary_request(
501 model,
502 format!("{ABSTRACT_BAND_INTRO}{}", build_summary_prompt(band, "")),
503 Some(150),
504 options,
505 parent,
506 )
507 },
508 )
509 .await?;
510
511 let detail_summary = generate_summary_with_capacity_retry(
513 provider,
514 model,
515 &pruned_detail,
516 &effective_config.summary_prompt,
517 &detail_band,
518 detail_budget,
519 max_overflow_retries,
520 "Failed to generate detail summary",
521 |band| {
522 band_summary_request(
523 model,
524 build_summary_prompt(band, &effective_config.summary_prompt),
525 options.max_output_tokens,
526 options,
527 parent,
528 )
529 },
530 )
531 .await?;
532
533 let recent_band = &summary_history[detail_end..];
535 let retained = collect_retained_user_messages(
536 recent_band,
537 effective_config.retained_user_message_tokens,
538 effective_config.retained_user_messages,
539 );
540
541 let mut new_history = Vec::with_capacity(2 + retained.len());
543 new_history.push(Message::system(format!("{ABSTRACT_PREFIX}{abstract_summary}")));
544 new_history.push(Message::system(format!("{DETAIL_PREFIX}{detail_summary}")));
545 new_history.extend(retained);
546 for message in continuity_tail_with_target(history, route_tail_target_tokens(provider, model, context_budget)) {
548 new_history.push(message.clone());
549 }
550 Ok(bound_compacted_history_to_context(new_history, provider, model, context_budget))
551}