use anyhow::Result;
use std::collections::HashMap;
use std::fmt::Write;
use std::time::Duration;
use crate::config::DEFAULT_TEXT_MODEL;
use crate::core::model_client::ModelClient;
use crate::logging;
use crate::models::{
CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt,
};
#[derive(Debug, Clone, PartialEq)]
pub struct CompactionConfig {
pub enabled: bool,
pub token_threshold: usize,
pub model: String,
pub image_input: crate::model_profile::SupportState,
pub effective_context_window: Option<u32>,
pub cache_summary: bool,
pub focus: Option<String>,
pub runtime_cost_owner: Option<String>,
pub workspace: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCompactionEnvelope {
pub config: CompactionConfig,
}
impl PreparedCompactionEnvelope {
#[must_use]
pub fn new(config: CompactionConfig) -> Self {
Self { config }
}
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
enabled: true,
token_threshold: 800_000,
model: DEFAULT_TEXT_MODEL.to_string(),
image_input: crate::model_profile::SupportState::Unknown,
effective_context_window: None,
cache_summary: true,
focus: None,
runtime_cost_owner: None,
workspace: None,
}
}
}
const COMPACTION_LANGUAGE_CONTRACT: &str = "Use the natural language of the most recent \
substantive user message for reasoning and user-facing prose. Keep code, identifiers, paths, \
commands, logs, tool payloads, quotations, and the English structural labels verbatim. English \
scaffolding is not a request to switch languages.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionFailureKind {
Deterministic,
Transient,
ContextOverflow,
}
impl CompactionFailureKind {
#[must_use]
pub fn is_transient(self) -> bool {
matches!(self, Self::Transient)
}
}
pub const KEEP_RECENT_MESSAGES: usize = 4;
const MIN_SUMMARIZE_MESSAGES: usize = 6;
const SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 240;
const TOOL_PRUNE_STOP_CHECK_BYTES: usize = 16 * 1024;
const RETAINED_TOOL_RESULT_MAX_CHARS: usize = 64 * 1024;
const RETAINED_THINKING_MAX_CHARS: usize = 16 * 1024;
const COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
const COMPACT_PROMPT: &str = "You are performing a context checkpoint compaction. Create a \
handoff summary for another LLM that will resume the task.\n\nInclude:\n\
- Current progress and key decisions made\n\
- Important context, constraints, or user preferences\n\
- What remains to be done (clear next steps)\n\
- Any critical data, examples, or references needed to continue (exact file paths, commands, and error text)\n\n\
Be concise, structured, and focused on helping the next LLM seamlessly continue the work. Do not call tools.\n\
Summarize the task, not the checkpoint machinery: do not mention compaction, checkpoints, or \
context management, and do not carry forward meta-commentary about them (e.g. \"context intact\") \
from earlier turns.";
const SUMMARY_HEADER: &str = "Another language model started to solve this problem and produced \
a summary of its thinking process. You also have access to the state of the tools that were used \
by that language model. Use this to build on the work that has already been done and avoid \
duplicating work. Here is the summary produced by the other language model, use the information \
in this summary to assist with your own analysis:";
pub const COMPACTION_SUMMARY_MARKER: &str = "Another language model started to solve this problem";
pub const LEGACY_COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)";
const COMPACTION_SUMMARY_BEGIN: &str = "<!-- compaction-summary:begin -->";
const COMPACTION_SUMMARY_END: &str = "<!-- compaction-summary:end -->";
#[must_use]
pub fn is_compaction_summary_text(text: &str) -> bool {
text.contains(COMPACTION_SUMMARY_MARKER) || text.contains(LEGACY_COMPACTION_SUMMARY_MARKER)
}
fn summary_section(text: &str) -> Option<&str> {
let begin = text.find(COMPACTION_SUMMARY_BEGIN)? + COMPACTION_SUMMARY_BEGIN.len();
let remainder = &text[begin..];
let end = remainder.find(COMPACTION_SUMMARY_END)?;
let summary = remainder[..end].trim();
(!summary.is_empty()).then_some(summary)
}
fn strip_summary_text(mut text: String) -> Option<String> {
while let Some(begin) = text.find(COMPACTION_SUMMARY_BEGIN) {
let after_begin = begin + COMPACTION_SUMMARY_BEGIN.len();
let end = text[after_begin..]
.find(COMPACTION_SUMMARY_END)
.map_or(text.len(), |offset| {
after_begin + offset + COMPACTION_SUMMARY_END.len()
});
text.replace_range(begin..end, "");
}
if let Some(marker) = text
.find(COMPACTION_SUMMARY_MARKER)
.or_else(|| text.find(LEGACY_COMPACTION_SUMMARY_MARKER))
{
text.truncate(marker);
}
let text = text.trim().to_string();
(!text.is_empty()).then_some(text)
}
#[must_use]
pub fn extract_compaction_summary(prompt: Option<&SystemPrompt>) -> Option<SystemPrompt> {
match prompt? {
SystemPrompt::Text(text) => summary_section(text)
.map(str::to_string)
.or_else(|| {
text.find(COMPACTION_SUMMARY_MARKER)
.or_else(|| text.find(LEGACY_COMPACTION_SUMMARY_MARKER))
.map(|start| text[start..].trim().to_string())
})
.map(SystemPrompt::Text),
SystemPrompt::Blocks(blocks) => {
let blocks = blocks
.iter()
.filter_map(|block| {
let text = summary_section(&block.text)
.map(str::to_string)
.or_else(|| {
block
.text
.find(COMPACTION_SUMMARY_MARKER)
.or_else(|| block.text.find(LEGACY_COMPACTION_SUMMARY_MARKER))
.map(|start| block.text[start..].trim().to_string())
})?;
let mut summary = block.clone();
summary.text = text;
Some(summary)
})
.collect::<Vec<_>>();
(!blocks.is_empty()).then_some(SystemPrompt::Blocks(blocks))
}
}
}
#[must_use]
pub fn strip_compaction_summaries(prompt: Option<&SystemPrompt>) -> Option<SystemPrompt> {
match prompt.cloned()? {
SystemPrompt::Text(text) => strip_summary_text(text).map(SystemPrompt::Text),
SystemPrompt::Blocks(blocks) => {
let blocks = blocks
.into_iter()
.filter_map(|mut block| {
block.text = strip_summary_text(block.text)?;
Some(block)
})
.collect::<Vec<_>>();
(!blocks.is_empty()).then_some(SystemPrompt::Blocks(blocks))
}
}
}
#[must_use]
pub fn summary_prompt_text(prompt: &SystemPrompt) -> String {
match prompt {
SystemPrompt::Text(text) => text.clone(),
SystemPrompt::Blocks(blocks) => blocks
.iter()
.map(|block| block.text.as_str())
.collect::<Vec<_>>()
.join("\n\n"),
}
}
#[must_use]
pub(crate) fn compaction_checkpoint_message(prompt: &SystemPrompt) -> Message {
Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: summary_prompt_text(prompt),
cache_control: None,
}],
}
}
#[must_use]
pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool {
user_text_of(message).is_some_and(|text| is_compaction_summary_text(&text))
}
fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
message
.content
.iter()
.map(|c| match c {
ContentBlock::Text { text, .. } => text.len() / 4,
ContentBlock::Thinking { thinking, .. } if include_thinking => thinking.len() / 4,
ContentBlock::Thinking { .. } => 0,
ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
.map(|s| s.len() / 4)
.unwrap_or(100),
ContentBlock::ToolResult {
content,
content_blocks,
..
} => {
let images = content_blocks.as_ref().map_or(0, |blocks| {
blocks
.iter()
.filter(|block| {
block.get("type").and_then(serde_json::Value::as_str) == Some("image")
})
.count()
});
content.len() / 4 + images * IMAGE_TOKEN_ESTIMATE
}
ContentBlock::ImageUrl { .. } => IMAGE_TOKEN_ESTIMATE,
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => 0,
})
.sum::<usize>()
}
const IMAGE_TOKEN_ESTIMATE: usize = 1000;
pub fn estimate_tokens(messages: &[Message]) -> usize {
messages
.iter()
.map(|message| estimate_tokens_for_message(message, message_has_tool_use(message)))
.sum()
}
fn message_has_tool_use(message: &Message) -> bool {
message
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
}
pub fn estimate_text_tokens_conservative(text: &str) -> usize {
text.chars().count().div_ceil(3)
}
fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize {
match system {
Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text),
Some(SystemPrompt::Blocks(blocks)) => blocks
.iter()
.map(|block| estimate_text_tokens_conservative(&block.text))
.sum(),
None => 0,
}
}
#[must_use]
pub fn estimate_input_tokens_conservative(
messages: &[Message],
system: Option<&SystemPrompt>,
) -> usize {
let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2);
let system_tokens = estimate_system_tokens_conservative(system);
let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
message_tokens
.saturating_add(system_tokens)
.saturating_add(framing_overhead)
}
#[must_use]
pub fn estimate_input_tokens_for_pressure(
messages: &[Message],
system: Option<&SystemPrompt>,
) -> usize {
let message_tokens = estimate_tokens(messages);
let system_tokens = estimate_system_tokens_conservative(system);
let framing_overhead = messages.len().saturating_mul(12).saturating_add(48);
message_tokens
.saturating_add(system_tokens)
.saturating_add(framing_overhead)
}
fn estimate_retained_floor_conservative(
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
prepared: &PreparedCompactionEnvelope,
) -> usize {
let config = &prepared.config;
let retained = retained_user_messages(messages, COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS);
let retained_tokens = estimate_tokens(&retained).saturating_mul(3).div_ceil(2);
let framing = retained.len().saturating_mul(12).saturating_add(48);
let anchors = user_anchors_section(config.workspace.as_deref());
let summary_scaffolding_tokens =
estimate_text_tokens_conservative(&build_compaction_summary_block_text("", &anchors));
let retained_system_prompt = strip_compaction_summaries(system_prompt);
retained_tokens
.saturating_add(estimate_system_tokens_conservative(
retained_system_prompt.as_ref(),
))
.saturating_add(framing)
.saturating_add(summary_scaffolding_tokens)
}
#[must_use]
pub fn compaction_pressure_reached(
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
config: &CompactionConfig,
) -> bool {
compaction_pressure_reached_with_billed(messages, system_prompt, config, None)
}
#[must_use]
pub fn compaction_pressure_reached_with_billed(
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
config: &CompactionConfig,
billed_input_tokens: Option<u64>,
) -> bool {
if !config.enabled {
return false;
}
let estimated = estimate_input_tokens_for_pressure(messages, system_prompt);
let billed = billed_input_tokens
.and_then(|tokens| usize::try_from(tokens).ok())
.unwrap_or(0);
estimated.max(billed) >= config.token_threshold
}
pub fn should_compact(
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
prepared: &PreparedCompactionEnvelope,
) -> bool {
should_compact_with_billed(messages, system_prompt, prepared, None)
}
pub fn should_compact_with_billed(
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
prepared: &PreparedCompactionEnvelope,
billed_input_tokens: Option<u64>,
) -> bool {
let config = &prepared.config;
if !config.enabled {
return false;
}
if !compaction_pressure_reached_with_billed(
messages,
system_prompt,
config,
billed_input_tokens,
) {
return false;
}
let mut projected_messages = messages.to_vec();
let pruned_bytes =
prune_tool_results_until(&mut projected_messages, KEEP_RECENT_MESSAGES, |_, _| false);
if pruned_bytes > 0 && !compaction_pressure_reached(&projected_messages, system_prompt, config)
{
return true;
}
if messages.len() < MIN_SUMMARIZE_MESSAGES {
return false;
}
estimate_retained_floor_conservative(messages, system_prompt, prepared) < config.token_threshold
}
fn truncate_chars(text: &str, max_chars: usize) -> &str {
if max_chars == 0 {
return "";
}
match text.char_indices().nth(max_chars) {
Some((idx, _)) => &text[..idx],
None => text,
}
}
fn tail_chars(text: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let total_chars = text.chars().count();
if total_chars <= max_chars {
return text.to_string();
}
let start_char = total_chars.saturating_sub(max_chars);
let start_idx = text
.char_indices()
.nth(start_char)
.map_or(0, |(idx, _)| idx);
text[start_idx..].to_string()
}
#[derive(Debug, Clone)]
struct ToolUseInfo {
name: String,
key: String,
args_preview: String,
}
fn tool_use_key(name: &str, input: &serde_json::Value) -> String {
format!(
"{name}:{}",
serde_json::to_string(input).unwrap_or_else(|_| input.to_string())
)
}
fn tool_args_preview(input: &serde_json::Value) -> String {
let redacted = codewhale_config::persistence::redact_json_secrets(input);
let raw = serde_json::to_string(&redacted).unwrap_or_else(|_| redacted.to_string());
truncate_chars(&raw, 120).to_string()
}
fn collect_tool_uses(messages: &[Message]) -> HashMap<String, ToolUseInfo> {
let mut tool_uses = HashMap::new();
for message in messages {
for block in &message.content {
if let ContentBlock::ToolUse {
id, name, input, ..
} = block
{
tool_uses.insert(
id.clone(),
ToolUseInfo {
name: name.clone(),
key: tool_use_key(name, input),
args_preview: tool_args_preview(input),
},
);
}
}
}
tool_uses
}
struct ToolResultPruneCandidate {
message_idx: usize,
block_idx: usize,
key: String,
tool_name: String,
args_preview: String,
original_len: usize,
}
fn tool_result_content_blocks_len(content_blocks: Option<&[serde_json::Value]>) -> usize {
content_blocks
.and_then(|blocks| serde_json::to_vec(blocks).ok())
.map_or(0, |bytes| bytes.len())
}
#[cfg(test)]
fn prune_tool_results(messages: &mut [Message], protected_window: usize) -> usize {
prune_tool_results_until(messages, protected_window, |_, _| false)
}
fn prune_tool_results_until<F>(
messages: &mut [Message],
protected_window: usize,
mut should_stop: F,
) -> usize
where
F: FnMut(&[Message], usize) -> bool,
{
let cutoff = messages.len().saturating_sub(protected_window);
if cutoff == 0 {
return 0;
}
let tool_uses = collect_tool_uses(messages);
let mut candidates = Vec::new();
let mut latest_by_key: HashMap<String, usize> = HashMap::new();
let mut count_by_key: HashMap<String, usize> = HashMap::new();
for (message_idx, message) in messages.iter().take(cutoff).enumerate() {
for (block_idx, block) in message.content.iter().enumerate() {
let ContentBlock::ToolResult {
tool_use_id,
content,
content_blocks,
..
} = block
else {
continue;
};
let Some(info) = tool_uses.get(tool_use_id) else {
continue;
};
latest_by_key.insert(info.key.clone(), message_idx);
*count_by_key.entry(info.key.clone()).or_insert(0) += 1;
candidates.push(ToolResultPruneCandidate {
message_idx,
block_idx,
key: info.key.clone(),
tool_name: info.name.clone(),
args_preview: info.args_preview.clone(),
original_len: content
.len()
.saturating_add(tool_result_content_blocks_len(content_blocks.as_deref())),
});
}
}
candidates.reverse();
let mut bytes_saved = 0usize;
for candidate in candidates {
let duplicate_count = count_by_key.get(&candidate.key).copied().unwrap_or(0);
let is_latest_duplicate = duplicate_count > 1
&& latest_by_key.get(&candidate.key) == Some(&candidate.message_idx);
if is_latest_duplicate {
continue;
}
if duplicate_count <= 1 && candidate.original_len <= SUMMARY_TOOL_RESULT_SNIPPET_CHARS {
continue;
}
let summary = format!(
"[{}] tool result pruned ({} bytes; args: {})",
candidate.tool_name, candidate.original_len, candidate.args_preview
);
if summary.len() >= candidate.original_len {
continue;
}
if let ContentBlock::ToolResult {
content,
content_blocks,
..
} = &mut messages[candidate.message_idx].content[candidate.block_idx]
{
bytes_saved = bytes_saved
.saturating_add(content.len().saturating_sub(summary.len()))
.saturating_add(tool_result_content_blocks_len(content_blocks.as_deref()));
*content = summary;
*content_blocks = None;
if should_stop(messages, bytes_saved) {
break;
}
}
}
bytes_saved
}
fn truncate_retained_block(label: &str, content: &mut String, max_chars: usize) -> bool {
let char_count = content.chars().count();
if char_count <= max_chars {
return false;
}
let snippet_budget = max_chars.saturating_sub(256).max(1024);
let head_chars = snippet_budget / 2;
let tail_chars_budget = snippet_budget.saturating_sub(head_chars);
let head = truncate_chars(content, head_chars).to_string();
let tail = tail_chars(content, tail_chars_budget);
*content =
format!("[{label} retained-history truncated from {char_count} chars]\n{head}\n…\n{tail}");
true
}
#[allow(clippy::collapsible_match)]
fn sanitize_retained_messages(mut messages: Vec<Message>) -> Vec<Message> {
for message in &mut messages {
for block in &mut message.content {
match block {
ContentBlock::ToolResult {
content,
content_blocks,
..
} => {
if truncate_retained_block(
"tool result",
content,
RETAINED_TOOL_RESULT_MAX_CHARS,
) {
*content_blocks = None;
}
}
ContentBlock::Thinking {
thinking,
signature,
..
} if signature.is_none() => {
truncate_retained_block(
"thinking block",
thinking,
RETAINED_THINKING_MAX_CHARS,
);
}
_ => {}
}
}
}
messages
}
#[derive(Debug)]
pub struct CompactionResult {
pub messages: Vec<Message>,
pub summary_prompt: Option<SystemPrompt>,
pub retries_used: u32,
}
fn classify_compaction_failure(e: &anyhow::Error) -> CompactionFailureKind {
if let Some(error) = llm_error_in_chain(e) {
return match error {
crate::llm_client::LlmError::ContextLengthError(_) => {
CompactionFailureKind::ContextOverflow
}
crate::llm_client::LlmError::QuotaExhausted(_) => CompactionFailureKind::Deterministic,
error if error.is_retryable() => CompactionFailureKind::Transient,
_ => CompactionFailureKind::Deterministic,
};
}
let text = e.to_string();
if is_context_window_error_message(&text) {
return CompactionFailureKind::ContextOverflow;
}
let category = crate::error_taxonomy::classify_error_message(&text);
match category {
crate::error_taxonomy::ErrorCategory::Network
| crate::error_taxonomy::ErrorCategory::RateLimit
| crate::error_taxonomy::ErrorCategory::Timeout => CompactionFailureKind::Transient,
_ => CompactionFailureKind::Deterministic,
}
}
fn llm_error_in_chain(error: &anyhow::Error) -> Option<&crate::llm_client::LlmError> {
error
.chain()
.find_map(|cause| cause.downcast_ref::<crate::llm_client::LlmError>())
}
#[must_use]
pub fn report_compaction_failure(
prefix: &str,
id: &str,
auto: bool,
error: &anyhow::Error,
) -> String {
let raw = error.to_string();
let safe_raw = crate::safe_label::safe_error_text(&raw);
tracing::warn!(
compaction_id = %id,
auto,
error = %safe_raw,
"context compaction failed"
);
let detail = match llm_error_in_chain(error) {
Some(crate::llm_client::LlmError::QuotaExhausted(_)) => {
"provider plan quota exhausted — switch provider/model or renew the provider plan"
.to_string()
}
Some(crate::llm_client::LlmError::RateLimited { .. }) => {
"provider rate limit blocked compaction — retry after the limit resets or switch provider/model"
.to_string()
}
Some(crate::llm_client::LlmError::AuthenticationError(_)) => {
"provider authentication failed — sign in or replace the credential, then retry"
.to_string()
}
Some(crate::llm_client::LlmError::AuthorizationError(_)) => {
"provider authorization rejected compaction — verify account access or switch provider/model"
.to_string()
}
_ => match crate::error_taxonomy::classify_error_message(&raw) {
crate::error_taxonomy::ErrorCategory::RateLimit => {
"provider rate limit blocked compaction — retry after the limit resets or switch provider/model"
.to_string()
}
crate::error_taxonomy::ErrorCategory::Authentication => {
"provider authentication failed — sign in or replace the credential, then retry"
.to_string()
}
crate::error_taxonomy::ErrorCategory::Authorization => {
"provider authorization rejected compaction — verify account access or switch provider/model"
.to_string()
}
_ => safe_raw,
},
};
format!("{prefix}: {detail}")
}
fn is_transient_error(e: &anyhow::Error) -> bool {
classify_compaction_failure(e).is_transient()
}
fn is_context_window_error_message(text: &str) -> bool {
let lower = text.to_lowercase();
lower.contains("too long for this model")
|| lower.contains("prompt is too long")
|| lower.contains("maximum prompt length")
|| lower.contains("maximum context length")
|| lower.contains("context_length_exceeded")
|| lower.contains("context window")
|| (lower.contains("context")
&& (lower.contains("token") || lower.contains("too long") || lower.contains("maximum")))
}
pub async fn compact_messages_safe(
client: &dyn ModelClient,
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
prepared: &PreparedCompactionEnvelope,
) -> Result<CompactionResult> {
const MAX_RETRIES: u32 = 3;
const BASE_DELAY_MS: u64 = 1000;
let config = &prepared.config;
let was_over_threshold = compaction_pressure_reached(messages, system_prompt, config);
let mut pruned_messages = messages.to_vec();
let mut now_under_threshold = false;
let mut next_stop_check_bytes = 0usize;
let pruned_bytes = prune_tool_results_until(
&mut pruned_messages,
KEEP_RECENT_MESSAGES,
|candidate_messages, bytes_saved| {
if !was_over_threshold || bytes_saved < next_stop_check_bytes {
return false;
}
next_stop_check_bytes = bytes_saved.saturating_add(TOOL_PRUNE_STOP_CHECK_BYTES);
now_under_threshold =
!compaction_pressure_reached(candidate_messages, system_prompt, config);
now_under_threshold
},
);
if was_over_threshold && pruned_bytes > 0 && !now_under_threshold {
now_under_threshold = !compaction_pressure_reached(&pruned_messages, system_prompt, config);
}
let compaction_input: &[Message] = if pruned_bytes > 0 {
logging::info(format!(
"Local tool-result prune saved {pruned_bytes} bytes before LLM compaction"
));
if was_over_threshold && now_under_threshold {
return Ok(CompactionResult {
messages: sanitize_retained_messages(pruned_messages),
summary_prompt: None,
retries_used: 0,
});
}
&pruned_messages
} else {
messages
};
let mut last_error: Option<anyhow::Error> = None;
let mut quality_retries = 0u32;
for attempt in 0..MAX_RETRIES {
if attempt > 0 {
let delay = Duration::from_millis(BASE_DELAY_MS * (1 << (attempt - 1)));
tokio::time::sleep(delay).await;
}
match compact_messages_with_metadata(client, compaction_input, config, &mut quality_retries)
.await
{
Ok((msgs, prompt, removed)) => {
drop(removed);
return Ok(CompactionResult {
messages: sanitize_retained_messages(msgs),
summary_prompt: prompt,
retries_used: attempt.saturating_add(quality_retries),
});
}
Err(e) => {
if !is_transient_error(&e) {
return Err(e);
}
last_error = Some(e);
}
}
}
Err(last_error
.unwrap_or_else(|| anyhow::anyhow!("Compaction failed after {MAX_RETRIES} retries")))
}
fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String {
let summary = summary.trim();
let summary = if summary.is_empty() {
"(no summary available)"
} else {
summary
};
let mut text = format!("{SUMMARY_HEADER}\n\n{summary}");
text.push_str(anchors);
text
}
fn retained_user_messages(messages: &[Message], max_tokens: usize) -> Vec<Message> {
let mut selected: Vec<Message> = Vec::new();
let mut remaining = max_tokens;
for msg in messages.iter().rev() {
if remaining == 0 {
break;
}
let Some(text) = user_text_of(msg) else {
continue;
};
if is_compaction_summary_text(&text) {
continue;
}
let tokens = estimate_text_tokens_conservative(&text);
let text = if tokens <= remaining {
remaining -= tokens;
text
} else {
let budget_chars = remaining.saturating_mul(3).max(1);
remaining = 0;
truncate_chars(&text, budget_chars).to_string()
};
selected.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text,
cache_control: None,
}],
});
}
selected.reverse();
selected
}
fn user_anchors_section(workspace: Option<&std::path::Path>) -> String {
let Some(workspace) = workspace else {
return String::new();
};
let primary = workspace.join(".codewhale").join("anchors.md");
let path = if primary.exists() {
primary
} else {
workspace.join(".deepseek").join("anchors.md")
};
match std::fs::read_to_string(path) {
Ok(contents) if !contents.trim().is_empty() => {
format!("\n\nUser-pinned anchors (verbatim):\n{}", contents.trim())
}
_ => String::new(),
}
}
#[cfg(test)]
async fn compact_messages(
client: &dyn ModelClient,
messages: &[Message],
config: &CompactionConfig,
) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
let mut quality_retries = 0;
let (messages, summary_prompt, removed) =
compact_messages_with_metadata(client, messages, config, &mut quality_retries).await?;
Ok((messages, summary_prompt, removed))
}
async fn compact_messages_with_metadata(
client: &dyn ModelClient,
messages: &[Message],
config: &CompactionConfig,
quality_retries: &mut u32,
) -> Result<(Vec<Message>, Option<SystemPrompt>, Vec<Message>)> {
if messages.is_empty() {
return Ok((Vec::new(), None, Vec::new()));
}
let summary = create_summary(client, messages, config, quality_retries).await?;
let anchors = user_anchors_section(config.workspace.as_deref());
let checkpoint_text = build_compaction_summary_block_text(&summary, &anchors);
let summary_block = SystemBlock {
block_type: "text".to_string(),
text: checkpoint_text.clone(),
cache_control: config.cache_summary.then(|| CacheControl {
cache_type: "ephemeral".to_string(),
}),
};
let mut retained = retained_user_messages(messages, COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS);
retained.push(compaction_checkpoint_message(&SystemPrompt::Text(
checkpoint_text,
)));
Ok((
retained,
Some(SystemPrompt::Blocks(vec![summary_block])),
Vec::new(),
))
}
fn compact_prompt(focus: Option<&str>) -> String {
let mut prompt = format!("{COMPACT_PROMPT} {COMPACTION_LANGUAGE_CONTRACT}");
if let Some(focus) = focus.map(str::trim).filter(|focus| !focus.is_empty()) {
let _ = write!(
prompt,
"\n\nThe user asked this compaction to focus on: {focus}"
);
}
prompt
}
fn compact_quality_retry_prompt(focus: Option<&str>) -> String {
let mut prompt = format!(
"The previous handoff response was empty or a placeholder. Return a substantive factual \
continuation handoff. State the user objective, completed and current work, hard constraints, verified \
evidence, unresolved failures, and the single next action. Do not refuse, call tools, discuss \
checkpoint machinery, or return a placeholder. {COMPACTION_LANGUAGE_CONTRACT}"
);
if let Some(focus) = focus.map(str::trim).filter(|focus| !focus.is_empty()) {
let _ = write!(
prompt,
"\n\nThe user asked this compaction to focus on: {focus}"
);
}
prompt
}
fn validate_compaction_summary(summary: &str) -> Result<()> {
let trimmed = summary.trim();
if trimmed.is_empty() {
anyhow::bail!("Compaction summary response was unusable: no text was returned.");
}
let normalized = trimmed
.trim_matches(|ch: char| !ch.is_alphanumeric())
.to_ascii_lowercase();
if normalized.is_empty() {
anyhow::bail!(
"Compaction summary response was unusable: only whitespace or punctuation was returned."
);
}
if matches!(
normalized.as_str(),
"no summary available"
| "summary unavailable"
| "no summary"
| "n/a"
| "na"
| "not available"
| "i cannot provide a summary"
| "i can't provide a summary"
| "unable to provide a summary"
) {
anyhow::bail!("Compaction summary response was unusable: a placeholder was returned.");
}
Ok(())
}
fn drop_oldest_history_messages(messages: &mut Vec<Message>) {
if messages.len() <= 1 {
return;
}
messages.remove(0);
while messages.len() > 1
&& messages[0]
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolResult { .. }))
{
messages.remove(0);
}
}
async fn create_summary(
client: &dyn ModelClient,
messages: &[Message],
config: &CompactionConfig,
quality_retries: &mut u32,
) -> Result<String> {
let mut request_messages = messages.to_vec();
let stripped_images = crate::image_attach::strip_images_when_unsupported(
&mut request_messages,
config.image_input,
&config.model,
);
if stripped_images > 0 {
logging::warn(format!(
"Compaction omitted {stripped_images} image block(s) unsupported by its route"
));
}
request_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: compact_prompt(config.focus.as_deref()),
cache_control: None,
}],
});
let mut quality_retry_used = false;
loop {
let cost_route = client.effective_route_envelope(&config.model, chrono::Utc::now());
let request = MessageRequest {
model: config.model.clone(),
messages: request_messages.clone(),
max_tokens: crate::route_budget::effective_max_output_tokens_for_route(
cost_route.provider,
&cost_route.model,
None,
),
system: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: None,
stream: Some(false),
temperature: None,
top_p: None,
};
let cost_scope = crate::cost_status::scope_token();
let response = match client.create_message(request).await {
Ok(response) => response,
Err(err) if is_context_window_error(&err) && request_messages.len() > 2 => {
logging::warn(format!(
"Compaction summary input over the context window ({err}); \
dropping the oldest history item and retrying"
));
drop_oldest_history_messages(&mut request_messages);
continue;
}
Err(err) => return Err(err),
};
crate::cost_status::report_effective_route_for_runtime(
cost_scope,
config.runtime_cost_owner.as_deref(),
&format!(
"compaction:dispatch:{}:response:{}",
cost_route
.dispatched_at
.timestamp_nanos_opt()
.unwrap_or_default(),
response.id
),
&cost_route,
&response.usage,
);
if crate::models::is_incomplete_stop_reason(response.stop_reason.as_deref()) {
anyhow::bail!(
"Compaction summary response incomplete: provider stop reason `{}`; the partial summary was not accepted.",
crate::models::stop_reason_detail(response.stop_reason.as_deref())
);
}
let summary = response
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if let Err(error) = validate_compaction_summary(&summary) {
if quality_retry_used {
return Err(error.context(
"Compaction summary remained unusable after one conservative retry; \
no replacement checkpoint was committed",
));
}
quality_retry_used = true;
*quality_retries = (*quality_retries).saturating_add(1);
logging::warn(
"Compaction provider returned an unusable successful response; retrying once with the conservative handoff prompt",
);
let Some(instruction) = request_messages.last_mut() else {
return Err(error.context(
"Compaction summary validation failed and the retry instruction was missing",
));
};
instruction.content = vec![ContentBlock::Text {
text: compact_quality_retry_prompt(config.focus.as_deref()),
cache_control: None,
}];
continue;
}
return Ok(summary);
}
}
fn is_context_window_error(e: &anyhow::Error) -> bool {
let text = e.to_string();
if crate::error_taxonomy::classify_error_message(&text)
!= crate::error_taxonomy::ErrorCategory::InvalidInput
{
return false;
}
let lower = text.to_lowercase();
lower.contains("context")
|| lower.contains("token")
|| lower.contains("prompt is too long")
|| lower.contains("requested")
|| lower.contains("maximum")
}
fn user_text_of(msg: &Message) -> Option<String> {
if msg.role != "user" {
return None;
}
let text = msg
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
}
#[cfg(test)]
#[path = "compaction/tests.rs"]
mod quota_tests;
#[cfg(test)]
mod tests {
use crate::models::{ImageUrlContent, Message};
#[test]
fn inline_image_estimates_nonzero_tokens() {
let msg = Message {
role: "user".to_string(),
content: vec![ContentBlock::ImageUrl {
image_url: ImageUrlContent {
url: "data:image/png;base64,AAAA".to_string(),
},
}],
};
assert!(
estimate_tokens_for_message(&msg, false) >= IMAGE_TOKEN_ESTIMATE,
"an inline image must not estimate to 0 tokens"
);
}
use super::*;
use serde_json::json;
fn msg(role: &str, text: &str) -> Message {
Message {
role: role.to_string(),
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
}
}
fn prepared(config: &CompactionConfig) -> PreparedCompactionEnvelope {
PreparedCompactionEnvelope::new(config.clone())
}
fn tool_use(id: &str, name: &str, input: serde_json::Value) -> Message {
Message {
role: "assistant".to_string(),
content: vec![ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input,
caller: None,
thought_signature: None,
}],
}
}
fn tool_result(id: &str, content: &str) -> Message {
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: None,
content_blocks: None,
}],
}
}
#[test]
fn truncate_chars_respects_unicode_boundaries() {
let text = "abc😀é";
assert_eq!(truncate_chars(text, 0), "");
assert_eq!(truncate_chars(text, 1), "a");
assert_eq!(truncate_chars(text, 3), "abc");
assert_eq!(truncate_chars(text, 4), "abc😀");
assert_eq!(truncate_chars(text, 5), "abc😀é");
}
#[test]
fn prune_tool_results_summarizes_old_verbose_outputs() {
let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
let mut messages = vec![
tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-1", &verbose),
msg("user", "recent question"),
msg("assistant", "recent answer"),
];
let saved = prune_tool_results(&mut messages, 2);
assert!(saved > 0);
let ContentBlock::ToolResult { content, .. } = &messages[1].content[0] else {
panic!("expected tool result");
};
assert!(content.contains("[read_file] tool result pruned"));
assert!(content.contains("Cargo.toml"));
assert!(content.len() < verbose.len());
}
#[test]
fn prune_tool_results_preserves_protected_tail() {
let verbose = "x".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 80);
let mut messages = vec![
msg("user", "older context"),
tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-1", &verbose),
];
let saved = prune_tool_results(&mut messages, 2);
assert_eq!(saved, 0);
let ContentBlock::ToolResult { content, .. } = &messages[2].content[0] else {
panic!("expected tool result");
};
assert_eq!(content, &verbose);
}
#[test]
fn prune_tool_results_preserves_prefix_bytes_when_reverse_prune_is_enough() {
let older_verbose = "old ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40);
let newer_verbose = "new ".repeat(SUMMARY_TOOL_RESULT_SNIPPET_CHARS + 40);
let mut messages = vec![
tool_use("call-old", "read_file", json!({"path": "old.txt"})),
tool_result("call-old", &older_verbose),
tool_use("call-new", "read_file", json!({"path": "new.txt"})),
tool_result("call-new", &newer_verbose),
msg("user", "protected tail"),
];
let original = messages.clone();
let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0);
assert!(saved > 0);
assert_eq!(&messages[..3], &original[..3]);
assert_eq!(&messages[4..], &original[4..]);
let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else {
panic!("expected pruned tool result");
};
assert!(content.contains("[read_file] tool result pruned"));
assert!(content.contains("new.txt"));
assert!(content.len() < newer_verbose.len());
}
#[test]
fn prune_tool_results_stops_after_newest_duplicate_prune() {
let oldest = "oldest ".repeat(80);
let middle = "middle ".repeat(80);
let latest = "latest ".repeat(80);
let mut messages = vec![
tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-1", &oldest),
tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-2", &middle),
tool_use("call-3", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-3", &latest),
msg("user", "protected tail"),
];
let original = messages.clone();
let saved = prune_tool_results_until(&mut messages, 1, |_, saved| saved > 0);
assert!(saved > 0);
assert_eq!(&messages[..3], &original[..3]);
assert_eq!(&messages[4..], &original[4..]);
let ContentBlock::ToolResult { content, .. } = &messages[3].content[0] else {
panic!("expected middle duplicate to be pruned");
};
assert!(content.contains("[read_file] tool result pruned"));
}
#[test]
fn prune_tool_results_dedupes_identical_reads_but_keeps_latest_full_body() {
let first = "first ".repeat(80);
let second = "second ".repeat(80);
let mut messages = vec![
tool_use("call-1", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-1", &first),
tool_use("call-2", "read_file", json!({"path": "Cargo.toml"})),
tool_result("call-2", &second),
msg("user", "tail"),
];
let saved = prune_tool_results(&mut messages, 1);
assert!(saved > 0);
let ContentBlock::ToolResult { content: older, .. } = &messages[1].content[0] else {
panic!("expected older tool result");
};
assert!(older.contains("tool result pruned"));
let ContentBlock::ToolResult {
content: latest, ..
} = &messages[3].content[0]
else {
panic!("expected latest tool result");
};
assert_eq!(latest, &second);
}
#[test]
fn context_window_errors_are_detected_for_summary_fallback() {
for msg in [
"HTTP 400 Bad Request: maximum context length is 1000000 tokens",
"invalid_request_error: prompt is too long for the current model",
"You requested 1000001 tokens but the maximum is 1000000",
"request exceeds context window",
] {
assert!(
is_context_window_error(&anyhow::anyhow!(msg)),
"expected context-window detection for `{msg}`",
);
}
assert!(!is_context_window_error(&anyhow::anyhow!(
"Invalid request: missing required field"
)));
assert!(!is_context_window_error(&anyhow::anyhow!(
"503 Service Unavailable"
)));
}
#[test]
fn tool_args_preview_redacts_sensitive_first_without_dropping_siblings() {
let input: serde_json::Value = serde_json::from_str(
r#"{"api_key":"sk-tool-secret-value","command":"cargo test -p auth"}"#,
)
.unwrap();
let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
assert_eq!(preview["api_key"], codewhale_config::persistence::REDACTED);
assert_eq!(preview["command"], "cargo test -p auth");
}
#[test]
fn tool_args_preview_redacts_sensitive_later_without_touching_earlier_fields() {
let input: serde_json::Value =
serde_json::from_str(r#"{"command":"cargo test","api_key":"plain-secret-value"}"#)
.unwrap();
let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
assert_eq!(preview["command"], "cargo test");
assert_eq!(preview["api_key"], codewhale_config::persistence::REDACTED);
}
#[test]
fn tool_args_preview_redacts_nested_sensitive_values_recursively() {
let input: serde_json::Value = serde_json::from_str(
r#"{"meta":{"token":"nested-secret","keep":"yes"},"steps":[{"password":"pw","name":"a"}]}"#,
)
.unwrap();
let preview: serde_json::Value = serde_json::from_str(&tool_args_preview(&input)).unwrap();
assert_eq!(
preview["meta"]["token"],
codewhale_config::persistence::REDACTED
);
assert_eq!(preview["meta"]["keep"], "yes");
assert_eq!(
preview["steps"][0]["password"],
codewhale_config::persistence::REDACTED
);
assert_eq!(preview["steps"][0]["name"], "a");
}
#[test]
fn tool_args_preview_redacts_complete_multi_word_secret_value() {
let input: serde_json::Value =
serde_json::from_str(r#"{"command":"run this","password":"hunter two words"}"#)
.unwrap();
let serialized = tool_args_preview(&input);
let preview: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(preview["command"], "run this");
assert_eq!(preview["password"], codewhale_config::persistence::REDACTED);
assert!(!serialized.contains("hunter"));
assert!(!serialized.contains("two words"));
}
struct FixedSummaryClient {
request: std::sync::Mutex<Option<MessageRequest>>,
provider: &'static str,
model: &'static str,
}
impl Default for FixedSummaryClient {
fn default() -> Self {
Self {
request: std::sync::Mutex::new(None),
provider: "test",
model: "test-model",
}
}
}
impl FixedSummaryClient {
fn for_route(provider: &'static str, model: &'static str) -> Self {
Self {
request: std::sync::Mutex::new(None),
provider,
model,
}
}
}
const FIXED_SUMMARY: &str = "1. Primary request and intent — migrate the session store. \
2. Key technical concepts — sqlite. 7. Pending tasks — finish the fixed clock. \
8. Current work — rerunning the session tests.";
struct ScriptedSummaryClient {
responses: std::sync::Mutex<std::collections::VecDeque<anyhow::Result<Vec<ContentBlock>>>>,
requests: std::sync::Mutex<Vec<MessageRequest>>,
}
impl ScriptedSummaryClient {
fn new(responses: Vec<Vec<ContentBlock>>) -> Self {
Self::with_outcomes(responses.into_iter().map(Ok).collect())
}
fn with_outcomes(responses: Vec<anyhow::Result<Vec<ContentBlock>>>) -> Self {
Self {
responses: std::sync::Mutex::new(responses.into()),
requests: std::sync::Mutex::new(Vec::new()),
}
}
}
#[async_trait::async_trait]
impl crate::core::model_client::ModelClient for ScriptedSummaryClient {
fn provider_name(&self) -> &str {
"test"
}
fn model(&self) -> &str {
"test-model"
}
async fn create_message(
&self,
request: MessageRequest,
) -> anyhow::Result<crate::models::MessageResponse> {
self.requests
.lock()
.expect("capture scripted summary request")
.push(request);
let outcome = self
.responses
.lock()
.expect("read scripted summary response")
.pop_front()
.ok_or_else(|| anyhow::anyhow!("scripted summary responses exhausted"))?;
let content = outcome?;
Ok(crate::models::MessageResponse {
id: "summary-scripted".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content,
model: "test-model".to_string(),
stop_reason: None,
stop_sequence: None,
container: None,
usage: crate::models::Usage::default(),
})
}
async fn create_message_stream(
&self,
_request: MessageRequest,
) -> anyhow::Result<crate::llm_client::StreamEventBox> {
anyhow::bail!("streaming is unused by compaction")
}
async fn health_check(&self) -> anyhow::Result<bool> {
Ok(true)
}
}
#[async_trait::async_trait]
impl crate::core::model_client::ModelClient for FixedSummaryClient {
fn provider_name(&self) -> &str {
self.provider
}
fn model(&self) -> &str {
self.model
}
async fn create_message(
&self,
request: MessageRequest,
) -> anyhow::Result<crate::models::MessageResponse> {
*self.request.lock().expect("capture summary request") = Some(request);
Ok(crate::models::MessageResponse {
id: "summary-fixture".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: FIXED_SUMMARY.to_string(),
cache_control: None,
}],
model: self.model.to_string(),
stop_reason: None,
stop_sequence: None,
container: None,
usage: crate::models::Usage::default(),
})
}
async fn create_message_stream(
&self,
_request: MessageRequest,
) -> anyhow::Result<crate::llm_client::StreamEventBox> {
anyhow::bail!("streaming is unused by compaction")
}
async fn health_check(&self) -> anyhow::Result<bool> {
Ok(true)
}
}
#[tokio::test]
async fn compaction_commits_summary_and_retains_recent_user_messages() {
let messages = vec![
msg(
"user",
"Objective: migrate the session store to sqlite without breaking existing logins",
),
msg("assistant", "Working on it."),
tool_use(
"t1",
"Bash",
json!({"command": "cargo test -p session-store"}),
),
tool_result("t1", "test session_store::roundtrip ... ok\nexit code 0"),
msg("user", "Sounds good, do it"),
msg("assistant", "Nearly done, rerunning the suite."),
];
let config = CompactionConfig {
model: "test-model".to_string(),
cache_summary: false,
..Default::default()
};
let client = FixedSummaryClient::default();
let (retained, summary_prompt, _) =
compact_messages(&client, &messages, &config).await.unwrap();
let request = client
.request
.lock()
.expect("read summary request")
.clone()
.expect("summary request was captured");
assert_eq!(&request.messages[..messages.len()], messages.as_slice());
assert_eq!(request.messages.len(), messages.len() + 1);
let ContentBlock::Text { text, .. } = &request.messages.last().unwrap().content[0] else {
panic!("final compaction instruction must be text");
};
assert!(!text.contains(COMPACTION_SUMMARY_MARKER));
assert_eq!(request.temperature, None);
assert_eq!(request.top_p, None);
assert_eq!(
request.max_tokens,
crate::route_budget::effective_max_output_tokens_for_route(
crate::config::ApiProvider::Custom,
"test-model",
None,
)
);
let Some(SystemPrompt::Blocks(blocks)) = summary_prompt else {
panic!("compaction must produce a summary system block");
};
let text = &blocks[0].text;
assert!(text.contains(FIXED_SUMMARY));
assert!(text.contains("Another language model"));
assert_eq!(retained.len(), 3);
assert!(retained.iter().all(|message| message.role == "user"));
assert!(retained[0].content.iter().any(|block| matches!(
block,
ContentBlock::Text { text, .. } if text.contains("Objective: migrate")
)));
assert!(retained[1].content.iter().any(|block| matches!(
block,
ContentBlock::Text { text, .. } if text == "Sounds good, do it"
)));
assert!(is_compaction_checkpoint_message(&retained[2]));
assert_eq!(user_text_of(&retained[2]).as_deref(), Some(text.as_str()));
}
#[test]
fn summary_quality_gate_rejects_empty_and_known_placeholder_text() {
for summary in [
"",
" \n\t ",
"...",
"。。。",
"🫧",
"N/A",
"(no summary available)",
"I cannot provide a summary.",
] {
let error = validate_compaction_summary(summary)
.expect_err("degenerate summary must fail closed");
assert!(error.to_string().contains("unusable"), "{error}");
}
validate_compaction_summary(FIXED_SUMMARY)
.expect("a substantive continuation handoff must be accepted");
validate_compaction_summary(
"目的: #4394の空要約を防止。完了: 検証と実装。制約: 履歴を変更しない。次: テスト実行。",
)
.expect("a concise multilingual handoff must not be rejected by prose length");
}
#[tokio::test]
async fn empty_successful_summary_retries_once_without_replacing_history() {
let original = vec![
msg(
"user",
"Keep the migration transactional and preserve existing sessions.",
),
msg(
"assistant",
"I am updating the session store and its fixtures.",
),
];
let client = ScriptedSummaryClient::new(vec![
vec![ContentBlock::Text {
text: " \n\t ".to_string(),
cache_control: None,
}],
vec![ContentBlock::Text {
text: FIXED_SUMMARY.to_string(),
cache_control: None,
}],
]);
let config = CompactionConfig {
model: "test-model".to_string(),
cache_summary: false,
..Default::default()
};
let result = compact_messages_safe(&client, &original, None, &prepared(&config))
.await
.expect("the conservative retry should recover a usable summary");
let requests = client
.requests
.lock()
.expect("read scripted summary requests");
assert_eq!(requests.len(), 2, "quality failure retries exactly once");
let ContentBlock::Text { text, .. } = &requests[1]
.messages
.last()
.expect("retry instruction")
.content[0]
else {
panic!("retry instruction must be text");
};
assert!(text.contains("previous handoff response was empty"));
drop(requests);
assert_eq!(
result.retries_used, 1,
"quality retry must reach diagnostics"
);
assert_eq!(original[0].role, "user", "source history remains untouched");
assert!(result.messages.iter().any(is_compaction_checkpoint_message));
let Some(SystemPrompt::Blocks(blocks)) = result.summary_prompt else {
panic!("recovered summary must be committed");
};
assert!(blocks[0].text.contains(FIXED_SUMMARY));
assert!(!blocks[0].text.contains("(no summary available)"));
}
#[tokio::test]
async fn quality_retry_count_survives_a_later_transient_failure() {
let client = ScriptedSummaryClient::with_outcomes(vec![
Ok(vec![ContentBlock::Text {
text: "...".to_string(),
cache_control: None,
}]),
Err(anyhow::anyhow!("request timed out")),
Ok(vec![ContentBlock::Text {
text: FIXED_SUMMARY.to_string(),
cache_control: None,
}]),
]);
let config = CompactionConfig {
model: "test-model".to_string(),
cache_summary: false,
..Default::default()
};
let result = compact_messages_safe(
&client,
&[msg("user", "Preserve the current migration state.")],
None,
&prepared(&config),
)
.await
.expect("the outer retry should recover after the transient failure");
assert_eq!(
result.retries_used, 2,
"one quality retry plus one outer transient retry must be reported"
);
assert_eq!(
client
.requests
.lock()
.expect("read scripted summary requests")
.len(),
3,
"the diagnostic count must match the two calls after the initial request"
);
}
#[tokio::test]
async fn non_text_summary_failure_preserves_history_after_one_retry() {
let original = vec![
msg(
"user",
"Do not lose the current branch or the failing test name.",
),
msg("assistant", "The failing test is session_store::roundtrip."),
];
let client = ScriptedSummaryClient::new(vec![
vec![ContentBlock::thinking("internal-only response")],
vec![ContentBlock::thinking("still no user-visible handoff")],
]);
let config = CompactionConfig {
model: "test-model".to_string(),
cache_summary: false,
..Default::default()
};
let error = compact_messages_safe(&client, &original, None, &prepared(&config))
.await
.expect_err("two non-text responses must not replace history");
assert!(
error
.to_string()
.contains("remained unusable after one conservative retry"),
"{error}"
);
assert_eq!(
client
.requests
.lock()
.expect("read scripted summary requests")
.len(),
2,
"quality failure gets one retry, not the transient retry ladder"
);
assert_eq!(
original,
vec![
msg(
"user",
"Do not lose the current branch or the failing test name."
),
msg("assistant", "The failing test is session_store::roundtrip."),
],
"borrowed source history must remain byte-for-byte unchanged"
);
}
#[tokio::test]
async fn compaction_uses_the_resolved_route_output_allowance() {
for (route_label, provider, model) in [
(
"thinking-default route",
crate::config::ApiProvider::Deepseek,
"deepseek-v4-flash",
),
(
"fixed-sampling route",
crate::config::ApiProvider::Moonshot,
"k3",
),
] {
let client = FixedSummaryClient::for_route(provider.as_str(), model);
let config = CompactionConfig {
model: model.to_string(),
cache_summary: false,
..Default::default()
};
compact_messages(&client, &[msg("user", "summarize this task")], &config)
.await
.expect("route compaction should complete");
let request = client
.request
.lock()
.expect("read summary request")
.clone()
.expect("summary request was captured");
assert_eq!(
request.max_tokens,
crate::route_budget::effective_max_output_tokens_for_route(provider, model, None),
"{route_label} must use the ordinary route output policy"
);
assert_eq!(request.temperature, None);
assert_eq!(request.top_p, None);
}
}
struct TruncatedSummaryClient;
#[async_trait::async_trait]
impl crate::core::model_client::ModelClient for TruncatedSummaryClient {
fn provider_name(&self) -> &str {
"test"
}
fn model(&self) -> &str {
"test-model"
}
async fn create_message(
&self,
_request: MessageRequest,
) -> anyhow::Result<crate::models::MessageResponse> {
Ok(crate::models::MessageResponse {
id: "summary-truncated".to_string(),
r#type: "message".to_string(),
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "1. Primary request and intent — mig".to_string(),
cache_control: None,
}],
model: "test-model".to_string(),
stop_reason: Some("max_tokens".to_string()),
stop_sequence: None,
container: None,
usage: crate::models::Usage::default(),
})
}
async fn create_message_stream(
&self,
_request: MessageRequest,
) -> anyhow::Result<crate::llm_client::StreamEventBox> {
anyhow::bail!("streaming is unused by compaction")
}
async fn health_check(&self) -> anyhow::Result<bool> {
Ok(true)
}
}
#[tokio::test]
async fn truncated_summary_response_fails_compaction() {
let messages: Vec<Message> = (0..40)
.map(|index| {
msg(
if index % 2 == 0 { "user" } else { "assistant" },
&format!("padding message {index} with enough text to compact"),
)
})
.collect();
let config = CompactionConfig {
model: "test-model".to_string(),
cache_summary: false,
..Default::default()
};
let error = compact_messages(&TruncatedSummaryClient, &messages, &config)
.await
.expect_err("a truncated summary must not be committed");
let text = error.to_string();
assert!(text.contains("incomplete"), "{text}");
assert!(text.contains("max_tokens"), "{text}");
}
#[test]
fn estimate_tokens_empty_messages() {
let messages: Vec<Message> = vec![];
assert_eq!(estimate_tokens(&messages), 0);
}
#[test]
fn estimate_tokens_with_text() {
let messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Hello, world!".to_string(), cache_control: None,
}],
}];
let tokens = estimate_tokens(&messages);
assert!(tokens > 0 && tokens < 10);
}
#[test]
fn estimate_tokens_counts_tool_round_thinking_across_turns() {
let thinking = "reasoning ".repeat(800);
let current_messages = vec![
Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Use a tool".to_string(),
cache_control: None,
}],
},
Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Thinking {
signature: None,
state: None,
thinking: thinking.clone(),
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "Cargo.toml"}),
caller: None,
thought_signature: None,
},
],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: "manifest".to_string(),
is_error: None,
content_blocks: None,
}],
},
];
let historical_messages = {
let mut messages = current_messages.clone();
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done.".to_string(),
cache_control: None,
}],
});
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Next question.".to_string(),
cache_control: None,
}],
});
messages
};
let completed_messages = {
let mut messages = current_messages.clone();
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done.".to_string(),
cache_control: None,
}],
});
messages
};
let lower_bound = thinking.len() / 5;
assert!(estimate_tokens(¤t_messages) > lower_bound);
assert!(estimate_tokens(&completed_messages) > lower_bound);
assert!(estimate_tokens(&historical_messages) > lower_bound);
}
#[test]
fn should_compact_respects_enabled_flag() {
let config = CompactionConfig {
enabled: false,
..Default::default()
};
let messages: Vec<Message> = (0..100)
.map(|_| Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "test".to_string(),
cache_control: None,
}],
})
.collect();
assert!(!should_compact(&messages, None, &prepared(&config)));
}
#[test]
fn message_count_no_longer_triggers_compaction() {
let config = CompactionConfig {
enabled: true,
token_threshold: 1_000_000,
..Default::default()
};
let many_messages: Vec<Message> = (0..200)
.map(|_| Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "x".to_string(),
cache_control: None,
}],
})
.collect();
assert!(!should_compact(&many_messages, None, &prepared(&config)));
}
#[test]
fn full_request_pressure_crosses_token_threshold() {
let config = CompactionConfig {
enabled: true,
token_threshold: 20_000,
..Default::default()
};
let messages: Vec<Message> = (0..20).map(|_| msg("user", &"x".repeat(5_000))).collect();
assert!(compaction_pressure_reached(&messages, None, &config));
}
#[test]
fn auto_compaction_uses_full_request_pressure_across_context_sizes() {
for (window, output_reserve) in [
(128_000_u64, 4_096_u64),
(272_000, 4_096),
(
1_000_000,
u64::from(crate::route_budget::TURN_MAX_OUTPUT_TOKENS),
),
] {
let budget = crate::context_budget::ContextBudget::new(window, 0, output_reserve);
let threshold = usize::try_from(budget.compaction_trigger_for_percent(80.0))
.expect("test threshold fits usize");
let raw_target = threshold.saturating_mul(7) / 10;
let chars_per_message = raw_target.saturating_mul(4) / 14;
let messages: Vec<Message> = (0..14)
.map(|index| {
msg(
if index % 2 == 0 { "user" } else { "assistant" },
&"x".repeat(chars_per_message),
)
})
.collect();
let raw = estimate_tokens(&messages);
let full = estimate_input_tokens_for_pressure(&messages, None);
let config = CompactionConfig {
enabled: true,
token_threshold: threshold,
..Default::default()
};
assert!(
raw < threshold,
"raw message estimator alone must not cross {window}"
);
assert!(
full < threshold,
"70%-filled fixture must stay under the {window} trigger: {full} >= {threshold}"
);
assert!(
crate::compaction::compaction_pressure_reached_with_billed(
&messages,
None,
&config,
Some(threshold as u64),
),
"billed prompt tokens at the trigger must reach pressure for {window}"
);
assert!(
crate::compaction::should_compact_with_billed(
&messages,
None,
&prepared(&config),
Some(threshold as u64),
),
"billed pressure must trigger eligibility for a {window}-token route"
);
}
}
#[test]
fn auto_compaction_skips_pressure_that_cannot_be_reclaimed_below_trigger() {
let messages: Vec<Message> = (0..20)
.map(|index| {
msg(
if index % 2 == 0 { "user" } else { "assistant" },
&"x".repeat(500),
)
})
.collect();
let system = SystemPrompt::Text("s".repeat(24_000));
let config = CompactionConfig {
enabled: true,
token_threshold: 10_000,
..Default::default()
};
assert!(
estimate_input_tokens_conservative(&messages, Some(&system)) >= config.token_threshold,
"fixture must be under full-request pressure"
);
assert!(
!should_compact(&messages, Some(&system), &prepared(&config)),
"a pinned/system floor above the trigger would loop every tool step"
);
}
#[test]
fn full_request_threshold_is_inclusive() {
let messages: Vec<Message> = (0..10)
.map(|index| msg(if index % 2 == 0 { "user" } else { "assistant" }, "payload"))
.collect();
let threshold = estimate_input_tokens_for_pressure(&messages, None);
let config = CompactionConfig {
enabled: true,
token_threshold: threshold,
..Default::default()
};
assert!(compaction_pressure_reached(&messages, None, &config));
}
#[test]
fn test_should_compact_below_token_threshold() {
let config = CompactionConfig {
enabled: true,
token_threshold: 1000,
..Default::default()
};
let messages: Vec<Message> = (0..5).map(|_| msg("user", "short")).collect();
assert!(!should_compact(&messages, None, &prepared(&config)));
}
#[test]
fn auto_compaction_uses_token_threshold_without_fixed_floor() {
let config = CompactionConfig {
enabled: true,
token_threshold: 20_000,
..Default::default()
};
let messages: Vec<Message> = (0..20)
.map(|index| {
if index % 2 == 0 {
msg("user", &"x".repeat(100))
} else {
msg("assistant", &"x".repeat(10_000))
}
})
.collect();
assert!(should_compact(&messages, None, &prepared(&config)));
}
#[test]
fn test_compaction_result_retries_used() {
let result = CompactionResult {
messages: vec![],
summary_prompt: None,
retries_used: 2,
};
assert_eq!(result.retries_used, 2);
assert!(result.messages.is_empty());
}
}