Skip to main content

agentkit_compaction/
lib.rs

1//! Transcript compaction primitives for reducing context size while preserving
2//! useful state.
3//!
4//! This crate provides the building blocks for compacting an agent transcript
5//! when it grows too large. The main concepts are:
6//!
7//! - **Triggers** ([`CompactionTrigger`]) decide *when* compaction should run
8//!   (e.g. after a certain item count is exceeded).
9//! - **Strategies** ([`CompactionStrategy`]) decide *how* the transcript is
10//!   transformed: dropping reasoning, removing failed tool results, keeping
11//!   only recent items, or summarising older items via a backend.
12//! - **Pipelines** ([`CompactionPipeline`]) chain multiple strategies into a
13//!   single pass.
14//! - **Backends** ([`CompactionBackend`]) provide provider-backed
15//!   summarisation for strategies that need it (e.g.
16//!   [`SummarizeOlderStrategy`]).
17//!
18//! Combine these pieces through [`CompactionConfig`] and hand the config to
19//! `agentkit-loop` (or your own runtime) to keep transcripts under control.
20
21use std::collections::BTreeSet;
22use std::sync::Arc;
23
24use agentkit_core::{Item, ItemKind, MetadataMap, Part, SessionId, TurnCancellation, TurnId};
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29/// The reason a compaction was triggered.
30///
31/// Returned by [`CompactionTrigger::should_compact`] and forwarded to
32/// strategies so they can adapt their behaviour.
33#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
34pub enum CompactionReason {
35    /// The transcript exceeded a configured item count.
36    TranscriptTooLong,
37    /// A caller explicitly requested compaction.
38    Manual,
39    /// An application-specific reason described by the inner string.
40    Custom(String),
41}
42
43/// Input to a [`CompactionStrategy`].
44///
45/// Carries the full transcript together with session context so that
46/// strategies can decide which items to keep, drop, or summarise.
47#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
48pub struct CompactionRequest {
49    /// Identifier for the current session.
50    pub session_id: SessionId,
51    /// Identifier for the turn that triggered compaction, if any.
52    pub turn_id: Option<TurnId>,
53    /// The transcript to compact.
54    pub transcript: Vec<Item>,
55    /// Why compaction was triggered.
56    pub reason: CompactionReason,
57    /// Arbitrary key-value metadata forwarded through the pipeline.
58    pub metadata: MetadataMap,
59}
60
61impl CompactionRequest {
62    /// Builds a compaction request without an associated turn id.
63    pub fn new(
64        session_id: impl Into<SessionId>,
65        transcript: Vec<Item>,
66        reason: CompactionReason,
67    ) -> Self {
68        Self {
69            session_id: session_id.into(),
70            turn_id: None,
71            transcript,
72            reason,
73            metadata: MetadataMap::new(),
74        }
75    }
76
77    /// Builds a compaction request for a specific turn.
78    pub fn for_turn(
79        session_id: impl Into<SessionId>,
80        turn_id: impl Into<TurnId>,
81        transcript: Vec<Item>,
82        reason: CompactionReason,
83    ) -> Self {
84        Self::new(session_id, transcript, reason).with_turn_id(turn_id)
85    }
86
87    /// Sets the turn id.
88    pub fn with_turn_id(mut self, turn_id: impl Into<TurnId>) -> Self {
89        self.turn_id = Some(turn_id.into());
90        self
91    }
92
93    /// Replaces the request metadata.
94    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
95        self.metadata = metadata;
96        self
97    }
98}
99
100/// Output of a [`CompactionStrategy`].
101///
102/// Contains the compacted transcript along with metadata about what changed.
103#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
104pub struct CompactionResult {
105    /// The compacted transcript.
106    pub transcript: Vec<Item>,
107    /// How many items were removed or replaced during compaction.
108    pub replaced_items: usize,
109    /// Metadata produced by the strategy (e.g. summarisation statistics).
110    pub metadata: MetadataMap,
111}
112
113impl CompactionResult {
114    /// Builds a compaction result with empty metadata.
115    pub fn new(transcript: Vec<Item>, replaced_items: usize) -> Self {
116        Self {
117            transcript,
118            replaced_items,
119            metadata: MetadataMap::new(),
120        }
121    }
122
123    /// Replaces the result metadata.
124    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
125        self.metadata = metadata;
126        self
127    }
128}
129
130/// Request sent to a [`CompactionBackend`] asking it to summarise a set of
131/// transcript items.
132#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133pub struct SummaryRequest {
134    /// Identifier for the current session.
135    pub session_id: SessionId,
136    /// Identifier for the turn that triggered compaction, if any.
137    pub turn_id: Option<TurnId>,
138    /// The transcript items to summarise.
139    pub items: Vec<Item>,
140    /// Why compaction was triggered.
141    pub reason: CompactionReason,
142    /// Arbitrary key-value metadata forwarded from the pipeline.
143    pub metadata: MetadataMap,
144}
145
146impl SummaryRequest {
147    /// Builds a summary request without an associated turn id.
148    pub fn new(
149        session_id: impl Into<SessionId>,
150        items: Vec<Item>,
151        reason: CompactionReason,
152    ) -> Self {
153        Self {
154            session_id: session_id.into(),
155            turn_id: None,
156            items,
157            reason,
158            metadata: MetadataMap::new(),
159        }
160    }
161
162    /// Builds a summary request for a specific turn.
163    pub fn for_turn(
164        session_id: impl Into<SessionId>,
165        turn_id: impl Into<TurnId>,
166        items: Vec<Item>,
167        reason: CompactionReason,
168    ) -> Self {
169        Self::new(session_id, items, reason).with_turn_id(turn_id)
170    }
171
172    /// Sets the turn id.
173    pub fn with_turn_id(mut self, turn_id: impl Into<TurnId>) -> Self {
174        self.turn_id = Some(turn_id.into());
175        self
176    }
177
178    /// Replaces the request metadata.
179    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
180        self.metadata = metadata;
181        self
182    }
183}
184
185/// Response from a [`CompactionBackend`] containing the summarised items.
186#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
187pub struct SummaryResult {
188    /// The summary items that replace the originals in the transcript.
189    pub items: Vec<Item>,
190    /// Metadata produced during summarisation (e.g. token counts).
191    pub metadata: MetadataMap,
192}
193
194impl SummaryResult {
195    /// Builds a summary result with empty metadata.
196    pub fn new(items: Vec<Item>) -> Self {
197        Self {
198            items,
199            metadata: MetadataMap::new(),
200        }
201    }
202
203    /// Replaces the result metadata.
204    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
205        self.metadata = metadata;
206        self
207    }
208}
209
210/// Decides whether compaction should run for a given transcript.
211///
212/// Implement this trait to create custom triggers. The built-in
213/// [`ItemCountTrigger`] fires when the transcript exceeds a fixed item count.
214///
215/// # Example
216///
217/// ```rust
218/// use agentkit_compaction::{CompactionReason, CompactionTrigger, ItemCountTrigger};
219/// use agentkit_core::SessionId;
220///
221/// let trigger = ItemCountTrigger::new(32);
222/// let items = Vec::new();
223/// assert!(trigger.should_compact(&SessionId::new("s"), None, &items).is_none());
224/// ```
225pub trait CompactionTrigger: Send + Sync {
226    /// Returns `Some(reason)` if compaction should run, `None` otherwise.
227    ///
228    /// # Arguments
229    ///
230    /// * `session_id` - The current session identifier.
231    /// * `turn_id` - The turn that is being evaluated, if any.
232    /// * `transcript` - The full transcript to inspect.
233    fn should_compact(
234        &self,
235        session_id: &SessionId,
236        turn_id: Option<&TurnId>,
237        transcript: &[Item],
238    ) -> Option<CompactionReason>;
239}
240
241/// Provider-backed summarisation service.
242///
243/// Implement this trait to connect a language model (or any other
244/// summarisation service) so that strategies like [`SummarizeOlderStrategy`]
245/// can condense older transcript items into a shorter summary.
246///
247/// # Errors
248///
249/// Implementations should return [`CompactionError::Failed`] when
250/// summarisation cannot be completed, or [`CompactionError::Cancelled`] when
251/// the cancellation token is signalled.
252#[async_trait]
253pub trait CompactionBackend: Send + Sync {
254    /// Summarise the given items into a shorter set of replacement items.
255    ///
256    /// # Arguments
257    ///
258    /// * `request` - The items to summarise together with session context.
259    /// * `cancellation` - An optional cancellation token; implementations
260    ///   should check this periodically and bail early when cancelled.
261    ///
262    /// # Errors
263    ///
264    /// Returns [`CompactionError`] on failure or cancellation.
265    async fn summarize(
266        &self,
267        request: SummaryRequest,
268        cancellation: Option<TurnCancellation>,
269    ) -> Result<SummaryResult, CompactionError>;
270}
271
272/// Runtime context passed to each [`CompactionStrategy`] during execution.
273///
274/// Provides access to an optional [`CompactionBackend`] (needed by
275/// [`SummarizeOlderStrategy`]), shared metadata, and a cancellation token
276/// that strategies should respect.
277pub struct CompactionContext<'a> {
278    /// An optional backend for strategies that need to call an external
279    /// summarisation service.
280    pub backend: Option<&'a dyn CompactionBackend>,
281    /// Shared metadata available to all strategies in the pipeline.
282    pub metadata: &'a MetadataMap,
283    /// Cancellation token; strategies should check this and return
284    /// [`CompactionError::Cancelled`] when signalled.
285    pub cancellation: Option<TurnCancellation>,
286}
287
288/// A single compaction step that transforms a transcript.
289///
290/// Strategies are the core abstraction in this crate. Each strategy receives
291/// the transcript inside a [`CompactionRequest`] and returns a
292/// [`CompactionResult`] with the (possibly shorter) transcript.
293///
294/// Built-in strategies:
295///
296/// | Strategy | What it does |
297/// |---|---|
298/// | [`DropReasoningStrategy`] | Strips reasoning parts from items |
299/// | [`DropFailedToolResultsStrategy`] | Removes errored tool results |
300/// | [`KeepRecentStrategy`] | Keeps only the N most recent removable items |
301/// | [`SummarizeOlderStrategy`] | Replaces older items with a backend-generated summary |
302///
303/// Use [`CompactionPipeline`] to chain multiple strategies together.
304///
305/// # Example
306///
307/// ```rust
308/// use agentkit_compaction::DropReasoningStrategy;
309///
310/// // Strategies are composable via CompactionPipeline
311/// let strategy = DropReasoningStrategy::new();
312/// ```
313#[async_trait]
314pub trait CompactionStrategy: Send + Sync {
315    /// Apply this strategy to the transcript in `request`.
316    ///
317    /// # Arguments
318    ///
319    /// * `request` - The transcript and session context to compact.
320    /// * `ctx` - Runtime context providing the backend, metadata, and
321    ///   cancellation token.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`CompactionError`] on failure or cancellation.
326    async fn apply(
327        &self,
328        request: CompactionRequest,
329        ctx: &mut CompactionContext<'_>,
330    ) -> Result<CompactionResult, CompactionError>;
331}
332
333/// Top-level configuration that bundles a trigger, strategy, and optional
334/// backend into a single value you can hand to `agentkit-loop`.
335///
336/// # Example
337///
338/// ```rust
339/// use agentkit_compaction::{
340///     CompactionConfig, CompactionPipeline, DropReasoningStrategy,
341///     ItemCountTrigger, KeepRecentStrategy,
342/// };
343/// use agentkit_core::ItemKind;
344///
345/// let config = CompactionConfig::new(
346///     ItemCountTrigger::new(32),
347///     CompactionPipeline::new()
348///         .with_strategy(DropReasoningStrategy::new())
349///         .with_strategy(
350///             KeepRecentStrategy::new(24)
351///                 .preserve_kind(ItemKind::System)
352///                 .preserve_kind(ItemKind::Context),
353///         ),
354/// );
355/// ```
356#[derive(Clone)]
357pub struct CompactionConfig {
358    /// The trigger that decides when compaction should run.
359    pub trigger: Arc<dyn CompactionTrigger>,
360    /// The strategy (or pipeline of strategies) to execute.
361    pub strategy: Arc<dyn CompactionStrategy>,
362    /// An optional backend for strategies that need summarisation.
363    pub backend: Option<Arc<dyn CompactionBackend>>,
364    /// Metadata forwarded to every strategy invocation.
365    pub metadata: MetadataMap,
366}
367
368impl CompactionConfig {
369    /// Create a new configuration with the given trigger and strategy.
370    ///
371    /// The backend defaults to `None`. Use [`with_backend`](Self::with_backend)
372    /// to attach one when your pipeline includes [`SummarizeOlderStrategy`].
373    ///
374    /// # Arguments
375    ///
376    /// * `trigger` - Decides when compaction should fire.
377    /// * `strategy` - The strategy (or [`CompactionPipeline`]) to execute.
378    pub fn new(
379        trigger: impl CompactionTrigger + 'static,
380        strategy: impl CompactionStrategy + 'static,
381    ) -> Self {
382        Self {
383            trigger: Arc::new(trigger),
384            strategy: Arc::new(strategy),
385            backend: None,
386            metadata: MetadataMap::new(),
387        }
388    }
389
390    /// Attach a [`CompactionBackend`] for strategies that require
391    /// summarisation (e.g. [`SummarizeOlderStrategy`]).
392    pub fn with_backend(mut self, backend: impl CompactionBackend + 'static) -> Self {
393        self.backend = Some(Arc::new(backend));
394        self
395    }
396
397    /// Set metadata that will be forwarded to every strategy invocation.
398    pub fn with_metadata(mut self, metadata: MetadataMap) -> Self {
399        self.metadata = metadata;
400        self
401    }
402}
403
404/// A [`CompactionTrigger`] that fires when the transcript exceeds a fixed
405/// number of items.
406///
407/// This is the simplest built-in trigger: once `transcript.len()` is greater
408/// than `max_items`, it returns
409/// [`CompactionReason::TranscriptTooLong`].
410///
411/// # Example
412///
413/// ```rust
414/// use agentkit_compaction::{CompactionTrigger, ItemCountTrigger};
415/// use agentkit_core::SessionId;
416///
417/// let trigger = ItemCountTrigger::new(100);
418/// // An empty transcript does not trigger compaction.
419/// assert!(trigger.should_compact(&SessionId::new("s"), None, &[]).is_none());
420/// ```
421#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
422pub struct ItemCountTrigger {
423    /// Maximum number of items allowed before compaction fires.
424    pub max_items: usize,
425}
426
427impl ItemCountTrigger {
428    /// Create a trigger that fires when the transcript has more than
429    /// `max_items` items.
430    pub fn new(max_items: usize) -> Self {
431        Self { max_items }
432    }
433}
434
435impl CompactionTrigger for ItemCountTrigger {
436    fn should_compact(
437        &self,
438        _session_id: &SessionId,
439        _turn_id: Option<&TurnId>,
440        transcript: &[Item],
441    ) -> Option<CompactionReason> {
442        (transcript.len() > self.max_items).then_some(CompactionReason::TranscriptTooLong)
443    }
444}
445
446/// An ordered sequence of [`CompactionStrategy`] steps executed one after
447/// another.
448///
449/// Each strategy receives the transcript produced by the previous one,
450/// creating a pipeline effect. The pipeline itself implements
451/// [`CompactionStrategy`], so it can be nested or used anywhere a single
452/// strategy is expected.
453///
454/// The pipeline checks the [`CompactionContext::cancellation`] token between
455/// steps and returns [`CompactionError::Cancelled`] early if cancellation is
456/// signalled.
457///
458/// # Example
459///
460/// ```rust
461/// use agentkit_compaction::{
462///     CompactionPipeline, DropFailedToolResultsStrategy,
463///     DropReasoningStrategy, KeepRecentStrategy,
464/// };
465/// use agentkit_core::ItemKind;
466///
467/// let pipeline = CompactionPipeline::new()
468///     .with_strategy(DropReasoningStrategy::new())
469///     .with_strategy(DropFailedToolResultsStrategy::new())
470///     .with_strategy(
471///         KeepRecentStrategy::new(24)
472///             .preserve_kind(ItemKind::System)
473///             .preserve_kind(ItemKind::Context),
474///     );
475/// ```
476#[derive(Clone, Default)]
477pub struct CompactionPipeline {
478    strategies: Vec<Arc<dyn CompactionStrategy>>,
479}
480
481impl CompactionPipeline {
482    /// Create an empty pipeline with no strategies.
483    pub fn new() -> Self {
484        Self::default()
485    }
486
487    /// Append a strategy to the end of the pipeline.
488    ///
489    /// Strategies run in the order they are added.
490    pub fn with_strategy(mut self, strategy: impl CompactionStrategy + 'static) -> Self {
491        self.strategies.push(Arc::new(strategy));
492        self
493    }
494}
495
496#[async_trait]
497impl CompactionStrategy for CompactionPipeline {
498    async fn apply(
499        &self,
500        mut request: CompactionRequest,
501        ctx: &mut CompactionContext<'_>,
502    ) -> Result<CompactionResult, CompactionError> {
503        let mut replaced_items = 0;
504        let mut metadata = MetadataMap::new();
505
506        for strategy in &self.strategies {
507            if ctx
508                .cancellation
509                .as_ref()
510                .is_some_and(TurnCancellation::is_cancelled)
511            {
512                return Err(CompactionError::Cancelled);
513            }
514            let result = strategy.apply(request.clone(), ctx).await?;
515            request.transcript = result.transcript;
516            replaced_items += result.replaced_items;
517            metadata.extend(result.metadata);
518        }
519
520        Ok(CompactionResult {
521            transcript: request.transcript,
522            replaced_items,
523            metadata,
524        })
525    }
526}
527
528/// Strategy that removes [`Part::Reasoning`] parts from every item.
529///
530/// Reasoning parts contain chain-of-thought content that is useful during
531/// generation but rarely needed once the answer has been produced. Stripping
532/// them reduces transcript size without losing user-visible content.
533///
534/// When `drop_empty_items` is `true` (the default), items that become empty
535/// after reasoning removal are dropped entirely.
536///
537/// # Example
538///
539/// ```rust
540/// use agentkit_compaction::DropReasoningStrategy;
541///
542/// let strategy = DropReasoningStrategy::new();
543///
544/// // Keep items that become empty after stripping reasoning:
545/// let keep_empties = DropReasoningStrategy::new().drop_empty_items(false);
546/// ```
547#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
548pub struct DropReasoningStrategy {
549    drop_empty_items: bool,
550}
551
552impl DropReasoningStrategy {
553    /// Create a new strategy that drops reasoning parts and removes items
554    /// that become empty as a result.
555    pub fn new() -> Self {
556        Self {
557            drop_empty_items: true,
558        }
559    }
560
561    /// Control whether items that become empty after reasoning removal are
562    /// dropped from the transcript.
563    ///
564    /// Defaults to `true`.
565    pub fn drop_empty_items(mut self, value: bool) -> Self {
566        self.drop_empty_items = value;
567        self
568    }
569}
570
571#[async_trait]
572impl CompactionStrategy for DropReasoningStrategy {
573    async fn apply(
574        &self,
575        request: CompactionRequest,
576        _ctx: &mut CompactionContext<'_>,
577    ) -> Result<CompactionResult, CompactionError> {
578        let mut transcript = Vec::with_capacity(request.transcript.len());
579        let mut replaced_items = 0;
580
581        for mut item in request.transcript {
582            let original_len = item.parts.len();
583            item.parts
584                .retain(|part| !matches!(part, Part::Reasoning(_)));
585            let changed = item.parts.len() != original_len;
586            if item.parts.is_empty() && self.drop_empty_items {
587                if changed {
588                    replaced_items += 1;
589                }
590                continue;
591            }
592            if changed {
593                replaced_items += 1;
594            }
595            transcript.push(item);
596        }
597
598        Ok(CompactionResult {
599            transcript,
600            replaced_items,
601            metadata: MetadataMap::new(),
602        })
603    }
604}
605
606/// Strategy that removes [`Part::ToolResult`] parts where `is_error` is
607/// `true`.
608///
609/// Failed tool invocations clutter the transcript and can confuse the model
610/// on subsequent turns. This strategy strips those results while leaving
611/// successful tool output intact.
612///
613/// When `drop_empty_items` is `true` (the default), items that become empty
614/// after removal are dropped entirely.
615///
616/// # Example
617///
618/// ```rust
619/// use agentkit_compaction::DropFailedToolResultsStrategy;
620///
621/// let strategy = DropFailedToolResultsStrategy::new();
622///
623/// // Keep items that become empty after stripping failed results:
624/// let keep_empties = DropFailedToolResultsStrategy::new().drop_empty_items(false);
625/// ```
626#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
627pub struct DropFailedToolResultsStrategy {
628    drop_empty_items: bool,
629}
630
631impl DropFailedToolResultsStrategy {
632    /// Create a new strategy that drops failed tool results and removes
633    /// items that become empty as a result.
634    pub fn new() -> Self {
635        Self {
636            drop_empty_items: true,
637        }
638    }
639
640    /// Control whether items that become empty after failed-result removal
641    /// are dropped from the transcript.
642    ///
643    /// Defaults to `true`.
644    pub fn drop_empty_items(mut self, value: bool) -> Self {
645        self.drop_empty_items = value;
646        self
647    }
648}
649
650#[async_trait]
651impl CompactionStrategy for DropFailedToolResultsStrategy {
652    async fn apply(
653        &self,
654        request: CompactionRequest,
655        _ctx: &mut CompactionContext<'_>,
656    ) -> Result<CompactionResult, CompactionError> {
657        let mut transcript = Vec::with_capacity(request.transcript.len());
658        let mut replaced_items = 0;
659
660        for mut item in request.transcript {
661            let original_len = item.parts.len();
662            item.parts.retain(|part| {
663                !matches!(
664                    part,
665                    Part::ToolResult(result) if result.is_error
666                )
667            });
668            let changed = item.parts.len() != original_len;
669            if item.parts.is_empty() && self.drop_empty_items {
670                if changed {
671                    replaced_items += 1;
672                }
673                continue;
674            }
675            if changed {
676                replaced_items += 1;
677            }
678            transcript.push(item);
679        }
680
681        Ok(CompactionResult {
682            transcript,
683            replaced_items,
684            metadata: MetadataMap::new(),
685        })
686    }
687}
688
689/// Strategy that keeps only the `N` most recent removable items and drops
690/// the rest.
691///
692/// Items whose [`ItemKind`] is in the `preserve_kinds` set are always
693/// retained regardless of their position. This lets you protect system
694/// prompts and context items while trimming older conversation turns.
695///
696/// # Example
697///
698/// ```rust
699/// use agentkit_compaction::KeepRecentStrategy;
700/// use agentkit_core::ItemKind;
701///
702/// let strategy = KeepRecentStrategy::new(16)
703///     .preserve_kind(ItemKind::System)
704///     .preserve_kind(ItemKind::Context);
705/// ```
706#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
707pub struct KeepRecentStrategy {
708    keep_last: usize,
709    preserve_kinds: BTreeSet<ItemKind>,
710}
711
712impl KeepRecentStrategy {
713    /// Create a strategy that keeps the last `keep_last` removable items.
714    pub fn new(keep_last: usize) -> Self {
715        Self {
716            keep_last,
717            preserve_kinds: BTreeSet::new(),
718        }
719    }
720
721    /// Mark an [`ItemKind`] as preserved so that items of this kind are never
722    /// dropped, regardless of their position in the transcript.
723    pub fn preserve_kind(mut self, kind: ItemKind) -> Self {
724        self.preserve_kinds.insert(kind);
725        self
726    }
727}
728
729#[async_trait]
730impl CompactionStrategy for KeepRecentStrategy {
731    async fn apply(
732        &self,
733        request: CompactionRequest,
734        _ctx: &mut CompactionContext<'_>,
735    ) -> Result<CompactionResult, CompactionError> {
736        let removable = removable_indices(&request.transcript, &self.preserve_kinds);
737        if removable.len() <= self.keep_last {
738            return Ok(CompactionResult {
739                transcript: request.transcript,
740                replaced_items: 0,
741                metadata: MetadataMap::new(),
742            });
743        }
744
745        let keep_indices = removable
746            .iter()
747            .skip(removable.len() - self.keep_last)
748            .copied()
749            .collect::<BTreeSet<_>>();
750        let transcript = request
751            .transcript
752            .into_iter()
753            .enumerate()
754            .filter_map(|(index, item)| {
755                (self.preserve_kinds.contains(&item.kind) || keep_indices.contains(&index))
756                    .then_some(item)
757            })
758            .collect::<Vec<_>>();
759
760        Ok(CompactionResult {
761            transcript,
762            replaced_items: removable.len() - self.keep_last,
763            metadata: MetadataMap::new(),
764        })
765    }
766}
767
768/// Strategy that replaces older transcript items with a backend-generated
769/// summary.
770///
771/// The most recent `keep_last` removable items are kept verbatim. Everything
772/// older (excluding items with a preserved [`ItemKind`]) is sent to the
773/// configured [`CompactionBackend`] for summarisation. The summary items
774/// replace the originals at their position in the transcript.
775///
776/// This strategy requires a backend. If [`CompactionContext::backend`] is
777/// `None`, [`CompactionError::MissingBackend`] is returned.
778///
779/// # Example
780///
781/// ```rust
782/// use agentkit_compaction::SummarizeOlderStrategy;
783/// use agentkit_core::ItemKind;
784///
785/// let strategy = SummarizeOlderStrategy::new(8)
786///     .preserve_kind(ItemKind::System);
787/// ```
788#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
789pub struct SummarizeOlderStrategy {
790    keep_last: usize,
791    preserve_kinds: BTreeSet<ItemKind>,
792}
793
794impl SummarizeOlderStrategy {
795    /// Create a strategy that keeps the last `keep_last` removable items and
796    /// summarises everything older.
797    pub fn new(keep_last: usize) -> Self {
798        Self {
799            keep_last,
800            preserve_kinds: BTreeSet::new(),
801        }
802    }
803
804    /// Mark an [`ItemKind`] as preserved so that items of this kind are never
805    /// summarised, regardless of their position in the transcript.
806    pub fn preserve_kind(mut self, kind: ItemKind) -> Self {
807        self.preserve_kinds.insert(kind);
808        self
809    }
810}
811
812#[async_trait]
813impl CompactionStrategy for SummarizeOlderStrategy {
814    async fn apply(
815        &self,
816        request: CompactionRequest,
817        ctx: &mut CompactionContext<'_>,
818    ) -> Result<CompactionResult, CompactionError> {
819        let Some(backend) = ctx.backend else {
820            return Err(CompactionError::MissingBackend(
821                "summarize strategy requires a compaction backend".into(),
822            ));
823        };
824
825        let removable = removable_indices(&request.transcript, &self.preserve_kinds);
826        if removable.len() <= self.keep_last {
827            return Ok(CompactionResult {
828                transcript: request.transcript,
829                replaced_items: 0,
830                metadata: MetadataMap::new(),
831            });
832        }
833
834        let summary_indices = removable[..removable.len() - self.keep_last].to_vec();
835        let first_summary_index = summary_indices[0];
836        let summary_index_set = summary_indices.iter().copied().collect::<BTreeSet<_>>();
837        let summary_items = summary_indices
838            .iter()
839            .map(|index| request.transcript[*index].clone())
840            .collect::<Vec<_>>();
841        let summary = backend
842            .summarize(
843                SummaryRequest {
844                    session_id: request.session_id.clone(),
845                    turn_id: request.turn_id.clone(),
846                    items: summary_items,
847                    reason: request.reason.clone(),
848                    metadata: request.metadata.clone(),
849                },
850                ctx.cancellation.clone(),
851            )
852            .await?;
853
854        let mut transcript = Vec::new();
855        let mut inserted_summary = false;
856        let mut summary_output = Some(summary.items);
857        for (index, item) in request.transcript.into_iter().enumerate() {
858            if summary_index_set.contains(&index) {
859                if !inserted_summary && index == first_summary_index {
860                    transcript.extend(summary_output.take().unwrap_or_default());
861                    inserted_summary = true;
862                }
863                continue;
864            }
865            transcript.push(item);
866        }
867
868        Ok(CompactionResult {
869            transcript,
870            replaced_items: summary_indices.len(),
871            metadata: summary.metadata,
872        })
873    }
874}
875
876fn removable_indices(transcript: &[Item], preserve_kinds: &BTreeSet<ItemKind>) -> Vec<usize> {
877    transcript
878        .iter()
879        .enumerate()
880        .filter_map(|(index, item)| (!preserve_kinds.contains(&item.kind)).then_some(index))
881        .collect()
882}
883
884/// Errors that can occur during compaction.
885#[derive(Debug, Error)]
886pub enum CompactionError {
887    /// The operation was cancelled via the [`TurnCancellation`] token.
888    #[error("compaction cancelled")]
889    Cancelled,
890    /// A strategy that requires a [`CompactionBackend`] was invoked without
891    /// one.
892    #[error("missing compaction backend: {0}")]
893    MissingBackend(String),
894    /// A catch-all for other failures (e.g. backend errors).
895    #[error("compaction failed: {0}")]
896    Failed(String),
897}
898
899#[cfg(test)]
900mod tests {
901    use agentkit_core::{CancellationController, Part, TextPart, ToolOutput, ToolResultPart};
902
903    use super::*;
904
905    fn user_item(text: &str) -> Item {
906        Item {
907            id: None,
908            kind: ItemKind::User,
909            parts: vec![Part::Text(TextPart {
910                text: text.into(),
911                metadata: MetadataMap::new(),
912            })],
913            metadata: MetadataMap::new(),
914        }
915    }
916
917    fn assistant_with_reasoning() -> Item {
918        Item {
919            id: None,
920            kind: ItemKind::Assistant,
921            parts: vec![
922                Part::Reasoning(agentkit_core::ReasoningPart {
923                    summary: Some("think".into()),
924                    data: None,
925                    redacted: false,
926                    metadata: MetadataMap::new(),
927                }),
928                Part::Text(TextPart {
929                    text: "answer".into(),
930                    metadata: MetadataMap::new(),
931                }),
932            ],
933            metadata: MetadataMap::new(),
934        }
935    }
936
937    fn failed_tool_item() -> Item {
938        Item {
939            id: None,
940            kind: ItemKind::Tool,
941            parts: vec![Part::ToolResult(ToolResultPart {
942                call_id: "call-1".into(),
943                output: ToolOutput::Text("failed".into()),
944                is_error: true,
945                metadata: MetadataMap::new(),
946            })],
947            metadata: MetadataMap::new(),
948        }
949    }
950
951    #[test]
952    fn item_count_trigger_fires_after_limit() {
953        let trigger = ItemCountTrigger::new(2);
954        let transcript = vec![user_item("a"), user_item("b"), user_item("c")];
955        assert_eq!(
956            trigger.should_compact(&SessionId::new("s"), None, &transcript),
957            Some(CompactionReason::TranscriptTooLong)
958        );
959    }
960
961    #[tokio::test]
962    async fn pipeline_applies_local_strategies_in_order() {
963        let request = CompactionRequest {
964            session_id: "s".into(),
965            turn_id: None,
966            transcript: vec![
967                user_item("a"),
968                assistant_with_reasoning(),
969                failed_tool_item(),
970                user_item("b"),
971                user_item("c"),
972            ],
973            reason: CompactionReason::TranscriptTooLong,
974            metadata: MetadataMap::new(),
975        };
976        let pipeline = CompactionPipeline::new()
977            .with_strategy(DropReasoningStrategy::new())
978            .with_strategy(DropFailedToolResultsStrategy::new())
979            .with_strategy(
980                KeepRecentStrategy::new(2)
981                    .preserve_kind(ItemKind::System)
982                    .preserve_kind(ItemKind::Context),
983            );
984        let mut ctx = CompactionContext {
985            backend: None,
986            metadata: &MetadataMap::new(),
987            cancellation: None,
988        };
989
990        let result = pipeline.apply(request, &mut ctx).await.unwrap();
991        assert_eq!(result.transcript.len(), 2);
992        assert!(result.replaced_items >= 2);
993        assert!(result.transcript.iter().all(|item| {
994            item.parts
995                .iter()
996                .all(|part| !matches!(part, Part::Reasoning(_)))
997        }));
998    }
999
1000    struct FakeBackend;
1001
1002    #[async_trait]
1003    impl CompactionBackend for FakeBackend {
1004        async fn summarize(
1005            &self,
1006            request: SummaryRequest,
1007            _cancellation: Option<TurnCancellation>,
1008        ) -> Result<SummaryResult, CompactionError> {
1009            Ok(SummaryResult {
1010                items: vec![Item {
1011                    id: None,
1012                    kind: ItemKind::Context,
1013                    parts: vec![Part::Text(TextPart {
1014                        text: format!("summary of {} items", request.items.len()),
1015                        metadata: MetadataMap::new(),
1016                    })],
1017                    metadata: MetadataMap::new(),
1018                }],
1019                metadata: MetadataMap::new(),
1020            })
1021        }
1022    }
1023
1024    #[tokio::test]
1025    async fn summarize_strategy_uses_backend() {
1026        let request = CompactionRequest {
1027            session_id: "s".into(),
1028            turn_id: None,
1029            transcript: vec![user_item("a"), user_item("b"), user_item("c")],
1030            reason: CompactionReason::TranscriptTooLong,
1031            metadata: MetadataMap::new(),
1032        };
1033        let strategy = SummarizeOlderStrategy::new(1);
1034        let mut ctx = CompactionContext {
1035            backend: Some(&FakeBackend),
1036            metadata: &MetadataMap::new(),
1037            cancellation: None,
1038        };
1039
1040        let result = strategy.apply(request, &mut ctx).await.unwrap();
1041        assert_eq!(result.replaced_items, 2);
1042        assert_eq!(result.transcript.len(), 2);
1043        match &result.transcript[0].parts[0] {
1044            Part::Text(text) => assert_eq!(text.text, "summary of 2 items"),
1045            other => panic!("unexpected part: {other:?}"),
1046        }
1047    }
1048
1049    #[tokio::test]
1050    async fn pipeline_stops_when_cancelled() {
1051        let controller = CancellationController::new();
1052        let checkpoint = controller.handle().checkpoint();
1053        controller.interrupt();
1054        let request = CompactionRequest {
1055            session_id: "s".into(),
1056            turn_id: None,
1057            transcript: vec![user_item("a"), user_item("b"), user_item("c")],
1058            reason: CompactionReason::TranscriptTooLong,
1059            metadata: MetadataMap::new(),
1060        };
1061        let pipeline = CompactionPipeline::new().with_strategy(DropReasoningStrategy::new());
1062        let mut ctx = CompactionContext {
1063            backend: None,
1064            metadata: &MetadataMap::new(),
1065            cancellation: Some(checkpoint),
1066        };
1067
1068        let error = pipeline.apply(request, &mut ctx).await.unwrap_err();
1069        assert!(matches!(error, CompactionError::Cancelled));
1070    }
1071}