1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct CompactionSettings {
33 pub enabled: bool,
35 pub reserve_tokens: u64,
37 pub keep_recent_tokens: u64,
39}
40
41impl CompactionSettings {
42 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub struct RetryPolicy {
68 pub enabled: bool,
70 pub max_retries: u32,
72 pub base_delay_ms: u64,
74 pub max_delay_ms: u64,
76}
77
78impl RetryPolicy {
79 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 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
98pub 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
140pub 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
186pub struct ContextEstimate {
187 pub tokens: u64,
189 pub usage_tokens: u64,
191 pub trailing_tokens: u64,
193 pub last_usage_index: Option<usize>,
195}
196
197pub 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
227pub 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
279pub 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#[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 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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
415pub struct FileOperations {
416 pub read: BTreeSet<String>,
418 pub written: BTreeSet<String>,
420 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 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 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 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
488pub 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
521pub 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
584pub 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#[derive(Clone, Debug, Serialize)]
596#[serde(rename_all = "camelCase")]
597pub struct Preparation {
598 pub first_kept_entry_id: String,
600 pub messages_to_summarize: Vec<Message>,
602 pub turn_prefix_messages: Vec<Message>,
604 pub is_split_turn: bool,
606 pub tokens_before: u64,
608 #[serde(skip_serializing_if = "Option::is_none")]
610 pub previous_summary: Option<String>,
611 pub file_ops: FileOperations,
613 pub settings: CompactionSettings,
615}
616
617pub 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 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
696pub struct Summarizer<'a> {
698 pub model: &'a Model,
700 pub apis: Apis,
702 pub auth: Auth,
704 pub thinking_level: ThinkingLevel,
706 pub retry: RetryPolicy,
708 pub request: Result<StreamOptions, String>,
711 pub cancel: CancellationToken,
713 pub on_retry: Box<dyn Fn(SummaryRetry) + Send + Sync + 'a>,
715 pub hooks: yapi_ai::stream::RequestHooks,
717}
718
719#[derive(Clone, Debug, PartialEq, Eq)]
721pub enum SummaryRetry {
722 Scheduled {
724 attempt: u32,
726 max_attempts: u32,
728 delay_ms: u64,
730 error_message: String,
732 },
733 AttemptStart,
735 Finished,
737}
738
739#[derive(Clone, Debug, PartialEq)]
741pub struct Summary {
742 pub text: String,
744 pub usage: Usage,
746}
747
748pub(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 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 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 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 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
1042fn branch_entry_message(entry: &FileEntry) -> Option<Message> {
1045 match entry {
1046 FileEntry::Message(entry) if matches!(entry.message, Message::ToolResult(_)) => None,
1047 FileEntry::Message(_)
1049 | FileEntry::CustomMessage(_)
1050 | FileEntry::BranchSummary(_)
1051 | FileEntry::Compaction(_) => entry_messages(entry).pop(),
1052 _ => None,
1053 }
1054}
1055
1056pub 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 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#[derive(Clone, Debug, PartialEq)]
1098pub enum BranchSummary {
1099 Done {
1101 summary: String,
1103 usage: Option<Usage>,
1105 read_files: Vec<String>,
1107 modified_files: Vec<String>,
1109 },
1110 Aborted,
1112}
1113
1114impl Summarizer<'_> {
1115 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
1173pub 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}