返回 CodeWhale
purge.rs
根目录 / crates / tui / src / purge.rs
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
1269 lines RUST