use crate::compact::strip_images_from_messages;
use crate::tools::config_tools::{
BASH_TOOL_NAME, FILE_EDIT_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_WRITE_TOOL_NAME, GLOB_TOOL_NAME,
GREP_TOOL_NAME, NOTEBOOK_EDIT_TOOL_NAME, POWERSHELL_TOOL_NAME, WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
};
use crate::types::Message;
use crate::utils::env_utils;
use std::collections::HashSet;
use std::sync::Mutex;
pub const TIME_BASED_MC_CLEARED_MESSAGE: &str = "[Old tool result content cleared]";
pub const IMAGE_MAX_TOKEN_SIZE: usize = 2000;
fn compactable_tools() -> HashSet<&'static str> {
let mut set = HashSet::new();
set.insert(FILE_READ_TOOL_NAME);
set.insert(BASH_TOOL_NAME);
set.insert(POWERSHELL_TOOL_NAME);
set.insert(GREP_TOOL_NAME);
set.insert(GLOB_TOOL_NAME);
set.insert(WEB_SEARCH_TOOL_NAME);
set.insert(WEB_FETCH_TOOL_NAME);
set.insert(FILE_EDIT_TOOL_NAME);
set.insert(FILE_WRITE_TOOL_NAME);
set.insert(NOTEBOOK_EDIT_TOOL_NAME);
set
}
pub fn evaluate_time_based_trigger(messages: &[Message]) -> Option<TimeBasedTriggerResult> {
let config = crate::services::compact::time_based_mc_config::get_time_based_mc_config();
if !config.enabled {
return None;
}
let last_assistant = messages
.iter()
.rev()
.find(|m| matches!(m.role, crate::types::MessageRole::Assistant));
let Some(last_msg) = last_assistant else {
return None;
};
let now_ms = chrono::Utc::now().timestamp_millis() as i64;
let last_ts = now_ms;
let gap_minutes = ((now_ms - last_ts) as f64 / 60_000.0).abs();
if !gap_minutes.is_finite() || gap_minutes < config.gap_threshold_minutes as f64 {
return None;
}
Some(TimeBasedTriggerResult {
gap_minutes,
config,
})
}
pub struct TimeBasedTriggerResult {
pub gap_minutes: f64,
pub config: crate::services::compact::time_based_mc_config::TimeBasedMCConfig,
}
pub fn collect_compactable_tool_ids(messages: &[Message]) -> Vec<String> {
let compactable = compactable_tools();
let mut ids = Vec::new();
for msg in messages {
if let crate::types::MessageRole::Assistant = msg.role {
if let Some(tool_calls) = &msg.tool_calls {
for tc in tool_calls {
if compactable.contains(tc.name.as_str()) {
ids.push(tc.id.clone());
}
}
}
}
}
ids
}
pub fn maybe_time_based_microcompact(messages: &mut [Message]) -> Option<TimeBasedMCResult> {
let trigger = evaluate_time_based_trigger(messages)?;
let config = trigger.config;
let compactable_ids = collect_compactable_tool_ids(messages);
let keep_recent = config.keep_recent.max(1);
let keep_set: HashSet<String> = compactable_ids
.iter()
.rev()
.take(keep_recent)
.cloned()
.collect();
let clear_set: HashSet<String> = compactable_ids
.iter()
.filter(|id| !keep_set.contains(*id))
.cloned()
.collect();
if clear_set.is_empty() {
return None;
}
let mut tokens_saved = 0;
for msg in messages.iter_mut() {
if let crate::types::MessageRole::Tool = msg.role {
if let Some(tool_call_id) = &msg.tool_call_id {
if clear_set.contains(tool_call_id) && msg.content != TIME_BASED_MC_CLEARED_MESSAGE
{
tokens_saved += crate::compact::rough_token_count_estimation(&msg.content, 4.0);
msg.content = TIME_BASED_MC_CLEARED_MESSAGE.to_string();
}
}
}
}
if tokens_saved == 0 {
return None;
}
log::debug!(
"[TIME-BASED MC] gap {:.0}min > {}min, cleared {} tool results (~{} tokens), kept last {}",
trigger.gap_minutes,
config.gap_threshold_minutes,
clear_set.len(),
tokens_saved,
keep_recent
);
reset_microcompact_state();
Some(TimeBasedMCResult {
tokens_saved,
tools_cleared: clear_set.len(),
})
}
pub struct TimeBasedMCResult {
pub tokens_saved: usize,
pub tools_cleared: usize,
}
struct CachedMCState {
registered_tools: HashSet<String>,
tool_order: Vec<String>,
deleted_refs: HashSet<String>,
pinned_edits: Vec<PinnedCacheEdit>,
}
struct PinnedCacheEdit {
user_message_index: usize,
block: serde_json::Value,
}
static CACHED_MC_STATE: Mutex<Option<CachedMCState>> = Mutex::new(None);
static PENDING_CACHE_EDITS: Mutex<Option<serde_json::Value>> = Mutex::new(None);
static MICROCMPACT_STATE_RESET: Mutex<bool> = Mutex::new(false);
pub fn reset_microcompact_state() {
if let Ok(mut state) = CACHED_MC_STATE.lock() {
*state = None;
}
if let Ok(mut pending) = PENDING_CACHE_EDITS.lock() {
*pending = None;
}
if let Ok(mut flag) = MICROCMPACT_STATE_RESET.lock() {
*flag = true;
}
log::debug!("[microcompact] State reset");
}
pub fn consume_pending_cache_edits() -> Option<serde_json::Value> {
PENDING_CACHE_EDITS.lock().ok().and_then(|mut p| p.take())
}
pub fn calculate_tool_result_tokens(content: &str) -> usize {
crate::compact::rough_token_count_estimation(content, 4.0)
}
pub fn estimate_message_tokens(messages: &[Message]) -> usize {
let mut total = 0;
for msg in messages {
match &msg.role {
crate::types::MessageRole::User | crate::types::MessageRole::Assistant => {
total += crate::compact::rough_token_count_estimation(&msg.content, 4.0);
}
crate::types::MessageRole::Tool => {
total += msg.content.len() / 2;
}
crate::types::MessageRole::System => {
total += crate::compact::rough_token_count_estimation(&msg.content, 4.0);
}
}
}
(total as f64 * (4.0 / 3.0)).ceil() as usize
}
pub fn microcompact_messages(messages: &mut [Message]) {
if let Some(_result) = maybe_time_based_microcompact(messages) {
return;
}
for msg in messages.iter_mut() {
if let crate::types::MessageRole::Tool = &msg.role {
if msg.content.len() > 16_000 {
let tool_name = msg.tool_call_id.as_deref().unwrap_or("Tool");
msg.content = truncate_tool_result_content(&msg.content, tool_name);
}
}
}
}
pub fn needs_microcompact(messages: &[Message], threshold: usize) -> bool {
let total_tool_chars: usize = messages
.iter()
.filter(|m| matches!(m.role, crate::types::MessageRole::Tool))
.map(|m| m.content.len())
.sum();
let estimated_tokens = total_tool_chars / 4;
estimated_tokens > threshold
}
pub fn truncate_tool_result_content(content: &str, tool_name: &str) -> String {
const MAX_TOOL_RESULT_CHARS: usize = 16_000;
const MAX_GLOB_RESULTS: usize = 100;
if tool_name == "Glob" {
let total_lines = content.lines().count();
if total_lines <= MAX_GLOB_RESULTS {
return content.to_string();
}
let lines: Vec<&str> = content.lines().take(MAX_GLOB_RESULTS).collect();
let truncated = lines.join("\n");
return format!(
"{}\n\n... ({} more files not shown. Use more specific glob patterns to reduce results)",
truncated,
total_lines.saturating_sub(MAX_GLOB_RESULTS)
);
}
if content.len() <= MAX_TOOL_RESULT_CHARS {
return content.to_string();
}
let chars: Vec<char> = content.chars().take(MAX_TOOL_RESULT_CHARS).collect();
format!(
"{}\n\n... (truncated {} characters)",
chars.into_iter().collect::<String>(),
content.len().saturating_sub(MAX_TOOL_RESULT_CHARS)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collect_compactable_tool_ids() {
let messages = vec![
Message {
role: crate::types::MessageRole::Assistant,
content: String::new(),
tool_calls: Some(vec![crate::types::ToolCall {
id: "call_1".to_string(),
r#type: "function".to_string(),
name: FILE_READ_TOOL_NAME.to_string(),
arguments: serde_json::json!({}),
}]),
..Default::default()
},
Message {
role: crate::types::MessageRole::Assistant,
content: String::new(),
tool_calls: Some(vec![crate::types::ToolCall {
id: "call_2".to_string(),
r#type: "function".to_string(),
name: "SomeOtherTool".to_string(),
arguments: serde_json::json!({}),
}]),
..Default::default()
},
];
let ids = collect_compactable_tool_ids(&messages);
assert_eq!(ids.len(), 1);
assert_eq!(ids[0], "call_1");
}
#[test]
fn test_truncate_tool_result_small() {
let content = "small content";
let result = truncate_tool_result_content(content, "Read");
assert_eq!(result, "small content");
}
#[test]
fn test_truncate_tool_result_large() {
let content = "x".repeat(20000);
let result = truncate_tool_result_content(&content, "Read");
assert!(result.len() < content.len());
assert!(result.contains("truncated"));
}
#[test]
fn test_estimate_message_tokens() {
let messages = vec![Message {
role: crate::types::MessageRole::User,
content: "Hello, this is a test message".to_string(),
..Default::default()
}];
let tokens = estimate_message_tokens(&messages);
assert!(tokens > 0);
}
#[test]
fn test_reset_microcompact_state() {
reset_microcompact_state();
assert!(*MICROCMPACT_STATE_RESET.lock().unwrap());
}
#[test]
fn test_calculate_tool_result_tokens() {
let content = "test content";
let tokens = calculate_tool_result_tokens(content);
assert!(tokens > 0);
}
}