| 1 | //! Agent-driven context purging. |
| 2 | //! |
| 3 | //! Unlike compaction (which summarises old messages via LLM), purge lets the |
| 4 | //! agent analyse the conversation history and surgically remove or rewrite |
| 5 | //! individual messages that are no longer needed. The agent uses the |
| 6 | //! `purge_context` tool to submit a list of operations; the engine validates |
| 7 | //! and executes them. |
| 8 | |
| 9 | use regex::Regex; |
| 10 | use std::fmt::Write; |
| 11 | use tokio::sync::mpsc::Sender; |
| 12 | |
| 13 | use crate::config::ApiProvider; |
| 14 | use crate::core::events::Event; |
| 15 | use crate::fast_hash::{FastHashMap, FastHashSet}; |
| 16 | use crate::llm_client::LlmClient; |
| 17 | use crate::regex_cache::compile_user_regex; |
| 18 | use codewhale_models::Role; |
| 19 | use codewhale_models::{ContentBlock, Message, MessageRequest, Tool}; |
| 20 | |
| 21 | // ── Prompt‑building constants ────────────────────────────────────────────── |
| 22 | |
| 23 | const TEXT_SNIPPET_CHARS: usize = 60; |
| 24 | const TOOL_RESULT_SNIPPET_CHARS: usize = 80; |
| 25 | const TOOL_USE_ARGS_CHARS: usize = 120; |
| 26 | |
| 27 | // ── Prompt instruction template ───────────────────────────────────────────── |
| 28 | |
| 29 | const PURGE_INSTRUCTIONS: &str = "\ |
| 30 | ## Context Purge |
| 31 | |
| 32 | Free space in the conversation's context window. Below is the current history with stable numeric IDs.\ |
| 33 | Identify content that is clearly no longer needed for the ongoing work. |
| 34 | |
| 35 | ### Operations |
| 36 | |
| 37 | remove — Delete an entire message by its ID. Example: |
| 38 | {\"op\": \"remove\", \"msg\": 3} |
| 39 | |
| 40 | replace — Rewrite part of a specific content block using regex substitution. |
| 41 | pattern uses Rust regex syntax. Must specify both `block` and |
| 42 | `pattern` and `with`. Example: |
| 43 | {\"op\": \"replace\", \"msg\": 7, \"block\": 0, |
| 44 | \"pattern\": \"read \\\\d+ files\", \"with\": \"read files\"} |
| 45 | |
| 46 | offload — Preserve a complete message in durable session storage and replace it |
| 47 | with a compact retrieval handle. Use for long material that may be needed |
| 48 | again; retrieve_tool_result can recover exact content later. Example: |
| 49 | {\"op\": \"offload\", \"msg\": 3} |
| 50 | |
| 51 | ### Pairing rule |
| 52 | |
| 53 | Every ToolUse block is paired with its ToolResult. If you remove a message |
| 54 | containing a tool call, its result will be removed too — and vice versa. You |
| 55 | do not need to list both. Offload similarly archives the entire paired group, |
| 56 | including thinking, signatures, media, and tool data; no original blocks are discarded. |
| 57 | |
| 58 | ### What to keep |
| 59 | |
| 60 | - Important decisions, architectural choices |
| 61 | - File paths that are still relevant |
| 62 | - Tool outputs that contain information not yet acted upon |
| 63 | |
| 64 | ### What to prune |
| 65 | |
| 66 | - Verbose tool outputs whose information has been fully consumed |
| 67 | - Redundant confirmations (\"done\", \"ok\", \"that worked\") |
| 68 | - Superseded file reads (the file was later written/modified) |
| 69 | - Boilerplate that the model already incorporated into later work |
| 70 | |
| 71 | Be conservative. When in doubt, keep the message. |
| 72 | |
| 73 | ### Conversation |
| 74 | "; |
| 75 | |
| 76 | // ── Purge operation types ─────────────────────────────────────────────────── |
| 77 | |
| 78 | /// A single purge operation submitted by the agent. |
| 79 | #[derive(Debug, Clone)] |
| 80 | pub enum PurgeOp { |
| 81 | /// Remove an entire message (plus its tool-call/result counterpart). |
| 82 | Remove { msg_id: usize }, |
| 83 | /// Archive an entire message and its paired tool messages before replacing them with a handle. |
| 84 | Offload { msg_id: usize }, |
| 85 | /// Regex-replace within a specific content block. |
| 86 | Replace { |
| 87 | msg_id: usize, |
| 88 | block_idx: usize, |
| 89 | pattern: Regex, |
| 90 | with: String, |
| 91 | }, |
| 92 | } |
| 93 | |
| 94 | /// Result of executing purge operations. |
| 95 | #[derive(Debug, Clone)] |
| 96 | pub struct PurgeResult { |
| 97 | /// The remaining messages after all operations. |
| 98 | pub messages: Vec<Message>, |
| 99 | /// How many messages were removed. |
| 100 | pub removed_count: usize, |
| 101 | /// How many replace operations were applied. |
| 102 | pub replaced_count: usize, |
| 103 | /// Messages preserved in a durable session artifact instead of active context. |
| 104 | pub offloaded_count: usize, |
| 105 | } |
| 106 | |
| 107 | // ── Event emission helpers ────────────────────────────────────────────────── |
| 108 | |
| 109 | /// Emit a `PurgeStarted` event to the UI. |
| 110 | pub async fn emit_purge_started(tx: &Sender<Event>, message: String) { |
| 111 | let _ = tx.send(Event::PurgeStarted { message }).await; |
| 112 | } |
| 113 | |
| 114 | /// Emit a `PurgeCompleted` event to the UI. |
| 115 | pub async fn emit_purge_completed( |
| 116 | tx: &Sender<Event>, |
| 117 | messages_before: usize, |
| 118 | messages_after: usize, |
| 119 | removed_count: usize, |
| 120 | replaced_count: usize, |
| 121 | message: String, |
| 122 | ) { |
| 123 | let _ = tx |
| 124 | .send(Event::PurgeCompleted { |
| 125 | messages_before, |
| 126 | messages_after, |
| 127 | removed_count, |
| 128 | replaced_count, |
| 129 | message, |
| 130 | }) |
| 131 | .await; |
| 132 | } |
| 133 | |
| 134 | /// Emit a `PurgeFailed` event to the UI. |
| 135 | pub async fn emit_purge_failed(tx: &Sender<Event>, message: String) { |
| 136 | let _ = tx.send(Event::PurgeFailed { message }).await; |
| 137 | } |
| 138 | |
| 139 | // ── Prompt builder ────────────────────────────────────────────────────────── |
| 140 | |
| 141 | /// Build the purge request user message — a formatted listing of the current |
| 142 | /// conversation with ephemeral sequential IDs. |
| 143 | pub fn build_purge_prompt(messages: &[Message]) -> String { |
| 144 | let mut buf = String::with_capacity(messages.len().saturating_mul(256)); |
| 145 | buf.push_str(PURGE_INSTRUCTIONS); |
| 146 | |
| 147 | for (idx, msg) in messages.iter().enumerate() { |
| 148 | let msg_id = idx + 1; // 1‑based for the agent |
| 149 | if msg.role == "user" { |
| 150 | // User messages: always a single block — omit block index. |
| 151 | format_user_message(&mut buf, msg_id, msg); |
| 152 | } else { |
| 153 | // Assistant messages: may be multi‑block — show block indices. |
| 154 | let _ = writeln!(buf, "[{msg_id}] {role}", role = msg.role); |
| 155 | for (blk_idx, block) in msg.content.iter().enumerate() { |
| 156 | format_content_block(&mut buf, blk_idx, block); |
| 157 | } |
| 158 | buf.push('\n'); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | buf |
| 163 | } |
| 164 | |
| 165 | fn format_user_message(buf: &mut String, msg_id: usize, msg: &Message) { |
| 166 | let block = msg.content.first(); |
| 167 | match block { |
| 168 | Some(ContentBlock::Text { text, .. }) => { |
| 169 | let snippet = truncate_str(text, TEXT_SNIPPET_CHARS); |
| 170 | let _ = writeln!( |
| 171 | buf, |
| 172 | "[{msg_id}] user Text ({len} chars): \"{snippet}\"", |
| 173 | len = text.len() |
| 174 | ); |
| 175 | } |
| 176 | Some(ContentBlock::ToolResult { |
| 177 | content, |
| 178 | tool_use_id, |
| 179 | .. |
| 180 | }) => { |
| 181 | let snippet = truncate_str(content, TOOL_RESULT_SNIPPET_CHARS); |
| 182 | let _ = writeln!( |
| 183 | buf, |
| 184 | "[{msg_id}] user ToolResult (id={tool_use_id}, {len} chars): \"{snippet}\"", |
| 185 | len = content.len(), |
| 186 | ); |
| 187 | } |
| 188 | _ => { |
| 189 | let _ = writeln!(buf, "[{msg_id}] user (non‑text block)"); |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | fn format_content_block(buf: &mut String, blk_idx: usize, block: &ContentBlock) { |
| 195 | match block { |
| 196 | ContentBlock::Text { text, .. } => { |
| 197 | let snippet = truncate_str(text, TEXT_SNIPPET_CHARS); |
| 198 | let _ = writeln!( |
| 199 | buf, |
| 200 | " [{blk_idx}] Text ({len} chars): \"{snippet}\"", |
| 201 | len = text.len(), |
| 202 | ); |
| 203 | } |
| 204 | ContentBlock::Thinking { .. } => { |
| 205 | // Omit thinking blocks — API-mandated on tool-call messages; |
| 206 | // the agent cannot remove them, so listing them only adds noise. |
| 207 | } |
| 208 | ContentBlock::ToolUse { |
| 209 | name, input, id, .. |
| 210 | } => { |
| 211 | let args = serde_json::to_string(input).unwrap_or_default(); |
| 212 | let args_preview = truncate_str(&args, TOOL_USE_ARGS_CHARS); |
| 213 | let _ = writeln!( |
| 214 | buf, |
| 215 | " [{blk_idx}] ToolUse ({name}, id={id}, args={args_preview})" |
| 216 | ); |
| 217 | } |
| 218 | ContentBlock::ToolResult { |
| 219 | content, |
| 220 | tool_use_id, |
| 221 | .. |
| 222 | } => { |
| 223 | let snippet = truncate_str(content, TOOL_RESULT_SNIPPET_CHARS); |
| 224 | let _ = writeln!( |
| 225 | buf, |
| 226 | " [{blk_idx}] ToolResult (id={tool_use_id}, {len} chars): \"{snippet}\"", |
| 227 | len = content.len(), |
| 228 | ); |
| 229 | } |
| 230 | ContentBlock::ServerToolUse { |
| 231 | name, input, id, .. |
| 232 | } => { |
| 233 | let args = serde_json::to_string(input).unwrap_or_default(); |
| 234 | let args_preview = truncate_str(&args, TOOL_USE_ARGS_CHARS); |
| 235 | let _ = writeln!( |
| 236 | buf, |
| 237 | " [{blk_idx}] ServerToolUse ({name}, id={id}, args={args_preview})" |
| 238 | ); |
| 239 | } |
| 240 | ContentBlock::ToolSearchToolResult { |
| 241 | tool_use_id, |
| 242 | content, |
| 243 | .. |
| 244 | } => { |
| 245 | let snippet = truncate_str(&content.to_string(), TOOL_RESULT_SNIPPET_CHARS); |
| 246 | let _ = writeln!( |
| 247 | buf, |
| 248 | " [{blk_idx}] ToolSearchToolResult (id={tool_use_id}, content={snippet})" |
| 249 | ); |
| 250 | } |
| 251 | ContentBlock::CodeExecutionToolResult { |
| 252 | tool_use_id, |
| 253 | content, |
| 254 | .. |
| 255 | } => { |
| 256 | let snippet = truncate_str(&content.to_string(), TOOL_RESULT_SNIPPET_CHARS); |
| 257 | let _ = writeln!( |
| 258 | buf, |
| 259 | " [{blk_idx}] CodeExecutionToolResult (id={tool_use_id}, content={snippet})" |
| 260 | ); |
| 261 | } |
| 262 | ContentBlock::ImageUrl { .. } => {} |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | fn truncate_str(text: &str, max_chars: usize) -> String { |
| 267 | if text.chars().count() <= max_chars { |
| 268 | return text.to_string(); |
| 269 | } |
| 270 | let take = max_chars.saturating_sub(3); |
| 271 | let mut out: String = text.chars().take(take).collect(); |
| 272 | out.push_str("..."); |
| 273 | out |
| 274 | } |
| 275 | |
| 276 | // ── Operation parser ──────────────────────────────────────────────────────── |
| 277 | |
| 278 | /// Parse the `purge_context` tool input JSON into a list of validated |
| 279 | /// `PurgeOp`s. Returns an error string on invalid input. |
| 280 | pub fn parse_purge_operations( |
| 281 | input: &serde_json::Value, |
| 282 | message_count: usize, |
| 283 | ) -> Result<Vec<PurgeOp>, String> { |
| 284 | let ops = input |
| 285 | .get("operations") |
| 286 | .and_then(|v| v.as_array()) |
| 287 | .ok_or_else(|| "missing or invalid 'operations' array".to_string())?; |
| 288 | |
| 289 | let mut parsed = Vec::with_capacity(ops.len()); |
| 290 | |
| 291 | for (i, op) in ops.iter().enumerate() { |
| 292 | let op_type = op |
| 293 | .get("op") |
| 294 | .and_then(|v| v.as_str()) |
| 295 | .ok_or_else(|| format!("operation[{i}]: missing 'op' field"))?; |
| 296 | |
| 297 | let msg = op |
| 298 | .get("msg") |
| 299 | .and_then(|v| v.as_u64()) |
| 300 | .ok_or_else(|| format!("operation[{i}]: missing or invalid 'msg'"))?; |
| 301 | |
| 302 | let msg_id = usize::try_from(msg).unwrap_or(usize::MAX); |
| 303 | if msg_id == 0 || msg_id > message_count { |
| 304 | return Err(format!( |
| 305 | "operation[{i}]: msg {msg} out of range (1–{message_count})" |
| 306 | )); |
| 307 | } |
| 308 | |
| 309 | match op_type { |
| 310 | "remove" => { |
| 311 | parsed.push(PurgeOp::Remove { msg_id }); |
| 312 | } |
| 313 | "offload" => parsed.push(PurgeOp::Offload { msg_id }), |
| 314 | "replace" => { |
| 315 | let block_idx = op |
| 316 | .get("block") |
| 317 | .and_then(|v| v.as_u64()) |
| 318 | .map(|v| v as usize) |
| 319 | .ok_or_else(|| format!("operation[{i}]: 'replace' requires 'block'"))?; |
| 320 | |
| 321 | let pattern_str = op |
| 322 | .get("pattern") |
| 323 | .and_then(|v| v.as_str()) |
| 324 | .ok_or_else(|| format!("operation[{i}]: 'replace' requires 'pattern'"))?; |
| 325 | |
| 326 | let with = op |
| 327 | .get("with") |
| 328 | .and_then(|v| v.as_str()) |
| 329 | .unwrap_or("") |
| 330 | .to_string(); |
| 331 | |
| 332 | let pattern = compile_user_regex(pattern_str) |
| 333 | .map_err(|e| format!("operation[{i}]: invalid regex pattern: {e}"))?; |
| 334 | |
| 335 | parsed.push(PurgeOp::Replace { |
| 336 | msg_id, |
| 337 | block_idx, |
| 338 | pattern, |
| 339 | with, |
| 340 | }); |
| 341 | } |
| 342 | other => { |
| 343 | return Err(format!( |
| 344 | "operation[{i}]: unknown op '{other}' (expected 'remove', 'replace' or 'offload')" |
| 345 | )); |
| 346 | } |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | Ok(parsed) |
| 351 | } |
| 352 | |
| 353 | // ── Operation executor ────────────────────────────────────────────────────── |
| 354 | |
| 355 | /// Execute a list of purge operations against the message history. |
| 356 | /// |
| 357 | /// Operations are processed in the order given but effective removal runs |
| 358 | /// from highest index to lowest to keep earlier indices stable. After all |
| 359 | /// user-requested operations, tool‑call/result pair cascading runs to |
| 360 | /// prevent orphaned blocks. |
| 361 | pub fn execute_purge_operations( |
| 362 | messages: &[Message], |
| 363 | ops: &[PurgeOp], |
| 364 | session_id: &str, |
| 365 | ) -> Result<PurgeResult, String> { |
| 366 | let mut offloaded: FastHashSet<usize> = ops |
| 367 | .iter() |
| 368 | .filter_map(|op| { |
| 369 | if let PurgeOp::Offload { msg_id } = op { |
| 370 | msg_id.checked_sub(1).filter(|idx| *idx < messages.len()) |
| 371 | } else { |
| 372 | None |
| 373 | } |
| 374 | }) |
| 375 | .collect(); |
| 376 | cascade_tool_pair_removals(messages, &mut offloaded); |
| 377 | // Publish original, full-fidelity messages before any destructive operation. |
| 378 | // Failure leaves the caller's conversation intact, including mixed operations. |
| 379 | let mut pointer = if offloaded.is_empty() { |
| 380 | None |
| 381 | } else { |
| 382 | Some(publish_offloaded_context(session_id, messages, &offloaded)?) |
| 383 | }; |
| 384 | let mut msgs = messages.to_vec(); |
| 385 | let mut msg_indices_to_remove: FastHashSet<usize> = FastHashSet::default(); |
| 386 | let mut replaced_count = 0usize; |
| 387 | |
| 388 | // Phase 1: collect removes and apply replaces. |
| 389 | for op in ops { |
| 390 | match op { |
| 391 | PurgeOp::Remove { msg_id } => { |
| 392 | let idx = msg_id.saturating_sub(1); |
| 393 | if idx < msgs.len() { |
| 394 | msg_indices_to_remove.insert(idx); |
| 395 | } |
| 396 | } |
| 397 | PurgeOp::Offload { .. } => {} |
| 398 | PurgeOp::Replace { |
| 399 | msg_id, |
| 400 | block_idx, |
| 401 | pattern, |
| 402 | with, |
| 403 | } => { |
| 404 | let idx = msg_id.saturating_sub(1); |
| 405 | if idx >= msgs.len() || offloaded.contains(&idx) { |
| 406 | continue; |
| 407 | } |
| 408 | if let Some(block) = msgs[idx].content.get_mut(*block_idx) { |
| 409 | let old_text = block_content_text(block).to_string(); |
| 410 | let new_text = pattern.replace_all(&old_text, with.as_str()).to_string(); |
| 411 | apply_block_replacement(block, &new_text); |
| 412 | replaced_count = replaced_count.saturating_add(1); |
| 413 | } |
| 414 | } |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | // Phase 2: cascade removal to tool-call/result counterparts. |
| 419 | cascade_tool_pair_removals(&msgs, &mut msg_indices_to_remove); |
| 420 | |
| 421 | // Archival takes precedence over remove/replace when a paired group overlaps. |
| 422 | let removed_count = msg_indices_to_remove.difference(&offloaded).count(); |
| 423 | let first_offloaded = offloaded.iter().min().copied(); |
| 424 | let mut retained = Vec::with_capacity(msgs.len()); |
| 425 | for (idx, msg) in msgs.into_iter().enumerate() { |
| 426 | if Some(idx) == first_offloaded |
| 427 | && let Some(text) = pointer.take() |
| 428 | { |
| 429 | retained.push(Message { |
| 430 | role: messages[idx].role.clone(), |
| 431 | content: vec![ContentBlock::Text { |
| 432 | text, |
| 433 | cache_control: None, |
| 434 | }], |
| 435 | }); |
| 436 | } |
| 437 | if !offloaded.contains(&idx) && !msg_indices_to_remove.contains(&idx) { |
| 438 | retained.push(msg); |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | Ok(PurgeResult { |
| 443 | messages: retained, |
| 444 | removed_count, |
| 445 | replaced_count, |
| 446 | offloaded_count: offloaded.len(), |
| 447 | }) |
| 448 | } |
| 449 | |
| 450 | fn publish_offloaded_context( |
| 451 | session_id: &str, |
| 452 | messages: &[Message], |
| 453 | selected: &FastHashSet<usize>, |
| 454 | ) -> Result<String, String> { |
| 455 | use crate::tools::large_output_router::{ |
| 456 | EvidenceArtifact, EvidenceRetentionState, publish_evidence_metadata, unix_millis_now, |
| 457 | }; |
| 458 | let archived: Vec<_> = messages |
| 459 | .iter() |
| 460 | .enumerate() |
| 461 | .filter(|(idx, _)| selected.contains(idx)) |
| 462 | .map(|(idx, message)| serde_json::json!({"message_id": idx + 1, "message": message})) |
| 463 | .collect(); |
| 464 | let bytes = serde_json::to_vec_pretty(&serde_json::json!({ |
| 465 | "schema_version": 1, |
| 466 | "messages": archived, |
| 467 | })) |
| 468 | .map_err(|err| format!("Could not encode offloaded context; history unchanged: {err}"))?; |
| 469 | let call_id = format!("purge_{}", uuid::Uuid::new_v4()); |
| 470 | let handle = crate::artifacts::artifact_id_for_tool_call(&call_id); |
| 471 | let metadata = EvidenceArtifact { |
| 472 | handle: handle.clone(), |
| 473 | digest: crate::hashing::sha256_hex(&bytes), |
| 474 | size_bytes: bytes.len().try_into().unwrap_or(u64::MAX), |
| 475 | content_type: "application/json".to_string(), |
| 476 | tool_name: "purge_context".to_string(), |
| 477 | call_id, |
| 478 | origin_session: session_id.to_string(), |
| 479 | generation: 1, |
| 480 | redacted: false, |
| 481 | encoding: "utf-8".to_string(), |
| 482 | retention_state: EvidenceRetentionState::Live, |
| 483 | created_at_unix_ms: unix_millis_now(), |
| 484 | // This is the only full copy after offload: retain it with the session. |
| 485 | retain_until_unix_ms: u64::MAX, |
| 486 | storage_path: crate::artifacts::session_artifact_relative_path(&handle), |
| 487 | }; |
| 488 | publish_evidence_metadata(session_id, &metadata) |
| 489 | .and_then(|_| { |
| 490 | crate::artifacts::write_session_artifact_immutable(session_id, &handle, &bytes) |
| 491 | }) |
| 492 | .map_err(|err| format!("Could not store offloaded context; history unchanged: {err}"))?; |
| 493 | Ok(format!( |
| 494 | "[Offloaded context: {} messages preserved exactly in session artifact {handle}, generation 1. \ |
| 495 | Use retrieve_tool_result with ref={handle}, mode=query/lines to inspect, or mode=bytes for exact recovery. \ |
| 496 | The archive includes original message IDs and complete content blocks. Retained until this session is deleted.]", |
| 497 | selected.len() |
| 498 | )) |
| 499 | } |
| 500 | |
| 501 | /// When a message containing a ToolUse or ToolResult is marked for removal, |
| 502 | /// cascade that removal to its counterpart so the API never sees orphaned |
| 503 | /// blocks. Runs a fixpoint loop until the remove set is closed under pairing. |
| 504 | fn cascade_tool_pair_removals(messages: &[Message], remove_set: &mut FastHashSet<usize>) { |
| 505 | if remove_set.is_empty() { |
| 506 | return; |
| 507 | } |
| 508 | |
| 509 | // Internal transcript IDs and message indices are assigned by the engine, |
| 510 | // so this per-purge pairing pass can use the faster non-cryptographic hasher. |
| 511 | let mut call_id_to_idx: FastHashMap<String, usize> = FastHashMap::default(); |
| 512 | let mut result_id_to_idx: FastHashMap<String, usize> = FastHashMap::default(); |
| 513 | |
| 514 | for (idx, msg) in messages.iter().enumerate() { |
| 515 | for block in &msg.content { |
| 516 | match block { |
| 517 | ContentBlock::ToolUse { id, .. } | ContentBlock::ServerToolUse { id, .. } => { |
| 518 | call_id_to_idx.insert(id.clone(), idx); |
| 519 | } |
| 520 | ContentBlock::ToolResult { tool_use_id, .. } |
| 521 | | ContentBlock::ToolSearchToolResult { tool_use_id, .. } |
| 522 | | ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => { |
| 523 | result_id_to_idx.insert(tool_use_id.clone(), idx); |
| 524 | } |
| 525 | _ => {} |
| 526 | } |
| 527 | } |
| 528 | } |
| 529 | |
| 530 | // Fixpoint: when a tool-call is removed, also remove its result (and vice versa). |
| 531 | let max_iters = messages.len().max(10); |
| 532 | for _ in 0..max_iters { |
| 533 | let snapshot: Vec<usize> = remove_set.iter().copied().collect(); |
| 534 | let mut changed = false; |
| 535 | |
| 536 | for idx in snapshot { |
| 537 | let msg = &messages[idx]; |
| 538 | for block in &msg.content { |
| 539 | match block { |
| 540 | ContentBlock::ToolUse { id, .. } | ContentBlock::ServerToolUse { id, .. } => { |
| 541 | if let Some(&result_idx) = result_id_to_idx.get(id) |
| 542 | && remove_set.insert(result_idx) |
| 543 | { |
| 544 | changed = true; |
| 545 | } |
| 546 | } |
| 547 | ContentBlock::ToolResult { tool_use_id, .. } |
| 548 | | ContentBlock::ToolSearchToolResult { tool_use_id, .. } |
| 549 | | ContentBlock::CodeExecutionToolResult { tool_use_id, .. } => { |
| 550 | if let Some(&call_idx) = call_id_to_idx.get(tool_use_id) |
| 551 | && remove_set.insert(call_idx) |
| 552 | { |
| 553 | changed = true; |
| 554 | } |
| 555 | } |
| 556 | _ => {} |
| 557 | } |
| 558 | } |
| 559 | } |
| 560 | |
| 561 | if !changed { |
| 562 | break; |
| 563 | } |
| 564 | } |
| 565 | } |
| 566 | |
| 567 | fn block_content_text(block: &ContentBlock) -> &str { |
| 568 | match block { |
| 569 | ContentBlock::Text { text, .. } => text, |
| 570 | ContentBlock::ToolResult { content, .. } => content, |
| 571 | _ => "", |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | fn apply_block_replacement(block: &mut ContentBlock, new_text: &str) { |
| 576 | match block { |
| 577 | ContentBlock::Text { text, .. } => { |
| 578 | *text = new_text.to_string(); |
| 579 | } |
| 580 | ContentBlock::ToolResult { content, .. } => { |
| 581 | *content = new_text.to_string(); |
| 582 | } |
| 583 | _ => {} |
| 584 | } |
| 585 | } |
| 586 | |
| 587 | // ── Tool definition builder ────────────────────────────────────────────────── |
| 588 | |
| 589 | /// Build the `purge_context` tool definition sent to the model during a purge |
| 590 | /// turn. This tool is ad-hoc — it is not registered in the normal tool catalog |
| 591 | /// and has no dispatch handler. |
| 592 | pub fn build_purge_tool() -> Tool { |
| 593 | Tool { |
| 594 | tool_type: None, |
| 595 | name: "purge_context".to_string(), |
| 596 | description: "Remove, condense, or durably offload conversation history to free context window space." |
| 597 | .to_string(), |
| 598 | input_schema: serde_json::json!({ |
| 599 | "type": "object", |
| 600 | "properties": { |
| 601 | "operations": { |
| 602 | "type": "array", |
| 603 | "items": { |
| 604 | "type": "object", |
| 605 | "properties": { |
| 606 | "op": {"type": "string", "enum": ["remove", "replace", "offload"]}, |
| 607 | "msg": {"type": "integer"}, |
| 608 | "block": {"type": "integer"}, |
| 609 | "pattern": {"type": "string"}, |
| 610 | "with": {"type": "string"} |
| 611 | }, |
| 612 | "required": ["op", "msg"] |
| 613 | } |
| 614 | } |
| 615 | }, |
| 616 | "required": ["operations"] |
| 617 | }), |
| 618 | allowed_callers: None, |
| 619 | defer_loading: None, |
| 620 | input_examples: None, |
| 621 | strict: Some(true), |
| 622 | cache_control: None, |
| 623 | } |
| 624 | } |
| 625 | |
| 626 | // ── Orchestration ──────────────────────────────────────────────────────────── |
| 627 | |
| 628 | /// Run a full purge cycle: build the prompt, call the model with the |
| 629 | /// `purge_context` tool, parse the response, and execute the operations. |
| 630 | /// |
| 631 | /// Returns the `PurgeResult` with the modified message list on success, |
| 632 | /// or a human-readable error string on failure. |
| 633 | /// |
| 634 | /// Cost reporting is handled internally as a side-effect of the API call. |
| 635 | /// The caller is responsible for emitting start/completed/failed events |
| 636 | /// and for replacing the session message list with `PurgeResult.messages`. |
| 637 | pub async fn run_purge( |
| 638 | client: &impl LlmClient, |
| 639 | _provider: ApiProvider, |
| 640 | session_id: &str, |
| 641 | messages: &[Message], |
| 642 | model: &str, |
| 643 | reasoning_effort: Option<String>, |
| 644 | max_tokens: u32, |
| 645 | ) -> Result<PurgeResult, String> { |
| 646 | // 1. Build the purge prompt from the current conversation. |
| 647 | let prompt = build_purge_prompt(messages); |
| 648 | |
| 649 | // 2. Clone messages and inject the prompt as a user message. |
| 650 | let mut request_messages = messages.to_vec(); |
| 651 | request_messages.push(Message { |
| 652 | role: Role::User, |
| 653 | content: vec![ContentBlock::Text { |
| 654 | text: prompt, |
| 655 | cache_control: None, |
| 656 | }], |
| 657 | }); |
| 658 | |
| 659 | // 3. Build the tool definition and the request. |
| 660 | let purge_tool = build_purge_tool(); |
| 661 | let request = MessageRequest { |
| 662 | model: model.to_string(), |
| 663 | messages: request_messages, |
| 664 | max_tokens, |
| 665 | system: None, |
| 666 | tools: Some(vec![purge_tool]), |
| 667 | tool_choice: None, |
| 668 | metadata: None, |
| 669 | thinking: None, |
| 670 | reasoning_effort, |
| 671 | stream: Some(false), |
| 672 | temperature: None, |
| 673 | top_p: None, |
| 674 | }; |
| 675 | |
| 676 | // 4. Send to the model. Capture the session scope before awaiting so a |
| 677 | // late response cannot accrue into a subsequently loaded/new session. |
| 678 | let cost_scope = crate::cost_status::scope_token(); |
| 679 | let cost_route = client.effective_route_envelope(model, chrono::Utc::now()); |
| 680 | let response = client |
| 681 | .create_message(request) |
| 682 | .await |
| 683 | .map_err(|e| format!("Purge API error: {e}"))?; |
| 684 | |
| 685 | // Report the route, not just the provider name: the endpoint decides |
| 686 | // whether this is a metered public API, a plan quota, or a local runtime. |
| 687 | // Purge currently has TUI admission only. Freeze that session origin rather |
| 688 | // than borrowing a previous Runtime turn's owner from ambient config. |
| 689 | let source_id = format!( |
| 690 | "purge:{}:{}", |
| 691 | cost_route |
| 692 | .dispatched_at |
| 693 | .timestamp_nanos_opt() |
| 694 | .unwrap_or_default(), |
| 695 | response.id |
| 696 | ); |
| 697 | crate::cost_status::report_effective_route_for_interactive_origin( |
| 698 | cost_scope, |
| 699 | session_id, |
| 700 | &source_id, |
| 701 | &source_id, |
| 702 | &cost_route, |
| 703 | &response.usage, |
| 704 | ); |
| 705 | |
| 706 | // A truncated response can still carry a complete-looking `purge_context` |
| 707 | // call; executing it would mutate the session from incomplete output. |
| 708 | if codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()) { |
| 709 | return Err(format!( |
| 710 | "Purge model response incomplete: provider stop reason `{}`; no purge was applied.", |
| 711 | codewhale_models::stop_reason_detail(response.stop_reason.as_deref()) |
| 712 | )); |
| 713 | } |
| 714 | |
| 715 | // 5. Find the `purge_context` tool call in the response. |
| 716 | let tool_input = response.content.iter().find_map(|block| { |
| 717 | if let ContentBlock::ToolUse { name, input, .. } = block |
| 718 | && name == "purge_context" |
| 719 | { |
| 720 | return Some(input.clone()); |
| 721 | } |
| 722 | None |
| 723 | }); |
| 724 | |
| 725 | match tool_input { |
| 726 | Some(input) => { |
| 727 | let ops = parse_purge_operations(&input, messages.len()) |
| 728 | .map_err(|e| format!("Purge parse error: {e}"))?; |
| 729 | execute_purge_operations(messages, &ops, session_id) |
| 730 | } |
| 731 | None => Err("Purge: model did not call purge_context tool".to_string()), |
| 732 | } |
| 733 | } |
| 734 | |
| 735 | // ── Tests ─────────────────────────────────────────────────────────────────── |
| 736 | |
| 737 | #[cfg(test)] |
| 738 | mod tests { |
| 739 | use super::*; |
| 740 | use serde_json::json; |
| 741 | |
| 742 | fn msg_text(role: &str, text: &str) -> Message { |
| 743 | Message { |
| 744 | role: Role::from(role), |
| 745 | content: vec![ContentBlock::Text { |
| 746 | text: text.to_string(), |
| 747 | cache_control: None, |
| 748 | }], |
| 749 | } |
| 750 | } |
| 751 | |
| 752 | fn msg_tool_use(id: &str, name: &str, input: serde_json::Value) -> Message { |
| 753 | Message { |
| 754 | role: Role::Assistant, |
| 755 | content: vec![ContentBlock::ToolUse { |
| 756 | id: id.to_string(), |
| 757 | name: name.to_string(), |
| 758 | input, |
| 759 | caller: None, |
| 760 | thought_signature: None, |
| 761 | }], |
| 762 | } |
| 763 | } |
| 764 | |
| 765 | fn msg_tool_result(id: &str, content: &str) -> Message { |
| 766 | Message { |
| 767 | role: Role::User, |
| 768 | content: vec![ContentBlock::ToolResult { |
| 769 | tool_use_id: id.to_string(), |
| 770 | content: content.to_string(), |
| 771 | is_error: None, |
| 772 | content_blocks: None, |
| 773 | }], |
| 774 | } |
| 775 | } |
| 776 | |
| 777 | #[test] |
| 778 | fn offload_round_trips_full_paired_context_through_the_existing_retrieval_tool() { |
| 779 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 780 | use crate::tools::spec::{ToolContext, ToolSpec}; |
| 781 | use crate::tools::tool_result_retrieval::RetrieveToolResultTool; |
| 782 | use base64::Engine as _; |
| 783 | |
| 784 | let _env = lock_test_env(); |
| 785 | let _cost_guard = crate::cost_status::test_scope(); |
| 786 | let home = tempfile::tempdir().unwrap(); |
| 787 | let state = home.path().join("explicit-state"); |
| 788 | let _state = EnvVarGuard::set("CODEWHALE_HOME", &state); |
| 789 | let session_id = "purge-owned-session"; |
| 790 | let messages: Vec<Message> = serde_json::from_value(json!([ |
| 791 | {"role":"user", "content":[{"type":"text", "text":"Keep the task"}]}, |
| 792 | {"role":"assistant", "content":[ |
| 793 | {"type":"thinking", "thinking":"retained reasoning", "signature":"signed-exact"}, |
| 794 | {"type":"tool_use", "id":"read-original", "name":"read_file", "input":{"path":"old.rs"}, "thought_signature":"google-exact"} |
| 795 | ]}, |
| 796 | {"role":"user", "content":[ |
| 797 | {"type":"tool_result", "tool_use_id":"read-original", "content":"original-file-data\n".repeat(1000), "content_blocks":[{"type":"image","source":{"data":"Zml4dHVyZQ=="}}]}, |
| 798 | {"type":"image_url", "image_url":{"url":"data:image/png;base64,Zml4dHVyZQ=="}} |
| 799 | ]}, |
| 800 | {"role":"assistant", "content":[{"type":"text", "text":"Keep the current answer"}]} |
| 801 | ])).unwrap(); |
| 802 | let original = messages.clone(); |
| 803 | let mock = MockLlmClient::new(vec![]); |
| 804 | mock.push_message_response(msg_response_with_tool_call(json!([ |
| 805 | {"op":"offload", "msg":3} |
| 806 | ]))); |
| 807 | let runtime = tokio::runtime::Builder::new_current_thread() |
| 808 | .enable_all() |
| 809 | .build() |
| 810 | .unwrap(); |
| 811 | let result = runtime |
| 812 | .block_on(run_purge( |
| 813 | &mock, |
| 814 | ApiProvider::Deepseek, |
| 815 | session_id, |
| 816 | &messages, |
| 817 | "mock", |
| 818 | None, |
| 819 | 4096, |
| 820 | )) |
| 821 | .unwrap(); |
| 822 | assert_eq!(messages, original); |
| 823 | assert_eq!(result.offloaded_count, 2); |
| 824 | assert_eq!(result.removed_count, 0); |
| 825 | assert_eq!(result.messages.len(), 3); |
| 826 | assert_eq!(result.messages[0], original[0]); |
| 827 | assert_eq!(result.messages[2], original[3]); |
| 828 | assert!( |
| 829 | serde_json::to_vec(&result.messages).unwrap().len() |
| 830 | < serde_json::to_vec(&original).unwrap().len() |
| 831 | ); |
| 832 | let pointer = block_content_text(&result.messages[1].content[0]); |
| 833 | let handle = pointer |
| 834 | .split_whitespace() |
| 835 | .find(|part| part.starts_with("art_purge_")) |
| 836 | .unwrap() |
| 837 | .trim_end_matches(','); |
| 838 | let context = ToolContext::new(home.path()).with_state_namespace(session_id); |
| 839 | let retrieved = runtime |
| 840 | .block_on(RetrieveToolResultTool.execute( |
| 841 | json!({"ref":handle,"mode":"bytes","generation":1,"max_bytes":131072}), |
| 842 | &context, |
| 843 | )) |
| 844 | .unwrap(); |
| 845 | let payload: serde_json::Value = serde_json::from_str(&retrieved.content).unwrap(); |
| 846 | let bytes = base64::engine::general_purpose::STANDARD |
| 847 | .decode(payload["data"].as_str().unwrap()) |
| 848 | .unwrap(); |
| 849 | let archive: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); |
| 850 | let restored: Vec<Message> = archive["messages"] |
| 851 | .as_array() |
| 852 | .unwrap() |
| 853 | .iter() |
| 854 | .map(|entry| serde_json::from_value(entry["message"].clone()).unwrap()) |
| 855 | .collect(); |
| 856 | assert_eq!(restored, original[1..3]); |
| 857 | assert_eq!(archive["messages"][0]["message_id"], 2); |
| 858 | assert_eq!(archive["messages"][1]["message_id"], 3); |
| 859 | let metadata = |
| 860 | crate::tools::large_output_router::read_evidence_metadata(session_id, handle).unwrap(); |
| 861 | assert_eq!(metadata.retain_until_unix_ms, u64::MAX); |
| 862 | let path = state |
| 863 | .join("sessions") |
| 864 | .join(session_id) |
| 865 | .join(&metadata.storage_path); |
| 866 | assert_eq!(std::fs::read(&path).unwrap(), bytes); |
| 867 | let other = ToolContext::new(home.path()).with_state_namespace("another-session"); |
| 868 | assert!( |
| 869 | runtime |
| 870 | .block_on( |
| 871 | RetrieveToolResultTool.execute(json!({"ref":handle,"mode":"bytes"}), &other) |
| 872 | ) |
| 873 | .is_err() |
| 874 | ); |
| 875 | std::fs::write(path, b"changed fixture").unwrap(); |
| 876 | assert!( |
| 877 | runtime |
| 878 | .block_on( |
| 879 | RetrieveToolResultTool.execute(json!({"ref":handle,"mode":"bytes"}), &context) |
| 880 | ) |
| 881 | .is_err() |
| 882 | ); |
| 883 | } |
| 884 | |
| 885 | #[test] |
| 886 | fn offload_closes_server_tool_pairs_and_wins_over_overlapping_destructive_operations() { |
| 887 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 888 | let _env = lock_test_env(); |
| 889 | let home = tempfile::tempdir().unwrap(); |
| 890 | let _state = EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 891 | let messages: Vec<Message> = serde_json::from_value(json!([ |
| 892 | {"role":"assistant", "content":[ |
| 893 | {"type":"server_tool_use", "id":"search", "name":"tool_search", "input":{}}, |
| 894 | {"type":"server_tool_use", "id":"execute", "name":"code_execution", "input":{}} |
| 895 | ]}, |
| 896 | {"role":"assistant", "content":[{"type":"tool_search_tool_result", "tool_use_id":"search", "content":{"tools":["read"]}}]}, |
| 897 | {"role":"assistant", "content":[{"type":"code_execution_tool_result", "tool_use_id":"execute", "content":{"output":"exact"}}]}, |
| 898 | {"role":"user", "content":[{"type":"text", "text":"keep"}]} |
| 899 | ])).unwrap(); |
| 900 | let ops = parse_purge_operations( |
| 901 | &json!({"operations":[ |
| 902 | {"op":"remove", "msg":1}, {"op":"offload", "msg":2}, |
| 903 | {"op":"replace", "msg":3, "block":0, "pattern":"exact", "with":"lost"} |
| 904 | ]}), |
| 905 | messages.len(), |
| 906 | ) |
| 907 | .unwrap(); |
| 908 | let result = execute_purge_operations(&messages, &ops, "server-pairs").unwrap(); |
| 909 | assert_eq!(result.offloaded_count, 3); |
| 910 | assert_eq!(result.removed_count, 0); |
| 911 | assert_eq!(result.replaced_count, 0); |
| 912 | assert_eq!(result.messages.len(), 2); |
| 913 | assert_eq!(result.messages[1], messages[3]); |
| 914 | } |
| 915 | |
| 916 | #[test] |
| 917 | fn offload_storage_failure_leaves_mixed_purge_history_unchanged() { |
| 918 | use crate::test_support::{EnvVarGuard, lock_test_env}; |
| 919 | let _env = lock_test_env(); |
| 920 | let home = tempfile::tempdir().unwrap(); |
| 921 | let _state = EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 922 | std::fs::write(home.path().join("sessions"), b"not a directory").unwrap(); |
| 923 | let messages = vec![msg_text("user", "keep"), msg_text("assistant", "original")]; |
| 924 | let original = messages.clone(); |
| 925 | let ops = parse_purge_operations(&json!({"operations":[ |
| 926 | {"op":"remove", "msg":1}, {"op":"replace", "msg":2, "block":0, "pattern":"original", "with":"lost"}, {"op":"offload", "msg":2} |
| 927 | ]}), messages.len()).unwrap(); |
| 928 | let error = execute_purge_operations(&messages, &ops, "storage-failure").unwrap_err(); |
| 929 | assert!(error.contains("history unchanged")); |
| 930 | assert_eq!(messages, original); |
| 931 | } |
| 932 | |
| 933 | #[test] |
| 934 | fn parse_remove_operations() { |
| 935 | let input = json!({ |
| 936 | "operations": [ |
| 937 | {"op": "remove", "msg": 1}, |
| 938 | {"op": "remove", "msg": 3} |
| 939 | ] |
| 940 | }); |
| 941 | let ops = parse_purge_operations(&input, 5).unwrap(); |
| 942 | assert_eq!(ops.len(), 2); |
| 943 | assert!(matches!(ops[0], PurgeOp::Remove { msg_id: 1 })); |
| 944 | assert!(matches!(ops[1], PurgeOp::Remove { msg_id: 3 })); |
| 945 | } |
| 946 | |
| 947 | #[test] |
| 948 | fn parse_replace_operation() { |
| 949 | let input = json!({ |
| 950 | "operations": [ |
| 951 | {"op": "replace", "msg": 2, "block": 0, "pattern": "hello", "with": "hi"} |
| 952 | ] |
| 953 | }); |
| 954 | let ops = parse_purge_operations(&input, 5).unwrap(); |
| 955 | assert_eq!(ops.len(), 1); |
| 956 | assert!(matches!(ops[0], PurgeOp::Replace { msg_id: 2, .. })); |
| 957 | } |
| 958 | |
| 959 | #[test] |
| 960 | fn parse_rejects_out_of_range_msg() { |
| 961 | let input = json!({"operations": [{"op": "remove", "msg": 10}]}); |
| 962 | assert!(parse_purge_operations(&input, 5).is_err()); |
| 963 | } |
| 964 | |
| 965 | #[test] |
| 966 | fn parse_rejects_invalid_regex() { |
| 967 | let input = json!({ |
| 968 | "operations": [{"op": "replace", "msg": 1, "block": 0, "pattern": "[", "with": "x"}] |
| 969 | }); |
| 970 | assert!(parse_purge_operations(&input, 5).is_err()); |
| 971 | } |
| 972 | |
| 973 | #[test] |
| 974 | fn execute_remove_works() { |
| 975 | let msgs = vec![ |
| 976 | msg_text("user", "hello"), |
| 977 | msg_text("assistant", "hi there"), |
| 978 | msg_text("user", "bye"), |
| 979 | ]; |
| 980 | let ops = vec![PurgeOp::Remove { msg_id: 2 }]; |
| 981 | let result = execute_purge_operations(&msgs, &ops, "purge-test").unwrap(); |
| 982 | assert_eq!(result.removed_count, 1); |
| 983 | assert_eq!(result.messages.len(), 2); |
| 984 | } |
| 985 | |
| 986 | #[test] |
| 987 | fn execute_replace_text_block() { |
| 988 | let msgs = vec![msg_text("assistant", "Hello world! Hello again!")]; |
| 989 | let pattern = Regex::new("Hello").unwrap(); |
| 990 | let ops = vec![PurgeOp::Replace { |
| 991 | msg_id: 1, |
| 992 | block_idx: 0, |
| 993 | pattern, |
| 994 | with: "Hi".to_string(), |
| 995 | }]; |
| 996 | let result = execute_purge_operations(&msgs, &ops, "purge-test").unwrap(); |
| 997 | assert_eq!(result.replaced_count, 1); |
| 998 | |
| 999 | if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] { |
| 1000 | assert_eq!(text, "Hi world! Hi again!"); |
| 1001 | } else { |
| 1002 | panic!("expected text block"); |
| 1003 | } |
| 1004 | } |
| 1005 | |
| 1006 | #[test] |
| 1007 | fn tool_call_result_pairing_cascaded() { |
| 1008 | // Message 2 (idx 1) is a tool call. Message 3 (idx 2) is its result. |
| 1009 | // Removing the tool call should cascade to remove the result too. |
| 1010 | let msgs = vec![ |
| 1011 | msg_text("user", "read a file"), |
| 1012 | msg_tool_use("call_01", "read_file", json!({"path": "x.rs"})), |
| 1013 | msg_tool_result("call_01", "fn main() {}"), |
| 1014 | ]; |
| 1015 | let ops = vec![PurgeOp::Remove { msg_id: 2 }]; // remove tool call only |
| 1016 | let result = execute_purge_operations(&msgs, &ops, "purge-test").unwrap(); |
| 1017 | // Both tool call and its result should be gone (cascaded). |
| 1018 | assert_eq!( |
| 1019 | result.removed_count, 2, |
| 1020 | "tool call + its result should both be removed" |
| 1021 | ); |
| 1022 | assert_eq!(result.messages.len(), 1); |
| 1023 | } |
| 1024 | |
| 1025 | #[test] |
| 1026 | fn tool_result_removal_cascades_to_call() { |
| 1027 | // Removing the result should cascade to remove the call. |
| 1028 | let msgs = vec![ |
| 1029 | msg_text("user", "read a file"), |
| 1030 | msg_tool_use("call_01", "read_file", json!({"path": "x.rs"})), |
| 1031 | msg_tool_result("call_01", "fn main() {}"), |
| 1032 | ]; |
| 1033 | let ops = vec![PurgeOp::Remove { msg_id: 3 }]; // remove result only |
| 1034 | let result = execute_purge_operations(&msgs, &ops, "purge-test").unwrap(); |
| 1035 | assert_eq!( |
| 1036 | result.removed_count, 2, |
| 1037 | "tool result + its call should both be removed" |
| 1038 | ); |
| 1039 | assert_eq!(result.messages.len(), 1); |
| 1040 | } |
| 1041 | |
| 1042 | #[test] |
| 1043 | fn prompt_truncates_long_content() { |
| 1044 | let long_text = "x".repeat(200); |
| 1045 | let msgs = vec![msg_text("user", &long_text)]; |
| 1046 | let prompt = build_purge_prompt(&msgs); |
| 1047 | assert!(prompt.contains("(200 chars)")); |
| 1048 | assert!(prompt.contains("xxx...")); // truncated |
| 1049 | assert!(!prompt.contains(&long_text)); |
| 1050 | } |
| 1051 | |
| 1052 | #[test] |
| 1053 | fn prompt_shows_full_short_content() { |
| 1054 | let msgs = vec![msg_text("user", "hi")]; |
| 1055 | let prompt = build_purge_prompt(&msgs); |
| 1056 | assert!(prompt.contains("\"hi\"")); |
| 1057 | assert!(!prompt.contains("...")); |
| 1058 | } |
| 1059 | |
| 1060 | #[test] |
| 1061 | fn prompt_omits_thinking_blocks() { |
| 1062 | let msgs = vec![Message { |
| 1063 | role: Role::Assistant, |
| 1064 | content: vec![ |
| 1065 | ContentBlock::Thinking { |
| 1066 | signature: None, |
| 1067 | state: None, |
| 1068 | thinking: "let me think...".to_string(), |
| 1069 | }, |
| 1070 | ContentBlock::Text { |
| 1071 | text: "done".to_string(), |
| 1072 | cache_control: None, |
| 1073 | }, |
| 1074 | ], |
| 1075 | }]; |
| 1076 | let prompt = build_purge_prompt(&msgs); |
| 1077 | assert!(!prompt.contains("let me think")); |
| 1078 | assert!(prompt.contains("Text (4 chars)")); |
| 1079 | } |
| 1080 | |
| 1081 | #[test] |
| 1082 | fn build_purge_tool_has_correct_shape() { |
| 1083 | let tool = build_purge_tool(); |
| 1084 | assert_eq!(tool.name, "purge_context"); |
| 1085 | let schema = &tool.input_schema; |
| 1086 | assert_eq!(schema["type"], "object"); |
| 1087 | assert!(schema["properties"]["operations"]["type"] == "array"); |
| 1088 | let ops_item = &schema["properties"]["operations"]["items"]; |
| 1089 | assert_eq!(ops_item["type"], "object"); |
| 1090 | let required = ops_item["required"].as_array().unwrap(); |
| 1091 | assert!(required.contains(&json!("op"))); |
| 1092 | assert!(required.contains(&json!("msg"))); |
| 1093 | } |
| 1094 | |
| 1095 | use crate::llm_client::mock::MockLlmClient; |
| 1096 | use codewhale_models::{MessageResponse, Usage}; |
| 1097 | |
| 1098 | fn msg_response_with_tool_call(operations: serde_json::Value) -> MessageResponse { |
| 1099 | MessageResponse { |
| 1100 | id: "resp_test".to_string(), |
| 1101 | r#type: "message".to_string(), |
| 1102 | role: "assistant".to_string(), |
| 1103 | content: vec![ContentBlock::ToolUse { |
| 1104 | id: "call_purge".to_string(), |
| 1105 | name: "purge_context".to_string(), |
| 1106 | input: json!({"operations": operations}), |
| 1107 | caller: None, |
| 1108 | thought_signature: None, |
| 1109 | }], |
| 1110 | model: "mock-model".to_string(), |
| 1111 | stop_reason: None, |
| 1112 | stop_sequence: None, |
| 1113 | container: None, |
| 1114 | usage: Usage::default(), |
| 1115 | } |
| 1116 | } |
| 1117 | |
| 1118 | fn msg_response_without_tool_call(text: &str) -> MessageResponse { |
| 1119 | MessageResponse { |
| 1120 | id: "resp_plain".to_string(), |
| 1121 | r#type: "message".to_string(), |
| 1122 | role: "assistant".to_string(), |
| 1123 | content: vec![ContentBlock::Text { |
| 1124 | text: text.to_string(), |
| 1125 | cache_control: None, |
| 1126 | }], |
| 1127 | model: "mock".to_string(), |
| 1128 | stop_reason: None, |
| 1129 | stop_sequence: None, |
| 1130 | container: None, |
| 1131 | usage: Usage::default(), |
| 1132 | } |
| 1133 | } |
| 1134 | |
| 1135 | #[tokio::test] |
| 1136 | async fn run_purge_removes_message() { |
| 1137 | let _env = crate::test_support::lock_test_env(); |
| 1138 | let home = tempfile::tempdir().unwrap(); |
| 1139 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 1140 | let _cost_guard = crate::cost_status::test_scope(); |
| 1141 | let mock = MockLlmClient::new(vec![]); |
| 1142 | mock.push_message_response(msg_response_with_tool_call(json!([ |
| 1143 | {"op": "remove", "msg": 2} |
| 1144 | ]))); |
| 1145 | |
| 1146 | let messages = vec![ |
| 1147 | msg_text("user", "hello"), |
| 1148 | msg_text("assistant", "remove me"), |
| 1149 | msg_text("user", "bye"), |
| 1150 | ]; |
| 1151 | |
| 1152 | let result = run_purge( |
| 1153 | &mock, |
| 1154 | ApiProvider::Deepseek, |
| 1155 | "purge-test", |
| 1156 | &messages, |
| 1157 | "mock", |
| 1158 | None, |
| 1159 | 4096, |
| 1160 | ) |
| 1161 | .await |
| 1162 | .unwrap(); |
| 1163 | assert_eq!(result.removed_count, 1); |
| 1164 | assert_eq!(result.replaced_count, 0); |
| 1165 | assert_eq!(result.messages.len(), 2); |
| 1166 | |
| 1167 | if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] { |
| 1168 | assert_eq!(text, "hello"); |
| 1169 | } else { |
| 1170 | panic!( |
| 1171 | "expected text block, got {:?}", |
| 1172 | result.messages[0].content[0] |
| 1173 | ); |
| 1174 | } |
| 1175 | if let ContentBlock::Text { text, .. } = &result.messages[1].content[0] { |
| 1176 | assert_eq!(text, "bye"); |
| 1177 | } else { |
| 1178 | panic!( |
| 1179 | "expected text block, got {:?}", |
| 1180 | result.messages[1].content[0] |
| 1181 | ); |
| 1182 | } |
| 1183 | } |
| 1184 | |
| 1185 | #[tokio::test] |
| 1186 | async fn run_purge_replace_condenses_text() { |
| 1187 | let _env = crate::test_support::lock_test_env(); |
| 1188 | let home = tempfile::tempdir().unwrap(); |
| 1189 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 1190 | let _cost_guard = crate::cost_status::test_scope(); |
| 1191 | let mock = MockLlmClient::new(vec![]); |
| 1192 | mock.push_message_response(msg_response_with_tool_call(json!([ |
| 1193 | {"op": "replace", "msg": 1, "block": 0, "pattern": "very long and verbose", "with": "short"} |
| 1194 | ]))); |
| 1195 | |
| 1196 | let messages = vec![msg_text("assistant", "this is very long and verbose text")]; |
| 1197 | |
| 1198 | let result = run_purge( |
| 1199 | &mock, |
| 1200 | ApiProvider::Deepseek, |
| 1201 | "purge-test", |
| 1202 | &messages, |
| 1203 | "mock", |
| 1204 | None, |
| 1205 | 4096, |
| 1206 | ) |
| 1207 | .await |
| 1208 | .unwrap(); |
| 1209 | assert_eq!(result.removed_count, 0); |
| 1210 | assert_eq!(result.replaced_count, 1); |
| 1211 | |
| 1212 | if let ContentBlock::Text { text, .. } = &result.messages[0].content[0] { |
| 1213 | assert_eq!(text, "this is short text"); |
| 1214 | } else { |
| 1215 | panic!( |
| 1216 | "expected text block, got {:?}", |
| 1217 | result.messages[0].content[0] |
| 1218 | ); |
| 1219 | } |
| 1220 | } |
| 1221 | |
| 1222 | #[tokio::test] |
| 1223 | async fn run_purge_errors_when_no_tool_call() { |
| 1224 | let _env = crate::test_support::lock_test_env(); |
| 1225 | let home = tempfile::tempdir().unwrap(); |
| 1226 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 1227 | let _cost_guard = crate::cost_status::test_scope(); |
| 1228 | let mock = MockLlmClient::new(vec![]); |
| 1229 | mock.push_message_response(msg_response_without_tool_call("nothing to clean up")); |
| 1230 | |
| 1231 | let messages = vec![msg_text("user", "hi")]; |
| 1232 | let err = run_purge( |
| 1233 | &mock, |
| 1234 | ApiProvider::Deepseek, |
| 1235 | "purge-test", |
| 1236 | &messages, |
| 1237 | "mock", |
| 1238 | None, |
| 1239 | 4096, |
| 1240 | ) |
| 1241 | .await |
| 1242 | .unwrap_err(); |
| 1243 | assert!(err.contains("did not call purge_context")); |
| 1244 | } |
| 1245 | |
| 1246 | #[tokio::test] |
| 1247 | async fn run_purge_errors_on_api_failure() { |
| 1248 | let _env = crate::test_support::lock_test_env(); |
| 1249 | let home = tempfile::tempdir().unwrap(); |
| 1250 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path()); |
| 1251 | let _cost_guard = crate::cost_status::test_scope(); |
| 1252 | // No canned response — MockLlmClient returns an error. |
| 1253 | let mock = MockLlmClient::new(vec![]); |
| 1254 | let messages = vec![msg_text("user", "hi")]; |
| 1255 | let err = run_purge( |
| 1256 | &mock, |
| 1257 | ApiProvider::Deepseek, |
| 1258 | "purge-test", |
| 1259 | &messages, |
| 1260 | "mock", |
| 1261 | None, |
| 1262 | 4096, |
| 1263 | ) |
| 1264 | .await |
| 1265 | .unwrap_err(); |
| 1266 | assert!(err.contains("Purge API error")); |
| 1267 | } |
| 1268 | } |
| 1269 |