feat: add /purge slash command for agent-driven context pruning#2387
Conversation
New `/purge` command lets the agent surgically remove or rewrite conversation history via a purge_context tool call. The engine validates and applies the operations, cascading tool-result removal to the paired tool-use call.
There was a problem hiding this comment.
Code Review
This pull request introduces agent-driven context purging, allowing the agent to surgically prune conversation history by removing or rewriting individual messages to free up context space. The feedback on the implementation highlights three key areas for improvement: expanding the cascade logic in cascade_tool_pair_removals to handle all tool-related content blocks (such as ServerToolUse, ToolSearchToolResult, and CodeExecutionToolResult) to prevent orphaned blocks; explicitly matching all tool result variants in format_user_message so they are visible to the agent; and conditionally incrementing replaced_count only when a regex replacement actually occurs.
| for (idx, msg) in messages.iter().enumerate() { | ||
| for block in &msg.content { | ||
| match block { | ||
| ContentBlock::ToolUse { id, .. } => { | ||
| call_id_to_idx.insert(id.clone(), idx); | ||
| } | ||
| ContentBlock::ToolResult { tool_use_id, .. } => { | ||
| result_id_to_idx.insert(tool_use_id.clone(), idx); | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Fixpoint: when a tool-call is removed, also remove its result (and vice versa). | ||
| let max_iters = messages.len().max(10); | ||
| for _ in 0..max_iters { | ||
| let snapshot: Vec<usize> = remove_set.iter().copied().collect(); | ||
| let mut changed = false; | ||
|
|
||
| for idx in snapshot { | ||
| let msg = &messages[idx]; | ||
| for block in &msg.content { | ||
| match block { | ||
| ContentBlock::ToolUse { id, .. } => { | ||
| if let Some(&result_idx) = result_id_to_idx.get(id) | ||
| && remove_set.insert(result_idx) | ||
| { | ||
| changed = true; | ||
| } | ||
| } | ||
| ContentBlock::ToolResult { tool_use_id, .. } => { | ||
| if let Some(&call_idx) = call_id_to_idx.get(tool_use_id) | ||
| && remove_set.insert(call_idx) | ||
| { | ||
| changed = true; | ||
| } | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The cascade logic in cascade_tool_pair_removals only handles ContentBlock::ToolUse and ContentBlock::ToolResult. It completely misses other tool-related content blocks such as ContentBlock::ServerToolUse, ContentBlock::ToolSearchToolResult, and ContentBlock::CodeExecutionToolResult. If any of these unhandled blocks are removed, their counterparts will not be cascaded, leaving orphaned tool calls or results in the conversation history, which can cause API validation errors (e.g., HTTP 400 Bad Request) on subsequent LLM requests.
for (idx, msg) in messages.iter().enumerate() {
for block in &msg.content {
match block {
ContentBlock::ToolUse { id, .. } | ContentBlock::ServerToolUse { id, .. } => {
call_id_to_idx.insert(id.clone(), idx);
}
ContentBlock::ToolResult { tool_use_id, .. }
| ContentBlock::ToolSearchToolResult { tool_use_id, .. }
| ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => {
result_id_to_idx.insert(tool_use_id.clone(), idx);
}
_ => {}
}
}
}
// Fixpoint: when a tool-call is removed, also remove its result (and vice versa).
let max_iters = messages.len().max(10);
for _ in 0..max_iters {
let snapshot: Vec<usize> = remove_set.iter().copied().collect();
let mut changed = false;
for idx in snapshot {
let msg = &messages[idx];
for block in &msg.content {
match block {
ContentBlock::ToolUse { id, .. } | ContentBlock::ServerToolUse { id, .. } => {
if let Some(&result_idx) = result_id_to_idx.get(id)
&& remove_set.insert(result_idx)
{
changed = true;
}
}
ContentBlock::ToolResult { tool_use_id, .. }
| ContentBlock::ToolSearchToolResult { tool_use_id, .. }
| ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => {
if let Some(&call_idx) = call_id_to_idx.get(tool_use_id)
&& remove_set.insert(call_idx)
{
changed = true;
}
}
_ => {}
}
}
}
}| fn format_user_message(buf: &mut String, msg_id: usize, msg: &Message) { | ||
| let block = msg.content.first(); | ||
| match block { | ||
| Some(ContentBlock::Text { text, .. }) => { | ||
| let snippet = truncate_str(text, TEXT_SNIPPET_CHARS); | ||
| let _ = writeln!( | ||
| buf, | ||
| "[{msg_id}] user Text ({len} chars): \"{snippet}\"", | ||
| len = text.len() | ||
| ); | ||
| } | ||
| Some(ContentBlock::ToolResult { | ||
| content, | ||
| tool_use_id, | ||
| .. | ||
| }) => { | ||
| let snippet = truncate_str(content, TOOL_RESULT_SNIPPET_CHARS); | ||
| let _ = writeln!( | ||
| buf, | ||
| "[{msg_id}] user ToolResult (id={tool_use_id}, {len} chars): \"{snippet}\"", | ||
| len = content.len(), | ||
| ); | ||
| } | ||
| _ => { | ||
| let _ = writeln!(buf, "[{msg_id}] user (non‑text block)"); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
In format_user_message, only ContentBlock::Text and ContentBlock::ToolResult are matched. If a user message contains other tool result variants like ToolSearchToolResult or CodeExecutionToolResult, they will fall into the wildcard pattern and be formatted as (non-text block). This prevents the agent from seeing their content or tool use IDs, making it impossible for the agent to surgically prune them. Matching these variants explicitly ensures they are properly formatted and visible to the agent.
fn format_user_message(buf: &mut String, msg_id: usize, msg: &Message) {
let block = msg.content.first();
match block {
Some(ContentBlock::Text { text, .. }) => {
let snippet = truncate_str(text, TEXT_SNIPPET_CHARS);
let _ = writeln!(
buf,
"[{msg_id}] user Text ({len} chars): \"{snippet}\"",
len = text.len()
);
}
Some(ContentBlock::ToolResult {
content,
tool_use_id,
..
}) => {
let snippet = truncate_str(content, TOOL_RESULT_SNIPPET_CHARS);
let _ = writeln!(
buf,
"[{msg_id}] user ToolResult (id={tool_use_id}, {len} chars): \"{snippet}\"",
len = content.len(),
);
}
Some(ContentBlock::ToolSearchToolResult {
tool_use_id,
content,
..
}) => {
let snippet = truncate_str(&content.to_string(), TOOL_RESULT_SNIPPET_CHARS);
let _ = writeln!(
buf,
"[{msg_id}] user ToolSearchToolResult (id={tool_use_id}): \"{snippet}\""
);
}
Some(ContentBlock::CodeExecutionToolResult {
tool_use_id,
content,
..
}) => {
let snippet = truncate_str(&content.to_string(), TOOL_RESULT_SNIPPET_CHARS);
let _ = writeln!(
buf,
"[{msg_id}] user CodeExecutionToolResult (id={tool_use_id}): \"{snippet}\""
);
}
_ => {
let _ = writeln!(buf, "[{msg_id}] user (non‑text block)");
}
}
}| if let Some(block) = msgs[idx].content.get_mut(*block_idx) { | ||
| let old_text = block_content_text(block).to_string(); | ||
| let new_text = pattern.replace_all(&old_text, with.as_str()).to_string(); | ||
| apply_block_replacement(block, &new_text); | ||
| replaced_count = replaced_count.saturating_add(1); | ||
| } |
There was a problem hiding this comment.
The replaced_count is incremented unconditionally for every replace operation, even if the regex pattern did not match and no actual replacement occurred. We can check if the replacement actually took place by checking if pattern.replace_all returned a Cow::Owned variant, and only apply the replacement and increment the counter in that case.
if let Some(block) = msgs[idx].content.get_mut(*block_idx) {
let old_text = block_content_text(block).to_string();
if let std::borrow::Cow::Owned(new_text) = pattern.replace_all(&old_text, with.as_str()) {
apply_block_replacement(block, &new_text);
replaced_count = replaced_count.saturating_add(1);
}
}| match tool_input { | ||
| Some(input) => { | ||
| let ops = parse_purge_operations(&input, messages.len()) | ||
| .map_err(|e| format!("Purge parse error: {e}"))?; | ||
| Ok(execute_purge_operations(messages, &ops)) | ||
| } | ||
| None => Err("Purge: model did not call purge_context tool".to_string()), | ||
| } |
There was a problem hiding this comment.
When the model decides there is nothing to prune (a completely valid response),
run_purge returns Err("Purge: model did not call purge_context tool"), which propagates as a PurgeFailed event and TurnOutcomeStatus::Failed. The user sees a failure message for what is actually a successful no-op. Since tool_choice is None, any well-behaved model that determines the context is already compact will trigger this path. The fix is either to set tool_choice to force a tool call, or to treat an absent tool call as a zero-operation success.
| match tool_input { | |
| Some(input) => { | |
| let ops = parse_purge_operations(&input, messages.len()) | |
| .map_err(|e| format!("Purge parse error: {e}"))?; | |
| Ok(execute_purge_operations(messages, &ops)) | |
| } | |
| None => Err("Purge: model did not call purge_context tool".to_string()), | |
| } | |
| match tool_input { | |
| Some(input) => { | |
| let ops = parse_purge_operations(&input, messages.len()) | |
| .map_err(|e| format!("Purge parse error: {e}"))?; | |
| Ok(execute_purge_operations(messages, &ops)) | |
| } | |
| None => { | |
| // Model responded with text instead of a tool call — treat as a | |
| // deliberate "nothing to prune" decision, not a failure. | |
| Ok(PurgeResult { | |
| messages: messages.to_vec(), | |
| removed_count: 0, | |
| replaced_count: 0, | |
| }) | |
| } | |
| } |
| if let Some(block) = msgs[idx].content.get_mut(*block_idx) { | ||
| let old_text = block_content_text(block).to_string(); | ||
| let new_text = pattern.replace_all(&old_text, with.as_str()).to_string(); | ||
| apply_block_replacement(block, &new_text); | ||
| replaced_count = replaced_count.saturating_add(1); | ||
| } |
There was a problem hiding this comment.
replaced_count is incremented whenever a target content block exists, regardless of whether the regex actually matched any text in it. If the pattern matches nothing (old_text == new_text), the operation is a no-op but still inflates the "condensed" counter shown in the completion summary.
| if let Some(block) = msgs[idx].content.get_mut(*block_idx) { | |
| let old_text = block_content_text(block).to_string(); | |
| let new_text = pattern.replace_all(&old_text, with.as_str()).to_string(); | |
| apply_block_replacement(block, &new_text); | |
| replaced_count = replaced_count.saturating_add(1); | |
| } | |
| if let Some(block) = msgs[idx].content.get_mut(*block_idx) { | |
| let old_text = block_content_text(block).to_string(); | |
| let new_text = pattern.replace_all(&old_text, with.as_str()).to_string(); | |
| if new_text != old_text { | |
| apply_block_replacement(block, &new_text); | |
| replaced_count = replaced_count.saturating_add(1); | |
| } | |
| } |
| if let Some(&result_idx) = result_id_to_idx.get(id) | ||
| && remove_set.insert(result_idx) | ||
| { | ||
| changed = true; | ||
| } | ||
| } | ||
| ContentBlock::ToolResult { tool_use_id, .. } => { | ||
| if let Some(&call_idx) = call_id_to_idx.get(tool_use_id) | ||
| && remove_set.insert(call_idx) | ||
| { | ||
| changed = true; | ||
| } | ||
| } | ||
| _ => {} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if !changed { | ||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn block_content_text(block: &ContentBlock) -> &str { | ||
| match block { | ||
| ContentBlock::Text { text, .. } => text, | ||
| ContentBlock::ToolResult { content, .. } => content, |
There was a problem hiding this comment.
Multi-block user messages only show first block in purge prompt
format_user_message shows only content.first(), so user messages with more than one block (e.g. a text prompt followed by an image attachment, or batched tool results) are partially invisible to the purge agent. The agent may incorrectly classify such messages as safe to remove because it only sees a fraction of their content. Assistant messages, by contrast, iterate all blocks with format_content_block. Iterating user blocks the same way would keep the two paths consistent and give the agent a complete view.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
Thanks @mo-vic — this is an ambitious but very useful complement to I refreshed the branch over current
CI is running again now. |
|
Merged at c9d6a97. Thank you @mo-vic — |
New
/purgecommand lets the agent surgically remove or rewrite conversation history via a purge_context tool call. The engine validates and applies the operations, cascading tool-result removal to the paired tool-use call.Summary
While recent work has largely focused on skills and progressive disclosure, this PR moves in the opposite direction. It tackles the quiet, unglamorous problem of context window garbage collection by introducing a
/purgecommand that lets the agent drop clearly obsolete conversation turns, verbatim tool outputs, and superseded confirmations.Code manually reviewed by human.
Why it matters
/compactsummarises the entire conversation into a dense handoff block. It's fast, but it destroys detail. After compaction the model no longer knows which file was read on turn 3, what the stack trace looked like, or why a specific approach was abandoned. The user ends up re-explaining context the model should already have./purgeis surgical. Instead of summarising everything, the agent inspects the full transcript and returns a list of targeted operations — remove turn 5 (dead-end debugging), replace turns 7–8 with a one-sentence note, drop the verbatim output of that 2000-line log read on turn 2.The result is a shorter transcript where the useful information survives intact. The user can continue work without the model suddenly forgetting the investigation that took three turns to complete.
What's included
purge.rsmodule: prompt construction, operation execution, cascade logic (removing a tool result cascades to remove its paired tool-use call)PurgeStarted,PurgeCompleted, `PurgeFailedAppAction::PurgeContextwired throughcommands/session.rs/purgewith Chinese alias/qingchuregistered in the command paletteARCHITECTURE.mdupdated with the new module and event lifecycle entryTesting
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-featurescargo test --workspace --all-featuresChecklist
Greptile Summary
This PR introduces
/purge, a new slash command that asks the model to inspect the conversation history and return a list of targeted remove/replace operations, then applies them to surgically shrink the context window without discarding everything like/compactdoes.purge.rshandles prompt building, operation parsing (with Rust-regex validation), cascade logic that automatically co-removes orphaned tool-call/result pairs, event emission, and a full unit-test suite.Op::PurgeContext→Engine::handle_purge→ three newEventvariants (PurgeStarted,PurgeCompleted,PurgeFailed) → UI state flagis_purgingpropagated through all the same guards that already handleis_compacting.ARCHITECTURE.mdis updated.Confidence Score: 4/5
Safe to merge with one fix: the purge API call sends the full conversation and the formatted listing of that same conversation together, which causes the request to overflow the context window precisely when the context is fullest and purge is most needed.
The run_purge function clones the full conversation into request_messages and then appends the formatted purge-prompt as a new user turn on top of it. This means every purge request carries the conversation twice — once as message objects and once as a truncated text dump. A session near its context limit will push the purge request over that limit and receive an API error before any operations are applied. All other parts of the PR (cascade logic, event wiring, UI state, localization) are cleanly implemented.
crates/tui/src/purge.rs — specifically the request_messages construction in run_purge (around line 835)
Important Files Changed
Sequence Diagram
sequenceDiagram participant User participant TUI as TUI (ui.rs) participant Engine participant Purge as purge.rs participant API as LLM API User->>TUI: /purge TUI->>Engine: Op::PurgeContext Engine->>TUI: Event::PurgeStarted Engine->>Purge: run_purge(client, messages, model, ...) Purge->>Purge: build_purge_prompt(messages) Purge->>API: MessageRequest [full messages] + [purge prompt user turn] API-->>Purge: MessageResponse (purge_context ToolUse) Purge->>Purge: parse_purge_operations(tool_input) Purge->>Purge: execute_purge_operations(messages, ops) Purge->>Purge: cascade_tool_pair_removals(...) Purge-->>Engine: Ok(PurgeResult) Engine->>Engine: "session.messages = result.messages" Engine->>TUI: Event::PurgeCompleted Engine->>TUI: Event::TurnComplete(Completed) TUI-->>User: Status message + updated historyReviews (2): Last reviewed commit: "fix: cover purge command in Vietnamese l..." | Re-trigger Greptile