Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions crates/tui/src/compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,6 +123,29 @@ pub struct CompactionPlan {
pub summarize_indices: Vec<usize>,
}

#[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<usize>,
pub preserved_indices: Vec<usize>,
}

fn path_regex() -> &'static Regex {
static PATH_RE: OnceLock<Regex> = OnceLock::new();
PATH_RE.get_or_init(|| {
Expand Down Expand Up @@ -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<HardCompactionPlan> {
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();
Comment on lines +493 to +496
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The preserved_indices are the exact complement of summarize_indices in the compaction plan, which is already computed and stored in soft_plan.pinned_indices. Instead of collecting summarize_indices into a temporary BTreeSet and performing a linear scan with .contains() checks, you can directly consume and collect soft_plan.pinned_indices into a Vec. This avoids unnecessary allocations and reduces the complexity from O(N log N) to O(N).

Suggested change
let summarized: BTreeSet<_> = soft_plan.summarize_indices.iter().copied().collect();
let preserved_indices = (0..messages.len())
.filter(|idx| !summarized.contains(idx))
.collect();
let preserved_indices = soft_plan.pinned_indices.into_iter().collect();


Some(HardCompactionPlan {
summarize_indices: soft_plan.summarize_indices,
preserved_indices,
})
}

fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<usize>) {
if pinned_indices.is_empty() {
return;
Expand Down Expand Up @@ -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<Message> = (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<usize> = (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::<Vec<_>>());
}

#[test]
fn plan_hard_compaction_keeps_tool_pairs_across_tail_boundary() {
let mut messages: Vec<Message> = (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 {
Expand Down
Loading