Skip to main content

cookie_agent_engine/runtime/
compaction.rs

1use std::{
2    collections::{HashMap, HashSet, VecDeque},
3    io,
4    sync::{Arc, Mutex},
5};
6
7use cookie_agent_config::ContextCompactionTrigger;
8use cookie_agent_models::adapters::with_native_compaction_instructions;
9use cookie_agent_protocol::{
10    ContextCheckpoint, ContextCheckpointBoundaries, ContextCheckpointBudgets,
11    ContextCheckpointCommit, ExtensionSessionBeforeCompactParams, InternalAgentKind,
12    InternalSummaryCheckpoint, PersistedToolResult, PluginDiagnosticKind, RunId,
13    SessionCompactResult, SessionId, SessionStatus, StoredEvent, SummaryByteLimit,
14    ToolEmittedContent,
15};
16use oven_sdk::{
17    CompactionCapability, CompactionRequest, ModelError, Request as ModelRequest, ToolDefinition,
18};
19use std::sync::atomic::Ordering;
20use tokio_util::sync::CancellationToken;
21
22use super::titles::active_fallback_index;
23use super::{
24    Engine, EngineError, Event, FrozenInternalAgentPolicy, InternalAgentExecution,
25    InternalAgentHistoryInput, SessionCommand, internal_agents::internal_agent_output_limit,
26};
27use crate::{
28    model_bridge::AbortBridge,
29    model_history::{self, assemble_model_context},
30    policy::{self, FrozenRunPolicy},
31};
32
33#[derive(Clone, Copy, Debug, Default)]
34pub(crate) struct ContextTokenEstimator {
35    pub(crate) tokens_per_byte: f64,
36    pub(crate) last_committed_input_tokens: u64,
37}
38
39pub(crate) struct PredictiveCompactionInput<'a> {
40    pub(crate) session: SessionId,
41    pub(crate) run: RunId,
42    pub(crate) serialized_message_bytes: usize,
43    pub(crate) policy: &'a FrozenRunPolicy,
44    pub(crate) fallback_index: usize,
45    pub(crate) cancellation: &'a CancellationToken,
46    pub(crate) actor_direct: bool,
47}
48
49#[derive(Clone, Copy, Eq, PartialEq)]
50pub(crate) enum CompactionDeferredKind {
51    Start,
52    PromotePendingInputs,
53    PromotePendingOrComplete,
54    Resume,
55}
56
57impl ContextTokenEstimator {
58    pub(crate) fn record_committed_turn(
59        &mut self,
60        serialized_context_bytes: usize,
61        input_tokens: Option<u64>,
62    ) {
63        self.last_committed_input_tokens = input_tokens.unwrap_or(0);
64        if serialized_context_bytes > 0
65            && let Some(input_tokens) = input_tokens.filter(|tokens| *tokens > 0)
66        {
67            self.tokens_per_byte = input_tokens as f64 / serialized_context_bytes as f64;
68        }
69    }
70
71    pub(crate) fn projected_tokens(self, serialized_message_bytes: usize) -> Option<u64> {
72        (self.tokens_per_byte > 0.0).then(|| {
73            self.last_committed_input_tokens
74                .saturating_add((serialized_message_bytes as f64 * self.tokens_per_byte) as u64)
75        })
76    }
77
78    pub(crate) fn estimated_context_tokens(self, serialized_context_bytes: usize) -> Option<u64> {
79        (self.tokens_per_byte > 0.0)
80            .then(|| (serialized_context_bytes as f64 * self.tokens_per_byte).ceil() as u64)
81    }
82
83    pub(crate) fn should_compact(self, serialized_message_bytes: usize, soft_tokens: u64) -> bool {
84        self.projected_tokens(serialized_message_bytes)
85            .is_some_and(|projected| projected >= soft_tokens)
86    }
87
88    pub(crate) fn record_compaction(&mut self, estimated_input_tokens: u64) {
89        self.last_committed_input_tokens = estimated_input_tokens;
90    }
91}
92
93pub(crate) fn should_run_predictive_compaction(
94    estimator: ContextTokenEstimator,
95    serialized_message_bytes: usize,
96    soft_tokens: u64,
97    session_persisted: bool,
98) -> bool {
99    session_persisted && estimator.should_compact(serialized_message_bytes, soft_tokens)
100}
101
102/// Compaction runtime state owned by [`super::Inner`].
103#[derive(Default)]
104pub(crate) struct CompactionState {
105    pub(crate) in_progress: Mutex<HashSet<SessionId>>,
106    pub(super) deferred: Mutex<HashMap<SessionId, VecDeque<SessionCommand>>>,
107    pub(crate) context_token_estimators: Mutex<HashMap<SessionId, ContextTokenEstimator>>,
108}
109
110pub(crate) const COMPACTION_INSTRUCTION: &str = "Create a detailed technical summary of the conversation so work can continue without the earlier context. Include: the goal/objective; decisions and their rationale; files changed and current code state; commands run and their outcomes; errors encountered and fixes applied; and the pending next step. Preserve exact identifiers, paths, constraints, and unresolved questions. Return summary text only and do not call tools.";
111pub(super) const TOOL_OUTPUT_ELISION_MIN_BYTES: usize = 8 * 1024;
112// Frozen policies normally provide this value. Keep substantial headroom when replaying an
113// unavailable or legacy policy whose output limit is zero.
114pub(super) const DEFAULT_COMPACTION_OUTPUT_RESERVE_TOKENS: u64 = 20_000;
115const MAX_AUTO_COMPACTION_FAILURES: u8 = 3;
116// Video is frame-sampled and can cost tens of thousands of tokens independent of file bytes.
117const VIDEO_FILE_FIT_SURROGATE_BYTES: usize = 40_000 * 4;
118
119pub(super) struct CompactionInput<'a> {
120    pub(super) session: SessionId,
121    pub(super) run: RunId,
122    pub(super) cancellation: &'a CancellationToken,
123    pub(super) binding: &'a cookie_agent_protocol::FrozenModelBinding,
124    pub(super) owner_policy: &'a FrozenRunPolicy,
125    pub(super) internal_policy: &'a FrozenInternalAgentPolicy,
126    pub(super) tools: &'a [ToolDefinition],
127    pub(super) events: Arc<[StoredEvent]>,
128    pub(super) force: bool,
129    /// Predictive compaction has already made its own trigger decision.
130    pub(super) skip_usage_trigger: bool,
131    pub(super) overflow_recovery: bool,
132    pub(super) focus: Option<&'a str>,
133    pub(super) actor_direct: bool,
134    pub(super) origin: cookie_agent_protocol::EventOrigin,
135}
136
137impl Engine {
138    pub async fn compact_session(
139        &self,
140        session: SessionId,
141        focus: Option<&str>,
142        origin: cookie_agent_protocol::EventOrigin,
143    ) -> Result<bool, EngineError> {
144        self.compact_session_result(session, focus, origin)
145            .await
146            .map(|result| result.compacted)
147    }
148
149    pub async fn compact_session_result(
150        &self,
151        session: SessionId,
152        focus: Option<&str>,
153        origin: cookie_agent_protocol::EventOrigin,
154    ) -> Result<SessionCompactResult, EngineError> {
155        let focus = focus.map(str::to_owned);
156        self.request(session, |reply| SessionCommand::Compact {
157            focus,
158            origin,
159            reply,
160        })
161        .await
162    }
163
164    pub(super) async fn compact_session_direct(
165        &self,
166        session: SessionId,
167        focus: Option<&str>,
168        origin: cookie_agent_protocol::EventOrigin,
169    ) -> Result<SessionCompactResult, EngineError> {
170        let projection = self.inner.store.get(session)?;
171        if projection.status == SessionStatus::Running {
172            return Err(EngineError::SessionRunning(session));
173        }
174        let events = projection.log.event_snapshot();
175        let run = projection
176            .log
177            .last_run_started()
178            .map(|(_, run, _)| run)
179            .ok_or(EngineError::NoRunnableModel)?;
180        let policy = self.historical_title_policy(&events, run)?;
181        let binding = active_compaction_binding(&policy, &events, run)?;
182        let internal_policy = self.internal_agent_policy(
183            InternalAgentKind::ContextCompaction,
184            &policy,
185            Some(binding),
186        )?;
187        let tools = self.tool_definitions(session, &policy)?;
188        let before = projection.log.latest_checkpoint_seq();
189        match self
190            .maybe_compact_context(CompactionInput {
191                session,
192                run,
193                cancellation: &CancellationToken::new(),
194                binding,
195                owner_policy: &policy,
196                internal_policy: &internal_policy,
197                tools: &tools,
198                events,
199                force: true,
200                skip_usage_trigger: false,
201                overflow_recovery: false,
202                focus,
203                actor_direct: false,
204                origin,
205            })
206            .await
207        {
208            Ok(_) => {}
209            Err(EngineError::CompactionCancelled(reason)) => {
210                return Ok(SessionCompactResult {
211                    compacted: false,
212                    cancellation_reason: Some(reason),
213                });
214            }
215            Err(error) => return Err(error),
216        }
217        Ok(SessionCompactResult {
218            compacted: projection.log.latest_checkpoint_seq() > before,
219            cancellation_reason: None,
220        })
221    }
222
223    pub(super) async fn maybe_compact_context(
224        &self,
225        mut input: CompactionInput<'_>,
226    ) -> Result<Arc<[StoredEvent]>, EngineError> {
227        let Some(context_limit) = input.binding.descriptor.capabilities.limits.context else {
228            return Ok(input.events);
229        };
230        let config = &self.inner.config.runtime.context_compaction;
231        let trigger_tokens = resolve_compaction_trigger(context_limit, &config.trigger);
232        if !compaction_gate(input.force, config.auto_compaction, trigger_tokens) {
233            return Ok(input.events);
234        }
235        let active_run = self
236            .inner
237            .sessions
238            .active
239            .lock()
240            .ok()
241            .and_then(|runs| runs.get(&input.run).cloned());
242        if !input.force
243            && active_run.as_ref().is_some_and(|run| {
244                run.auto_compaction_failures.load(Ordering::Relaxed) >= MAX_AUTO_COMPACTION_FAILURES
245            })
246        {
247            return Ok(input.events);
248        }
249        let projection = self.inner.store.get(input.session)?;
250        if !input.force && !input.skip_usage_trigger {
251            let log = &projection.log;
252            let Some((usage_seq, observed_tokens)) = log.latest_real_usage() else {
253                return Ok(input.events);
254            };
255            let last_checkpoint_seq = log.latest_checkpoint_seq();
256            if usage_seq < last_checkpoint_seq {
257                return Ok(input.events);
258            }
259            if !usage_reaches_compaction_trigger(observed_tokens, trigger_tokens) {
260                return Ok(input.events);
261            }
262        }
263
264        let requested_input_through_seq = input.events.last().map_or(0, |event| event.seq);
265        let current_events = projection.log.event_snapshot();
266        if projection
267            .log
268            .checkpoint_covers_input(requested_input_through_seq)
269        {
270            return Ok(current_events);
271        }
272
273        let has_producer_input =
274            crate::goal_projection::GoalProducerProjection::from_events(&input.events)
275                .messages
276                .iter()
277                .any(|message| {
278                    !message.consumed
279                        && !message.discarded
280                        && message.admission.is_some_and(|(run, _)| run == input.run)
281                });
282        let producer_claim = if !has_producer_input {
283            None
284        } else if input.actor_direct {
285            Some(self.claim_producer_snapshot_direct(input.session, input.run)?)
286        } else {
287            Some(
288                self.claim_existing_producer_inputs(input.session, input.run)
289                    .await?,
290            )
291        };
292        if let Some(claim) = &producer_claim {
293            input.events = Arc::clone(&claim.events);
294        }
295
296        let mut compaction_focus = input.focus.map(str::to_owned);
297        let mut additions = Vec::new();
298        let context_id = crate::plugin::plugin_context_id();
299        for plugin in self.inner.plugins.interception_plugins(
300            cookie_agent_protocol::ExtensionInterceptionHook::SessionBeforeCompact,
301        ) {
302            let result = self
303                .inner
304                .plugins
305                .intercept_named::<_, cookie_agent_protocol::ExtensionSessionBeforeCompactResult>(
306                    &plugin,
307                    cookie_agent_protocol::PLUGIN_INTERCEPT_SESSION_BEFORE_COMPACT_METHOD,
308                    &ExtensionSessionBeforeCompactParams {
309                        session_id: input.session,
310                        context_id: context_id.clone(),
311                        checkpoint_id: format!("{}:{requested_input_through_seq}", input.session),
312                        additions: additions.clone(),
313                        instructions: compaction_focus.clone(),
314                    },
315                    Some(input.session),
316                    Some(&context_id),
317                )
318                .await;
319            match result {
320                Ok(result) => {
321                    if result.cancel {
322                        let reason = result
323                            .reason
324                            .unwrap_or_else(|| "compaction cancelled by plugin".into());
325                        self.record_plugin_diagnostic(
326                            input.session,
327                            plugin,
328                            PluginDiagnosticKind::HookBlocked,
329                            reason.clone(),
330                        );
331                        return Err(EngineError::CompactionCancelled(reason));
332                    }
333                    if let Some(instructions) = result.instructions_override {
334                        if instructions.len() > 64 * 1024 {
335                            self.record_plugin_diagnostic(
336                                input.session,
337                                plugin.clone(),
338                                PluginDiagnosticKind::InvalidModification,
339                                "plugin compaction instruction override exceeds the 64 KiB limit"
340                                    .into(),
341                            );
342                        } else {
343                            compaction_focus = Some(instructions);
344                        }
345                    }
346                    if let Some(addendum) = result.addendum.filter(|value| !value.is_empty()) {
347                        let addition_bytes = additions.iter().map(String::len).sum::<usize>();
348                        if addition_bytes.saturating_add(addendum.len()) > 64 * 1024 {
349                            self.record_plugin_diagnostic(
350                                input.session,
351                                plugin,
352                                PluginDiagnosticKind::InvalidModification,
353                                "plugin compaction additions exceed the 64 KiB limit".into(),
354                            );
355                            continue;
356                        }
357                        let focus = compaction_focus.get_or_insert_with(String::new);
358                        if !focus.is_empty() {
359                            focus.push('\n');
360                        }
361                        focus.push_str(&addendum);
362                        additions.push(addendum);
363                    }
364                }
365                Err(error) => {
366                    let kind = if error.contains("crashed") || error.contains("not connected") {
367                        PluginDiagnosticKind::InterceptionCrash
368                    } else {
369                        PluginDiagnosticKind::InterceptionTimeout
370                    };
371                    self.record_plugin_diagnostic(input.session, plugin, kind, error);
372                }
373            }
374        }
375
376        let composed_prompt = self.run_agent_prompt(input.session, input.run)?;
377        let mut events = input.events.to_vec();
378        let mut context = assemble_model_context(
379            &events,
380            &self.inner.artifacts,
381            input.binding,
382            &composed_prompt,
383        )?;
384        // Native compaction keeps its existing durable elision policy. Summary compaction
385        // checks the actual internal-agent request and only prunes a private retry snapshot.
386        let raw_fits =
387            if input.binding.descriptor.capabilities.compaction != CompactionCapability::Native {
388                true
389            } else if let Some(raw_fits) = raw_fit_from_real_usage(
390                input.overflow_recovery,
391                projection
392                    .log
393                    .latest_real_usage()
394                    .map(|(_, observed_tokens)| observed_tokens),
395                |tokens| compaction_input_fits(input.binding, input.internal_policy, tokens),
396            ) {
397                raw_fits
398            } else {
399                let raw_fit_tokens =
400                    self.estimated_request_tokens(input.session, &context.history, input.tools)?;
401                compaction_input_fits(input.binding, input.internal_policy, raw_fit_tokens)
402            };
403        let context_tokens_before = if raw_fits {
404            self.estimated_request_tokens(input.session, &context.history, input.tools)?
405        } else {
406            events = self
407                .stage_tool_output_elision(
408                    input.session,
409                    events,
410                    input.actor_direct,
411                    input.origin.clone(),
412                )
413                .await?;
414            context = assemble_model_context(
415                &events,
416                &self.inner.artifacts,
417                input.binding,
418                &composed_prompt,
419            )?;
420            self.estimated_request_tokens(input.session, &context.history, input.tools)?
421        };
422        if !input.force && context_tokens_before < trigger_tokens {
423            return Ok(Arc::from(events));
424        }
425
426        let input_through_seq = events.last().map_or(0, |event| event.seq);
427        let previous = projection.log.latest_checkpoint_seq();
428        let mut source_from_seq = if previous == 0 {
429            1
430        } else {
431            previous.saturating_add(1)
432        };
433        if let Some(recent_from) = events
434            .iter()
435            .rev()
436            .find_map(|event| match &event.payload {
437                Event::ContextCheckpointCommitted { commit } => {
438                    Some(commit.boundaries.recent_from_seq)
439                }
440                _ => None,
441            })
442            .flatten()
443        {
444            source_from_seq = source_from_seq.min(recent_from);
445        }
446        let mut boundaries = ContextCheckpointBoundaries {
447            source_from_seq,
448            source_through_seq: input_through_seq,
449            input_through_seq,
450            prior_checkpoint_seq: (previous > 0).then_some(previous),
451            recent_from_seq: None,
452        };
453        let summary_limit = SummaryByteLimit::new(config.max_summary_bytes as u64)
454            .map_err(|error| EngineError::from(ModelError::invalid_request(error.to_string())))?;
455        let native_checkpoint = if input.binding.descriptor.capabilities.compaction
456            == CompactionCapability::Native
457        {
458            if let Ok(model) = policy::resolve_model(input.binding, &input.owner_policy.runtime) {
459                let mut request = ModelRequest::new(context.history.clone())
460                    .with_tools(input.tools.to_vec())
461                    .with_header_context(self.model_header_context(input.session)?);
462                crate::media::validate_media_part_counts(
463                    &request.history,
464                    &input
465                        .owner_policy
466                        .model_capabilities(input.binding)
467                        .ok_or(EngineError::NoRunnableModel)?,
468                )
469                .map_err(ModelError::invalid_request)?;
470                if let Some(native_context) = context.native_context.clone() {
471                    request = request.with_native_context(native_context);
472                }
473                let cache_strategy = input
474                    .owner_policy
475                    .cache_strategy(input.binding, input.session);
476                let request =
477                    model.prepare_request_with_cache_strategy(request, cache_strategy.as_ref());
478                let compact_request = with_native_compaction_instructions(
479                    CompactionRequest::new(request),
480                    input.binding.descriptor.adapter_id.as_str(),
481                    compaction_focus.clone(),
482                );
483                if model.model().supports_compaction(&compact_request) {
484                    let abort = AbortBridge::new(input.cancellation.child_token());
485                    match model.model().compact(compact_request, abort.signal()).await {
486                        Ok(result) => model_history::persist_native_context(
487                            result.native_context,
488                            input.binding,
489                        )
490                        .ok(),
491                        Err(_) => None,
492                    }
493                } else {
494                    None
495                }
496            } else {
497                None
498            }
499        } else {
500            None
501        };
502        let checkpoint_prefix =
503            model_history::checkpoint_retained_history(&context.history, &events, None);
504        if let Some(window) = native_checkpoint {
505            let input_tokens_after = estimated_request_tokens(&checkpoint_prefix, input.tools)?;
506            let budgets = ContextCheckpointBudgets {
507                context_limit_tokens: context_limit,
508                trigger_tokens: trigger_tokens.max(1).min(context_limit),
509                input_tokens_before: context_tokens_before,
510                input_tokens_after,
511                max_summary_bytes: summary_limit,
512                keep_recent_tokens: 0,
513            };
514            let commit = ContextCheckpointCommit {
515                checkpoint: ContextCheckpoint::NativeWindow { window },
516                boundaries: boundaries.clone(),
517                budgets,
518            };
519            if commit.validate_for_binding(input.binding).is_ok() {
520                self.append_compaction_event(
521                    input.session,
522                    Some(input.run),
523                    Event::ContextCheckpointCommitted { commit },
524                    input.actor_direct,
525                    input.origin.clone(),
526                )
527                .await?;
528                return self.finalize_context_checkpoint(input.session, input_tokens_after);
529            }
530        }
531
532        // Reserve an output-sized summary before selecting a suffix. The reserve is the
533        // compaction agent's own effective output cap, which it inherits from the owner run
534        // when its document declares none. The final fit check uses the actual replay
535        // projection and the same calibrated estimator as the input.
536        let output_reserve = internal_agent_output_limit(input.binding, input.internal_policy)
537            .unwrap_or(DEFAULT_COMPACTION_OUTPUT_RESERVE_TOKENS);
538        let retained_limit = context_limit
539            .saturating_sub(output_reserve)
540            .min(if trigger_tokens > 0 {
541                trigger_tokens - 1
542            } else {
543                context_limit
544            })
545            .min(context_tokens_before.saturating_sub(1));
546        let summary_output_limit = input
547            .internal_policy
548            .models
549            .iter()
550            .filter_map(|binding| internal_agent_output_limit(binding, input.internal_policy))
551            .max()
552            .unwrap_or(DEFAULT_COMPACTION_OUTPUT_RESERVE_TOKENS);
553        let summary_reserve_bytes = summary_output_limit
554            .saturating_mul(4)
555            .min(config.max_summary_bytes as u64) as usize;
556        let summary_reserve = "x".repeat(summary_reserve_bytes);
557        let base = model_history::project_summary_context(
558            &events,
559            &self.inner.artifacts,
560            input.binding,
561            &composed_prompt,
562            input_through_seq,
563            None,
564            &summary_reserve,
565        )?;
566        let base_tokens =
567            self.estimated_request_tokens(input.session, &base.history, input.tools)?;
568        let keep_recent_tokens = effective_recent_budget(
569            config.keep_recent_tokens,
570            context_limit,
571            retained_limit.saturating_sub(base_tokens),
572        );
573        let recent_from_seq = select_recent_tail(
574            model_history::compaction_tail_candidates(&events),
575            keep_recent_tokens,
576            base_tokens,
577            retained_limit,
578            |candidate| {
579                let projected = model_history::project_summary_context(
580                    &events,
581                    &self.inner.artifacts,
582                    input.binding,
583                    &composed_prompt,
584                    input_through_seq,
585                    Some(candidate),
586                    &summary_reserve,
587                )?;
588                self.estimated_request_tokens(input.session, &projected.history, input.tools)
589            },
590        )?;
591        let prefix = model_history::compaction_prefix_history(
592            &events,
593            &self.inner.artifacts,
594            input.binding,
595            &composed_prompt,
596            recent_from_seq,
597        )?;
598        let prior_summary_count = usize::from(
599            events
600                .iter()
601                .rev()
602                .find_map(|event| match &event.payload {
603                    Event::ContextCheckpointCommitted { commit } => Some(matches!(
604                        commit.checkpoint,
605                        ContextCheckpoint::InternalSummary { .. }
606                    )),
607                    _ => None,
608                })
609                .unwrap_or(false),
610        );
611        if prefix.len() <= checkpoint_prefix.len().saturating_add(prior_summary_count) {
612            return Ok(Arc::from(events));
613        }
614        let (history, instruction) = compaction_history(
615            context.history,
616            compaction_focus.as_deref(),
617            &input.internal_policy.agent.composed_prompt,
618        );
619        let mut summary = self
620            .run_internal_history_agent(
621                input.session,
622                Some(input.run),
623                InternalAgentKind::ContextCompaction,
624                input.internal_policy,
625                InternalAgentHistoryInput {
626                    history,
627                    summary_source: instruction,
628                    // First trial keeps the session tool definitions so the
629                    // request stays a cache-friendly extension of the latest
630                    // conversation turn; a tool-call answer is rejected by
631                    // reject_non_text and counted as a compaction failure.
632                    tools: input.tools.to_vec(),
633                    reject_non_text: true,
634                },
635                InternalAgentExecution {
636                    cancellation: input.cancellation,
637                    actor_direct: input.actor_direct,
638                },
639            )
640            .await;
641        if matches!(&summary, Err(EngineError::Model(error))
642            if error.kind == oven_sdk::ModelErrorKind::ContextLength)
643            && !input.cancellation.is_cancelled()
644        {
645            let history = pruned_compaction_history(
646                &events,
647                &self.inner.artifacts,
648                input.session,
649                input.binding,
650                &composed_prompt,
651            )?;
652            let (history, instruction) = compaction_history(
653                history,
654                compaction_focus.as_deref(),
655                &input.internal_policy.agent.composed_prompt,
656            );
657            summary = self
658                .run_internal_history_agent(
659                    input.session,
660                    Some(input.run),
661                    InternalAgentKind::ContextCompaction,
662                    input.internal_policy,
663                    InternalAgentHistoryInput {
664                        history,
665                        summary_source: instruction,
666                        // The pruned retry already rewrites tool outputs, so it
667                        // drops the tool definitions too: cache affinity is
668                        // lost either way, and fewer tools means less to send.
669                        tools: Vec::new(),
670                        reject_non_text: true,
671                    },
672                    InternalAgentExecution {
673                        cancellation: input.cancellation,
674                        actor_direct: input.actor_direct,
675                    },
676                )
677                .await;
678        }
679        let Ok(summary) = summary else {
680            if !input.force
681                && let Some(run) = &active_run
682                && run.auto_compaction_failures.fetch_add(1, Ordering::Relaxed) + 1
683                    == MAX_AUTO_COMPACTION_FAILURES
684                && !run
685                    .auto_compaction_diagnostic_emitted
686                    .swap(true, Ordering::Relaxed)
687            {
688                self.record_plugin_diagnostic(
689                    input.session,
690                    "engine:auto-compact".into(),
691                    PluginDiagnosticKind::UnsupportedCapability,
692                    "automatic context compaction disabled after 3 consecutive failures for this run"
693                        .to_string(),
694                );
695            }
696            return Ok(Arc::from(events));
697        };
698        if summary.text.trim().is_empty() {
699            return Ok(Arc::from(events));
700        }
701        let checkpoint = InternalSummaryCheckpoint::new(
702            summary.text,
703            summary.invocation_id,
704            summary.internal_run_id,
705            summary_limit,
706        )
707        .map_err(|error| EngineError::from(ModelError::invalid_response(error.to_string())))?;
708        let retained_context = model_history::project_summary_context(
709            &events,
710            &self.inner.artifacts,
711            input.binding,
712            &composed_prompt,
713            input_through_seq,
714            recent_from_seq,
715            checkpoint.summary(),
716        )?;
717        let input_tokens_after =
718            self.estimated_request_tokens(input.session, &retained_context.history, input.tools)?;
719        let actual_base = model_history::project_summary_context(
720            &events,
721            &self.inner.artifacts,
722            input.binding,
723            &composed_prompt,
724            input_through_seq,
725            None,
726            checkpoint.summary(),
727        )?;
728        let actual_base_tokens =
729            self.estimated_request_tokens(input.session, &actual_base.history, input.tools)?;
730        let keep_recent_tokens = effective_recent_budget(
731            config.keep_recent_tokens,
732            context_limit,
733            retained_limit.saturating_sub(actual_base_tokens),
734        );
735        if input_tokens_after > retained_limit
736            || input_tokens_after.saturating_sub(actual_base_tokens) > keep_recent_tokens
737        {
738            return Ok(Arc::from(events));
739        }
740        boundaries.recent_from_seq = recent_from_seq;
741        if let Some(recent_from_seq) = recent_from_seq {
742            boundaries.source_from_seq = boundaries.source_from_seq.min(recent_from_seq);
743        }
744        let budgets = ContextCheckpointBudgets {
745            context_limit_tokens: context_limit,
746            trigger_tokens: trigger_tokens.max(1).min(context_limit),
747            input_tokens_before: context_tokens_before,
748            input_tokens_after,
749            max_summary_bytes: summary_limit,
750            keep_recent_tokens,
751        };
752        let commit = ContextCheckpointCommit {
753            checkpoint: ContextCheckpoint::InternalSummary { checkpoint },
754            boundaries,
755            budgets,
756        };
757        if commit.validate().is_err() {
758            return Ok(Arc::from(events));
759        }
760        self.append_compaction_event(
761            input.session,
762            Some(input.run),
763            Event::ContextCheckpointCommitted { commit },
764            input.actor_direct,
765            input.origin.clone(),
766        )
767        .await?;
768        if let Some(run) = active_run {
769            run.auto_compaction_failures.store(0, Ordering::Relaxed);
770        }
771        self.finalize_context_checkpoint(input.session, input_tokens_after)
772    }
773
774    fn finalize_context_checkpoint(
775        &self,
776        session: SessionId,
777        input_tokens_after: u64,
778    ) -> Result<Arc<[StoredEvent]>, EngineError> {
779        self.inner
780            .compaction
781            .context_token_estimators
782            .lock()
783            .unwrap_or_else(|poisoned| poisoned.into_inner())
784            .entry(session)
785            .or_default()
786            .record_compaction(input_tokens_after);
787        Ok(self.inner.store.get(session)?.log.event_snapshot())
788    }
789
790    async fn stage_tool_output_elision(
791        &self,
792        session: SessionId,
793        events: Vec<StoredEvent>,
794        actor_direct: bool,
795        origin: cookie_agent_protocol::EventOrigin,
796    ) -> Result<Vec<StoredEvent>, EngineError> {
797        let protected_turns = events
798            .iter()
799            .rev()
800            .filter_map(|event| match &event.payload {
801                Event::ModelTurnCommitted { model_turn_seq, .. } => Some(*model_turn_seq),
802                _ => None,
803            })
804            .take(2)
805            .collect::<HashSet<_>>();
806        let starts = events
807            .iter()
808            .filter_map(|event| match &event.payload {
809                Event::ToolCallStarted { start } => {
810                    Some((start.tool_call_id, start.owner.model_turn_seq))
811                }
812                _ => None,
813            })
814            .collect::<HashMap<_, _>>();
815        let already_elided = events
816            .iter()
817            .filter_map(|event| match event.payload {
818                Event::ToolOutputElided { tool_call_id, .. } => Some(tool_call_id),
819                _ => None,
820            })
821            .collect::<HashSet<_>>();
822        for event in &events {
823            let Event::ToolCallTerminated { termination } = &event.payload else {
824                continue;
825            };
826            let Some(result) = &termination.result else {
827                continue;
828            };
829            let Some(model_turn_seq) = starts.get(&termination.tool_call_id) else {
830                continue;
831            };
832            if !should_elide_tool_output(
833                *model_turn_seq,
834                &protected_turns,
835                already_elided.contains(&termination.tool_call_id),
836                elidable_bytes(result),
837            ) {
838                continue;
839            }
840            let (retained, _) = self
841                .inner
842                .artifacts
843                .retain(session, result.output.as_bytes())?;
844            self.append_compaction_event(
845                session,
846                event.run_id,
847                Event::ToolOutputElided {
848                    tool_call_id: termination.tool_call_id,
849                    original_bytes: result.output.len() as u64,
850                    retained,
851                },
852                actor_direct,
853                origin.clone(),
854            )
855            .await?;
856        }
857        Ok(self.inner.store.get(session)?.log.events())
858    }
859
860    fn estimated_request_tokens(
861        &self,
862        session: SessionId,
863        history: &[oven_sdk::HistoryTurn],
864        tools: &[ToolDefinition],
865    ) -> Result<u64, EngineError> {
866        let bytes = serialized_fit_request_bytes(history, tools)?;
867        let calibrated = self
868            .inner
869            .compaction
870            .context_token_estimators
871            .lock()
872            .unwrap_or_else(|poisoned| poisoned.into_inner())
873            .get(&session)
874            .copied()
875            .and_then(|estimator| estimator.estimated_context_tokens(bytes));
876        if let Some(calibrated) = calibrated {
877            return Ok(calibrated);
878        }
879        Ok(estimated_tokens_for_bytes(bytes))
880    }
881
882    async fn append_compaction_event(
883        &self,
884        session: SessionId,
885        run: Option<RunId>,
886        event: Event,
887        actor_direct: bool,
888        origin: cookie_agent_protocol::EventOrigin,
889    ) -> Result<(), EngineError> {
890        if actor_direct {
891            self.append_direct(session, run, origin, event)
892        } else {
893            self.append(session, run, origin, event).await
894        }
895    }
896}
897
898pub(crate) fn active_compaction_binding<'a>(
899    policy: &'a FrozenRunPolicy,
900    events: &[StoredEvent],
901    run: RunId,
902) -> Result<&'a cookie_agent_protocol::FrozenModelBinding, EngineError> {
903    policy
904        .selected_suffix
905        .get(active_fallback_index(events, run))
906        .ok_or(EngineError::NoRunnableModel)
907}
908
909pub(super) fn resolve_compaction_trigger(
910    context_limit: u64,
911    trigger: &ContextCompactionTrigger,
912) -> u64 {
913    match trigger {
914        ContextCompactionTrigger::Percent { percent } => context_limit
915            .saturating_mul(u64::from(*percent))
916            .saturating_div(100),
917        ContextCompactionTrigger::BufferTokens { buffer_tokens } => {
918            context_limit.saturating_sub(*buffer_tokens)
919        }
920    }
921}
922
923fn compaction_gate(force: bool, auto: bool, trigger_tokens: u64) -> bool {
924    force || (auto && trigger_tokens > 0)
925}
926
927fn usage_reaches_compaction_trigger(observed_tokens: u64, trigger_tokens: u64) -> bool {
928    observed_tokens >= trigger_tokens
929}
930
931fn effective_recent_budget(target: u64, context_limit: u64, available: u64) -> u64 {
932    target.min(context_limit / 4).min(available)
933}
934
935fn select_recent_tail(
936    candidates: Vec<u64>,
937    budget: u64,
938    base_tokens: u64,
939    retained_limit: u64,
940    mut estimate: impl FnMut(u64) -> Result<u64, EngineError>,
941) -> Result<Option<u64>, EngineError> {
942    let mut selected = None;
943    if budget == 0 {
944        return Ok(selected);
945    }
946    for candidate in candidates.into_iter().rev() {
947        let tokens = estimate(candidate)?;
948        if tokens > retained_limit || tokens.saturating_sub(base_tokens) > budget {
949            break;
950        }
951        selected = Some(candidate);
952    }
953    Ok(selected)
954}
955
956#[cfg(test)]
957fn checkpoint_covers_input(events: &[StoredEvent], input_through_seq: u64) -> bool {
958    events.iter().rev().any(|event| {
959        matches!(
960            &event.payload,
961            Event::ContextCheckpointCommitted { commit }
962                if commit.boundaries.input_through_seq >= input_through_seq
963        )
964    })
965}
966
967pub(crate) fn serialized_fit_request_bytes(
968    history: &[oven_sdk::HistoryTurn],
969    tools: &[ToolDefinition],
970) -> Result<usize, EngineError> {
971    let (history, attachment_surrogate_bytes) = fit_history(history);
972    let mut writer = CountingWriter::default();
973    serde_json::to_writer(&mut writer, &(history, tools))
974        .map_err(|error| EngineError::from(ModelError::invalid_request(error.to_string())))?;
975    Ok(writer.bytes.saturating_add(attachment_surrogate_bytes))
976}
977
978fn fit_history(history: &[oven_sdk::HistoryTurn]) -> (Vec<oven_sdk::HistoryTurn>, usize) {
979    // Media costs are flat-ish, usually hundreds to low-thousands of tokens per part, and do not
980    // track serialized byte size. Exclude image/PDF/audio files; video alone gets a flat surrogate
981    // because frame sampling can be materially expensive. Overestimation can trigger unnecessary
982    // compaction and discard attachments, while real usage calibrates residual error.
983    let mut history = history.to_vec();
984    let mut part_bytes = 0_usize;
985    for turn in &mut history {
986        match turn {
987            oven_sdk::HistoryTurn::System(message) => {
988                message.content.retain(|part| match part {
989                    oven_sdk::SystemPart::Text(text) => {
990                        part_bytes =
991                            part_bytes.saturating_add(fit_part_bytes(FitPart::Text(&text.text)));
992                        false
993                    }
994                    oven_sdk::SystemPart::Custom(_) => true,
995                });
996            }
997            oven_sdk::HistoryTurn::User(message) => {
998                message.content.retain(|part| match part {
999                    oven_sdk::InputPart::Text(text) => {
1000                        part_bytes =
1001                            part_bytes.saturating_add(fit_part_bytes(FitPart::Text(&text.text)));
1002                        false
1003                    }
1004                    oven_sdk::InputPart::File(file) => {
1005                        part_bytes = part_bytes
1006                            .saturating_add(fit_part_bytes(FitPart::File(&file.media_type)));
1007                        false
1008                    }
1009                    oven_sdk::InputPart::Custom(_) => true,
1010                });
1011            }
1012            oven_sdk::HistoryTurn::Assistant(turn) => {
1013                for part in &mut turn.message.content {
1014                    if let oven_sdk::AssistantPart::ToolResult(result) = part {
1015                        part_bytes = part_bytes
1016                            .saturating_add(remove_tool_content_parts(&mut result.content));
1017                    }
1018                }
1019                turn.message.content.retain(|part| match part {
1020                    oven_sdk::AssistantPart::Text(text) => {
1021                        part_bytes =
1022                            part_bytes.saturating_add(fit_part_bytes(FitPart::Text(&text.text)));
1023                        false
1024                    }
1025                    oven_sdk::AssistantPart::File(file) => {
1026                        part_bytes = part_bytes
1027                            .saturating_add(fit_part_bytes(FitPart::File(&file.media_type)));
1028                        false
1029                    }
1030                    _ => true,
1031                });
1032            }
1033            oven_sdk::HistoryTurn::Tool(message) => {
1034                for result in &mut message.results {
1035                    part_bytes =
1036                        part_bytes.saturating_add(remove_tool_content_parts(&mut result.content));
1037                }
1038            }
1039        }
1040    }
1041    (history, part_bytes)
1042}
1043
1044fn remove_tool_content_parts(content: &mut oven_sdk::ToolContent) -> usize {
1045    match content {
1046        oven_sdk::ToolContent::Text(text) => {
1047            let bytes = fit_part_bytes(FitPart::Text(text));
1048            text.clear();
1049            bytes
1050        }
1051        oven_sdk::ToolContent::Mixed(values) => {
1052            let mut part_bytes = 0_usize;
1053            values.retain(|value| match value {
1054                oven_sdk::ContentValue::Text(text) => {
1055                    part_bytes = part_bytes.saturating_add(fit_part_bytes(FitPart::Text(text)));
1056                    false
1057                }
1058                oven_sdk::ContentValue::File(file) => {
1059                    part_bytes =
1060                        part_bytes.saturating_add(fit_part_bytes(FitPart::File(&file.media_type)));
1061                    false
1062                }
1063                oven_sdk::ContentValue::Json(_) => true,
1064            });
1065            part_bytes
1066        }
1067        oven_sdk::ToolContent::Json(_) | oven_sdk::ToolContent::Denied { .. } => 0,
1068    }
1069}
1070
1071#[derive(Clone, Copy)]
1072enum FitPart<'a> {
1073    Text(&'a str),
1074    File(&'a str),
1075}
1076
1077fn fit_part_bytes(part: FitPart<'_>) -> usize {
1078    match part {
1079        FitPart::Text(text) => text.len(),
1080        FitPart::File(media_type) if media_type.starts_with("video/") => {
1081            VIDEO_FILE_FIT_SURROGATE_BYTES
1082        }
1083        FitPart::File(_) => 0,
1084    }
1085}
1086
1087fn elidable_bytes(result: &PersistedToolResult) -> usize {
1088    let emitted_bytes = result
1089        .additional_messages
1090        .iter()
1091        .map(|message| {
1092            let marker_bytes =
1093                usize::from(message.role == cookie_agent_protocol::ToolEmittedMessageRole::System)
1094                    .saturating_mul(fit_part_bytes(FitPart::Text(
1095                        model_history::TOOL_EMITTED_SYSTEM_USER_MARKER,
1096                    )));
1097            message.content.iter().fold(marker_bytes, |total, part| {
1098                total.saturating_add(match part {
1099                    ToolEmittedContent::Text(text) => fit_part_bytes(FitPart::Text(text)),
1100                    ToolEmittedContent::File(attachment) => {
1101                        fit_part_bytes(FitPart::File(attachment.mime_type.as_str()))
1102                    }
1103                })
1104            })
1105        })
1106        .fold(0_usize, usize::saturating_add);
1107    result.attachments.iter().fold(
1108        fit_part_bytes(FitPart::Text(&result.output)).saturating_add(emitted_bytes),
1109        |total, attachment| {
1110            total.saturating_add(fit_part_bytes(FitPart::File(attachment.mime_type.as_str())))
1111        },
1112    )
1113}
1114
1115#[derive(Default)]
1116struct CountingWriter {
1117    bytes: usize,
1118}
1119
1120impl io::Write for CountingWriter {
1121    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
1122        self.bytes = self.bytes.saturating_add(buffer.len());
1123        Ok(buffer.len())
1124    }
1125
1126    fn flush(&mut self) -> io::Result<()> {
1127        Ok(())
1128    }
1129}
1130
1131pub(super) fn estimated_request_tokens(
1132    history: &[oven_sdk::HistoryTurn],
1133    tools: &[ToolDefinition],
1134) -> Result<u64, EngineError> {
1135    Ok(estimated_tokens_for_bytes(serialized_fit_request_bytes(
1136        history, tools,
1137    )?))
1138}
1139
1140fn estimated_tokens_for_bytes(bytes: usize) -> u64 {
1141    (bytes as u64).div_ceil(4)
1142}
1143
1144fn raw_fit_from_real_usage(
1145    overflow_recovery: bool,
1146    observed_tokens: Option<u64>,
1147    fits: impl FnOnce(u64) -> bool,
1148) -> Option<bool> {
1149    if overflow_recovery {
1150        Some(false)
1151    } else {
1152        observed_tokens.filter(|tokens| fits(*tokens)).map(|_| true)
1153    }
1154}
1155
1156fn compaction_input_fits(
1157    binding: &cookie_agent_protocol::FrozenModelBinding,
1158    internal_policy: &FrozenInternalAgentPolicy,
1159    input_tokens: u64,
1160) -> bool {
1161    if binding.descriptor.capabilities.compaction != CompactionCapability::Native {
1162        // Provider admission is authoritative for internal agents. The estimate remains
1163        // useful for native compaction budgeting, but must not reject a prompt locally.
1164        let _ = (internal_policy, input_tokens);
1165        return true;
1166    }
1167    input_tokens <= native_compaction_input_budget(binding, internal_policy)
1168}
1169
1170fn native_compaction_input_budget(
1171    binding: &cookie_agent_protocol::FrozenModelBinding,
1172    internal_policy: &FrozenInternalAgentPolicy,
1173) -> u64 {
1174    let output_reserve = internal_agent_output_limit(binding, internal_policy)
1175        .unwrap_or(DEFAULT_COMPACTION_OUTPUT_RESERVE_TOKENS);
1176    binding
1177        .descriptor
1178        .capabilities
1179        .limits
1180        .context
1181        .unwrap_or(0)
1182        .saturating_sub(output_reserve)
1183        .max(1)
1184}
1185
1186fn compaction_instruction(focus: Option<&str>) -> String {
1187    focus.map_or_else(
1188        || COMPACTION_INSTRUCTION.to_owned(),
1189        |focus| format!("{COMPACTION_INSTRUCTION}\n\nUser-requested focus: {focus}"),
1190    )
1191}
1192
1193fn pruned_compaction_history(
1194    events: &[StoredEvent],
1195    store: &super::artifacts::ArtifactRouter,
1196    session: SessionId,
1197    binding: &cookie_agent_protocol::FrozenModelBinding,
1198    composed_prompt: &str,
1199) -> Result<Vec<oven_sdk::HistoryTurn>, EngineError> {
1200    // Emitted messages lose their tool ownership after assembly. Remove them in a private
1201    // snapshot first, then prune only results in the active checkpoint-aware history.
1202    let mut events = events.to_vec();
1203    for event in &mut events {
1204        if let Event::ToolCallTerminated { termination } = &mut event.payload
1205            && let Some(result) = &mut termination.result
1206        {
1207            result.additional_messages.clear();
1208            result.attachments.clear();
1209        }
1210    }
1211    let mut history = assemble_model_context(&events, store, binding, composed_prompt)?.history;
1212    let mut retrieval_calls = HashSet::new();
1213    for turn in &mut history {
1214        match turn {
1215            oven_sdk::HistoryTurn::Assistant(turn) => {
1216                for part in &turn.message.content {
1217                    if let oven_sdk::AssistantPart::ToolCall(call) = part {
1218                        if call.name == "read"
1219                            && call
1220                                .input
1221                                .get("filePath")
1222                                .and_then(serde_json::Value::as_str)
1223                                .is_some_and(|path| {
1224                                    cookie_agent_protocol::ArtifactReadPath::parse(path).is_ok()
1225                                })
1226                        {
1227                            retrieval_calls.insert(call.id.clone());
1228                        } else {
1229                            retrieval_calls.remove(&call.id);
1230                        }
1231                    }
1232                }
1233                for part in &mut turn.message.content {
1234                    if let oven_sdk::AssistantPart::ToolResult(result) = part {
1235                        prune_compaction_result(result, &retrieval_calls, store, session)?;
1236                    }
1237                }
1238            }
1239            oven_sdk::HistoryTurn::Tool(message) => {
1240                for result in &mut message.results {
1241                    prune_compaction_result(result, &retrieval_calls, store, session)?;
1242                }
1243            }
1244            _ => {}
1245        }
1246    }
1247    Ok(history)
1248}
1249
1250fn prune_compaction_result(
1251    result: &mut oven_sdk::ToolResultPart,
1252    retrieval_calls: &HashSet<String>,
1253    store: &super::artifacts::ArtifactRouter,
1254    session: SessionId,
1255) -> Result<(), EngineError> {
1256    let marker = if retrieval_calls.contains(&result.tool_call_id) {
1257        // This is a private summary-input copy; the original result stays in history.
1258        "[artifact read output omitted for compaction]".to_owned()
1259    } else {
1260        let content = match &result.content {
1261            oven_sdk::ToolContent::Text(text) => text.as_bytes().to_vec(),
1262            content => serde_json::to_vec(content)
1263                .map_err(|error| ModelError::invalid_request(error.to_string()))?,
1264        };
1265        let (_, artifact_id) = store.retain(session, &content)?;
1266        let mut marker =
1267            model_history::tool_output_elision_marker(&artifact_id, content.len() as u64, 0);
1268        if !matches!(result.content, oven_sdk::ToolContent::Text(_)) {
1269            marker.push_str(" Stored as serialized tool content (JSON).");
1270        }
1271        let read_more = serde_json::json!({
1272            "read_more": {
1273                "tool": "read",
1274                "arguments": {"filePath": format!("artifact://{artifact_id}")}
1275            }
1276        });
1277        marker.push('\n');
1278        marker.push_str(&read_more.to_string());
1279        marker
1280    };
1281    result.content = oven_sdk::ToolContent::Text(marker);
1282    result.metadata = None;
1283    Ok(())
1284}
1285
1286fn compaction_history(
1287    mut history: Vec<oven_sdk::HistoryTurn>,
1288    focus: Option<&str>,
1289    system_prompt: &str,
1290) -> (Vec<oven_sdk::HistoryTurn>, String) {
1291    // Keep the history (including its system prompt) intact so the summarizer
1292    // request is a cache-friendly extension of the latest conversation turn;
1293    // the compaction agent's own prompt rides along in the trailing
1294    // instruction instead of replacing the session system prompt.
1295    let base = compaction_instruction(focus);
1296    let instruction = if system_prompt.trim().is_empty() {
1297        base
1298    } else {
1299        format!("{}\n\n{base}", system_prompt.trim())
1300    };
1301    history.push(oven_sdk::HistoryTurn::user(oven_sdk::UserMessage::new(
1302        vec![oven_sdk::InputPart::Text(oven_sdk::TextPart::new(
1303            instruction.clone(),
1304        ))],
1305    )));
1306    (history, instruction)
1307}
1308
1309fn should_elide_tool_output(
1310    model_turn_seq: u64,
1311    protected_turns: &HashSet<u64>,
1312    already_elided: bool,
1313    output_bytes: usize,
1314) -> bool {
1315    !protected_turns.contains(&model_turn_seq)
1316        && !already_elided
1317        && output_bytes >= TOOL_OUTPUT_ELISION_MIN_BYTES
1318}
1319
1320#[cfg(test)]
1321mod tests;