Skip to content

feat: add /purge slash command for agent-driven context pruning#2387

Merged
Hmbown merged 2 commits into
Hmbown:mainfrom
mo-vic:dev
May 31, 2026
Merged

feat: add /purge slash command for agent-driven context pruning#2387
Hmbown merged 2 commits into
Hmbown:mainfrom
mo-vic:dev

Conversation

@mo-vic

@mo-vic mo-vic commented May 31, 2026

Copy link
Copy Markdown

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.

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 /purge command that lets the agent drop clearly obsolete conversation turns, verbatim tool outputs, and superseded confirmations.

Code manually reviewed by human.

Why it matters

/compact summarises 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.

/purge is 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

  • New purge.rs module: prompt construction, operation execution, cascade logic (removing a tool result cascades to remove its paired tool-use call)
  • Three new engine events: PurgeStarted, PurgeCompleted, `PurgeFailed
  • AppAction::PurgeContext wired through commands/session.rs
  • Slash command /purge with Chinese alias /qingchu registered in the command palette
  • Localization strings in en, ja, zh-Hans, pt-BR, es
  • Unit tests covering single-message removal, cascade removal, replace operations, prompt length truncation, and thinking-block omission
  • ARCHITECTURE.md updated with the new module and event lifecycle entry

Testing

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features
  • cargo test --workspace --all-features

Checklist

  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes

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 /compact does.

  • purge.rs handles 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.
  • The command is wired end-to-end: Op::PurgeContextEngine::handle_purge → three new Event variants (PurgeStarted, PurgeCompleted, PurgeFailed) → UI state flag is_purging propagated through all the same guards that already handle is_compacting.
  • Localization strings are added for all six supported locales and ARCHITECTURE.md is 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

Filename Overview
crates/tui/src/purge.rs New 920-line module implementing prompt building, operation parsing/execution, cascade logic, event emission, and integration tests. The run_purge orchestration function sends the full conversation history as request messages and appends a formatted listing of those same messages as a user turn, doubling input token usage and making purge likely to hit the context limit precisely when the context is fullest.
crates/tui/src/core/engine.rs Adds handle_purge which wires run_purge into the engine op loop, updates session messages on success, and emits TurnComplete. Consistent with how handle_manual_compaction is structured.
crates/tui/src/core/events.rs Adds three new events (PurgeStarted, PurgeCompleted, PurgeFailed) mirroring the compaction event pattern. No issues.
crates/tui/src/tui/ui.rs Handles the three new purge events by toggling is_purging and updating status_message, propagates is_purging through all the same guards that already handle is_compacting.
crates/tui/src/tui/app.rs Adds is_purging flag and PurgeContext AppAction, initialized and wired correctly.
crates/tui/src/commands/mod.rs Registers /purge command with alias /qingchu and dispatches it to session::purge. Clean.
crates/tui/src/tui/footer_ui.rs Adds is_purging to footer_working_strip_active and footer_state_label. No issues.
crates/tui/src/localization.rs Adds CmdPurgeDescription to the enum and all six locales (en, vi, ja, zh-Hans, pt-BR, es). Complete coverage.

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 history
Loading

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (2): Last reviewed commit: "fix: cover purge command in Vietnamese l..." | Re-trigger Greptile

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.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

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.

Comment thread crates/tui/src/purge.rs
Comment on lines +411 to +452
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;
}
}
_ => {}
}
}
}

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.

high

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;
                        }
                    }
                    _ => {}
                }
            }
        }
    }

Comment thread crates/tui/src/purge.rs
Comment on lines +152 to +179
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)");
}
}
}

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

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)");
        }
    }
}

Comment thread crates/tui/src/purge.rs
Comment on lines +370 to +375
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);
}

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 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);
                    }
                }

Comment thread crates/tui/src/purge.rs
Comment on lines +585 to +592
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()),
}

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.

P1 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.

Suggested change
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,
})
}
}

Fix in Codex Fix in Claude Code Fix in Cursor

Comment thread crates/tui/src/purge.rs
Comment on lines +370 to +375
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);
}

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.

P2 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.

Suggested change
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);
}
}

Fix in Codex Fix in Claude Code Fix in Cursor

Comment thread crates/tui/src/purge.rs
Comment on lines +436 to +463
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,

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.

P2 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!

Fix in Codex Fix in Claude Code Fix in Cursor

@Hmbown

Hmbown commented May 31, 2026

Copy link
Copy Markdown
Owner

Thanks @mo-vic — this is an ambitious but very useful complement to /compact, and I like that it keeps pruning explicit and auditable through a dedicated purge_context operation.

I refreshed the branch over current main after the SlopLedger/mobile merges and pushed a1b30c6e, which only covers the new /purge command description in the Vietnamese locale that main added. Local verification on the merged tree:

  • cargo fmt --all -- --check
  • git diff --check
  • python3 scripts/check-provider-registry.py
  • cargo test -p codewhale-tui purge -- --nocapture
  • cargo check -p codewhale-tui --all-features --locked

CI is running again now.

@Hmbown Hmbown merged commit c9d6a97 into Hmbown:main May 31, 2026
2 checks passed
@Hmbown

Hmbown commented May 31, 2026

Copy link
Copy Markdown
Owner

Merged at c9d6a97. Thank you @mo-vic/purge is a thoughtful counterpart to /compact, and the dedicated purge_context tool/operation validation makes the pruning auditable instead of magical. I used admin merge because the PR only had GitGuardian/Greptile checks on GitHub, but local verification covered the merged tree with focused purge tests and an all-features check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants