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#[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;
112pub(super) const DEFAULT_COMPACTION_OUTPUT_RESERVE_TOKENS: u64 = 20_000;
115const MAX_AUTO_COMPACTION_FAILURES: u8 = 3;
116const 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 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 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 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 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 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 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 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 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 "[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 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;