compact.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
  2. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  3. pub struct CompactionConfig {
  4. pub preserve_recent_messages: usize,
  5. pub max_estimated_tokens: usize,
  6. }
  7. impl Default for CompactionConfig {
  8. fn default() -> Self {
  9. Self {
  10. preserve_recent_messages: 4,
  11. max_estimated_tokens: 10_000,
  12. }
  13. }
  14. }
  15. #[derive(Debug, Clone, PartialEq, Eq)]
  16. pub struct CompactionResult {
  17. pub summary: String,
  18. pub formatted_summary: String,
  19. pub compacted_session: Session,
  20. pub removed_message_count: usize,
  21. }
  22. #[must_use]
  23. pub fn estimate_session_tokens(session: &Session) -> usize {
  24. session.messages.iter().map(estimate_message_tokens).sum()
  25. }
  26. #[must_use]
  27. pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
  28. session.messages.len() > config.preserve_recent_messages
  29. && estimate_session_tokens(session) >= config.max_estimated_tokens
  30. }
  31. #[must_use]
  32. pub fn format_compact_summary(summary: &str) -> String {
  33. let without_analysis = strip_tag_block(summary, "analysis");
  34. let formatted = if let Some(content) = extract_tag_block(&without_analysis, "summary") {
  35. without_analysis.replace(
  36. &format!("<summary>{content}</summary>"),
  37. &format!("Summary:\n{}", content.trim()),
  38. )
  39. } else {
  40. without_analysis
  41. };
  42. collapse_blank_lines(&formatted).trim().to_string()
  43. }
  44. #[must_use]
  45. pub fn get_compact_continuation_message(
  46. summary: &str,
  47. suppress_follow_up_questions: bool,
  48. recent_messages_preserved: bool,
  49. ) -> String {
  50. let mut base = format!(
  51. "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
  52. format_compact_summary(summary)
  53. );
  54. if recent_messages_preserved {
  55. base.push_str("\n\nRecent messages are preserved verbatim.");
  56. }
  57. if suppress_follow_up_questions {
  58. base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
  59. }
  60. base
  61. }
  62. #[must_use]
  63. pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
  64. if !should_compact(session, config) {
  65. return CompactionResult {
  66. summary: String::new(),
  67. formatted_summary: String::new(),
  68. compacted_session: session.clone(),
  69. removed_message_count: 0,
  70. };
  71. }
  72. let keep_from = session
  73. .messages
  74. .len()
  75. .saturating_sub(config.preserve_recent_messages);
  76. let removed = &session.messages[..keep_from];
  77. let preserved = session.messages[keep_from..].to_vec();
  78. let summary = summarize_messages(removed);
  79. let formatted_summary = format_compact_summary(&summary);
  80. let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
  81. let mut compacted_messages = vec![ConversationMessage {
  82. role: MessageRole::System,
  83. blocks: vec![ContentBlock::Text { text: continuation }],
  84. usage: None,
  85. }];
  86. compacted_messages.extend(preserved);
  87. CompactionResult {
  88. summary,
  89. formatted_summary,
  90. compacted_session: Session {
  91. version: session.version,
  92. messages: compacted_messages,
  93. },
  94. removed_message_count: removed.len(),
  95. }
  96. }
  97. fn summarize_messages(messages: &[ConversationMessage]) -> String {
  98. let user_messages = messages
  99. .iter()
  100. .filter(|message| message.role == MessageRole::User)
  101. .count();
  102. let assistant_messages = messages
  103. .iter()
  104. .filter(|message| message.role == MessageRole::Assistant)
  105. .count();
  106. let tool_messages = messages
  107. .iter()
  108. .filter(|message| message.role == MessageRole::Tool)
  109. .count();
  110. let mut tool_names = messages
  111. .iter()
  112. .flat_map(|message| message.blocks.iter())
  113. .filter_map(|block| match block {
  114. ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
  115. ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
  116. ContentBlock::Text { .. } => None,
  117. })
  118. .collect::<Vec<_>>();
  119. tool_names.sort_unstable();
  120. tool_names.dedup();
  121. let mut lines = vec![
  122. "<summary>".to_string(),
  123. "Conversation summary:".to_string(),
  124. format!(
  125. "- Scope: {} earlier messages compacted (user={}, assistant={}, tool={}).",
  126. messages.len(),
  127. user_messages,
  128. assistant_messages,
  129. tool_messages
  130. ),
  131. ];
  132. if !tool_names.is_empty() {
  133. lines.push(format!("- Tools mentioned: {}.", tool_names.join(", ")));
  134. }
  135. let recent_user_requests = collect_recent_role_summaries(messages, MessageRole::User, 3);
  136. if !recent_user_requests.is_empty() {
  137. lines.push("- Recent user requests:".to_string());
  138. lines.extend(
  139. recent_user_requests
  140. .into_iter()
  141. .map(|request| format!(" - {request}")),
  142. );
  143. }
  144. let pending_work = infer_pending_work(messages);
  145. if !pending_work.is_empty() {
  146. lines.push("- Pending work:".to_string());
  147. lines.extend(pending_work.into_iter().map(|item| format!(" - {item}")));
  148. }
  149. let key_files = collect_key_files(messages);
  150. if !key_files.is_empty() {
  151. lines.push(format!("- Key files referenced: {}.", key_files.join(", ")));
  152. }
  153. if let Some(current_work) = infer_current_work(messages) {
  154. lines.push(format!("- Current work: {current_work}"));
  155. }
  156. lines.push("- Key timeline:".to_string());
  157. for message in messages {
  158. let role = match message.role {
  159. MessageRole::System => "system",
  160. MessageRole::User => "user",
  161. MessageRole::Assistant => "assistant",
  162. MessageRole::Tool => "tool",
  163. };
  164. let content = message
  165. .blocks
  166. .iter()
  167. .map(summarize_block)
  168. .collect::<Vec<_>>()
  169. .join(" | ");
  170. lines.push(format!(" - {role}: {content}"));
  171. }
  172. lines.push("</summary>".to_string());
  173. lines.join("\n")
  174. }
  175. fn summarize_block(block: &ContentBlock) -> String {
  176. let raw = match block {
  177. ContentBlock::Text { text } => text.clone(),
  178. ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
  179. ContentBlock::ToolResult {
  180. tool_name,
  181. output,
  182. is_error,
  183. ..
  184. } => format!(
  185. "tool_result {tool_name}: {}{output}",
  186. if *is_error { "error " } else { "" }
  187. ),
  188. };
  189. truncate_summary(&raw, 160)
  190. }
  191. fn collect_recent_role_summaries(
  192. messages: &[ConversationMessage],
  193. role: MessageRole,
  194. limit: usize,
  195. ) -> Vec<String> {
  196. messages
  197. .iter()
  198. .filter(|message| message.role == role)
  199. .rev()
  200. .filter_map(|message| first_text_block(message))
  201. .take(limit)
  202. .map(|text| truncate_summary(text, 160))
  203. .collect::<Vec<_>>()
  204. .into_iter()
  205. .rev()
  206. .collect()
  207. }
  208. fn infer_pending_work(messages: &[ConversationMessage]) -> Vec<String> {
  209. messages
  210. .iter()
  211. .rev()
  212. .filter_map(first_text_block)
  213. .filter(|text| {
  214. let lowered = text.to_ascii_lowercase();
  215. lowered.contains("todo")
  216. || lowered.contains("next")
  217. || lowered.contains("pending")
  218. || lowered.contains("follow up")
  219. || lowered.contains("remaining")
  220. })
  221. .take(3)
  222. .map(|text| truncate_summary(text, 160))
  223. .collect::<Vec<_>>()
  224. .into_iter()
  225. .rev()
  226. .collect()
  227. }
  228. fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
  229. let mut files = messages
  230. .iter()
  231. .flat_map(|message| message.blocks.iter())
  232. .map(|block| match block {
  233. ContentBlock::Text { text } => text.as_str(),
  234. ContentBlock::ToolUse { input, .. } => input.as_str(),
  235. ContentBlock::ToolResult { output, .. } => output.as_str(),
  236. })
  237. .flat_map(extract_file_candidates)
  238. .collect::<Vec<_>>();
  239. files.sort();
  240. files.dedup();
  241. files.into_iter().take(8).collect()
  242. }
  243. fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
  244. messages
  245. .iter()
  246. .rev()
  247. .filter_map(first_text_block)
  248. .find(|text| !text.trim().is_empty())
  249. .map(|text| truncate_summary(text, 200))
  250. }
  251. fn first_text_block(message: &ConversationMessage) -> Option<&str> {
  252. message.blocks.iter().find_map(|block| match block {
  253. ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
  254. ContentBlock::ToolUse { .. }
  255. | ContentBlock::ToolResult { .. }
  256. | ContentBlock::Text { .. } => None,
  257. })
  258. }
  259. fn has_interesting_extension(candidate: &str) -> bool {
  260. std::path::Path::new(candidate)
  261. .extension()
  262. .and_then(|extension| extension.to_str())
  263. .is_some_and(|extension| {
  264. ["rs", "ts", "tsx", "js", "json", "md"]
  265. .iter()
  266. .any(|expected| extension.eq_ignore_ascii_case(expected))
  267. })
  268. }
  269. fn extract_file_candidates(content: &str) -> Vec<String> {
  270. content
  271. .split_whitespace()
  272. .filter_map(|token| {
  273. let candidate = token.trim_matches(|char: char| {
  274. matches!(char, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
  275. });
  276. if candidate.contains('/') && has_interesting_extension(candidate) {
  277. Some(candidate.to_string())
  278. } else {
  279. None
  280. }
  281. })
  282. .collect()
  283. }
  284. fn truncate_summary(content: &str, max_chars: usize) -> String {
  285. if content.chars().count() <= max_chars {
  286. return content.to_string();
  287. }
  288. let mut truncated = content.chars().take(max_chars).collect::<String>();
  289. truncated.push('…');
  290. truncated
  291. }
  292. fn estimate_message_tokens(message: &ConversationMessage) -> usize {
  293. message
  294. .blocks
  295. .iter()
  296. .map(|block| match block {
  297. ContentBlock::Text { text } => text.len() / 4 + 1,
  298. ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
  299. ContentBlock::ToolResult {
  300. tool_name, output, ..
  301. } => (tool_name.len() + output.len()) / 4 + 1,
  302. })
  303. .sum()
  304. }
  305. fn extract_tag_block(content: &str, tag: &str) -> Option<String> {
  306. let start = format!("<{tag}>");
  307. let end = format!("</{tag}>");
  308. let start_index = content.find(&start)? + start.len();
  309. let end_index = content[start_index..].find(&end)? + start_index;
  310. Some(content[start_index..end_index].to_string())
  311. }
  312. fn strip_tag_block(content: &str, tag: &str) -> String {
  313. let start = format!("<{tag}>");
  314. let end = format!("</{tag}>");
  315. if let (Some(start_index), Some(end_index_rel)) = (content.find(&start), content.find(&end)) {
  316. let end_index = end_index_rel + end.len();
  317. let mut stripped = String::new();
  318. stripped.push_str(&content[..start_index]);
  319. stripped.push_str(&content[end_index..]);
  320. stripped
  321. } else {
  322. content.to_string()
  323. }
  324. }
  325. fn collapse_blank_lines(content: &str) -> String {
  326. let mut result = String::new();
  327. let mut last_blank = false;
  328. for line in content.lines() {
  329. let is_blank = line.trim().is_empty();
  330. if is_blank && last_blank {
  331. continue;
  332. }
  333. result.push_str(line);
  334. result.push('\n');
  335. last_blank = is_blank;
  336. }
  337. result
  338. }
  339. #[cfg(test)]
  340. mod tests {
  341. use super::{
  342. collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
  343. infer_pending_work, should_compact, CompactionConfig,
  344. };
  345. use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
  346. #[test]
  347. fn formats_compact_summary_like_upstream() {
  348. let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
  349. assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
  350. }
  351. #[test]
  352. fn leaves_small_sessions_unchanged() {
  353. let session = Session {
  354. version: 1,
  355. messages: vec![ConversationMessage::user_text("hello")],
  356. };
  357. let result = compact_session(&session, CompactionConfig::default());
  358. assert_eq!(result.removed_message_count, 0);
  359. assert_eq!(result.compacted_session, session);
  360. assert!(result.summary.is_empty());
  361. assert!(result.formatted_summary.is_empty());
  362. }
  363. #[test]
  364. fn compacts_older_messages_into_a_system_summary() {
  365. let session = Session {
  366. version: 1,
  367. messages: vec![
  368. ConversationMessage::user_text("one ".repeat(200)),
  369. ConversationMessage::assistant(vec![ContentBlock::Text {
  370. text: "two ".repeat(200),
  371. }]),
  372. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  373. ConversationMessage {
  374. role: MessageRole::Assistant,
  375. blocks: vec![ContentBlock::Text {
  376. text: "recent".to_string(),
  377. }],
  378. usage: None,
  379. },
  380. ],
  381. };
  382. let result = compact_session(
  383. &session,
  384. CompactionConfig {
  385. preserve_recent_messages: 2,
  386. max_estimated_tokens: 1,
  387. },
  388. );
  389. assert_eq!(result.removed_message_count, 2);
  390. assert_eq!(
  391. result.compacted_session.messages[0].role,
  392. MessageRole::System
  393. );
  394. assert!(matches!(
  395. &result.compacted_session.messages[0].blocks[0],
  396. ContentBlock::Text { text } if text.contains("Summary:")
  397. ));
  398. assert!(result.formatted_summary.contains("Scope:"));
  399. assert!(result.formatted_summary.contains("Key timeline:"));
  400. assert!(should_compact(
  401. &session,
  402. CompactionConfig {
  403. preserve_recent_messages: 2,
  404. max_estimated_tokens: 1,
  405. }
  406. ));
  407. assert!(
  408. estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
  409. );
  410. }
  411. #[test]
  412. fn truncates_long_blocks_in_summary() {
  413. let summary = super::summarize_block(&ContentBlock::Text {
  414. text: "x".repeat(400),
  415. });
  416. assert!(summary.ends_with('…'));
  417. assert!(summary.chars().count() <= 161);
  418. }
  419. #[test]
  420. fn extracts_key_files_from_message_content() {
  421. let files = collect_key_files(&[ConversationMessage::user_text(
  422. "Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
  423. )]);
  424. assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
  425. assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
  426. }
  427. #[test]
  428. fn infers_pending_work_from_recent_messages() {
  429. let pending = infer_pending_work(&[
  430. ConversationMessage::user_text("done"),
  431. ConversationMessage::assistant(vec![ContentBlock::Text {
  432. text: "Next: update tests and follow up on remaining CLI polish.".to_string(),
  433. }]),
  434. ]);
  435. assert_eq!(pending.len(), 1);
  436. assert!(pending[0].contains("Next: update tests"));
  437. }
  438. }