Skip to main content

oino_core/session/
compaction_flow.rs

1//! Manual and automatic compaction plus overflow recovery (`compact`, `_checkCompaction`,
2//! `_runAutoCompaction`, `_compactBeforeNextAssistantResponse`, `_omitRecoveryAttempt`).
3
4use 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
20/// Maps `summarization_retry_*` callbacks to session events.
21pub(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    /// Routes a virtual model, resolves credentials.
98    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    /// Runs `session_before_compact`; `Err(true)` = cancelled.
184    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    /// Appends the compaction and emits `session_compact`; returns the result.
218    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    /// Manual compaction; aborts the current run first, never retries.
256    ///
257    /// # Errors
258    ///
259    /// `Already compacted`, `Nothing to compact (session too small)`, `Compaction cancelled`,
260    /// or the summariser failure.
261    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    /// Enables or disables automatic compaction (settings).
366    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    /// E x c e e d s   C o m p a c t i o n   T h r e s h o l d.
373    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    /// `_compactBeforeNextAssistantResponse`; returns the projected messages.
393    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    /// Entry id of a persisted assistant message (latest match on the branch).
411    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    /// `_checkCompaction`; returns whether the post-run loop should continue.
428    #[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    /// M o d e l   F o r   M e s s a g e.
581    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    /// Returns whether it is retryable error.
593    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    /// Omits the failed attempt and its tool results from context.
605    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    /// `_runAutoCompaction`; returns whether the post-run loop should continue.
644    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    /// The agent's stream function (summaries reuse the provider dispatch).
741    pub(crate) fn agent_stream(&self) -> Arc<dyn oino_agent::StreamFn> {
742        Arc::clone(&self.stream)
743    }
744}