Merge pull request #2387 from mo-vic/dev

feat: add /purge slash command for agent-driven context pruning
This commit is contained in:
Hunter Bown
2026-05-30 23:30:17 -07:00
committed by GitHub
13 changed files with 1104 additions and 11 deletions
+7
View File
@@ -322,6 +322,12 @@ pub const COMMANDS: &[CommandInfo] = &[
usage: "/compact",
description_id: MessageId::CmdCompactDescription,
},
CommandInfo {
name: "purge",
aliases: &["qingchu"],
usage: "/purge",
description_id: MessageId::CmdPurgeDescription,
},
CommandInfo {
name: "relay",
aliases: &["batonpass", "接力"],
@@ -603,6 +609,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"relay" | "batonpass" | "接力" => relay(app, arg),
"load" | "jiazai" => session::load(app, arg),
"compact" | "yasuo" => session::compact(app),
"purge" | "qingchu" => session::purge(app),
"cycles" | "zhouqi" => cycle::list_cycles(app),
"cycle" => cycle::show_cycle(app, arg),
"recall" => cycle::recall_archive(app, arg),
+8
View File
@@ -292,6 +292,14 @@ pub fn compact(_app: &mut App) -> CommandResult {
)
}
/// Trigger agent-driven context purging.
pub fn purge(_app: &mut App) -> CommandResult {
CommandResult::with_message_and_action(
"Agent context purge triggered...".to_string(),
AppAction::PurgeContext,
)
}
/// Export conversation to markdown
pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
let export_path = path.map_or_else(
+81
View File
@@ -41,6 +41,7 @@ use crate::models::{
MessageRequest, StreamEvent, SystemPrompt, Tool, Usage,
};
use crate::prompts;
use crate::purge::{emit_purge_completed, emit_purge_failed, emit_purge_started, run_purge};
use crate::seam_manager::{SeamConfig, SeamManager};
use crate::tools::goal::{SharedGoalState, new_shared_goal_state};
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
@@ -832,6 +833,9 @@ impl Engine {
Op::CompactContext => {
self.handle_manual_compaction().await;
}
Op::PurgeContext => {
self.handle_purge().await;
}
Op::EditLastTurn { new_message } => {
// #383: /edit — remove the last user+assistant exchange
// from the session, then re-send with the new content.
@@ -1353,6 +1357,83 @@ impl Engine {
.await;
}
async fn handle_purge(&mut self) {
let zero_usage = Usage {
input_tokens: 0,
output_tokens: 0,
..Usage::default()
};
let Some(client) = self.deepseek_client.clone() else {
let message = "Purge unavailable: API client not configured".to_string();
emit_purge_failed(&self.tx_event, message.clone()).await;
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(message.clone())))
.await;
let _ = self
.tx_event
.send(Event::TurnComplete {
usage: zero_usage,
status: TurnOutcomeStatus::Failed,
error: Some(message),
})
.await;
return;
};
emit_purge_started(
&self.tx_event,
"Agent context purge in progress\u{2026}".to_string(),
)
.await;
let messages_before = self.session.messages.len();
let (status, error) = match run_purge(
&client,
&self.session.messages,
&self.session.model,
self.session.reasoning_effort.clone(),
effective_max_output_tokens(&self.session.model),
)
.await
{
Ok(result) => {
let messages_after = result.messages.len();
self.session.messages = result.messages;
self.emit_session_updated().await;
let summary = format!(
"Purge complete: {messages_before} → {messages_after} messages \
({} removed, {} condensed)",
result.removed_count, result.replaced_count,
);
emit_purge_completed(
&self.tx_event,
messages_before,
messages_after,
result.removed_count,
result.replaced_count,
summary,
)
.await;
(TurnOutcomeStatus::Completed, None)
}
Err(e) => {
emit_purge_failed(&self.tx_event, e.clone()).await;
(TurnOutcomeStatus::Failed, Some(e))
}
};
let _ = self
.tx_event
.send(Event::TurnComplete {
usage: zero_usage,
status,
error,
})
.await;
}
fn estimated_input_tokens(&self) -> usize {
estimate_input_tokens_conservative(
&self.session.messages,
+23
View File
@@ -114,6 +114,29 @@ pub enum Event {
messages_after: Option<usize>,
},
/// Context purge started.
PurgeStarted {
/// Status message for display.
message: String,
},
/// Context purge completed.
PurgeCompleted {
/// Number of messages before purge.
messages_before: usize,
/// Number of messages after purge.
messages_after: usize,
/// How many messages were removed.
removed_count: usize,
/// How many replace operations were applied.
replaced_count: usize,
/// Summary message for display.
message: String,
},
/// Context purge failed.
PurgeFailed { message: String },
/// Context compaction failed.
CompactionFailed {
id: String,
+3
View File
@@ -80,6 +80,9 @@ pub enum Op {
/// Run context compaction immediately.
CompactContext,
/// Run agent-driven context purging.
PurgeContext,
/// Edit the last user message: remove the last user+assistant exchange
/// from the session, then re-send with the new content.
#[allow(dead_code)]
+18
View File
@@ -259,6 +259,7 @@ pub enum MessageId {
CmdBalanceDescription,
CmdClearDescription,
CmdCompactDescription,
CmdPurgeDescription,
CmdConfigDescription,
CmdContextDescription,
CmdCostDescription,
@@ -523,6 +524,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdCacheDescription,
MessageId::CmdClearDescription,
MessageId::CmdCompactDescription,
MessageId::CmdPurgeDescription,
MessageId::CmdConfigDescription,
MessageId::CmdContextDescription,
MessageId::CmdCostDescription,
@@ -990,6 +992,9 @@ fn english(id: MessageId) -> &'static str {
MessageId::CmdCompactDescription => {
"Trigger context compaction to free up space (legacy; v0.6.6 prefers cycle restart)"
}
MessageId::CmdPurgeDescription => {
"Let the agent surgically prune conversation history to free context space"
}
MessageId::CmdConfigDescription => "Open interactive configuration editor",
MessageId::CmdContextDescription => "Open compact session context inspector",
MessageId::CmdCostDescription => "Show session cost breakdown",
@@ -1386,6 +1391,9 @@ fn vietnamese(id: MessageId) -> Option<&'static str> {
MessageId::CmdCompactDescription => {
"Kích hoạt nén ngữ cảnh để giải phóng không gian (cũ; v0.6.6 ưu tiên khởi động lại chu kỳ)"
}
MessageId::CmdPurgeDescription => {
"Cho agent cắt gọn lịch sử trò chuyện để giải phóng ngữ cảnh"
}
MessageId::CmdConfigDescription => "Mở trình chỉnh sửa cấu hình tương tác",
MessageId::CmdContextDescription => "Mở trình kiểm tra ngữ cảnh phiên thu gọn",
MessageId::CmdCostDescription => "Hiển thị chi tiết chi phí của phiên làm việc",
@@ -1818,6 +1826,9 @@ fn japanese(id: MessageId) -> Option<&'static str> {
MessageId::CmdCompactDescription => {
"コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)"
}
MessageId::CmdPurgeDescription => {
"エージェントに会話履歴を分析させ、不要なメッセージを削除・要約"
}
MessageId::CmdConfigDescription => "インタラクティブな設定エディタを開く",
MessageId::CmdContextDescription => "コンパクトなセッションコンテキスト検査ツールを開く",
MessageId::CmdCostDescription => "セッションのコスト内訳を表示",
@@ -2201,6 +2212,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
MessageId::CmdCompactDescription => {
"触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)"
}
MessageId::CmdPurgeDescription => "让 Agent 分析对话历史,精确保留有用信息并移除冗余内容",
MessageId::CmdConfigDescription => "打开交互式配置编辑器",
MessageId::CmdContextDescription => "打开紧凑会话上下文检查器",
MessageId::CmdCostDescription => "显示本次会话的费用明细",
@@ -2544,6 +2556,9 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> {
MessageId::CmdCompactDescription => {
"Compactar o contexto para liberar espaço (legado; a v0.6.6 prefere o reinício de ciclo)"
}
MessageId::CmdPurgeDescription => {
"Deixe o agente podar cirurgicamente o histórico para liberar espaço de contexto"
}
MessageId::CmdConfigDescription => "Abrir o editor interativo de configuração",
MessageId::CmdContextDescription => "Abrir o inspetor compacto de contexto da sessão",
MessageId::CmdCostDescription => "Exibir o detalhamento de custo da sessão",
@@ -2959,6 +2974,9 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> {
MessageId::CmdCompactDescription => {
"Compactar el contexto para liberar espacio (heredado; v0.6.6 prefiere reinicio de ciclo)"
}
MessageId::CmdPurgeDescription => {
"Permite al agente eliminar quirúrgicamente historial innecesario para liberar espacio de contexto"
}
MessageId::CmdConfigDescription => "Abrir el editor interactivo de configuración",
MessageId::CmdContextDescription => "Abrir el inspector compacto de contexto de la sesión",
MessageId::CmdCostDescription => "Mostrar el desglose de costo de la sesión",
+1
View File
@@ -52,6 +52,7 @@ mod pricing;
mod project_context;
mod project_doc;
mod prompts;
mod purge;
pub mod repl;
mod retry_status;
pub mod rlm;
+920
View File
@@ -0,0 +1,920 @@
//! Agent-driven context purging.
//!
//! Unlike compaction (which summarises old messages via LLM), purge lets the
//! agent analyse the conversation history and surgically remove or rewrite
//! individual messages that are no longer needed. The agent uses the
//! `purge_context` tool to submit a list of operations; the engine validates
//! and executes them.
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use tokio::sync::mpsc::Sender;
use crate::core::events::Event;
use crate::llm_client::LlmClient;
use crate::models::{ContentBlock, Message, MessageRequest, Tool};
// ── Promptbuilding constants ──────────────────────────────────────────────
const TEXT_SNIPPET_CHARS: usize = 60;
const TOOL_RESULT_SNIPPET_CHARS: usize = 80;
const TOOL_USE_ARGS_CHARS: usize = 120;
// ── Prompt instruction template ─────────────────────────────────────────────
const PURGE_INSTRUCTIONS: &str = "\
## Context Purge
Free space in the conversation's context window. Below is the current history with stable numeric IDs.\
Identify content that is clearly no longer needed for the ongoing work.
### Operations
remove Delete an entire message by its ID. Example:
{\"op\": \"remove\", \"msg\": 3}
replace Rewrite part of a specific content block using regex substitution.
pattern uses Rust regex syntax. Must specify both `block` and
`pattern` and `with`. Example:
{\"op\": \"replace\", \"msg\": 7, \"block\": 0,
\"pattern\": \"read \\\\d+ files\", \"with\": \"read files\"}
### Pairing rule
Every ToolUse block is paired with its ToolResult. If you remove a message
containing a tool call, its result will be removed too and vice versa. You
do not need to list both.
### What to keep
- Important decisions, architectural choices
- File paths that are still relevant
- Tool outputs that contain information not yet acted upon
### What to prune
- Verbose tool outputs whose information has been fully consumed
- Redundant confirmations (\"done\", \"ok\", \"that worked\")
- Superseded file reads (the file was later written/modified)
- Boilerplate that the model already incorporated into later work
Be conservative. When in doubt, keep the message.
### Conversation
";
// ── Purge operation types ───────────────────────────────────────────────────
/// A single purge operation submitted by the agent.
#[derive(Debug, Clone)]
pub enum PurgeOp {
/// Remove an entire message (plus its tool-call/result counterpart).
Remove { msg_id: usize },
/// Regex-replace within a specific content block.
Replace {
msg_id: usize,
block_idx: usize,
pattern: Regex,
with: String,
},
}
/// Result of executing purge operations.
#[derive(Debug, Clone)]
pub struct PurgeResult {
/// The remaining messages after all operations.
pub messages: Vec<Message>,
/// How many messages were removed.
pub removed_count: usize,
/// How many replace operations were applied.
pub replaced_count: usize,
}
// ── Event emission helpers ──────────────────────────────────────────────────
/// Emit a `PurgeStarted` event to the UI.
pub async fn emit_purge_started(tx: &Sender<Event>, message: String) {
let _ = tx.send(Event::PurgeStarted { message }).await;
}
/// Emit a `PurgeCompleted` event to the UI.
pub async fn emit_purge_completed(
tx: &Sender<Event>,
messages_before: usize,
messages_after: usize,
removed_count: usize,
replaced_count: usize,
message: String,
) {
let _ = tx
.send(Event::PurgeCompleted {
messages_before,
messages_after,
removed_count,
replaced_count,
message,
})
.await;
}
/// Emit a `PurgeFailed` event to the UI.
pub async fn emit_purge_failed(tx: &Sender<Event>, message: String) {
let _ = tx.send(Event::PurgeFailed { message }).await;
}
// ── Prompt builder ──────────────────────────────────────────────────────────
/// Build the purge request user message — a formatted listing of the current
/// conversation with ephemeral sequential IDs.
pub fn build_purge_prompt(messages: &[Message]) -> String {
let mut buf = String::with_capacity(messages.len().saturating_mul(256));
buf.push_str(PURGE_INSTRUCTIONS);
for (idx, msg) in messages.iter().enumerate() {
let msg_id = idx + 1; // 1based for the agent
if msg.role == "user" {
// User messages: always a single block — omit block index.
format_user_message(&mut buf, msg_id, msg);
} else {
// Assistant messages: may be multiblock — show block indices.
let _ = writeln!(buf, "[{msg_id}] {role}", role = msg.role);
for (blk_idx, block) in msg.content.iter().enumerate() {
format_content_block(&mut buf, blk_idx, block);
}
buf.push('\n');
}
}
buf
}
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 (nontext block)");
}
}
}
fn format_content_block(buf: &mut String, blk_idx: usize, block: &ContentBlock) {
match block {
ContentBlock::Text { text, .. } => {
let snippet = truncate_str(text, TEXT_SNIPPET_CHARS);
let _ = writeln!(
buf,
" [{blk_idx}] Text ({len} chars): \"{snippet}\"",
len = text.len(),
);
}
ContentBlock::Thinking { .. } => {
// Omit thinking blocks — API-mandated on tool-call messages;
// the agent cannot remove them, so listing them only adds noise.
}
ContentBlock::ToolUse {
name, input, id, ..
} => {
let args = serde_json::to_string(input).unwrap_or_default();
let args_preview = truncate_str(&args, TOOL_USE_ARGS_CHARS);
let _ = writeln!(
buf,
" [{blk_idx}] ToolUse ({name}, id={id}, args={args_preview})"
);
}
ContentBlock::ToolResult {
content,
tool_use_id,
..
} => {
let snippet = truncate_str(content, TOOL_RESULT_SNIPPET_CHARS);
let _ = writeln!(
buf,
" [{blk_idx}] ToolResult (id={tool_use_id}, {len} chars): \"{snippet}\"",
len = content.len(),
);
}
ContentBlock::ServerToolUse {
name, input, id, ..
} => {
let args = serde_json::to_string(input).unwrap_or_default();
let args_preview = truncate_str(&args, TOOL_USE_ARGS_CHARS);
let _ = writeln!(
buf,
" [{blk_idx}] ServerToolUse ({name}, id={id}, args={args_preview})"
);
}
ContentBlock::ToolSearchToolResult {
tool_use_id,
content,
..
} => {
let snippet = truncate_str(&content.to_string(), TOOL_RESULT_SNIPPET_CHARS);
let _ = writeln!(
buf,
" [{blk_idx}] ToolSearchToolResult (id={tool_use_id}, content={snippet})"
);
}
ContentBlock::CodeExecutionToolResult {
tool_use_id,
content,
..
} => {
let snippet = truncate_str(&content.to_string(), TOOL_RESULT_SNIPPET_CHARS);
let _ = writeln!(
buf,
" [{blk_idx}] CodeExecutionToolResult (id={tool_use_id}, content={snippet})"
);
}
}
}
fn truncate_str(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let take = max_chars.saturating_sub(3);
let mut out: String = text.chars().take(take).collect();
out.push_str("...");
out
}
// ── Operation parser ────────────────────────────────────────────────────────
/// Parse the `purge_context` tool input JSON into a list of validated
/// `PurgeOp`s. Returns an error string on invalid input.
pub fn parse_purge_operations(
input: &serde_json::Value,
message_count: usize,
) -> Result<Vec<PurgeOp>, String> {
let ops = input
.get("operations")
.and_then(|v| v.as_array())
.ok_or_else(|| "missing or invalid 'operations' array".to_string())?;
let mut parsed = Vec::with_capacity(ops.len());
for (i, op) in ops.iter().enumerate() {
let op_type = op
.get("op")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("operation[{i}]: missing 'op' field"))?;
let msg = op
.get("msg")
.and_then(|v| v.as_u64())
.ok_or_else(|| format!("operation[{i}]: missing or invalid 'msg'"))?;
let msg_id = usize::try_from(msg).unwrap_or(usize::MAX);
if msg_id == 0 || msg_id > message_count {
return Err(format!(
"operation[{i}]: msg {msg} out of range (1{message_count})"
));
}
match op_type {
"remove" => {
parsed.push(PurgeOp::Remove { msg_id });
}
"replace" => {
let block_idx = op
.get("block")
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.ok_or_else(|| format!("operation[{i}]: 'replace' requires 'block'"))?;
let pattern_str = op
.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("operation[{i}]: 'replace' requires 'pattern'"))?;
let with = op
.get("with")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let pattern = Regex::new(pattern_str)
.map_err(|e| format!("operation[{i}]: invalid regex pattern: {e}"))?;
parsed.push(PurgeOp::Replace {
msg_id,
block_idx,
pattern,
with,
});
}
other => {
return Err(format!(
"operation[{i}]: unknown op '{other}' (expected 'remove' or 'replace')"
));
}
}
}
Ok(parsed)
}
// ── Operation executor ──────────────────────────────────────────────────────
/// Execute a list of purge operations against the message history.
///
/// Operations are processed in the order given but effective removal runs
/// from highest index to lowest to keep earlier indices stable. After all
/// user-requested operations, toolcall/result pair cascading runs to
/// prevent orphaned blocks.
pub fn execute_purge_operations(messages: &[Message], ops: &[PurgeOp]) -> PurgeResult {
let mut msgs = messages.to_vec();
let mut msg_indices_to_remove: HashSet<usize> = HashSet::new();
let mut replaced_count = 0usize;
// Phase 1: collect removes and apply replaces.
for op in ops {
match op {
PurgeOp::Remove { msg_id } => {
let idx = msg_id.saturating_sub(1);
if idx < msgs.len() {
msg_indices_to_remove.insert(idx);
}
}
PurgeOp::Replace {
msg_id,
block_idx,
pattern,
with,
} => {
let idx = msg_id.saturating_sub(1);
if idx >= msgs.len() {
continue;
}
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);
}
}
}
}
// Phase 2: cascade removal to tool-call/result counterparts.
cascade_tool_pair_removals(&msgs, &mut msg_indices_to_remove);
// Phase 3: sort indices descending and remove.
let mut to_remove: Vec<usize> = msg_indices_to_remove.into_iter().collect();
to_remove.sort_unstable_by(|a, b| b.cmp(a));
let removed_count = to_remove.len();
for idx in to_remove {
msgs.remove(idx);
}
PurgeResult {
messages: msgs,
removed_count,
replaced_count,
}
}
/// When a message containing a ToolUse or ToolResult is marked for removal,
/// cascade that removal to its counterpart so the API never sees orphaned
/// blocks. Runs a fixpoint loop until the remove set is closed under pairing.
fn cascade_tool_pair_removals(messages: &[Message], remove_set: &mut HashSet<usize>) {
if remove_set.is_empty() {
return;
}
// Build lookup maps: tool_use id → message index, tool_result id → message index.
let mut call_id_to_idx: HashMap<String, usize> = HashMap::new();
let mut result_id_to_idx: HashMap<String, usize> = HashMap::new();
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;
}
}
_ => {}
}
}
}
if !changed {
break;
}
}
}
fn block_content_text(block: &ContentBlock) -> &str {
match block {
ContentBlock::Text { text, .. } => text,
ContentBlock::ToolResult { content, .. } => content,
_ => "",
}
}
fn apply_block_replacement(block: &mut ContentBlock, new_text: &str) {
match block {
ContentBlock::Text { text, .. } => {
*text = new_text.to_string();
}
ContentBlock::ToolResult { content, .. } => {
*content = new_text.to_string();
}
_ => {}
}
}
// ── Tool definition builder ──────────────────────────────────────────────────
/// Build the `purge_context` tool definition sent to the model during a purge
/// turn. This tool is ad-hoc — it is not registered in the normal tool catalog
/// and has no dispatch handler.
pub fn build_purge_tool() -> Tool {
Tool {
tool_type: None,
name: "purge_context".to_string(),
description: "Remove or condense conversation history to free context window space."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"op": {"type": "string", "enum": ["remove", "replace"]},
"msg": {"type": "integer"},
"block": {"type": "integer"},
"pattern": {"type": "string"},
"with": {"type": "string"}
},
"required": ["op", "msg"]
}
}
},
"required": ["operations"]
}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: Some(true),
cache_control: None,
}
}
// ── Orchestration ────────────────────────────────────────────────────────────
/// Run a full purge cycle: build the prompt, call the model with the
/// `purge_context` tool, parse the response, and execute the operations.
///
/// Returns the `PurgeResult` with the modified message list on success,
/// or a human-readable error string on failure.
///
/// Cost reporting is handled internally as a side-effect of the API call.
/// The caller is responsible for emitting start/completed/failed events
/// and for replacing the session message list with `PurgeResult.messages`.
pub async fn run_purge(
client: &impl LlmClient,
messages: &[Message],
model: &str,
reasoning_effort: Option<String>,
max_tokens: u32,
) -> Result<PurgeResult, String> {
// 1. Build the purge prompt from the current conversation.
let prompt = build_purge_prompt(messages);
// 2. Clone messages and inject the prompt as a user message.
let mut request_messages = messages.to_vec();
request_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: prompt,
cache_control: None,
}],
});
// 3. Build the tool definition and the request.
let purge_tool = build_purge_tool();
let request = MessageRequest {
model: model.to_string(),
messages: request_messages,
max_tokens,
system: None,
tools: Some(vec![purge_tool]),
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort,
stream: Some(false),
temperature: Some(0.2),
top_p: None,
};
// 4. Send to the model.
let response = client
.create_message(request)
.await
.map_err(|e| format!("Purge API error: {e}"))?;
crate::cost_status::report(&response.model, &response.usage);
// 5. Find the `purge_context` tool call in the response.
let tool_input = response.content.iter().find_map(|block| {
if let ContentBlock::ToolUse { name, input, .. } = block
&& name == "purge_context"
{
return Some(input.clone());
}
None
});
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()),
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn msg_text(role: &str, text: &str) -> Message {
Message {
role: role.to_string(),
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
}
}
fn msg_tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input,
caller: None,
}],
}
}
fn msg_tool_result(id: &str, content: &str) -> Message {
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
content_blocks: None,
}],
}
}
#[test]
fn parse_remove_operations() {
let input = json!({
"operations": [
{"op": "remove", "msg": 1},
{"op": "remove", "msg": 3}
]
});
let ops = parse_purge_operations(&input, 5).unwrap();
assert_eq!(ops.len(), 2);
assert!(matches!(ops[0], PurgeOp::Remove { msg_id: 1 }));
assert!(matches!(ops[1], PurgeOp::Remove { msg_id: 3 }));
}
#[test]
fn parse_replace_operation() {
let input = json!({
"operations": [
{"op": "replace", "msg": 2, "block": 0, "pattern": "hello", "with": "hi"}
]
});
let ops = parse_purge_operations(&input, 5).unwrap();
assert_eq!(ops.len(), 1);
assert!(matches!(ops[0], PurgeOp::Replace { msg_id: 2, .. }));
}
#[test]
fn parse_rejects_out_of_range_msg() {
let input = json!({"operations": [{"op": "remove", "msg": 10}]});
assert!(parse_purge_operations(&input, 5).is_err());
}
#[test]
fn parse_rejects_invalid_regex() {
let input = json!({
"operations": [{"op": "replace", "msg": 1, "block": 0, "pattern": "[", "with": "x"}]
});
assert!(parse_purge_operations(&input, 5).is_err());
}
#[test]
fn execute_remove_works() {
let msgs = vec![
msg_text("user", "hello"),
msg_text("assistant", "hi there"),
msg_text("user", "bye"),
];
let ops = vec![PurgeOp::Remove { msg_id: 2 }];
let result = execute_purge_operations(&msgs, &ops);
assert_eq!(result.removed_count, 1);
assert_eq!(result.messages.len(), 2);
}
#[test]
fn execute_replace_text_block() {
let msgs = vec![msg_text("assistant", "Hello world! Hello again!")];
let pattern = Regex::new("Hello").unwrap();
let ops = vec![PurgeOp::Replace {
msg_id: 1,
block_idx: 0,
pattern,
with: "Hi".to_string(),
}];
let result = execute_purge_operations(&msgs, &ops);
assert_eq!(result.replaced_count, 1);
if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] {
assert_eq!(text, "Hi world! Hi again!");
} else {
panic!("expected text block");
}
}
#[test]
fn tool_call_result_pairing_cascaded() {
// Message 2 (idx 1) is a tool call. Message 3 (idx 2) is its result.
// Removing the tool call should cascade to remove the result too.
let msgs = vec![
msg_text("user", "read a file"),
msg_tool_use("call_01", "read_file", json!({"path": "x.rs"})),
msg_tool_result("call_01", "fn main() {}"),
];
let ops = vec![PurgeOp::Remove { msg_id: 2 }]; // remove tool call only
let result = execute_purge_operations(&msgs, &ops);
// Both tool call and its result should be gone (cascaded).
assert_eq!(
result.removed_count, 2,
"tool call + its result should both be removed"
);
assert_eq!(result.messages.len(), 1);
}
#[test]
fn tool_result_removal_cascades_to_call() {
// Removing the result should cascade to remove the call.
let msgs = vec![
msg_text("user", "read a file"),
msg_tool_use("call_01", "read_file", json!({"path": "x.rs"})),
msg_tool_result("call_01", "fn main() {}"),
];
let ops = vec![PurgeOp::Remove { msg_id: 3 }]; // remove result only
let result = execute_purge_operations(&msgs, &ops);
assert_eq!(
result.removed_count, 2,
"tool result + its call should both be removed"
);
assert_eq!(result.messages.len(), 1);
}
#[test]
fn prompt_truncates_long_content() {
let long_text = "x".repeat(200);
let msgs = vec![msg_text("user", &long_text)];
let prompt = build_purge_prompt(&msgs);
assert!(prompt.contains("(200 chars)"));
assert!(prompt.contains("xxx...")); // truncated
assert!(!prompt.contains(&long_text));
}
#[test]
fn prompt_shows_full_short_content() {
let msgs = vec![msg_text("user", "hi")];
let prompt = build_purge_prompt(&msgs);
assert!(prompt.contains("\"hi\""));
assert!(!prompt.contains("..."));
}
#[test]
fn prompt_omits_thinking_blocks() {
let msgs = vec![Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Thinking {
thinking: "let me think...".to_string(),
},
ContentBlock::Text {
text: "done".to_string(),
cache_control: None,
},
],
}];
let prompt = build_purge_prompt(&msgs);
assert!(!prompt.contains("let me think"));
assert!(prompt.contains("Text (4 chars)"));
}
#[test]
fn build_purge_tool_has_correct_shape() {
let tool = build_purge_tool();
assert_eq!(tool.name, "purge_context");
let schema = &tool.input_schema;
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["operations"]["type"] == "array");
let ops_item = &schema["properties"]["operations"]["items"];
assert_eq!(ops_item["type"], "object");
let required = ops_item["required"].as_array().unwrap();
assert!(required.contains(&json!("op")));
assert!(required.contains(&json!("msg")));
}
use crate::llm_client::mock::MockLlmClient;
use crate::models::{MessageResponse, Usage};
fn msg_response_with_tool_call(operations: serde_json::Value) -> MessageResponse {
MessageResponse {
id: "resp_test".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: "call_purge".to_string(),
name: "purge_context".to_string(),
input: json!({"operations": operations}),
caller: None,
}],
model: "mock-model".to_string(),
stop_reason: None,
stop_sequence: None,
container: None,
usage: Usage::default(),
}
}
fn msg_response_without_tool_call(text: &str) -> MessageResponse {
MessageResponse {
id: "resp_plain".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
model: "mock".to_string(),
stop_reason: None,
stop_sequence: None,
container: None,
usage: Usage::default(),
}
}
#[tokio::test]
async fn run_purge_removes_message() {
let mock = MockLlmClient::new(vec![]);
mock.push_message_response(msg_response_with_tool_call(json!([
{"op": "remove", "msg": 2}
])));
let messages = vec![
msg_text("user", "hello"),
msg_text("assistant", "remove me"),
msg_text("user", "bye"),
];
let result = run_purge(&mock, &messages, "mock", None, 4096)
.await
.unwrap();
assert_eq!(result.removed_count, 1);
assert_eq!(result.replaced_count, 0);
assert_eq!(result.messages.len(), 2);
if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] {
assert_eq!(text, "hello");
} else {
panic!(
"expected text block, got {:?}",
&result.messages[0].content[0]
);
}
if let ContentBlock::Text { text, .. } = &result.messages[1].content[0] {
assert_eq!(text, "bye");
} else {
panic!(
"expected text block, got {:?}",
&result.messages[1].content[0]
);
}
}
#[tokio::test]
async fn run_purge_replace_condenses_text() {
let mock = MockLlmClient::new(vec![]);
mock.push_message_response(msg_response_with_tool_call(json!([
{"op": "replace", "msg": 1, "block": 0, "pattern": "very long and verbose", "with": "short"}
])));
let messages = vec![msg_text("assistant", "this is very long and verbose text")];
let result = run_purge(&mock, &messages, "mock", None, 4096)
.await
.unwrap();
assert_eq!(result.removed_count, 0);
assert_eq!(result.replaced_count, 1);
if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] {
assert_eq!(text, "this is short text");
} else {
panic!(
"expected text block, got {:?}",
&result.messages[0].content[0]
);
}
}
#[tokio::test]
async fn run_purge_errors_when_no_tool_call() {
let mock = MockLlmClient::new(vec![]);
mock.push_message_response(msg_response_without_tool_call("nothing to clean up"));
let messages = vec![msg_text("user", "hi")];
let err = run_purge(&mock, &messages, "mock", None, 4096)
.await
.unwrap_err();
assert!(err.contains("did not call purge_context"));
}
#[tokio::test]
async fn run_purge_errors_on_api_failure() {
// No canned response — MockLlmClient returns an error.
let mock = MockLlmClient::new(vec![]);
let messages = vec![msg_text("user", "hi")];
let err = run_purge(&mock, &messages, "mock", None, 4096)
.await
.unwrap_err();
assert!(err.contains("Purge API error"));
}
}
+4
View File
@@ -1461,6 +1461,8 @@ pub struct App {
pub thinking_started_at: Option<Instant>,
/// Whether context compaction is currently in progress.
pub is_compacting: bool,
/// Whether context purge is currently in progress.
pub is_purging: bool,
/// Set when the user scrolls up/down during a streaming turn so subsequent
/// streamed chunks don't yank the view back to the live tail. Cleared
/// when the user explicitly returns to bottom or the turn completes.
@@ -2039,6 +2041,7 @@ impl App {
needs_redraw: true,
thinking_started_at: None,
is_compacting: false,
is_purging: false,
user_scrolled_during_stream: false,
coherence_state: CoherenceState::default(),
last_send_at: None,
@@ -4782,6 +4785,7 @@ pub enum AppAction {
UpdateCompaction(CompactionConfig),
OpenContextInspector,
CompactContext,
PurgeContext,
TaskAdd {
prompt: String,
},
+8 -1
View File
@@ -167,7 +167,11 @@ pub(crate) fn stall_reason(app: &App) -> Option<&'static str> {
/// though the agent is still working.
pub(crate) fn footer_working_strip_active(app: &App) -> bool {
let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress");
app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress
app.is_loading
|| app.is_compacting
|| app.is_purging
|| running_agent_count(app) > 0
|| turn_in_progress
}
pub(crate) fn footer_working_label_frame(now_ms: u64, fancy_animations: bool) -> u64 {
@@ -811,6 +815,9 @@ pub(crate) fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Co
if app.is_compacting {
return ("compacting \u{238B}", app.ui_theme.status_warning);
}
if app.is_purging {
return ("purging \u{238B}", app.ui_theme.status_warning);
}
// Note: we deliberately do NOT show a "thinking" label for `is_loading`.
// The animated water-spout strip in the footer's spacer is the visual
// signal that the model is live; "thinking" was misleading because it
+27 -8
View File
@@ -1728,7 +1728,7 @@ async fn run_event_loop(
}
app.update_model_compaction_budget();
app.workspace = workspace;
if (app.is_loading || app.is_compacting)
if (app.is_loading || app.is_compacting || app.is_purging)
&& let Ok(manager) = SessionManager::default_location()
{
let session = build_session_snapshot(app, &manager);
@@ -1764,6 +1764,18 @@ async fn run_event_loop(
app.is_compacting = false;
app.status_message = Some(message);
}
EngineEvent::PurgeStarted { message } => {
app.is_purging = true;
app.status_message = Some(message);
}
EngineEvent::PurgeCompleted { message, .. } => {
app.is_purging = false;
app.status_message = Some(message);
}
EngineEvent::PurgeFailed { message } => {
app.is_purging = false;
app.status_message = Some(message);
}
EngineEvent::CycleAdvanced { from, to, briefing } => {
// Mirror the engine-side counter on the UI app state
// so the sidebar / slash commands stay in sync, and
@@ -2181,7 +2193,7 @@ async fn run_event_loop(
if reconcile_turn_liveness(app, Instant::now(), has_running_agents) {
app.needs_redraw = true;
}
if (app.is_loading || has_running_agents || app.is_compacting)
if (app.is_loading || has_running_agents || app.is_compacting || app.is_purging)
&& last_status_frame.elapsed()
>= Duration::from_millis(status_animation_interval_ms(app))
{
@@ -2243,7 +2255,7 @@ async fn run_event_loop(
// long passage can be selected in one drag (#1163).
tick_selection_autoscroll(app);
let allow_workspace_context_refresh =
!app.is_loading && !has_running_agents && !app.is_compacting;
!app.is_loading && !has_running_agents && !app.is_compacting && !app.is_purging;
workspace_context::refresh_if_needed(app, now, allow_workspace_context_refresh);
// Draw is gated by the frame-rate limiter (120 FPS cap). When a
@@ -2275,11 +2287,12 @@ async fn run_event_loop(
app.needs_redraw = false;
}
let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting {
Duration::from_millis(active_poll_ms(app))
} else {
Duration::from_millis(idle_poll_ms(app))
};
let mut poll_timeout =
if app.is_loading || has_running_agents || app.is_compacting || app.is_purging {
Duration::from_millis(active_poll_ms(app))
} else {
Duration::from_millis(idle_poll_ms(app))
};
if let Some(until_flush) = app.paste_burst_next_flush_delay_if_enabled(now) {
poll_timeout = poll_timeout.min(until_flush);
}
@@ -3961,6 +3974,7 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool
&& app.runtime_turn_status.is_none()
&& !has_running_agents
&& !app.is_compacting
&& !app.is_purging
&& app.dispatch_started_at.is_some_and(|started| {
now.saturating_duration_since(started) > DISPATCH_WATCHDOG_TIMEOUT
})
@@ -3982,6 +3996,7 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool
)
&& !has_running_agents
&& !app.is_compacting
&& !app.is_purging
{
app.is_loading = false;
app.dispatch_started_at = None;
@@ -5102,6 +5117,10 @@ async fn apply_command_result(
app.status_message = Some("Compacting context...".to_string());
let _ = engine_handle.send(Op::CompactContext).await;
}
AppAction::PurgeContext => {
app.status_message = Some("Agent purging context...".to_string());
let _ = engine_handle.send(Op::PurgeContext).await;
}
AppAction::TaskAdd { prompt } => {
let request = NewTaskRequest {
prompt: prompt.clone(),
+1 -1
View File
@@ -1924,7 +1924,7 @@ fn composer_top_right_chrome(app: &App, area_width: u16) -> Option<Line<'static>
}
fn should_render_empty_state(app: &App) -> bool {
app.history.is_empty() && !app.is_loading && !app.is_compacting
app.history.is_empty() && !app.is_loading && !app.is_compacting && !app.is_purging
}
fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
+3 -1
View File
@@ -174,6 +174,7 @@ drives turns through Chat Completions.
- **`utils.rs`** - Common utilities
- **`logging.rs`** - Logging infrastructure
- **`compaction.rs`** - Context compaction for long conversations
- **`purge.rs`** - Agent-driven context purging (surgical message removal/rewriting)
- **`pricing.rs`** - Cost estimation
- **`prompts.rs`** - System prompt templates
- **`project_doc.rs`** - Project documentation handling
@@ -241,7 +242,8 @@ ordinary durable tasks.
3. Engine events are mapped to item lifecycle events (`item.started|item.delta|item.completed`)
4. Interrupt/steer operations apply to the active turn only
5. Compaction (auto/manual) is emitted as `context_compaction` item lifecycle
6. Clients replay history and resume with `/v1/threads/{id}/events?since_seq=<n>`
6. Purge (agent-driven) is emitted as `context_purge` item lifecycle
7. Clients replay history and resume with `/v1/threads/{id}/events?since_seq=<n>`
### Durable Schema Gates