Skip to main content

vtcode_core/compaction/
summarize.rs

1//! Local summarization: prompts, retries, and hierarchical bands.
2
3use super::*;
4
5/// Build a cache-safe local-summary request that reuses the parent prefix.
6/// `tool_choice` is forced to `none` so the summarizer cannot spend the
7/// compaction pass on tool calls while the system/tools/messages prefix
8/// stays identical to the parent's last request.
9///
10/// `supports_turn_scoped` mirrors the live turn path: canonical history keeps
11/// the typed `clear_at` marker, but routes without native support receive it
12/// as an ordinary system directive (text preserved, `clear_at` stripped) so
13/// `merge-gateway` and other non-Anthropic wires do not fail validation./// Build a cache-safe local-summary request that reuses the parent prefix.
14/// `tool_choice` is forced to `none` so the summarizer cannot spend the
15/// compaction pass on tool calls while the system/tools/messages prefix
16/// stays identical to the parent's last request.
17///
18/// `supports_turn_scoped` mirrors the live turn path: canonical history keeps
19/// the typed `clear_at` marker, but routes without native support receive it
20/// as an ordinary system directive (text preserved, `clear_at` stripped) so
21/// `merge-gateway` and other non-Anthropic wires do not fail validation.
22pub(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/// Compact conversation history using the configured summarizer.
54#[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
64/// Compact conversation history using a caller-resolved context budget for
65/// input bounding and locally rebuilt output. Standalone native responses are
66/// returned as the provider's canonical next context window.
67pub 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        // Message-count retention is only a fast path. A single large tool
80        // result can exceed the resolved token budget even when the history
81        // contains fewer messages than `keep_last_messages`.
82        return Ok(bound_compacted_history_to_context(history.to_vec(), provider, model, context_budget));
83    }
84
85    // `supports_responses_compaction` is shared by standalone Responses
86    // endpoints and Anthropic's inline context-management path. This legacy
87    // function calls `compact_history` directly, so only the narrower
88    // standalone capability is safe here; inline providers use the manual
89    // strategy dispatcher below (or the local fallback on this legacy path).
90    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        // A standalone compaction response is the provider's canonical next
101        // context window. Do not append a locally selected continuity tail or
102        // discard opaque provider items from it. The provider has already
103        // produced the exact window that must be replayed on the next turn.
104        return Ok(compacted);
105    }
106
107    let effective_config = context_bounded_compaction_config(provider, model, history, config, context_budget);
108    // Cache-safe forking: prepend the parent's full conversation prefix and
109    // append the compaction instruction, instead of reformatting the entire
110    // history into a single new user message (which would pay the full
111    // uncached input rate). No parent system/tools are available on this
112    // legacy path; callers with a request envelope should prefer
113    // `compact_history_manual_with_parent_context`.
114    // Bound the fork so the summary request itself fits the summarizer window;
115    // an unbounded fork is what made `/compact` fail on near-full or
116    // smaller-window (model-switch) histories.
117    let history_budget = compaction_history_budget(provider, model, context_budget);
118    let tail_target = route_tail_target_tokens(provider, model, context_budget);
119    // Trim oversized tool outputs before bounding so large dumps do not evict
120    // whole protocol groups from the summarizer fork.
121    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            // Keep the same protocol-safe continuity tail as the live/manual
143            // paths. Forked histories must not lose the newest working turn.
144            true,
145            tail_target,
146        ),
147        provider,
148        model,
149        context_budget,
150    ))
151}
152
153/// Local (provider-agnostic) summarization compaction.
154///
155/// Forks the parent conversation cache-safely: same history prefix plus the
156/// compaction instruction appended, with the parent system/tools prefix when
157/// supplied (`None` keeps the legacy standalone shape). Rebuilds the history
158/// as a summary system message plus the retained recent user messages.
159/// Applies the manual options to the summary request.
160///
161/// When `config.hierarchical` is `true`, delegates to
162/// [`summarize_locally_hierarchical`] which produces a multi-tier pyramid
163/// (abstract + detail + verbatim) instead of a flat summary./// Local (provider-agnostic) summarization compaction.
164///
165/// Forks the parent conversation cache-safely: same history prefix plus the
166/// compaction instruction appended, with the parent system/tools prefix when
167/// supplied (`None` keeps the legacy standalone shape). Rebuilds the history
168/// as a summary system message plus the retained recent user messages.
169/// Applies the manual options to the summary request.
170///
171/// When `config.hierarchical` is `true`, delegates to
172/// [`summarize_locally_hierarchical`] which produces a multi-tier pyramid
173/// (abstract + detail + verbatim) instead of a flat summary.
174pub(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    // Bound the fork so the summary request itself fits the summarizer window.
197    // `/compact` is normally run when the context is already near full, so an
198    // unbounded fork is rejected by the provider and the whole command fails.
199    // Oversized tool outputs are pruned first so large dumps do not evict
200    // whole protocol groups from the fork.
201    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
224/// Generate a summary request, retrying with progressively halved input budgets
225/// when the provider rejects the request as over context capacity.
226///
227/// Token estimates are heuristic, so a bounded input can still overflow the
228/// summarizer window. Bounded retries recover instead of failing the whole
229/// `/compact` command; any other error (or exhausted retries) propagates.
230/// `make_request` rebuilds the request shape (cache-safe fork or single-shot
231/// band prompt) from the given source messages so every local summarization
232/// path shares one retry contract.
233pub(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                // With an unknown budget the bound is verbatim, so halving
265                // `None` would resend the identical fork. Derive a fallback
266                // from the failing attempt so the retry can still shrink.
267                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                // A retry only helps when it actually shrinks the input: with
273                // an already-fitting history it would resend the identical
274                // request, so propagate the original failure instead.
275                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
307/// Generate a cache-safe fork summary with the shared capacity-retry contract.
308async 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
344/// Intro framing for the hierarchical abstract band (oldest third).
345const 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
348/// Build a single-shot band summary request: one user message carrying the
349/// pre-rendered band prompt, with the parent system/tools prefix reused.
350fn 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}
369/// Hierarchical local summarization: abstract + detail + verbatim pyramid.
370///
371/// Splits the history into three bands and summarizes each with a different
372/// compression target:
373/// - **Abstract** (oldest third): 1-2 sentence overview
374/// - **Detail** (middle third): paragraph-level summary
375/// - **Verbatim** (newest third): kept as-is plus importance-weighted retained messages
376///
377/// This follows the hierarchical summarization strategy from the context window
378/// management literature: recent turns verbatim, older turns as paragraph
379/// summaries, oldest turns as a single abstract.
380///
381/// Band requests carry only their band's messages (not the full history
382/// prefix), so message-prefix reuse does not apply; the parent system/tools
383/// prefix is still reused, and `tool_choice` stays `none` so neither pass can
384/// spend the compaction budget on tool calls.
385async 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    // Split history into three bands at roughly equal thirds. Each band is
405    // bounded to the summarizer window so a near-full context cannot overflow
406    // the band request itself (same class of bug as the flat-path fork).
407    // Oversized tool outputs are pruned first so large dumps do not evict
408    // whole protocol groups from a band.
409    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    // Band 1 (oldest): compress into 1-2 sentence abstract.
417    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    // Band 2 (middle): paragraph-level summary using the full summary prompt.
441    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    // Band 3 (newest): retain verbatim via the bounded protocol tail.
465    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    // Assemble: [abstract, detail, ...retained_recent, ...continuity_tail]
473    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    // Live compaction: retain the most recent turn verbatim for continuity.
478    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}