1use std::sync::atomic::Ordering;
5
6use tokio_util::sync::CancellationToken;
7use yapi_ai::errors::{is_context_overflow, is_recoverable_length, is_retryable_assistant_error};
8use yapi_types::event::AgentEvent;
9use yapi_types::event::{CompactionReason, CompactionResult, SummarySource};
10use yapi_types::extension_event::ExtensionEvent;
11use yapi_types::message::{AssistantMessage, Message, StopReason, ThinkingLevel};
12use yapi_types::model::Model;
13use yapi_types::session::FileEntry;
14use yapi_types::sync::lock;
15
16use crate::compaction::{
17 CompactionSettings, Preparation, RetryPolicy, Summarizer, SummaryRetry,
18 calculate_context_tokens, estimate_context_tokens, estimate_projected_context_tokens,
19 estimate_tokens, prepare_compaction, should_compact,
20};
21use crate::session::build_projection;
22use crate::time::parse_iso;
23
24use super::{AgentSession, Compacting};
25
26enum CompactionCheck {
28 None,
29 Overflow { will_retry: bool },
30 OverflowFailed(String),
31 Threshold,
32}
33
34impl AgentSession {
35 pub fn abort_retry(&self) {
37 let cancel = lock(&self.inner.retry_cancel).clone();
38 if let Some(cancel) = cancel {
39 self.finish_cancelled_retry();
43 cancel.cancel();
44 }
45 }
46
47 fn model_for_message(&self, message: &AssistantMessage) -> Option<Model> {
49 self.model()
50 .filter(|model| model.is(&message.provider, &message.model))
51 }
52
53 fn is_retryable(&self, message: &AssistantMessage) -> bool {
54 let window = self
55 .model_for_message(message)
56 .or_else(|| self.model())
57 .map_or(0, |model| model.context_window);
58 !is_context_overflow(message, window) && is_retryable_assistant_error(message)
59 }
60
61 fn retry_policy(&self) -> RetryPolicy {
62 RetryPolicy::resolve(lock(&self.inner.settings).settings())
63 }
64
65 pub(super) fn will_retry_after(&self, messages: &[Message]) -> bool {
67 let cancelled = lock(&self.inner.cancel)
68 .as_ref()
69 .is_some_and(CancellationToken::is_cancelled);
70 let policy = self.retry_policy();
71 if cancelled
72 || !policy.enabled
73 || lock(&self.inner.recovery).retry_attempt >= policy.max_retries
74 {
75 return false;
76 }
77 messages
78 .iter()
79 .rev()
80 .find_map(|message| match message {
81 Message::Assistant(assistant) => Some(self.is_retryable(assistant)),
82 _ => None,
83 })
84 .unwrap_or(false)
85 }
86
87 pub(super) fn finish_cancelled_retry(&self) {
88 let attempt = std::mem::take(&mut lock(&self.inner.recovery).retry_attempt);
89 if attempt > 0 {
90 self.emit(&AgentEvent::AutoRetryEnd {
91 success: false,
92 attempt,
93 final_error: Some("Retry cancelled".into()),
94 });
95 }
96 }
97
98 fn omit_recovery_attempt(&self, entry_ids: &[String]) {
101 for id in entry_ids {
102 let entry = self.with_session(|session| {
103 let edit = session.append_context_edit(id, None).ok()?;
104 session.entry(&edit).cloned()
105 });
106 if let Some(entry) = entry {
107 self.emit(&AgentEvent::EntryAppended { entry });
108 }
109 }
110 }
111
112 async fn prepare_retry(
113 &self,
114 message: &AssistantMessage,
115 entry_id: Option<String>,
116 cancel: &CancellationToken,
117 ) -> bool {
118 let policy = self.retry_policy();
119 if !policy.enabled {
120 return false;
121 }
122 let attempt = {
123 let mut recovery = lock(&self.inner.recovery);
124 if recovery.retry_attempt >= policy.max_retries {
125 return false;
126 }
127 recovery.retry_attempt += 1;
128 recovery.retry_attempt
129 };
130 let delay_ms = policy.delay_ms(attempt);
131 self.emit(&AgentEvent::AutoRetryStart {
132 attempt,
133 max_attempts: policy.max_retries,
134 delay_ms,
135 error_message: message
136 .error_message
137 .clone()
138 .filter(|m| !m.is_empty())
139 .unwrap_or_else(|| "Unknown error".into()),
140 });
141 self.omit_recovery_attempt(&entry_id.into_iter().collect::<Vec<_>>());
142 let retry = cancel.child_token();
143 *lock(&self.inner.retry_cancel) = Some(retry.clone());
144 let waited = tokio::select! {
145 () = retry.cancelled() => {
146 self.finish_cancelled_retry();
147 false
148 }
149 () = tokio::time::sleep(std::time::Duration::from_millis(delay_ms)) => true,
150 };
151 *lock(&self.inner.retry_cancel) = None;
152 waited
153 }
154
155 pub(super) async fn handle_post_run(&self, cancel: &CancellationToken) -> Result<bool, String> {
158 let (last, tool_results) = {
159 let mut recovery = lock(&self.inner.recovery);
160 (
161 recovery.last_assistant.take(),
162 std::mem::take(&mut recovery.last_tool_results),
163 )
164 };
165 if cancel.is_cancelled() {
166 self.finish_cancelled_retry();
167 return Ok(false);
168 }
169 let Some((message, entry_id)) = last else {
170 return Ok(self.has_queued());
171 };
172 if self.is_retryable(&message)
173 && self.prepare_retry(&message, entry_id.clone(), cancel).await
174 {
175 return Ok(!cancel.is_cancelled());
176 }
177 if cancel.is_cancelled() {
178 self.finish_cancelled_retry();
179 return Ok(false);
180 }
181 if message.stop_reason == StopReason::Error {
182 let attempt = std::mem::take(&mut lock(&self.inner.recovery).retry_attempt);
183 if attempt > 0 {
184 self.emit(&AgentEvent::AutoRetryEnd {
185 success: false,
186 attempt,
187 final_error: message.error_message.clone(),
188 });
189 }
190 }
191 if self
192 .check_compaction(&message, entry_id, tool_results, cancel, true)
193 .await?
194 {
195 return Ok(!cancel.is_cancelled());
196 }
197 Ok(!cancel.is_cancelled() && self.has_queued())
198 }
199
200 pub(super) async fn compact_before_prompt(&self) -> Result<(), String> {
206 let last = self.with_session(|session| {
207 let branch = session.branch_path(None);
208 build_projection(&branch)
209 .entries
210 .iter()
211 .rev()
212 .find_map(|entry| {
213 let message =
214 entry
215 .messages
216 .iter()
217 .rev()
218 .find_map(|message| match message {
219 Message::Assistant(assistant) => Some((**assistant).clone()),
220 _ => None,
221 })?;
222 Some((message, entry.source.meta().map(|meta| meta.id.clone())))
223 })
224 });
225 let Some((message, entry_id)) = last else {
226 return Ok(());
227 };
228 let cancel = CancellationToken::new();
229 *lock(&self.inner.cancel) = Some(cancel.clone());
230 let checked = self
231 .check_compaction(&message, entry_id, Vec::new(), &cancel, false)
232 .await;
233 *lock(&self.inner.cancel) = None;
234 checked.map(drop)
235 }
236
237 pub(super) async fn compact_before_next_response(
242 &self,
243 cancel: &CancellationToken,
244 ) -> Result<(), String> {
245 let Some(model) = self.model().filter(|model| model.context_window > 0) else {
246 return Ok(());
247 };
248 if let Some(error) = lock(&self.inner.settings).compaction_error(Some(&model)) {
249 return Err(error);
250 }
251 let settings =
252 CompactionSettings::resolve(lock(&self.inner.settings).settings(), Some(&model));
253 let tokens = self.with_session(|session| {
254 let branch = session.branch_path(None);
255 estimate_projected_context_tokens(&build_projection(&branch), &branch).tokens
256 });
257 if should_compact(tokens, model.context_window, &settings) {
258 self.run_auto_compaction(CompactionReason::Threshold, false, cancel)
259 .await;
260 }
261 Ok(())
262 }
263
264 async fn check_compaction(
268 &self,
269 message: &AssistantMessage,
270 entry_id: Option<String>,
271 tool_results: Vec<String>,
272 cancel: &CancellationToken,
273 skip_aborted: bool,
274 ) -> Result<bool, String> {
275 let model = self.model();
276 if let Some(error) = lock(&self.inner.settings).compaction_error(model.as_ref()) {
277 return Err(error);
278 }
279 let Some(model) = model else {
280 return Ok(false);
281 };
282 let settings =
283 CompactionSettings::resolve(lock(&self.inner.settings).settings(), Some(&model));
284 if !settings.enabled || (skip_aborted && message.stop_reason == StopReason::Aborted) {
285 return Ok(false);
286 }
287 let message_model = self.model_for_message(message);
288 let context_window = message_model.as_ref().unwrap_or(&model).context_window;
289 let overflow_attempted = lock(&self.inner.recovery).overflow_recovery_attempted;
290 let check = self.with_session(|session| {
291 let branch = session.branch_path(None);
292 let latest_compaction = branch.iter().rev().find_map(|entry| match entry {
293 FileEntry::Compaction(compaction) => Some(compaction),
294 _ => None,
295 });
296 let compaction_time =
297 latest_compaction.and_then(|compaction| parse_iso(&compaction.meta.timestamp));
298 if compaction_time.is_some_and(|time| message.timestamp <= time) {
299 return CompactionCheck::None;
300 }
301 let projection = build_projection(&branch);
302 let entry_id = entry_id.as_deref();
303 let projected = entry_id.is_none_or(|id| {
304 projection.entries.iter().any(|entry| {
305 entry.source.meta().is_some_and(|meta| meta.id == id)
306 && entry
307 .messages
308 .iter()
309 .any(|message| matches!(message, Message::Assistant(_)))
310 })
311 });
312 let after: Vec<&FileEntry> = entry_id
313 .and_then(|id| {
314 branch
315 .iter()
316 .position(|entry| entry.meta().is_some_and(|meta| meta.id == id))
317 })
318 .map(|index| branch[index + 1..].to_vec())
319 .unwrap_or_default();
320 let post_edit = after
321 .iter()
322 .any(|entry| matches!(entry, FileEntry::ContextEdit(_)));
323 let latest_edit = after.iter().rev().find_map(|entry| match entry {
324 FileEntry::ContextEdit(edit) if Some(edit.target_id.as_str()) == entry_id => {
325 Some(edit)
326 }
327 _ => None,
328 });
329 let retained = entry_id.is_none()
330 || (!after
331 .iter()
332 .any(|entry| matches!(entry, FileEntry::Compaction(_)))
333 && latest_edit.is_none_or(|edit| edit.replacement.is_some()));
334 let usage_matches = projected && !post_edit;
335 let explicit = message.stop_reason == StopReason::Error && is_context_overflow(message, 0);
336 let same_model = message_model.is_some();
337 let overflow = same_model
338 && ((explicit && retained)
339 || (usage_matches && is_context_overflow(message, context_window)));
340 let length = message_model.as_ref().is_some_and(|model| {
341 projected && is_recoverable_length(message, model.max_tokens)
342 });
343 if overflow || length {
344 let will_retry = message.stop_reason != StopReason::Stop;
345 if !will_retry {
346 return CompactionCheck::Overflow { will_retry: false };
347 }
348 if overflow_attempted {
349 return CompactionCheck::OverflowFailed(if overflow {
350 "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.".into()
351 } else {
352 "Truncated response recovery failed after one compact-and-retry attempt.".into()
353 });
354 }
355 return CompactionCheck::Overflow { will_retry: true };
356 }
357 let has_edits = projection
358 .entries
359 .iter()
360 .any(|entry| matches!(entry.source, FileEntry::ContextEdit(_)));
361 let direct = calculate_context_tokens(&message.usage);
362 let tokens = if has_edits {
363 estimate_projected_context_tokens(&projection, &branch).tokens
364 } else if message.stop_reason == StopReason::Error || direct == 0 {
365 let estimate = estimate_context_tokens(&projection.messages);
366 if let Some(index) = estimate.last_usage_index
367 && let Message::Assistant(usage_message) = &projection.messages[index]
368 && compaction_time.is_some_and(|time| usage_message.timestamp <= time)
369 {
370 return CompactionCheck::None;
371 }
372 estimate.tokens
373 } else {
374 direct
375 };
376 if should_compact(tokens, context_window, &settings) {
377 CompactionCheck::Threshold
378 } else {
379 CompactionCheck::None
380 }
381 });
382 Ok(match check {
383 CompactionCheck::None => false,
384 CompactionCheck::Threshold => {
385 self.run_auto_compaction(CompactionReason::Threshold, false, cancel)
386 .await
387 }
388 CompactionCheck::OverflowFailed(error_message) => {
389 self.compaction_failed(CompactionReason::Overflow, Some(error_message), false)
390 .await;
391 false
392 }
393 CompactionCheck::Overflow { will_retry } => {
394 if will_retry {
395 lock(&self.inner.recovery).overflow_recovery_attempted = true;
396 let ids: Vec<String> = entry_id.into_iter().chain(tool_results).collect();
397 self.omit_recovery_attempt(&ids);
398 }
399 self.run_auto_compaction(CompactionReason::Overflow, will_retry, cancel)
400 .await
401 }
402 })
403 }
404
405 pub(super) async fn summarizer<'a>(
408 &'a self,
409 model: &'a Model,
410 thinking_level: ThinkingLevel,
411 source: SummarySource,
412 reason: Option<CompactionReason>,
413 cancel: CancellationToken,
414 ) -> Summarizer<'a> {
415 Summarizer {
416 model,
417 apis: self.apis(),
418 auth: self.registry().auth(model).await,
419 thinking_level,
420 retry: self.retry_policy(),
421 request: self.request_options(),
422 hooks: yapi_ai::stream::RequestHooks {
423 headers: self.headers_hook(&cancel),
424 ..Default::default()
425 },
426 cancel,
427 on_retry: Box::new(move |retry| self.emit_summary_retry(retry, source, reason)),
428 }
429 }
430
431 pub async fn bug_report_summary(
435 &self,
436 hint: Option<&str>,
437 cancel: CancellationToken,
438 ) -> Result<String, String> {
439 let model = self.model().ok_or_else(|| "No model selected".to_owned())?;
440 let mut summarizer = self
441 .summarizer(
442 &model,
443 self.thinking_level(),
444 SummarySource::Compaction,
445 None,
446 cancel,
447 )
448 .await;
449 summarizer.on_retry = Box::new(|_| {});
451 crate::bug_report::summary(&summarizer, &self.messages(), hint).await
452 }
453
454 async fn run_compaction(
460 &self,
461 model: &Model,
462 preparation: &Preparation,
463 custom_instructions: Option<&str>,
464 (reason, will_retry): (CompactionReason, bool),
465 cancel: &CancellationToken,
466 ) -> Compacted {
467 let mut from_extension = false;
468 let mut supplied = None;
469 if self.has_handlers("session_before_compact") {
470 let preparation = serde_json::to_value(preparation).unwrap_or_default();
471 let event = self.with_session(|session| {
472 ExtensionEvent::SessionBeforeCompact {
473 preparation,
474 branch_entries: &session.branch_path(None),
475 custom_instructions,
476 reason,
477 will_retry,
478 }
479 .to_value()
480 });
481 let result = self.emit_extension_event(&event, cancel.clone()).await;
482 if result
483 .as_ref()
484 .is_some_and(|result| result["cancel"] == true)
485 {
486 return Compacted {
487 outcome: Err("Compaction cancelled".into()),
488 from_extension,
489 refused: true,
490 };
491 }
492 supplied = result.and_then(|result| {
493 serde_json::from_value::<CompactionResult>(result["compaction"].clone()).ok()
494 });
495 from_extension = supplied.is_some();
496 }
497 let outcome = async {
498 let mut result = match supplied {
499 Some(result) => result,
500 None => {
501 let summarizer = self
502 .summarizer(
503 model,
504 self.thinking_level(),
505 SummarySource::Compaction,
506 Some(reason),
507 cancel.clone(),
508 )
509 .await;
510 summarizer.compact(preparation, custom_instructions).await?
511 }
512 };
513 if cancel.is_cancelled() {
515 return Err("Compaction cancelled".to_owned());
516 }
517 let (entry, estimate) = self.with_session(|session| {
518 let id = session
519 .append_compaction(
520 result.summary.clone(),
521 Some(result.first_kept_entry_id.clone()),
522 result.tokens_before,
523 result.details.clone(),
524 Some(from_extension),
525 result.usage.clone(),
526 )
527 .ok();
528 let estimate = session
529 .build_context()
530 .messages
531 .iter()
532 .map(estimate_tokens)
533 .sum();
534 (id.and_then(|id| session.entry(&id).cloned()), estimate)
535 });
536 result.estimated_tokens_after = Some(estimate);
537 if let Some(entry) = entry {
538 let event = ExtensionEvent::SessionCompact {
539 compaction_entry: &entry,
540 from_extension,
541 reason,
542 will_retry,
543 };
544 self.emit_extension_event(&event.to_value(), CancellationToken::new())
545 .await;
546 }
547 Ok(result)
548 }
549 .await;
550 Compacted {
551 outcome,
552 from_extension,
553 refused: false,
554 }
555 }
556
557 async fn compaction_failed(
560 &self,
561 reason: CompactionReason,
562 error_message: Option<String>,
563 from_extension: bool,
564 ) {
565 let aborted = error_message.is_none();
566 self.emit_compaction_end(reason, Err(error_message.clone()), false);
567 if !self.has_handlers("session_compact_failed") {
568 return;
569 }
570 let event = ExtensionEvent::SessionCompactFailed {
571 reason,
572 error_message: error_message.as_deref(),
573 aborted,
574 will_retry: false,
575 from_extension,
576 };
577 self.emit_extension_event(&event.to_value(), CancellationToken::new())
578 .await;
579 }
580
581 fn emit_compaction_end(
584 &self,
585 reason: CompactionReason,
586 outcome: Result<CompactionResult, Option<String>>,
587 will_retry: bool,
588 ) {
589 let (result, aborted, error_message) = match outcome {
590 Ok(result) => (Some(result), false, None),
591 Err(None) => (None, true, None),
592 Err(Some(error)) => (None, false, Some(error)),
593 };
594 self.emit(&AgentEvent::CompactionEnd {
595 reason,
596 result,
597 aborted,
598 will_retry,
599 error_message,
600 });
601 }
602
603 fn emit_summary_retry(
605 &self,
606 retry: SummaryRetry,
607 source: SummarySource,
608 reason: Option<CompactionReason>,
609 ) {
610 self.emit(&match retry {
611 SummaryRetry::Scheduled {
612 attempt,
613 max_attempts,
614 delay_ms,
615 error_message,
616 } => AgentEvent::SummarizationRetryScheduled {
617 attempt,
618 max_attempts,
619 delay_ms,
620 error_message,
621 },
622 SummaryRetry::AttemptStart => {
623 AgentEvent::SummarizationRetryAttemptStart { source, reason }
624 }
625 SummaryRetry::Finished => AgentEvent::SummarizationRetryFinished,
626 });
627 }
628
629 async fn run_auto_compaction(
630 &self,
631 reason: CompactionReason,
632 will_retry: bool,
633 cancel: &CancellationToken,
634 ) -> bool {
635 let Some(model) = self.model() else {
636 return false;
637 };
638 let settings =
639 CompactionSettings::resolve(lock(&self.inner.settings).settings(), Some(&model));
640 let Some(preparation) =
641 self.with_session(|session| prepare_compaction(&session.branch_path(None), settings))
642 else {
643 return false;
644 };
645 self.emit(&AgentEvent::CompactionStart { reason });
646 let compacting = Compacting::start(&self.inner.compacting);
647 let compacted = self
648 .run_compaction(&model, &preparation, None, (reason, will_retry), cancel)
649 .await;
650 drop(compacting);
651 self.inner.idle.notify_waiters();
652 match compacted.outcome {
653 Ok(result) => {
654 self.emit_compaction_end(reason, Ok(result), will_retry);
655 will_retry || self.has_queued()
656 }
657 Err(message) => {
658 let aborted = cancel.is_cancelled() || compacted.refused;
659 let error_message = (!aborted).then(|| match reason {
660 CompactionReason::Overflow => {
661 format!("Context overflow recovery failed: {message}")
662 }
663 _ => format!("Auto-compaction failed: {message}"),
664 });
665 self.compaction_failed(reason, error_message, compacted.from_extension)
666 .await;
667 false
668 }
669 }
670 }
671
672 pub async fn compact(
675 &self,
676 custom_instructions: Option<&str>,
677 ) -> Result<CompactionResult, String> {
678 self.abort();
679 self.wait_for_idle().await;
680 let cancel = CancellationToken::new();
681 *lock(&self.inner.cancel) = Some(cancel.clone());
682 let compacting = Compacting::start(&self.inner.compacting);
683 self.inner.manual_compaction.store(true, Ordering::SeqCst);
684 self.emit(&AgentEvent::CompactionStart {
685 reason: CompactionReason::Manual,
686 });
687 let outcome = async {
688 let model = self
689 .model()
690 .ok_or_else(|| crate::auth_guidance::no_model_selected(&self.inner.docs))?;
691 let settings =
692 CompactionSettings::resolve(lock(&self.inner.settings).settings(), Some(&model));
693 let preparation = self.with_session(|session| {
694 let branch = session.branch_path(None);
695 match prepare_compaction(&branch, settings) {
696 Some(preparation) => Ok(preparation),
697 None if matches!(branch.last(), Some(FileEntry::Compaction(_))) => {
698 Err("Already compacted".to_owned())
699 }
700 None => Err("Nothing to compact (session too small)".to_owned()),
701 }
702 })?;
703 Ok(self
704 .run_compaction(
705 &model,
706 &preparation,
707 custom_instructions,
708 (CompactionReason::Manual, false),
709 &cancel,
710 )
711 .await)
712 }
713 .await;
714 let compacted = outcome.unwrap_or_else(|error| Compacted {
715 outcome: Err(error),
716 from_extension: false,
717 refused: false,
718 });
719 *lock(&self.inner.cancel) = None;
720 self.inner.manual_compaction.store(false, Ordering::SeqCst);
721 drop(compacting);
722 self.inner.idle.notify_waiters();
724 let reason = CompactionReason::Manual;
725 match compacted.outcome {
726 Ok(result) => {
727 self.emit_compaction_end(reason, Ok(result.clone()), false);
728 Ok(result)
729 }
730 Err(message) => {
731 let aborted = cancel.is_cancelled() || compacted.refused;
732 let error_message = (!aborted).then(|| format!("Compaction failed: {message}"));
733 self.compaction_failed(reason, error_message, compacted.from_extension)
734 .await;
735 Err(message)
737 }
738 }
739 }
740}
741
742struct Compacted {
745 outcome: Result<CompactionResult, String>,
746 from_extension: bool,
747 refused: bool,
748}