diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 6e4c3caa..8db11743 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -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), diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index a54426c1..fecf2bf9 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -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( diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 8ae059cc..f870b5a4 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -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, diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 65e551ce..0373dc04 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -114,6 +114,29 @@ pub enum Event { messages_after: Option, }, + /// 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, diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index bf36d3cc..87f47945 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -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)] diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index a2f0cc44..645c758c 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -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", diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 2bf18ddf..e94ab07c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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; diff --git a/crates/tui/src/purge.rs b/crates/tui/src/purge.rs new file mode 100644 index 00000000..c7e09c68 --- /dev/null +++ b/crates/tui/src/purge.rs @@ -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}; + +// ── Prompt‑building 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, + /// 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, message: String) { + let _ = tx.send(Event::PurgeStarted { message }).await; +} + +/// Emit a `PurgeCompleted` event to the UI. +pub async fn emit_purge_completed( + tx: &Sender, + 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, 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; // 1‑based 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 multi‑block — 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 (non‑text 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, 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, tool‑call/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 = 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 = 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) { + 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 = HashMap::new(); + let mut result_id_to_idx: HashMap = 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 = 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, + max_tokens: u32, +) -> Result { + // 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")); + } +} diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index ba2ab6e9..990156b8 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1461,6 +1461,8 @@ pub struct App { pub thinking_started_at: Option, /// 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, }, diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 9ec3ac83..a3af6647 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -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 diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index ff9c2324..437395f4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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(), diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 4ab2cc84..7425a516 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1924,7 +1924,7 @@ fn composer_top_right_chrome(app: &App, area_width: u16) -> Option } 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> { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b77394c3..ac2e9f13 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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=` +6. Purge (agent-driven) is emitted as `context_purge` item lifecycle +7. Clients replay history and resume with `/v1/threads/{id}/events?since_seq=` ### Durable Schema Gates