| 1 | //! Context compaction for long conversations. |
| 2 | |
| 3 | use anyhow::Result; |
| 4 | use std::collections::HashMap; |
| 5 | use std::fmt::Write; |
| 6 | use std::time::Duration; |
| 7 | |
| 8 | use crate::config::DEFAULT_TEXT_MODEL; |
| 9 | use crate::core::model_client::ModelClient; |
| 10 | use crate::logging; |
| 11 | use codewhale_models::Role; |
| 12 | use codewhale_models::{ |
| 13 | CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt, Tool, Usage, |
| 14 | }; |
| 15 | |
| 16 | #[path = "compaction/last_round.rs"] |
| 17 | mod last_round; |
| 18 | #[cfg(test)] |
| 19 | #[path = "compaction/survival_contract.rs"] |
| 20 | mod survival_contract; |
| 21 | pub(crate) use last_round::last_round_start; |
| 22 | pub use last_round::{ |
| 23 | CompactionCoverage, CompactionKeep, CompactionPath, LastCompactionSnapshot, |
| 24 | inspect_compaction_keep, last_round_kept_count, pinned_anchors_text, |
| 25 | }; |
| 26 | |
| 27 | /// Configuration for conversation compaction behavior. |
| 28 | /// |
| 29 | /// v0.8.11 simplified this from the prior token-OR-message-count trigger |
| 30 | /// to a token-only trigger. The |
| 31 | /// `message_threshold` field was removed: its only purpose was to fire |
| 32 | /// compaction on long sessions of small messages, which is exactly the |
| 33 | /// case where rewriting the prefix cache is least valuable. Token |
| 34 | /// budget is the right signal; message count was a 128K-era heuristic. |
| 35 | #[derive(Debug, Clone, PartialEq)] |
| 36 | pub struct CompactionConfig { |
| 37 | pub enabled: bool, |
| 38 | pub token_threshold: usize, |
| 39 | pub model: String, |
| 40 | /// Exact route image-input fact for the summarizer's outbound history. |
| 41 | pub image_input: crate::model_profile::SupportState, |
| 42 | /// Route-effective context window. `None` preserves compatibility for |
| 43 | /// callers that have not resolved a provider route yet. |
| 44 | pub effective_context_window: Option<u32>, |
| 45 | pub cache_summary: bool, |
| 46 | /// Optional user-supplied focus for a manual `/compact <focus>`: injected |
| 47 | /// into the summary request so the checkpoint weights what the user |
| 48 | /// said matters. `None` for automatic compaction. |
| 49 | pub focus: Option<String>, |
| 50 | /// Runtime turn that owns provider calls made by this compaction pass. |
| 51 | /// `None` for the foreground TUI. This is accounting provenance only and |
| 52 | /// is never included in a provider request. |
| 53 | pub runtime_cost_owner: Option<String>, |
| 54 | /// Workspace root, used only to re-state the user's `/anchor` file after |
| 55 | /// the summary. `None` skips anchors. |
| 56 | pub workspace: Option<std::path::PathBuf>, |
| 57 | /// Standing operator instructions from `[compaction] summary_instructions` |
| 58 | /// (#5956), appended to the summarizer prompt on every pass — manual and |
| 59 | /// automatic. `None` keeps the built-in prompt byte-identical. A manual |
| 60 | /// `/compact <focus>` still composes after this text. |
| 61 | pub summary_instructions: Option<String>, |
| 62 | /// Verbatim retention budget for recent plain user messages in the |
| 63 | /// replacement history (`[compaction] retained_user_message_tokens`, |
| 64 | /// #5956). Defaults to [`COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS`]. |
| 65 | pub retained_user_message_tokens: usize, |
| 66 | } |
| 67 | |
| 68 | /// Host-prepared configuration carried from compaction eligibility through |
| 69 | /// the replacement-history commit. |
| 70 | #[derive(Debug, Clone, PartialEq)] |
| 71 | pub struct PreparedCompactionEnvelope { |
| 72 | pub config: CompactionConfig, |
| 73 | /// Durable handoff owner; set by the engine, never added to the stable prefix. |
| 74 | pub session_id: Option<String>, |
| 75 | /// Exact tool prefix of the interrupted request. Tool execution remains |
| 76 | /// disabled on the summary call; retaining schemas preserves cache reuse. |
| 77 | pub tools: Option<Vec<Tool>>, |
| 78 | } |
| 79 | |
| 80 | impl PreparedCompactionEnvelope { |
| 81 | #[must_use] |
| 82 | pub fn new(config: CompactionConfig) -> Self { |
| 83 | Self { |
| 84 | config, |
| 85 | session_id: None, |
| 86 | tools: None, |
| 87 | } |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | impl Default for CompactionConfig { |
| 92 | fn default() -> Self { |
| 93 | Self { |
| 94 | // ON BY DEFAULT since v0.8.6 (#402 P0 survivability). v0.8.64 |
| 95 | // resolves the user-facing default through the active model's |
| 96 | // known context window, while explicit `auto_compact = false` |
| 97 | // remains the opt-out. This fallback covers code paths that build |
| 98 | // a `CompactionConfig` directly; real per-model values are still |
| 99 | // derived through the threshold helpers. |
| 100 | enabled: true, |
| 101 | // v0.8.11: 50K was a 128K-era leftover that biased every |
| 102 | // unconfigured caller toward "compact almost immediately on large-context routes." |
| 103 | // Bumped to 800K (80% of a 1M window) so the fallback |
| 104 | // default matches the hard automatic compaction guardrail. This |
| 105 | // keeps replacement compaction a late continuity guardrail. |
| 106 | // Real call sites override this via |
| 107 | // `compaction_threshold_for_model_and_effort`. |
| 108 | token_threshold: 800_000, |
| 109 | model: DEFAULT_TEXT_MODEL.to_string(), |
| 110 | image_input: crate::model_profile::SupportState::Unknown, |
| 111 | effective_context_window: None, |
| 112 | cache_summary: true, |
| 113 | focus: None, |
| 114 | runtime_cost_owner: None, |
| 115 | workspace: None, |
| 116 | summary_instructions: None, |
| 117 | retained_user_message_tokens: COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS, |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// A provider can return HTTP success with an empty, non-text, or known |
| 123 | /// placeholder response. Committing that response would discard the useful |
| 124 | /// history while leaving only a placeholder checkpoint. Keep this deliberately |
| 125 | /// conservative: it is a corruption guard, not a prose-length or language |
| 126 | /// scorer. |
| 127 | const COMPACTION_LANGUAGE_CONTRACT: &str = "Use the natural language of the most recent \ |
| 128 | substantive user message for reasoning and user-facing prose. Keep code, identifiers, paths, \ |
| 129 | commands, logs, tool payloads, quotations, and the English structural labels verbatim. English \ |
| 130 | scaffolding is not a request to switch languages."; |
| 131 | |
| 132 | /// Failure kind for compaction LLM calls (deterministic vs transient). |
| 133 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 134 | pub enum CompactionFailureKind { |
| 135 | /// Same payload will fail again — do not sleep/retry unchanged. |
| 136 | Deterministic, |
| 137 | /// May resolve on retry (network, rate limit, timeout). |
| 138 | Transient, |
| 139 | /// Context overflow — drop the oldest history item and retry. |
| 140 | ContextOverflow, |
| 141 | } |
| 142 | |
| 143 | impl CompactionFailureKind { |
| 144 | #[must_use] |
| 145 | pub fn is_transient(self) -> bool { |
| 146 | matches!(self, Self::Transient) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | pub const KEEP_RECENT_MESSAGES: usize = 4; |
| 151 | const MIN_SUMMARIZE_MESSAGES: usize = 6; |
| 152 | const SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 240; |
| 153 | const TOOL_PRUNE_STOP_CHECK_BYTES: usize = 16 * 1024; |
| 154 | const RETAINED_TOOL_RESULT_MAX_CHARS: usize = 64 * 1024; |
| 155 | /// Token budget for the recent user messages retained verbatim in the |
| 156 | /// replacement history (Codex parity: COMPACT_USER_MESSAGE_MAX_TOKENS). |
| 157 | /// |
| 158 | /// This is now the *default* only: `[compaction] retained_user_message_tokens` |
| 159 | /// overrides it per session (#5956). Aliased to the config default so the two |
| 160 | /// names cannot drift apart. |
| 161 | pub(crate) const COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS: usize = |
| 162 | crate::config::DEFAULT_COMPACTION_RETAINED_USER_MESSAGE_TOKENS; |
| 163 | /// Handoff summarization prompt, appended to the live conversation as the |
| 164 | /// final user message (ported from Codex `templates/compact/prompt.md`). |
| 165 | const COMPACT_PROMPT: &str = "You are performing a context checkpoint compaction. Create a \ |
| 166 | handoff summary for another LLM that will resume the task.\n\nInclude:\n\ |
| 167 | - Current progress and key decisions made\n\ |
| 168 | - Important context, constraints, or user preferences\n\ |
| 169 | - What remains to be done (clear next steps)\n\ |
| 170 | - The user's current objective, latest corrections, and already-granted permissions or explicit prohibitions\n\ |
| 171 | - Active commands, task and session handles, changed files, and the exact verification still needed\n\ |
| 172 | - Any critical data, examples, or references needed to continue (exact file paths, commands, and error text)\n\n\ |
| 173 | Be concise, structured, and focused on helping the next LLM seamlessly continue the work. Do not call tools.\n\ |
| 174 | Summarize the task, not the checkpoint machinery: do not mention compaction, checkpoints, or \ |
| 175 | context management, and do not carry forward meta-commentary about them (e.g. \"context intact\") \ |
| 176 | from earlier turns."; |
| 177 | |
| 178 | /// Preamble for the one conversation-history checkpoint created by compaction. |
| 179 | /// This intentionally follows Codex's `templates/compact/summary_prefix.md`: |
| 180 | /// the checkpoint is a user-history item, never standing system-prompt prose. |
| 181 | const SUMMARY_HEADER: &str = "Another language model started to solve this problem and produced \ |
| 182 | a summary of its thinking process. You also have access to the state of the tools that were used \ |
| 183 | by that language model. Use this to build on the work that has already been done and avoid \ |
| 184 | duplicating work. Here is the summary produced by the other language model, use the information \ |
| 185 | in this summary to assist with your own analysis:"; |
| 186 | |
| 187 | /// Detection marker for committed compaction-summary text: the stable first |
| 188 | /// sentence of [`SUMMARY_HEADER`]. `engine/context.rs` restores summaries by |
| 189 | /// the same marker on session load. |
| 190 | pub const COMPACTION_SUMMARY_MARKER: &str = "Another language model started to solve this problem"; |
| 191 | /// Marker written by pre-v0.9.6 compaction; sessions saved under the old |
| 192 | /// format must still be recognized so their summary is replaced, not stacked. |
| 193 | pub const LEGACY_COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)"; |
| 194 | const COMPACTION_CHECKPOINT_PROVENANCE: &str = "<!-- codewhale.compaction-checkpoint.v1 -->"; |
| 195 | const COMPACTION_SUMMARY_BEGIN: &str = "<!-- compaction-summary:begin -->"; |
| 196 | const COMPACTION_SUMMARY_END: &str = "<!-- compaction-summary:end -->"; |
| 197 | |
| 198 | /// Whether a system-prompt text block is a committed compaction summary. |
| 199 | #[must_use] |
| 200 | pub fn is_compaction_summary_text(text: &str) -> bool { |
| 201 | text.contains(COMPACTION_SUMMARY_MARKER) || text.contains(LEGACY_COMPACTION_SUMMARY_MARKER) |
| 202 | } |
| 203 | |
| 204 | fn summary_section(text: &str) -> Option<&str> { |
| 205 | let begin = text.find(COMPACTION_SUMMARY_BEGIN)? + COMPACTION_SUMMARY_BEGIN.len(); |
| 206 | let remainder = &text[begin..]; |
| 207 | let end = remainder.find(COMPACTION_SUMMARY_END)?; |
| 208 | let summary = remainder[..end].trim(); |
| 209 | (!summary.is_empty()).then_some(summary) |
| 210 | } |
| 211 | |
| 212 | fn strip_summary_text(mut text: String) -> Option<String> { |
| 213 | while let Some(begin) = text.find(COMPACTION_SUMMARY_BEGIN) { |
| 214 | let after_begin = begin + COMPACTION_SUMMARY_BEGIN.len(); |
| 215 | let end = text[after_begin..] |
| 216 | .find(COMPACTION_SUMMARY_END) |
| 217 | .map_or(text.len(), |offset| { |
| 218 | after_begin + offset + COMPACTION_SUMMARY_END.len() |
| 219 | }); |
| 220 | text.replace_range(begin..end, ""); |
| 221 | } |
| 222 | if let Some(marker) = text |
| 223 | .find(COMPACTION_SUMMARY_MARKER) |
| 224 | .or_else(|| text.find(LEGACY_COMPACTION_SUMMARY_MARKER)) |
| 225 | { |
| 226 | text.truncate(marker); |
| 227 | } |
| 228 | let text = text.trim().to_string(); |
| 229 | (!text.is_empty()).then_some(text) |
| 230 | } |
| 231 | |
| 232 | /// Extract the persisted checkpoint payload from a legacy system-prompt |
| 233 | /// carrier. Runtime-thread storage used that carrier before checkpoints moved |
| 234 | /// into conversation history; the engine strips it before provider dispatch. |
| 235 | #[must_use] |
| 236 | pub fn extract_compaction_summary(prompt: Option<&SystemPrompt>) -> Option<SystemPrompt> { |
| 237 | match prompt? { |
| 238 | SystemPrompt::Text(text) => summary_section(text) |
| 239 | .map(str::to_string) |
| 240 | .or_else(|| { |
| 241 | text.find(COMPACTION_SUMMARY_MARKER) |
| 242 | .or_else(|| text.find(LEGACY_COMPACTION_SUMMARY_MARKER)) |
| 243 | .map(|start| text[start..].trim().to_string()) |
| 244 | }) |
| 245 | .map(SystemPrompt::Text), |
| 246 | SystemPrompt::Blocks(blocks) => { |
| 247 | let blocks = blocks |
| 248 | .iter() |
| 249 | .filter_map(|block| { |
| 250 | let text = summary_section(&block.text) |
| 251 | .map(str::to_string) |
| 252 | .or_else(|| { |
| 253 | block |
| 254 | .text |
| 255 | .find(COMPACTION_SUMMARY_MARKER) |
| 256 | .or_else(|| block.text.find(LEGACY_COMPACTION_SUMMARY_MARKER)) |
| 257 | .map(|start| block.text[start..].trim().to_string()) |
| 258 | })?; |
| 259 | let mut summary = block.clone(); |
| 260 | summary.text = text; |
| 261 | Some(summary) |
| 262 | }) |
| 263 | .collect::<Vec<_>>(); |
| 264 | (!blocks.is_empty()).then_some(SystemPrompt::Blocks(blocks)) |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | /// Remove every committed compaction-summary block from a system prompt. |
| 270 | /// |
| 271 | /// Compaction commits exactly one live summary: the newest one replaces its |
| 272 | /// predecessors. Before this existed, each compaction appended another |
| 273 | /// summary block to the successor system prompt, so the stable prefix grew by |
| 274 | /// up to a full summary per pass — which re-latched compaction pressure and |
| 275 | /// retriggered compaction on the next turn, forever. |
| 276 | #[must_use] |
| 277 | pub fn strip_compaction_summaries(prompt: Option<&SystemPrompt>) -> Option<SystemPrompt> { |
| 278 | match prompt.cloned()? { |
| 279 | SystemPrompt::Text(text) => strip_summary_text(text).map(SystemPrompt::Text), |
| 280 | SystemPrompt::Blocks(blocks) => { |
| 281 | let blocks = blocks |
| 282 | .into_iter() |
| 283 | .filter_map(|mut block| { |
| 284 | block.text = strip_summary_text(block.text)?; |
| 285 | Some(block) |
| 286 | }) |
| 287 | .collect::<Vec<_>>(); |
| 288 | (!blocks.is_empty()).then_some(SystemPrompt::Blocks(blocks)) |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | /// Flatten a committed summary prompt to the text stored in history. |
| 294 | #[must_use] |
| 295 | pub fn summary_prompt_text(prompt: &SystemPrompt) -> String { |
| 296 | match prompt { |
| 297 | SystemPrompt::Text(text) => text.clone(), |
| 298 | SystemPrompt::Blocks(blocks) => blocks |
| 299 | .iter() |
| 300 | .map(|block| block.text.as_str()) |
| 301 | .collect::<Vec<_>>() |
| 302 | .join("\n\n"), |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | #[must_use] |
| 307 | pub(crate) fn compaction_checkpoint_message(prompt: &SystemPrompt) -> Message { |
| 308 | Message { |
| 309 | role: Role::User, |
| 310 | content: vec![ |
| 311 | ContentBlock::Text { |
| 312 | text: summary_prompt_text(prompt), |
| 313 | cache_control: None, |
| 314 | }, |
| 315 | ContentBlock::Text { |
| 316 | text: COMPACTION_CHECKPOINT_PROVENANCE.to_string(), |
| 317 | cache_control: None, |
| 318 | }, |
| 319 | ], |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | #[must_use] |
| 324 | pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool { |
| 325 | user_text_of(message).is_some_and(|text| is_compaction_summary_text(&text)) |
| 326 | } |
| 327 | |
| 328 | /// Request-time recognition is narrower than legacy summary replacement: |
| 329 | /// user text merely quoting the marker must keep its original wire position. |
| 330 | pub(crate) fn is_wire_compaction_checkpoint_message(message: &Message) -> bool { |
| 331 | let [ |
| 332 | ContentBlock::Text { |
| 333 | text, |
| 334 | cache_control: None, |
| 335 | }, |
| 336 | ContentBlock::Text { |
| 337 | text: provenance, |
| 338 | cache_control: None, |
| 339 | }, |
| 340 | ] = message.content.as_slice() |
| 341 | else { |
| 342 | return false; |
| 343 | }; |
| 344 | message.role == Role::User |
| 345 | && text.starts_with(SUMMARY_HEADER) |
| 346 | && provenance == COMPACTION_CHECKPOINT_PROVENANCE |
| 347 | } |
| 348 | |
| 349 | /// Keep the checkpoint at its original historical boundary on session load. |
| 350 | /// Later user turns must remain after the saved compaction boundary. |
| 351 | pub(crate) fn restore_compaction_checkpoint( |
| 352 | mut messages: Vec<Message>, |
| 353 | checkpoint: Option<&SystemPrompt>, |
| 354 | ) -> Vec<Message> { |
| 355 | let typed_position = messages |
| 356 | .iter() |
| 357 | .position(is_wire_compaction_checkpoint_message); |
| 358 | let checkpoint_index = if let Some(index) = typed_position { |
| 359 | messages.retain(|message| !is_wire_compaction_checkpoint_message(message)); |
| 360 | index |
| 361 | } else { |
| 362 | // Legacy sessions have no independent provenance. Preserve their |
| 363 | // existing broad cleanup behavior; identical user text is ambiguous. |
| 364 | messages.retain(|message| !is_compaction_checkpoint_message(message)); |
| 365 | messages.len() |
| 366 | }; |
| 367 | if let Some(checkpoint) = checkpoint { |
| 368 | messages.insert( |
| 369 | checkpoint_index.min(messages.len()), |
| 370 | compaction_checkpoint_message(checkpoint), |
| 371 | ); |
| 372 | } |
| 373 | messages |
| 374 | } |
| 375 | |
| 376 | pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize { |
| 377 | message |
| 378 | .content |
| 379 | .iter() |
| 380 | .map(|c| match c { |
| 381 | ContentBlock::Text { text, .. } => text.len() / 4, |
| 382 | // Replay-capable routes retain reasoning even on text-only |
| 383 | // assistant messages and across later user turns. |
| 384 | ContentBlock::Thinking { thinking, .. } if include_thinking => thinking.len() / 4, |
| 385 | ContentBlock::Thinking { .. } => 0, |
| 386 | ContentBlock::ToolUse { input, .. } => serde_json::to_string(input) |
| 387 | .map(|s| s.len() / 4) |
| 388 | .unwrap_or(100), |
| 389 | ContentBlock::ToolResult { |
| 390 | content, |
| 391 | content_blocks, |
| 392 | .. |
| 393 | } => { |
| 394 | let images = content_blocks.as_ref().map_or(0, |blocks| { |
| 395 | blocks |
| 396 | .iter() |
| 397 | .filter(|block| { |
| 398 | block.get("type").and_then(serde_json::Value::as_str) == Some("image") |
| 399 | }) |
| 400 | .count() |
| 401 | }); |
| 402 | content.len() / 4 + images * IMAGE_TOKEN_ESTIMATE |
| 403 | } |
| 404 | // An inline image is real input the model pays for; estimating it |
| 405 | // at 0 undercounts the budget and risks overflow in image-heavy |
| 406 | // sessions. Use a conservative flat per-image estimate (vision |
| 407 | // tiles are typically ~1k tokens); erring high compacts slightly |
| 408 | // early rather than overflowing. |
| 409 | ContentBlock::ImageUrl { .. } => IMAGE_TOKEN_ESTIMATE, |
| 410 | ContentBlock::ServerToolUse { input, .. } => input.to_string().len() / 4, |
| 411 | ContentBlock::ToolSearchToolResult { content, .. } |
| 412 | | ContentBlock::CodeExecutionToolResult { content, .. } => { |
| 413 | content.to_string().len() / 4 |
| 414 | } |
| 415 | }) |
| 416 | .sum::<usize>() |
| 417 | } |
| 418 | |
| 419 | /// Conservative flat token estimate for an inline image (`ContentBlock::ImageUrl`). |
| 420 | /// Vision models bill images by resized tile count; ~1k tokens is a safe |
| 421 | /// mid-range estimate that keeps the compaction trigger from under-reading an |
| 422 | /// image-heavy session. |
| 423 | const IMAGE_TOKEN_ESTIMATE: usize = 1000; |
| 424 | |
| 425 | pub fn estimate_tokens(messages: &[Message]) -> usize { |
| 426 | // Rough estimate: ~4 bytes per token. Count every retained reasoning |
| 427 | // block: DeepSeek/Kimi replay text-only assistant reasoning too. This |
| 428 | // route-neutral estimate cannot assume a transport will omit it. |
| 429 | messages |
| 430 | .iter() |
| 431 | .map(|message| estimate_tokens_for_message(message, true)) |
| 432 | .sum() |
| 433 | } |
| 434 | |
| 435 | pub(crate) fn message_has_tool_use(message: &Message) -> bool { |
| 436 | message |
| 437 | .content |
| 438 | .iter() |
| 439 | .any(|block| matches!(block, ContentBlock::ToolUse { .. })) |
| 440 | } |
| 441 | |
| 442 | pub(crate) fn estimate_text_tokens_conservative(text: &str) -> usize { |
| 443 | text.chars().count().div_ceil(3) |
| 444 | } |
| 445 | |
| 446 | fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize { |
| 447 | match system { |
| 448 | Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text), |
| 449 | Some(SystemPrompt::Blocks(blocks)) => blocks |
| 450 | .iter() |
| 451 | .map(|block| estimate_text_tokens_conservative(&block.text)) |
| 452 | .sum(), |
| 453 | None => 0, |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | /// Conservative estimate for full request input tokens (messages + system + framing). |
| 458 | #[must_use] |
| 459 | pub fn estimate_input_tokens_conservative( |
| 460 | messages: &[Message], |
| 461 | system: Option<&SystemPrompt>, |
| 462 | ) -> usize { |
| 463 | let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2); |
| 464 | let system_tokens = estimate_system_tokens_conservative(system); |
| 465 | let framing_overhead = messages.len().saturating_mul(12).saturating_add(48); |
| 466 | message_tokens |
| 467 | .saturating_add(system_tokens) |
| 468 | .saturating_add(framing_overhead) |
| 469 | } |
| 470 | |
| 471 | /// Best-effort estimate of real request input tokens, without the 1.5× |
| 472 | /// safety inflation used by overflow math. |
| 473 | /// |
| 474 | /// Compaction *pressure* compares against a threshold whose percentage means |
| 475 | /// "fraction of the context window" on the user-facing meter. Feeding the |
| 476 | /// inflated overflow estimate into that comparison made an 80% setting fire |
| 477 | /// at roughly half the real usage. Overflow protection keeps its inflated |
| 478 | /// estimator; the pressure trigger uses this one, preferring provider-billed |
| 479 | /// prompt tokens when the caller has them. |
| 480 | #[must_use] |
| 481 | pub fn estimate_input_tokens_for_pressure( |
| 482 | messages: &[Message], |
| 483 | system: Option<&SystemPrompt>, |
| 484 | ) -> usize { |
| 485 | let message_tokens = estimate_tokens(messages); |
| 486 | let system_tokens = estimate_system_tokens_conservative(system); |
| 487 | let framing_overhead = messages.len().saturating_mul(12).saturating_add(48); |
| 488 | message_tokens |
| 489 | .saturating_add(system_tokens) |
| 490 | .saturating_add(framing_overhead) |
| 491 | } |
| 492 | |
| 493 | fn estimate_retained_floor_conservative( |
| 494 | messages: &[Message], |
| 495 | system_prompt: Option<&SystemPrompt>, |
| 496 | prepared: &PreparedCompactionEnvelope, |
| 497 | ) -> usize { |
| 498 | let config = &prepared.config; |
| 499 | let retained = last_round::replacement_messages(messages, config.retained_user_message_tokens); |
| 500 | let retained_tokens = estimate_tokens(&retained).saturating_mul(3).div_ceil(2); |
| 501 | let framing = retained.len().saturating_mul(12).saturating_add(48); |
| 502 | let anchors = user_anchors_section(config.workspace.as_deref()); |
| 503 | let summary_scaffolding_tokens = |
| 504 | estimate_text_tokens_conservative(&build_compaction_summary_block_text("", &anchors)); |
| 505 | |
| 506 | // Post-compaction the committed summary is REPLACED, not stacked, so prior |
| 507 | // summary blocks must not inflate the floor. Count only the exact installed |
| 508 | // scaffolding here; the model owns the concise summary length, just as it |
| 509 | // owns the answer length on an ordinary turn. |
| 510 | let retained_system_prompt = strip_compaction_summaries(system_prompt); |
| 511 | retained_tokens |
| 512 | .saturating_add(estimate_system_tokens_conservative( |
| 513 | retained_system_prompt.as_ref(), |
| 514 | )) |
| 515 | .saturating_add(framing) |
| 516 | .saturating_add(summary_scaffolding_tokens) |
| 517 | } |
| 518 | |
| 519 | /// Whether the current canonical request has reached the configured automatic |
| 520 | /// compaction pressure. This deliberately excludes eligibility/reclaimability: |
| 521 | /// local tool-result pruning uses it to decide when pressure has actually |
| 522 | /// cleared, even if the remaining transcript cannot support an LLM summary. |
| 523 | #[must_use] |
| 524 | pub fn compaction_pressure_reached( |
| 525 | messages: &[Message], |
| 526 | system_prompt: Option<&SystemPrompt>, |
| 527 | config: &CompactionConfig, |
| 528 | ) -> bool { |
| 529 | compaction_pressure_reached_with_billed(messages, system_prompt, config, None) |
| 530 | } |
| 531 | |
| 532 | /// Pressure check that additionally honors provider-billed prompt tokens. |
| 533 | /// |
| 534 | /// Billed usage is the ground truth for how large the context actually is; |
| 535 | /// the estimator undercounts non-ASCII text and cannot see server-side |
| 536 | /// framing. Whichever signal is higher decides, so an undercounting estimate |
| 537 | /// cannot hide pressure the provider already billed for. Callers must only |
| 538 | /// pass a billed count that describes the message list being checked — |
| 539 | /// post-prune re-checks pass `None` and fall back to the estimate. |
| 540 | #[must_use] |
| 541 | pub fn compaction_pressure_reached_with_billed( |
| 542 | messages: &[Message], |
| 543 | system_prompt: Option<&SystemPrompt>, |
| 544 | config: &CompactionConfig, |
| 545 | billed_input_tokens: Option<u64>, |
| 546 | ) -> bool { |
| 547 | if !config.enabled { |
| 548 | return false; |
| 549 | } |
| 550 | let billed = billed_input_tokens |
| 551 | .and_then(|tokens| usize::try_from(tokens).ok()) |
| 552 | .unwrap_or(0); |
| 553 | // Billing alone proving pressure short-circuits the walk (#perf-r5): |
| 554 | // `estimated.max(billed) >= threshold` is unconditionally true when |
| 555 | // `billed >= threshold`, so estimating cannot change the answer and the |
| 556 | // O(transcript) pass is skipped. Over-pressure sessions pay this check |
| 557 | // multiple times per step (pressure gate + decision re-check). |
| 558 | if billed >= config.token_threshold { |
| 559 | return true; |
| 560 | } |
| 561 | let estimated = estimate_input_tokens_for_pressure(messages, system_prompt); |
| 562 | estimated.max(billed) >= config.token_threshold |
| 563 | } |
| 564 | |
| 565 | /// Estimate-only eligibility check ([`should_compact_with_billed`] with no |
| 566 | /// billed tokens): used by the request preview, where no provider bill exists. |
| 567 | pub fn should_compact( |
| 568 | messages: &[Message], |
| 569 | system_prompt: Option<&SystemPrompt>, |
| 570 | prepared: &PreparedCompactionEnvelope, |
| 571 | ) -> bool { |
| 572 | should_compact_with_billed(messages, system_prompt, prepared, None) |
| 573 | } |
| 574 | |
| 575 | /// Why an over-pressure context still did not start an automatic pass. |
| 576 | /// |
| 577 | /// A refusal is not a bug by itself — each guard exists for a reason — but a |
| 578 | /// silent refusal is: the user watches a full context meter while |
| 579 | /// auto-compaction appears broken (#5577). Callers surface these. |
| 580 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 581 | pub enum CompactionRefusal { |
| 582 | /// Too few messages for a summary pass to mean anything. |
| 583 | TooFewMessages { count: usize }, |
| 584 | /// The conservative retained floor (system prompt + kept messages + |
| 585 | /// summary allowance) cannot get below the trigger, so a pass would |
| 586 | /// recur on every step without relieving pressure. |
| 587 | RetainedFloor { floor: usize, threshold: usize }, |
| 588 | } |
| 589 | |
| 590 | /// Outcome of the automatic-compaction eligibility check. |
| 591 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 592 | pub enum CompactionDecision { |
| 593 | /// Disabled, or pressure not reached: nothing to do, nothing to explain. |
| 594 | NotNeeded, |
| 595 | /// Start a pass. |
| 596 | Compact, |
| 597 | /// Pressure is real but a guard declined; the reason names the guard. |
| 598 | Refused(CompactionRefusal), |
| 599 | } |
| 600 | |
| 601 | /// Eligibility check that honors provider-billed prompt tokens for the |
| 602 | /// pressure gate, mirroring [`compaction_pressure_reached_with_billed`]. |
| 603 | pub fn should_compact_with_billed( |
| 604 | messages: &[Message], |
| 605 | system_prompt: Option<&SystemPrompt>, |
| 606 | prepared: &PreparedCompactionEnvelope, |
| 607 | billed_input_tokens: Option<u64>, |
| 608 | ) -> bool { |
| 609 | matches!( |
| 610 | compaction_decision_with_billed(messages, system_prompt, prepared, billed_input_tokens), |
| 611 | CompactionDecision::Compact |
| 612 | ) |
| 613 | } |
| 614 | |
| 615 | /// Full eligibility decision, including *why* an over-pressure context was |
| 616 | /// refused, so hosts can tell the user instead of silently holding. |
| 617 | #[must_use] |
| 618 | pub fn compaction_decision_with_billed( |
| 619 | messages: &[Message], |
| 620 | system_prompt: Option<&SystemPrompt>, |
| 621 | prepared: &PreparedCompactionEnvelope, |
| 622 | billed_input_tokens: Option<u64>, |
| 623 | ) -> CompactionDecision { |
| 624 | let config = &prepared.config; |
| 625 | if !config.enabled { |
| 626 | return CompactionDecision::NotNeeded; |
| 627 | } |
| 628 | // Pressure gate + prune projection share one estimate (#perf-r5): both |
| 629 | // consume `estimate_input_tokens_for_pressure` over the same |
| 630 | // `(messages, system_prompt)`, a pure function, so it is computed at |
| 631 | // most once. `billed >= threshold` proves pressure without estimating |
| 632 | // (max is unconditionally >= threshold then); the estimate is deferred |
| 633 | // until something actually needs it — the prune projection below — so |
| 634 | // the billed-corner still reaches the TooFew and RetainedFloor guards |
| 635 | // unchanged, and skips the walk entirely when no prune candidates exist. |
| 636 | let billed = billed_input_tokens |
| 637 | .and_then(|tokens| usize::try_from(tokens).ok()) |
| 638 | .unwrap_or(0); |
| 639 | let estimated: Option<usize> = if billed < config.token_threshold { |
| 640 | let estimate = estimate_input_tokens_for_pressure(messages, system_prompt); |
| 641 | if estimate.max(billed) < config.token_threshold { |
| 642 | return CompactionDecision::NotNeeded; |
| 643 | } |
| 644 | Some(estimate) |
| 645 | } else { |
| 646 | None |
| 647 | }; |
| 648 | |
| 649 | // The execution path mechanically prunes old verbose tool results before |
| 650 | // asking the model for a summary. Local pruning alone may be enough to |
| 651 | // clear pressure even when the transcript is too small for an LLM pass. |
| 652 | // Project that outcome from the measured plan — per-block deltas use the |
| 653 | // estimator's own arithmetic, so this equals re-estimating a pruned copy |
| 654 | // without cloning a multi-megabyte transcript on every step. |
| 655 | let prune_plan = plan_tool_result_prunes(messages, KEEP_RECENT_MESSAGES); |
| 656 | if !prune_plan.is_empty() { |
| 657 | let estimate = match estimated { |
| 658 | Some(value) => value, |
| 659 | None => estimate_input_tokens_for_pressure(messages, system_prompt), |
| 660 | }; |
| 661 | let reclaimed_tokens: usize = prune_plan.iter().map(PlannedPrune::tokens_reclaimed).sum(); |
| 662 | let projected = estimate.saturating_sub(reclaimed_tokens); |
| 663 | if projected < config.token_threshold.saturating_mul(4) / 5 { |
| 664 | return CompactionDecision::Compact; |
| 665 | } |
| 666 | } |
| 667 | |
| 668 | if messages.len() < MIN_SUMMARIZE_MESSAGES { |
| 669 | return CompactionDecision::Refused(CompactionRefusal::TooFewMessages { |
| 670 | count: messages.len(), |
| 671 | }); |
| 672 | } |
| 673 | |
| 674 | // Reclaimability guard: do not start a pass whose replacement request |
| 675 | // (system prompt + retained user messages + summary allowance) |
| 676 | // cannot get below the trigger, or a large stable prefix would cause |
| 677 | // auto-compaction on every tool step. |
| 678 | let floor = estimate_retained_floor_conservative(messages, system_prompt, prepared); |
| 679 | if floor >= config.token_threshold { |
| 680 | return CompactionDecision::Refused(CompactionRefusal::RetainedFloor { |
| 681 | floor, |
| 682 | threshold: config.token_threshold, |
| 683 | }); |
| 684 | } |
| 685 | CompactionDecision::Compact |
| 686 | } |
| 687 | |
| 688 | fn truncate_chars(text: &str, max_chars: usize) -> &str { |
| 689 | if max_chars == 0 { |
| 690 | return ""; |
| 691 | } |
| 692 | match text.char_indices().nth(max_chars) { |
| 693 | Some((idx, _)) => &text[..idx], |
| 694 | None => text, |
| 695 | } |
| 696 | } |
| 697 | |
| 698 | fn tail_chars(text: &str, max_chars: usize) -> String { |
| 699 | if max_chars == 0 { |
| 700 | return String::new(); |
| 701 | } |
| 702 | let total_chars = text.chars().count(); |
| 703 | if total_chars <= max_chars { |
| 704 | return text.to_string(); |
| 705 | } |
| 706 | let start_char = total_chars.saturating_sub(max_chars); |
| 707 | let start_idx = text |
| 708 | .char_indices() |
| 709 | .nth(start_char) |
| 710 | .map_or(0, |(idx, _)| idx); |
| 711 | text[start_idx..].to_string() |
| 712 | } |
| 713 | |
| 714 | #[derive(Debug, Clone)] |
| 715 | struct ToolUseInfo { |
| 716 | name: String, |
| 717 | key: String, |
| 718 | args_preview: String, |
| 719 | } |
| 720 | |
| 721 | fn tool_use_key(name: &str, input: &serde_json::Value) -> String { |
| 722 | format!( |
| 723 | "{name}:{}", |
| 724 | serde_json::to_string(input).unwrap_or_else(|_| input.to_string()) |
| 725 | ) |
| 726 | } |
| 727 | |
| 728 | fn tool_args_preview(input: &serde_json::Value) -> String { |
| 729 | let redacted = codewhale_config::persistence::redact_json_secrets(input); |
| 730 | let raw = serde_json::to_string(&redacted).unwrap_or_else(|_| redacted.to_string()); |
| 731 | truncate_chars(&raw, 120).to_string() |
| 732 | } |
| 733 | |
| 734 | fn collect_tool_uses(messages: &[Message]) -> HashMap<String, ToolUseInfo> { |
| 735 | let mut tool_uses = HashMap::new(); |
| 736 | for message in messages { |
| 737 | for block in &message.content { |
| 738 | if let ContentBlock::ToolUse { |
| 739 | id, name, input, .. |
| 740 | } = block |
| 741 | { |
| 742 | tool_uses.insert( |
| 743 | id.clone(), |
| 744 | ToolUseInfo { |
| 745 | name: name.clone(), |
| 746 | key: tool_use_key(name, input), |
| 747 | args_preview: tool_args_preview(input), |
| 748 | }, |
| 749 | ); |
| 750 | } |
| 751 | } |
| 752 | } |
| 753 | tool_uses |
| 754 | } |
| 755 | |
| 756 | struct ToolResultPruneCandidate { |
| 757 | message_idx: usize, |
| 758 | block_idx: usize, |
| 759 | key: String, |
| 760 | tool_name: String, |
| 761 | args_preview: String, |
| 762 | original_len: usize, |
| 763 | } |
| 764 | |
| 765 | fn tool_result_content_blocks_len(content_blocks: Option<&[serde_json::Value]>) -> usize { |
| 766 | content_blocks |
| 767 | .and_then(|blocks| serde_json::to_vec(blocks).ok()) |
| 768 | .map_or(0, |bytes| bytes.len()) |
| 769 | } |
| 770 | |
| 771 | #[cfg(test)] |
| 772 | fn prune_tool_results(messages: &mut [Message], protected_window: usize) -> usize { |
| 773 | prune_tool_results_until(messages, protected_window, |_, _| false) |
| 774 | } |
| 775 | |
| 776 | /// Mechanically prune old verbose tool results before paying for an LLM summary. |
| 777 | /// |
| 778 | /// The most recent `protected_window` messages stay byte-for-byte intact. Older |
| 779 | /// duplicate tool results keep the freshest full body and replace earlier |
| 780 | /// copies with one-line summaries; non-duplicate old results are summarized only |
| 781 | /// when they exceed the normal summary snippet size. |
| 782 | fn prune_tool_results_until<F>( |
| 783 | messages: &mut [Message], |
| 784 | protected_window: usize, |
| 785 | mut should_stop: F, |
| 786 | ) -> usize |
| 787 | where |
| 788 | F: FnMut(&[Message], usize) -> bool, |
| 789 | { |
| 790 | let plan = plan_tool_result_prunes(messages, protected_window); |
| 791 | let mut bytes_saved = 0usize; |
| 792 | for planned in plan { |
| 793 | if let ContentBlock::ToolResult { |
| 794 | content, |
| 795 | content_blocks, |
| 796 | .. |
| 797 | } = &mut messages[planned.message_idx].content[planned.block_idx] |
| 798 | { |
| 799 | bytes_saved = bytes_saved.saturating_add(planned.bytes_reclaimed()); |
| 800 | *content = planned.summary; |
| 801 | *content_blocks = None; |
| 802 | |
| 803 | if should_stop(messages, bytes_saved) { |
| 804 | break; |
| 805 | } |
| 806 | } |
| 807 | } |
| 808 | bytes_saved |
| 809 | } |
| 810 | |
| 811 | /// One tool-result replacement the pruner has decided on, measured up front |
| 812 | /// so eligibility checks can project the outcome without cloning a |
| 813 | /// multi-megabyte transcript ([`compaction_decision_with_billed`] used to |
| 814 | /// copy the entire message list every over-pressure step just to ask "would |
| 815 | /// pruning be enough?"). |
| 816 | struct PlannedPrune { |
| 817 | message_idx: usize, |
| 818 | block_idx: usize, |
| 819 | summary: String, |
| 820 | content_len: usize, |
| 821 | blocks_len: usize, |
| 822 | image_count: usize, |
| 823 | } |
| 824 | |
| 825 | impl PlannedPrune { |
| 826 | /// Byte reduction this replacement realizes, matching the pruner's |
| 827 | /// accounting exactly. |
| 828 | fn bytes_reclaimed(&self) -> usize { |
| 829 | self.content_len |
| 830 | .saturating_sub(self.summary.len()) |
| 831 | .saturating_add(self.blocks_len) |
| 832 | } |
| 833 | |
| 834 | /// Estimator-token reduction, using the same per-block arithmetic as |
| 835 | /// [`estimate_tokens_for_message`] so a projection built from these |
| 836 | /// deltas equals re-estimating the pruned transcript. |
| 837 | fn tokens_reclaimed(&self) -> usize { |
| 838 | let before = self.content_len / 4 + self.image_count * IMAGE_TOKEN_ESTIMATE; |
| 839 | let after = self.summary.len() / 4; |
| 840 | before.saturating_sub(after) |
| 841 | } |
| 842 | } |
| 843 | |
| 844 | /// Decide, without mutating anything, which old tool results pruning would |
| 845 | /// replace. The most recent `protected_window` messages stay untouched; older |
| 846 | /// duplicate results keep the freshest full body; non-duplicates are replaced |
| 847 | /// only when they exceed the summary snippet size. |
| 848 | fn plan_tool_result_prunes(messages: &[Message], protected_window: usize) -> Vec<PlannedPrune> { |
| 849 | let cutoff = messages.len().saturating_sub(protected_window); |
| 850 | if cutoff == 0 { |
| 851 | return Vec::new(); |
| 852 | } |
| 853 | |
| 854 | let tool_uses = collect_tool_uses(messages); |
| 855 | let mut candidates = Vec::new(); |
| 856 | let mut latest_by_key: HashMap<String, usize> = HashMap::new(); |
| 857 | let mut count_by_key: HashMap<String, usize> = HashMap::new(); |
| 858 | |
| 859 | for (message_idx, message) in messages.iter().take(cutoff).enumerate() { |
| 860 | for (block_idx, block) in message.content.iter().enumerate() { |
| 861 | let ContentBlock::ToolResult { |
| 862 | tool_use_id, |
| 863 | content, |
| 864 | content_blocks, |
| 865 | .. |
| 866 | } = block |
| 867 | else { |
| 868 | continue; |
| 869 | }; |
| 870 | let Some(info) = tool_uses.get(tool_use_id) else { |
| 871 | continue; |
| 872 | }; |
| 873 | latest_by_key.insert(info.key.clone(), message_idx); |
| 874 | *count_by_key.entry(info.key.clone()).or_insert(0) += 1; |
| 875 | candidates.push(ToolResultPruneCandidate { |
| 876 | message_idx, |
| 877 | block_idx, |
| 878 | key: info.key.clone(), |
| 879 | tool_name: info.name.clone(), |
| 880 | args_preview: info.args_preview.clone(), |
| 881 | original_len: content |
| 882 | .len() |
| 883 | .saturating_add(tool_result_content_blocks_len(content_blocks.as_deref())), |
| 884 | }); |
| 885 | } |
| 886 | } |
| 887 | |
| 888 | // The maps above are fully populated before planning completes, so the order |
| 889 | // below only changes which message bytes are rewritten first. Planning from |
| 890 | // newest to oldest lets the pruner stop as soon as enough bytes were saved, |
| 891 | // preserving the earlier JSON request prefix for byte-level KV caches. |
| 892 | candidates.reverse(); |
| 893 | |
| 894 | let mut plan = Vec::new(); |
| 895 | for candidate in candidates { |
| 896 | let duplicate_count = count_by_key.get(&candidate.key).copied().unwrap_or(0); |
| 897 | let is_latest_duplicate = duplicate_count > 1 |
| 898 | && latest_by_key.get(&candidate.key) == Some(&candidate.message_idx); |
| 899 | if is_latest_duplicate { |
| 900 | continue; |
| 901 | } |
| 902 | if duplicate_count <= 1 && candidate.original_len <= SUMMARY_TOOL_RESULT_SNIPPET_CHARS { |
| 903 | continue; |
| 904 | } |
| 905 | |
| 906 | let summary = format!( |
| 907 | "[{}] tool result pruned ({} bytes; args: {})", |
| 908 | candidate.tool_name, candidate.original_len, candidate.args_preview |
| 909 | ); |
| 910 | if summary.len() >= candidate.original_len { |
| 911 | continue; |
| 912 | } |
| 913 | |
| 914 | let ContentBlock::ToolResult { |
| 915 | content, |
| 916 | content_blocks, |
| 917 | .. |
| 918 | } = &messages[candidate.message_idx].content[candidate.block_idx] |
| 919 | else { |
| 920 | continue; |
| 921 | }; |
| 922 | plan.push(PlannedPrune { |
| 923 | message_idx: candidate.message_idx, |
| 924 | block_idx: candidate.block_idx, |
| 925 | summary, |
| 926 | content_len: content.len(), |
| 927 | blocks_len: tool_result_content_blocks_len(content_blocks.as_deref()), |
| 928 | image_count: content_blocks.as_ref().map_or(0, |blocks| { |
| 929 | blocks |
| 930 | .iter() |
| 931 | .filter(|block| { |
| 932 | block.get("type").and_then(serde_json::Value::as_str) == Some("image") |
| 933 | }) |
| 934 | .count() |
| 935 | }), |
| 936 | }); |
| 937 | } |
| 938 | plan |
| 939 | } |
| 940 | |
| 941 | fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize) -> bool { |
| 942 | let char_count = content.chars().count(); |
| 943 | if char_count <= max_chars { |
| 944 | return false; |
| 945 | } |
| 946 | |
| 947 | let snippet_budget = max_chars.saturating_sub(256).max(1024); |
| 948 | let head_chars = snippet_budget / 2; |
| 949 | let tail_chars_budget = snippet_budget.saturating_sub(head_chars); |
| 950 | let head = truncate_chars(content, head_chars).to_string(); |
| 951 | let tail = tail_chars(content, tail_chars_budget); |
| 952 | *content = |
| 953 | format!("[{label} retained-history truncated from {char_count} chars]\n{head}\n…\n{tail}"); |
| 954 | true |
| 955 | } |
| 956 | |
| 957 | // Retained reasoning is replay protocol even without a signature (DeepSeek |
| 958 | // tool turns). Summarize older exchanges as units; do not rewrite their peers. |
| 959 | fn sanitize_retained_messages(mut messages: Vec<Message>) -> Vec<Message> { |
| 960 | for message in &mut messages { |
| 961 | for block in &mut message.content { |
| 962 | if let ContentBlock::ToolResult { |
| 963 | content, |
| 964 | content_blocks, |
| 965 | .. |
| 966 | } = block |
| 967 | && truncate_retained_block("tool result", content, RETAINED_TOOL_RESULT_MAX_CHARS) |
| 968 | { |
| 969 | *content_blocks = None; |
| 970 | } |
| 971 | } |
| 972 | } |
| 973 | messages |
| 974 | } |
| 975 | |
| 976 | /// Result of a compaction operation with metadata. |
| 977 | #[derive(Debug)] |
| 978 | pub struct CompactionResult { |
| 979 | /// Compacted messages |
| 980 | pub messages: Vec<Message>, |
| 981 | /// Host-persistence copy of the history checkpoint. |
| 982 | pub summary_prompt: Option<SystemPrompt>, |
| 983 | /// Number of retries used before success |
| 984 | pub retries_used: u32, |
| 985 | /// Last-round coverage for inspector receipts. |
| 986 | pub coverage: CompactionCoverage, |
| 987 | } |
| 988 | |
| 989 | /// Classify a compaction LLM failure for the retry / input-ladder policy. |
| 990 | fn classify_compaction_failure(e: &anyhow::Error) -> CompactionFailureKind { |
| 991 | if let Some(error) = llm_error_in_chain(e) { |
| 992 | return match error { |
| 993 | crate::llm_client::LlmError::ContextLengthError(_) => { |
| 994 | CompactionFailureKind::ContextOverflow |
| 995 | } |
| 996 | crate::llm_client::LlmError::QuotaExhausted(_) => CompactionFailureKind::Deterministic, |
| 997 | error if error.is_retryable() => CompactionFailureKind::Transient, |
| 998 | _ => CompactionFailureKind::Deterministic, |
| 999 | }; |
| 1000 | } |
| 1001 | |
| 1002 | let text = e.to_string(); |
| 1003 | if is_context_window_error_message(&text) { |
| 1004 | return CompactionFailureKind::ContextOverflow; |
| 1005 | } |
| 1006 | let category = crate::error_taxonomy::classify_error_message(&text); |
| 1007 | match category { |
| 1008 | crate::error_taxonomy::ErrorCategory::Network |
| 1009 | | crate::error_taxonomy::ErrorCategory::RateLimit |
| 1010 | | crate::error_taxonomy::ErrorCategory::Timeout => CompactionFailureKind::Transient, |
| 1011 | _ => CompactionFailureKind::Deterministic, |
| 1012 | } |
| 1013 | } |
| 1014 | |
| 1015 | fn llm_error_in_chain(error: &anyhow::Error) -> Option<&crate::llm_client::LlmError> { |
| 1016 | error |
| 1017 | .chain() |
| 1018 | .find_map(|cause| cause.downcast_ref::<crate::llm_client::LlmError>()) |
| 1019 | } |
| 1020 | |
| 1021 | /// Record and render a compaction failure as actionable, credential-safe text. |
| 1022 | /// |
| 1023 | /// This classifies only the error supplied by the failed request; it never |
| 1024 | /// infers a cause from later provider failures. Unknown diagnostics stay |
| 1025 | /// visible after central secret/path redaction, and the same safe detail is |
| 1026 | /// written to the runtime log so a transient status message remains auditable. |
| 1027 | #[must_use] |
| 1028 | pub fn report_compaction_failure( |
| 1029 | prefix: &str, |
| 1030 | id: &str, |
| 1031 | auto: bool, |
| 1032 | error: &anyhow::Error, |
| 1033 | ) -> String { |
| 1034 | let raw = error.to_string(); |
| 1035 | let safe_raw = crate::safe_label::safe_error_text(&raw); |
| 1036 | tracing::warn!( |
| 1037 | compaction_id = %id, |
| 1038 | auto, |
| 1039 | error = %safe_raw, |
| 1040 | "context compaction failed" |
| 1041 | ); |
| 1042 | let detail = match llm_error_in_chain(error) { |
| 1043 | Some(crate::llm_client::LlmError::QuotaExhausted(_)) => { |
| 1044 | "provider plan quota exhausted — switch provider/model or renew the provider plan" |
| 1045 | .to_string() |
| 1046 | } |
| 1047 | Some(crate::llm_client::LlmError::RateLimited { .. }) => { |
| 1048 | "provider rate limit blocked compaction — retry after the limit resets or switch provider/model" |
| 1049 | .to_string() |
| 1050 | } |
| 1051 | Some(crate::llm_client::LlmError::AuthenticationError(_)) => { |
| 1052 | "provider authentication failed — sign in or replace the credential, then retry" |
| 1053 | .to_string() |
| 1054 | } |
| 1055 | Some(crate::llm_client::LlmError::AuthorizationError(_)) => { |
| 1056 | "provider authorization rejected compaction — verify account access or switch provider/model" |
| 1057 | .to_string() |
| 1058 | } |
| 1059 | _ => match crate::error_taxonomy::classify_error_message(&raw) { |
| 1060 | crate::error_taxonomy::ErrorCategory::RateLimit => { |
| 1061 | "provider rate limit blocked compaction — retry after the limit resets or switch provider/model" |
| 1062 | .to_string() |
| 1063 | } |
| 1064 | crate::error_taxonomy::ErrorCategory::Authentication => { |
| 1065 | "provider authentication failed — sign in or replace the credential, then retry" |
| 1066 | .to_string() |
| 1067 | } |
| 1068 | crate::error_taxonomy::ErrorCategory::Authorization => { |
| 1069 | "provider authorization rejected compaction — verify account access or switch provider/model" |
| 1070 | .to_string() |
| 1071 | } |
| 1072 | _ => safe_raw, |
| 1073 | }, |
| 1074 | }; |
| 1075 | |
| 1076 | format!("{prefix}: {detail}") |
| 1077 | } |
| 1078 | |
| 1079 | /// Check if an error is transient and worth retrying. Categories that map to |
| 1080 | /// transient retry: Network, RateLimit, Timeout. Context overflow is *not* |
| 1081 | /// transient — it needs a smaller input (ladder), not the same payload. |
| 1082 | fn is_transient_error(e: &anyhow::Error) -> bool { |
| 1083 | classify_compaction_failure(e).is_transient() |
| 1084 | } |
| 1085 | |
| 1086 | fn is_context_window_error_message(text: &str) -> bool { |
| 1087 | let lower = text.to_lowercase(); |
| 1088 | lower.contains("too long for this model") |
| 1089 | || lower.contains("prompt is too long") |
| 1090 | || lower.contains("maximum prompt length") |
| 1091 | || lower.contains("maximum context length") |
| 1092 | || lower.contains("context_length_exceeded") |
| 1093 | || lower.contains("context window") |
| 1094 | || (lower.contains("context") |
| 1095 | && (lower.contains("token") || lower.contains("too long") || lower.contains("maximum"))) |
| 1096 | } |
| 1097 | |
| 1098 | /// Compact messages with retry and backoff for transient errors. |
| 1099 | /// |
| 1100 | /// This function wraps `compact_messages` with retry logic to handle |
| 1101 | /// transient network errors and rate limits. It uses exponential backoff |
| 1102 | /// with delays of 1s, 2s, 4s between retries. |
| 1103 | /// |
| 1104 | /// # Safety |
| 1105 | /// - Never panics |
| 1106 | /// - Never corrupts the original messages (returns error instead) |
| 1107 | /// - Only retries on transient errors (network, rate limit, etc.) |
| 1108 | /// |
| 1109 | /// `invocation_usage` retains every decoded response, including rejected |
| 1110 | /// summaries, across retries and cancellation of this future. |
| 1111 | pub async fn compact_messages_safe( |
| 1112 | client: &dyn ModelClient, |
| 1113 | messages: &[Message], |
| 1114 | system_prompt: Option<&SystemPrompt>, |
| 1115 | prepared: &PreparedCompactionEnvelope, |
| 1116 | invocation_usage: &mut Usage, |
| 1117 | ) -> Result<CompactionResult> { |
| 1118 | const MAX_RETRIES: u32 = 3; |
| 1119 | const BASE_DELAY_MS: u64 = 1000; |
| 1120 | |
| 1121 | // Persist the complete pre-compaction history before any pruning or provider |
| 1122 | // call. Failure leaves the original context intact. The model-authored |
| 1123 | // handoff is saved separately before replacement is returned to the engine. |
| 1124 | let checkpoint_id = uuid::Uuid::new_v4().to_string(); |
| 1125 | if let Some(session_id) = prepared.session_id.as_deref() { |
| 1126 | let bytes = serde_json::to_vec(&codewhale_config::persistence::redact_json_secrets( |
| 1127 | &serde_json::to_value(messages)?, |
| 1128 | ))?; |
| 1129 | crate::artifacts::write_session_relative_immutable( |
| 1130 | session_id, |
| 1131 | &std::path::PathBuf::from("artifacts") |
| 1132 | .join(format!("context-transfer-{checkpoint_id}.json")), |
| 1133 | &bytes, |
| 1134 | )?; |
| 1135 | } |
| 1136 | |
| 1137 | let config = &prepared.config; |
| 1138 | let was_over_threshold = compaction_pressure_reached(messages, system_prompt, config); |
| 1139 | // Leave room for useful work after a local prune. Clearing the trigger |
| 1140 | // by a few tokens caused another prefix rewrite on the next tool result. |
| 1141 | let prune_target = config.token_threshold.saturating_mul(4) / 5; |
| 1142 | let mut pruned_messages = messages.to_vec(); |
| 1143 | let mut now_under_threshold = false; |
| 1144 | let mut next_stop_check_bytes = 0usize; |
| 1145 | let pruned_bytes = prune_tool_results_until( |
| 1146 | &mut pruned_messages, |
| 1147 | KEEP_RECENT_MESSAGES, |
| 1148 | |candidate_messages, bytes_saved| { |
| 1149 | if !was_over_threshold || bytes_saved < next_stop_check_bytes { |
| 1150 | return false; |
| 1151 | } |
| 1152 | |
| 1153 | // Stop at the first suffix-side prune check that clears the target. |
| 1154 | // The check itself is a full compaction-plan pass, so bound it by saved |
| 1155 | // bytes instead of running it after every candidate in huge sessions. |
| 1156 | next_stop_check_bytes = bytes_saved.saturating_add(TOOL_PRUNE_STOP_CHECK_BYTES); |
| 1157 | now_under_threshold = |
| 1158 | estimate_input_tokens_for_pressure(candidate_messages, system_prompt) |
| 1159 | < prune_target; |
| 1160 | now_under_threshold |
| 1161 | }, |
| 1162 | ); |
| 1163 | if was_over_threshold && pruned_bytes > 0 && !now_under_threshold { |
| 1164 | // The throttled in-loop check may skip the exact candidate that clears the |
| 1165 | // budget. Do one final pass so a successful local prune still avoids LLM compaction. |
| 1166 | now_under_threshold = |
| 1167 | estimate_input_tokens_for_pressure(&pruned_messages, system_prompt) < prune_target; |
| 1168 | } |
| 1169 | |
| 1170 | if pruned_bytes > 0 { |
| 1171 | logging::info(format!( |
| 1172 | "Local tool-result prune saved {pruned_bytes} bytes before LLM compaction" |
| 1173 | )); |
| 1174 | if was_over_threshold && now_under_threshold { |
| 1175 | let kept = sanitize_retained_messages(pruned_messages); |
| 1176 | last_round::validate_last_round_coverage(messages, &kept)?; |
| 1177 | let coverage = last_round::measure_coverage( |
| 1178 | messages, |
| 1179 | &kept, |
| 1180 | CompactionPath::PruneOnly, |
| 1181 | pinned_anchors_text(config.workspace.as_deref()) |
| 1182 | .map(|text| text.chars().count()) |
| 1183 | .unwrap_or(0), |
| 1184 | ); |
| 1185 | return Ok(CompactionResult { |
| 1186 | messages: kept, |
| 1187 | summary_prompt: None, |
| 1188 | retries_used: 0, |
| 1189 | coverage, |
| 1190 | }); |
| 1191 | } |
| 1192 | } |
| 1193 | |
| 1194 | let mut last_error: Option<anyhow::Error> = None; |
| 1195 | let mut quality_retries = 0u32; |
| 1196 | |
| 1197 | for attempt in 0..MAX_RETRIES { |
| 1198 | if attempt > 0 { |
| 1199 | // Exponential backoff: 1s, 2s, 4s |
| 1200 | let delay = Duration::from_millis(BASE_DELAY_MS * (1 << (attempt - 1))); |
| 1201 | tokio::time::sleep(delay).await; |
| 1202 | } |
| 1203 | |
| 1204 | match compact_messages_with_metadata( |
| 1205 | client, |
| 1206 | // If a local prune cannot clear pressure, summarize the original |
| 1207 | // evidence. Pruning first both erased facts the handoff needs and |
| 1208 | // invalidated the cached history prefix for the summary request. |
| 1209 | messages, |
| 1210 | config, |
| 1211 | system_prompt, |
| 1212 | prepared.tools.as_deref(), |
| 1213 | &mut quality_retries, |
| 1214 | invocation_usage, |
| 1215 | ) |
| 1216 | .await |
| 1217 | { |
| 1218 | Ok((msgs, prompt, mut coverage)) => { |
| 1219 | let kept = sanitize_retained_messages(msgs); |
| 1220 | last_round::validate_last_round_coverage(messages, &kept)?; |
| 1221 | if config.enabled |
| 1222 | && compaction_pressure_reached(messages, system_prompt, config) |
| 1223 | && estimate_input_tokens_for_pressure(&kept, system_prompt) |
| 1224 | >= estimate_input_tokens_for_pressure(messages, system_prompt) |
| 1225 | { |
| 1226 | anyhow::bail!( |
| 1227 | "Compaction did not reduce context; original conversation was preserved." |
| 1228 | ); |
| 1229 | } |
| 1230 | let keep: CompactionKeep = inspect_compaction_keep(&kept); |
| 1231 | coverage.last_round_messages = keep.last_round_messages; |
| 1232 | coverage.last_round_tool_results = keep.last_round_tool_results; |
| 1233 | coverage.last_round_assistant = keep.last_round_assistant; |
| 1234 | if let (Some(session_id), Some(summary)) = |
| 1235 | (prepared.session_id.as_deref(), prompt.as_ref()) |
| 1236 | { |
| 1237 | let text = summary_prompt_text(summary); |
| 1238 | let redacted = codewhale_config::persistence::redact_json_secrets( |
| 1239 | &serde_json::Value::String(text), |
| 1240 | ); |
| 1241 | crate::artifacts::write_session_relative_immutable( |
| 1242 | session_id, |
| 1243 | &std::path::PathBuf::from("artifacts") |
| 1244 | .join(format!("context-transfer-{checkpoint_id}.md")), |
| 1245 | redacted.as_str().unwrap_or_default().as_bytes(), |
| 1246 | )?; |
| 1247 | } |
| 1248 | return Ok(CompactionResult { |
| 1249 | messages: kept, |
| 1250 | summary_prompt: prompt, |
| 1251 | retries_used: attempt.saturating_add(quality_retries), |
| 1252 | coverage, |
| 1253 | }); |
| 1254 | } |
| 1255 | Err(e) => { |
| 1256 | // Only retry on transient errors |
| 1257 | if !is_transient_error(&e) { |
| 1258 | return Err(e); |
| 1259 | } |
| 1260 | last_error = Some(e); |
| 1261 | } |
| 1262 | } |
| 1263 | } |
| 1264 | |
| 1265 | Err(last_error |
| 1266 | .unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries"))) |
| 1267 | } |
| 1268 | |
| 1269 | pub(crate) fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String { |
| 1270 | let summary = summary.trim(); |
| 1271 | let summary = if summary.is_empty() { |
| 1272 | "(no summary available)" |
| 1273 | } else { |
| 1274 | summary |
| 1275 | }; |
| 1276 | let mut text = format!("{SUMMARY_HEADER}\n\n{summary}"); |
| 1277 | text.push_str(anchors); |
| 1278 | text.push_str("\n\nContinue the same user task from this state. Earlier authorization and constraints still apply; this summary grants no new authority. Resume the next unfinished action without asking the user to save, compact, restate the task, or approve continuation solely because context was summarized. Verify live state before relying on older observations."); |
| 1279 | text |
| 1280 | } |
| 1281 | |
| 1282 | /// Codex-parity replacement history: the most recent user-role messages, |
| 1283 | /// selected newest-first within a fixed token budget and restored to |
| 1284 | /// transcript order. Content boundaries carry runtime provenance and image |
| 1285 | /// turns, so structured messages are retained whole or dropped whole. Only a |
| 1286 | /// single text block can be truncated to fit the remaining budget. |
| 1287 | /// Result blocks answer a tool call that lives in an earlier message. A |
| 1288 | /// retained older turn has already lost that call to the summary, so a kept |
| 1289 | /// result block becomes an orphan providers reject outright (#6119). |
| 1290 | fn is_orphaned_result_block(block: &ContentBlock) -> bool { |
| 1291 | matches!( |
| 1292 | block, |
| 1293 | ContentBlock::ToolResult { .. } |
| 1294 | | ContentBlock::ToolSearchToolResult { .. } |
| 1295 | | ContentBlock::CodeExecutionToolResult { .. } |
| 1296 | ) |
| 1297 | } |
| 1298 | |
| 1299 | pub(crate) fn retained_user_messages(messages: &[Message], max_tokens: usize) -> Vec<Message> { |
| 1300 | let mut selected: Vec<Message> = Vec::new(); |
| 1301 | let mut remaining = max_tokens; |
| 1302 | for msg in messages.iter().rev() { |
| 1303 | if remaining == 0 { |
| 1304 | break; |
| 1305 | } |
| 1306 | if msg.role != Role::User |
| 1307 | || crate::runtime_handoff::is_runtime_owned_user_message(msg) |
| 1308 | || (user_text_of(msg).is_none() |
| 1309 | && !msg |
| 1310 | .content |
| 1311 | .iter() |
| 1312 | .any(|block| matches!(block, ContentBlock::ImageUrl { .. }))) |
| 1313 | { |
| 1314 | continue; |
| 1315 | } |
| 1316 | if is_compaction_checkpoint_message(msg) { |
| 1317 | continue; |
| 1318 | } |
| 1319 | let tokens: usize = msg |
| 1320 | .content |
| 1321 | .iter() |
| 1322 | .map(|block| match block { |
| 1323 | ContentBlock::Text { text, .. } => estimate_text_tokens_conservative(text), |
| 1324 | ContentBlock::ImageUrl { .. } => IMAGE_TOKEN_ESTIMATE, |
| 1325 | _ => 0, |
| 1326 | }) |
| 1327 | .sum(); |
| 1328 | let mut retained = msg.clone(); |
| 1329 | if tokens <= remaining { |
| 1330 | // Keep the text and images; never a result block whose call was |
| 1331 | // summarized away with the region around it (#6119). |
| 1332 | retained |
| 1333 | .content |
| 1334 | .retain(|block| !is_orphaned_result_block(block)); |
| 1335 | remaining -= tokens; |
| 1336 | } else { |
| 1337 | let [ContentBlock::Text { text, .. }] = retained.content.as_mut_slice() else { |
| 1338 | // Never flatten or partially retain an engine metadata block: |
| 1339 | // that would turn runtime-owned traffic into a user prompt. |
| 1340 | break; |
| 1341 | }; |
| 1342 | *text = truncate_chars(text, remaining.saturating_mul(3)).to_string(); |
| 1343 | remaining = 0; |
| 1344 | } |
| 1345 | selected.push(retained); |
| 1346 | } |
| 1347 | selected.reverse(); |
| 1348 | selected |
| 1349 | } |
| 1350 | |
| 1351 | /// User-pinned facts from `/anchor` (`.codewhale/anchors.md`). These are the |
| 1352 | /// user's own words, re-stated after the summary because the command promises |
| 1353 | /// they survive compaction. |
| 1354 | fn user_anchors_section(workspace: Option<&std::path::Path>) -> String { |
| 1355 | match pinned_anchors_text(workspace) { |
| 1356 | Some(contents) => format!("\n\nUser-pinned anchors (verbatim):\n{contents}"), |
| 1357 | None => String::new(), |
| 1358 | } |
| 1359 | } |
| 1360 | |
| 1361 | #[cfg(test)] |
| 1362 | async fn compact_messages( |
| 1363 | client: &dyn ModelClient, |
| 1364 | messages: &[Message], |
| 1365 | config: &CompactionConfig, |
| 1366 | ) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> { |
| 1367 | let mut quality_retries = 0; |
| 1368 | let mut invocation_usage = Usage::default(); |
| 1369 | let (messages, summary_prompt, _coverage) = compact_messages_with_metadata( |
| 1370 | client, |
| 1371 | messages, |
| 1372 | config, |
| 1373 | None, |
| 1374 | None, |
| 1375 | &mut quality_retries, |
| 1376 | &mut invocation_usage, |
| 1377 | ) |
| 1378 | .await?; |
| 1379 | Ok((messages, summary_prompt, Vec::new())) |
| 1380 | } |
| 1381 | |
| 1382 | async fn compact_messages_with_metadata( |
| 1383 | client: &dyn ModelClient, |
| 1384 | messages: &[Message], |
| 1385 | config: &CompactionConfig, |
| 1386 | system_prompt: Option<&SystemPrompt>, |
| 1387 | tools: Option<&[Tool]>, |
| 1388 | quality_retries: &mut u32, |
| 1389 | invocation_usage: &mut Usage, |
| 1390 | ) -> Result<(Vec<Message>, Option<SystemPrompt>, CompactionCoverage)> { |
| 1391 | if messages.is_empty() { |
| 1392 | return Ok((Vec::new(), None, CompactionCoverage::default())); |
| 1393 | } |
| 1394 | |
| 1395 | let summary = create_summary( |
| 1396 | client, |
| 1397 | messages, |
| 1398 | config, |
| 1399 | system_prompt, |
| 1400 | tools, |
| 1401 | quality_retries, |
| 1402 | invocation_usage, |
| 1403 | ) |
| 1404 | .await?; |
| 1405 | let anchors = user_anchors_section(config.workspace.as_deref()); |
| 1406 | let checkpoint_text = build_compaction_summary_block_text(&summary, &anchors); |
| 1407 | let summary_block = SystemBlock { |
| 1408 | block_type: "text".to_string(), |
| 1409 | text: checkpoint_text.clone(), |
| 1410 | cache_control: config.cache_summary.then(|| CacheControl { |
| 1411 | cache_type: "ephemeral".to_string(), |
| 1412 | }), |
| 1413 | }; |
| 1414 | |
| 1415 | let retained = last_round::build_replacement_history( |
| 1416 | messages, |
| 1417 | &checkpoint_text, |
| 1418 | pinned_anchors_text(config.workspace.as_deref()).as_deref(), |
| 1419 | config.retained_user_message_tokens, |
| 1420 | )?; |
| 1421 | let mut coverage = last_round::measure_coverage( |
| 1422 | messages, |
| 1423 | &retained, |
| 1424 | CompactionPath::Summary, |
| 1425 | pinned_anchors_text(config.workspace.as_deref()) |
| 1426 | .map(|text| text.chars().count()) |
| 1427 | .unwrap_or(0), |
| 1428 | ); |
| 1429 | // Report the tuning actually in force so the receipt shows the operator |
| 1430 | // their knobs took effect (#5956). |
| 1431 | coverage.retained_user_message_tokens = config.retained_user_message_tokens; |
| 1432 | coverage.operator_instructions_applied = |
| 1433 | operator_instructions_section(config.summary_instructions.as_deref()).is_some(); |
| 1434 | Ok(( |
| 1435 | retained, |
| 1436 | Some(SystemPrompt::Blocks(vec![summary_block])), |
| 1437 | coverage, |
| 1438 | )) |
| 1439 | } |
| 1440 | |
| 1441 | /// Delimiters around the operator's standing summarizer instructions. The |
| 1442 | /// summarizer sees a plain user message, so the section must announce itself: |
| 1443 | /// unfenced free text reads as more conversation to summarize. |
| 1444 | const OPERATOR_INSTRUCTIONS_HEADER: &str = "--- Additional instructions from the operator ---"; |
| 1445 | const OPERATOR_INSTRUCTIONS_FOOTER: &str = "--- End of additional instructions ---"; |
| 1446 | |
| 1447 | /// Render `[compaction] summary_instructions` as a delimited prompt suffix. |
| 1448 | /// |
| 1449 | /// `None` (unset, or whitespace-only) produces no section at all, which is |
| 1450 | /// what keeps the default prompt byte-identical to the pre-#5956 constant. |
| 1451 | /// The cap is enforced here rather than at config load so the warning fires |
| 1452 | /// once per compaction pass instead of once per turn. |
| 1453 | fn operator_instructions_section(instructions: Option<&str>) -> Option<String> { |
| 1454 | let text = instructions |
| 1455 | .map(str::trim) |
| 1456 | .filter(|text| !text.is_empty())?; |
| 1457 | let max_chars = crate::config::COMPACTION_SUMMARY_INSTRUCTIONS_MAX_CHARS; |
| 1458 | let text = if text.chars().count() > max_chars { |
| 1459 | logging::warn(format!( |
| 1460 | "[compaction] summary_instructions is longer than {max_chars} characters; \ |
| 1461 | the summarizer prompt suffix was truncated" |
| 1462 | )); |
| 1463 | truncate_chars(text, max_chars) |
| 1464 | } else { |
| 1465 | text |
| 1466 | }; |
| 1467 | Some(format!( |
| 1468 | "\n\n{OPERATOR_INSTRUCTIONS_HEADER}\n{text}\n{OPERATOR_INSTRUCTIONS_FOOTER}" |
| 1469 | )) |
| 1470 | } |
| 1471 | |
| 1472 | fn compact_prompt(focus: Option<&str>, instructions: Option<&str>) -> String { |
| 1473 | let mut prompt = format!("{COMPACT_PROMPT} {COMPACTION_LANGUAGE_CONTRACT}"); |
| 1474 | if let Some(section) = operator_instructions_section(instructions) { |
| 1475 | prompt.push_str(§ion); |
| 1476 | } |
| 1477 | if let Some(focus) = focus.map(str::trim).filter(|focus| !focus.is_empty()) { |
| 1478 | let _ = write!( |
| 1479 | prompt, |
| 1480 | "\n\nThe user asked this compaction to focus on: {focus}" |
| 1481 | ); |
| 1482 | } |
| 1483 | prompt |
| 1484 | } |
| 1485 | |
| 1486 | fn compact_quality_retry_prompt(focus: Option<&str>, instructions: Option<&str>) -> String { |
| 1487 | let mut prompt = format!( |
| 1488 | "The previous handoff response was empty or a placeholder. Return a substantive factual \ |
| 1489 | continuation handoff. State the user objective, completed and current work, hard constraints, verified \ |
| 1490 | evidence, unresolved failures, and the single next action. Do not refuse, call tools, discuss \ |
| 1491 | checkpoint machinery, or return a placeholder. {COMPACTION_LANGUAGE_CONTRACT}" |
| 1492 | ); |
| 1493 | if let Some(section) = operator_instructions_section(instructions) { |
| 1494 | prompt.push_str(§ion); |
| 1495 | } |
| 1496 | if let Some(focus) = focus.map(str::trim).filter(|focus| !focus.is_empty()) { |
| 1497 | let _ = write!( |
| 1498 | prompt, |
| 1499 | "\n\nThe user asked this compaction to focus on: {focus}" |
| 1500 | ); |
| 1501 | } |
| 1502 | prompt |
| 1503 | } |
| 1504 | |
| 1505 | fn validate_compaction_summary(summary: &str) -> Result<()> { |
| 1506 | let trimmed = summary.trim(); |
| 1507 | if trimmed.is_empty() { |
| 1508 | anyhow::bail!("Compaction summary response was unusable: no text was returned."); |
| 1509 | } |
| 1510 | |
| 1511 | // Strip every non-word edge, not just ASCII punctuation. Providers can |
| 1512 | // return visually non-empty Unicode punctuation or emoji-only payloads; |
| 1513 | // neither is a usable continuation checkpoint. `is_alphanumeric` keeps |
| 1514 | // this language-neutral for CJK and other scripts without imposing a |
| 1515 | // prose-length heuristic. |
| 1516 | let normalized = trimmed |
| 1517 | .trim_matches(|ch: char| !ch.is_alphanumeric()) |
| 1518 | .to_ascii_lowercase(); |
| 1519 | if normalized.is_empty() { |
| 1520 | anyhow::bail!( |
| 1521 | "Compaction summary response was unusable: only whitespace or punctuation was returned." |
| 1522 | ); |
| 1523 | } |
| 1524 | if matches!( |
| 1525 | normalized.as_str(), |
| 1526 | "no summary available" |
| 1527 | | "summary unavailable" |
| 1528 | | "no summary" |
| 1529 | | "n/a" |
| 1530 | | "na" |
| 1531 | | "not available" |
| 1532 | | "i cannot provide a summary" |
| 1533 | | "i can't provide a summary" |
| 1534 | | "unable to provide a summary" |
| 1535 | ) { |
| 1536 | anyhow::bail!("Compaction summary response was unusable: a placeholder was returned."); |
| 1537 | } |
| 1538 | Ok(()) |
| 1539 | } |
| 1540 | |
| 1541 | /// Drop the oldest history message before retrying an over-window summary |
| 1542 | /// request (Codex parity: `history.remove_first_item()`), plus any tool |
| 1543 | /// results the removal orphans — strict providers reject unpaired results. |
| 1544 | fn drop_oldest_history_messages(messages: &mut Vec<Message>) { |
| 1545 | if messages.len() <= 1 { |
| 1546 | return; |
| 1547 | } |
| 1548 | messages.remove(0); |
| 1549 | while messages.len() > 1 |
| 1550 | && messages[0] |
| 1551 | .content |
| 1552 | .iter() |
| 1553 | .any(|block| matches!(block, ContentBlock::ToolResult { .. })) |
| 1554 | { |
| 1555 | messages.remove(0); |
| 1556 | } |
| 1557 | } |
| 1558 | |
| 1559 | async fn create_summary( |
| 1560 | client: &dyn ModelClient, |
| 1561 | messages: &[Message], |
| 1562 | config: &CompactionConfig, |
| 1563 | system_prompt: Option<&SystemPrompt>, |
| 1564 | tools: Option<&[Tool]>, |
| 1565 | quality_retries: &mut u32, |
| 1566 | invocation_usage: &mut Usage, |
| 1567 | ) -> Result<String> { |
| 1568 | // The summarization request IS the live conversation plus one final user |
| 1569 | // message asking for the handoff summary, so the provider's prefix cache |
| 1570 | // covers everything already sent this session. |
| 1571 | let mut request_messages = messages.to_vec(); |
| 1572 | let stripped_images = crate::image_attach::strip_images_when_unsupported( |
| 1573 | &mut request_messages, |
| 1574 | config.image_input, |
| 1575 | &config.model, |
| 1576 | ); |
| 1577 | if stripped_images > 0 { |
| 1578 | logging::warn(format!( |
| 1579 | "Compaction omitted {stripped_images} image block(s) unsupported by its route" |
| 1580 | )); |
| 1581 | } |
| 1582 | request_messages.push(Message { |
| 1583 | role: Role::User, |
| 1584 | content: vec![ContentBlock::Text { |
| 1585 | text: compact_prompt( |
| 1586 | config.focus.as_deref(), |
| 1587 | config.summary_instructions.as_deref(), |
| 1588 | ), |
| 1589 | cache_control: None, |
| 1590 | }], |
| 1591 | }); |
| 1592 | |
| 1593 | let mut quality_retry_used = false; |
| 1594 | loop { |
| 1595 | // Codex compaction is a normal model generation over the existing |
| 1596 | // cached prefix. Do the same here: the resolved route decides how |
| 1597 | // much output the model may need instead of imposing a smaller, |
| 1598 | // compaction-only ceiling that can be consumed by hidden reasoning. |
| 1599 | let cost_route = client.effective_route_envelope(&config.model, chrono::Utc::now()); |
| 1600 | let request = MessageRequest { |
| 1601 | model: config.model.clone(), |
| 1602 | messages: request_messages.clone(), |
| 1603 | max_tokens: client.effective_max_output_tokens(&cost_route.model), |
| 1604 | system: system_prompt.cloned(), |
| 1605 | tools: tools.map(<[Tool]>::to_vec), |
| 1606 | tool_choice: tools |
| 1607 | .filter(|tools| !tools.is_empty()) |
| 1608 | .map(|_| serde_json::json!("none")), |
| 1609 | metadata: None, |
| 1610 | thinking: None, |
| 1611 | reasoning_effort: None, |
| 1612 | stream: Some(false), |
| 1613 | // Route parity with ordinary turns: turns send no sampling |
| 1614 | // params, so every provider's own normalization/defaults apply. |
| 1615 | // A hard-coded 0.3 leaked to the wire on routes that pass |
| 1616 | // temperature through (e.g. Kimi Code membership), where the |
| 1617 | // fixed-sampling contract rejects it and the whole compaction |
| 1618 | // pass fails. |
| 1619 | temperature: None, |
| 1620 | top_p: None, |
| 1621 | }; |
| 1622 | |
| 1623 | // Capture the session scope before awaiting so a late response cannot |
| 1624 | // accrue into a subsequently loaded/new session. |
| 1625 | let cost_scope = crate::cost_status::scope_token(); |
| 1626 | let response = match client.create_message(request).await { |
| 1627 | Ok(response) => response, |
| 1628 | Err(err) if is_context_window_error(&err) && request_messages.len() > 2 => { |
| 1629 | logging::warn(format!( |
| 1630 | "Compaction summary input over the context window ({err}); \ |
| 1631 | dropping the oldest history item and retrying" |
| 1632 | )); |
| 1633 | drop_oldest_history_messages(&mut request_messages); |
| 1634 | continue; |
| 1635 | } |
| 1636 | Err(err) => return Err(err), |
| 1637 | }; |
| 1638 | |
| 1639 | // Keep the caller's total before any validation or subsequent await. |
| 1640 | // A rejected summary or canceled retry still consumed these tokens. |
| 1641 | crate::core::turn::add_usage_to(invocation_usage, &response.usage); |
| 1642 | |
| 1643 | // Compaction summary calls are billed; route the tokens through the |
| 1644 | // side-channel so the dashboard total matches the website (#526). |
| 1645 | crate::cost_status::report_effective_route_for_runtime( |
| 1646 | cost_scope, |
| 1647 | config.runtime_cost_owner.as_deref(), |
| 1648 | &format!( |
| 1649 | "compaction:dispatch:{}:response:{}", |
| 1650 | cost_route |
| 1651 | .dispatched_at |
| 1652 | .timestamp_nanos_opt() |
| 1653 | .unwrap_or_default(), |
| 1654 | response.id |
| 1655 | ), |
| 1656 | &cost_route, |
| 1657 | &response.usage, |
| 1658 | ); |
| 1659 | |
| 1660 | // Usage above is already billed; a provider-declared incomplete |
| 1661 | // summary must still fail rather than replace the session history |
| 1662 | // with a fragment. |
| 1663 | if codewhale_models::is_incomplete_stop_reason(response.stop_reason.as_deref()) { |
| 1664 | anyhow::bail!( |
| 1665 | "Compaction summary response incomplete: provider stop reason `{}`; the partial summary was not accepted.", |
| 1666 | codewhale_models::stop_reason_detail(response.stop_reason.as_deref()) |
| 1667 | ); |
| 1668 | } |
| 1669 | if response |
| 1670 | .content |
| 1671 | .iter() |
| 1672 | .any(|block| matches!(block, ContentBlock::ToolUse { .. })) |
| 1673 | { |
| 1674 | anyhow::bail!( |
| 1675 | "Compaction returned a tool call instead of a completed handoff; original conversation was preserved." |
| 1676 | ); |
| 1677 | } |
| 1678 | |
| 1679 | let summary = response |
| 1680 | .content |
| 1681 | .iter() |
| 1682 | .filter_map(|block| match block { |
| 1683 | ContentBlock::Text { text, .. } => Some(text.clone()), |
| 1684 | _ => None, |
| 1685 | }) |
| 1686 | .collect::<Vec<_>>() |
| 1687 | .join("\n"); |
| 1688 | |
| 1689 | if let Err(error) = validate_compaction_summary(&summary) { |
| 1690 | if quality_retry_used { |
| 1691 | return Err(error.context( |
| 1692 | "Compaction summary remained unusable after one conservative retry; \ |
| 1693 | no replacement checkpoint was committed", |
| 1694 | )); |
| 1695 | } |
| 1696 | |
| 1697 | quality_retry_used = true; |
| 1698 | *quality_retries = (*quality_retries).saturating_add(1); |
| 1699 | logging::warn( |
| 1700 | "Compaction provider returned an unusable successful response; retrying once with the conservative handoff prompt", |
| 1701 | ); |
| 1702 | let Some(instruction) = request_messages.last_mut() else { |
| 1703 | return Err(error.context( |
| 1704 | "Compaction summary validation failed and the retry instruction was missing", |
| 1705 | )); |
| 1706 | }; |
| 1707 | instruction.content = vec![ContentBlock::Text { |
| 1708 | text: compact_quality_retry_prompt( |
| 1709 | config.focus.as_deref(), |
| 1710 | config.summary_instructions.as_deref(), |
| 1711 | ), |
| 1712 | cache_control: None, |
| 1713 | }]; |
| 1714 | continue; |
| 1715 | } |
| 1716 | |
| 1717 | return Ok(summary); |
| 1718 | } |
| 1719 | } |
| 1720 | |
| 1721 | fn is_context_window_error(e: &anyhow::Error) -> bool { |
| 1722 | let text = e.to_string(); |
| 1723 | if crate::error_taxonomy::classify_error_message(&text) |
| 1724 | != crate::error_taxonomy::ErrorCategory::InvalidInput |
| 1725 | { |
| 1726 | return false; |
| 1727 | } |
| 1728 | |
| 1729 | let lower = text.to_lowercase(); |
| 1730 | lower.contains("context") |
| 1731 | || lower.contains("token") |
| 1732 | || lower.contains("prompt is too long") |
| 1733 | || lower.contains("requested") |
| 1734 | || lower.contains("maximum") |
| 1735 | } |
| 1736 | |
| 1737 | /// Collect text from a user message without treating tool-result payloads |
| 1738 | /// as new user instructions. |
| 1739 | fn user_text_of(msg: &Message) -> Option<String> { |
| 1740 | if msg.role != "user" { |
| 1741 | return None; |
| 1742 | } |
| 1743 | let text = msg |
| 1744 | .content |
| 1745 | .iter() |
| 1746 | .filter_map(|block| match block { |
| 1747 | ContentBlock::Text { text, .. } => Some(text.as_str()), |
| 1748 | _ => None, |
| 1749 | }) |
| 1750 | .collect::<Vec<_>>() |
| 1751 | .join("\n"); |
| 1752 | let text = text.trim(); |
| 1753 | (!text.is_empty()).then(|| text.to_string()) |
| 1754 | } |
| 1755 | |
| 1756 | #[cfg(test)] |
| 1757 | #[path = "compaction/tests.rs"] |
| 1758 | mod quota_tests; |
| 1759 | |
| 1760 | #[cfg(test)] |
| 1761 | mod tests { |
| 1762 | use codewhale_models::{ImageUrlContent, Message}; |
| 1763 | |
| 1764 | #[test] |
| 1765 | fn restore_replaces_duplicate_generated_checkpoints_without_deleting_user_quote() { |
| 1766 | let summary = SystemPrompt::Text(build_compaction_summary_block_text("Summary", "")); |
| 1767 | let generated = compaction_checkpoint_message(&summary); |
| 1768 | let user_quote = Message { |
| 1769 | role: Role::User, |
| 1770 | content: vec![ContentBlock::Text { |
| 1771 | text: summary_prompt_text(&summary), |
| 1772 | cache_control: None, |
| 1773 | }], |
| 1774 | }; |
| 1775 | assert!(!is_wire_compaction_checkpoint_message(&user_quote)); |
| 1776 | let restored = restore_compaction_checkpoint( |
| 1777 | vec![generated.clone(), user_quote.clone(), generated], |
| 1778 | Some(&summary), |
| 1779 | ); |
| 1780 | assert_eq!(restored.len(), 2); |
| 1781 | assert!(is_wire_compaction_checkpoint_message(&restored[0])); |
| 1782 | assert_eq!(restored[1], user_quote); |
| 1783 | |
| 1784 | // No provenance means the historical broad cleanup remains in force. |
| 1785 | let legacy = Message { |
| 1786 | role: Role::User, |
| 1787 | content: vec![ContentBlock::Text { |
| 1788 | text: format!("{COMPACTION_SUMMARY_MARKER}\nold summary"), |
| 1789 | cache_control: None, |
| 1790 | }], |
| 1791 | }; |
| 1792 | let legacy_restored = |
| 1793 | restore_compaction_checkpoint(vec![legacy.clone(), legacy], Some(&summary)); |
| 1794 | assert_eq!(legacy_restored.len(), 1); |
| 1795 | assert!(is_wire_compaction_checkpoint_message(&legacy_restored[0])); |
| 1796 | } |
| 1797 | |
| 1798 | #[test] |
| 1799 | fn inline_image_estimates_nonzero_tokens() { |
| 1800 | let msg = Message { |
| 1801 | role: Role::User, |
| 1802 | content: vec![ContentBlock::ImageUrl { |
| 1803 | image_url: ImageUrlContent { |
| 1804 | url: "data:image/png;base64,AAAA".to_string(), |
| 1805 | }, |
| 1806 | }], |
| 1807 | }; |
| 1808 | assert!( |
| 1809 | estimate_tokens_for_message(&msg, false) >= IMAGE_TOKEN_ESTIMATE, |
| 1810 | "an inline image must not estimate to 0 tokens" |
| 1811 | ); |
| 1812 | } |
| 1813 | |
| 1814 | use super::*; |
| 1815 | use serde_json::json; |
| 1816 | |
| 1817 | fn msg(role: &str, text: &str) -> Message { |
| 1818 | Message { |
| 1819 | role: Role::from(role), |
| 1820 | content: vec![ContentBlock::Text { |
| 1821 | text: text.to_string(), |
| 1822 | cache_control: None, |
| 1823 | }], |
| 1824 | } |
| 1825 | } |
| 1826 | |
| 1827 | fn prepared(config: &CompactionConfig) -> PreparedCompactionEnvelope { |
| 1828 | PreparedCompactionEnvelope::new(config.clone()) |
| 1829 | } |
| 1830 | |
| 1831 | fn tool_use(id: &str, name: &str, input: serde_json::Value) -> Message { |
| 1832 | Message { |
| 1833 | role: Role::Assistant, |
| 1834 | content: vec![ContentBlock::ToolUse { |
| 1835 | id: id.to_string(), |
| 1836 | name: name.to_string(), |
| 1837 | input, |
| 1838 | caller: None, |
| 1839 | thought_signature: None, |
| 1840 | }], |
| 1841 | } |
| 1842 | } |
| 1843 | |
| 1844 | fn tool_result(id: &str, content: &str) -> Message { |
| 1845 | Message { |
| 1846 | role: Role::User, |
| 1847 | content: vec![ContentBlock::ToolResult { |
| 1848 | tool_use_id: id.to_string(), |
| 1849 | content: content.to_string(), |
| 1850 | is_error: None, |
| 1851 | content_blocks: None, |
| 1852 | }], |
| 1853 | } |
| 1854 | } |
| 1855 | |
| 1856 | #[test] |
| 1857 | fn truncate_chars_respects_unicode_boundaries() { |
| 1858 | let text = "abc😀é"; |
| 1859 | assert_eq!(truncate_chars(text, 0), ""); |
| 1860 | assert_eq!(truncate_chars(text, 1), "a"); |
| 1861 | assert_eq!(truncate_chars(text, 3), "abc"); |
| 1862 | assert_eq!(truncate_chars(text, 4), "abc😀"); |
| 1863 | assert_eq!(truncate_chars(text, 5), "abc😀é"); |
| 1864 | } |
| 1865 | |
| 1866 | #[test] |
| 1867 | fn prune_tool_results_summarizes_old_verbose_outputs() { |
| 1868 | let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80); |
| 1869 | let mut messages = vec![ |
| 1870 | tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})), |
| 1871 | tool_result("call-1", &verbose), |
| 1872 | msg("user", "recent question"), |
| 1873 | msg("assistant", "recent answer"), |
| 1874 | ]; |
| 1875 | |
| 1876 | let saved = prune_tool_results(&mut messages, 2); |
| 1877 | |
| 1878 | assert!(saved > 0); |
| 1879 | let ContentBlock::ToolResult { content, .. } = &messages[1].content[0] else { |
| 1880 | panic!("expected tool result"); |
| 1881 | }; |
| 1882 | assert!(content.contains("[read_file] tool result pruned")); |
| 1883 | assert!(content.contains("Cargo.toml")); |
| 1884 | assert!(content.len() < verbose.len()); |
| 1885 | } |
| 1886 | |
| 1887 | #[test] |
| 1888 | fn prune_tool_results_preserves_protected_tail() { |
| 1889 | let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80); |
| 1890 | let mut messages = vec![ |
| 1891 | msg("user", "older context"), |
| 1892 | tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})), |
| 1893 | tool_result("call-1", &verbose), |
| 1894 | ]; |
| 1895 | |
| 1896 | let saved = prune_tool_results(&mut messages, 2); |
| 1897 | |
| 1898 | assert_eq!(saved, 0); |
| 1899 | let ContentBlock::ToolResult { content, .. } = &messages[2].content[0] else { |
| 1900 | panic!("expected tool result"); |
| 1901 | }; |
| 1902 | assert_eq!(content, &verbose); |
| 1903 | } |
| 1904 | |
| 1905 | #[test] |
| 1906 | fn prune_tool_results_preserves_prefix_bytes_when_reverse_prune_is_enough() { |
| 1907 | let older_verbose = "old ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40); |
| 1908 | let newer_verbose = "new ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40); |
| 1909 | let mut messages = vec![ |
| 1910 | tool_use("call-old", "read_file", json!({"path": "old.txt"})), |
| 1911 | tool_result("call-old", &older_verbose), |
| 1912 | tool_use("call-new", "read_file", json!({"path": "new.txt"})), |
| 1913 | tool_result("call-new", &newer_verbose), |
| 1914 | msg("user", "protected tail"), |
| 1915 | ]; |
| 1916 | let original = messages.clone(); |
| 1917 | |
| 1918 | // Simulate the caller clearing its token budget after one suffix prune. |
| 1919 | let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0); |
| 1920 | |
| 1921 | assert!(saved > 0); |
| 1922 | assert_eq!(&messages[..3], &original[..3]); |
| 1923 | assert_eq!(&messages[4..], &original[4..]); |
| 1924 | let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else { |
| 1925 | panic!("expected pruned tool result"); |
| 1926 | }; |
| 1927 | assert!(content.contains("[read_file] tool result pruned")); |
| 1928 | assert!(content.contains("new.txt")); |
| 1929 | assert!(content.len() < newer_verbose.len()); |
| 1930 | } |
| 1931 | |
| 1932 | #[test] |
| 1933 | fn prune_tool_results_stops_after_newest_duplicate_prune() { |
| 1934 | let oldest = "oldest ".repeat(80); |
| 1935 | let middle = "middle ".repeat(80); |
| 1936 | let latest = "latest ".repeat(80); |
| 1937 | let mut messages = vec![ |
| 1938 | tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})), |
| 1939 | tool_result("call-1", &oldest), |
| 1940 | tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})), |
| 1941 | tool_result("call-2", &middle), |
| 1942 | tool_use("call-3", "read_file", json!({"path": "Cargo.toml"})), |
| 1943 | tool_result("call-3", &latest), |
| 1944 | msg("user", "protected tail"), |
| 1945 | ]; |
| 1946 | let original = messages.clone(); |
| 1947 | |
| 1948 | let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0); |
| 1949 | |
| 1950 | assert!(saved > 0); |
| 1951 | assert_eq!(&messages[..3], &original[..3]); |
| 1952 | assert_eq!(&messages[4..], &original[4..]); |
| 1953 | let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else { |
| 1954 | panic!("expected middle duplicate to be pruned"); |
| 1955 | }; |
| 1956 | assert!(content.contains("[read_file] tool result pruned")); |
| 1957 | } |
| 1958 | |
| 1959 | #[test] |
| 1960 | fn prune_tool_results_dedupes_identical_reads_but_keeps_latest_full_body() { |
| 1961 | let first = "first ".repeat(80); |
| 1962 | let second = "second ".repeat(80); |
| 1963 | let mut messages = vec![ |
| 1964 | tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})), |
| 1965 | tool_result("call-1", &first), |
| 1966 | tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})), |
| 1967 | tool_result("call-2", &second), |
| 1968 | msg("user", "tail"), |
| 1969 | ]; |
| 1970 | |
| 1971 | let saved = prune_tool_results(&mut messages, 1); |
| 1972 | |
| 1973 | assert!(saved > 0); |
| 1974 | let ContentBlock::ToolResult { content: older, .. } = &messages[1].content[0] else { |
| 1975 | panic!("expected older tool result"); |
| 1976 | }; |
| 1977 | assert!(older.contains("tool result pruned")); |
| 1978 | let ContentBlock::ToolResult { |
| 1979 | content: latest, .. |
| 1980 | } = &messages[3].content[0] |
| 1981 | else { |
| 1982 | panic!("expected latest tool result"); |
| 1983 | }; |
| 1984 | assert_eq!(latest, &second); |
| 1985 | } |
| 1986 | |
| 1987 | #[test] |
| 1988 | fn context_window_errors_are_detected_for_summary_fallback() { |
| 1989 | for msg in [ |
| 1990 | "HTTP 400 Bad Request: maximum context length is 1000000 tokens", |
| 1991 | "invalid_request_error: prompt is too long for the current model", |
| 1992 | "You requested 1000001 tokens but the maximum is 1000000", |
| 1993 | "request exceeds context window", |
| 1994 | ] { |
| 1995 | assert!( |
| 1996 | is_context_window_error(&anyhow::anyhow!(msg)), |
| 1997 | "expected context-window detection for `{msg}`", |
| 1998 | ); |
| 1999 | } |
| 2000 | |
| 2001 | assert!(!is_context_window_error(&anyhow::anyhow!( |
| 2002 | "Invalid request: missing required field" |
| 2003 | ))); |
| 2004 | assert!(!is_context_window_error(&anyhow::anyhow!( |
| 2005 | "503 Service Unavailable" |
| 2006 | ))); |
| 2007 | } |
| 2008 | |
| 2009 | #[test] |
| 2010 | fn tool_args_preview_redacts_sensitive_first_without_dropping_siblings() { |
| 2011 | let input: serde_json::Value = serde_json::from_str( |
| 2012 | r#"{"api_key":"sk-tool-secret-value","command":"cargo test -p auth"}"#, |
| 2013 | ) |
| 2014 | .unwrap(); |
| 2015 | |
| 2016 | let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap(); |
| 2017 | |
| 2018 | assert_eq!(preview["api_key"], codewhale_config::persistence::REDACTED); |
| 2019 | assert_eq!(preview["command"], "cargo test -p auth"); |
| 2020 | } |
| 2021 | |
| 2022 | #[test] |
| 2023 | fn tool_args_preview_redacts_sensitive_later_without_touching_earlier_fields() { |
| 2024 | let input: serde_json::Value = |
| 2025 | serde_json::from_str(r#"{"command":"cargo test","api_key":"plain-secret-value"}"#) |
| 2026 | .unwrap(); |
| 2027 | |
| 2028 | let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap(); |
| 2029 | |
| 2030 | assert_eq!(preview["command"], "cargo test"); |
| 2031 | assert_eq!(preview["api_key"], codewhale_config::persistence::REDACTED); |
| 2032 | } |
| 2033 | |
| 2034 | #[test] |
| 2035 | fn tool_args_preview_redacts_nested_sensitive_values_recursively() { |
| 2036 | let input: serde_json::Value = serde_json::from_str( |
| 2037 | r#"{"meta":{"token":"nested-secret","keep":"yes"},"steps":[{"password":"pw","name":"a"}]}"#, |
| 2038 | ) |
| 2039 | .unwrap(); |
| 2040 | |
| 2041 | let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap(); |
| 2042 | |
| 2043 | assert_eq!( |
| 2044 | preview["meta"]["token"], |
| 2045 | codewhale_config::persistence::REDACTED |
| 2046 | ); |
| 2047 | assert_eq!(preview["meta"]["keep"], "yes"); |
| 2048 | assert_eq!( |
| 2049 | preview["steps"][0]["password"], |
| 2050 | codewhale_config::persistence::REDACTED |
| 2051 | ); |
| 2052 | assert_eq!(preview["steps"][0]["name"], "a"); |
| 2053 | } |
| 2054 | |
| 2055 | #[test] |
| 2056 | fn tool_args_preview_redacts_complete_multi_word_secret_value() { |
| 2057 | let input: serde_json::Value = |
| 2058 | serde_json::from_str(r#"{"command":"run this","password":"hunter two words"}"#) |
| 2059 | .unwrap(); |
| 2060 | |
| 2061 | let serialized = tool_args_preview(&input); |
| 2062 | let preview: serde_json::Value = serde_json::from_str(&serialized).unwrap(); |
| 2063 | |
| 2064 | assert_eq!(preview["command"], "run this"); |
| 2065 | assert_eq!(preview["password"], codewhale_config::persistence::REDACTED); |
| 2066 | assert!(!serialized.contains("hunter")); |
| 2067 | assert!(!serialized.contains("two words")); |
| 2068 | } |
| 2069 | |
| 2070 | struct FixedSummaryClient { |
| 2071 | request: std::sync::Mutex<Option<MessageRequest>>, |
| 2072 | provider: &'static str, |
| 2073 | model: &'static str, |
| 2074 | } |
| 2075 | |
| 2076 | impl Default for FixedSummaryClient { |
| 2077 | fn default() -> Self { |
| 2078 | Self { |
| 2079 | request: std::sync::Mutex::new(None), |
| 2080 | provider: "test", |
| 2081 | model: "test-model", |
| 2082 | } |
| 2083 | } |
| 2084 | } |
| 2085 | |
| 2086 | impl FixedSummaryClient { |
| 2087 | fn for_route(provider: &'static str, model: &'static str) -> Self { |
| 2088 | Self { |
| 2089 | request: std::sync::Mutex::new(None), |
| 2090 | provider, |
| 2091 | model, |
| 2092 | } |
| 2093 | } |
| 2094 | } |
| 2095 | |
| 2096 | const FIXED_SUMMARY: &str = "1. Primary request and intent — migrate the session store. \ |
| 2097 | 2. Key technical concepts — sqlite. 7. Pending tasks — finish the fixed clock. \ |
| 2098 | 8. Current work — rerunning the session tests."; |
| 2099 | |
| 2100 | struct ScriptedSummaryClient { |
| 2101 | responses: std::sync::Mutex<std::collections::VecDeque<anyhow::Result<Vec<ContentBlock>>>>, |
| 2102 | requests: std::sync::Mutex<Vec<MessageRequest>>, |
| 2103 | retry_started: Option<std::sync::Arc<tokio::sync::Notify>>, |
| 2104 | } |
| 2105 | |
| 2106 | impl ScriptedSummaryClient { |
| 2107 | fn new(responses: Vec<Vec<ContentBlock>>) -> Self { |
| 2108 | Self::with_outcomes(responses.into_iter().map(Ok).collect()) |
| 2109 | } |
| 2110 | |
| 2111 | fn with_outcomes(responses: Vec<anyhow::Result<Vec<ContentBlock>>>) -> Self { |
| 2112 | Self { |
| 2113 | responses: std::sync::Mutex::new(responses.into()), |
| 2114 | requests: std::sync::Mutex::new(Vec::new()), |
| 2115 | retry_started: None, |
| 2116 | } |
| 2117 | } |
| 2118 | } |
| 2119 | |
| 2120 | #[async_trait::async_trait] |
| 2121 | impl crate::core::model_client::ModelClient for ScriptedSummaryClient { |
| 2122 | fn provider_name(&self) -> &str { |
| 2123 | "test" |
| 2124 | } |
| 2125 | |
| 2126 | fn model(&self) -> &str { |
| 2127 | "test-model" |
| 2128 | } |
| 2129 | |
| 2130 | async fn create_message( |
| 2131 | &self, |
| 2132 | request: MessageRequest, |
| 2133 | ) -> anyhow::Result<codewhale_models::MessageResponse> { |
| 2134 | self.requests |
| 2135 | .lock() |
| 2136 | .expect("capture scripted summary request") |
| 2137 | .push(request); |
| 2138 | let outcome = self |
| 2139 | .responses |
| 2140 | .lock() |
| 2141 | .expect("read scripted summary response") |
| 2142 | .pop_front(); |
| 2143 | if outcome.is_none() |
| 2144 | && let Some(retry_started) = &self.retry_started |
| 2145 | { |
| 2146 | retry_started.notify_one(); |
| 2147 | return std::future::pending().await; |
| 2148 | } |
| 2149 | let content = outcome |
| 2150 | .ok_or_else(|| anyhow::anyhow!("scripted summary responses exhausted"))??; |
| 2151 | Ok(codewhale_models::MessageResponse { |
| 2152 | id: "summary-scripted".to_string(), |
| 2153 | r#type: "message".to_string(), |
| 2154 | role: "assistant".to_string(), |
| 2155 | content, |
| 2156 | model: "test-model".to_string(), |
| 2157 | stop_reason: None, |
| 2158 | stop_sequence: None, |
| 2159 | container: None, |
| 2160 | usage: Usage { |
| 2161 | input_tokens: 17, |
| 2162 | output_tokens: 3, |
| 2163 | prompt_cache_hit_tokens: Some(5), |
| 2164 | reasoning_tokens: Some(2), |
| 2165 | ..Usage::default() |
| 2166 | }, |
| 2167 | }) |
| 2168 | } |
| 2169 | |
| 2170 | async fn create_message_stream( |
| 2171 | &self, |
| 2172 | _request: MessageRequest, |
| 2173 | ) -> anyhow::Result<crate::llm_client::StreamEventBox> { |
| 2174 | anyhow::bail!("streaming is unused by compaction") |
| 2175 | } |
| 2176 | |
| 2177 | async fn health_check(&self) -> anyhow::Result<bool> { |
| 2178 | Ok(true) |
| 2179 | } |
| 2180 | } |
| 2181 | |
| 2182 | #[async_trait::async_trait] |
| 2183 | impl crate::core::model_client::ModelClient for FixedSummaryClient { |
| 2184 | fn provider_name(&self) -> &str { |
| 2185 | self.provider |
| 2186 | } |
| 2187 | |
| 2188 | fn model(&self) -> &str { |
| 2189 | self.model |
| 2190 | } |
| 2191 | |
| 2192 | async fn create_message( |
| 2193 | &self, |
| 2194 | request: MessageRequest, |
| 2195 | ) -> anyhow::Result<codewhale_models::MessageResponse> { |
| 2196 | *self.request.lock().expect("capture summary request") = Some(request); |
| 2197 | Ok(codewhale_models::MessageResponse { |
| 2198 | id: "summary-fixture".to_string(), |
| 2199 | r#type: "message".to_string(), |
| 2200 | role: "assistant".to_string(), |
| 2201 | content: vec![ContentBlock::Text { |
| 2202 | text: FIXED_SUMMARY.to_string(), |
| 2203 | cache_control: None, |
| 2204 | }], |
| 2205 | model: self.model.to_string(), |
| 2206 | stop_reason: None, |
| 2207 | stop_sequence: None, |
| 2208 | container: None, |
| 2209 | usage: codewhale_models::Usage::default(), |
| 2210 | }) |
| 2211 | } |
| 2212 | |
| 2213 | async fn create_message_stream( |
| 2214 | &self, |
| 2215 | _request: MessageRequest, |
| 2216 | ) -> anyhow::Result<crate::llm_client::StreamEventBox> { |
| 2217 | anyhow::bail!("streaming is unused by compaction") |
| 2218 | } |
| 2219 | |
| 2220 | async fn health_check(&self) -> anyhow::Result<bool> { |
| 2221 | Ok(true) |
| 2222 | } |
| 2223 | } |
| 2224 | |
| 2225 | #[tokio::test] |
| 2226 | async fn compaction_persists_original_and_model_handoff_before_returning_replacement() { |
| 2227 | let _environment = crate::test_support::lock_test_env(); |
| 2228 | let root = tempfile::tempdir().unwrap(); |
| 2229 | let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path()); |
| 2230 | let original = (0..12) |
| 2231 | .map(|i| { |
| 2232 | msg( |
| 2233 | if i % 2 == 0 { "user" } else { "assistant" }, |
| 2234 | &format!("Work item {i}"), |
| 2235 | ) |
| 2236 | }) |
| 2237 | .collect::<Vec<_>>(); |
| 2238 | let mut envelope = prepared(&CompactionConfig::default()); |
| 2239 | envelope.session_id = Some("handoff-test".into()); |
| 2240 | let client = FixedSummaryClient::default(); |
| 2241 | let mut usage = Usage::default(); |
| 2242 | let result = compact_messages_safe(&client, &original, None, &envelope, &mut usage) |
| 2243 | .await |
| 2244 | .unwrap(); |
| 2245 | assert!(result.summary_prompt.is_some()); |
| 2246 | let files = std::fs::read_dir(root.path().join("sessions/handoff-test/artifacts")) |
| 2247 | .unwrap() |
| 2248 | .map(|entry| entry.unwrap().path()) |
| 2249 | .collect::<Vec<_>>(); |
| 2250 | let json = files |
| 2251 | .iter() |
| 2252 | .find(|path| path.extension().is_some_and(|ext| ext == "json")) |
| 2253 | .unwrap(); |
| 2254 | let restored: Vec<Message> = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap(); |
| 2255 | assert_eq!(restored, original); |
| 2256 | let markdown = files |
| 2257 | .iter() |
| 2258 | .find(|path| path.extension().is_some_and(|ext| ext == "md")) |
| 2259 | .unwrap(); |
| 2260 | assert!( |
| 2261 | std::fs::read_to_string(markdown) |
| 2262 | .unwrap() |
| 2263 | .contains("migrate the session store") |
| 2264 | ); |
| 2265 | // An unwritable artifact destination must abort before a provider call. |
| 2266 | envelope.session_id = Some("blocked-handoff".into()); |
| 2267 | std::fs::write( |
| 2268 | root.path().join("sessions/blocked-handoff"), |
| 2269 | b"not a directory", |
| 2270 | ) |
| 2271 | .unwrap(); |
| 2272 | let blocked = FixedSummaryClient::default(); |
| 2273 | assert!( |
| 2274 | compact_messages_safe(&blocked, &original, None, &envelope, &mut usage) |
| 2275 | .await |
| 2276 | .is_err() |
| 2277 | ); |
| 2278 | assert!(blocked.request.lock().unwrap().is_none()); |
| 2279 | } |
| 2280 | |
| 2281 | #[tokio::test] |
| 2282 | async fn compaction_commits_summary_and_retains_recent_user_messages() { |
| 2283 | let messages = vec![ |
| 2284 | msg( |
| 2285 | "user", |
| 2286 | "Objective: migrate the session store to sqlite without breaking existing logins", |
| 2287 | ), |
| 2288 | msg("assistant", "Working on it."), |
| 2289 | tool_use( |
| 2290 | "t1", |
| 2291 | "Bash", |
| 2292 | json!({"command": "cargo test -p session-store"}), |
| 2293 | ), |
| 2294 | tool_result("t1", "test session_store::roundtrip ... ok\nexit code 0"), |
| 2295 | msg("user", "Sounds good, do it"), |
| 2296 | msg("assistant", "Nearly done, rerunning the suite."), |
| 2297 | ]; |
| 2298 | let config = CompactionConfig { |
| 2299 | model: "test-model".to_string(), |
| 2300 | cache_summary: false, |
| 2301 | ..Default::default() |
| 2302 | }; |
| 2303 | let client = FixedSummaryClient::default(); |
| 2304 | |
| 2305 | let (retained, summary_prompt, _) = |
| 2306 | compact_messages(&client, &messages, &config).await.unwrap(); |
| 2307 | |
| 2308 | let request = client |
| 2309 | .request |
| 2310 | .lock() |
| 2311 | .expect("read summary request") |
| 2312 | .clone() |
| 2313 | .expect("summary request was captured"); |
| 2314 | assert_eq!(&request.messages[..messages.len()], messages.as_slice()); |
| 2315 | assert_eq!(request.messages.len(), messages.len() + 1); |
| 2316 | let ContentBlock::Text { text, .. } = &request.messages.last().unwrap().content[0] else { |
| 2317 | panic!("final compaction instruction must be text"); |
| 2318 | }; |
| 2319 | assert!(!text.contains(COMPACTION_SUMMARY_MARKER)); |
| 2320 | assert_eq!(request.temperature, None); |
| 2321 | assert_eq!(request.top_p, None); |
| 2322 | assert_eq!( |
| 2323 | request.max_tokens, |
| 2324 | crate::route_budget::effective_max_output_tokens_for_route( |
| 2325 | crate::config::ApiProvider::Custom, |
| 2326 | "test-model", |
| 2327 | None, |
| 2328 | ) |
| 2329 | ); |
| 2330 | |
| 2331 | let Some(SystemPrompt::Blocks(blocks)) = summary_prompt else { |
| 2332 | panic!("compaction must produce a summary system block"); |
| 2333 | }; |
| 2334 | let text = &blocks[0].text; |
| 2335 | assert!(text.contains(FIXED_SUMMARY)); |
| 2336 | assert!(text.contains("Another language model")); |
| 2337 | |
| 2338 | // Replacement history keeps older user turns, then the open round |
| 2339 | // verbatim (user + assistant + tools), then one checkpoint. |
| 2340 | assert!(retained.iter().any(|message| { |
| 2341 | user_text_of(message).is_some_and(|text| text.contains("Objective: migrate")) |
| 2342 | })); |
| 2343 | assert!( |
| 2344 | retained |
| 2345 | .iter() |
| 2346 | .any(|message| { user_text_of(message).as_deref() == Some("Sounds good, do it") }) |
| 2347 | ); |
| 2348 | assert!(retained.iter().any(|message| { |
| 2349 | message.role.is_assistant_like() |
| 2350 | && message.content.iter().any(|block| { |
| 2351 | matches!( |
| 2352 | block, |
| 2353 | ContentBlock::Text { text, .. } |
| 2354 | if text.contains("Nearly done, rerunning the suite.") |
| 2355 | ) |
| 2356 | }) |
| 2357 | })); |
| 2358 | assert!(retained.iter().any(|message| { |
| 2359 | message.content.iter().any(|block| { |
| 2360 | matches!( |
| 2361 | block, |
| 2362 | ContentBlock::ToolResult { content, .. } |
| 2363 | if content.contains("session_store::roundtrip") |
| 2364 | ) |
| 2365 | }) |
| 2366 | })); |
| 2367 | assert!(is_compaction_checkpoint_message(retained.last().unwrap())); |
| 2368 | assert!(is_wire_compaction_checkpoint_message( |
| 2369 | retained.last().unwrap() |
| 2370 | )); |
| 2371 | assert!(matches!( |
| 2372 | &retained.last().unwrap().content[0], |
| 2373 | ContentBlock::Text { text: checkpoint, .. } if checkpoint == text |
| 2374 | )); |
| 2375 | last_round::validate_last_round_coverage(&messages, &retained[..retained.len() - 1]) |
| 2376 | .unwrap(); |
| 2377 | } |
| 2378 | |
| 2379 | #[tokio::test] |
| 2380 | async fn uninterrupted_task_compacts_repeatedly_with_original_prefix_and_recent_tool_pairs() { |
| 2381 | let system = SystemPrompt::Text("stable project instructions and permissions".into()); |
| 2382 | let mut prepared = PreparedCompactionEnvelope::new(CompactionConfig { |
| 2383 | token_threshold: 40_000, |
| 2384 | model: "test-model".into(), |
| 2385 | ..Default::default() |
| 2386 | }); |
| 2387 | prepared.tools = Some(vec![ |
| 2388 | serde_json::from_value(json!({ |
| 2389 | "name": "File", "description": "Read a file", "input_schema": {"type": "object"} |
| 2390 | })) |
| 2391 | .unwrap(), |
| 2392 | ]); |
| 2393 | let client = FixedSummaryClient::default(); |
| 2394 | let mut messages = vec![msg( |
| 2395 | "user", |
| 2396 | "Finish the migration. Preserve logins; do not publish.", |
| 2397 | )]; |
| 2398 | for epoch in 0..3 { |
| 2399 | for step in 0..20 { |
| 2400 | let id = format!("{epoch}-{step}"); |
| 2401 | let mut call = tool_use(&id, "File", json!({"path":"session.rs"})); |
| 2402 | call.content.insert( |
| 2403 | 0, |
| 2404 | ContentBlock::Text { |
| 2405 | text: format!("Evidence {id}: {}", "x".repeat(12_000)), |
| 2406 | cache_control: None, |
| 2407 | }, |
| 2408 | ); |
| 2409 | if step == 19 { |
| 2410 | call.content.insert( |
| 2411 | 0, |
| 2412 | ContentBlock::Thinking { |
| 2413 | thinking: "retained reasoning ".repeat(2000), |
| 2414 | signature: None, |
| 2415 | state: None, |
| 2416 | }, |
| 2417 | ); |
| 2418 | } |
| 2419 | messages.push(call); |
| 2420 | messages.push(tool_result( |
| 2421 | &id, |
| 2422 | &format!("Observed {id}: {}", "e".repeat(1000)), |
| 2423 | )); |
| 2424 | } |
| 2425 | let original = messages.clone(); |
| 2426 | let mut usage = Usage::default(); |
| 2427 | let result = |
| 2428 | compact_messages_safe(&client, &messages, Some(&system), &prepared, &mut usage) |
| 2429 | .await |
| 2430 | .unwrap(); |
| 2431 | let request = client.request.lock().unwrap().clone().unwrap(); |
| 2432 | assert_eq!(request.system.as_ref(), Some(&system)); |
| 2433 | assert_eq!(request.tools, prepared.tools); |
| 2434 | assert_eq!(request.tool_choice, Some(json!("none"))); |
| 2435 | assert_eq!( |
| 2436 | &request.messages[..original.len()], |
| 2437 | original.as_slice(), |
| 2438 | "summary must see the original evidence and reusable history prefix" |
| 2439 | ); |
| 2440 | assert!(estimate_tokens(&result.messages) < estimate_tokens(&original) / 2); |
| 2441 | assert_eq!( |
| 2442 | result |
| 2443 | .messages |
| 2444 | .iter() |
| 2445 | .filter(|m| is_compaction_checkpoint_message(m)) |
| 2446 | .count(), |
| 2447 | 1 |
| 2448 | ); |
| 2449 | for step in [18, 19] { |
| 2450 | let id = format!("{epoch}-{step}"); |
| 2451 | let expected = original.iter().find(|m| m.content.iter().any(|b| matches!(b, ContentBlock::ToolUse { id: found, .. } if found == &id))).unwrap(); |
| 2452 | assert!( |
| 2453 | result.messages.contains(expected), |
| 2454 | "retained assistant text, calls and reasoning must survive unchanged" |
| 2455 | ); |
| 2456 | assert!(result.messages.iter().any(|m| m.content.iter().any(|b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == &id)))); |
| 2457 | } |
| 2458 | assert_eq!(result.messages[0], original[0]); |
| 2459 | messages = result.messages; |
| 2460 | } |
| 2461 | } |
| 2462 | |
| 2463 | #[test] |
| 2464 | fn coverage_floor_rejects_a_replacement_that_drops_last_round_assistant() { |
| 2465 | let original = vec![ |
| 2466 | msg("user", "What failed?"), |
| 2467 | msg("assistant", "session_store::roundtrip panics on reload."), |
| 2468 | ]; |
| 2469 | let gutting = vec![msg("user", "What failed?")]; |
| 2470 | let error = last_round::validate_last_round_coverage(&original, &gutting) |
| 2471 | .expect_err("dropping last-round assistant text must fail closed"); |
| 2472 | assert!(error.to_string().contains("assistant"), "{error}"); |
| 2473 | } |
| 2474 | |
| 2475 | #[tokio::test] |
| 2476 | async fn compaction_preserves_runtime_provenance_and_saved_user_title() { |
| 2477 | let runtime = crate::runtime_handoff::operate_contract_runtime_message(); |
| 2478 | let mut user = msg("user", "Build a focus timer"); |
| 2479 | user.content.push(ContentBlock::Text { |
| 2480 | text: "<turn_meta>\nInput provenance: external_user\nInput authority: external_current_turn\n</turn_meta>".to_string(), |
| 2481 | cache_control: None, |
| 2482 | }); |
| 2483 | let messages = vec![ |
| 2484 | runtime.clone(), |
| 2485 | msg("assistant", "Ready."), |
| 2486 | user.clone(), |
| 2487 | msg("assistant", "Building the timer."), |
| 2488 | msg("user", "Keep the controls simple"), |
| 2489 | msg("assistant", "Adding start and stop."), |
| 2490 | ]; |
| 2491 | let (retained, summary, _) = compact_messages( |
| 2492 | &FixedSummaryClient::default(), |
| 2493 | &messages, |
| 2494 | &CompactionConfig::default(), |
| 2495 | ) |
| 2496 | .await |
| 2497 | .unwrap(); |
| 2498 | assert_eq!(retained.first(), Some(&runtime)); |
| 2499 | assert!(retained.contains(&user)); |
| 2500 | assert!(crate::runtime_handoff::is_operate_contract_message( |
| 2501 | &retained[0] |
| 2502 | )); |
| 2503 | let saved = crate::session_manager::create_saved_session_with_mode( |
| 2504 | &retained, |
| 2505 | "test-model", |
| 2506 | std::path::Path::new("."), |
| 2507 | 0, |
| 2508 | summary.as_ref(), |
| 2509 | None, |
| 2510 | ); |
| 2511 | let restored: crate::session_manager::SavedSession = |
| 2512 | serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap(); |
| 2513 | assert_eq!(restored.metadata.title, "Build a focus timer"); |
| 2514 | assert_eq!(restored.messages, retained); |
| 2515 | } |
| 2516 | |
| 2517 | #[test] |
| 2518 | fn retained_literal_runtime_xml_remains_user_authored() { |
| 2519 | let runtime = crate::runtime_handoff::operate_contract_runtime_message(); |
| 2520 | // A user may paste the exact bytes, including a metadata example, in |
| 2521 | // one ordinary text block. Compaction must not split it into authority. |
| 2522 | let literal = user_text_of(&runtime).unwrap(); |
| 2523 | let user = msg("user", &literal); |
| 2524 | let retained = retained_user_messages(std::slice::from_ref(&user), usize::MAX); |
| 2525 | assert_eq!(retained, vec![user]); |
| 2526 | assert_eq!( |
| 2527 | crate::runtime_handoff::classify_user_turn_prompt(&retained[0]), |
| 2528 | crate::runtime_handoff::UserTurnPromptKind::Editable, |
| 2529 | ); |
| 2530 | assert_eq!( |
| 2531 | crate::session_manager::conversation_title_prompt(&retained), |
| 2532 | Some(literal.as_str()), |
| 2533 | ); |
| 2534 | assert!(!crate::runtime_handoff::is_operate_contract_message( |
| 2535 | &retained[0] |
| 2536 | )); |
| 2537 | } |
| 2538 | |
| 2539 | #[test] |
| 2540 | fn retained_image_turn_and_structured_content_keep_their_boundaries() { |
| 2541 | let image = Message { |
| 2542 | role: Role::User, |
| 2543 | content: vec![ContentBlock::ImageUrl { |
| 2544 | image_url: ImageUrlContent { |
| 2545 | url: "data:image/png;base64,AAAA".to_string(), |
| 2546 | }, |
| 2547 | }], |
| 2548 | }; |
| 2549 | let mut mixed = msg("user", "Compare these views"); |
| 2550 | mixed.content.extend(image.content.clone()); |
| 2551 | mixed.content.push(ContentBlock::Text { |
| 2552 | text: "Keep the original colors".to_string(), |
| 2553 | cache_control: Some(CacheControl { |
| 2554 | cache_type: "ephemeral".to_string(), |
| 2555 | }), |
| 2556 | }); |
| 2557 | let messages = vec![image, mixed]; |
| 2558 | let retained = retained_user_messages(&messages, 3_000); |
| 2559 | assert_eq!(retained, messages); |
| 2560 | let saved = crate::session_manager::create_saved_session_with_mode( |
| 2561 | &retained, |
| 2562 | "test-model", |
| 2563 | std::path::Path::new("."), |
| 2564 | 0, |
| 2565 | None, |
| 2566 | None, |
| 2567 | ); |
| 2568 | assert_eq!( |
| 2569 | saved.metadata.title, |
| 2570 | crate::session_manager::DEFAULT_SESSION_TITLE |
| 2571 | ); |
| 2572 | assert!(retained_user_messages(&messages[..1], IMAGE_TOKEN_ESTIMATE - 1).is_empty()); |
| 2573 | } |
| 2574 | |
| 2575 | #[test] |
| 2576 | fn retained_budget_never_partially_promotes_structural_metadata() { |
| 2577 | let runtime = crate::runtime_handoff::operate_contract_runtime_message(); |
| 2578 | let text = msg("user", "αβγδεζηθικ"); |
| 2579 | let retained = retained_user_messages(&[runtime.clone(), text.clone()], 5); |
| 2580 | assert_eq!(retained, vec![text]); |
| 2581 | assert!(retained_user_messages(&[runtime], 1).is_empty()); |
| 2582 | assert_eq!( |
| 2583 | user_text_of(&retained_user_messages(&[msg("user", "αβγδεζηθικ")], 2)[0]).as_deref(), |
| 2584 | Some("αβγδεζ"), |
| 2585 | ); |
| 2586 | } |
| 2587 | |
| 2588 | #[test] |
| 2589 | fn retained_older_turn_drops_result_blocks_whose_call_was_summarized() { |
| 2590 | // #6119: a host-supplied user message can mix text with a tool |
| 2591 | // result; the tool_use it answers lives in the summarized region, so |
| 2592 | // the retained copy must keep the text and drop the orphaned result. |
| 2593 | let mut mixed = msg("user", "Please keep this context."); |
| 2594 | mixed.content.push(ContentBlock::ToolResult { |
| 2595 | tool_use_id: "toolu_orphan_1".to_string(), |
| 2596 | content: "{\"ok\":true}".to_string(), |
| 2597 | is_error: None, |
| 2598 | content_blocks: None, |
| 2599 | }); |
| 2600 | let retained = retained_user_messages(std::slice::from_ref(&mixed), usize::MAX); |
| 2601 | assert_eq!(retained.len(), 1); |
| 2602 | assert!( |
| 2603 | !retained[0] |
| 2604 | .content |
| 2605 | .iter() |
| 2606 | .any(|block| matches!(block, ContentBlock::ToolResult { .. })), |
| 2607 | "the retained copy must not keep an orphaned tool_result" |
| 2608 | ); |
| 2609 | assert_eq!( |
| 2610 | user_text_of(&retained[0]).as_deref(), |
| 2611 | Some("Please keep this context.") |
| 2612 | ); |
| 2613 | // Insufficient budget still refuses to partially retain a multi-block |
| 2614 | // turn; the structural-metadata guard is unchanged. |
| 2615 | assert!(retained_user_messages(std::slice::from_ref(&mixed), 1).is_empty()); |
| 2616 | } |
| 2617 | |
| 2618 | #[test] |
| 2619 | fn summary_quality_gate_rejects_empty_and_known_placeholder_text() { |
| 2620 | for summary in [ |
| 2621 | "", |
| 2622 | " \n\t ", |
| 2623 | "...", |
| 2624 | "。。。", |
| 2625 | "🫧", |
| 2626 | "N/A", |
| 2627 | "(no summary available)", |
| 2628 | "I cannot provide a summary.", |
| 2629 | ] { |
| 2630 | let error = validate_compaction_summary(summary) |
| 2631 | .expect_err("degenerate summary must fail closed"); |
| 2632 | assert!(error.to_string().contains("unusable"), "{error}"); |
| 2633 | } |
| 2634 | validate_compaction_summary(FIXED_SUMMARY) |
| 2635 | .expect("a substantive continuation handoff must be accepted"); |
| 2636 | validate_compaction_summary( |
| 2637 | "目的: #4394の空要約を防止。完了: 検証と実装。制約: 履歴を変更しない。次: テスト実行。", |
| 2638 | ) |
| 2639 | .expect("a concise multilingual handoff must not be rejected by prose length"); |
| 2640 | } |
| 2641 | |
| 2642 | #[tokio::test] |
| 2643 | async fn empty_successful_summary_retries_once_without_replacing_history() { |
| 2644 | let original = vec![ |
| 2645 | msg( |
| 2646 | "user", |
| 2647 | "Keep the migration transactional and preserve existing sessions.", |
| 2648 | ), |
| 2649 | msg( |
| 2650 | "assistant", |
| 2651 | "I am updating the session store and its fixtures.", |
| 2652 | ), |
| 2653 | ]; |
| 2654 | let client = ScriptedSummaryClient::new(vec![ |
| 2655 | vec![ContentBlock::Text { |
| 2656 | text: " \n\t ".to_string(), |
| 2657 | cache_control: None, |
| 2658 | }], |
| 2659 | vec![ContentBlock::Text { |
| 2660 | text: FIXED_SUMMARY.to_string(), |
| 2661 | cache_control: None, |
| 2662 | }], |
| 2663 | ]); |
| 2664 | let config = CompactionConfig { |
| 2665 | model: "test-model".to_string(), |
| 2666 | cache_summary: false, |
| 2667 | ..Default::default() |
| 2668 | }; |
| 2669 | |
| 2670 | let mut invocation_usage = Usage::default(); |
| 2671 | let result = compact_messages_safe( |
| 2672 | &client, |
| 2673 | &original, |
| 2674 | None, |
| 2675 | &prepared(&config), |
| 2676 | &mut invocation_usage, |
| 2677 | ) |
| 2678 | .await |
| 2679 | .expect("the conservative retry should recover a usable summary"); |
| 2680 | assert_eq!(invocation_usage.input_tokens, 34); |
| 2681 | assert_eq!(invocation_usage.output_tokens, 6); |
| 2682 | assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10)); |
| 2683 | assert_eq!(invocation_usage.reasoning_tokens, Some(4)); |
| 2684 | |
| 2685 | let requests = client |
| 2686 | .requests |
| 2687 | .lock() |
| 2688 | .expect("read scripted summary requests"); |
| 2689 | assert_eq!(requests.len(), 2, "quality failure retries exactly once"); |
| 2690 | let ContentBlock::Text { text, .. } = &requests[1] |
| 2691 | .messages |
| 2692 | .last() |
| 2693 | .expect("retry instruction") |
| 2694 | .content[0] |
| 2695 | else { |
| 2696 | panic!("retry instruction must be text"); |
| 2697 | }; |
| 2698 | assert!(text.contains("previous handoff response was empty")); |
| 2699 | drop(requests); |
| 2700 | |
| 2701 | assert_eq!( |
| 2702 | result.retries_used, 1, |
| 2703 | "quality retry must reach diagnostics" |
| 2704 | ); |
| 2705 | assert_eq!(original[0].role, "user", "source history remains untouched"); |
| 2706 | assert!(result.messages.iter().any(is_compaction_checkpoint_message)); |
| 2707 | let Some(SystemPrompt::Blocks(blocks)) = result.summary_prompt else { |
| 2708 | panic!("recovered summary must be committed"); |
| 2709 | }; |
| 2710 | assert!(blocks[0].text.contains(FIXED_SUMMARY)); |
| 2711 | assert!(!blocks[0].text.contains("(no summary available)")); |
| 2712 | } |
| 2713 | |
| 2714 | #[tokio::test] |
| 2715 | async fn quality_retry_count_survives_a_later_transient_failure() { |
| 2716 | let client = ScriptedSummaryClient::with_outcomes(vec![ |
| 2717 | Ok(vec![ContentBlock::Text { |
| 2718 | text: "...".to_string(), |
| 2719 | cache_control: None, |
| 2720 | }]), |
| 2721 | Err(anyhow::anyhow!("request timed out")), |
| 2722 | Ok(vec![ContentBlock::Text { |
| 2723 | text: FIXED_SUMMARY.to_string(), |
| 2724 | cache_control: None, |
| 2725 | }]), |
| 2726 | ]); |
| 2727 | let config = CompactionConfig { |
| 2728 | model: "test-model".to_string(), |
| 2729 | cache_summary: false, |
| 2730 | ..Default::default() |
| 2731 | }; |
| 2732 | |
| 2733 | let mut invocation_usage = Usage::default(); |
| 2734 | let result = compact_messages_safe( |
| 2735 | &client, |
| 2736 | &[msg("user", "Preserve the current migration state.")], |
| 2737 | None, |
| 2738 | &prepared(&config), |
| 2739 | &mut invocation_usage, |
| 2740 | ) |
| 2741 | .await |
| 2742 | .expect("the outer retry should recover after the transient failure"); |
| 2743 | assert_eq!(invocation_usage.input_tokens, 34); |
| 2744 | assert_eq!(invocation_usage.output_tokens, 6); |
| 2745 | assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10)); |
| 2746 | assert_eq!(invocation_usage.reasoning_tokens, Some(4)); |
| 2747 | |
| 2748 | assert_eq!( |
| 2749 | result.retries_used, 2, |
| 2750 | "one quality retry plus one outer transient retry must be reported" |
| 2751 | ); |
| 2752 | assert_eq!( |
| 2753 | client |
| 2754 | .requests |
| 2755 | .lock() |
| 2756 | .expect("read scripted summary requests") |
| 2757 | .len(), |
| 2758 | 3, |
| 2759 | "the diagnostic count must match the two calls after the initial request" |
| 2760 | ); |
| 2761 | } |
| 2762 | |
| 2763 | #[tokio::test] |
| 2764 | async fn compaction_usage_survives_cancellation_during_quality_retry() { |
| 2765 | let _cost_scope = crate::cost_status::test_scope(); |
| 2766 | let retry_started = std::sync::Arc::new(tokio::sync::Notify::new()); |
| 2767 | let mut client = ScriptedSummaryClient::new(vec![vec![ContentBlock::Text { |
| 2768 | text: "...".to_string(), |
| 2769 | cache_control: None, |
| 2770 | }]]); |
| 2771 | client.retry_started = Some(std::sync::Arc::clone(&retry_started)); |
| 2772 | let messages = vec![msg("user", "Preserve the migration state.")]; |
| 2773 | let prepared = prepared(&CompactionConfig::default()); |
| 2774 | let mut invocation_usage = Usage::default(); |
| 2775 | { |
| 2776 | let compaction = |
| 2777 | compact_messages_safe(&client, &messages, None, &prepared, &mut invocation_usage); |
| 2778 | tokio::pin!(compaction); |
| 2779 | tokio::select! { |
| 2780 | result = &mut compaction => panic!("retry must remain pending: {result:?}"), |
| 2781 | _ = retry_started.notified() => {}, |
| 2782 | _ = tokio::time::sleep(Duration::from_secs(10)) => panic!("quality retry did not start"), |
| 2783 | } |
| 2784 | } |
| 2785 | assert_eq!(invocation_usage.input_tokens, 17); |
| 2786 | assert_eq!(invocation_usage.output_tokens, 3); |
| 2787 | assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(5)); |
| 2788 | assert_eq!(invocation_usage.reasoning_tokens, Some(2)); |
| 2789 | assert_eq!(client.requests.lock().unwrap().len(), 2); |
| 2790 | } |
| 2791 | |
| 2792 | #[tokio::test] |
| 2793 | async fn non_text_summary_failure_preserves_history_after_one_retry() { |
| 2794 | let original = vec![ |
| 2795 | msg( |
| 2796 | "user", |
| 2797 | "Do not lose the current branch or the failing test name.", |
| 2798 | ), |
| 2799 | msg("assistant", "The failing test is session_store::roundtrip."), |
| 2800 | ]; |
| 2801 | let client = ScriptedSummaryClient::new(vec![ |
| 2802 | vec![ContentBlock::thinking("internal-only response")], |
| 2803 | vec![ContentBlock::thinking("still no user-visible handoff")], |
| 2804 | ]); |
| 2805 | let config = CompactionConfig { |
| 2806 | model: "test-model".to_string(), |
| 2807 | cache_summary: false, |
| 2808 | ..Default::default() |
| 2809 | }; |
| 2810 | |
| 2811 | let mut invocation_usage = Usage::default(); |
| 2812 | let error = compact_messages_safe( |
| 2813 | &client, |
| 2814 | &original, |
| 2815 | None, |
| 2816 | &prepared(&config), |
| 2817 | &mut invocation_usage, |
| 2818 | ) |
| 2819 | .await |
| 2820 | .expect_err("two non-text responses must not replace history"); |
| 2821 | assert_eq!(invocation_usage.input_tokens, 34); |
| 2822 | assert_eq!(invocation_usage.output_tokens, 6); |
| 2823 | assert_eq!(invocation_usage.prompt_cache_hit_tokens, Some(10)); |
| 2824 | assert_eq!(invocation_usage.reasoning_tokens, Some(4)); |
| 2825 | |
| 2826 | assert!( |
| 2827 | error |
| 2828 | .to_string() |
| 2829 | .contains("remained unusable after one conservative retry"), |
| 2830 | "{error}" |
| 2831 | ); |
| 2832 | assert_eq!( |
| 2833 | client |
| 2834 | .requests |
| 2835 | .lock() |
| 2836 | .expect("read scripted summary requests") |
| 2837 | .len(), |
| 2838 | 2, |
| 2839 | "quality failure gets one retry, not the transient retry ladder" |
| 2840 | ); |
| 2841 | assert_eq!( |
| 2842 | original, |
| 2843 | vec![ |
| 2844 | msg( |
| 2845 | "user", |
| 2846 | "Do not lose the current branch or the failing test name." |
| 2847 | ), |
| 2848 | msg("assistant", "The failing test is session_store::roundtrip."), |
| 2849 | ], |
| 2850 | "borrowed source history must remain byte-for-byte unchanged" |
| 2851 | ); |
| 2852 | } |
| 2853 | #[tokio::test] |
| 2854 | async fn compaction_uses_the_resolved_route_output_allowance() { |
| 2855 | for (route_label, provider, model) in [ |
| 2856 | ( |
| 2857 | "thinking-default route", |
| 2858 | crate::config::ApiProvider::Deepseek, |
| 2859 | "deepseek-v4-flash", |
| 2860 | ), |
| 2861 | ( |
| 2862 | "fixed-sampling route", |
| 2863 | crate::config::ApiProvider::Moonshot, |
| 2864 | "k3", |
| 2865 | ), |
| 2866 | ] { |
| 2867 | let client = FixedSummaryClient::for_route(provider.as_str(), model); |
| 2868 | let config = CompactionConfig { |
| 2869 | model: model.to_string(), |
| 2870 | cache_summary: false, |
| 2871 | ..Default::default() |
| 2872 | }; |
| 2873 | compact_messages(&client, &[msg("user", "summarize this task")], &config) |
| 2874 | .await |
| 2875 | .expect("route compaction should complete"); |
| 2876 | |
| 2877 | let request = client |
| 2878 | .request |
| 2879 | .lock() |
| 2880 | .expect("read summary request") |
| 2881 | .clone() |
| 2882 | .expect("summary request was captured"); |
| 2883 | assert_eq!( |
| 2884 | request.max_tokens, |
| 2885 | crate::route_budget::effective_max_output_tokens_for_route(provider, model, None), |
| 2886 | "{route_label} must use the ordinary route output policy" |
| 2887 | ); |
| 2888 | assert_eq!(request.temperature, None); |
| 2889 | assert_eq!(request.top_p, None); |
| 2890 | } |
| 2891 | } |
| 2892 | |
| 2893 | struct TruncatedSummaryClient; |
| 2894 | |
| 2895 | #[async_trait::async_trait] |
| 2896 | impl crate::core::model_client::ModelClient for TruncatedSummaryClient { |
| 2897 | fn provider_name(&self) -> &str { |
| 2898 | "test" |
| 2899 | } |
| 2900 | |
| 2901 | fn model(&self) -> &str { |
| 2902 | "test-model" |
| 2903 | } |
| 2904 | |
| 2905 | async fn create_message( |
| 2906 | &self, |
| 2907 | _request: MessageRequest, |
| 2908 | ) -> anyhow::Result<codewhale_models::MessageResponse> { |
| 2909 | Ok(codewhale_models::MessageResponse { |
| 2910 | id: "summary-truncated".to_string(), |
| 2911 | r#type: "message".to_string(), |
| 2912 | role: "assistant".to_string(), |
| 2913 | content: vec![ContentBlock::Text { |
| 2914 | text: "1. Primary request and intent — mig".to_string(), |
| 2915 | cache_control: None, |
| 2916 | }], |
| 2917 | model: "test-model".to_string(), |
| 2918 | stop_reason: Some("max_tokens".to_string()), |
| 2919 | stop_sequence: None, |
| 2920 | container: None, |
| 2921 | usage: codewhale_models::Usage::default(), |
| 2922 | }) |
| 2923 | } |
| 2924 | |
| 2925 | async fn create_message_stream( |
| 2926 | &self, |
| 2927 | _request: MessageRequest, |
| 2928 | ) -> anyhow::Result<crate::llm_client::StreamEventBox> { |
| 2929 | anyhow::bail!("streaming is unused by compaction") |
| 2930 | } |
| 2931 | |
| 2932 | async fn health_check(&self) -> anyhow::Result<bool> { |
| 2933 | Ok(true) |
| 2934 | } |
| 2935 | } |
| 2936 | |
| 2937 | /// A provider-truncated summary must fail compaction instead of replacing |
| 2938 | /// session history with a fragment. |
| 2939 | #[tokio::test] |
| 2940 | async fn truncated_summary_response_fails_compaction() { |
| 2941 | let messages: Vec<Message> = (0..40) |
| 2942 | .map(|index| { |
| 2943 | msg( |
| 2944 | if index % 2 == 0 { "user" } else { "assistant" }, |
| 2945 | &format!("padding message {index} with enough text to compact"), |
| 2946 | ) |
| 2947 | }) |
| 2948 | .collect(); |
| 2949 | let config = CompactionConfig { |
| 2950 | model: "test-model".to_string(), |
| 2951 | cache_summary: false, |
| 2952 | ..Default::default() |
| 2953 | }; |
| 2954 | |
| 2955 | let error = compact_messages(&TruncatedSummaryClient, &messages, &config) |
| 2956 | .await |
| 2957 | .expect_err("a truncated summary must not be committed"); |
| 2958 | let text = error.to_string(); |
| 2959 | assert!(text.contains("incomplete"), "{text}"); |
| 2960 | assert!(text.contains("max_tokens"), "{text}"); |
| 2961 | } |
| 2962 | |
| 2963 | #[test] |
| 2964 | fn estimate_tokens_empty_messages() { |
| 2965 | let messages: Vec<Message> = vec![]; |
| 2966 | assert_eq!(estimate_tokens(&messages), 0); |
| 2967 | } |
| 2968 | |
| 2969 | #[test] |
| 2970 | fn estimate_tokens_with_text() { |
| 2971 | let messages = vec![Message { |
| 2972 | role: Role::User, |
| 2973 | content: vec![ContentBlock::Text { |
| 2974 | text: "Hello, world!".to_string(), // 13 chars = ~3 tokens |
| 2975 | cache_control: None, |
| 2976 | }], |
| 2977 | }]; |
| 2978 | let tokens = estimate_tokens(&messages); |
| 2979 | assert!(tokens > 0 && tokens < 10); |
| 2980 | } |
| 2981 | |
| 2982 | #[test] |
| 2983 | fn pressure_counts_text_only_reasoning_and_server_tool_payloads() { |
| 2984 | let payload = "retained evidence ".repeat(1000); |
| 2985 | let blocks = vec![ |
| 2986 | ContentBlock::thinking(payload.clone()), |
| 2987 | ContentBlock::ServerToolUse { |
| 2988 | id: "server-call".into(), |
| 2989 | name: "code_execution".into(), |
| 2990 | input: json!({"code": payload}), |
| 2991 | }, |
| 2992 | ContentBlock::CodeExecutionToolResult { |
| 2993 | tool_use_id: "server-call".into(), |
| 2994 | content: json!({"stdout": payload}), |
| 2995 | }, |
| 2996 | ContentBlock::ToolSearchToolResult { |
| 2997 | tool_use_id: "search-call".into(), |
| 2998 | content: json!({"description": payload}), |
| 2999 | }, |
| 3000 | ]; |
| 3001 | for block in blocks { |
| 3002 | let messages = vec![Message { |
| 3003 | role: Role::Assistant, |
| 3004 | content: vec![block], |
| 3005 | }]; |
| 3006 | assert!(estimate_tokens(&messages) >= payload.len() / 4); |
| 3007 | assert!(estimate_input_tokens_for_pressure(&messages, None) >= payload.len() / 4); |
| 3008 | } |
| 3009 | } |
| 3010 | |
| 3011 | #[test] |
| 3012 | fn estimate_tokens_counts_tool_round_thinking_across_turns() { |
| 3013 | // Per DeepSeek thinking-mode rules, any assistant message that |
| 3014 | // performed a tool call keeps its reasoning_content in the request |
| 3015 | // forever, including across new user turns. Token estimates must |
| 3016 | // count those bytes. |
| 3017 | let thinking = "reasoning ".repeat(800); |
| 3018 | let current_messages = vec![ |
| 3019 | Message { |
| 3020 | role: Role::User, |
| 3021 | content: vec![ContentBlock::Text { |
| 3022 | text: "Use a tool".to_string(), |
| 3023 | cache_control: None, |
| 3024 | }], |
| 3025 | }, |
| 3026 | Message { |
| 3027 | role: Role::Assistant, |
| 3028 | content: vec![ |
| 3029 | ContentBlock::Thinking { |
| 3030 | signature: None, |
| 3031 | state: None, |
| 3032 | thinking: thinking.clone(), |
| 3033 | }, |
| 3034 | ContentBlock::ToolUse { |
| 3035 | id: "tool-1".to_string(), |
| 3036 | name: "read_file".to_string(), |
| 3037 | input: serde_json::json!({"path": "Cargo.toml"}), |
| 3038 | caller: None, |
| 3039 | thought_signature: None, |
| 3040 | }, |
| 3041 | ], |
| 3042 | }, |
| 3043 | Message { |
| 3044 | role: Role::User, |
| 3045 | content: vec![ContentBlock::ToolResult { |
| 3046 | tool_use_id: "tool-1".to_string(), |
| 3047 | content: "manifest".to_string(), |
| 3048 | is_error: None, |
| 3049 | content_blocks: None, |
| 3050 | }], |
| 3051 | }, |
| 3052 | ]; |
| 3053 | let historical_messages = { |
| 3054 | let mut messages = current_messages.clone(); |
| 3055 | messages.push(Message { |
| 3056 | role: Role::Assistant, |
| 3057 | content: vec![ContentBlock::Text { |
| 3058 | text: "Done.".to_string(), |
| 3059 | cache_control: None, |
| 3060 | }], |
| 3061 | }); |
| 3062 | messages.push(Message { |
| 3063 | role: Role::User, |
| 3064 | content: vec![ContentBlock::Text { |
| 3065 | text: "Next question.".to_string(), |
| 3066 | cache_control: None, |
| 3067 | }], |
| 3068 | }); |
| 3069 | messages |
| 3070 | }; |
| 3071 | let completed_messages = { |
| 3072 | let mut messages = current_messages.clone(); |
| 3073 | messages.push(Message { |
| 3074 | role: Role::Assistant, |
| 3075 | content: vec![ContentBlock::Text { |
| 3076 | text: "Done.".to_string(), |
| 3077 | cache_control: None, |
| 3078 | }], |
| 3079 | }); |
| 3080 | messages |
| 3081 | }; |
| 3082 | |
| 3083 | let lower_bound = thinking.len() / 5; |
| 3084 | assert!(estimate_tokens(¤t_messages) > lower_bound); |
| 3085 | assert!(estimate_tokens(&completed_messages) > lower_bound); |
| 3086 | assert!(estimate_tokens(&historical_messages) > lower_bound); |
| 3087 | } |
| 3088 | |
| 3089 | #[test] |
| 3090 | fn should_compact_respects_enabled_flag() { |
| 3091 | let config = CompactionConfig { |
| 3092 | enabled: false, |
| 3093 | ..Default::default() |
| 3094 | }; |
| 3095 | // Even with many messages, disabled compaction should return false |
| 3096 | let messages: Vec<Message> = (0..100) |
| 3097 | .map(|_| Message { |
| 3098 | role: Role::User, |
| 3099 | content: vec![ContentBlock::Text { |
| 3100 | text: "test".to_string(), |
| 3101 | cache_control: None, |
| 3102 | }], |
| 3103 | }) |
| 3104 | .collect(); |
| 3105 | assert!(!should_compact(&messages, None, &prepared(&config))); |
| 3106 | } |
| 3107 | |
| 3108 | /// The #5577 acceptance case: a session whose provider bills 842K prompt |
| 3109 | /// tokens on a 1M window (threshold 800K) MUST compact even when the |
| 3110 | /// local estimate is far lower — the bounded working list undercounts |
| 3111 | /// what the provider actually saw, and billed truth wins. |
| 3112 | #[test] |
| 3113 | fn billed_842k_on_a_1m_window_compacts_despite_a_small_estimate() { |
| 3114 | let config = CompactionConfig { |
| 3115 | enabled: true, |
| 3116 | token_threshold: 800_000, |
| 3117 | ..Default::default() |
| 3118 | }; |
| 3119 | let messages: Vec<Message> = (0..40) |
| 3120 | .map(|i| Message { |
| 3121 | role: if i % 2 == 0 { |
| 3122 | Role::User |
| 3123 | } else { |
| 3124 | Role::Assistant |
| 3125 | }, |
| 3126 | content: vec![ContentBlock::Text { |
| 3127 | text: format!("short message {i}"), |
| 3128 | cache_control: None, |
| 3129 | }], |
| 3130 | }) |
| 3131 | .collect(); |
| 3132 | // Estimate alone stays far under the trigger… |
| 3133 | assert!(!should_compact(&messages, None, &prepared(&config))); |
| 3134 | // …but the provider's billed prompt total decides. |
| 3135 | assert_eq!( |
| 3136 | compaction_decision_with_billed(&messages, None, &prepared(&config), Some(842_000)), |
| 3137 | CompactionDecision::Compact |
| 3138 | ); |
| 3139 | } |
| 3140 | |
| 3141 | /// A refusal under real pressure must name its guard so the host can |
| 3142 | /// tell the user, instead of the silent hold that reads as a broken |
| 3143 | /// auto-compactor (#5577). |
| 3144 | #[test] |
| 3145 | fn refusals_under_pressure_name_their_guard() { |
| 3146 | let config = CompactionConfig { |
| 3147 | enabled: true, |
| 3148 | token_threshold: 100, |
| 3149 | ..Default::default() |
| 3150 | }; |
| 3151 | // Too few messages to summarize: over-pressure, short transcript. |
| 3152 | let few: Vec<Message> = (0..3) |
| 3153 | .map(|i| Message { |
| 3154 | role: Role::User, |
| 3155 | content: vec![ContentBlock::Text { |
| 3156 | text: format!("message {i} {}", "x".repeat(300)), |
| 3157 | cache_control: None, |
| 3158 | }], |
| 3159 | }) |
| 3160 | .collect(); |
| 3161 | assert_eq!( |
| 3162 | compaction_decision_with_billed(&few, None, &prepared(&config), None), |
| 3163 | CompactionDecision::Refused(CompactionRefusal::TooFewMessages { count: few.len() }) |
| 3164 | ); |
| 3165 | |
| 3166 | // Retained floor above the trigger: a giant system prompt no pass |
| 3167 | // can reclaim. The refusal carries the numbers the user needs. |
| 3168 | let many: Vec<Message> = (0..12) |
| 3169 | .map(|i| Message { |
| 3170 | role: if i % 2 == 0 { |
| 3171 | Role::User |
| 3172 | } else { |
| 3173 | Role::Assistant |
| 3174 | }, |
| 3175 | content: vec![ContentBlock::Text { |
| 3176 | text: format!("message {i} {}", "y".repeat(200)), |
| 3177 | cache_control: None, |
| 3178 | }], |
| 3179 | }) |
| 3180 | .collect(); |
| 3181 | let system = SystemPrompt::Text("s".repeat(4_000)); |
| 3182 | match compaction_decision_with_billed(&many, Some(&system), &prepared(&config), None) { |
| 3183 | CompactionDecision::Refused(CompactionRefusal::RetainedFloor { floor, threshold }) => { |
| 3184 | assert_eq!(threshold, 100); |
| 3185 | assert!(floor >= threshold, "floor {floor} must be over {threshold}"); |
| 3186 | } |
| 3187 | other => panic!("expected a retained-floor refusal, got {other:?}"), |
| 3188 | } |
| 3189 | } |
| 3190 | |
| 3191 | /// v0.8.11: message-count is no longer a compaction trigger. Long |
| 3192 | /// chats of small messages stay uncompacted because rewriting the |
| 3193 | /// prefix cache for a tiny budget reclaim is net-negative. Only token |
| 3194 | /// pressure (and the explicit `/compact` slash command) trigger |
| 3195 | /// compaction. |
| 3196 | #[test] |
| 3197 | fn message_count_no_longer_triggers_compaction() { |
| 3198 | let config = CompactionConfig { |
| 3199 | enabled: true, |
| 3200 | token_threshold: 1_000_000, |
| 3201 | ..Default::default() |
| 3202 | }; |
| 3203 | |
| 3204 | // 200 tiny messages, well above the prior message threshold. |
| 3205 | let many_messages: Vec<Message> = (0..200) |
| 3206 | .map(|_| Message { |
| 3207 | role: Role::User, |
| 3208 | content: vec![ContentBlock::Text { |
| 3209 | text: "x".to_string(), |
| 3210 | cache_control: None, |
| 3211 | }], |
| 3212 | }) |
| 3213 | .collect(); |
| 3214 | // Token total stays minuscule so the token threshold is not hit; |
| 3215 | // without the prior message-count trigger, no compaction. |
| 3216 | assert!(!should_compact(&many_messages, None, &prepared(&config))); |
| 3217 | } |
| 3218 | |
| 3219 | // ======================================================================== |
| 3220 | // Additional Compaction Trigger Tests |
| 3221 | // ======================================================================== |
| 3222 | |
| 3223 | #[test] |
| 3224 | fn full_request_pressure_crosses_token_threshold() { |
| 3225 | let config = CompactionConfig { |
| 3226 | enabled: true, |
| 3227 | token_threshold: 20_000, |
| 3228 | ..Default::default() |
| 3229 | }; |
| 3230 | |
| 3231 | // Create messages that exceed token threshold |
| 3232 | let messages: Vec<Message> = (0..20).map(|_| msg("user", &"x".repeat(5_000))).collect(); |
| 3233 | |
| 3234 | assert!(compaction_pressure_reached(&messages, None, &config)); |
| 3235 | } |
| 3236 | |
| 3237 | #[test] |
| 3238 | fn auto_compaction_uses_full_request_pressure_across_context_sizes() { |
| 3239 | for (window, output_reserve) in [ |
| 3240 | (128_000_u64, 4_096_u64), |
| 3241 | (272_000, 4_096), |
| 3242 | // Large windows use the same ordinary request reservation; there |
| 3243 | // is no second, non-wire reasoning allowance. |
| 3244 | (1_000_000, 65_536), |
| 3245 | ] { |
| 3246 | let budget = crate::context_budget::ContextBudget::new(window, 0, output_reserve); |
| 3247 | let threshold = usize::try_from(budget.compaction_trigger_for_percent(80.0)) |
| 3248 | .expect("test threshold fits usize"); |
| 3249 | let raw_target = threshold.saturating_mul(7) / 10; |
| 3250 | let chars_per_message = raw_target.saturating_mul(4) / 14; |
| 3251 | let messages: Vec<Message> = (0..14) |
| 3252 | .map(|index| { |
| 3253 | msg( |
| 3254 | if index % 2 == 0 { "user" } else { "assistant" }, |
| 3255 | &"x".repeat(chars_per_message), |
| 3256 | ) |
| 3257 | }) |
| 3258 | .collect(); |
| 3259 | let raw = estimate_tokens(&messages); |
| 3260 | let full = estimate_input_tokens_for_pressure(&messages, None); |
| 3261 | let config = CompactionConfig { |
| 3262 | enabled: true, |
| 3263 | token_threshold: threshold, |
| 3264 | ..Default::default() |
| 3265 | }; |
| 3266 | |
| 3267 | assert!( |
| 3268 | raw < threshold, |
| 3269 | "raw message estimator alone must not cross {window}" |
| 3270 | ); |
| 3271 | // The pressure estimate adds per-message framing on top of the |
| 3272 | // raw message tokens; billed usage from the provider can also |
| 3273 | // cross the trigger on its own. |
| 3274 | assert!( |
| 3275 | full < threshold, |
| 3276 | "70%-filled fixture must stay under the {window} trigger: {full} >= {threshold}" |
| 3277 | ); |
| 3278 | assert!( |
| 3279 | crate::compaction::compaction_pressure_reached_with_billed( |
| 3280 | &messages, |
| 3281 | None, |
| 3282 | &config, |
| 3283 | Some(threshold as u64), |
| 3284 | ), |
| 3285 | "billed prompt tokens at the trigger must reach pressure for {window}" |
| 3286 | ); |
| 3287 | assert!( |
| 3288 | crate::compaction::should_compact_with_billed( |
| 3289 | &messages, |
| 3290 | None, |
| 3291 | &prepared(&config), |
| 3292 | Some(threshold as u64), |
| 3293 | ), |
| 3294 | "billed pressure must trigger eligibility for a {window}-token route" |
| 3295 | ); |
| 3296 | } |
| 3297 | } |
| 3298 | |
| 3299 | #[test] |
| 3300 | fn auto_compaction_skips_pressure_that_cannot_be_reclaimed_below_trigger() { |
| 3301 | let messages: Vec<Message> = (0..20) |
| 3302 | .map(|index| { |
| 3303 | msg( |
| 3304 | if index % 2 == 0 { "user" } else { "assistant" }, |
| 3305 | &"x".repeat(500), |
| 3306 | ) |
| 3307 | }) |
| 3308 | .collect(); |
| 3309 | let system = SystemPrompt::Text("s".repeat(24_000)); |
| 3310 | let config = CompactionConfig { |
| 3311 | enabled: true, |
| 3312 | token_threshold: 10_000, |
| 3313 | ..Default::default() |
| 3314 | }; |
| 3315 | |
| 3316 | assert!( |
| 3317 | estimate_input_tokens_conservative(&messages, Some(&system)) >= config.token_threshold, |
| 3318 | "fixture must be under full-request pressure" |
| 3319 | ); |
| 3320 | assert!( |
| 3321 | !should_compact(&messages, Some(&system), &prepared(&config)), |
| 3322 | "a pinned/system floor above the trigger would loop every tool step" |
| 3323 | ); |
| 3324 | } |
| 3325 | |
| 3326 | #[test] |
| 3327 | fn full_request_threshold_is_inclusive() { |
| 3328 | let messages: Vec<Message> = (0..10) |
| 3329 | .map(|index| msg(if index % 2 == 0 { "user" } else { "assistant" }, "payload")) |
| 3330 | .collect(); |
| 3331 | let threshold = estimate_input_tokens_for_pressure(&messages, None); |
| 3332 | let config = CompactionConfig { |
| 3333 | enabled: true, |
| 3334 | token_threshold: threshold, |
| 3335 | ..Default::default() |
| 3336 | }; |
| 3337 | |
| 3338 | assert!(compaction_pressure_reached(&messages, None, &config)); |
| 3339 | } |
| 3340 | |
| 3341 | #[test] |
| 3342 | fn test_should_compact_below_token_threshold() { |
| 3343 | let config = CompactionConfig { |
| 3344 | enabled: true, |
| 3345 | token_threshold: 1000, |
| 3346 | ..Default::default() |
| 3347 | }; |
| 3348 | |
| 3349 | // Create short messages |
| 3350 | let messages: Vec<Message> = (0..5).map(|_| msg("user", "short")).collect(); |
| 3351 | |
| 3352 | assert!(!should_compact(&messages, None, &prepared(&config))); |
| 3353 | } |
| 3354 | |
| 3355 | #[test] |
| 3356 | fn auto_compaction_uses_token_threshold_without_fixed_floor() { |
| 3357 | let config = CompactionConfig { |
| 3358 | enabled: true, |
| 3359 | token_threshold: 20_000, |
| 3360 | ..Default::default() |
| 3361 | }; |
| 3362 | |
| 3363 | // Long sessions are dominated by assistant/tool output; the retained |
| 3364 | // user tail stays small, so the pass is reclaimable. |
| 3365 | let messages: Vec<Message> = (0..20) |
| 3366 | .map(|index| { |
| 3367 | if index % 2 == 0 { |
| 3368 | msg("user", &"x".repeat(100)) |
| 3369 | } else { |
| 3370 | msg("assistant", &"x".repeat(10_000)) |
| 3371 | } |
| 3372 | }) |
| 3373 | .collect(); |
| 3374 | assert!(should_compact(&messages, None, &prepared(&config))); |
| 3375 | } |
| 3376 | |
| 3377 | #[test] |
| 3378 | fn test_compaction_result_retries_used() { |
| 3379 | // This test verifies the CompactionResult structure |
| 3380 | let result = CompactionResult { |
| 3381 | messages: vec![], |
| 3382 | summary_prompt: None, |
| 3383 | retries_used: 2, |
| 3384 | coverage: CompactionCoverage::default(), |
| 3385 | }; |
| 3386 | |
| 3387 | assert_eq!(result.retries_used, 2); |
| 3388 | assert!(result.messages.is_empty()); |
| 3389 | } |
| 3390 | } |
| 3391 |