1mod branch;
8mod utils;
9
10pub use branch::{
11 BRANCH_SUMMARY_PREAMBLE, BRANCH_SUMMARY_PROMPT, BranchPreparation, BranchSummaryDetails,
12 BranchSummaryResult, CollectEntriesResult, DEFAULT_BRANCH_CONTEXT_WINDOW,
13 DEFAULT_BRANCH_MAX_TOKENS, DEFAULT_BRANCH_RESERVE_TOKENS, GenerateBranchSummaryOptions,
14 collect_entries_for_branch_summary, generate_branch_summary, prepare_branch_entries,
15};
16pub use utils::{
17 FileOperations, SUMMARIZATION_SYSTEM_PROMPT, TOOL_RESULT_MAX_CHARS, compute_file_lists,
18 create_file_ops, extract_file_ops_from_message, format_file_operations, serialize_conversation,
19};
20
21use std::collections::BTreeMap;
22use std::fmt::Write as _;
23use std::future::Future;
24use std::pin::Pin;
25use std::sync::Arc;
26
27use futures::StreamExt;
28use pi_agent::AgentMessage;
29use pi_ai::{
30 AssistantContent, AssistantMessage, AssistantMessageEvent, Context, Message, Model,
31 ModelThinkingLevel, ProviderError, StopReason, StreamOptions, TextContent, Usage, UserContent,
32 UserMessage, UserMessageContent,
33};
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36use thiserror::Error;
37use tokio_util::sync::CancellationToken;
38
39use crate::core::messages::{MessageConversionError, convert_to_llm};
40use crate::core::sessions::{
41 LeafRef, SessionEntry, build_session_context, session_entry_to_context_messages,
42};
43
44pub const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = CompactionSettings {
50 enabled: true,
51 reserve_tokens: 16_384,
52 keep_recent_tokens: 20_000,
53};
54
55const ESTIMATED_IMAGE_CHARS: usize = 4800;
57
58pub const SUMMARIZATION_PROMPT: &str = "The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.\n\nUse this EXACT format:\n\n## Goal\n[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned by user]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Current work]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [Ordered list of what should happen next]\n\n## Critical Context\n- [Any data, examples, or references needed to continue]\n- [Or \"(none)\" if not applicable]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";
60
61pub const UPDATE_SUMMARIZATION_PROMPT: &str = "The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.\n\nUpdate the existing structured summary with new information. RULES:\n- PRESERVE all existing information from the previous summary\n- ADD new progress, decisions, and context from the new messages\n- UPDATE the Progress section: move items from \"In Progress\" to \"Done\" when completed\n- UPDATE \"Next Steps\" based on what was accomplished\n- PRESERVE exact file paths, function names, and error messages\n- If something is no longer relevant, you may remove it\n\nUse this EXACT format:\n\n## Goal\n[Preserve existing goals, add new ones if the task expanded]\n\n## Constraints & Preferences\n- [Preserve existing, add new ones discovered]\n\n## Progress\n### Done\n- [x] [Include previously done items AND newly completed items]\n\n### In Progress\n- [ ] [Current work - update based on progress]\n\n### Blocked\n- [Current blockers - remove if resolved]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale] (preserve all previous, add new)\n\n## Next Steps\n1. [Update based on current state]\n\n## Critical Context\n- [Preserve important context, add new if needed]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";
63
64pub const TURN_PREFIX_SUMMARIZATION_PROMPT: &str = "This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.\n\nSummarize the prefix to provide context for the retained suffix:\n\n## Original Request\n[What did the user ask for in this turn?]\n\n## Early Progress\n- [Key decisions and work done in the prefix]\n\n## Context for Suffix\n- [Information needed to understand the retained recent work]\n\nBe concise. Focus on what's needed to understand the kept suffix.";
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct CompactionSettings {
75 pub enabled: bool,
77 pub reserve_tokens: u64,
79 pub keep_recent_tokens: u64,
81}
82
83impl Default for CompactionSettings {
84 fn default() -> Self {
85 DEFAULT_COMPACTION_SETTINGS
86 }
87}
88
89#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct CompactionDetails {
93 pub read_files: Vec<String>,
95 pub modified_files: Vec<String>,
97}
98
99#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct CompactionResult {
103 pub summary: String,
105 pub first_kept_entry_id: String,
107 pub tokens_before: u64,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub estimated_tokens_after: Option<u64>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub details: Option<Value>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub from_hook: Option<bool>,
118}
119
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub struct ContextUsageEstimate {
123 pub tokens: u64,
125 pub usage_tokens: u64,
127 pub trailing_tokens: u64,
129 pub last_usage_index: Option<usize>,
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct CutPointResult {
136 pub first_kept_entry_index: usize,
138 pub turn_start_index: usize,
140 pub is_split_turn: bool,
142}
143
144#[derive(Clone, Debug)]
146pub struct CompactionPreparation {
147 pub first_kept_entry_id: String,
149 pub messages_to_summarize: Vec<AgentMessage>,
151 pub turn_prefix_messages: Vec<AgentMessage>,
153 pub is_split_turn: bool,
155 pub tokens_before: u64,
157 pub previous_summary: Option<String>,
159 pub file_ops: FileOperations,
161 pub settings: CompactionSettings,
163}
164
165#[derive(Debug, Error)]
167pub enum CompactionError {
168 #[error("Already compacted")]
170 AlreadyCompacted,
171 #[error("Nothing to compact (session too small)")]
173 NothingToCompact,
174 #[error("First kept entry has no UUID - session may need migration")]
176 MissingFirstKeptId,
177 #[error("Summarization failed: {0}")]
179 SummarizationFailed(String),
180 #[error("Turn prefix summarization failed: {0}")]
182 TurnPrefixSummarizationFailed(String),
183 #[error("Summarization cancelled")]
185 Cancelled,
186 #[error(transparent)]
188 MessageConversion(#[from] MessageConversionError),
189 #[error("Summarization failed: {0}")]
191 Provider(#[from] ProviderError),
192}
193
194#[derive(Clone, Debug, Default)]
196pub struct BeforeCompactResult {
197 pub cancel: bool,
199 pub compaction: Option<CompactionResult>,
201}
202
203pub trait CompactionHooks: Send + Sync {
205 fn before_compact(
207 &self,
208 _preparation: &CompactionPreparation,
209 _custom_instructions: Option<&str>,
210 _cancel: &CancellationToken,
211 ) -> Pin<Box<dyn Future<Output = Result<BeforeCompactResult, CompactionError>> + Send + '_>>
212 {
213 Box::pin(async { Ok(BeforeCompactResult::default()) })
214 }
215
216 fn after_compact(
218 &self,
219 _result: &CompactionResult,
220 _from_hook: bool,
221 ) -> Pin<Box<dyn Future<Output = Result<(), CompactionError>> + Send + '_>> {
222 Box::pin(async { Ok(()) })
223 }
224}
225
226#[derive(Clone, Copy, Debug, Default)]
228pub struct NoopCompactionHooks;
229
230impl CompactionHooks for NoopCompactionHooks {}
231
232pub type SummarizeStreamFn = Arc<
234 dyn Fn(
235 Model,
236 Context,
237 StreamOptions,
238 ) -> Pin<
239 Box<
240 dyn Future<
241 Output = Pin<
242 Box<
243 dyn futures::Stream<
244 Item = Result<AssistantMessageEvent, ProviderError>,
245 > + Send,
246 >,
247 >,
248 > + Send,
249 >,
250 > + Send
251 + Sync,
252>;
253
254#[must_use]
262pub fn calculate_context_tokens(usage: &Usage) -> u64 {
263 if usage.total_tokens != 0 {
264 usage.total_tokens
265 } else {
266 usage
267 .input
268 .saturating_add(usage.output)
269 .saturating_add(usage.cache_read)
270 .saturating_add(usage.cache_write)
271 }
272}
273
274fn get_assistant_usage(msg: &AgentMessage) -> Option<&Usage> {
275 let AgentMessage::Llm(llm) = msg else {
276 return None;
277 };
278 let Message::Assistant(assistant) = llm.as_ref() else {
279 return None;
280 };
281 if matches!(
282 assistant.stop_reason,
283 StopReason::Aborted | StopReason::Error
284 ) {
285 return None;
286 }
287 if calculate_context_tokens(&assistant.usage) == 0 {
288 return None;
289 }
290 Some(&assistant.usage)
291}
292
293#[must_use]
295pub fn get_last_assistant_usage(entries: &[&SessionEntry]) -> Option<Usage> {
296 for entry in entries.iter().rev() {
297 if let SessionEntry::Message(m) = entry
298 && let Some(usage) = get_assistant_usage(&m.message)
299 {
300 return Some(usage.clone());
301 }
302 }
303 None
304}
305
306fn get_last_assistant_usage_info(messages: &[AgentMessage]) -> Option<(Usage, usize)> {
307 for (i, msg) in messages.iter().enumerate().rev() {
308 if let Some(usage) = get_assistant_usage(msg) {
309 return Some((usage.clone(), i));
310 }
311 }
312 None
313}
314
315#[must_use]
317pub fn estimate_context_tokens(messages: &[AgentMessage]) -> ContextUsageEstimate {
318 match get_last_assistant_usage_info(messages) {
319 None => {
320 let estimated: u64 = messages.iter().map(estimate_tokens).sum();
321 ContextUsageEstimate {
322 tokens: estimated,
323 usage_tokens: 0,
324 trailing_tokens: estimated,
325 last_usage_index: None,
326 }
327 }
328 Some((usage, index)) => {
329 let usage_tokens = calculate_context_tokens(&usage);
330 let trailing_tokens: u64 = messages
331 .iter()
332 .skip(index.saturating_add(1))
333 .map(estimate_tokens)
334 .sum();
335 ContextUsageEstimate {
336 tokens: usage_tokens.saturating_add(trailing_tokens),
337 usage_tokens,
338 trailing_tokens,
339 last_usage_index: Some(index),
340 }
341 }
342 }
343}
344
345#[must_use]
349pub fn should_compact(
350 context_tokens: u64,
351 context_window: u64,
352 settings: &CompactionSettings,
353) -> bool {
354 if !settings.enabled {
355 return false;
356 }
357 i128::from(context_tokens) > i128::from(context_window) - i128::from(settings.reserve_tokens)
358}
359
360fn js_string_len(text: &str) -> usize {
365 text.encode_utf16().count()
366}
367
368fn estimate_text_and_image_user_content(content: &UserMessageContent) -> usize {
369 match content {
370 UserMessageContent::Text(text) => js_string_len(text),
371 UserMessageContent::Blocks(blocks) => blocks
372 .iter()
373 .map(|block| match block {
374 UserContent::Text(text) => js_string_len(&text.text.read()),
375 UserContent::Image(_) => ESTIMATED_IMAGE_CHARS,
376 })
377 .sum(),
378 }
379}
380
381fn estimate_tool_result_chars(content: &[pi_ai::ToolResultContent]) -> usize {
382 content
383 .iter()
384 .map(|block| match block {
385 pi_ai::ToolResultContent::Text(text) => js_string_len(&text.text.read()),
386 pi_ai::ToolResultContent::Image(_) => ESTIMATED_IMAGE_CHARS,
387 })
388 .sum()
389}
390
391fn ceil_div4(chars: usize) -> u64 {
392 if chars == 0 {
393 0
394 } else {
395 u64::try_from(chars.div_ceil(4)).unwrap_or(u64::MAX)
396 }
397}
398
399#[must_use]
401pub fn estimate_tokens(message: &AgentMessage) -> u64 {
402 match message {
403 AgentMessage::Llm(llm) => match llm.as_ref() {
404 Message::User(user) => ceil_div4(estimate_text_and_image_user_content(&user.content)),
405 Message::Assistant(assistant) => {
406 let mut chars = 0usize;
407 for block in &assistant.content {
408 match block {
409 AssistantContent::Text(text) => chars += js_string_len(&text.text.read()),
410 AssistantContent::Thinking(thinking) => {
411 chars += js_string_len(&thinking.thinking.read());
412 }
413 AssistantContent::ToolCall(call) => {
414 chars += js_string_len(&call.name);
415 chars += serde_json::to_string(&call.arguments)
416 .map_or(0, |serialized| js_string_len(&serialized));
417 }
418 }
419 }
420 ceil_div4(chars)
421 }
422 Message::ToolResult(result) => ceil_div4(estimate_tool_result_chars(&result.content)),
423 },
424 AgentMessage::Custom(custom) => match custom.role.as_str() {
425 "custom" => {
426 let chars = custom
427 .payload
428 .get("content")
429 .map_or(0, estimate_custom_content_chars);
430 ceil_div4(chars)
431 }
432 "bashExecution" => {
433 let command = custom
434 .payload
435 .get("command")
436 .and_then(Value::as_str)
437 .unwrap_or("");
438 let output = custom
439 .payload
440 .get("output")
441 .and_then(Value::as_str)
442 .unwrap_or("");
443 ceil_div4(js_string_len(command) + js_string_len(output))
444 }
445 "branchSummary" | "compactionSummary" => {
446 let summary = custom
447 .payload
448 .get("summary")
449 .and_then(Value::as_str)
450 .unwrap_or("");
451 ceil_div4(js_string_len(summary))
452 }
453 _ => 0,
454 },
455 }
456}
457
458fn estimate_custom_content_chars(content: &Value) -> usize {
459 match content {
460 Value::String(text) => js_string_len(text),
461 Value::Array(blocks) => blocks
462 .iter()
463 .map(|block| {
464 let kind = block.get("type").and_then(Value::as_str).unwrap_or("");
465 if kind == "text" {
466 block
467 .get("text")
468 .and_then(Value::as_str)
469 .map_or(0, js_string_len)
470 } else if kind == "image" {
471 ESTIMATED_IMAGE_CHARS
472 } else {
473 0
474 }
475 })
476 .sum(),
477 _ => 0,
478 }
479}
480
481fn is_cut_point_message(message: &AgentMessage) -> bool {
486 matches!(
487 message.role(),
488 "user" | "assistant" | "bashExecution" | "custom" | "branchSummary" | "compactionSummary"
489 )
490}
491
492fn is_turn_start_message(message: &AgentMessage) -> bool {
493 matches!(
494 message.role(),
495 "user" | "bashExecution" | "custom" | "branchSummary" | "compactionSummary"
496 )
497}
498
499fn entry_context_messages(entry: &SessionEntry) -> Vec<AgentMessage> {
500 session_entry_to_context_messages(entry).unwrap_or_default()
501}
502
503fn is_turn_start_entry(entry: &SessionEntry) -> bool {
504 if entry.discriminant() == "compaction" {
505 return false;
506 }
507 entry_context_messages(entry)
508 .iter()
509 .any(is_turn_start_message)
510}
511
512fn find_valid_cut_points(
513 entries: &[&SessionEntry],
514 start_index: usize,
515 end_index: usize,
516) -> Vec<usize> {
517 let mut cut_points = Vec::new();
518 let end = end_index.min(entries.len());
519 for (i, entry) in entries.iter().enumerate().take(end).skip(start_index) {
520 if entry.discriminant() == "compaction" {
521 continue;
522 }
523 if entry_context_messages(entry)
524 .iter()
525 .any(is_cut_point_message)
526 {
527 cut_points.push(i);
528 }
529 }
530 cut_points
531}
532
533#[must_use]
537pub fn find_turn_start_index(
538 entries: &[&SessionEntry],
539 entry_index: usize,
540 start_index: usize,
541) -> usize {
542 let mut i = entry_index;
543 loop {
544 if i < start_index || i >= entries.len() {
545 return usize::MAX;
546 }
547 if is_turn_start_entry(entries[i]) {
548 return i;
549 }
550 if i == 0 {
551 return usize::MAX;
552 }
553 i -= 1;
554 }
555}
556
557#[must_use]
563pub fn find_cut_point(
564 entries: &[&SessionEntry],
565 start_index: usize,
566 end_index: usize,
567 keep_recent_tokens: u64,
568) -> CutPointResult {
569 let cut_points = find_valid_cut_points(entries, start_index, end_index);
570 if cut_points.is_empty() {
571 return CutPointResult {
572 first_kept_entry_index: start_index,
573 turn_start_index: usize::MAX,
574 is_split_turn: false,
575 };
576 }
577
578 let mut accumulated_tokens = 0u64;
579 let mut cut_index = cut_points[0];
580 let end = end_index.min(entries.len());
581
582 if end > start_index {
583 let mut i = end - 1;
584 loop {
585 let entry = entries[i];
586 let message_tokens: u64 = entry_context_messages(entry)
587 .iter()
588 .map(estimate_tokens)
589 .sum();
590 if message_tokens > 0 {
591 accumulated_tokens = accumulated_tokens.saturating_add(message_tokens);
592 if accumulated_tokens >= keep_recent_tokens {
593 for &cp in &cut_points {
594 if cp >= i {
595 cut_index = cp;
596 break;
597 }
598 }
599 break;
600 }
601 }
602 if i == start_index {
603 break;
604 }
605 i -= 1;
606 }
607 }
608
609 while cut_index > start_index {
611 let prev = entries[cut_index - 1];
612 if prev.discriminant() == "compaction" || !entry_context_messages(prev).is_empty() {
613 break;
614 }
615 cut_index -= 1;
616 }
617
618 let cut_entry = entries[cut_index];
619 let starts_turn = is_turn_start_entry(cut_entry);
620 let turn_start_index = if starts_turn {
621 usize::MAX
622 } else {
623 find_turn_start_index(entries, cut_index, start_index)
624 };
625
626 CutPointResult {
627 first_kept_entry_index: cut_index,
628 turn_start_index,
629 is_split_turn: !starts_turn && turn_start_index != usize::MAX,
630 }
631}
632
633fn get_message_from_entry_for_compaction(entry: &SessionEntry) -> Option<AgentMessage> {
638 if entry.discriminant() == "compaction" {
639 return None;
640 }
641 entry_context_messages(entry).into_iter().next()
642}
643
644fn extract_file_operations(
645 messages: &[AgentMessage],
646 entries: &[&SessionEntry],
647 prev_compaction_index: Option<usize>,
648) -> FileOperations {
649 let mut file_ops = create_file_ops();
650
651 if let Some(idx) = prev_compaction_index
652 && let Some(SessionEntry::Compaction(prev)) = entries.get(idx).copied()
653 {
654 let from_hook = prev.from_hook.unwrap_or(false);
655 if !from_hook && let Some(details) = prev.details.as_ref() {
656 merge_details_into_file_ops(details, &mut file_ops);
657 }
658 }
659
660 for msg in messages {
661 extract_file_ops_from_message(msg, &mut file_ops);
662 }
663 file_ops
664}
665
666fn merge_details_into_file_ops(details: &Value, file_ops: &mut FileOperations) {
667 if let Some(arr) = details.get("readFiles").and_then(Value::as_array) {
668 for f in arr {
669 if let Some(path) = f.as_str() {
670 file_ops.read.insert(path.to_owned());
671 }
672 }
673 }
674 if let Some(arr) = details.get("modifiedFiles").and_then(Value::as_array) {
675 for f in arr {
676 if let Some(path) = f.as_str() {
677 file_ops.edited.insert(path.to_owned());
679 }
680 }
681 }
682}
683
684pub fn prepare_compaction(
698 path_entries: &[&SessionEntry],
699 settings: CompactionSettings,
700) -> Result<Option<CompactionPreparation>, CompactionError> {
701 if path_entries
702 .last()
703 .is_some_and(|e| e.discriminant() == "compaction")
704 {
705 return Ok(None);
706 }
707
708 let mut prev_compaction_index = None;
709 for (i, entry) in path_entries.iter().enumerate().rev() {
710 if entry.discriminant() == "compaction" {
711 prev_compaction_index = Some(i);
712 break;
713 }
714 }
715
716 let mut previous_summary = None;
717 let mut boundary_start = 0usize;
718 if let Some(idx) = prev_compaction_index
719 && let SessionEntry::Compaction(prev) = path_entries[idx]
720 {
721 previous_summary = Some(prev.summary.clone());
722 let first_kept_idx = path_entries
723 .iter()
724 .position(|e| e.id() == Some(prev.first_kept_entry_id.as_str()));
725 boundary_start = first_kept_idx.unwrap_or(idx.saturating_add(1));
726 }
727 let boundary_end = path_entries.len();
728
729 let session_ctx = build_session_context(path_entries, LeafRef::Last)?;
730 let tokens_before = estimate_context_tokens(&session_ctx.messages).tokens;
731
732 let cut_point = find_cut_point(
733 path_entries,
734 boundary_start,
735 boundary_end,
736 settings.keep_recent_tokens,
737 );
738
739 let first_kept_entry = path_entries.get(cut_point.first_kept_entry_index).copied();
740 let Some(first_kept_entry) = first_kept_entry else {
741 return Ok(None);
742 };
743 let Some(first_kept_entry_id) = first_kept_entry.id().map(str::to_owned) else {
744 return Ok(None);
745 };
746
747 let history_end = if cut_point.is_split_turn {
748 cut_point.turn_start_index
749 } else {
750 cut_point.first_kept_entry_index
751 };
752
753 let mut messages_to_summarize = Vec::new();
754 for entry in path_entries.iter().take(history_end).skip(boundary_start) {
755 if let Some(msg) = get_message_from_entry_for_compaction(entry) {
756 messages_to_summarize.push(msg);
757 }
758 }
759
760 let mut turn_prefix_messages = Vec::new();
761 if cut_point.is_split_turn {
762 for entry in path_entries
763 .iter()
764 .take(cut_point.first_kept_entry_index)
765 .skip(cut_point.turn_start_index)
766 {
767 if let Some(msg) = get_message_from_entry_for_compaction(entry) {
768 turn_prefix_messages.push(msg);
769 }
770 }
771 }
772
773 if messages_to_summarize.is_empty() && turn_prefix_messages.is_empty() {
774 return Ok(None);
775 }
776
777 let mut file_ops =
778 extract_file_operations(&messages_to_summarize, path_entries, prev_compaction_index);
779 if cut_point.is_split_turn {
780 for msg in &turn_prefix_messages {
781 extract_file_ops_from_message(msg, &mut file_ops);
782 }
783 }
784
785 Ok(Some(CompactionPreparation {
786 first_kept_entry_id,
787 messages_to_summarize,
788 turn_prefix_messages,
789 is_split_turn: cut_point.is_split_turn,
790 tokens_before,
791 previous_summary,
792 file_ops,
793 settings,
794 }))
795}
796
797#[must_use]
799pub fn preparation_none_error(path_entries: &[&SessionEntry]) -> CompactionError {
800 if path_entries
801 .last()
802 .is_some_and(|e| e.discriminant() == "compaction")
803 {
804 CompactionError::AlreadyCompacted
805 } else {
806 CompactionError::NothingToCompact
807 }
808}
809
810fn thinking_level_from_str(level: &str) -> Option<ModelThinkingLevel> {
815 match level {
816 "off" => Some(ModelThinkingLevel::Off),
817 "minimal" => Some(ModelThinkingLevel::Minimal),
818 "low" => Some(ModelThinkingLevel::Low),
819 "medium" => Some(ModelThinkingLevel::Medium),
820 "high" => Some(ModelThinkingLevel::High),
821 "xhigh" => Some(ModelThinkingLevel::Xhigh),
822 "max" => Some(ModelThinkingLevel::Max),
823 _ => None,
824 }
825}
826
827fn create_summarization_options(
828 model: &Model,
829 max_tokens: u64,
830 api_key: Option<String>,
831 headers: Option<BTreeMap<String, Option<String>>>,
832 env: Option<BTreeMap<String, String>>,
833 signal: Option<CancellationToken>,
834 thinking_level: Option<&str>,
835) -> StreamOptions {
836 let mut options = StreamOptions {
837 max_tokens: Some(max_tokens),
838 signal,
839 api_key,
840 headers,
841 env,
842 ..StreamOptions::default()
843 };
844 if model.reasoning
845 && let Some(level) = thinking_level.filter(|level| *level != "off")
846 && thinking_level_from_str(level).is_some()
847 {
848 options
849 .extra
850 .insert("reasoning".to_owned(), Value::String(level.to_owned()));
851 }
852 options
853}
854
855async fn complete_summarization(
856 model: &Model,
857 context: Context,
858 options: StreamOptions,
859 stream_fn: &SummarizeStreamFn,
860) -> Result<AssistantMessage, CompactionError> {
861 ensure_not_cancelled(options.signal.as_ref())?;
862
863 let mut stream = stream_fn(model.clone(), context, options).await;
864 let mut last: Option<AssistantMessage> = None;
865
866 while let Some(item) = stream.next().await {
867 match item {
868 Ok(AssistantMessageEvent::Done { message, .. }) => {
869 return Ok(message);
870 }
871 Ok(AssistantMessageEvent::Error { error, .. }) => {
872 return Ok(error);
873 }
874 Ok(other) => {
875 if let Some(partial) = event_partial_message(&other) {
877 last = Some(partial.clone());
878 }
879 }
880 Err(err) => return Err(CompactionError::Provider(err)),
881 }
882 }
883
884 last.ok_or_else(|| CompactionError::SummarizationFailed("Unknown error".to_owned()))
885}
886
887fn event_partial_message(event: &AssistantMessageEvent) -> Option<&AssistantMessage> {
888 match event {
889 AssistantMessageEvent::Start { partial }
890 | AssistantMessageEvent::TextStart { partial, .. }
891 | AssistantMessageEvent::TextDelta { partial, .. }
892 | AssistantMessageEvent::TextEnd { partial, .. }
893 | AssistantMessageEvent::ThinkingStart { partial, .. }
894 | AssistantMessageEvent::ThinkingDelta { partial, .. }
895 | AssistantMessageEvent::ThinkingEnd { partial, .. }
896 | AssistantMessageEvent::ToolCallStart { partial, .. }
897 | AssistantMessageEvent::ToolCallDelta { partial, .. }
898 | AssistantMessageEvent::ToolCallEnd { partial, .. } => Some(partial),
899 AssistantMessageEvent::Done { .. } | AssistantMessageEvent::Error { .. } => None,
900 }
901}
902
903fn assistant_text(message: &AssistantMessage) -> String {
904 message
905 .content
906 .iter()
907 .filter_map(|block| match block {
908 AssistantContent::Text(text) => Some(text.text.to_string()),
909 _ => None,
910 })
911 .collect::<Vec<_>>()
912 .join("\n")
913}
914
915fn history_max_tokens(reserve_tokens: u64, model: &Model) -> u64 {
916 let budget = (reserve_tokens / 5) * 4 + (reserve_tokens % 5) * 4 / 5;
917 if model.max_tokens > 0 {
918 budget.min(model.max_tokens)
919 } else {
920 budget
921 }
922}
923
924fn turn_prefix_max_tokens(reserve_tokens: u64, model: &Model) -> u64 {
925 let budget = reserve_tokens / 2;
926 if model.max_tokens > 0 {
927 budget.min(model.max_tokens)
928 } else {
929 budget
930 }
931}
932
933fn ensure_not_cancelled(signal: Option<&CancellationToken>) -> Result<(), CompactionError> {
934 if signal.is_some_and(CancellationToken::is_cancelled) {
935 Err(CompactionError::Cancelled)
936 } else {
937 Ok(())
938 }
939}
940
941pub async fn generate_summary(
951 preparation: &CompactionPreparation,
952 options: &CompactOptions<'_>,
953) -> Result<String, CompactionError> {
954 ensure_not_cancelled(options.signal.as_ref())?;
955
956 let max_tokens = history_max_tokens(preparation.settings.reserve_tokens, options.model);
957 let nonempty_previous_summary = preparation
958 .previous_summary
959 .as_deref()
960 .filter(|summary| !summary.is_empty());
961 let mut base_prompt = if nonempty_previous_summary.is_some() {
962 UPDATE_SUMMARIZATION_PROMPT.to_owned()
963 } else {
964 SUMMARIZATION_PROMPT.to_owned()
965 };
966 if let Some(custom) = options.custom_instructions.filter(|s| !s.is_empty()) {
967 base_prompt = format!("{base_prompt}\n\nAdditional focus: {custom}");
968 }
969
970 let llm_messages = convert_to_llm(&preparation.messages_to_summarize)?;
971 let conversation_text = serialize_conversation(&llm_messages);
972
973 let mut prompt_text = format!("<conversation>\n{conversation_text}\n</conversation>\n\n");
974 if let Some(prev) = nonempty_previous_summary {
975 let _ = write!(
976 prompt_text,
977 "<previous-summary>\n{prev}\n</previous-summary>\n\n"
978 );
979 }
980 prompt_text.push_str(&base_prompt);
981
982 let summarization_messages = vec![Message::User(UserMessage::new(
983 UserMessageContent::Blocks(vec![UserContent::Text(TextContent::new(prompt_text))]),
984 now_millis(),
985 ))];
986
987 let completion_options = create_summarization_options(
988 options.model,
989 max_tokens,
990 options.api_key.clone(),
991 options.headers.clone(),
992 options.env.clone(),
993 options.signal.clone(),
994 options.thinking_level,
995 );
996
997 ensure_not_cancelled(options.signal.as_ref())?;
998
999 let response = complete_summarization(
1000 options.model,
1001 Context {
1002 system_prompt: Some(SUMMARIZATION_SYSTEM_PROMPT.to_owned()),
1003 messages: summarization_messages,
1004 tools: None,
1005 },
1006 completion_options,
1007 &options.stream_fn,
1008 )
1009 .await?;
1010
1011 ensure_not_cancelled(options.signal.as_ref())?;
1012
1013 if response.stop_reason == StopReason::Aborted {
1014 return Err(CompactionError::Cancelled);
1015 }
1016 if response.stop_reason == StopReason::Error {
1017 let msg = response
1018 .error_message
1019 .clone()
1020 .unwrap_or_else(|| "Unknown error".to_owned());
1021 return Err(CompactionError::SummarizationFailed(msg));
1022 }
1023
1024 Ok(assistant_text(&response))
1025}
1026
1027async fn generate_turn_prefix_summary(
1028 preparation: &CompactionPreparation,
1029 options: &CompactOptions<'_>,
1030) -> Result<String, CompactionError> {
1031 ensure_not_cancelled(options.signal.as_ref())?;
1032
1033 let max_tokens = turn_prefix_max_tokens(preparation.settings.reserve_tokens, options.model);
1034 let llm_messages = convert_to_llm(&preparation.turn_prefix_messages)?;
1035 let conversation_text = serialize_conversation(&llm_messages);
1036 let prompt_text = format!(
1037 "<conversation>\n{conversation_text}\n</conversation>\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
1038 );
1039 let summarization_messages = vec![Message::User(UserMessage::new(
1040 UserMessageContent::Blocks(vec![UserContent::Text(TextContent::new(prompt_text))]),
1041 now_millis(),
1042 ))];
1043
1044 ensure_not_cancelled(options.signal.as_ref())?;
1045
1046 let response = complete_summarization(
1047 options.model,
1048 Context {
1049 system_prompt: Some(SUMMARIZATION_SYSTEM_PROMPT.to_owned()),
1050 messages: summarization_messages,
1051 tools: None,
1052 },
1053 create_summarization_options(
1054 options.model,
1055 max_tokens,
1056 options.api_key.clone(),
1057 options.headers.clone(),
1058 options.env.clone(),
1059 options.signal.clone(),
1060 options.thinking_level,
1061 ),
1062 &options.stream_fn,
1063 )
1064 .await?;
1065
1066 ensure_not_cancelled(options.signal.as_ref())?;
1067
1068 if response.stop_reason == StopReason::Aborted {
1069 return Err(CompactionError::Cancelled);
1070 }
1071 if response.stop_reason == StopReason::Error {
1072 let msg = response
1073 .error_message
1074 .clone()
1075 .unwrap_or_else(|| "Unknown error".to_owned());
1076 return Err(CompactionError::TurnPrefixSummarizationFailed(msg));
1077 }
1078
1079 Ok(assistant_text(&response))
1080}
1081
1082fn now_millis() -> i64 {
1083 use std::time::{SystemTime, UNIX_EPOCH};
1084 SystemTime::now()
1085 .duration_since(UNIX_EPOCH)
1086 .map_or(0, |duration| {
1087 i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
1088 })
1089}
1090
1091async fn summarize_preparation(
1092 preparation: &CompactionPreparation,
1093 options: &CompactOptions<'_>,
1094) -> Result<String, CompactionError> {
1095 if preparation.is_split_turn && !preparation.turn_prefix_messages.is_empty() {
1096 let history_result = if preparation.messages_to_summarize.is_empty() {
1097 "No prior history.".to_owned()
1098 } else {
1099 generate_summary(preparation, options).await?
1100 };
1101
1102 ensure_not_cancelled(options.signal.as_ref())?;
1103 let turn_prefix_result = generate_turn_prefix_summary(preparation, options).await?;
1104 Ok(format!(
1105 "{history_result}\n\n---\n\n**Turn Context (split turn):**\n\n{turn_prefix_result}"
1106 ))
1107 } else {
1108 generate_summary(preparation, options).await
1109 }
1110}
1111
1112pub struct CompactOptions<'a> {
1114 pub model: &'a Model,
1116 pub api_key: Option<String>,
1118 pub headers: Option<BTreeMap<String, Option<String>>>,
1120 pub custom_instructions: Option<&'a str>,
1122 pub signal: Option<CancellationToken>,
1124 pub thinking_level: Option<&'a str>,
1126 pub stream_fn: SummarizeStreamFn,
1128 pub env: Option<BTreeMap<String, String>>,
1130 pub hooks: Option<&'a dyn CompactionHooks>,
1132}
1133
1134pub async fn compact(
1141 preparation: &CompactionPreparation,
1142 options: CompactOptions<'_>,
1143) -> Result<CompactionResult, CompactionError> {
1144 ensure_not_cancelled(options.signal.as_ref())?;
1145
1146 let hooks = options.hooks.unwrap_or(&NoopCompactionHooks);
1147 let before = hooks
1148 .before_compact(
1149 preparation,
1150 options.custom_instructions,
1151 options.signal.as_ref().unwrap_or(&CancellationToken::new()),
1152 )
1153 .await?;
1154 if before.cancel {
1155 return Err(CompactionError::Cancelled);
1156 }
1157 if let Some(mut replaced) = before.compaction {
1158 replaced.from_hook = Some(true);
1159 hooks.after_compact(&replaced, true).await?;
1160 return Ok(replaced);
1161 }
1162
1163 ensure_not_cancelled(options.signal.as_ref())?;
1164
1165 if preparation.first_kept_entry_id.is_empty() {
1166 return Err(CompactionError::MissingFirstKeptId);
1167 }
1168
1169 let mut summary = summarize_preparation(preparation, &options).await?;
1170
1171 ensure_not_cancelled(options.signal.as_ref())?;
1172
1173 let (read_files, modified_files) = compute_file_lists(&preparation.file_ops);
1174 summary.push_str(&format_file_operations(&read_files, &modified_files));
1175
1176 let details = CompactionDetails {
1177 read_files,
1178 modified_files,
1179 };
1180 let details_value = serde_json::to_value(&details).unwrap_or(Value::Null);
1181
1182 let result = CompactionResult {
1183 summary,
1184 first_kept_entry_id: preparation.first_kept_entry_id.clone(),
1185 tokens_before: preparation.tokens_before,
1186 estimated_tokens_after: None,
1187 details: Some(details_value),
1188 from_hook: None,
1189 };
1190
1191 hooks.after_compact(&result, false).await?;
1192 Ok(result)
1193}
1194
1195#[cfg(test)]
1200mod tests {
1201 use super::*;
1202 use pi_ai::{AssistantMessage, TextContent, ToolCall, ToolResultContent, ToolResultMessage};
1203 use serde_json::Map;
1204 use serde_json::json;
1205
1206 fn result_ok<T, E>(result: Result<T, E>) -> T {
1207 assert!(result.is_ok());
1208 match result {
1209 Ok(value) => value,
1210 Err(_) => unreachable!(),
1211 }
1212 }
1213
1214 fn result_err<T, E>(result: Result<T, E>) -> E {
1215 assert!(result.is_err());
1216 match result {
1217 Ok(_) => unreachable!(),
1218 Err(error) => error,
1219 }
1220 }
1221
1222 fn option_some<T>(option: Option<T>) -> T {
1223 assert!(option.is_some());
1224 match option {
1225 Some(value) => value,
1226 None => unreachable!(),
1227 }
1228 }
1229
1230 fn usage(input: u64, output: u64, cache_read: u64, cache_write: u64) -> Usage {
1231 Usage {
1232 input,
1233 output,
1234 cache_read,
1235 cache_write,
1236 cache_write1h: None,
1237 reasoning: None,
1238 total_tokens: input + output + cache_read + cache_write,
1239 cost: pi_ai::UsageCost::default(),
1240 }
1241 }
1242
1243 fn user_msg(text: &str) -> AgentMessage {
1244 AgentMessage::Llm(Box::new(Message::User(UserMessage::new(
1245 UserMessageContent::Text(text.to_owned()),
1246 1,
1247 ))))
1248 }
1249
1250 fn assistant_msg(text: &str, u: Usage) -> AgentMessage {
1251 let mut msg =
1252 AssistantMessage::new("anthropic-messages", "anthropic", "claude-sonnet-4-5", 1);
1253 msg.content = vec![AssistantContent::Text(TextContent::new(text))];
1254 msg.usage = u;
1255 msg.stop_reason = StopReason::Stop;
1256 AgentMessage::Llm(Box::new(Message::Assistant(msg)))
1257 }
1258
1259 fn tool_result_msg(text: &str) -> AgentMessage {
1260 AgentMessage::Llm(Box::new(Message::ToolResult(ToolResultMessage::new(
1261 "tc1",
1262 "read",
1263 vec![ToolResultContent::Text(TextContent::new(text))],
1264 false,
1265 1,
1266 ))))
1267 }
1268
1269 fn custom_msg(content: &str) -> AgentMessage {
1270 let mut payload = Map::new();
1271 payload.insert("customType".into(), Value::String("test".into()));
1272 payload.insert("content".into(), Value::String(content.into()));
1273 payload.insert("display".into(), Value::Bool(true));
1274 payload.insert("timestamp".into(), Value::from(1));
1275 AgentMessage::Custom(pi_agent::CustomAgentMessage::new("custom", payload))
1276 }
1277
1278 fn bash_msg(command: &str, output: &str) -> AgentMessage {
1279 let mut payload = Map::new();
1280 payload.insert("command".into(), Value::String(command.into()));
1281 payload.insert("output".into(), Value::String(output.into()));
1282 payload.insert("cancelled".into(), Value::Bool(false));
1283 payload.insert("truncated".into(), Value::Bool(false));
1284 payload.insert("timestamp".into(), Value::from(1));
1285 AgentMessage::Custom(pi_agent::CustomAgentMessage::new("bashExecution", payload))
1286 }
1287
1288 fn branch_summary_msg(summary: &str) -> AgentMessage {
1289 let mut payload = Map::new();
1290 payload.insert("summary".into(), Value::String(summary.into()));
1291 payload.insert("fromId".into(), Value::String("root".into()));
1292 payload.insert("timestamp".into(), Value::from(1));
1293 AgentMessage::Custom(pi_agent::CustomAgentMessage::new("branchSummary", payload))
1294 }
1295
1296 fn compaction_summary_msg(summary: &str) -> AgentMessage {
1297 let mut payload = Map::new();
1298 payload.insert("summary".into(), Value::String(summary.into()));
1299 payload.insert("tokensBefore".into(), Value::from(100));
1300 payload.insert("timestamp".into(), Value::from(1));
1301 AgentMessage::Custom(pi_agent::CustomAgentMessage::new(
1302 "compactionSummary",
1303 payload,
1304 ))
1305 }
1306
1307 fn message_entry(id: &str, parent: Option<&str>, message: AgentMessage) -> SessionEntry {
1308 let message = result_ok(serde_json::to_value(message));
1309 result_ok(serde_json::from_value(json!({
1310 "type": "message",
1311 "id": id,
1312 "parentId": parent,
1313 "timestamp": "2025-01-01T00:00:00.000Z",
1314 "message": message,
1315 })))
1316 }
1317
1318 fn compaction_entry(
1319 id: &str,
1320 parent: Option<&str>,
1321 summary: &str,
1322 first_kept: &str,
1323 ) -> SessionEntry {
1324 result_ok(serde_json::from_value(json!({
1325 "type": "compaction",
1326 "id": id,
1327 "parentId": parent,
1328 "timestamp": "2025-01-01T00:00:00.000Z",
1329 "summary": summary,
1330 "firstKeptEntryId": first_kept,
1331 "tokensBefore": 10000,
1332 })))
1333 }
1334
1335 fn model_change_entry(id: &str, parent: Option<&str>) -> SessionEntry {
1336 result_ok(serde_json::from_value(json!({
1337 "type": "model_change",
1338 "id": id,
1339 "parentId": parent,
1340 "timestamp": "2025-01-01T00:00:00.000Z",
1341 "provider": "openai",
1342 "modelId": "gpt-4",
1343 })))
1344 }
1345
1346 fn custom_message_entry(id: &str, parent: Option<&str>, content: &str) -> SessionEntry {
1347 result_ok(serde_json::from_value(json!({
1348 "type": "custom_message",
1349 "id": id,
1350 "parentId": parent,
1351 "timestamp": "2025-01-01T00:00:00.000Z",
1352 "customType": "test",
1353 "content": content,
1354 "display": true,
1355 })))
1356 }
1357
1358 fn test_model(max_tokens: u64, context_window: u64) -> Model {
1359 Model {
1360 id: "test".into(),
1361 name: "Test".into(),
1362 api: "anthropic-messages".into(),
1363 provider: "anthropic".into(),
1364 base_url: "https://example.test".into(),
1365 reasoning: false,
1366 thinking_level_map: None,
1367 input: vec![pi_ai::ModelInput::Text],
1368 cost: pi_ai::ModelCost::default(),
1369 context_window,
1370 max_tokens,
1371 headers: None,
1372 compat: None,
1373 extra: BTreeMap::new(),
1374 }
1375 }
1376
1377 fn mock_stream_fn(text: &str, stop: StopReason) -> SummarizeStreamFn {
1378 let text = text.to_owned();
1379 Arc::new(move |_model, _ctx, _opts| {
1380 let text = text.clone();
1381 Box::pin(async move {
1382 let mut msg = AssistantMessage::new("a", "p", "m", 1);
1383 msg.content = vec![AssistantContent::Text(TextContent::new(text))];
1384 msg.stop_reason = stop;
1385 if stop == StopReason::Error {
1386 msg.error_message = Some("boom".into());
1387 }
1388 let stream = futures::stream::iter(vec![Ok(AssistantMessageEvent::Done {
1389 reason: pi_ai::DoneReason::Stop,
1390 message: msg,
1391 })]);
1392 Box::pin(stream)
1393 as Pin<
1394 Box<
1395 dyn futures::Stream<Item = Result<AssistantMessageEvent, ProviderError>>
1396 + Send,
1397 >,
1398 >
1399 })
1400 })
1401 }
1402
1403 #[test]
1404 fn calculate_context_tokens_prefers_total() {
1405 let mut u = usage(1000, 500, 200, 100);
1406 assert_eq!(calculate_context_tokens(&u), 1800);
1407 u.total_tokens = 0;
1408 assert_eq!(calculate_context_tokens(&u), 1800);
1409 let zero = usage(0, 0, 0, 0);
1410 assert_eq!(calculate_context_tokens(&zero), 0);
1411 }
1412
1413 #[test]
1414 fn get_last_assistant_usage_skips_aborted_error_zero() {
1415 let a1 = message_entry("a1", None, assistant_msg("Hi", usage(100, 50, 0, 0)));
1416 let a2 = message_entry("a2", Some("a1"), {
1417 let mut m = AssistantMessage::new("anthropic-messages", "anthropic", "m", 1);
1418 m.usage = usage(300, 150, 0, 0);
1419 m.stop_reason = StopReason::Aborted;
1420 AgentMessage::Llm(Box::new(Message::Assistant(m)))
1421 });
1422 let entries = [&a1, &a2];
1423 let found = option_some(get_last_assistant_usage(&entries));
1424 assert_eq!(found.input, 100);
1425
1426 let zero = message_entry("z", Some("a1"), assistant_msg("Partial", usage(0, 0, 0, 0)));
1427 let entries = [&a1, &zero];
1428 let found = option_some(get_last_assistant_usage(&entries));
1429 assert_eq!(found.input, 100);
1430
1431 let only_user = message_entry("u", None, user_msg("Hello"));
1432 assert!(get_last_assistant_usage(&[&only_user]).is_none());
1433 }
1434
1435 #[test]
1436 fn estimate_context_tokens_anchors_usage() {
1437 let messages = vec![
1438 user_msg("Hello"),
1439 assistant_msg("Hi", usage(100, 50, 0, 0)),
1440 user_msg("continue"),
1441 assistant_msg("Partial thinking", usage(0, 0, 0, 0)),
1442 ];
1443 let estimate = estimate_context_tokens(&messages);
1444 assert_eq!(estimate.usage_tokens, 150);
1445 assert_eq!(estimate.last_usage_index, Some(1));
1446 assert!(estimate.trailing_tokens > 0);
1447 assert_eq!(estimate.tokens, 150 + estimate.trailing_tokens);
1448 }
1449
1450 #[test]
1451 fn estimate_tokens_every_role() {
1452 assert!(estimate_tokens(&user_msg("abcd")) >= 1);
1453 let with_image = UserMessage::new(
1454 UserMessageContent::Blocks(vec![
1455 UserContent::Text(TextContent::new("hi")),
1456 UserContent::Image(pi_ai::ImageContent::new("abc", "image/png")),
1457 ]),
1458 1,
1459 );
1460 let img_tokens = estimate_tokens(&AgentMessage::Llm(Box::new(Message::User(with_image))));
1461 assert!(img_tokens >= 4800 / 4);
1462
1463 let mut asst = AssistantMessage::new("a", "p", "m", 1);
1464 asst.content = vec![
1465 AssistantContent::Text(TextContent::new("hello")),
1466 AssistantContent::Thinking(pi_ai::ThinkingContent::new("think")),
1467 AssistantContent::ToolCall(ToolCall::new(
1468 "1",
1469 "read",
1470 Map::from_iter([("path".into(), Value::String("x".into()))]),
1471 )),
1472 ];
1473 assert!(estimate_tokens(&AgentMessage::Llm(Box::new(Message::Assistant(asst)))) > 0);
1474
1475 assert!(estimate_tokens(&tool_result_msg("result text")) > 0);
1476 assert!(estimate_tokens(&custom_msg("custom body")) > 0);
1477 assert!(estimate_tokens(&bash_msg("ls", "out")) > 0);
1478 assert!(estimate_tokens(&branch_summary_msg("branch")) > 0);
1479 assert!(estimate_tokens(&compaction_summary_msg("compact")) > 0);
1480 assert_eq!(estimate_tokens(&user_msg("😀")), 1);
1482 assert_eq!(estimate_tokens(&user_msg("😀😀")), 1);
1484 assert_eq!(estimate_tokens(&user_msg("😀😀😀😀😀")), 3);
1486 }
1487
1488 #[test]
1489 fn should_compact_strict_gt_and_disabled() {
1490 let settings = CompactionSettings {
1491 enabled: true,
1492 reserve_tokens: 10_000,
1493 keep_recent_tokens: 20_000,
1494 };
1495 assert!(should_compact(95_000, 100_000, &settings));
1496 assert!(!should_compact(90_000, 100_000, &settings)); assert!(!should_compact(89_000, 100_000, &settings));
1498 let oversized_reserve = CompactionSettings {
1500 enabled: true,
1501 reserve_tokens: 10,
1502 keep_recent_tokens: 20_000,
1503 };
1504 assert!(should_compact(0, 5, &oversized_reserve));
1505 let disabled = CompactionSettings {
1506 enabled: false,
1507 ..settings
1508 };
1509 assert!(!should_compact(95_000, 100_000, &disabled));
1510 }
1511
1512 #[test]
1513 fn find_cut_point_never_cuts_tool_result() {
1514 let u = message_entry("u", None, user_msg("hi"));
1515 let a = message_entry("a", Some("u"), {
1516 let mut m = AssistantMessage::new("a", "p", "m", 1);
1517 m.content = vec![AssistantContent::ToolCall(ToolCall::new(
1518 "1",
1519 "read",
1520 Map::from_iter([("path".into(), Value::String("f".into()))]),
1521 ))];
1522 m.usage = usage(0, 0, 0, 0);
1523 AgentMessage::Llm(Box::new(Message::Assistant(m)))
1524 });
1525 let tr = message_entry("tr", Some("a"), tool_result_msg(&"x".repeat(8000)));
1526 let a2 = message_entry("a2", Some("tr"), assistant_msg("done", usage(0, 50, 0, 0)));
1527 let entries = [&u, &a, &tr, &a2];
1528 let result = find_cut_point(&entries, 0, entries.len(), 1);
1529 assert_ne!(entries[result.first_kept_entry_index].id(), Some("tr"));
1531 }
1532
1533 #[test]
1534 fn find_cut_point_metadata_expansion_and_split_turn() {
1535 let u1 = message_entry("u1", None, user_msg("Turn 1"));
1536 let a1 = message_entry(
1537 "a1",
1538 Some("u1"),
1539 assistant_msg("A1", usage(0, 100, 1000, 0)),
1540 );
1541 let u2 = message_entry("u2", Some("a1"), user_msg("Turn 2"));
1542 let mc = model_change_entry("mc", Some("u2")); let a2 = message_entry(
1544 "a2",
1545 Some("mc"),
1546 assistant_msg("A2", usage(0, 100, 8000, 0)),
1547 );
1548 let a3 = message_entry(
1549 "a3",
1550 Some("a2"),
1551 assistant_msg("A3", usage(0, 100, 10000, 0)),
1552 );
1553 let entries = [&u1, &a1, &u2, &mc, &a2, &a3];
1554 let result = find_cut_point(&entries, 0, entries.len(), 3000);
1555 if entries[result.first_kept_entry_index]
1557 .id()
1558 .is_some_and(|id| id == "a2" || id == "a3")
1559 {
1560 assert!(result.is_split_turn);
1561 assert_eq!(entries[result.turn_start_index].id(), Some("u2"));
1562 }
1563 }
1564
1565 #[test]
1566 fn find_cut_point_custom_message_budget() {
1567 let u = message_entry("u", None, user_msg("hi"));
1568 let a = message_entry("a", Some("u"), assistant_msg("hello", usage(100, 50, 0, 0)));
1569 let c = custom_message_entry("c", Some("a"), &"x".repeat(4000));
1570 let a2 = message_entry("a2", Some("c"), assistant_msg("ok", usage(100, 50, 0, 0)));
1571 let entries = [&u, &a, &c, &a2];
1572 let tiny = find_cut_point(&entries, 0, entries.len(), 1);
1573 assert_eq!(tiny.first_kept_entry_index, 3);
1574 assert!(tiny.is_split_turn);
1575 assert_eq!(tiny.turn_start_index, 2);
1576 let fits = find_cut_point(&entries, 0, entries.len(), 2);
1577 assert_eq!(fits.first_kept_entry_index, 2);
1578 assert!(!fits.is_split_turn);
1579 }
1580
1581 #[test]
1582 fn prepare_compaction_previous_boundary_and_nothing() {
1583 let u1 = message_entry("u1", None, user_msg("user msg 1"));
1584 let a1 = message_entry(
1585 "a1",
1586 Some("u1"),
1587 assistant_msg("assistant msg 1", usage(100, 50, 0, 0)),
1588 );
1589 let u2 = message_entry("u2", Some("a1"), user_msg("user msg 2 - kept"));
1590 let a2 = message_entry(
1591 "a2",
1592 Some("u2"),
1593 assistant_msg("assistant msg 2", usage(100, 50, 0, 0)),
1594 );
1595 let compaction = compaction_entry("c1", Some("a2"), "First summary", "u2");
1596 let u3 = message_entry("u3", Some("c1"), user_msg("new"));
1597 let a3 = message_entry(
1598 "a3",
1599 Some("u3"),
1600 assistant_msg("new a", usage(100, 50, 0, 0)),
1601 );
1602 let path = [&u1, &a1, &u2, &a2, &compaction, &u3, &a3];
1603 let _default = result_ok(prepare_compaction(&path, DEFAULT_COMPACTION_SETTINGS));
1604 let settings = CompactionSettings {
1606 keep_recent_tokens: 1,
1607 ..DEFAULT_COMPACTION_SETTINGS
1608 };
1609 let prep = option_some(result_ok(prepare_compaction(&path, settings)));
1610 assert_eq!(prep.previous_summary.as_deref(), Some("First summary"));
1611 assert!(!prep.first_kept_entry_id.is_empty());
1612
1613 let path2 = [&u1, &a1, &compaction];
1615 assert!(result_ok(prepare_compaction(&path2, settings)).is_none());
1616 assert!(matches!(
1617 preparation_none_error(&path2),
1618 CompactionError::AlreadyCompacted
1619 ));
1620 }
1621
1622 #[test]
1623 fn prepare_compaction_nothing_to_compact_error() {
1624 let u = message_entry("u", None, user_msg("hi"));
1625 let a = message_entry("a", Some("u"), assistant_msg("yo", usage(10, 5, 0, 0)));
1626 let path = [&u, &a];
1627 let settings = CompactionSettings {
1628 keep_recent_tokens: 50_000,
1629 ..DEFAULT_COMPACTION_SETTINGS
1630 };
1631 let prep = result_ok(prepare_compaction(&path, settings));
1632 assert!(prep.is_none());
1633 assert!(matches!(
1634 preparation_none_error(&path),
1635 CompactionError::NothingToCompact
1636 ));
1637 }
1638
1639 #[tokio::test]
1640 async fn compact_prompts_caps_custom_and_split_merge() {
1641 let captured_prompts = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1642 let captured_max = Arc::new(std::sync::Mutex::new(Vec::<u64>::new()));
1643 let prompts = Arc::clone(&captured_prompts);
1644 let maxes = Arc::clone(&captured_max);
1645
1646 let stream_fn: SummarizeStreamFn = Arc::new(move |model, ctx, opts| {
1647 let prompts = Arc::clone(&prompts);
1648 let maxes = Arc::clone(&maxes);
1649 Box::pin(async move {
1650 if let Some(max) = opts.max_tokens {
1651 result_ok(maxes.lock()).push(max);
1652 }
1653 if let Some(Message::User(user)) = ctx.messages.first() {
1654 let text = match &user.content {
1655 UserMessageContent::Text(t) => t.clone(),
1656 UserMessageContent::Blocks(blocks) => blocks
1657 .iter()
1658 .filter_map(|b| match b {
1659 UserContent::Text(t) => Some(t.text.to_string()),
1660 UserContent::Image(_) => None,
1661 })
1662 .collect::<String>(),
1663 };
1664 result_ok(prompts.lock()).push(text);
1665 }
1666 let _ = model;
1667 let mut msg = AssistantMessage::new("a", "p", "m", 1);
1668 msg.content = vec![AssistantContent::Text(TextContent::new("SUMMARY"))];
1669 msg.stop_reason = StopReason::Stop;
1670 let stream = futures::stream::iter(vec![Ok(AssistantMessageEvent::Done {
1671 reason: pi_ai::DoneReason::Stop,
1672 message: msg,
1673 })]);
1674 Box::pin(stream)
1675 as Pin<
1676 Box<
1677 dyn futures::Stream<Item = Result<AssistantMessageEvent, ProviderError>>
1678 + Send,
1679 >,
1680 >
1681 })
1682 });
1683
1684 let model = test_model(4096, 128_000);
1685 let prep = CompactionPreparation {
1686 first_kept_entry_id: "keep".into(),
1687 messages_to_summarize: vec![
1688 user_msg("history user"),
1689 assistant_msg("hist a", usage(10, 5, 0, 0)),
1690 ],
1691 turn_prefix_messages: vec![
1692 user_msg("turn prefix"),
1693 assistant_msg("prefix a", usage(10, 5, 0, 0)),
1694 ],
1695 is_split_turn: true,
1696 tokens_before: 999,
1697 previous_summary: Some("Prev".into()),
1698 file_ops: FileOperations::default(),
1699 settings: CompactionSettings {
1700 enabled: true,
1701 reserve_tokens: 1000,
1702 keep_recent_tokens: 100,
1703 },
1704 };
1705
1706 let result = result_ok(
1707 compact(
1708 &prep,
1709 CompactOptions {
1710 model: &model,
1711 api_key: None,
1712 headers: None,
1713 custom_instructions: Some("focus on tests"),
1714 signal: None,
1715 thinking_level: None,
1716 stream_fn,
1717 env: None,
1718 hooks: None,
1719 },
1720 )
1721 .await,
1722 );
1723
1724 assert!(result.summary.contains("SUMMARY"));
1725 assert!(result.summary.contains("**Turn Context (split turn):**"));
1726 assert_eq!(result.first_kept_entry_id, "keep");
1727 assert_eq!(result.tokens_before, 999);
1728
1729 let prompts = result_ok(captured_prompts.lock());
1730 assert_eq!(prompts.len(), 2);
1731 assert!(prompts[0].contains("<previous-summary>"));
1733 assert!(prompts[0].contains(UPDATE_SUMMARIZATION_PROMPT));
1734 assert!(prompts[0].contains("Additional focus: focus on tests"));
1735 assert!(prompts[1].contains(TURN_PREFIX_SUMMARIZATION_PROMPT));
1737 assert!(!prompts[1].contains("Additional focus"));
1738
1739 let maxes = result_ok(captured_max.lock());
1740 assert_eq!(maxes[0], 800);
1742 assert_eq!(maxes[1], 500);
1743 }
1744
1745 #[tokio::test]
1746 async fn empty_previous_summary_uses_initial_prompt() {
1747 let captured_prompts = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1748 let prompts = Arc::clone(&captured_prompts);
1749 let stream_fn: SummarizeStreamFn = Arc::new(move |_model, ctx, _opts| {
1750 let prompts = Arc::clone(&prompts);
1751 Box::pin(async move {
1752 if let Some(Message::User(user)) = ctx.messages.first() {
1753 let text = match &user.content {
1754 UserMessageContent::Text(t) => t.clone(),
1755 UserMessageContent::Blocks(blocks) => blocks
1756 .iter()
1757 .filter_map(|b| match b {
1758 UserContent::Text(t) => Some(t.text.to_string()),
1759 UserContent::Image(_) => None,
1760 })
1761 .collect::<String>(),
1762 };
1763 result_ok(prompts.lock()).push(text);
1764 }
1765 let mut msg = AssistantMessage::new("a", "p", "m", 1);
1766 msg.content = vec![AssistantContent::Text(TextContent::new("SUMMARY"))];
1767 msg.stop_reason = StopReason::Stop;
1768 let stream = futures::stream::iter(vec![Ok(AssistantMessageEvent::Done {
1769 reason: pi_ai::DoneReason::Stop,
1770 message: msg,
1771 })]);
1772 Box::pin(stream)
1773 as Pin<
1774 Box<
1775 dyn futures::Stream<Item = Result<AssistantMessageEvent, ProviderError>>
1776 + Send,
1777 >,
1778 >
1779 })
1780 });
1781
1782 let model = test_model(2048, 128_000);
1783 let prep = CompactionPreparation {
1784 first_kept_entry_id: "k".into(),
1785 messages_to_summarize: vec![user_msg("x")],
1786 turn_prefix_messages: vec![],
1787 is_split_turn: false,
1788 tokens_before: 1,
1789 previous_summary: Some(String::new()),
1790 file_ops: FileOperations::default(),
1791 settings: DEFAULT_COMPACTION_SETTINGS,
1792 };
1793 result_ok(
1794 compact(
1795 &prep,
1796 CompactOptions {
1797 model: &model,
1798 api_key: None,
1799 headers: None,
1800 custom_instructions: None,
1801 signal: None,
1802 thinking_level: None,
1803 stream_fn,
1804 env: None,
1805 hooks: None,
1806 },
1807 )
1808 .await,
1809 );
1810
1811 let prompts = result_ok(captured_prompts.lock());
1812 assert_eq!(prompts.len(), 1);
1813 assert!(prompts[0].contains(SUMMARIZATION_PROMPT));
1814 assert!(!prompts[0].contains(UPDATE_SUMMARIZATION_PROMPT));
1815 assert!(!prompts[0].contains("<previous-summary>"));
1816 }
1817
1818 #[test]
1819 fn emoji_utf16_cut_point_budget() {
1820 let u1 = message_entry("u1", None, user_msg("😀"));
1822 let u2 = message_entry("u2", Some("u1"), user_msg("😀"));
1823 let u3 = message_entry("u3", Some("u2"), user_msg("😀"));
1824 let entries = [&u1, &u2, &u3];
1825 let cut = find_cut_point(&entries, 0, entries.len(), 2);
1826 assert_eq!(cut.first_kept_entry_index, 1);
1827 }
1828
1829 #[tokio::test]
1830 async fn compact_summarizer_failure_and_cancel() {
1831 let model = test_model(2048, 128_000);
1832 let prep = CompactionPreparation {
1833 first_kept_entry_id: "k".into(),
1834 messages_to_summarize: vec![user_msg("x")],
1835 turn_prefix_messages: vec![],
1836 is_split_turn: false,
1837 tokens_before: 1,
1838 previous_summary: None,
1839 file_ops: FileOperations::default(),
1840 settings: DEFAULT_COMPACTION_SETTINGS,
1841 };
1842
1843 let err = result_err(
1844 compact(
1845 &prep,
1846 CompactOptions {
1847 model: &model,
1848 api_key: None,
1849 headers: None,
1850 custom_instructions: None,
1851 signal: None,
1852 thinking_level: None,
1853 stream_fn: mock_stream_fn("x", StopReason::Error),
1854 env: None,
1855 hooks: None,
1856 },
1857 )
1858 .await,
1859 );
1860 assert_eq!(err.to_string(), "Summarization failed: boom");
1861
1862 let cancel = CancellationToken::new();
1863 cancel.cancel();
1864 let err = result_err(
1865 compact(
1866 &prep,
1867 CompactOptions {
1868 model: &model,
1869 api_key: None,
1870 headers: None,
1871 custom_instructions: None,
1872 signal: Some(cancel),
1873 thinking_level: None,
1874 stream_fn: mock_stream_fn("x", StopReason::Stop),
1875 env: None,
1876 hooks: None,
1877 },
1878 )
1879 .await,
1880 );
1881 assert!(matches!(err, CompactionError::Cancelled));
1882 }
1883
1884 #[tokio::test]
1885 async fn compact_hook_replacement_from_hook() {
1886 struct ReplaceHooks;
1887 impl CompactionHooks for ReplaceHooks {
1888 fn before_compact(
1889 &self,
1890 prep: &CompactionPreparation,
1891 _custom: Option<&str>,
1892 _cancel: &CancellationToken,
1893 ) -> Pin<
1894 Box<dyn Future<Output = Result<BeforeCompactResult, CompactionError>> + Send + '_>,
1895 > {
1896 let result = CompactionResult {
1897 summary: "hook summary".into(),
1898 first_kept_entry_id: prep.first_kept_entry_id.clone(),
1899 tokens_before: prep.tokens_before,
1900 estimated_tokens_after: None,
1901 details: None,
1902 from_hook: None,
1903 };
1904 Box::pin(async move {
1905 Ok(BeforeCompactResult {
1906 cancel: false,
1907 compaction: Some(result),
1908 })
1909 })
1910 }
1911 }
1912
1913 let model = test_model(2048, 128_000);
1914 let prep = CompactionPreparation {
1915 first_kept_entry_id: "k".into(),
1916 messages_to_summarize: vec![user_msg("x")],
1917 turn_prefix_messages: vec![],
1918 is_split_turn: false,
1919 tokens_before: 42,
1920 previous_summary: None,
1921 file_ops: FileOperations::default(),
1922 settings: DEFAULT_COMPACTION_SETTINGS,
1923 };
1924 let hooks = ReplaceHooks;
1925 let result = result_ok(
1926 compact(
1927 &prep,
1928 CompactOptions {
1929 model: &model,
1930 api_key: None,
1931 headers: None,
1932 custom_instructions: None,
1933 signal: None,
1934 thinking_level: None,
1935 stream_fn: mock_stream_fn("should not run", StopReason::Stop),
1936 env: None,
1937 hooks: Some(&hooks),
1938 },
1939 )
1940 .await,
1941 );
1942 assert_eq!(result.summary, "hook summary");
1943 assert_eq!(result.from_hook, Some(true));
1944 }
1945
1946 #[test]
1947 fn prompt_constants_exact() {
1948 assert!(SUMMARIZATION_PROMPT.contains("## Goal"));
1949 assert!(UPDATE_SUMMARIZATION_PROMPT.contains("PRESERVE all existing information"));
1950 assert!(TURN_PREFIX_SUMMARIZATION_PROMPT.contains("## Original Request"));
1951 assert_eq!(DEFAULT_COMPACTION_SETTINGS.reserve_tokens, 16_384);
1952 assert_eq!(DEFAULT_COMPACTION_SETTINGS.keep_recent_tokens, 20_000);
1953 assert!(std::hint::black_box(DEFAULT_COMPACTION_SETTINGS).enabled);
1954 }
1955}