Skip to main content

vtcode_core/compaction/
config.rs

1//! Compaction configuration, route policy, and parent context.
2
3use super::*;
4
5const DEFAULT_COMPACTION_TARGET_THRESHOLD: f64 = 0.50;
6const DEFAULT_COMPACTION_KEEP_LAST_MESSAGES: usize = 10;
7const DEFAULT_RETAINED_USER_MESSAGE_TOKENS: usize = 20_000;
8const DEFAULT_RETAINED_USER_MESSAGES: usize = 6;
9/// Internal continuity budget. This is deliberately not configuration: changing
10/// it changes the shape of every compacted request and therefore provider cache
11/// behavior.
12pub(crate) const CONTINUITY_TAIL_TARGET_TOKENS: usize = 20_000;
13/// Keep history below the model window after reserving space for the system
14/// prompt, memory envelope, summary framing, and the next response.
15pub(crate) const COMPACTION_CONTEXT_OVERHEAD_FRACTION_DENOMINATOR: usize = 8;
16pub(crate) const COMPACTION_CONTEXT_FIXED_OVERHEAD_TOKENS: usize = 512;
17pub(crate) const SUMMARY_PREFIX: &str = "Previous conversation summary:\n";
18pub(crate) const ABSTRACT_PREFIX: &str = "Earlier context (abstract):\n";
19pub(crate) const DETAIL_PREFIX: &str = "Recent context (summary):\n";
20
21/// Default summarization prompt. Structures the summary for continuity: after
22/// reading it, the next context must feel like a seamless continuation, not a
23/// fresh start. Kept as a `const` so `CompactionConfig::default` does not
24/// re-allocate a ~1KB literal on every construction.
25const DEFAULT_SUMMARY_PROMPT: &str = "Summarize the conversation so far using this exact structure. The goal is continuity: after reading this summary, the next context must feel like a seamless continuation of the same task, not a fresh start.\n\n## Goal\n[What the user is trying to accomplish]\n\n## Constraints & Preferences\n- [Requirements, preferences, or constraints from the user]\n\n## What I Was Just Doing\n[The single most recent action in progress: what step the agent was executing, which tool or edit was underway, and where it left off. This is the continuity anchor.]\n\n## Last Action & Result\n[The last completed action and its outcome (success, error, or partial). Include the exact error or status if relevant.]\n\n## Progress\n### Done\n- [Completed work]\n\n### In Progress\n- [Current work]\n\n### Blocked\n- [Blocking issues, if any]\n\n## Key Decisions\n- **[Decision]**: [Reason]\n\n## Next Steps\n1. [Most important next step]\n\n## Critical Context\n- [Facts needed to continue]\n\nKeep it concise and actionable. Always preserve the current task objective and acceptance criteria, file paths that were read or modified, test results and error messages, and decisions with their reasoning.";
26
27/// Compaction configuration for context window management.
28#[derive(Debug, Clone)]
29pub struct CompactionConfig {
30    /// Threshold (0.0-1.0) at which to trigger compaction.
31    pub trigger_threshold: f64,
32    /// Target usage ratio (0.0-1.0) after compaction.
33    pub target_threshold: f64,
34    /// Prompt for summarization.
35    pub summary_prompt: String,
36    /// Legacy short-circuit used to skip local compaction for tiny histories.
37    pub keep_last_messages: usize,
38    /// Total token budget reserved for retaining real user messages verbatim.
39    pub retained_user_message_tokens: usize,
40    /// Maximum number of recent user messages to retain verbatim.
41    pub retained_user_messages: usize,
42    /// Force local summarization even for short histories and providers with native compaction.
43    pub always_summarize: bool,
44    /// Enable hierarchical summarization (multi-level pyramid).
45    ///
46    /// When `true`, compaction produces three tiers instead of a flat summary:
47    /// - **Abstract**: oldest turns compressed into 1-2 sentences
48    /// - **Detail**: middle turns summarized into a paragraph
49    /// - **Verbatim**: most recent turns kept as-is
50    ///
51    /// When `false` (default), all old turns become a single flat summary.
52    pub hierarchical: bool,
53    /// Auto-compaction suppression state. `SUPPRESS_NONE` (0) means compaction
54    /// is allowed; any other value gates automatic compaction until the
55    /// appropriate clearing event (success, model switch, etc.).
56    pub auto_compact_suppressed: u8,
57}
58
59impl Default for CompactionConfig {
60    fn default() -> Self {
61        Self {
62            trigger_threshold: DEFAULT_COMPACTION_TRIGGER_RATIO,
63            target_threshold: DEFAULT_COMPACTION_TARGET_THRESHOLD,
64            summary_prompt: DEFAULT_SUMMARY_PROMPT.to_string(),
65            keep_last_messages: DEFAULT_COMPACTION_KEEP_LAST_MESSAGES,
66            retained_user_message_tokens: DEFAULT_RETAINED_USER_MESSAGE_TOKENS,
67            retained_user_messages: DEFAULT_RETAINED_USER_MESSAGES,
68            always_summarize: false,
69            hierarchical: false,
70            auto_compact_suppressed: SUPPRESS_NONE,
71        }
72    }
73}
74
75/// Per-route compaction policy in the DeepSeek-harness shape.
76///
77/// `threshold_ratio` caps the auto-compaction trigger as a fraction of the
78/// route window (fail-safe: it can only fire *earlier*). `retain_ratio` sizes
79/// the verbatim continuity tail as a fraction of the route window so small
80/// windows keep a usable summary instead of a tail-only history.
81/// `max_overflow_retries` bounds extra bounded-fork attempts after a
82/// context-capacity rejection.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct CompactionRoutePolicy {
85    /// Trigger ceiling as a fraction of the route window. `1.0` preserves the
86    /// absolute-tokens trigger unchanged.
87    pub threshold_ratio: f64,
88    /// Verbatim-tail share of the route window.
89    pub retain_ratio: f64,
90    /// Extra attempts after a context-capacity rejection (progressive halving).
91    pub max_overflow_retries: u32,
92}
93
94/// Floor keeping the continuity anchor meaningful on tiny windows.
95const MIN_TAIL_TARGET_TOKENS: usize = 1_024;
96
97impl Default for CompactionRoutePolicy {
98    fn default() -> Self {
99        Self {
100            threshold_ratio: 1.0,
101            retain_ratio: 0.16,
102            max_overflow_retries: 1,
103        }
104    }
105}
106
107/// Per-route overrides, matched as `(provider_substring, model_substring)`.
108/// Empty until a route demonstrates a need: the window-derived defaults
109/// already adapt retention and budgets to every route's resolved capacity.
110const ROUTE_POLICIES: &[(&str, &str, CompactionRoutePolicy)] = &[];
111
112impl CompactionRoutePolicy {
113    /// Resolve the policy for a provider/model route (first table hit wins,
114    /// otherwise the default).
115    #[must_use]
116    pub fn resolve(provider_name: &str, model: &str) -> Self {
117        ROUTE_POLICIES
118            .iter()
119            .find(|(provider, model_match, _)| provider_name.contains(provider) && model.contains(model_match))
120            .map(|(_, _, policy)| *policy)
121            .unwrap_or_default()
122    }
123
124    /// Verbatim-tail budget for a route window. Unknown windows (`0`) keep the
125    /// legacy constant; otherwise the tail scales with the window so small
126    /// routes keep a usable summary instead of a tail-only history.
127    #[allow(
128        clippy::cast_sign_loss,
129        reason = "The ratio is clamped to [0.0, 1.0] and the window is non-negative, so the product cannot be negative."
130    )]
131    #[must_use]
132    pub fn tail_target_tokens(&self, window_tokens: usize) -> usize {
133        if window_tokens == 0 {
134            return CONTINUITY_TAIL_TARGET_TOKENS;
135        }
136        ((window_tokens as f64 * self.retain_ratio.clamp(0.0, 1.0)) as usize)
137            .clamp(MIN_TAIL_TARGET_TOKENS, CONTINUITY_TAIL_TARGET_TOKENS)
138    }
139
140    /// Fail-safe trigger cap: only fires compaction *earlier*, never later.
141    /// A `1.0` ratio (the default) leaves the absolute-tokens trigger
142    /// unchanged.
143    #[allow(
144        clippy::cast_sign_loss,
145        reason = "The ratio is clamped to [0.0, 1.0] and the window is non-negative, so the product cannot be negative."
146    )]
147    #[must_use]
148    pub fn apply_threshold_cap(&self, threshold_tokens: usize, window_tokens: usize) -> usize {
149        if self.threshold_ratio < 1.0 && window_tokens > 0 {
150            threshold_tokens.min((window_tokens as f64 * self.threshold_ratio.clamp(0.0, 1.0)) as usize)
151        } else {
152            threshold_tokens
153        }
154    }
155}
156
157/// Resolve the route tail budget from the session budget (already intersected
158/// with the provider window) or the provider window when unset.
159#[must_use]
160pub(crate) fn route_tail_target_tokens(
161    provider: &dyn LLMProvider,
162    model: &str,
163    context_budget: Option<usize>,
164) -> usize {
165    let policy = CompactionRoutePolicy::resolve(provider.name(), model);
166    let window = context_budget
167        .filter(|value| *value > 0)
168        .unwrap_or_else(|| provider.effective_context_size(model));
169    policy.tail_target_tokens(window)
170}
171
172/// Parent request prefix for cache-safe compaction forking.
173///
174/// Prompt caching is a prefix match: a compaction request only reuses the
175/// parent conversation's cached prefix when it carries the *exact same*
176/// system prompt, tool definitions, and history prefix, with the compaction
177/// instruction appended as the final new turn. A standalone single-message
178/// summary prompt pays the full uncached input rate for the entire history.
179#[derive(Debug, Clone, Default)]
180pub struct CompactionParentContext {
181    /// Exact system prompt of the parent segment (`request_envelope.system_prompt()`).
182    pub system_prompt: Option<Arc<str>>,
183    /// Exact ordered tool catalog of the parent segment
184    /// (`request_envelope.ordered_tools()`). Empty catalogs should be `None`.
185    pub tools: Option<Arc<Vec<ToolDefinition>>>,
186}
187
188impl CompactionParentContext {
189    #[must_use]
190    pub fn is_empty(&self) -> bool {
191        self.system_prompt.is_none() && self.tools.is_none()
192    }
193}
194
195/// Build a cache-safe compaction history: the parent's full conversation
196/// prefix verbatim, plus the compaction instruction as the only new turn.
197/// From the provider's perspective this looks nearly identical to the
198/// parent's last request, so the cached prefix is reused.
199#[must_use]
200pub fn build_cache_safe_compaction_history(history: &[Message], compaction_prompt: &str) -> Vec<Message> {
201    let mut forked = Vec::with_capacity(history.len().saturating_add(1));
202    forked.extend(history.iter().cloned());
203    forked.push(Message::user(compaction_prompt.to_string()));
204    forked
205}