feat: add /purge slash command for agent-driven context pruning
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.
This commit is contained in:
@@ -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", "接力"],
|
||||
@@ -596,6 +602,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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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};
|
||||
@@ -826,6 +827,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.
|
||||
@@ -1347,6 +1351,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -259,6 +259,7 @@ pub enum MessageId {
|
||||
CmdBalanceDescription,
|
||||
CmdClearDescription,
|
||||
CmdCompactDescription,
|
||||
CmdPurgeDescription,
|
||||
CmdConfigDescription,
|
||||
CmdContextDescription,
|
||||
CmdCostDescription,
|
||||
@@ -522,6 +523,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::CmdCacheDescription,
|
||||
MessageId::CmdClearDescription,
|
||||
MessageId::CmdCompactDescription,
|
||||
MessageId::CmdPurgeDescription,
|
||||
MessageId::CmdConfigDescription,
|
||||
MessageId::CmdContextDescription,
|
||||
MessageId::CmdCostDescription,
|
||||
@@ -988,6 +990,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",
|
||||
@@ -1814,6 +1819,9 @@ fn japanese(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdCompactDescription => {
|
||||
"コンテキスト圧縮で容量を確保(旧式:v0.6.6 以降はサイクル再起動を推奨)"
|
||||
}
|
||||
MessageId::CmdPurgeDescription => {
|
||||
"エージェントに会話履歴を分析させ、不要なメッセージを削除・要約"
|
||||
}
|
||||
MessageId::CmdConfigDescription => "インタラクティブな設定エディタを開く",
|
||||
MessageId::CmdContextDescription => "コンパクトなセッションコンテキスト検査ツールを開く",
|
||||
MessageId::CmdCostDescription => "セッションのコスト内訳を表示",
|
||||
@@ -2196,6 +2204,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> {
|
||||
MessageId::CmdCompactDescription => {
|
||||
"触发上下文压缩以释放空间(旧版命令;v0.6.6 起建议改用循环重启)"
|
||||
}
|
||||
MessageId::CmdPurgeDescription => "让 Agent 分析对话历史,精确保留有用信息并移除冗余内容",
|
||||
MessageId::CmdConfigDescription => "打开交互式配置编辑器",
|
||||
MessageId::CmdContextDescription => "打开紧凑会话上下文检查器",
|
||||
MessageId::CmdCostDescription => "显示本次会话的费用明细",
|
||||
@@ -2538,6 +2547,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",
|
||||
@@ -2952,6 +2964,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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; // 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<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, 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<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"));
|
||||
}
|
||||
}
|
||||
@@ -1444,6 +1444,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.
|
||||
@@ -2022,6 +2024,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,
|
||||
@@ -4765,6 +4768,7 @@ pub enum AppAction {
|
||||
UpdateCompaction(CompactionConfig),
|
||||
OpenContextInspector,
|
||||
CompactContext,
|
||||
PurgeContext,
|
||||
TaskAdd {
|
||||
prompt: String,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1708,7 +1708,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);
|
||||
@@ -1744,6 +1744,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
|
||||
@@ -2161,7 +2173,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))
|
||||
{
|
||||
@@ -2223,7 +2235,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
|
||||
@@ -2255,7 +2267,8 @@ async fn run_event_loop(
|
||||
app.needs_redraw = false;
|
||||
}
|
||||
|
||||
let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting {
|
||||
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))
|
||||
@@ -3941,6 +3954,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
|
||||
})
|
||||
@@ -3962,6 +3976,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;
|
||||
@@ -5082,6 +5097,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(),
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user