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}