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}