Skip to main content

yapi_core/
compaction.rs

1//! Compaction: summarize older context so a long session fits the model.
2//!
3//! Port of `packages/coding-agent/src/core/compaction/compaction.ts` and
4//! `compaction/utils.ts` in pi `v1.0.0`. The functions here are pure apart from
5//! the summary request. The agent session decides when to compact and records the
6//! result.
7
8use std::collections::BTreeSet;
9
10use serde::Serialize;
11use serde_json::{Value, json};
12use tokio_util::sync::CancellationToken;
13use yapi_ai::api::Apis;
14use yapi_ai::errors::{is_retryable_assistant_error, retry_delay_ms};
15use yapi_ai::registry::Auth;
16use yapi_ai::stream::{CacheRetention, Request, StreamOptions};
17use yapi_types::message::{
18    AssistantMessage, Content, ContentBlock, Message, StopReason, SystemMessage, ThinkingLevel,
19    Usage, UserMessage,
20};
21use yapi_types::model::Model;
22use yapi_types::session::FileEntry;
23use yapi_types::settings::Settings;
24
25use crate::messages::convert_to_llm;
26use crate::session::{ProjectedEntry, Projection, entry_messages};
27use crate::time::{now_ms, uuid_v7};
28
29/// When and how much to compact.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct CompactionSettings {
33    /// Automatic compaction is on.
34    pub enabled: bool,
35    /// Tokens kept free for the response. Compaction starts when less is left.
36    pub reserve_tokens: u64,
37    /// Recent tokens kept verbatim.
38    pub keep_recent_tokens: u64,
39}
40
41impl CompactionSettings {
42    /// The settings for `model`: its override, the ordinary setting, the default.
43    pub fn resolve(settings: &Settings, model: Option<&Model>) -> CompactionSettings {
44        let compaction = settings.compaction.as_ref();
45        let key = model.map(|model| format!("{}/{}", model.provider, model.id));
46        let overrides = key.as_ref().and_then(|key| {
47            compaction
48                .and_then(|c| c.model_overrides.as_ref())
49                .and_then(|overrides| overrides.get(key))
50        });
51        CompactionSettings {
52            enabled: compaction.and_then(|c| c.enabled).unwrap_or(true),
53            reserve_tokens: overrides
54                .and_then(|o| o.reserve_tokens)
55                .or_else(|| compaction.and_then(|c| c.reserve_tokens))
56                .unwrap_or(16384),
57            keep_recent_tokens: overrides
58                .and_then(|o| o.keep_recent_tokens)
59                .or_else(|| compaction.and_then(|c| c.keep_recent_tokens))
60                .unwrap_or(20000),
61        }
62    }
63}
64
65/// Retry policy for summary requests, from `settings.retry`.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub struct RetryPolicy {
68    /// Retries are on.
69    pub enabled: bool,
70    /// Retries after the first attempt.
71    pub max_retries: u32,
72    /// Delay before the first retry, doubling each time.
73    pub base_delay_ms: u64,
74    /// Cap on one delay.
75    pub max_delay_ms: u64,
76}
77
78impl RetryPolicy {
79    /// The policy in `settings`, with pi's defaults.
80    pub fn resolve(settings: &Settings) -> RetryPolicy {
81        let retry = settings.retry.as_ref();
82        RetryPolicy {
83            enabled: retry.and_then(|r| r.enabled).unwrap_or(true),
84            max_retries: retry.and_then(|r| r.max_retries).unwrap_or(3),
85            base_delay_ms: retry.and_then(|r| r.base_delay_ms).unwrap_or(2000),
86            max_delay_ms: retry
87                .and_then(|r| r.max_agent_delay_ms)
88                .unwrap_or(yapi_ai::errors::DEFAULT_MAX_AGENT_RETRY_DELAY_MS),
89        }
90    }
91
92    /// The delay before retry `attempt` (1-based).
93    pub fn delay_ms(&self, attempt: u32) -> u64 {
94        retry_delay_ms(self.base_delay_ms, Some(self.max_delay_ms), attempt)
95    }
96}
97
98/// Tokens a response's usage says the context held.
99pub fn calculate_context_tokens(usage: &Usage) -> u64 {
100    match usage.total_tokens {
101        Some(total) if total > 0 => total,
102        _ => usage.input + usage.output + usage.cache_read + usage.cache_write,
103    }
104}
105
106fn assistant_usage(message: &Message) -> Option<&Usage> {
107    match message {
108        Message::Assistant(assistant)
109            if !matches!(
110                assistant.stop_reason,
111                StopReason::Aborted | StopReason::Error
112            ) && calculate_context_tokens(&assistant.usage) > 0 =>
113        {
114            Some(&assistant.usage)
115        }
116        _ => None,
117    }
118}
119
120const ESTIMATED_IMAGE_CHARS: usize = 4800;
121
122fn blocks_chars(blocks: &[ContentBlock]) -> usize {
123    blocks
124        .iter()
125        .map(|block| match block {
126            ContentBlock::Text(text) => yapi_types::js::len(&text.text),
127            ContentBlock::Image(_) => ESTIMATED_IMAGE_CHARS,
128            _ => 0,
129        })
130        .sum()
131}
132
133fn content_chars(content: &Content) -> usize {
134    match content {
135        Content::Text(text) => yapi_types::js::len(text),
136        Content::Blocks(blocks) => blocks_chars(blocks),
137    }
138}
139
140/// Estimated tokens of one message: characters / 4, rounded up, where an image
141/// counts as 4800 characters.
142pub fn estimate_tokens(message: &Message) -> u64 {
143    let chars = match message {
144        Message::System(system) => {
145            let sections: usize = system
146                .sections
147                .iter()
148                .flatten()
149                .filter_map(|(_, text)| text.as_deref())
150                .map(yapi_types::js::len)
151                .sum();
152            let tools = system
153                .tools_added
154                .as_ref()
155                .and_then(|tools| yapi_types::json::to_string(tools).ok())
156                .map_or(0, |json| yapi_types::js::len(&json));
157            content_chars(&system.content) + sections + tools
158        }
159        Message::User(user) => content_chars(&user.content),
160        Message::Assistant(assistant) => assistant
161            .content
162            .iter()
163            .map(|block| match block {
164                ContentBlock::Text(text) => yapi_types::js::len(&text.text),
165                ContentBlock::Thinking(thinking) => yapi_types::js::len(&thinking.thinking),
166                ContentBlock::ToolCall(call) => {
167                    yapi_types::js::len(&call.name)
168                        + yapi_types::js::len(&yapi_types::json::stringify(&call.arguments))
169                }
170                ContentBlock::Image(_) => 0,
171            })
172            .sum(),
173        Message::Custom(custom) => content_chars(&custom.content),
174        Message::ToolResult(result) => blocks_chars(&result.content),
175        Message::BashExecution(bash) => {
176            yapi_types::js::len(&bash.command) + yapi_types::js::len(&bash.output)
177        }
178        Message::BranchSummary(summary) => yapi_types::js::len(&summary.summary),
179        Message::CompactionSummary(summary) => yapi_types::js::len(&summary.summary),
180    };
181    chars.div_ceil(4) as u64
182}
183
184/// Estimated context size: the last valid usage plus estimates for what follows.
185#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
186pub struct ContextEstimate {
187    /// Total tokens.
188    pub tokens: u64,
189    /// Tokens from the last usage.
190    pub usage_tokens: u64,
191    /// Estimated tokens after it.
192    pub trailing_tokens: u64,
193    /// Index of the message whose usage was used.
194    pub last_usage_index: Option<usize>,
195}
196
197/// Estimates context tokens from the last valid usage and the messages after it.
198pub fn estimate_context_tokens(messages: &[Message]) -> ContextEstimate {
199    let usage = messages
200        .iter()
201        .enumerate()
202        .rev()
203        .find_map(|(index, message)| assistant_usage(message).map(|usage| (index, usage)));
204    match usage {
205        None => {
206            let tokens = messages.iter().map(estimate_tokens).sum();
207            ContextEstimate {
208                tokens,
209                usage_tokens: 0,
210                trailing_tokens: tokens,
211                last_usage_index: None,
212            }
213        }
214        Some((index, usage)) => {
215            let usage_tokens = calculate_context_tokens(usage);
216            let trailing: u64 = messages[index + 1..].iter().map(estimate_tokens).sum();
217            ContextEstimate {
218                tokens: usage_tokens + trailing,
219                usage_tokens,
220                trailing_tokens: trailing,
221                last_usage_index: Some(index),
222            }
223        }
224    }
225}
226
227/// Estimates projected context without trusting usage recorded before a later
228/// context edit or compaction.
229pub fn estimate_projected_context_tokens(
230    projection: &Projection<'_>,
231    branch: &[&FileEntry],
232) -> ContextEstimate {
233    let estimate = estimate_context_tokens(&projection.messages);
234    if let Some(usage_index) = estimate.last_usage_index {
235        let mut next = 0;
236        let mut usage_entry = None;
237        for entry in &projection.entries {
238            next += entry.messages.len();
239            if usage_index < next {
240                usage_entry = entry.source.meta().map(|meta| meta.id.as_str());
241                break;
242            }
243        }
244        let usage_position = usage_entry.and_then(|id| {
245            branch
246                .iter()
247                .position(|entry| entry.meta().is_some_and(|meta| meta.id == id))
248        });
249        let invalidating = branch.iter().rposition(|entry| {
250            matches!(entry, FileEntry::ContextEdit(_) | FileEntry::Compaction(_))
251        });
252        let after = match (usage_position, invalidating) {
253            (Some(usage), Some(invalid)) => usage > invalid,
254            (Some(_), None) => true,
255            (None, _) => false,
256        };
257        if after {
258            return estimate;
259        }
260    }
261    let system = yapi_ai::transcript::current_system_message(&projection.messages);
262    let mut tokens = system
263        .map(|system| estimate_tokens(&Message::System(system)))
264        .unwrap_or(0);
265    tokens += projection
266        .messages
267        .iter()
268        .filter(|message| !matches!(message, Message::System(_)))
269        .map(estimate_tokens)
270        .sum::<u64>();
271    ContextEstimate {
272        tokens,
273        usage_tokens: 0,
274        trailing_tokens: tokens,
275        last_usage_index: None,
276    }
277}
278
279/// Whether the context leaves less than the reserve free.
280pub fn should_compact(tokens: u64, context_window: u64, settings: &CompactionSettings) -> bool {
281    settings.enabled && (tokens as i128) > context_window as i128 - settings.reserve_tokens as i128
282}
283
284fn is_cut_point(message: &Message) -> bool {
285    matches!(
286        message,
287        Message::User(_)
288            | Message::Assistant(_)
289            | Message::BashExecution(_)
290            | Message::Custom(_)
291            | Message::BranchSummary(_)
292            | Message::CompactionSummary(_)
293    )
294}
295
296fn is_turn_start(message: &Message) -> bool {
297    matches!(
298        message,
299        Message::User(_)
300            | Message::BashExecution(_)
301            | Message::Custom(_)
302            | Message::BranchSummary(_)
303            | Message::CompactionSummary(_)
304    )
305}
306
307fn is_compaction(entry: &ProjectedEntry<'_>) -> bool {
308    matches!(entry.source, FileEntry::Compaction(_))
309}
310
311fn starts_turn(entry: &ProjectedEntry<'_>) -> bool {
312    !is_compaction(entry) && entry.messages.iter().any(is_turn_start)
313}
314
315/// Where to cut the projected entries `start..end`.
316#[derive(Clone, Copy, Debug, PartialEq, Eq)]
317struct CutPoint {
318    first_kept: usize,
319    turn_start: Option<usize>,
320    split_turn: bool,
321}
322
323fn find_cut_point(
324    entries: &[ProjectedEntry<'_>],
325    start: usize,
326    end: usize,
327    keep_recent_tokens: u64,
328) -> CutPoint {
329    let cut_points: Vec<usize> = (start..end)
330        .filter(|&index| {
331            !is_compaction(&entries[index]) && entries[index].messages.iter().any(is_cut_point)
332        })
333        .collect();
334    let Some(&first) = cut_points.first() else {
335        return CutPoint {
336            first_kept: start,
337            turn_start: None,
338            split_turn: false,
339        };
340    };
341    let mut accumulated = 0;
342    let mut exceeded = false;
343    let mut cut = first;
344    for index in (start..end).rev() {
345        let tokens: u64 = entries[index].messages.iter().map(estimate_tokens).sum();
346        if tokens == 0 {
347            continue;
348        }
349        accumulated += tokens;
350        if accumulated >= keep_recent_tokens {
351            exceeded = true;
352            cut = cut_points
353                .iter()
354                .copied()
355                .find(|&candidate| candidate >= index)
356                .unwrap_or(cut_points[cut_points.len() - 1]);
357            break;
358        }
359    }
360
361    // A recovery attempt and its omission edits are invisible after the last
362    // visible input. Move past them only when the whole suffix is such an attempt.
363    let suffix = &entries[(cut + 1).min(end)..end];
364    let intrinsically_visible = |entry: &ProjectedEntry<'_>| {
365        !matches!(entry.source, FileEntry::ContextEdit(_))
366            && !entry_messages(entry.source).is_empty()
367    };
368    let omitted =
369        |entry: &ProjectedEntry<'_>| intrinsically_visible(entry) && entry.messages.is_empty();
370    let omitted_ids: Vec<&str> = suffix
371        .iter()
372        .filter(|entry| omitted(entry))
373        .filter_map(|entry| entry.source.meta().map(|meta| meta.id.as_str()))
374        .collect();
375    let external_replacement = suffix.iter().any(|entry| match entry.source {
376        FileEntry::ContextEdit(edit) => {
377            edit.replacement.is_some() && !omitted_ids.contains(&edit.target_id.as_str())
378        }
379        _ => false,
380    });
381    let recovery_suffix = exceeded && !external_replacement && suffix.iter().any(|entry| {
382        matches!(entry.source, FileEntry::Message(m) if matches!(m.message, Message::Assistant(_)))
383            && omitted(entry)
384    })
385        && suffix.iter().all(|entry| {
386            !is_compaction(entry) && (!intrinsically_visible(entry) || omitted(entry))
387        });
388    if recovery_suffix {
389        cut += 1;
390    }
391
392    while cut > start {
393        let previous = &entries[cut - 1];
394        if is_compaction(previous) || !previous.messages.is_empty() {
395            break;
396        }
397        cut -= 1;
398    }
399    let turn_start = if entries.get(cut).is_some_and(starts_turn) {
400        None
401    } else {
402        (start..=cut.min(end.saturating_sub(1)))
403            .rev()
404            .find(|&index| starts_turn(&entries[index]))
405    };
406    CutPoint {
407        first_kept: cut,
408        turn_start,
409        split_turn: turn_start.is_some(),
410    }
411}
412
413/// Files read and changed by tool calls, for the summary.
414#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
415pub struct FileOperations {
416    /// Read paths.
417    pub read: BTreeSet<String>,
418    /// Written paths.
419    pub written: BTreeSet<String>,
420    /// Edited paths.
421    pub edited: BTreeSet<String>,
422}
423
424impl FileOperations {
425    fn add(&mut self, tool: &str, arguments: &serde_json::Map<String, Value>) {
426        let Some(path) = arguments
427            .get("path")
428            .and_then(Value::as_str)
429            .filter(|p| !p.is_empty())
430        else {
431            return;
432        };
433        let set = match tool {
434            "read" => &mut self.read,
435            "write" => &mut self.written,
436            "edit" => &mut self.edited,
437            _ => return,
438        };
439        set.insert(path.to_owned());
440    }
441
442    /// Adds the `readFiles` and `modifiedFiles` lists of an earlier summary's
443    /// details.
444    fn add_details(&mut self, details: &Value) {
445        for (key, set) in [
446            ("readFiles", &mut self.read),
447            ("modifiedFiles", &mut self.edited),
448        ] {
449            let paths = details[key].as_array().into_iter().flatten();
450            set.extend(paths.filter_map(Value::as_str).map(str::to_owned));
451        }
452    }
453
454    /// Records the file tools a message called.
455    pub fn extract(&mut self, message: &Message) {
456        match message {
457            Message::ToolResult(result) => {
458                for call in result.nested_calls.iter().flat_map(|nested| &nested.calls) {
459                    if let Some(arguments) = &call.arguments {
460                        self.add(&call.name, arguments);
461                    }
462                }
463            }
464            Message::Assistant(assistant) => {
465                for block in &assistant.content {
466                    if let ContentBlock::ToolCall(call) = block {
467                        self.add(&call.name, &call.arguments);
468                    }
469                }
470            }
471            _ => {}
472        }
473    }
474
475    /// Paths only read, and paths changed, each sorted.
476    pub fn lists(&self) -> (Vec<String>, Vec<String>) {
477        let modified: BTreeSet<&String> = self.edited.iter().chain(&self.written).collect();
478        let read = self
479            .read
480            .iter()
481            .filter(|path| !modified.contains(path))
482            .cloned()
483            .collect();
484        (read, modified.into_iter().cloned().collect())
485    }
486}
487
488/// The file lists as tags appended to a summary.
489pub fn format_file_operations(read: &[String], modified: &[String]) -> String {
490    let mut sections = Vec::new();
491    if !read.is_empty() {
492        sections.push(format!("<read-files>\n{}\n</read-files>", read.join("\n")));
493    }
494    if !modified.is_empty() {
495        sections.push(format!(
496            "<modified-files>\n{}\n</modified-files>",
497            modified.join("\n")
498        ));
499    }
500    if sections.is_empty() {
501        String::new()
502    } else {
503        format!("\n\n{}", sections.join("\n\n"))
504    }
505}
506
507const TOOL_RESULT_MAX_CHARS: usize = 2000;
508
509fn truncate_for_summary(text: &str, max: usize) -> String {
510    let length = yapi_types::js::len(text);
511    if length <= max {
512        return text.to_owned();
513    }
514    format!(
515        "{}\n\n[... {} more characters truncated]",
516        yapi_types::js::slice(text, 0, max),
517        length - max
518    )
519}
520
521/// The conversation as plain text, so the summarizer does not continue it.
522/// Takes messages already converted for the model.
523pub fn serialize_conversation(messages: &[Message]) -> String {
524    let mut parts = Vec::new();
525    for message in messages {
526        match message {
527            Message::User(user) => {
528                let text = user.content.text("");
529                if !text.is_empty() {
530                    parts.push(format!("[User]: {text}"));
531                }
532            }
533            Message::Assistant(assistant) => {
534                let mut thinking = Vec::new();
535                let mut calls = Vec::new();
536                for block in &assistant.content {
537                    match block {
538                        ContentBlock::Thinking(block) => thinking.push(block.thinking.as_str()),
539                        ContentBlock::ToolCall(call) => {
540                            let arguments: Vec<String> = call
541                                .arguments
542                                .iter()
543                                .map(|(key, value)| {
544                                    format!("{key}={}", yapi_types::json::stringify(value))
545                                })
546                                .collect();
547                            calls.push(format!("{}({})", call.name, arguments.join(", ")));
548                        }
549                        _ => {}
550                    }
551                }
552                if !thinking.is_empty() {
553                    parts.push(format!("[Assistant thinking]: {}", thinking.join("\n")));
554                }
555                if assistant
556                    .content
557                    .iter()
558                    .any(|block| matches!(block, ContentBlock::Text(_)))
559                {
560                    parts.push(format!(
561                        "[Assistant]: {}",
562                        yapi_types::message::blocks_text(&assistant.content, "\n")
563                    ));
564                }
565                if !calls.is_empty() {
566                    parts.push(format!("[Assistant tool calls]: {}", calls.join("; ")));
567                }
568            }
569            Message::ToolResult(result) => {
570                let text = yapi_types::message::blocks_text(&result.content, "");
571                if !text.is_empty() {
572                    parts.push(format!(
573                        "[Tool result]: {}",
574                        truncate_for_summary(&text, TOOL_RESULT_MAX_CHARS)
575                    ));
576                }
577            }
578            _ => {}
579        }
580    }
581    parts.join("\n\n")
582}
583
584/// System prompt of summary requests.
585pub const SUMMARIZATION_SYSTEM_PROMPT: &str = "You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.";
586
587const 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.";
588
589const UPDATE_SUMMARIZATION_INSTRUCTIONS: &str = "Update 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.";
590
591const TURN_PREFIX_SUMMARIZATION_PROMPT: &str = "The messages above are earlier context from an ongoing conversation. Later messages are stored separately and do not need to be reconstructed.\n\nCreate a concise checkpoint of the user's request and the progress shown above. This checkpoint will be placed before the later messages so the conversation can continue with the necessary context.\n\n## Original Request\n[What did the user ask for?]\n\n## Progress So Far\n- [Key decisions and work completed in these messages]\n\n## Context Needed to Continue\n- [Information from these messages needed to understand the later work]\n\nOnly summarize information explicitly present above. Do not infer or recreate later messages.";
592
593/// What compaction will summarize and keep, serialized as pi's
594/// `CompactionPreparation`.
595#[derive(Clone, Debug, Serialize)]
596#[serde(rename_all = "camelCase")]
597pub struct Preparation {
598    /// First entry kept verbatim.
599    pub first_kept_entry_id: String,
600    /// Messages summarized and dropped.
601    pub messages_to_summarize: Vec<Message>,
602    /// The start of a turn split by the cut, summarized separately.
603    pub turn_prefix_messages: Vec<Message>,
604    /// The cut falls inside a turn.
605    pub is_split_turn: bool,
606    /// Estimated context tokens before compaction.
607    pub tokens_before: u64,
608    /// The previous compaction's summary, to update.
609    #[serde(skip_serializing_if = "Option::is_none")]
610    pub previous_summary: Option<String>,
611    /// Files touched by the summarized messages.
612    pub file_ops: FileOperations,
613    /// The settings used.
614    pub settings: CompactionSettings,
615}
616
617/// Plans a compaction of a branch, or `None` when there is nothing to summarize.
618pub fn prepare_compaction(
619    branch: &[&FileEntry],
620    settings: CompactionSettings,
621) -> Option<Preparation> {
622    if matches!(branch.last(), Some(FileEntry::Compaction(_))) {
623        return None;
624    }
625    let projection = crate::session::build_projection(branch);
626    let entries = &projection.entries;
627    // The newest compaction is projected first. Older ones contribute nothing.
628    let previous = entries
629        .iter()
630        .position(|entry| is_compaction(entry) && !entry.messages.is_empty());
631    let (previous_summary, start) = match previous {
632        Some(index) => match entries[index].source {
633            FileEntry::Compaction(compaction) => (Some(compaction.summary.clone()), index + 1),
634            _ => (None, 0),
635        },
636        None => (None, 0),
637    };
638    let end = entries.len();
639    let tokens_before = estimate_projected_context_tokens(&projection, branch).tokens;
640    let cut = find_cut_point(entries, start, end, settings.keep_recent_tokens);
641    let first_kept_entry_id = entries
642        .get(cut.first_kept)?
643        .source
644        .meta()
645        .map(|meta| meta.id.clone())
646        .filter(|id| !id.is_empty())?;
647    let history_end = if cut.split_turn {
648        cut.turn_start.unwrap_or(cut.first_kept)
649    } else {
650        cut.first_kept
651    };
652    let summarizable = |range: std::ops::Range<usize>| -> Vec<Message> {
653        entries[range]
654            .iter()
655            .filter(|entry| !is_compaction(entry))
656            .flat_map(|entry| {
657                entry
658                    .messages
659                    .iter()
660                    .filter(|message| !matches!(message, Message::System(_)))
661                    .cloned()
662            })
663            .collect()
664    };
665    let messages_to_summarize = summarizable(start..history_end.max(start));
666    let turn_prefix_messages = match (cut.split_turn, cut.turn_start) {
667        (true, Some(turn_start)) => summarizable(turn_start..cut.first_kept),
668        _ => Vec::new(),
669    };
670    if messages_to_summarize.is_empty() && turn_prefix_messages.is_empty() {
671        return None;
672    }
673    let mut file_ops = FileOperations::default();
674    if let Some(index) = previous
675        && let FileEntry::Compaction(compaction) = entries[index].source
676        && compaction.from_hook != Some(true)
677        && let Some(details) = &compaction.details
678    {
679        file_ops.add_details(details);
680    }
681    for message in messages_to_summarize.iter().chain(&turn_prefix_messages) {
682        file_ops.extract(message);
683    }
684    Some(Preparation {
685        first_kept_entry_id,
686        messages_to_summarize,
687        turn_prefix_messages,
688        is_split_turn: cut.split_turn,
689        tokens_before,
690        previous_summary,
691        file_ops,
692        settings,
693    })
694}
695
696/// What summary requests need besides the messages.
697pub struct Summarizer<'a> {
698    /// The model to summarize with.
699    pub model: &'a Model,
700    /// Wire APIs.
701    pub apis: Apis,
702    /// Credentials for the model.
703    pub auth: Auth,
704    /// The session's thinking level, used when the model reasons.
705    pub thinking_level: ThinkingLevel,
706    /// Retries for transient failures.
707    pub retry: RetryPolicy,
708    /// The settings every request carries, as the session's requests carry
709    /// them, or the error an invalid one raises.
710    pub request: Result<StreamOptions, String>,
711    /// Cancels the requests.
712    pub cancel: CancellationToken,
713    /// Hears about retries.
714    pub on_retry: Box<dyn Fn(SummaryRetry) + Send + Sync + 'a>,
715    /// Observers of the requests.
716    pub hooks: yapi_ai::stream::RequestHooks,
717}
718
719/// pi-ai's `RetryCallbacks`: what a summarizer reports while it retries.
720#[derive(Clone, Debug, PartialEq, Eq)]
721pub enum SummaryRetry {
722    /// A failed request is retried after `delay_ms`.
723    Scheduled {
724        /// The retry's number, from 1.
725        attempt: u32,
726        /// How many retries the policy allows.
727        max_attempts: u32,
728        /// The wait before it.
729        delay_ms: u64,
730        /// Why the request failed.
731        error_message: String,
732    },
733    /// The retried request starts.
734    AttemptStart,
735    /// Retrying ended, in success or not.
736    Finished,
737}
738
739/// A summary and the usage of the requests that made it.
740#[derive(Clone, Debug, PartialEq)]
741pub struct Summary {
742    /// The text.
743    pub text: String,
744    /// Usage of the summary requests.
745    pub usage: Usage,
746}
747
748/// Why a summary response cannot become a checkpoint, if it cannot: it
749/// failed, reached the token cap or called a tool.
750pub(crate) fn check_summary(response: &AssistantMessage, label: &str) -> Result<(), String> {
751    match response.stop_reason {
752        StopReason::Error => Err(format!(
753            "{label} failed: {}",
754            response
755                .error_message
756                .as_deref()
757                .filter(|m| !m.is_empty())
758                .unwrap_or("Unknown error")
759        )),
760        StopReason::Length => Err(format!(
761            "{label} failed: generation hit the token cap and the summary is incomplete"
762        )),
763        _ if response
764            .content
765            .iter()
766            .any(|block| matches!(block, ContentBlock::ToolCall(_))) =>
767        {
768            Err(format!("{label} attempted to call a tool"))
769        }
770        _ => Ok(()),
771    }
772}
773
774impl Summarizer<'_> {
775    async fn complete_once(
776        &self,
777        system: &str,
778        prompt: &str,
779        max_tokens: u64,
780        session_id: &str,
781    ) -> AssistantMessage {
782        let messages = vec![
783            Message::System(SystemMessage {
784                content: Content::Text(system.to_owned()),
785                sections: None,
786                timestamp: 0,
787                tools_added: None,
788                tools_removed: None,
789            }),
790            Message::User(UserMessage {
791                content: Content::Blocks(vec![ContentBlock::text(prompt)]),
792                timestamp: now_ms(),
793            }),
794        ];
795        let reasoning = (self.model.reasoning && self.thinking_level != ThinkingLevel::Off)
796            .then_some(self.thinking_level);
797        let options = StreamOptions {
798            reasoning,
799            max_tokens: Some(max_tokens),
800            session_id: Some(session_id.to_owned()),
801            cache_retention: Some(CacheRetention::None),
802            cancel: self.cancel.clone(),
803            hooks: self.hooks.clone(),
804            ..self.request.clone().unwrap_or_default()
805        };
806        let mut request = Request {
807            model: self.model.clone(),
808            messages,
809            options,
810        };
811        let checked = self.request.as_ref().map(|_| ()).map_err(Clone::clone);
812        let stream = match checked.and_then(|()| self.auth.clone().apply(&mut request)) {
813            Ok(()) => {
814                yapi_ai::stream::RequestHooks::prepare(&mut request).await;
815                self.apis.stream(request)
816            }
817            Err(message) => {
818                yapi_ai::api::failed_stream(&request.model, &request.options.cancel, message)
819            }
820        };
821        match stream.result().await {
822            Some(message) => message,
823            None => {
824                let mut message = yapi_ai::stream::new_output(self.model, now_ms());
825                message.stop_reason = StopReason::Error;
826                message.error_message = Some("Stream ended without a result".into());
827                message
828            }
829        }
830    }
831
832    /// One summary request under `system` with retries on transient
833    /// failures, reported as pi-ai's `retryAssistantCall` reports them.
834    pub(crate) async fn complete(
835        &self,
836        system: &str,
837        prompt: &str,
838        max_tokens: u64,
839    ) -> AssistantMessage {
840        let session_id = uuid_v7();
841        let max_attempts = if self.retry.enabled {
842            self.retry.max_retries
843        } else {
844            0
845        };
846        let notify = &self.on_retry;
847        let mut attempt = 0;
848        loop {
849            let response = self
850                .complete_once(system, prompt, max_tokens, &session_id)
851                .await;
852            if response.stop_reason != StopReason::Error
853                || attempt >= max_attempts
854                || !is_retryable_assistant_error(&response)
855            {
856                if attempt > 0 {
857                    notify(SummaryRetry::Finished);
858                }
859                return response;
860            }
861            attempt += 1;
862            let delay_ms = self.retry.delay_ms(attempt);
863            notify(SummaryRetry::Scheduled {
864                attempt,
865                max_attempts,
866                delay_ms,
867                error_message: response
868                    .error_message
869                    .clone()
870                    .filter(|message| !message.is_empty())
871                    .unwrap_or_else(|| "Unknown error".into()),
872            });
873            tokio::select! {
874                () = self.cancel.cancelled() => {
875                    notify(SummaryRetry::Finished);
876                    let mut response = response;
877                    response.stop_reason = StopReason::Aborted;
878                    response.error_message = None;
879                    return response;
880                }
881                () = tokio::time::sleep(std::time::Duration::from_millis(delay_ms)) => {}
882            }
883            notify(SummaryRetry::AttemptStart);
884        }
885    }
886
887    async fn summarize(
888        &self,
889        prompt: &str,
890        max_tokens: u64,
891        label: &str,
892    ) -> Result<Summary, String> {
893        // As in pi, a request started after the abort fails with its kind,
894        // while one aborted mid-stream counts as finished. The caller then
895        // sees the abort.
896        if self.cancel.is_cancelled() {
897            return Err(format!("{label} failed: This operation was aborted"));
898        }
899        let response = self
900            .complete(SUMMARIZATION_SYSTEM_PROMPT, prompt, max_tokens)
901            .await;
902        if response.stop_reason == StopReason::Aborted {
903            return Ok(Summary {
904                text: yapi_types::message::blocks_text(&response.content, "\n"),
905                usage: response.usage,
906            });
907        }
908        check_summary(&response, label)?;
909        Ok(Summary {
910            text: yapi_types::message::blocks_text(&response.content, "\n"),
911            usage: response.usage,
912        })
913    }
914
915    fn max_tokens(&self, fraction: f64, reserve_tokens: u64) -> u64 {
916        let budget = (fraction * reserve_tokens as f64).floor() as u64;
917        if self.model.max_tokens > 0 {
918            budget.min(self.model.max_tokens)
919        } else {
920            budget
921        }
922    }
923
924    /// Summarizes `messages`, updating `previous` when given.
925    pub async fn generate_summary(
926        &self,
927        messages: &[Message],
928        reserve_tokens: u64,
929        custom_instructions: Option<&str>,
930        previous: Option<&str>,
931    ) -> Result<Summary, String> {
932        let mut base = if previous.is_some() {
933            format!(
934                "The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.\n\n{UPDATE_SUMMARIZATION_INSTRUCTIONS}"
935            )
936        } else {
937            SUMMARIZATION_PROMPT.to_owned()
938        };
939        if let Some(instructions) = custom_instructions.filter(|text| !text.is_empty()) {
940            base = format!("{base}\n\nAdditional focus: {instructions}");
941        }
942        let conversation = serialize_conversation(&convert_to_llm(messages.to_vec()));
943        let mut prompt = format!("<conversation>\n{conversation}\n</conversation>\n\n");
944        if let Some(previous) = previous {
945            prompt += &format!("<previous-summary>\n{previous}\n</previous-summary>\n\n");
946        }
947        prompt += &base;
948        self.summarize(
949            &prompt,
950            self.max_tokens(0.8, reserve_tokens),
951            "Summarization",
952        )
953        .await
954    }
955
956    async fn turn_prefix_summary(
957        &self,
958        messages: &[Message],
959        reserve_tokens: u64,
960    ) -> Result<Summary, String> {
961        let conversation = serialize_conversation(&convert_to_llm(messages.to_vec()));
962        let prompt = format!(
963            "# Conversation\n{conversation}\n\n# Instructions\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
964        );
965        self.summarize(
966            &prompt,
967            self.max_tokens(0.5, reserve_tokens),
968            "Turn prefix summarization",
969        )
970        .await
971    }
972
973    /// Generates the compaction summary for a preparation.
974    pub async fn compact(
975        &self,
976        preparation: &Preparation,
977        custom_instructions: Option<&str>,
978    ) -> Result<yapi_types::event::CompactionResult, String> {
979        let reserve = preparation.settings.reserve_tokens;
980        let previous = preparation.previous_summary.as_deref();
981        let (mut summary, usage) =
982            if preparation.is_split_turn && !preparation.turn_prefix_messages.is_empty() {
983                let history = if preparation.messages_to_summarize.is_empty() {
984                    None
985                } else {
986                    Some(
987                        self.generate_summary(
988                            &preparation.messages_to_summarize,
989                            reserve,
990                            custom_instructions,
991                            previous,
992                        )
993                        .await?,
994                    )
995                };
996                let prefix = self
997                    .turn_prefix_summary(&preparation.turn_prefix_messages, reserve)
998                    .await?;
999                let history_text = match &history {
1000                    Some(history) => history.text.clone(),
1001                    None => previous.unwrap_or("No prior history.").to_owned(),
1002                };
1003                let usage = match history {
1004                    Some(history) => history.usage.combine(&prefix.usage),
1005                    None => prefix.usage,
1006                };
1007                (
1008                    format!(
1009                        "{history_text}\n\n---\n\n**Turn Context (split turn):**\n\n{}",
1010                        prefix.text
1011                    ),
1012                    usage,
1013                )
1014            } else {
1015                let result = self
1016                    .generate_summary(
1017                        &preparation.messages_to_summarize,
1018                        reserve,
1019                        custom_instructions,
1020                        previous,
1021                    )
1022                    .await?;
1023                (result.text, result.usage)
1024            };
1025        let (read, modified) = preparation.file_ops.lists();
1026        summary += &format_file_operations(&read, &modified);
1027        Ok(yapi_types::event::CompactionResult {
1028            summary,
1029            first_kept_entry_id: preparation.first_kept_entry_id.clone(),
1030            tokens_before: preparation.tokens_before,
1031            estimated_tokens_after: None,
1032            usage: Some(usage),
1033            details: Some(json!({"readFiles": read, "modifiedFiles": modified})),
1034        })
1035    }
1036}
1037
1038const BRANCH_SUMMARY_PREAMBLE: &str = "The user explored a different conversation branch before returning here.\nSummary of that exploration:\n\n";
1039
1040const BRANCH_SUMMARY_PROMPT: &str = "Create a structured summary of this conversation branch for context when returning later.\n\nUse this EXACT format:\n\n## Goal\n[What was the user trying to accomplish in this branch?]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements mentioned]\n- [Or \"(none)\" if none were mentioned]\n\n## Progress\n### Done\n- [x] [Completed tasks/changes]\n\n### In Progress\n- [ ] [Work that was started but not finished]\n\n### Blocked\n- [Issues preventing progress, if any]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [What should happen next to continue this work]\n\nKeep each section concise. Preserve exact file paths, function names, and error messages.";
1041
1042/// The message an entry contributes to a branch summary. Tool results are
1043/// skipped because the calls already contain their context.
1044fn branch_entry_message(entry: &FileEntry) -> Option<Message> {
1045    match entry {
1046        FileEntry::Message(entry) if matches!(entry.message, Message::ToolResult(_)) => None,
1047        // A compaction's summary comes after the system message it may include.
1048        FileEntry::Message(_)
1049        | FileEntry::CustomMessage(_)
1050        | FileEntry::BranchSummary(_)
1051        | FileEntry::Compaction(_) => entry_messages(entry).pop(),
1052        _ => None,
1053    }
1054}
1055
1056/// Messages of the abandoned branch, newest first until `token_budget` (0 for
1057/// none), and the files touched across all of it.
1058pub fn prepare_branch_entries(
1059    entries: &[FileEntry],
1060    token_budget: u64,
1061) -> (Vec<Message>, FileOperations) {
1062    let mut file_ops = FileOperations::default();
1063    for entry in entries {
1064        if let FileEntry::BranchSummary(summary) = entry
1065            && summary.from_hook != Some(true)
1066            && let Some(details) = &summary.details
1067        {
1068            file_ops.add_details(details);
1069        }
1070    }
1071    let mut messages: Vec<Message> = Vec::new();
1072    let mut total = 0;
1073    for entry in entries.iter().rev() {
1074        let Some(message) = branch_entry_message(entry) else {
1075            continue;
1076        };
1077        file_ops.extract(&message);
1078        let tokens = estimate_tokens(&message);
1079        if token_budget > 0 && total + tokens > token_budget {
1080            // Summaries are worth keeping when there is still some room.
1081            if matches!(
1082                entry,
1083                FileEntry::Compaction(_) | FileEntry::BranchSummary(_)
1084            ) && (total as f64) < token_budget as f64 * 0.9
1085            {
1086                messages.insert(0, message);
1087            }
1088            break;
1089        }
1090        messages.insert(0, message);
1091        total += tokens;
1092    }
1093    (messages, file_ops)
1094}
1095
1096/// What summarizing a branch produced.
1097#[derive(Clone, Debug, PartialEq)]
1098pub enum BranchSummary {
1099    /// A summary to record, with its usage and file lists.
1100    Done {
1101        /// The text.
1102        summary: String,
1103        /// Usage of the request, if one was made.
1104        usage: Option<Usage>,
1105        /// Files only read.
1106        read_files: Vec<String>,
1107        /// Files changed.
1108        modified_files: Vec<String>,
1109    },
1110    /// The request was cancelled.
1111    Aborted,
1112}
1113
1114impl Summarizer<'_> {
1115    /// Summarizes abandoned branch entries (chronological). `replace` makes
1116    /// `custom_instructions` the whole instruction instead of an addition.
1117    pub async fn branch_summary(
1118        &self,
1119        entries: &[FileEntry],
1120        custom_instructions: Option<&str>,
1121        replace: bool,
1122        reserve_tokens: u64,
1123    ) -> Result<BranchSummary, String> {
1124        let window = if self.model.context_window > 0 {
1125            self.model.context_window
1126        } else {
1127            128_000
1128        };
1129        let (messages, file_ops) =
1130            prepare_branch_entries(entries, window.saturating_sub(reserve_tokens));
1131        if messages.is_empty() {
1132            return Ok(BranchSummary::Done {
1133                summary: "No content to summarize".into(),
1134                usage: None,
1135                read_files: Vec::new(),
1136                modified_files: Vec::new(),
1137            });
1138        }
1139        let conversation = serialize_conversation(&convert_to_llm(messages));
1140        let instructions = match custom_instructions.filter(|text| !text.is_empty()) {
1141            Some(custom) if replace => custom.to_owned(),
1142            Some(custom) => format!("{BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: {custom}"),
1143            None => BRANCH_SUMMARY_PROMPT.to_owned(),
1144        };
1145        let prompt = format!("<conversation>\n{conversation}\n</conversation>\n\n{instructions}");
1146        let max_tokens = if self.model.max_tokens > 0 {
1147            self.model.max_tokens.min(4096)
1148        } else {
1149            4096
1150        };
1151        let response = self
1152            .complete(SUMMARIZATION_SYSTEM_PROMPT, &prompt, max_tokens)
1153            .await;
1154        if response.stop_reason == StopReason::Aborted {
1155            return Ok(BranchSummary::Aborted);
1156        }
1157        check_summary(&response, "Branch summarization")?;
1158        let (read_files, modified_files) = file_ops.lists();
1159        let summary = format!(
1160            "{BRANCH_SUMMARY_PREAMBLE}{}{}",
1161            yapi_types::message::blocks_text(&response.content, "\n"),
1162            format_file_operations(&read_files, &modified_files)
1163        );
1164        Ok(BranchSummary::Done {
1165            summary,
1166            usage: Some(response.usage),
1167            read_files,
1168            modified_files,
1169        })
1170    }
1171}
1172
1173/// Entries from `old_leaf` back to the deepest common ancestor with `target`,
1174/// oldest first, and that ancestor.
1175pub fn collect_branch_entries(
1176    session: &crate::session::SessionManager,
1177    old_leaf: Option<&str>,
1178    target: &str,
1179) -> (Vec<FileEntry>, Option<String>) {
1180    let Some(old_leaf) = old_leaf else {
1181        return (Vec::new(), None);
1182    };
1183    let ids = |path: Vec<&FileEntry>| -> Vec<String> {
1184        path.iter()
1185            .filter_map(|entry| entry.meta().map(|meta| meta.id.clone()))
1186            .collect()
1187    };
1188    let old_path = ids(session.branch_path(Some(old_leaf)));
1189    let target_path = ids(session.branch_path(Some(target)));
1190    let common = target_path
1191        .iter()
1192        .rev()
1193        .find(|id| old_path.contains(id))
1194        .cloned();
1195    let mut entries = Vec::new();
1196    let mut current = Some(old_leaf.to_owned());
1197    while let Some(id) = current.filter(|id| Some(id) != common.as_ref()) {
1198        let Some(entry) = session.entry(&id) else {
1199            break;
1200        };
1201        current = entry.meta().and_then(|meta| meta.parent_id.clone());
1202        entries.push(entry.clone());
1203    }
1204    entries.reverse();
1205    (entries, common)
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::*;
1211
1212    #[test]
1213    fn serializes_conversations() {
1214        let messages: Vec<Message> = serde_json::from_value(json!([
1215            {"role":"user","content":"hi","timestamp":1},
1216            {"role":"assistant","content":[
1217                {"type":"thinking","thinking":"plan"},
1218                {"type":"text","text":"Reading."},
1219                {"type":"toolCall","id":"c","name":"read","arguments":{"path":"a.txt","limit":null}}
1220            ],"api":"x","provider":"p","model":"m","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":2},
1221            {"role":"toolResult","toolCallId":"c","toolName":"read","content":[{"type":"text","text":"x"}],"isError":false,"timestamp":3}
1222        ]))
1223        .unwrap();
1224        assert_eq!(
1225            serialize_conversation(&messages),
1226            "[User]: hi\n\n[Assistant thinking]: plan\n\n[Assistant]: Reading.\n\n[Assistant tool calls]: read(path=\"a.txt\", limit=null)\n\n[Tool result]: x"
1227        );
1228        let mut ops = FileOperations::default();
1229        for message in &messages {
1230            ops.extract(message);
1231        }
1232        assert_eq!(ops.lists(), (vec!["a.txt".to_owned()], vec![]));
1233    }
1234
1235    #[test]
1236    fn thresholds() {
1237        let settings = CompactionSettings {
1238            enabled: true,
1239            reserve_tokens: 100,
1240            keep_recent_tokens: 10,
1241        };
1242        assert!(should_compact(950, 1000, &settings));
1243        assert!(!should_compact(900, 1000, &settings));
1244        assert!(should_compact(1, 50, &settings));
1245    }
1246}