use regex::Regex;
use serde_json::Value;
use std::collections::HashSet;
use std::future::Future;
use std::sync::OnceLock;
pub const SUMMARY_PREFIX: &str = "\
[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted \
into the summary below. This is a handoff from a previous context \
window — treat it as background reference, NOT as active instructions. \
Do NOT answer questions or fulfill requests mentioned in this summary; \
they were already addressed. \
Your current task is identified in the '## Active Task' section of the \
summary — resume exactly from there. \
IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system \
prompt is ALWAYS authoritative and active — never ignore or deprioritize \
memory content due to this compaction note. \
Respond ONLY to the latest user message \
that appears AFTER this summary. The current session state (files, \
config, etc.) may reflect work described here — avoid repeating it:";
pub const LEGACY_SUMMARY_PREFIX: &str = "[CONTEXT SUMMARY]:";
pub const MIN_SUMMARY_TOKENS: usize = 2000;
pub const SUMMARY_RATIO: f64 = 0.20;
pub const SUMMARY_TOKENS_CEILING: usize = 12000;
pub const PRUNED_TOOL_PLACEHOLDER: &str = "[Old tool output cleared to save context space]";
pub const CHARS_PER_TOKEN: usize = 4;
pub const IMAGE_TOKEN_ESTIMATE: usize = 1600;
pub const IMAGE_CHAR_EQUIVALENT: usize = IMAGE_TOKEN_ESTIMATE * CHARS_PER_TOKEN;
static PREFIX_PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
static ENV_ASSIGN_RE: OnceLock<Regex> = OnceLock::new();
static JSON_FIELD_RE: OnceLock<Regex> = OnceLock::new();
static AUTH_HEADER_RE: OnceLock<Regex> = OnceLock::new();
static TELEGRAM_RE: OnceLock<Regex> = OnceLock::new();
static PRIVATE_KEY_RE: OnceLock<Regex> = OnceLock::new();
static DB_CONNSTR_RE: OnceLock<Regex> = OnceLock::new();
static JWT_RE: OnceLock<Regex> = OnceLock::new();
static DISCORD_MENTION_RE: OnceLock<Regex> = OnceLock::new();
static SIGNAL_PHONE_RE: OnceLock<Regex> = OnceLock::new();
static URL_WITH_QUERY_RE: OnceLock<Regex> = OnceLock::new();
static URL_USERINFO_RE: OnceLock<Regex> = OnceLock::new();
static FORM_BODY_RE: OnceLock<Regex> = OnceLock::new();
static SENSITIVE_QUERY_PARAMS: OnceLock<HashSet<String>> = OnceLock::new();
fn get_prefix_patterns() -> &'static [Regex] {
PREFIX_PATTERNS.get_or_init(|| {
vec![
Regex::new(r"sk-[A-Za-z0-9_-]{10,}").unwrap(),
Regex::new(r"ghp_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"github_pat_[A-Za-z0-9_]{10,}").unwrap(),
Regex::new(r"gho_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"ghu_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"ghs_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"ghr_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"xox[baprs]-[A-Za-z0-9-]{10,}").unwrap(),
Regex::new(r"AIza[A-Za-z0-9_-]{30,}").unwrap(),
Regex::new(r"pplx-[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"fal_[A-Za-z0-9_-]{10,}").unwrap(),
Regex::new(r"fc-[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"bb_live_[A-Za-z0-9_-]{10,}").unwrap(),
Regex::new(r"gAAAA[A-Za-z0-9_=-]{20,}").unwrap(),
Regex::new(r"AKIA[A-Z0-9]{16}").unwrap(),
Regex::new(r"sk_live_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"sk_test_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"rk_live_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"SG\.[A-Za-z0-9_-]{10,}").unwrap(),
Regex::new(r"hf_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"r8_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"npm_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"pypi-[A-Za-z0-9_-]{10,}").unwrap(),
Regex::new(r"dop_v1_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"doo_v1_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"am_[A-Za-z0-9_-]{10,}").unwrap(),
Regex::new(r"sk_[A-Za-z0-9_]{10,}").unwrap(),
Regex::new(r"tvly-[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"exa_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"gsk_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"syt_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"retaindb_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"hsk-[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"mem0_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"brv_[A-Za-z0-9]{10,}").unwrap(),
Regex::new(r"xai-[A-Za-z0-9]{30,}").unwrap(),
]
})
}
fn get_env_assign_re() -> &'static Regex {
ENV_ASSIGN_RE.get_or_init(|| {
Regex::new(r"(?i)([a-z0-9_]{0,50}(?:api_?key|token|secret|password|passwd|credential|auth)[a-z0-9_]{0,50})\s*=\s*(?:['\x22]([^'\x22\s]+)['\x22]|([^\s'\x22]+))").unwrap()
})
}
fn get_json_field_re() -> &'static Regex {
JSON_FIELD_RE.get_or_init(|| {
Regex::new(r#"(?i)("api_?[Kk]ey"|"token"|"secret"|"password"|"access_token"|"refresh_token"|"auth_token"|"bearer"|"secret_value"|"raw_secret"|"secret_input"|"key_material")\s*:\s*"([^"]+)"#).unwrap()
})
}
fn get_auth_header_re() -> &'static Regex {
AUTH_HEADER_RE.get_or_init(|| {
Regex::new(r"(?i)(Authorization:\s*Bearer\s+)(\S+)").unwrap()
})
}
fn get_telegram_re() -> &'static Regex {
TELEGRAM_RE.get_or_init(|| {
Regex::new(r"(?i)(bot)?(\d{8,}):([-A-Za-z0-9_]{30,})").unwrap()
})
}
fn get_private_key_re() -> &'static Regex {
PRIVATE_KEY_RE.get_or_init(|| {
Regex::new(r"(?s)-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----").unwrap()
})
}
fn get_db_connstr_re() -> &'static Regex {
DB_CONNSTR_RE.get_or_init(|| {
Regex::new(r"(?i)((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://[^:]+:)([^@]+)(@)").unwrap()
})
}
fn get_jwt_re() -> &'static Regex {
JWT_RE.get_or_init(|| {
Regex::new(r"eyJ[A-Za-z0-9_-]{10,}(?:\.[A-Za-z0-9_=-]{4,}){0,2}").unwrap()
})
}
fn get_discord_mention_re() -> &'static Regex {
DISCORD_MENTION_RE.get_or_init(|| {
Regex::new(r"<@(!?)(\d{17,20})>").unwrap()
})
}
fn get_signal_phone_re() -> &'static Regex {
SIGNAL_PHONE_RE.get_or_init(|| {
Regex::new(r"(\+[1-9]\d{6,14})([a-zA-Z0-9]?)").unwrap()
})
}
fn get_url_with_query_re() -> &'static Regex {
URL_WITH_QUERY_RE.get_or_init(|| {
Regex::new(r"(?i)(https?|wss?|ftp)://([^\s/?#]+)([^\s?#]*)\?([^\s#]+)(#\S*)?").unwrap()
})
}
fn get_url_userinfo_re() -> &'static Regex {
URL_USERINFO_RE.get_or_init(|| {
Regex::new(r"(?i)(https?|wss?|ftp)://([^/\s:@]+):([^/\s@]+)@").unwrap()
})
}
fn get_form_body_re() -> &'static Regex {
FORM_BODY_RE.get_or_init(|| {
Regex::new(r"^[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*(?:&[A-Za-z_][A-Za-z0-9_.-]*=[^&\s]*)+$").unwrap()
})
}
fn get_sensitive_query_params() -> &'static HashSet<String> {
SENSITIVE_QUERY_PARAMS.get_or_init(|| {
let params = [
"access_token",
"refresh_token",
"id_token",
"token",
"api_key",
"apikey",
"client_secret",
"password",
"auth",
"jwt",
"session",
"secret",
"key",
"code",
"signature",
"x-amz-signature",
];
params.iter().map(|&s| s.to_string()).collect()
})
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct Message {
pub role: String,
pub content: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
pub fn mask_token(token: &str) -> String {
if token.is_empty() {
return "***".to_string();
}
if token.len() < 18 {
return "***".to_string();
}
format!("{}...{}", &token[..6], &token[token.len() - 4..])
}
pub fn redact_query_string(query: &str) -> String {
if query.is_empty() {
return query.to_string();
}
let starts_with_q = query.starts_with('?');
let actual_query = if starts_with_q { &query[1..] } else { query };
let parts: Vec<&str> = actual_query.split('&').collect();
let mut redacted_parts = Vec::new();
let sensitive = get_sensitive_query_params();
for pair in parts {
if !pair.contains('=') {
redacted_parts.push(pair.to_string());
continue;
}
let kv: Vec<&str> = pair.splitn(2, '=').collect();
let key = kv[0];
if sensitive.contains(&key.to_ascii_lowercase()) {
redacted_parts.push(format!("{}=***", key));
} else {
redacted_parts.push(pair.to_string());
}
}
let prefix = if starts_with_q { "?" } else { "" };
format!("{}{}", prefix, redacted_parts.join("&"))
}
pub fn redact_sensitive_text(text: &str, force: bool) -> String {
if text.is_empty() {
return text.to_string();
}
if !force {
let is_redact_enabled = std::env::var("REDACT_SECRETS")
.map(|v| v != "false")
.unwrap_or(true);
if !is_redact_enabled {
return text.to_string();
}
}
let mut result = text.to_string();
for pattern in get_prefix_patterns() {
result = pattern
.replace_all(&result, |caps: ®ex::Captures| mask_token(&caps[0]))
.to_string();
}
result = get_env_assign_re()
.replace_all(&result, |caps: ®ex::Captures| {
let key = &caps[1];
if let Some(val_quoted) = caps.get(2) {
let matched = &caps[0];
let quote_char = if matched.contains('"') { "\"" } else { "'" };
format!("{}={}{}{}", key, quote_char, mask_token(val_quoted.as_str()), quote_char)
} else if let Some(val_unquoted) = caps.get(3) {
format!("{}={}", key, mask_token(val_unquoted.as_str()))
} else {
caps[0].to_string()
}
})
.to_string();
result = get_json_field_re()
.replace_all(&result, |caps: ®ex::Captures| {
format!("{}: \x22{}\x22", &caps[1], mask_token(&caps[2]))
})
.to_string();
result = get_auth_header_re()
.replace_all(&result, |caps: ®ex::Captures| {
format!("{}{}", &caps[1], mask_token(&caps[2]))
})
.to_string();
result = get_telegram_re()
.replace_all(&result, |caps: ®ex::Captures| {
let bot = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let chat_id = &caps[2];
format!("{}{}:***", bot, chat_id)
})
.to_string();
result = get_private_key_re()
.replace_all(&result, "[REDACTED PRIVATE KEY]")
.to_string();
result = get_db_connstr_re()
.replace_all(&result, |caps: ®ex::Captures| {
format!("{}***{}", &caps[1], &caps[3])
})
.to_string();
result = get_jwt_re()
.replace_all(&result, |caps: ®ex::Captures| mask_token(&caps[0]))
.to_string();
result = get_url_userinfo_re()
.replace_all(&result, |caps: ®ex::Captures| {
format!("{}://{}:***@", &caps[1], &caps[2])
})
.to_string();
result = get_url_with_query_re()
.replace_all(&result, |caps: ®ex::Captures| {
let scheme = &caps[1];
let host = &caps[2];
let path = &caps[3];
let query = &caps[4];
let fragment = caps.get(5).map(|m| m.as_str()).unwrap_or("");
format!(
"{}://{}{}?{}{}",
scheme,
host,
path,
redact_query_string(query),
fragment
)
})
.to_string();
if result.contains('&') && result.contains('=') {
let stripped = result.trim();
if get_form_body_re().is_match(stripped) {
result = redact_query_string(stripped);
}
}
result = get_discord_mention_re()
.replace_all(&result, |caps: ®ex::Captures| {
let bang = &caps[1];
format!("<@{}***>", bang)
})
.to_string();
result = get_signal_phone_re()
.replace_all(&result, |caps: ®ex::Captures| {
let original = &caps[0];
let phone = &caps[1];
let next_char = caps.get(2).map(|m| m.as_str()).unwrap_or("");
if !next_char.is_empty() {
original.to_string()
} else if phone.len() <= 8 {
format!("{}****{}", &phone[..2], &phone[phone.len() - 2..])
} else {
format!("{}****{}", &phone[..4], &phone[phone.len() - 4..])
}
})
.to_string();
result
}
pub fn estimate_tokens_rough(text: &str) -> usize {
if text.is_empty() {
return 0;
}
text.len().div_ceil(4)
}
pub fn estimate_message_chars(msg: &Message) -> usize {
let mut shadow = msg.clone();
if let Value::Array(ref mut arr) = shadow.content {
for part in arr {
if let Value::Object(ref mut obj) = part {
if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
if t == "image" || t == "image_url" || t == "input_image" {
obj.insert(
"image".to_string(),
Value::String("[stripped]".to_string()),
);
}
}
}
}
} else if let Value::Object(ref obj) = shadow.content {
if obj.contains_key("_multimodal") {
let text_summary = obj
.get("text_summary")
.and_then(|v| v.as_str())
.unwrap_or("");
shadow.content = Value::String(text_summary.to_string());
}
}
match serde_json::to_string(&shadow) {
Ok(s) => s.len(),
Err(_) => format!("{:?}", shadow).len(),
}
}
pub fn count_image_tokens(msg: &Message, cost_per_image: usize) -> usize {
let mut count = 0;
match &msg.content {
Value::Array(arr) => {
for part in arr {
if let Value::Object(obj) = part {
if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
if t == "image" || t == "image_url" || t == "input_image" {
count += 1;
}
}
}
}
}
Value::Object(obj) => {
if obj.contains_key("_multimodal") {
if let Some(inner) = obj.get("content").and_then(|v| v.as_array()) {
for part in inner {
if let Value::Object(part_obj) = part {
if let Some(t) = part_obj.get("type").and_then(|v| v.as_str()) {
if t == "image" || t == "image_url" {
count += 1;
}
}
}
}
}
}
}
_ => {}
}
count * cost_per_image
}
pub fn estimate_messages_tokens_rough(messages: &[Message]) -> usize {
let image_token_cost = 1500;
let mut total_chars = 0;
let mut image_tokens = 0;
for msg in messages {
total_chars += estimate_message_chars(msg);
image_tokens += count_image_tokens(msg, image_token_cost);
}
total_chars.div_ceil(4) + image_tokens
}
pub fn content_length_for_budget(content: &Value) -> usize {
match content {
Value::String(s) => s.len(),
Value::Array(arr) => {
let mut total = 0;
for p in arr {
match p {
Value::String(s) => total += s.len(),
Value::Object(obj) => {
if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
if t == "image_url" || t == "input_image" || t == "image" {
total += IMAGE_CHAR_EQUIVALENT;
} else {
total += obj
.get("text")
.and_then(|v| v.as_str())
.map(|s| s.len())
.unwrap_or(0);
}
}
}
_ => total += p.to_string().len(),
}
}
total
}
_ => content.to_string().len(),
}
}
pub fn content_text_for_contains(content: &Value) -> String {
match content {
Value::Null => "".to_string(),
Value::String(s) => s.clone(),
Value::Array(arr) => {
let mut parts = Vec::new();
for part in arr {
match part {
Value::String(s) => parts.push(s.clone()),
Value::Object(obj) => {
if let Some(text) = obj.get("text").and_then(|v| v.as_str()) {
parts.push(text.to_string());
}
}
_ => {}
}
}
parts.join("\n")
}
_ => content.to_string(),
}
}
pub fn append_text_to_content(content: &Value, text: &str, prepend: bool) -> Value {
match content {
Value::Null => Value::String(text.to_string()),
Value::String(s) => {
if prepend {
Value::String(format!("{}{}", text, s))
} else {
Value::String(format!("{}{}", s, text))
}
}
Value::Array(arr) => {
let mut new_arr = arr.clone();
let mut text_block = serde_json::Map::new();
text_block.insert("type".to_string(), Value::String("text".to_string()));
text_block.insert("text".to_string(), Value::String(text.to_string()));
if prepend {
new_arr.insert(0, Value::Object(text_block));
} else {
new_arr.push(Value::Object(text_block));
}
Value::Array(new_arr)
}
_ => {
let s = content.to_string();
if prepend {
Value::String(format!("{}{}", text, s))
} else {
Value::String(format!("{}{}", s, text))
}
}
}
}
pub fn strip_image_parts_from_parts(parts: &[Value]) -> Option<Vec<Value>> {
let mut had_image = false;
let mut out = Vec::new();
for part in parts {
if let Value::Object(obj) = part {
if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
if t == "image" || t == "image_url" || t == "input_image" {
had_image = true;
let mut text_block = serde_json::Map::new();
text_block.insert("type".to_string(), Value::String("text".to_string()));
text_block.insert(
"text".to_string(),
Value::String("[screenshot removed to save context]".to_string()),
);
out.push(Value::Object(text_block));
continue;
}
}
}
out.push(part.clone());
}
if had_image { Some(out) } else { None }
}
pub fn truncate_tool_call_args_json(args: &str, head_chars: usize) -> String {
match serde_json::from_str::<Value>(args) {
Ok(parsed) => {
fn shrink(val: &Value, limit: usize) -> Value {
match val {
Value::String(s) => {
if s.len() > limit {
Value::String(format!("{}...[truncated]", &s[..limit]))
} else {
val.clone()
}
}
Value::Array(arr) => {
let new_arr = arr.iter().map(|item| shrink(item, limit)).collect();
Value::Array(new_arr)
}
Value::Object(obj) => {
let mut new_obj = serde_json::Map::new();
for (k, v) in obj {
new_obj.insert(k.clone(), shrink(v, limit));
}
Value::Object(new_obj)
}
_ => val.clone(),
}
}
let shrunk = shrink(&parsed, head_chars);
serde_json::to_string(&shrunk).unwrap_or_else(|_| args.to_string())
}
Err(_) => args.to_string(),
}
}
pub fn content_has_images(content: &Value) -> bool {
if let Value::Array(arr) = content {
arr.iter().any(|p| {
if let Value::Object(obj) = p {
if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
return t == "image" || t == "image_url" || t == "input_image";
}
}
false
})
} else {
false
}
}
pub fn strip_images_from_content(content: &Value) -> Value {
if let Value::Array(arr) = content {
if !content_has_images(content) {
return content.clone();
}
let mut out = Vec::new();
for p in arr {
let mut is_image = false;
if let Value::Object(obj) = p {
if let Some(t) = obj.get("type").and_then(|v| v.as_str()) {
if t == "image" || t == "image_url" || t == "input_image" {
is_image = true;
}
}
}
if is_image {
let mut text_block = serde_json::Map::new();
text_block.insert("type".to_string(), Value::String("text".to_string()));
text_block.insert(
"text".to_string(),
Value::String("[Attached image — stripped after compression]".to_string()),
);
out.push(Value::Object(text_block));
} else {
out.push(p.clone());
}
}
Value::Array(out)
} else {
content.clone()
}
}
pub fn strip_historical_media(messages: &[Message]) -> Vec<Message> {
if messages.is_empty() {
return messages.to_vec();
}
let mut anchor = None;
for i in (0..messages.len()).rev() {
if messages[i].role == "user" && content_has_images(&messages[i].content) {
anchor = Some(i);
break;
}
}
let anchor_idx = match anchor {
None => return messages.to_vec(),
Some(idx) => idx,
};
if anchor_idx == 0 {
return messages.to_vec();
}
let mut changed = false;
let mut result = Vec::new();
for (i, msg) in messages.iter().enumerate() {
if i >= anchor_idx || !content_has_images(&msg.content) {
result.push(msg.clone());
} else {
changed = true;
let mut copy_msg = msg.clone();
copy_msg.content = strip_images_from_content(&msg.content);
result.push(copy_msg);
}
}
if changed { result } else { messages.to_vec() }
}
pub fn summarize_tool_result(tool_name: &str, tool_args: &str, tool_content: &str) -> String {
let args: serde_json::Map<String, Value> = serde_json::from_str(tool_args).unwrap_or_default();
let content = tool_content;
let content_len = content.len();
let line_count = if content.trim().is_empty() {
0
} else {
content.trim().split('\n').count()
};
match tool_name {
"terminal" => {
let mut cmd = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if cmd.len() > 80 {
cmd = format!("{}...", &cmd[..77]);
}
static EXIT_CODE_RE: OnceLock<Regex> = OnceLock::new();
let exit_code_re = EXIT_CODE_RE
.get_or_init(|| Regex::new(r#""exit_code"\s*:\s*(-?\d+)"#).expect("static regex"));
let exit_code = if let Some(caps) = exit_code_re.captures(content) {
caps.get(1).map(|m| m.as_str()).unwrap_or("?")
} else {
"?"
};
format!(
"[terminal] ran `{}` -> exit {}, {} lines output",
cmd, exit_code, line_count
)
}
"read_file" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let offset = args.get("offset").and_then(|v| v.as_i64()).unwrap_or(1);
format!(
"[read_file] read {} from line {} ({} chars)",
path, offset, content_len
)
}
"write_file" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let written_lines = args
.get("content")
.and_then(|v| v.as_str())
.map(|c| c.split('\n').count().to_string())
.unwrap_or_else(|| "?".to_string());
format!("[write_file] wrote to {} ({} lines)", path, written_lines)
}
"search_files" => {
let pattern = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("?");
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let target = args
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("content");
static TOTAL_COUNT_RE: OnceLock<Regex> = OnceLock::new();
let total_count_re = TOTAL_COUNT_RE
.get_or_init(|| Regex::new(r#""total_count"\s*:\s*(\d+)"#).expect("static regex"));
let count = if let Some(caps) = total_count_re.captures(content) {
caps.get(1).map(|m| m.as_str()).unwrap_or("?")
} else {
"?"
};
format!(
"[search_files] {} search for '{}' in {} -> {} matches",
target, pattern, path, count
)
}
"patch" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("replace");
format!(
"[patch] {} in {} ({} chars result)",
mode, path, content_len
)
}
"browser_navigate" | "browser_click" | "browser_snapshot" | "browser_type"
| "browser_scroll" | "browser_vision" => {
let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("");
let ref_id = args.get("ref").and_then(|v| v.as_str()).unwrap_or("");
let detail = if !url.is_empty() {
format!(" {}", url)
} else if !ref_id.is_empty() {
format!(" ref={}", ref_id)
} else {
"".to_string()
};
format!("[{}]{} ({} chars)", tool_name, detail, content_len)
}
"web_search" => {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("?");
format!(
"[web_search] query='{}' ({} chars result)",
query, content_len
)
}
"web_extract" => {
let urls = args.get("urls");
let mut url_desc = "?".to_string();
if let Some(Value::Array(arr)) = urls {
if !arr.is_empty() {
if let Some(first) = arr[0].as_str() {
url_desc = first.to_string();
if arr.len() > 1 {
url_desc = format!("{} (+{} more)", url_desc, arr.len() - 1);
}
}
}
}
format!("[web_extract] {} ({} chars)", url_desc, content_len)
}
"delegate_task" => {
let mut goal = args
.get("goal")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if goal.len() > 60 {
goal = format!("{}...", &goal[..57]);
}
format!(
"[delegate_task] '{}' ({} chars result)",
goal, content_len
)
}
"execute_code" => {
let mut code = args
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("")
.replace('\n', " ");
if code.len() > 60 {
code = format!("{}...", &code[..57]);
}
format!("[execute_code] `{}` ({} lines output)", code, line_count)
}
"skill_view" | "skills_list" | "skill_manage" => {
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("?");
format!("[{}] name={} ({} chars)", tool_name, name, content_len)
}
"vision_analyze" => {
let question = args.get("question").and_then(|v| v.as_str()).unwrap_or("");
let q_preview = if question.len() > 50 {
&question[..50]
} else {
question
};
format!("[vision_analyze] '{}' ({} chars)", q_preview, content_len)
}
"memory" => {
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("?");
let target = args.get("target").and_then(|v| v.as_str()).unwrap_or("?");
format!("[memory] {} on {}", action, target)
}
"todo" => "[todo] updated task list".to_string(),
"clarify" => "[clarify] asked user a question".to_string(),
"text_to_speech" => format!("[text_to_speech] generated audio ({} chars)", content_len),
"cronjob" => {
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("?");
format!("[cronjob] {}", action)
}
"process" => {
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("?");
let sid = args.get("session_id").and_then(|v| v.as_str()).unwrap_or("?");
format!("[process] {} session={}", action, sid)
}
_ => {
let mut first_arg = "".to_string();
for (k, v) in args.iter().take(2) {
let sv = v.to_string();
let sv_trunc = if sv.len() > 40 {
format!("{}...", &sv[..37])
} else {
sv
};
first_arg = format!("{} {}={}", first_arg, k, sv_trunc);
}
format!(
"[{}]{} ({} chars result)",
tool_name, first_arg, content_len
)
}
}
}
pub struct ContextCompressor<F, Fut>
where
F: Fn(String) -> Fut + Send + Sync,
Fut: Future<Output = Result<String, String>> + Send,
{
pub context_length: usize,
pub summarize_callback: F,
pub threshold_percent: f64,
pub protect_first_n: usize,
pub protect_last_n: usize,
pub summary_target_ratio: f64,
pub abort_on_summary_failure: bool,
pub threshold_tokens: usize,
pub tail_token_budget: usize,
pub max_summary_tokens: usize,
pub compression_count: usize,
pub last_prompt_tokens: usize,
pub last_completion_tokens: usize,
pub previous_summary: Option<String>,
pub last_compression_savings_pct: f64,
pub ineffective_compression_count: usize,
pub summary_failure_cooldown_until: f64,
pub last_summary_error: Option<String>,
pub last_summary_dropped_count: usize,
pub last_summary_fallback_used: bool,
pub last_compress_aborted: bool,
}
fn get_now_secs() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
impl<F, Fut> ContextCompressor<F, Fut>
where
F: Fn(String) -> Fut + Send + Sync,
Fut: Future<Output = Result<String, String>> + Send,
{
pub fn new(
context_length: usize,
summarize_callback: F,
threshold_percent: Option<f64>,
protect_first_n: Option<usize>,
protect_last_n: Option<usize>,
summary_target_ratio: Option<f64>,
abort_on_summary_failure: Option<bool>,
) -> Self {
let tp = threshold_percent.unwrap_or(0.50);
let pfn = protect_first_n.unwrap_or(3);
let pln = protect_last_n.unwrap_or(20);
let str = summary_target_ratio.unwrap_or(0.20).clamp(0.10, 0.80);
let aosf = abort_on_summary_failure.unwrap_or(false);
let minimum_context_length = 2048;
let threshold_tokens =
((context_length as f64 * tp) as usize).max(minimum_context_length);
let tail_token_budget = (threshold_tokens as f64 * str) as usize;
let max_summary_tokens =
((context_length as f64 * 0.05) as usize).min(SUMMARY_TOKENS_CEILING);
Self {
context_length,
summarize_callback,
threshold_percent: tp,
protect_first_n: pfn,
protect_last_n: pln,
summary_target_ratio: str,
abort_on_summary_failure: aosf,
threshold_tokens,
tail_token_budget,
max_summary_tokens,
compression_count: 0,
last_prompt_tokens: 0,
last_completion_tokens: 0,
previous_summary: None,
last_compression_savings_pct: 100.0,
ineffective_compression_count: 0,
summary_failure_cooldown_until: 0.0,
last_summary_error: None,
last_summary_dropped_count: 0,
last_summary_fallback_used: false,
last_compress_aborted: false,
}
}
pub fn on_session_reset(&mut self) {
self.previous_summary = None;
self.last_summary_error = None;
self.last_summary_dropped_count = 0;
self.last_summary_fallback_used = false;
self.last_compression_savings_pct = 100.0;
self.ineffective_compression_count = 0;
self.summary_failure_cooldown_until = 0.0;
}
pub fn should_compress(&self, prompt_tokens: Option<usize>) -> bool {
let tokens = prompt_tokens.unwrap_or(self.last_prompt_tokens);
if tokens < self.threshold_tokens {
return false;
}
if self.ineffective_compression_count >= 2 {
return false;
}
true
}
#[allow(clippy::needless_range_loop)]
fn prune_old_tool_results(
&self,
messages: &[Message],
protect_tail_count: usize,
protect_tail_tokens: Option<usize>,
) -> (Vec<Message>, usize) {
if messages.is_empty() {
return (Vec::new(), 0);
}
let mut result = messages.to_vec();
let mut pruned = 0;
let mut call_id_to_tool = std::collections::HashMap::new();
for msg in &result {
if msg.role == "assistant" {
if let Some(Value::Array(calls)) = &msg.tool_calls {
for tc in calls {
let cid = tc
.get("id")
.or_else(|| tc.get("call_id"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !cid.is_empty() {
let name = tc
.get("function")
.and_then(|f| f.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let args = tc
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|v| v.as_str())
.unwrap_or("");
call_id_to_tool
.insert(cid.to_string(), (name.to_string(), args.to_string()));
}
}
}
}
}
let mut prune_boundary = result.len().saturating_sub(protect_tail_count);
if let Some(budget) = protect_tail_tokens {
if budget > 0 {
let mut accumulated = 0;
let mut boundary = result.len();
let min_protect = protect_tail_count.min(result.len());
for i in (0..result.len()).rev() {
let msg = &result[i];
let content_len = content_length_for_budget(&msg.content);
let mut msg_tokens = content_len / CHARS_PER_TOKEN + 10;
if let Some(Value::Array(calls)) = &msg.tool_calls {
for tc in calls {
let args = tc
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|v| v.as_str())
.unwrap_or("");
msg_tokens += args.len() / CHARS_PER_TOKEN;
}
}
if accumulated + msg_tokens > budget && (result.len() - i) >= min_protect {
boundary = i;
break;
}
accumulated += msg_tokens;
boundary = i;
}
let budget_protect_count = result.len() - boundary;
let protected_count = budget_protect_count.max(min_protect);
prune_boundary = result.len().saturating_sub(protected_count);
}
}
let mut content_hashes = std::collections::HashMap::new();
for i in (0..result.len()).rev() {
let msg = &result[i];
if msg.role != "tool" {
continue;
}
if let Value::String(content) = &msg.content {
if content.len() < 200 {
continue;
}
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let h = format!("{:x}", hasher.finalize())[..12].to_string();
if let std::collections::hash_map::Entry::Vacant(e) = content_hashes.entry(h) {
e.insert((i, msg.tool_call_id.clone().unwrap_or_default()));
} else {
result[i].content = Value::String(
"[Duplicate tool output — same content as a more recent call]".to_string(),
);
pruned += 1;
}
}
}
for i in 0..prune_boundary {
let msg = &result[i];
if msg.role != "tool" {
continue;
}
if let Value::Array(arr) = &msg.content {
if let Some(stripped) = strip_image_parts_from_parts(arr) {
result[i].content = Value::Array(stripped);
pruned += 1;
}
continue;
}
if let Value::Object(obj) = &msg.content {
if obj.contains_key("_multimodal") {
let summary = obj
.get("text_summary")
.and_then(|v| v.as_str())
.unwrap_or("[screenshot removed to save context]");
let len_to_take = summary.len().min(200);
result[i].content = Value::String(format!(
"[screenshot removed] {}",
&summary[..len_to_take]
));
pruned += 1;
continue;
}
}
if let Value::String(content) = &msg.content {
if content.is_empty() || content == PRUNED_TOOL_PLACEHOLDER {
continue;
}
if content.starts_with("[Duplicate tool output") {
continue;
}
if content.len() > 200 {
let call_id = msg.tool_call_id.clone().unwrap_or_default();
let (tool_name, tool_args) = call_id_to_tool
.get(&call_id)
.map(|(n, a)| (n.as_str(), a.as_str()))
.unwrap_or(("unknown", ""));
let summary = summarize_tool_result(tool_name, tool_args, content);
result[i].content = Value::String(summary);
pruned += 1;
}
}
}
for i in 0..prune_boundary {
let msg = &mut result[i];
if msg.role != "assistant" {
continue;
}
if let Some(Value::Array(calls)) = &mut msg.tool_calls {
for tc in calls.iter_mut() {
if let Value::Object(func_obj) =
tc.get_mut("function").unwrap_or(&mut Value::Null)
{
if let Some(args_val) = func_obj.get_mut("arguments") {
if let Some(args_str) = args_val.as_str() {
if args_str.len() > 500 {
let new_args = truncate_tool_call_args_json(args_str, 200);
if new_args != args_str {
*args_val = Value::String(new_args);
}
}
}
}
}
}
}
}
(result, pruned)
}
fn compute_summary_budget(&self, turns_to_summarize: &[Message]) -> usize {
let content_tokens = estimate_messages_tokens_rough(turns_to_summarize);
let budget = (content_tokens as f64 * SUMMARY_RATIO) as usize;
MIN_SUMMARY_TOKENS.max(budget.min(self.max_summary_tokens))
}
fn serialize_for_summary(&self, turns: &[Message]) -> String {
let content_max = 6000;
let content_head = 4000;
let content_tail = 1500;
let tool_args_max = 1500;
let tool_args_head = 1200;
let mut parts = Vec::new();
for msg in turns {
let role = &msg.role;
let mut content = redact_sensitive_text(&content_text_for_contains(&msg.content), true);
if role == "tool" {
let tool_id = msg.tool_call_id.clone().unwrap_or_default();
if content.len() > content_max {
content = format!(
"{}\n...[truncated]...\n{}",
&content[..content_head],
&content[content.len() - content_tail..]
);
}
parts.push(format!("[TOOL RESULT {}]: {}", tool_id, content));
continue;
}
if role == "assistant" {
if content.len() > content_max {
content = format!(
"{}\n...[truncated]...\n{}",
&content[..content_head],
&content[content.len() - content_tail..]
);
}
if let Some(Value::Array(calls)) = &msg.tool_calls {
let mut tc_parts = Vec::new();
for tc in calls {
let name = tc
.get("function")
.and_then(|f| f.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let mut args = redact_sensitive_text(
tc.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|v| v.as_str())
.unwrap_or(""),
true,
);
if args.len() > tool_args_max {
args = format!("{}...", &args[..tool_args_head]);
}
tc_parts.push(format!(" {}({})", name, args));
}
if !tc_parts.is_empty() {
content = format!("{}\n[Tool calls:\n{}\n]", content, tc_parts.join("\n"));
}
}
parts.push(format!("[ASSISTANT]: {}", content));
continue;
}
if content.len() > content_max {
content = format!(
"{}\n...[truncated]...\n{}",
&content[..content_head],
&content[content.len() - content_tail..]
);
}
parts.push(format!("[{}]: {}", role.to_uppercase(), content));
}
parts.join("\n\n")
}
async fn generate_summary(
&mut self,
turns_to_summarize: &[Message],
focus_topic: Option<&str>,
) -> Option<String> {
let now = get_now_secs();
if now < self.summary_failure_cooldown_until {
return None;
}
let summary_budget = self.compute_summary_budget(turns_to_summarize);
let content_to_summarize = self.serialize_for_summary(turns_to_summarize);
let summarizer_preamble = "\
You are a summarization agent creating a context checkpoint. \
Treat the conversation turns below as source material for a \
compact record of prior work. \
Produce only the structured summary; do not add a greeting, \
preamble, or prefix. \
Write the summary in the same language the user was using in the \
conversation — do not translate or switch to English. \
NEVER include API keys, tokens, passwords, secrets, credentials, \
or connection strings in the summary — replace any that appear \
with [REDACTED]. Note that the user had credentials present, but \
do not preserve their values.";
let template_sections = "\
## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or \
task assignment verbatim — the exact words they used. If multiple tasks \
were requested and only some are done, list only the ones NOT yet completed. \
Continuation should pick up exactly here. Example: \
\"User asked: 'Now refactor the auth module to use JWT instead of sessions'\" \
If no outstanding task exists, write \"None.\"]
## Goal
[What the user is trying to accomplish overall]
## Constraints & Preferences
[User preferences, coding style, constraints, important decisions]
## Completed Actions
[Numbered list of concrete actions taken — include tool used, target, and outcome. \
Format each as: N. ACTION target — outcome [tool: name] \
Example: \
1. READ config.py:45 — found `==` should be `!=` [tool: read_file] \
2. PATCH config.py:45 — changed `==` to `!=` [tool: patch] \
3. TEST `pytest tests/` — 3/50 failed: test_parse, test_validate, test_edge [tool: terminal] \
Be specific with file paths, commands, line numbers, and results.]
## Active State
[Current working state — include: \
- Working directory and branch (if applicable) \
- Modified/created files with brief note on each \
- Test status (X/Y passing) \
- Any running processes or servers \
- Environment details that matter]
## In Progress
[Work currently underway — what was being done when compaction fired]
## Blocked
[Any blockers, errors, or issues not yet resolved. Include exact error messages.]
## Key Decisions
[Important technical decisions and WHY they were made]
## Resolved Questions
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
## Pending User Asks
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write \"None.\"]
## Relevant Files
[Files read, modified, or created — with brief note on each]
## Remaining Work
[What remains to be done — framed as context, not instructions]
## Critical Context
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]";
let template_sections_prompt = format!(
"\
Target ~{} tokens. Be CONCRETE — include file paths, command outputs, \
error messages, line numbers, and specific values. Avoid vague descriptions like \"made some changes\" \
— say exactly what changed.\n\nWrite only the summary body. Do not include any preamble or prefix.",
summary_budget
);
let mut prompt = if let Some(prev) = &self.previous_summary {
format!(
"\
{}\n\n\
You are updating a context compaction summary. A previous compaction produced the summary below. \
New conversation turns have occurred since then and need to be incorporated.\n\n\
PREVIOUS SUMMARY:\n{}\n\n\
NEW TURNS TO INCORPORATE:\n{}\n\n\
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. \
ADD new completed actions to the numbered list (continue numbering). Move items from \"In Progress\" \
to \"Completed Actions\" when done. Move answered questions to \"Resolved Questions\". Update \"Active State\" \
to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update \"## Active Task\" \
to reflect the user's most recent unfulfilled request — this is the most important field for task continuity.\n\n\
{}\n\n{}",
summarizer_preamble,
prev,
content_to_summarize,
template_sections,
template_sections_prompt
)
} else {
format!(
"\
{}\n\n\
Create a structured checkpoint summary for the conversation after earlier turns are compacted. \
The summary should preserve enough detail for continuity without re-reading the original turns.\n\n\
TURNS TO SUMMARIZE:\n{}\n\n\
Use this exact structure:\n\n{}\n\n{}",
summarizer_preamble,
content_to_summarize,
template_sections,
template_sections_prompt
)
};
if let Some(topic) = focus_topic {
prompt += &format!("\n\nFOCUS TOPIC: \"{}\"\n\
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. \
For content related to \"{}\", include full detail — exact values, file paths, command outputs, \
error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively \
(brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of \
the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, \
or credentials — use [REDACTED].", topic, topic);
}
match (self.summarize_callback)(prompt).await {
Ok(summary_text) => {
let clean_summary = redact_sensitive_text(summary_text.trim(), true);
self.previous_summary = Some(clean_summary.clone());
self.summary_failure_cooldown_until = 0.0;
self.last_summary_error = None;
Some(self.with_summary_prefix(&clean_summary))
}
Err(e) => {
let err_str = e.to_string();
let is_transient = err_str.contains("timeout")
|| err_str.contains("rate limit")
|| err_str.contains("network")
|| err_str.contains("closed stream")
|| err_str.contains("unexpected eof");
let cooldown_seconds = if is_transient { 30.0 } else { 60.0 };
self.summary_failure_cooldown_until = get_now_secs() + cooldown_seconds;
self.last_summary_error = Some(err_str);
None
}
}
}
fn with_summary_prefix(&self, summary: &str) -> String {
let text = self.strip_summary_prefix(summary);
if text.is_empty() {
SUMMARY_PREFIX.to_string()
} else {
format!("{}\n{}", SUMMARY_PREFIX, text)
}
}
fn strip_summary_prefix(&self, summary: &str) -> String {
let text = summary.trim();
if let Some(rest) = text.strip_prefix(SUMMARY_PREFIX) {
rest.trim().to_string()
} else if let Some(rest) = text.strip_prefix(LEGACY_SUMMARY_PREFIX) {
rest.trim().to_string()
} else {
text.to_string()
}
}
fn is_context_summary_content(&self, content: &Value) -> bool {
let text = content_text_for_contains(content).trim().to_string();
text.starts_with(SUMMARY_PREFIX) || text.starts_with(LEGACY_SUMMARY_PREFIX)
}
fn find_latest_context_summary(
&self,
messages: &[Message],
start: usize,
end: usize,
) -> (Option<usize>, String) {
for i in (start..end).rev() {
let content = &messages[i].content;
if self.is_context_summary_content(content) {
return (
Some(i),
self.strip_summary_prefix(&content_text_for_contains(content)),
);
}
}
(None, "".to_string())
}
fn sanitize_tool_pairs(&self, messages: &[Message]) -> Vec<Message> {
let mut surviving_call_ids = HashSet::new();
for msg in messages {
if msg.role == "assistant" {
if let Some(Value::Array(calls)) = &msg.tool_calls {
for tc in calls {
let cid = tc
.get("id")
.or_else(|| tc.get("call_id"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !cid.is_empty() {
surviving_call_ids.insert(cid.to_string());
}
}
}
}
}
let mut result_call_ids = HashSet::new();
for msg in messages {
if msg.role == "tool" {
if let Some(cid) = &msg.tool_call_id {
result_call_ids.insert(cid.clone());
}
}
}
let orphaned_results: HashSet<_> = result_call_ids
.difference(&surviving_call_ids)
.cloned()
.collect();
let mut sanitized = messages.to_vec();
if !orphaned_results.is_empty() {
sanitized.retain(|m| {
!(m.role == "tool"
&& m.tool_call_id
.as_ref()
.is_some_and(|cid| orphaned_results.contains(cid)))
});
}
let missing_results: HashSet<_> = surviving_call_ids
.difference(&result_call_ids)
.cloned()
.collect();
if !missing_results.is_empty() {
let mut patched = Vec::new();
for msg in sanitized {
patched.push(msg.clone());
if msg.role == "assistant" {
if let Some(Value::Array(calls)) = &msg.tool_calls {
for tc in calls {
let cid = tc
.get("id")
.or_else(|| tc.get("call_id"))
.and_then(|v| v.as_str())
.unwrap_or("");
if !cid.is_empty() && missing_results.contains(cid) {
patched.push(Message {
role: "tool".to_string(),
content: Value::String("[Result from earlier conversation — see context summary above]".to_string()),
tool_call_id: Some(cid.to_string()),
tool_calls: None,
});
}
}
}
}
}
sanitized = patched;
}
sanitized
}
fn align_boundary_forward(&self, messages: &[Message], idx: usize) -> usize {
let mut cur = idx;
while cur < messages.len() && messages[cur].role == "tool" {
cur += 1;
}
cur
}
fn align_boundary_backward(&self, messages: &[Message], idx: usize) -> usize {
if idx == 0 || idx >= messages.len() {
return idx;
}
let mut check = idx - 1;
while check > 0 && messages[check].role == "tool" {
check -= 1;
}
if messages[check].role == "assistant" && messages[check].tool_calls.is_some() {
return check;
}
idx
}
fn protect_head_size(&self, messages: &[Message]) -> usize {
let mut head = 0;
if !messages.is_empty() && messages[0].role == "system" {
head = 1;
}
head + self.protect_first_n
}
#[allow(clippy::manual_find)]
fn find_last_user_message_idx(&self, messages: &[Message], head_end: usize) -> Option<usize> {
for i in (head_end..messages.len()).rev() {
if messages[i].role == "user" {
return Some(i);
}
}
None
}
fn ensure_last_user_message_in_tail(
&self,
messages: &[Message],
cut_idx: usize,
head_end: usize,
) -> usize {
let last_user_idx = self.find_last_user_message_idx(messages, head_end);
match last_user_idx {
None => cut_idx,
Some(idx) => {
if idx >= cut_idx {
cut_idx
} else {
idx.max(head_end + 1)
}
}
}
}
fn find_tail_cut_by_tokens(
&self,
messages: &[Message],
head_end: usize,
token_budget: Option<usize>,
) -> usize {
let budget = token_budget.unwrap_or(self.tail_token_budget);
let n = messages.len();
let min_tail = if n - head_end > 1 {
3.min(n - head_end - 1)
} else {
0
};
let soft_ceiling = (budget as f64 * 1.5) as usize;
let mut accumulated = 0;
let mut cut_idx = n;
for i in (head_end..n).rev() {
let msg = &messages[i];
let content_len = content_length_for_budget(&msg.content);
let mut msg_tokens = content_len / CHARS_PER_TOKEN + 10;
if let Some(Value::Array(calls)) = &msg.tool_calls {
for tc in calls {
let args = tc
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|v| v.as_str())
.unwrap_or("");
msg_tokens += args.len() / CHARS_PER_TOKEN;
}
}
if accumulated + msg_tokens > soft_ceiling && (n - i) >= min_tail {
break;
}
accumulated += msg_tokens;
cut_idx = i;
}
let fallback_cut = n - min_tail;
cut_idx = cut_idx.min(fallback_cut);
if cut_idx <= head_end {
cut_idx = fallback_cut.max(head_end + 1);
}
cut_idx = self.align_boundary_backward(messages, cut_idx);
cut_idx = self.ensure_last_user_message_in_tail(messages, cut_idx, head_end);
cut_idx.max(head_end + 1)
}
pub fn has_content_to_compress(&self, messages: &[Message]) -> bool {
let compress_start =
self.align_boundary_forward(messages, self.protect_head_size(messages));
let compress_end = self.find_tail_cut_by_tokens(messages, compress_start, None);
compress_start < compress_end
}
#[allow(clippy::needless_range_loop)]
pub async fn compress(
&mut self,
messages: &[Message],
current_tokens: Option<usize>,
focus_topic: Option<&str>,
force: bool,
) -> Vec<Message> {
self.last_summary_dropped_count = 0;
self.last_summary_fallback_used = false;
self.last_summary_error = None;
self.last_compress_aborted = false;
if force && self.summary_failure_cooldown_until > 0.0 {
self.summary_failure_cooldown_until = 0.0;
}
let n_messages = messages.len();
let min_for_compress = self.protect_head_size(messages) + 3 + 1;
if n_messages <= min_for_compress {
return messages.to_vec();
}
let display_tokens = current_tokens.unwrap_or_else(|| {
if self.last_prompt_tokens > 0 {
self.last_prompt_tokens
} else {
estimate_messages_tokens_rough(messages)
}
});
let (pruned_messages, _pruned_count) =
self.prune_old_tool_results(messages, self.protect_last_n, Some(self.tail_token_budget));
let mut compress_start = self.protect_head_size(&pruned_messages);
compress_start = self.align_boundary_forward(&pruned_messages, compress_start);
let compress_end = self.find_tail_cut_by_tokens(&pruned_messages, compress_start, None);
if compress_start >= compress_end {
return pruned_messages;
}
let mut turns_to_summarize = pruned_messages[compress_start..compress_end].to_vec();
let summary_search_start =
if !pruned_messages.is_empty() && pruned_messages[0].role == "system" {
1
} else {
0
};
let (summary_idx, summary_body) = self.find_latest_context_summary(
&pruned_messages,
summary_search_start,
compress_end,
);
if let Some(s_idx) = summary_idx {
if !summary_body.is_empty() && self.previous_summary.is_none() {
self.previous_summary = Some(summary_body);
}
turns_to_summarize = pruned_messages[compress_start.max(s_idx + 1)..compress_end].to_vec();
}
let mut summary = self.generate_summary(&turns_to_summarize, focus_topic).await;
if summary.is_none() && self.abort_on_summary_failure {
self.last_compress_aborted = true;
return pruned_messages;
}
let mut compressed = Vec::new();
for i in 0..compress_start {
let mut msg = pruned_messages[i].clone();
if i == 0 && msg.role == "system" {
let existing = msg.content.clone();
let compression_note = "\
[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. \
The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work. \
Your persistent memory (MEMORY.md, USER.md) remains fully authoritative regardless of compaction.]";
if !content_text_for_contains(&existing).contains("[Note: Some earlier conversation turns") {
let text = if let Value::String(s) = &existing {
if !s.is_empty() {
format!("\n\n{}", compression_note)
} else {
compression_note.to_string()
}
} else {
compression_note.to_string()
};
msg.content = append_text_to_content(&existing, &text, false);
}
}
compressed.push(msg);
}
if summary.is_none() {
let n_dropped = compress_end - compress_start;
self.last_summary_dropped_count = n_dropped;
self.last_summary_fallback_used = true;
summary = Some(format!("{}\n\
Summary generation was unavailable. {} message(s) were \
removed to free context space but could not be summarized. The removed \
messages contained earlier work in this session. Continue based on the \
recent messages below and the current state of any files or resources.", SUMMARY_PREFIX, n_dropped));
}
let mut merge_summary_into_tail = false;
let last_head_role = if compress_start > 0 {
pruned_messages[compress_start - 1].role.as_str()
} else {
"user"
};
let first_tail_role = if compress_end < n_messages {
pruned_messages[compress_end].role.as_str()
} else {
"user"
};
let mut summary_role = if last_head_role == "assistant" || last_head_role == "tool" {
"user".to_string()
} else {
"assistant".to_string()
};
if summary_role == first_tail_role {
let flipped = if summary_role == "user" {
"assistant"
} else {
"user"
};
if flipped != last_head_role {
summary_role = flipped.to_string();
} else {
merge_summary_into_tail = true;
}
}
let mut summary_text = summary.unwrap();
if !merge_summary_into_tail && summary_role == "user" {
summary_text = format!("{}\n\n--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---", summary_text);
}
if !merge_summary_into_tail {
compressed.push(Message {
role: summary_role,
content: Value::String(summary_text.clone()),
tool_calls: None,
tool_call_id: None,
});
}
for i in compress_end..n_messages {
let mut msg = pruned_messages[i].clone();
if merge_summary_into_tail && i == compress_end {
let merged_prefix = format!("{}\n\n--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---\n\n", summary_text);
msg.content = append_text_to_content(&msg.content, &merged_prefix, true);
merge_summary_into_tail = false;
}
compressed.push(msg);
}
self.compression_count += 1;
let mut sanitized = self.sanitize_tool_pairs(&compressed);
sanitized = strip_historical_media(&sanitized);
let new_estimate = estimate_messages_tokens_rough(&sanitized);
let saved_estimate = display_tokens.saturating_sub(new_estimate);
let savings_pct = if display_tokens > 0 {
(saved_estimate as f64 / display_tokens as f64) * 100.0
} else {
0.0
};
self.last_compression_savings_pct = savings_pct;
if savings_pct < 10.0 {
self.ineffective_compression_count += 1;
} else {
self.ineffective_compression_count = 0;
}
sanitized
}
}