diff --git a/CHANGELOG.md b/CHANGELOG.md index af852cf7c..b73d00dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is surfaced by `codewhale doctor`; `SSL_CERT_FILE` remains the preferred path for corporate or private CA roots. Thanks @wavezhang for the original #1893 direction. +- Added a default-disabled hard-compaction planner that can identify the + summarizable middle of a long conversation while preserving the recent tail, + existing tool-call/result pair guarantees, and working-set pinning. This + harvests the safe planning layer from #2522 without enabling hard compaction + or adding a message-rewrite execution path yet. Thanks @HUQIANTAO for the + proposal. - Added rich PlanArtifact support to `update_plan`: Plan mode can now carry grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index af852cf7c..b73d00dd4 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -35,6 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is surfaced by `codewhale doctor`; `SSL_CERT_FILE` remains the preferred path for corporate or private CA roots. Thanks @wavezhang for the original #1893 direction. +- Added a default-disabled hard-compaction planner that can identify the + summarizable middle of a long conversation while preserving the recent tail, + existing tool-call/result pair guarantees, and working-set pinning. This + harvests the safe planning layer from #2522 without enabling hard compaction + or adding a message-rewrite execution path yet. Thanks @HUQIANTAO for the + proposal. - Added rich PlanArtifact support to `update_plan`: Plan mode can now carry grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 139b7b4ca..72c360cc8 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -60,6 +60,8 @@ impl Default for CompactionConfig { } pub const KEEP_RECENT_MESSAGES: usize = 4; +#[allow(dead_code)] +pub const HARD_COMPACT_KEEP_RECENT: usize = 8; const RECENT_WORKING_SET_WINDOW: usize = 12; const MAX_WORKING_SET_PATHS: usize = 24; const MIN_SUMMARIZE_MESSAGES: usize = 6; @@ -121,6 +123,29 @@ pub struct CompactionPlan { pub summarize_indices: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct HardCompactionConfig { + pub enabled: bool, + pub keep_recent: usize, +} + +impl Default for HardCompactionConfig { + fn default() -> Self { + Self { + enabled: false, + keep_recent: HARD_COMPACT_KEEP_RECENT, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +pub struct HardCompactionPlan { + pub summarize_indices: Vec, + pub preserved_indices: Vec, +} + fn path_regex() -> &'static Regex { static PATH_RE: OnceLock = OnceLock::new(); PATH_RE.get_or_init(|| { @@ -450,6 +475,32 @@ pub fn plan_compaction( } } +#[allow(dead_code)] +pub fn plan_hard_compaction( + messages: &[Message], + workspace: Option<&Path>, + keep_recent: usize, +) -> Option { + if keep_recent == 0 || messages.len() < keep_recent.saturating_add(MIN_SUMMARIZE_MESSAGES) { + return None; + } + + let soft_plan = plan_compaction(messages, workspace, keep_recent, None, None); + if soft_plan.summarize_indices.len() < MIN_SUMMARIZE_MESSAGES { + return None; + } + + let summarized: BTreeSet<_> = soft_plan.summarize_indices.iter().copied().collect(); + let preserved_indices = (0..messages.len()) + .filter(|idx| !summarized.contains(idx)) + .collect(); + + Some(HardCompactionPlan { + summarize_indices: soft_plan.summarize_indices, + preserved_indices, + }) +} + fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet) { if pinned_indices.is_empty() { return; @@ -2100,6 +2151,80 @@ mod tests { assert!(plan.pinned_indices.contains(&1)); } + #[test] + fn plan_hard_compaction_returns_none_when_too_few_messages() { + let messages = vec![ + msg("user", "hello"), + msg("assistant", "hi"), + msg("user", "how are you"), + msg("assistant", "good"), + ]; + + assert!(plan_hard_compaction(&messages, None, HARD_COMPACT_KEEP_RECENT).is_none()); + } + + #[test] + fn plan_hard_compaction_preserves_recent_tail() { + let messages: Vec = (0..20) + .map(|i| { + msg( + if i % 2 == 0 { "user" } else { "assistant" }, + &format!("message {i}"), + ) + }) + .collect(); + + let plan = + plan_hard_compaction(&messages, None, HARD_COMPACT_KEEP_RECENT).expect("hard plan"); + + let expected_recent: Vec = (20 - HARD_COMPACT_KEEP_RECENT..20).collect(); + for idx in expected_recent { + assert!(plan.preserved_indices.contains(&idx)); + assert!(!plan.summarize_indices.contains(&idx)); + } + assert_eq!(plan.summarize_indices, (0..12).collect::>()); + } + + #[test] + fn plan_hard_compaction_keeps_tool_pairs_across_tail_boundary() { + let mut messages: Vec = (0..8) + .map(|i| msg("user", &format!("summarizable noise {i}"))) + .collect(); + messages.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "tail-call".to_string(), + name: "read_file".to_string(), + input: json!({"path": "crates/tui/src/compaction.rs"}), + caller: None, + }], + }); + messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tail-call".to_string(), + content: "file contents".to_string(), + is_error: None, + content_blocks: None, + }], + }); + + let plan = plan_hard_compaction(&messages, None, 1).expect("hard plan"); + + assert!(plan.preserved_indices.contains(&8)); + assert!(plan.preserved_indices.contains(&9)); + assert!(!plan.summarize_indices.contains(&8)); + assert!(!plan.summarize_indices.contains(&9)); + } + + #[test] + fn hard_compaction_config_defaults_to_disabled() { + let config = HardCompactionConfig::default(); + + assert!(!config.enabled); + assert_eq!(config.keep_recent, HARD_COMPACT_KEEP_RECENT); + } + #[test] fn should_compact_ignores_fully_pinned_context() { let config = CompactionConfig {