1use std::sync::{Arc, Weak};
5
6use oino_ai::{AgentMessage, AssistantContent, AssistantMessage, Model, StopReason};
7use oino_session::{CompactionDraft, EntryId, EntryKind, SessionEntry, SessionProjection};
8use serde_json::json;
9use tokio_util::sync::CancellationToken;
10
11use super::{AgentSession, Inner, SessionOpError, lock};
12use crate::compaction::{
13 self, CompactionPreparation, CompactionResult, CompactionSettings, RetryPolicy,
14 SummarizationRetryObserver, Summarizer, calculate_context_tokens, estimate_context_tokens,
15 estimate_projected_context_tokens, estimate_tokens, is_context_overflow, is_recoverable_length,
16 prepare_compaction, should_compact,
17};
18use crate::events::{AgentSessionEvent, CompactionReason, SummarizationSource};
19
20pub(crate) struct RetryEvents {
22 pub(crate) inner: Weak<Inner>,
23 pub(crate) source: SummarizationSource,
24}
25
26impl SummarizationRetryObserver for RetryEvents {
27 fn retry_scheduled(&self, attempt: u32, max_attempts: u32, delay_ms: u64, error_message: &str) {
28 if let Some(inner) = self.inner.upgrade() {
29 AgentSession::from_inner(inner).emit(AgentSessionEvent::SummarizationRetryScheduled {
30 attempt,
31 max_attempts,
32 delay_ms,
33 error_message: error_message.to_owned(),
34 });
35 }
36 }
37
38 fn retry_attempt_start(&self) {
39 if let Some(inner) = self.inner.upgrade() {
40 AgentSession::from_inner(inner).emit(
41 AgentSessionEvent::SummarizationRetryAttemptStart {
42 source: self.source,
43 },
44 );
45 }
46 }
47
48 fn retry_finished(&self, _success: bool, _attempt: u32, _final_error: Option<&str>) {
49 if let Some(inner) = self.inner.upgrade() {
50 AgentSession::from_inner(inner).emit(AgentSessionEvent::SummarizationRetryFinished);
51 }
52 }
53}
54
55fn reason_str(reason: CompactionReason) -> &'static str {
56 match reason {
57 CompactionReason::Manual => "manual",
58 CompactionReason::Threshold => "threshold",
59 CompactionReason::Overflow => "overflow",
60 }
61}
62
63fn messages_tokens(messages: &[AgentMessage]) -> u64 {
64 messages.iter().map(estimate_tokens).sum()
65}
66
67impl AgentSession {
68 pub(crate) fn compaction_settings(&self, model: Option<&Model>) -> CompactionSettings {
69 let settings = lock(&self.inner.settings)
70 .settings()
71 .compaction_settings(model.map(|m| (m.provider.as_str(), m.id.as_str())))
72 .unwrap_or_else(|error| {
73 tracing::warn!("invalid compaction settings: {error}");
74 oino_settings::CompactionSettings {
75 enabled: true,
76 reserve_tokens: compaction::DEFAULT_COMPACTION_SETTINGS.reserve_tokens,
77 keep_recent_tokens: compaction::DEFAULT_COMPACTION_SETTINGS.keep_recent_tokens,
78 }
79 });
80 CompactionSettings {
81 enabled: settings.enabled,
82 reserve_tokens: settings.reserve_tokens,
83 keep_recent_tokens: settings.keep_recent_tokens,
84 }
85 }
86
87 pub(crate) fn retry_policy(&self) -> RetryPolicy {
88 let retry = lock(&self.inner.settings).settings().retry_settings();
89 RetryPolicy {
90 enabled: retry.enabled,
91 max_retries: u32::try_from(retry.max_retries).unwrap_or(u32::MAX),
92 base_delay_ms: retry.base_delay_ms,
93 max_agent_delay_ms: Some(retry.max_agent_delay_ms),
94 }
95 }
96
97 pub(crate) async fn summarizer(
99 &self,
100 model: &Model,
101 cancel: CancellationToken,
102 source: SummarizationSource,
103 ) -> Result<Summarizer, String> {
104 let thinking = self.thinking_level();
105 let (model, thinking) = if oino_models::virtual_models::is_virtual_api(&model.api) {
106 let llm = oino_session::convert_to_llm(&self.messages());
107 let route = self
108 .route_virtual(
109 model,
110 llm,
111 oino_models::RouteReason::Direct,
112 thinking,
113 None,
114 &cancel,
115 )
116 .await?;
117 (route.model, route.thinking_level)
118 } else {
119 (model.clone(), thinking)
120 };
121 let auth = self
122 .inner
123 .models
124 .get_api_key_and_headers(&model, &oino_models::AuthOverrides::default())
125 .await
126 .map_err(|e| e.message)?;
127 Ok(Summarizer {
128 stream: self.inner.agent_stream(),
129 model,
130 api_key: auth.api_key,
131 headers: auth
132 .headers
133 .map(|h| h.into_iter().map(|(k, v)| (k, Some(v))).collect()),
134 thinking_level: Some(thinking),
135 session_id: None,
136 retry: Some(self.retry_policy()),
137 observer: Some(Arc::new(RetryEvents {
138 inner: Arc::downgrade(&self.inner),
139 source,
140 })),
141 cancel,
142 })
143 }
144
145 async fn run_default_compaction(
146 &self,
147 preparation: &CompactionPreparation,
148 model: &Model,
149 custom_instructions: Option<&str>,
150 cancel: &CancellationToken,
151 reason: CompactionReason,
152 ) -> Result<CompactionResult, String> {
153 let summarizer = self
154 .summarizer(
155 model,
156 cancel.clone(),
157 SummarizationSource::Compaction { reason },
158 )
159 .await?;
160 compaction::compact(preparation, &summarizer, custom_instructions)
161 .await
162 .map_err(|e| e.to_string())
163 }
164
165 async fn emit_session_compact_failed(
166 &self,
167 reason: CompactionReason,
168 error_message: Option<&str>,
169 aborted: bool,
170 will_retry: bool,
171 from_extension: bool,
172 ) {
173 let runner = self.runner();
174 if runner.has_handlers("session_compact_failed") {
175 let mut payload = json!({"type": "session_compact_failed", "reason": reason_str(reason), "aborted": aborted, "willRetry": will_retry, "fromExtension": from_extension});
176 if let Some(message) = error_message {
177 payload["errorMessage"] = json!(message);
178 }
179 runner.emit(payload).await;
180 }
181 }
182
183 async fn before_compact(
185 &self,
186 preparation: &CompactionPreparation,
187 branch: &[SessionEntry],
188 custom_instructions: Option<&str>,
189 reason: CompactionReason,
190 will_retry: bool,
191 ) -> Result<Option<CompactionResult>, ()> {
192 let runner = self.runner();
193 if !runner.has_handlers("session_before_compact") {
194 return Ok(None);
195 }
196 let mut payload = json!({
197 "type": "session_before_compact",
198 "preparation": preparation,
199 "branchEntries": branch,
200 "reason": reason_str(reason),
201 "willRetry": will_retry,
202 });
203 if let Some(instructions) = custom_instructions {
204 payload["customInstructions"] = json!(instructions);
205 }
206 let Some(result) = runner.emit(payload).await else {
207 return Ok(None);
208 };
209 if result.get("cancel") == Some(&serde_json::Value::Bool(true)) {
210 return Err(());
211 }
212 Ok(result
213 .get("compaction")
214 .and_then(|c| serde_json::from_value(c.clone()).ok()))
215 }
216
217 async fn commit_compaction(
219 &self,
220 result: CompactionResult,
221 from_extension: bool,
222 reason: CompactionReason,
223 will_retry: bool,
224 ) -> Result<CompactionResult, String> {
225 let entry_id = lock(&self.inner.manager)
226 .append_compaction(CompactionDraft {
227 summary: result.summary.clone(),
228 first_kept_entry_id: Some(result.first_kept_entry_id.clone()),
229 tokens_before: result.tokens_before,
230 details: result.details.clone(),
231 from_hook: Some(from_extension),
232 usage: result.usage,
233 system_message: None,
234 })
235 .map_err(|e| e.to_string())?;
236 self.refresh_context();
237 let after = messages_tokens(
238 &lock(&self.inner.manager)
239 .build_session_projection()
240 .messages,
241 );
242 let entry = lock(&self.inner.manager).entry(&entry_id).cloned();
243 let runner = self.runner();
244 if let (Some(entry), true) = (entry, runner.has_handlers("session_compact")) {
245 runner
246 .emit(json!({"type": "session_compact", "compactionEntry": entry, "fromExtension": from_extension, "reason": reason_str(reason), "willRetry": will_retry}))
247 .await;
248 }
249 Ok(CompactionResult {
250 estimated_tokens_after: Some(after),
251 ..result
252 })
253 }
254
255 pub async fn compact(
262 &self,
263 custom_instructions: Option<String>,
264 ) -> Result<CompactionResult, SessionOpError> {
265 self.abort().await;
266 let cancel = CancellationToken::new();
267 lock(&self.inner.cancels).compaction = Some(cancel.clone());
268 self.mark_busy();
269 self.emit(AgentSessionEvent::CompactionStart {
270 reason: CompactionReason::Manual,
271 });
272 let mut from_extension = false;
273 let mut cancelled_by_extension = false;
274 let outcome: Result<CompactionResult, String> = async {
275 let model = self.model().ok_or_else(|| {
276 oino_models::auth::guidance::no_model_selected_message(
277 &self.inner.docs_root.join("docs"),
278 )
279 })?;
280 let settings = self.compaction_settings(Some(&model));
281 let branch: Vec<SessionEntry> = lock(&self.inner.manager)
282 .branch(None)
283 .into_iter()
284 .cloned()
285 .collect();
286 let refs: Vec<&SessionEntry> = branch.iter().collect();
287 let Some(preparation) = prepare_compaction(&refs, &settings) else {
288 if branch
289 .last()
290 .is_some_and(|e| matches!(e.kind, EntryKind::Compaction(_)))
291 {
292 return Err("Already compacted".to_owned());
293 }
294 return Err("Nothing to compact (session too small)".to_owned());
295 };
296 let Ok(supplied) = self
297 .before_compact(
298 &preparation,
299 &branch,
300 custom_instructions.as_deref(),
301 CompactionReason::Manual,
302 false,
303 )
304 .await
305 else {
306 cancelled_by_extension = true;
307 return Err("Compaction cancelled".to_owned());
308 };
309 let result = if let Some(result) = supplied {
310 from_extension = true;
311 result
312 } else {
313 self.run_default_compaction(
314 &preparation,
315 &model,
316 custom_instructions.as_deref(),
317 &cancel,
318 CompactionReason::Manual,
319 )
320 .await?
321 };
322 if cancel.is_cancelled() {
323 return Err("Compaction cancelled".to_owned());
324 }
325 self.commit_compaction(result, from_extension, CompactionReason::Manual, false)
326 .await
327 }
328 .await;
329 lock(&self.inner.cancels).compaction = None;
330 self.set_idle_if_idle();
331 match outcome {
332 Ok(result) => {
333 self.emit(AgentSessionEvent::CompactionEnd {
334 reason: CompactionReason::Manual,
335 result: Some(result.clone()),
336 aborted: false,
337 will_retry: false,
338 error_message: None,
339 });
340 Ok(result)
341 }
342 Err(message) => {
343 let aborted = cancel.is_cancelled() || cancelled_by_extension;
344 let error_message = (!aborted).then(|| format!("Compaction failed: {message}"));
345 self.emit(AgentSessionEvent::CompactionEnd {
346 reason: CompactionReason::Manual,
347 result: None,
348 aborted,
349 will_retry: false,
350 error_message: error_message.clone(),
351 });
352 self.emit_session_compact_failed(
353 CompactionReason::Manual,
354 error_message.as_deref(),
355 aborted,
356 false,
357 from_extension,
358 )
359 .await;
360 Err(SessionOpError::Failed(message))
361 }
362 }
363 }
364
365 pub fn set_auto_compaction_enabled(&self, enabled: bool) {
367 if let Err(error) = self.with_settings(|s| s.set_compaction_enabled(enabled)) {
368 tracing::warn!("saving compaction.enabled failed: {error}");
369 }
370 }
371
372 pub(crate) fn exceeds_compaction_threshold(
374 &self,
375 model: &Model,
376 projection: &SessionProjection,
377 ) -> bool {
378 if model.context_window == 0 {
379 return false;
380 }
381 let tokens = {
382 let manager = lock(&self.inner.manager);
383 estimate_projected_context_tokens(projection, &manager.branch(None)).tokens
384 };
385 should_compact(
386 tokens,
387 model.context_window,
388 &self.compaction_settings(self.model().as_ref()),
389 )
390 }
391
392 pub(crate) async fn compact_before_next_assistant_response(&self) -> Vec<AgentMessage> {
394 let projection = lock(&self.inner.manager).build_session_projection();
395 let Some(model) = self.model() else {
396 return projection.messages;
397 };
398 if oino_models::virtual_models::is_virtual_api(&model.api)
399 || !self.exceeds_compaction_threshold(&model, &projection)
400 {
401 return projection.messages;
402 }
403 self.run_auto_compaction(CompactionReason::Threshold, false)
404 .await;
405 lock(&self.inner.manager)
406 .build_session_projection()
407 .messages
408 }
409
410 fn assistant_entry_id(&self, message: &AssistantMessage) -> Option<EntryId> {
412 let manager = lock(&self.inner.manager);
413 manager
414 .branch(None)
415 .into_iter()
416 .rev()
417 .find_map(|entry| match entry.message() {
418 Some(AgentMessage::Assistant(a))
419 if a.timestamp == message.timestamp && a.model == message.model =>
420 {
421 Some(entry.id.clone())
422 }
423 _ => None,
424 })
425 }
426
427 #[allow(clippy::too_many_lines, reason = "one-to-one port of _checkCompaction")]
429 pub(crate) async fn check_compaction(
430 &self,
431 assistant: &AssistantMessage,
432 skip_aborted: bool,
433 ) -> bool {
434 let model = self.model();
435 let settings = self.compaction_settings(model.as_ref());
436 if !settings.enabled || (skip_aborted && assistant.stop_reason == StopReason::Aborted) {
437 return false;
438 }
439 let message_model = self.model_for_message(assistant);
440 let same_model = message_model.is_some();
441 let context_window = message_model
442 .as_ref()
443 .or(model.as_ref())
444 .map_or(0, |m| m.context_window);
445 let (branch, projection) = {
446 let manager = lock(&self.inner.manager);
447 (
448 manager
449 .branch(None)
450 .into_iter()
451 .cloned()
452 .collect::<Vec<SessionEntry>>(),
453 manager.build_session_projection(),
454 )
455 };
456 let latest_compaction = branch
457 .iter()
458 .rev()
459 .find(|e| matches!(e.kind, EntryKind::Compaction(_)));
460 if let Some(compaction) = latest_compaction
461 && compaction
462 .timestamp
463 .to_ms()
464 .is_some_and(|ms| assistant.timestamp <= ms)
465 {
466 return false;
467 }
468 let entry_id = self.assistant_entry_id(assistant);
469 let projected = entry_id.as_ref().is_none_or(|id| {
470 projection.entries.iter().any(|e| {
471 &e.source_entry.id == id
472 && e.messages
473 .iter()
474 .any(|m| matches!(m, AgentMessage::Assistant(_)))
475 })
476 });
477 let index = entry_id
478 .as_ref()
479 .and_then(|id| branch.iter().position(|e| &e.id == id));
480 let after: &[SessionEntry] = index.map_or(&[], |i| &branch[i + 1..]);
481 let post_edit = after
482 .iter()
483 .any(|e| matches!(e.kind, EntryKind::ContextEdit { .. }));
484 let latest_edit = after.iter().rev().find_map(|e| match &e.kind {
485 EntryKind::ContextEdit {
486 target_id,
487 replacement,
488 } if Some(target_id) == entry_id.as_ref() => Some(replacement),
489 _ => None,
490 });
491 let retained = entry_id.is_none()
492 || (!after
493 .iter()
494 .any(|e| matches!(e.kind, EntryKind::Compaction(_)))
495 && latest_edit.is_none_or(Option::is_some));
496 let usage_matches = projected && !post_edit;
497 let explicit =
498 assistant.stop_reason == StopReason::Error && is_context_overflow(assistant, 0);
499 let overflow = same_model
500 && ((explicit && retained)
501 || (usage_matches && is_context_overflow(assistant, context_window)));
502 let recoverable = same_model
503 && projected
504 && message_model
505 .as_ref()
506 .is_some_and(|m| is_recoverable_length(assistant, m.max_tokens));
507 if overflow || recoverable {
508 let will_retry = assistant.stop_reason != StopReason::Stop;
509 if !will_retry {
510 return self
511 .run_auto_compaction(CompactionReason::Overflow, false)
512 .await;
513 }
514 if lock(&self.inner.book).overflow_recovery_attempted {
515 let error_message = if overflow {
516 "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model."
517 } else {
518 "Truncated response recovery failed after one compact-and-retry attempt."
519 };
520 self.emit(AgentSessionEvent::CompactionEnd {
521 reason: CompactionReason::Overflow,
522 result: None,
523 aborted: false,
524 will_retry: false,
525 error_message: Some(error_message.to_owned()),
526 });
527 self.emit_session_compact_failed(
528 CompactionReason::Overflow,
529 Some(error_message),
530 false,
531 false,
532 false,
533 )
534 .await;
535 return false;
536 }
537 lock(&self.inner.book).overflow_recovery_attempted = true;
538 self.omit_recovery_attempt(assistant);
539 let retry = self
540 .run_auto_compaction(CompactionReason::Overflow, will_retry)
541 .await;
542 if retry {
543 lock(&self.inner.book).failed_response = Some(assistant.clone());
544 }
545 return retry;
546 }
547 let has_edits = projection
548 .entries
549 .iter()
550 .any(|e| matches!(e.source_entry.kind, EntryKind::ContextEdit { .. }));
551 let direct = calculate_context_tokens(&assistant.usage);
552 let context_tokens = if has_edits {
553 let refs: Vec<&SessionEntry> = branch.iter().collect();
554 estimate_projected_context_tokens(&projection, &refs).tokens
555 } else if assistant.stop_reason == StopReason::Error || direct == 0 {
556 let messages = self.messages();
557 let estimate = estimate_context_tokens(&messages);
558 if let (Some(usage_index), Some(compaction)) =
559 (estimate.last_usage_index, latest_compaction)
560 && let Some(AgentMessage::Assistant(usage_message)) = messages.get(usage_index)
561 && compaction
562 .timestamp
563 .to_ms()
564 .is_some_and(|ms| usage_message.timestamp <= ms)
565 {
566 return false;
567 }
568 estimate.tokens
569 } else {
570 direct
571 };
572 if should_compact(context_tokens, context_window, &settings) {
573 return self
574 .run_auto_compaction(CompactionReason::Threshold, false)
575 .await;
576 }
577 false
578 }
579
580 fn model_for_message(&self, message: &AssistantMessage) -> Option<Model> {
582 let model = self.model()?;
583 if oino_models::virtual_models::is_virtual_api(&model.api) {
584 return self
585 .inner
586 .models
587 .find_physical(&message.provider, &message.model);
588 }
589 (model.provider == message.provider && model.id == message.model).then_some(model)
590 }
591
592 pub(crate) fn is_retryable_error(&self, message: &AssistantMessage) -> bool {
594 let window = self
595 .model_for_message(message)
596 .or_else(|| self.model())
597 .map_or(0, |m| m.context_window);
598 if is_context_overflow(message, window) {
599 return false;
600 }
601 compaction::is_retryable_assistant_error(message)
602 }
603
604 pub(crate) fn omit_recovery_attempt(&self, message: &AssistantMessage) {
606 let call_ids: Vec<&str> = message
607 .content
608 .iter()
609 .filter_map(|block| match block {
610 AssistantContent::ToolCall(call) => Some(call.id.as_str()),
611 _ => None,
612 })
613 .collect();
614 let mut targets = Vec::new();
615 {
616 let manager = lock(&self.inner.manager);
617 let branch = manager.branch(None);
618 if let Some(index) = branch.iter().rposition(|entry| {
619 matches!(entry.message(), Some(AgentMessage::Assistant(a)) if a.timestamp == message.timestamp && a.model == message.model)
620 }) {
621 targets.push(branch[index].id.clone());
622 for entry in &branch[index + 1..] {
623 match entry.message() {
624 Some(AgentMessage::Assistant(_)) => break,
625 Some(AgentMessage::ToolResult(result)) if call_ids.contains(&result.tool_call_id.as_str()) => {
626 targets.push(entry.id.clone());
627 }
628 _ => {}
629 }
630 }
631 }
632 }
633 for target in targets {
634 let appended = lock(&self.inner.manager).append_context_edit(&target, None);
635 match appended {
636 Ok(id) => self.publish_entry(&id),
637 Err(error) => tracing::error!("omitting recovery attempt failed: {error}"),
638 }
639 }
640 self.refresh_context();
641 }
642
643 pub(crate) async fn run_auto_compaction(
645 &self,
646 reason: CompactionReason,
647 will_retry: bool,
648 ) -> bool {
649 let Some(model) = self.model() else {
650 return false;
651 };
652 let settings = self.compaction_settings(Some(&model));
653 let branch: Vec<SessionEntry> = lock(&self.inner.manager)
654 .branch(None)
655 .into_iter()
656 .cloned()
657 .collect();
658 let refs: Vec<&SessionEntry> = branch.iter().collect();
659 let Some(preparation) = prepare_compaction(&refs, &settings) else {
660 return false;
661 };
662 let cancel = CancellationToken::new();
663 lock(&self.inner.cancels).auto_compaction = Some(cancel.clone());
664 self.mark_busy();
665 self.emit(AgentSessionEvent::CompactionStart { reason });
666 let mut from_extension = false;
667 let mut cancelled_by_extension = false;
668 let outcome: Result<CompactionResult, String> = async {
669 if cancel.is_cancelled() {
670 return Err("aborted".to_owned());
671 }
672 let Ok(supplied) = self
673 .before_compact(&preparation, &branch, None, reason, will_retry)
674 .await
675 else {
676 cancelled_by_extension = true;
677 return Err("Compaction cancelled".to_owned());
678 };
679 if cancel.is_cancelled() {
680 return Err("aborted".to_owned());
681 }
682 let result = if let Some(result) = supplied {
683 from_extension = true;
684 result
685 } else {
686 self.run_default_compaction(&preparation, &model, None, &cancel, reason)
687 .await?
688 };
689 if cancel.is_cancelled() {
690 return Err("aborted".to_owned());
691 }
692 self.commit_compaction(result, from_extension, reason, will_retry)
693 .await
694 }
695 .await;
696 lock(&self.inner.cancels).auto_compaction = None;
697 let resume = match outcome {
698 Ok(result) => {
699 self.emit(AgentSessionEvent::CompactionEnd {
700 reason,
701 result: Some(result),
702 aborted: false,
703 will_retry,
704 error_message: None,
705 });
706 will_retry || self.inner.agent.has_queued_messages()
707 }
708 Err(message) => {
709 let aborted = cancel.is_cancelled() || cancelled_by_extension;
710 let error_message = (!aborted).then(|| match reason {
711 CompactionReason::Overflow => {
712 format!("Context overflow recovery failed: {message}")
713 }
714 _ => format!("Auto-compaction failed: {message}"),
715 });
716 self.emit(AgentSessionEvent::CompactionEnd {
717 reason,
718 result: None,
719 aborted,
720 will_retry: false,
721 error_message: error_message.clone(),
722 });
723 self.emit_session_compact_failed(
724 reason,
725 error_message.as_deref(),
726 aborted,
727 false,
728 from_extension,
729 )
730 .await;
731 false
732 }
733 };
734 self.set_idle_if_idle();
735 resume
736 }
737}
738
739impl Inner {
740 pub(crate) fn agent_stream(&self) -> Arc<dyn oino_agent::StreamFn> {
742 Arc::clone(&self.stream)
743 }
744}