Skip to main content

vtcode_core/compaction/
summarize.rs

1//! Local summarization: prompts, retries, and hierarchical bands.
2
3use super::*;
4
5/// Keep the existing safety headroom while reserving the frozen request prefix
6/// and any explicit summary output limit. Unknown capacity keeps legacy bounds.
7fn 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    // A positive budget must fit the instruction plus one minimal history
37    // message. Otherwise the bounder's minimum (or zero-as-unknown sentinel)
38    // would turn an exhausted budget back into an oversized request.
39    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
64/// Build a cache-safe local-summary request that reuses the parent prefix.
65/// `tool_choice` is forced to `none` so the summarizer cannot spend the
66/// compaction pass on tool calls while the system/tools/messages prefix
67/// stays identical to the parent's last request.
68///
69/// `supports_turn_scoped` mirrors the live turn path: canonical history keeps
70/// the typed `clear_at` marker, but routes without native support receive it
71/// as an ordinary system directive (text preserved, `clear_at` stripped) so
72/// `merge-gateway` and other non-Anthropic wires do not fail validation.
73pub(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/// Compact conversation history using the configured summarizer.
105#[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
115/// Compact conversation history using a caller-resolved context budget for
116/// input bounding and locally rebuilt output. Standalone native responses are
117/// returned as the provider's canonical next context window.
118pub 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        // Message-count retention is only a fast path. A single large tool
131        // result can exceed the resolved token budget even when the history
132        // contains fewer messages than `keep_last_messages`.
133        return Ok(bound_compacted_history_to_context(history.to_vec(), provider, model, context_budget));
134    }
135
136    // `supports_responses_compaction` is shared by standalone Responses
137    // endpoints and Anthropic's inline context-management path. This legacy
138    // function calls `compact_history` directly, so only the narrower
139    // standalone capability is safe here; inline providers use the manual
140    // strategy dispatcher below (or the local fallback on this legacy path).
141    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        // A standalone compaction response is the provider's canonical next
152        // context window. Do not append a locally selected continuity tail or
153        // discard opaque provider items from it. The provider has already
154        // produced the exact window that must be replayed on the next turn.
155        return Ok(compacted);
156    }
157
158    let effective_config = context_bounded_compaction_config(provider, model, history, config, context_budget);
159    // Cache-safe forking: prepend the parent's full conversation prefix and
160    // append the compaction instruction, instead of reformatting the entire
161    // history into a single new user message (which would pay the full
162    // uncached input rate). No parent system/tools are available on this
163    // legacy path; callers with a request envelope should prefer
164    // `compact_history_manual_with_parent_context`.
165    // Bound the fork so the summary request itself fits the summarizer window;
166    // an unbounded fork is what made `/compact` fail on near-full or
167    // smaller-window (model-switch) histories.
168    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    // Trim oversized tool outputs before bounding so large dumps do not evict
172    // whole protocol groups from the summarizer fork.
173    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            // Keep the same protocol-safe continuity tail as the live/manual
195            // paths. Forked histories must not lose the newest working turn.
196            true,
197            tail_target,
198        ),
199        provider,
200        model,
201        context_budget,
202    ))
203}
204
205/// Local (provider-agnostic) summarization compaction.
206///
207/// Forks the parent conversation cache-safely: same history prefix plus the
208/// compaction instruction appended, with the parent system/tools prefix when
209/// supplied (`None` keeps the legacy standalone shape). Rebuilds the history
210/// as a summary system message plus the retained recent user messages.
211/// Applies the manual options to the summary request.
212///
213/// When `config.hierarchical` is `true`, delegates to
214/// [`summarize_locally_hierarchical`] which produces a multi-tier pyramid
215/// (abstract + detail + verbatim) instead of a flat summary./// Local (provider-agnostic) summarization compaction.
216///
217/// Forks the parent conversation cache-safely: same history prefix plus the
218/// compaction instruction appended, with the parent system/tools prefix when
219/// supplied (`None` keeps the legacy standalone shape). Rebuilds the history
220/// as a summary system message plus the retained recent user messages.
221/// Applies the manual options to the summary request.
222///
223/// When `config.hierarchical` is `true`, delegates to
224/// [`summarize_locally_hierarchical`] which produces a multi-tier pyramid
225/// (abstract + detail + verbatim) instead of a flat summary.
226pub(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    // Bound the fork so the summary request itself fits the summarizer window.
256    // `/compact` is normally run when the context is already near full, so an
257    // unbounded fork is rejected by the provider and the whole command fails.
258    // Oversized tool outputs are pruned first so large dumps do not evict
259    // whole protocol groups from the fork.
260    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
283/// Generate a summary request, retrying with progressively halved input budgets
284/// when the provider rejects the request as over context capacity.
285///
286/// Token estimates are heuristic, so a bounded input can still overflow the
287/// summarizer window. Bounded retries recover instead of failing the whole
288/// `/compact` command; any other error (or exhausted retries) propagates.
289/// `make_request` rebuilds the request shape (cache-safe fork or single-shot
290/// band prompt) from the given source messages so every local summarization
291/// path shares one retry contract.
292pub(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                // With an unknown budget the bound is verbatim, so halving
324                // `None` would resend the identical fork. Derive a fallback
325                // from the failing attempt so the retry can still shrink.
326                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                // A retry only helps when it actually shrinks the input: with
332                // an already-fitting history it would resend the identical
333                // request, so propagate the original failure instead.
334                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
366/// Generate a cache-safe fork summary with the shared capacity-retry contract.
367async 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
403/// Intro framing for the hierarchical abstract band (oldest third).
404const 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
407/// Build a single-shot band summary request: one user message carrying the
408/// pre-rendered band prompt, with the parent system/tools prefix reused.
409fn 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}
428/// Hierarchical local summarization: abstract + detail + verbatim pyramid.
429///
430/// Splits the history into three bands and summarizes each with a different
431/// compression target:
432/// - **Abstract** (oldest third): 1-2 sentence overview
433/// - **Detail** (middle third): paragraph-level summary
434/// - **Verbatim** (newest third): kept as-is plus importance-weighted retained messages
435///
436/// This follows the hierarchical summarization strategy from the context window
437/// management literature: recent turns verbatim, older turns as paragraph
438/// summaries, oldest turns as a single abstract.
439///
440/// Band requests carry only their band's messages (not the full history
441/// prefix), so message-prefix reuse does not apply; the parent system/tools
442/// prefix is still reused, and `tool_choice` stays `none` so neither pass can
443/// spend the compaction budget on tool calls.
444async 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    // Split history into three bands at roughly equal thirds. Each band is
464    // bounded to the summarizer window so a near-full context cannot overflow
465    // the band request itself (same class of bug as the flat-path fork).
466    // Oversized tool outputs are pruned first so large dumps do not evict
467    // whole protocol groups from a band.
468    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    // Band 1 (oldest): compress into 1-2 sentence abstract.
475    let abstract_budget =
476        local_summary_history_budget(provider, model, context_budget, ABSTRACT_BAND_INTRO, Some(150), parent)?;
477    // Validate both prefixes and bounded histories before spending either call.
478    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    // Band 2 (middle): paragraph-level summary using the full summary prompt.
512    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    // Band 3 (newest): retain verbatim via the bounded protocol tail.
534    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    // Assemble: [abstract, detail, ...retained_recent, ...continuity_tail]
542    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    // Live compaction: retain the most recent turn verbatim for continuity.
547    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}