compact.rs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  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 compacted_session: Session,
  19. pub removed_message_count: usize,
  20. }
  21. #[must_use]
  22. pub fn estimate_session_tokens(session: &Session) -> usize {
  23. session.messages.iter().map(estimate_message_tokens).sum()
  24. }
  25. #[must_use]
  26. pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
  27. session.messages.len() > config.preserve_recent_messages
  28. && estimate_session_tokens(session) >= config.max_estimated_tokens
  29. }
  30. #[must_use]
  31. pub fn format_compact_summary(summary: &str) -> String {
  32. let without_analysis = strip_tag_block(summary, "analysis");
  33. let formatted = if let Some(content) = extract_tag_block(&without_analysis, "summary") {
  34. without_analysis.replace(
  35. &format!("<summary>{content}</summary>"),
  36. &format!("Summary:\n{}", content.trim()),
  37. )
  38. } else {
  39. without_analysis
  40. };
  41. collapse_blank_lines(&formatted).trim().to_string()
  42. }
  43. #[must_use]
  44. pub fn get_compact_continuation_message(
  45. summary: &str,
  46. suppress_follow_up_questions: bool,
  47. recent_messages_preserved: bool,
  48. ) -> String {
  49. let mut base = format!(
  50. "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{}",
  51. format_compact_summary(summary)
  52. );
  53. if recent_messages_preserved {
  54. base.push_str("\n\nRecent messages are preserved verbatim.");
  55. }
  56. if suppress_follow_up_questions {
  57. 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.");
  58. }
  59. base
  60. }
  61. #[must_use]
  62. pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
  63. if !should_compact(session, config) {
  64. return CompactionResult {
  65. summary: String::new(),
  66. compacted_session: session.clone(),
  67. removed_message_count: 0,
  68. };
  69. }
  70. let keep_from = session
  71. .messages
  72. .len()
  73. .saturating_sub(config.preserve_recent_messages);
  74. let removed = &session.messages[..keep_from];
  75. let preserved = session.messages[keep_from..].to_vec();
  76. let summary = summarize_messages(removed);
  77. let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
  78. let mut compacted_messages = vec![ConversationMessage {
  79. role: MessageRole::System,
  80. blocks: vec![ContentBlock::Text { text: continuation }],
  81. usage: None,
  82. }];
  83. compacted_messages.extend(preserved);
  84. CompactionResult {
  85. summary,
  86. compacted_session: Session {
  87. version: session.version,
  88. messages: compacted_messages,
  89. },
  90. removed_message_count: removed.len(),
  91. }
  92. }
  93. fn summarize_messages(messages: &[ConversationMessage]) -> String {
  94. let mut lines = vec!["<summary>".to_string(), "Conversation summary:".to_string()];
  95. for message in messages {
  96. let role = match message.role {
  97. MessageRole::System => "system",
  98. MessageRole::User => "user",
  99. MessageRole::Assistant => "assistant",
  100. MessageRole::Tool => "tool",
  101. };
  102. let content = message
  103. .blocks
  104. .iter()
  105. .map(summarize_block)
  106. .collect::<Vec<_>>()
  107. .join(" | ");
  108. lines.push(format!("- {role}: {content}"));
  109. }
  110. lines.push("</summary>".to_string());
  111. lines.join("\n")
  112. }
  113. fn summarize_block(block: &ContentBlock) -> String {
  114. let raw = match block {
  115. ContentBlock::Text { text } => text.clone(),
  116. ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
  117. ContentBlock::ToolResult {
  118. tool_name,
  119. output,
  120. is_error,
  121. ..
  122. } => format!(
  123. "tool_result {tool_name}: {}{output}",
  124. if *is_error { "error " } else { "" }
  125. ),
  126. };
  127. truncate_summary(&raw, 160)
  128. }
  129. fn truncate_summary(content: &str, max_chars: usize) -> String {
  130. if content.chars().count() <= max_chars {
  131. return content.to_string();
  132. }
  133. let mut truncated = content.chars().take(max_chars).collect::<String>();
  134. truncated.push('…');
  135. truncated
  136. }
  137. fn estimate_message_tokens(message: &ConversationMessage) -> usize {
  138. message
  139. .blocks
  140. .iter()
  141. .map(|block| match block {
  142. ContentBlock::Text { text } => text.len() / 4 + 1,
  143. ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
  144. ContentBlock::ToolResult {
  145. tool_name, output, ..
  146. } => (tool_name.len() + output.len()) / 4 + 1,
  147. })
  148. .sum()
  149. }
  150. fn extract_tag_block(content: &str, tag: &str) -> Option<String> {
  151. let start = format!("<{tag}>");
  152. let end = format!("</{tag}>");
  153. let start_index = content.find(&start)? + start.len();
  154. let end_index = content[start_index..].find(&end)? + start_index;
  155. Some(content[start_index..end_index].to_string())
  156. }
  157. fn strip_tag_block(content: &str, tag: &str) -> String {
  158. let start = format!("<{tag}>");
  159. let end = format!("</{tag}>");
  160. if let (Some(start_index), Some(end_index_rel)) = (content.find(&start), content.find(&end)) {
  161. let end_index = end_index_rel + end.len();
  162. let mut stripped = String::new();
  163. stripped.push_str(&content[..start_index]);
  164. stripped.push_str(&content[end_index..]);
  165. stripped
  166. } else {
  167. content.to_string()
  168. }
  169. }
  170. fn collapse_blank_lines(content: &str) -> String {
  171. let mut result = String::new();
  172. let mut last_blank = false;
  173. for line in content.lines() {
  174. let is_blank = line.trim().is_empty();
  175. if is_blank && last_blank {
  176. continue;
  177. }
  178. result.push_str(line);
  179. result.push('\n');
  180. last_blank = is_blank;
  181. }
  182. result
  183. }
  184. #[cfg(test)]
  185. mod tests {
  186. use super::{
  187. compact_session, estimate_session_tokens, format_compact_summary, should_compact,
  188. CompactionConfig,
  189. };
  190. use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
  191. #[test]
  192. fn formats_compact_summary_like_upstream() {
  193. let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
  194. assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
  195. }
  196. #[test]
  197. fn leaves_small_sessions_unchanged() {
  198. let session = Session {
  199. version: 1,
  200. messages: vec![ConversationMessage::user_text("hello")],
  201. };
  202. let result = compact_session(&session, CompactionConfig::default());
  203. assert_eq!(result.removed_message_count, 0);
  204. assert_eq!(result.compacted_session, session);
  205. assert!(result.summary.is_empty());
  206. }
  207. #[test]
  208. fn compacts_older_messages_into_a_system_summary() {
  209. let session = Session {
  210. version: 1,
  211. messages: vec![
  212. ConversationMessage::user_text("one ".repeat(200)),
  213. ConversationMessage::assistant(vec![ContentBlock::Text {
  214. text: "two ".repeat(200),
  215. }]),
  216. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  217. ConversationMessage {
  218. role: MessageRole::Assistant,
  219. blocks: vec![ContentBlock::Text {
  220. text: "recent".to_string(),
  221. }],
  222. usage: None,
  223. },
  224. ],
  225. };
  226. let result = compact_session(
  227. &session,
  228. CompactionConfig {
  229. preserve_recent_messages: 2,
  230. max_estimated_tokens: 1,
  231. },
  232. );
  233. assert_eq!(result.removed_message_count, 2);
  234. assert_eq!(
  235. result.compacted_session.messages[0].role,
  236. MessageRole::System
  237. );
  238. assert!(matches!(
  239. &result.compacted_session.messages[0].blocks[0],
  240. ContentBlock::Text { text } if text.contains("Summary:")
  241. ));
  242. assert!(should_compact(
  243. &session,
  244. CompactionConfig {
  245. preserve_recent_messages: 2,
  246. max_estimated_tokens: 1,
  247. }
  248. ));
  249. assert!(
  250. estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
  251. );
  252. }
  253. #[test]
  254. fn truncates_long_blocks_in_summary() {
  255. let summary = super::summarize_block(&ContentBlock::Text {
  256. text: "x".repeat(400),
  257. });
  258. assert!(summary.ends_with('…'));
  259. assert!(summary.chars().count() <= 161);
  260. }
  261. }
备用站点 当前处于降级运行的备用站点,仅供应急访问,数据和功能可能不是最新。