Skip to main content

traitclaw_core/traits/
context_manager.rs

1//! Async context window management — the v0.3.0 evolution of [`ContextStrategy`].
2//!
3//! [`ContextManager`] is the async replacement for the sync [`ContextStrategy`] trait.
4//! It supports LLM-powered compression and accurate token counting.
5//!
6//! A blanket implementation is provided so that any existing [`ContextStrategy`]
7//! implementation automatically works as a [`ContextManager`] with zero code changes.
8//!
9//! # Example
10//!
11//! ```rust
12//! use traitclaw_core::traits::context_manager::ContextManager;
13//! use traitclaw_core::types::message::Message;
14//! use traitclaw_core::types::agent_state::AgentState;
15//! use async_trait::async_trait;
16//!
17//! struct MyCompressor;
18//!
19//! #[async_trait]
20//! impl ContextManager for MyCompressor {
21//!     async fn prepare(
22//!         &self,
23//!         messages: &mut Vec<Message>,
24//!         context_window: usize,
25//!         state: &mut AgentState,
26//!     ) {
27//!         // Custom async compression logic
28//!         let tokens = self.estimate_tokens(messages);
29//!         if tokens > context_window {
30//!             // Compress...
31//!         }
32//!     }
33//! }
34//! ```
35
36use async_trait::async_trait;
37
38#[allow(deprecated)]
39use crate::traits::context_strategy::ContextStrategy;
40use crate::types::agent_state::AgentState;
41use crate::types::message::Message;
42
43/// Async trait for pluggable context window management.
44///
45/// Called before each LLM request to ensure the message list fits within
46/// the model's context window. Supports async operations such as
47/// LLM-powered summarization and external token-counting APIs.
48///
49/// Implementations MUST NOT remove system messages.
50///
51/// # Migration from `ContextStrategy`
52///
53/// `ContextManager` replaces the sync [`ContextStrategy`] trait.
54/// Existing `ContextStrategy` implementations work automatically via a blanket impl.
55/// See the [migration guide](https://github.com/traitclaw/traitclaw/docs/migration-v0.2-to-v0.3.md)
56/// for details.
57#[async_trait]
58pub trait ContextManager: Send + Sync {
59    /// Prepare the message list by pruning or compressing if necessary.
60    ///
61    /// `context_window` is the model's maximum token capacity.
62    /// This method is async to support LLM-powered compression strategies.
63    async fn prepare(
64        &self,
65        messages: &mut Vec<Message>,
66        context_window: usize,
67        state: &mut AgentState,
68    );
69
70    /// Estimate the total token count for a message list.
71    ///
72    /// Default implementation uses the 4-characters ≈ 1-token approximation.
73    /// Override with [`TikTokenCounter`] for model-accurate counting.
74    fn estimate_tokens(&self, messages: &[Message]) -> usize {
75        messages.iter().map(|m| m.content.len() / 4 + 1).sum()
76    }
77}
78
79// ---------------------------------------------------------------------------
80// Blanket impl: any ContextStrategy automatically becomes a ContextManager
81// ---------------------------------------------------------------------------
82
83#[allow(deprecated)]
84#[async_trait]
85impl<T: ContextStrategy + 'static> ContextManager for T {
86    async fn prepare(
87        &self,
88        messages: &mut Vec<Message>,
89        context_window: usize,
90        state: &mut AgentState,
91    ) {
92        // Delegate to the sync ContextStrategy::prepare
93        ContextStrategy::prepare(self, messages, context_window, state);
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::types::model_info::ModelTier;
101    use std::sync::Arc;
102
103    // ── Object safety: confirm Arc<dyn ContextManager> compiles ──────────
104    #[test]
105    fn test_context_manager_is_object_safe() {
106        struct Dummy;
107
108        #[async_trait]
109        impl ContextManager for Dummy {
110            async fn prepare(
111                &self,
112                _messages: &mut Vec<Message>,
113                _context_window: usize,
114                _state: &mut AgentState,
115            ) {
116            }
117        }
118
119        let _: Arc<dyn ContextManager> = Arc::new(Dummy);
120    }
121
122    // ── Blanket impl: ContextStrategy → ContextManager ──────────────────
123    #[tokio::test]
124    async fn test_blanket_impl_delegates_to_context_strategy() {
125        #[allow(deprecated)]
126        use crate::traits::context_strategy::SlidingWindowStrategy;
127        use crate::types::message::MessageRole;
128
129        let strategy = SlidingWindowStrategy::default();
130        let mut messages = vec![
131            Message {
132                role: MessageRole::System,
133                content: "system".to_string(),
134                tool_call_id: None,
135            },
136            Message {
137                role: MessageRole::User,
138                content: "x".repeat(8000),
139                tool_call_id: None,
140            },
141            Message {
142                role: MessageRole::Assistant,
143                content: "y".repeat(8000),
144                tool_call_id: None,
145            },
146        ];
147        let mut state = AgentState::new(ModelTier::Small, 4096);
148
149        // Call through the ContextManager trait (blanket impl)
150        ContextManager::prepare(&strategy, &mut messages, 2000, &mut state).await;
151
152        // SlidingWindowStrategy should have removed some messages
153        assert!(
154            messages.len() < 3,
155            "blanket impl should delegate to sync prepare"
156        );
157        assert_eq!(
158            messages[0].role,
159            MessageRole::System,
160            "system message preserved"
161        );
162    }
163
164    // ── Default estimate_tokens() ───────────────────────────────────────
165    #[test]
166    fn test_default_estimate_tokens() {
167        struct Dummy;
168
169        #[async_trait]
170        impl ContextManager for Dummy {
171            async fn prepare(
172                &self,
173                _messages: &mut Vec<Message>,
174                _context_window: usize,
175                _state: &mut AgentState,
176            ) {
177            }
178        }
179
180        let cm = Dummy;
181        let messages = vec![
182            Message {
183                role: crate::types::message::MessageRole::User,
184                content: "a".repeat(400), // 400 chars → 400/4 + 1 = 101 tokens
185                tool_call_id: None,
186            },
187            Message {
188                role: crate::types::message::MessageRole::Assistant,
189                content: "b".repeat(800), // 800 chars → 800/4 + 1 = 201 tokens
190                tool_call_id: None,
191            },
192        ];
193
194        let tokens = cm.estimate_tokens(&messages);
195        assert_eq!(
196            tokens, 302,
197            "4-chars ≈ 1-token: (400/4+1) + (800/4+1) = 302"
198        );
199    }
200}