1use std::path::Path;
11
12use anyhow::Result;
13
14use crate::compaction::memory_envelope::{
15 MemoryEnvelopePersistence, MemoryEnvelopePlacement, SessionMemoryEnvelopeUpdate,
16 dedup_repeated_file_reads_for_local_compaction, effective_compaction_threshold_with_reserve,
17 effective_context_budget, persist_memory_envelope_async_with_update, strip_existing_memory_envelope,
18};
19use crate::compaction::two_pass::fingerprint_prefix;
20use crate::compaction::{
21 CompactionConfig, CompactionParentContext, CompactionStrategy, ManualCompactionOptions, SUPPRESS_NONE,
22 build_local_compacted_history, build_summary_prompt, classify_suppress_reason,
23 compact_history_manual_with_parent_context, compacted_history_shrinks, manual_compaction_strategy,
24};
25use crate::exec::events::CompactionMode;
26use crate::llm::{
27 collect_single_response,
28 provider::{LLMProvider, LLMRequest, Message, ToolChoice},
29};
30use vtcode_config::loader::VTCodeConfig;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct AutoCompactionOutcome {
35 pub original_len: usize,
36 pub compacted_len: usize,
37 pub mode: CompactionMode,
38 pub history_artifact_path: Option<String>,
39 pub envelope: Option<crate::compaction::memory_envelope::SessionMemoryEnvelope>,
42}
43
44pub struct AutoCompactionInput<'a> {
46 pub provider: &'a dyn LLMProvider,
47 pub model: &'a str,
48 pub session_id: &'a str,
49 pub workspace_root: &'a Path,
50 pub vt_cfg: Option<&'a VTCodeConfig>,
51 pub current_token_usage: usize,
53 pub reserved_output_tokens: usize,
55 pub touched_files: &'a [String],
57 pub engine_cfg: CompactionConfig,
59 pub manual_options: ManualCompactionOptions,
61 pub placement: MemoryEnvelopePlacement,
63 pub prefire: Option<&'a crate::compaction::PrefireState>,
65 pub auto_compact_suppressed: &'a mut u8,
69 pub force_compaction: bool,
73 pub steering_update: Option<&'a SessionMemoryEnvelopeUpdate>,
75 pub parent_context: Option<CompactionParentContext>,
80}
81
82pub async fn auto_compact_messages(
90 input: AutoCompactionInput<'_>,
91 history: &mut Vec<Message>,
92) -> Result<Option<AutoCompactionOutcome>> {
93 let AutoCompactionInput {
94 provider,
95 model,
96 session_id,
97 workspace_root,
98 vt_cfg,
99 current_token_usage,
100 reserved_output_tokens,
101 touched_files,
102 engine_cfg,
103 manual_options,
104 placement,
105 prefire,
106 auto_compact_suppressed,
107 force_compaction,
108 steering_update,
109 parent_context,
110 } = input;
111
112 if !vt_cfg.is_some_and(|cfg| cfg.agent.harness.auto_compaction_enabled) {
113 return Ok(None);
114 }
115
116 let Some(threshold) = effective_compaction_threshold_with_reserve(vt_cfg, provider, model, reserved_output_tokens)
117 else {
118 return Ok(None);
119 };
120
121 let context_budget = match effective_context_budget(vt_cfg, provider, model) {
125 0 => None,
126 budget => Some(budget),
127 };
128 let window = context_budget.unwrap_or(0);
131 let threshold = crate::compaction::CompactionRoutePolicy::resolve(provider.name(), model)
132 .apply_threshold_cap(threshold, window);
133 if current_token_usage < threshold && !force_compaction {
134 return Ok(None);
135 }
136
137 if *auto_compact_suppressed != SUPPRESS_NONE {
138 return Ok(None);
139 }
140
141 let mut compaction_input = history.clone();
142 strip_existing_memory_envelope(&mut compaction_input);
143 let original_history = compaction_input.clone();
144
145 let compaction_result: Result<Option<AutoCompactionOutcome>> = async {
146 if let Some(prefire_state) = prefire {
148 if let Some(mut compacted) = try_two_pass_with_prefire(
149 prefire_state,
150 provider,
151 model,
152 &original_history,
153 &engine_cfg,
154 context_budget,
155 parent_context.as_ref(),
156 )
157 .await?
158 {
159 let original_len = original_history.len();
160 let envelope = persist_memory_envelope_async_with_update(
161 workspace_root,
162 session_id,
163 vt_cfg,
164 &original_history,
165 touched_files,
166 &mut compacted,
167 MemoryEnvelopePersistence::PersistToDisk,
168 placement,
169 None,
170 steering_update,
171 )
172 .await?;
173 compacted =
174 crate::compaction::bound_compacted_history_to_context(compacted, provider, model, context_budget);
175 let history_artifact_path = envelope.as_ref().and_then(|item| item.history_artifact_path.clone());
176 let compacted_len = compacted.len();
177 *history = compacted;
178 return Ok(Some(AutoCompactionOutcome {
179 original_len,
180 compacted_len,
181 mode: CompactionMode::Local,
182 history_artifact_path,
183 envelope,
184 }));
185 }
186 }
187
188 let strategy = manual_compaction_strategy(provider, model);
193 let compaction_history = if matches!(strategy, CompactionStrategy::Local) {
194 dedup_repeated_file_reads_for_local_compaction(&compaction_input)
195 } else {
196 compaction_input.clone()
197 };
198
199 if !force_compaction
201 && !engine_cfg.always_summarize
202 && compaction_history.len() <= engine_cfg.keep_last_messages
203 {
204 return Ok(None);
205 }
206
207 let (mut compacted, mode) = compact_history_manual_with_parent_context(
208 provider,
209 model,
210 &compaction_history,
211 &engine_cfg,
212 &manual_options,
213 context_budget,
214 parent_context.as_ref(),
215 )
216 .await?;
217
218 if compacted == compaction_history {
219 return Ok(None);
220 }
221
222 if !compacted_history_shrinks(original_history.len(), compacted.len(), mode) {
226 return Ok(None);
227 }
228
229 let original_len = original_history.len();
230 let mut envelope_history = if mode == CompactionMode::Provider {
235 compacted.clone()
236 } else {
237 std::mem::take(&mut compacted)
238 };
239 let envelope = persist_memory_envelope_async_with_update(
240 workspace_root,
241 session_id,
242 vt_cfg,
243 &original_history,
244 touched_files,
245 &mut envelope_history,
246 MemoryEnvelopePersistence::PersistToDisk,
247 placement,
248 None,
249 steering_update,
250 )
251 .await?;
252 if mode == CompactionMode::Local {
253 compacted = envelope_history;
254 compacted =
255 crate::compaction::bound_compacted_history_to_context(compacted, provider, model, context_budget);
256 }
257 let history_artifact_path = envelope.as_ref().and_then(|item| item.history_artifact_path.clone());
258 let compacted_len = compacted.len();
259 *history = compacted;
260 Ok(Some(AutoCompactionOutcome {
261 original_len,
262 compacted_len,
263 mode,
264 history_artifact_path,
265 envelope,
266 }))
267 }
268 .await;
269
270 match &compaction_result {
271 Ok(Some(_)) => {
272 *auto_compact_suppressed = SUPPRESS_NONE;
273 }
274 Ok(None) => {}
275 Err(error) => {
276 let reason = classify_suppress_reason(&error.root_cause().to_string());
277 let new_state = reason.suppress_state();
278 if *auto_compact_suppressed == SUPPRESS_NONE {
281 *auto_compact_suppressed = new_state;
282 }
283 }
284 }
285
286 compaction_result
287}
288
289async fn try_two_pass_with_prefire(
299 prefire: &crate::compaction::PrefireState,
300 provider: &dyn LLMProvider,
301 model: &str,
302 history: &[Message],
303 config: &CompactionConfig,
304 context_budget: Option<usize>,
305 parent: Option<&CompactionParentContext>,
306) -> Result<Option<Vec<Message>>> {
307 let cache = match prefire.take() {
308 Some(cache) => cache,
309 None => return Ok(None),
310 };
311
312 if cache.prefix_len == 0 || cache.prefix_len > history.len() {
313 return Ok(None);
314 }
315
316 if cache.model_slug != model {
317 return Ok(None);
318 }
319
320 let prefix = &history[..cache.prefix_len];
321 if fingerprint_prefix(prefix) != cache.fingerprint {
322 return Ok(None);
323 }
324
325 let tail = &history[cache.prefix_len..];
326 let prompt = build_summary_prompt(tail, &config.summary_prompt);
327 let pass2_history = crate::compaction::two_pass::build_two_pass_pass2_history(prefix, tail, &cache.note1, &prompt);
328
329 let request = LLMRequest {
330 messages: std::sync::Arc::new(pass2_history),
331 model: model.to_string(),
332 system_prompt: parent.and_then(|parent| parent.system_prompt.clone()),
333 tools: parent.and_then(|parent| parent.tools.clone()).filter(|tools| !tools.is_empty()),
334 tool_choice: Some(ToolChoice::none()),
335 ..Default::default()
336 };
337
338 let response = collect_single_response(provider, request).await?;
339 let note2 = response.content.unwrap_or_default().trim().to_string();
340
341 if note2.trim().is_empty() {
342 return Ok(None);
343 }
344
345 let effective_config =
346 crate::compaction::context_bounded_compaction_config(provider, model, history, config, context_budget);
347 let tail_target = crate::compaction::route_tail_target_tokens(provider, model, context_budget);
348 let compacted = build_local_compacted_history(
349 history,
350 ¬e2,
351 effective_config.retained_user_message_tokens,
352 effective_config.retained_user_messages,
353 true,
354 tail_target,
355 );
356
357 let compacted = crate::compaction::bound_compacted_history_to_context(compacted, provider, model, context_budget);
358 if !compacted_history_shrinks(history.len(), compacted.len(), CompactionMode::Local) {
362 return Ok(None);
363 }
364
365 Ok(Some(compacted))
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::compaction::SUPPRESS_STICKY;
372 use crate::compaction::two_pass::fingerprint_prefix;
373 use crate::llm::provider::{
374 LLMError, LLMNormalizedStream, LLMProvider, LLMRequest, LLMResponse, Message, NormalizedStreamEvent, ToolChoice,
375 };
376 use async_trait::async_trait;
377 use futures::stream;
378 use std::sync::Mutex;
379
380 struct FailingProvider;
381
382 struct SuccessfulProvider;
383
384 struct NativeStandaloneProvider {
385 output: Vec<Message>,
386 }
387
388 #[async_trait]
389 impl LLMProvider for SuccessfulProvider {
390 fn name(&self) -> &str {
391 "successful"
392 }
393
394 async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
395 Ok(LLMResponse::new("successful-model", "summary"))
396 }
397
398 fn supported_models(&self) -> Vec<String> {
399 vec!["successful-model".to_string()]
400 }
401
402 fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
403 Ok(())
404 }
405
406 fn effective_context_size(&self, _model: &str) -> usize {
407 4_096
408 }
409 }
410
411 #[async_trait]
412 impl LLMProvider for NativeStandaloneProvider {
413 fn name(&self) -> &str {
414 "native-standalone"
415 }
416
417 async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
418 Ok(LLMResponse::new("native-model", "summary"))
419 }
420
421 fn supported_models(&self) -> Vec<String> {
422 vec!["native-model".to_string()]
423 }
424
425 fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
426 Ok(())
427 }
428
429 fn supports_manual_openai_compaction(&self, _model: &str) -> bool {
430 true
431 }
432
433 fn effective_context_size(&self, _model: &str) -> usize {
434 4_096
435 }
436
437 async fn compact_history_with_options(
438 &self,
439 _model: &str,
440 _history: &[Message],
441 _options: &crate::llm::provider::ResponsesCompactionOptions,
442 ) -> Result<Vec<Message>, LLMError> {
443 Ok(self.output.clone())
444 }
445 }
446
447 #[async_trait]
448 impl LLMProvider for FailingProvider {
449 fn name(&self) -> &str {
450 "failing"
451 }
452
453 async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
454 Err(LLMError::Provider {
455 message: "context length exceeded".to_string(),
456 metadata: None,
457 })
458 }
459
460 fn supported_models(&self) -> Vec<String> {
461 vec!["failing-model".to_string()]
462 }
463
464 fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
465 Ok(())
466 }
467
468 fn effective_context_size(&self, _model: &str) -> usize {
469 1000
470 }
471 }
472
473 struct CapturingProvider {
474 last_request: Mutex<Option<LLMRequest>>,
475 }
476
477 #[async_trait]
478 impl LLMProvider for CapturingProvider {
479 fn name(&self) -> &str {
480 "capturing"
481 }
482
483 fn supports_streaming(&self) -> bool {
484 true
485 }
486
487 fn supports_non_streaming(&self, _model: &str) -> bool {
488 false
489 }
490
491 async fn generate(&self, _request: LLMRequest) -> Result<LLMResponse, LLMError> {
492 Err(LLMError::Provider {
493 message: "capturing provider is streaming-only".to_string(),
494 metadata: None,
495 })
496 }
497
498 async fn stream_normalized(&self, request: LLMRequest) -> Result<LLMNormalizedStream, LLMError> {
499 *self.last_request.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(request);
500 Ok(Box::pin(stream::iter(vec![Ok(NormalizedStreamEvent::Done {
501 response: Box::new(LLMResponse::new("capturing-model", "summary")),
502 })])))
503 }
504
505 fn supported_models(&self) -> Vec<String> {
506 vec!["capturing-model".to_string()]
507 }
508
509 fn validate_request(&self, _request: &LLMRequest) -> Result<(), LLMError> {
510 Ok(())
511 }
512
513 fn effective_context_size(&self, _model: &str) -> usize {
514 4_096
515 }
516 }
517
518 #[tokio::test]
519 async fn suppressed_state_skips_compaction() {
520 let provider = FailingProvider;
521 let mut vt_cfg = VTCodeConfig::default();
522 vt_cfg.agent.harness.auto_compaction_enabled = true;
523 let mut history = vec![Message::user("hello".to_string())];
524 let mut suppressed = SUPPRESS_STICKY;
525
526 let result = auto_compact_messages(
527 AutoCompactionInput {
528 provider: &provider,
529 model: "failing-model",
530 session_id: "s1",
531 workspace_root: Path::new("."),
532 vt_cfg: Some(&vt_cfg),
533 reserved_output_tokens: 0,
534 current_token_usage: 900,
535 touched_files: &[],
536 engine_cfg: CompactionConfig::default(),
537 manual_options: ManualCompactionOptions::default(),
538 placement: MemoryEnvelopePlacement::BeforeLastUserOrSummary,
539 prefire: None,
540 auto_compact_suppressed: &mut suppressed,
541 force_compaction: false,
542 steering_update: None,
543 parent_context: None,
544 },
545 &mut history,
546 )
547 .await;
548
549 assert!(result.unwrap().is_none());
550 assert_eq!(suppressed, SUPPRESS_STICKY);
551 }
552
553 #[tokio::test]
554 async fn failure_sets_suppression_on_deterministic_error() {
555 let provider = FailingProvider;
556 let mut vt_cfg = VTCodeConfig::default();
557 vt_cfg.agent.harness.auto_compaction_enabled = true;
558 let mut history = vec![Message::user("hello".to_string())];
559 let mut suppressed = SUPPRESS_NONE;
560
561 let result = auto_compact_messages(
562 AutoCompactionInput {
563 provider: &provider,
564 model: "failing-model",
565 session_id: "s1",
566 workspace_root: Path::new("."),
567 vt_cfg: Some(&vt_cfg),
568 reserved_output_tokens: 0,
569 current_token_usage: 4_100,
572 touched_files: &[],
573 engine_cfg: CompactionConfig {
574 keep_last_messages: 0,
575 ..CompactionConfig::default()
576 },
577 manual_options: ManualCompactionOptions::default(),
578 placement: MemoryEnvelopePlacement::BeforeLastUserOrSummary,
579 prefire: None,
580 auto_compact_suppressed: &mut suppressed,
581 force_compaction: false,
582 steering_update: None,
583 parent_context: None,
584 },
585 &mut history,
586 )
587 .await;
588
589 assert!(result.is_err());
590 assert_eq!(suppressed, SUPPRESS_STICKY);
591 }
592
593 #[tokio::test]
594 async fn automatic_compaction_persists_live_steering_snapshot() {
595 let provider = SuccessfulProvider;
596 let mut vt_cfg = VTCodeConfig::default();
597 vt_cfg.agent.harness.auto_compaction_enabled = true;
598 vt_cfg.context.dynamic.enabled = true;
599 vt_cfg.context.dynamic.persist_history = true;
600 let workspace = tempfile::tempdir().expect("tempdir");
601 let mut history = (0..60)
604 .map(|index| Message::user(format!("request {index} {}", "detail ".repeat(25))))
605 .collect::<Vec<_>>();
606 let intent = crate::core::agent::steering::QueuedFollowUpIntent::from_parts("intent-1", "finish the request");
607 let steering_update = SessionMemoryEnvelopeUpdate {
608 pending_intents: Some(vec![intent.clone()]),
609 applied_intent_ids: vec!["applied-1".to_string()],
610 ..Default::default()
611 };
612 let mut suppressed = SUPPRESS_NONE;
613
614 let result = auto_compact_messages(
615 AutoCompactionInput {
616 provider: &provider,
617 model: "successful-model",
618 session_id: "session-1",
619 workspace_root: workspace.path(),
620 vt_cfg: Some(&vt_cfg),
621 reserved_output_tokens: 0,
622 current_token_usage: 4_100,
625 touched_files: &[],
626 engine_cfg: CompactionConfig {
627 keep_last_messages: 0,
628 ..CompactionConfig::default()
629 },
630 manual_options: ManualCompactionOptions::default(),
631 placement: MemoryEnvelopePlacement::BeforeLastUserOrSummary,
632 prefire: None,
633 auto_compact_suppressed: &mut suppressed,
634 force_compaction: false,
635 steering_update: Some(&steering_update),
636 parent_context: None,
637 },
638 &mut history,
639 )
640 .await
641 .expect("automatic compaction should succeed")
642 .expect("automatic compaction should run");
643
644 let envelope = result.envelope.expect("compaction should produce an envelope");
645 assert_eq!(envelope.pending_intents, vec![intent]);
646 assert_eq!(envelope.applied_intent_ids, vec!["applied-1"]);
647 }
648
649 #[tokio::test]
650 async fn automatic_native_compaction_preserves_provider_window_when_persisting_envelope() {
651 let provider_output = vec![
652 Message::assistant(String::new()).with_reasoning_details(Some(vec![serde_json::json!({
653 "type": "compaction",
654 "content": null,
655 "signature": "signed-provider-state",
656 })])),
657 Message::user("provider-retained".to_string()),
658 ];
659 let provider = NativeStandaloneProvider { output: provider_output.clone() };
660 let mut vt_cfg = VTCodeConfig::default();
661 vt_cfg.agent.harness.auto_compaction_enabled = true;
662 vt_cfg.context.dynamic.enabled = true;
663 vt_cfg.context.dynamic.persist_history = true;
664 let workspace = tempfile::tempdir().expect("tempdir");
665 let mut history = (0..12)
666 .map(|index| Message::user(format!("request {index}")))
667 .collect::<Vec<_>>();
668 let mut suppressed = SUPPRESS_NONE;
669
670 let result = auto_compact_messages(
671 AutoCompactionInput {
672 provider: &provider,
673 model: "native-model",
674 session_id: "session-1",
675 workspace_root: workspace.path(),
676 vt_cfg: Some(&vt_cfg),
677 reserved_output_tokens: 0,
678 current_token_usage: 4_100,
679 touched_files: &[],
680 engine_cfg: CompactionConfig {
681 keep_last_messages: 0,
682 ..CompactionConfig::default()
683 },
684 manual_options: ManualCompactionOptions::default(),
685 placement: MemoryEnvelopePlacement::Start,
686 prefire: None,
687 auto_compact_suppressed: &mut suppressed,
688 force_compaction: false,
689 steering_update: None,
690 parent_context: None,
691 },
692 &mut history,
693 )
694 .await
695 .expect("automatic native compaction should succeed")
696 .expect("automatic native compaction should run");
697
698 assert_eq!(result.mode, CompactionMode::Provider);
699 assert!(result.envelope.is_some());
700 assert_eq!(history, provider_output, "provider output is the canonical replay window");
701 }
702
703 #[tokio::test]
704 async fn prefire_pass2_fork_reuses_parent_prefix() {
705 use crate::compaction::{AsyncCompactionCache, PrefireState};
706
707 let provider = CapturingProvider { last_request: Mutex::new(None) };
708 let mut vt_cfg = VTCodeConfig::default();
709 vt_cfg.agent.harness.auto_compaction_enabled = true;
710 let workspace = tempfile::tempdir().expect("tempdir");
711 let history = (0..60)
714 .map(|index| Message::user(format!("request {index} {}", "detail ".repeat(25))))
715 .collect::<Vec<_>>();
716 let mut history = history;
717 let prefix_len = 50;
718 let prefire = PrefireState::default();
719 prefire.store(AsyncCompactionCache {
720 note1: "prior summary".to_string(),
721 prefix_len,
722 fingerprint: fingerprint_prefix(&history[..prefix_len]),
723 model_slug: "capturing-model".to_string(),
724 pass1_latency_ms: 5,
725 });
726 let mut suppressed = SUPPRESS_NONE;
727
728 let result = auto_compact_messages(
729 AutoCompactionInput {
730 provider: &provider,
731 model: "capturing-model",
732 session_id: "session-1",
733 workspace_root: workspace.path(),
734 vt_cfg: Some(&vt_cfg),
735 reserved_output_tokens: 0,
736 current_token_usage: 4_100,
739 touched_files: &[],
740 engine_cfg: CompactionConfig {
741 keep_last_messages: 0,
742 ..CompactionConfig::default()
743 },
744 manual_options: ManualCompactionOptions::default(),
745 placement: MemoryEnvelopePlacement::BeforeLastUserOrSummary,
746 prefire: Some(&prefire),
747 auto_compact_suppressed: &mut suppressed,
748 force_compaction: false,
749 steering_update: None,
750 parent_context: Some(CompactionParentContext {
751 system_prompt: Some(std::sync::Arc::from("parent system")),
752 tools: None,
753 }),
754 },
755 &mut history,
756 )
757 .await
758 .expect("automatic compaction should succeed")
759 .expect("prefire two-pass compaction should run");
760
761 assert_eq!(result.mode, CompactionMode::Local);
762 let captured = provider.last_request.lock().unwrap().clone().expect("captured pass-2 request");
763 assert_eq!(captured.system_prompt.as_deref(), Some("parent system"));
764 assert!(matches!(captured.tool_choice, Some(ToolChoice::None)));
765 assert!(!captured.messages.is_empty());
766 }
767}