Skip to main content

agent_base/engine/runtime/
session_manager.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Instant;
4
5use tokio::sync::{Mutex, RwLock};
6
7use crate::engine::AgentSession;
8use crate::engine::context::ContextWindowManager;
9use crate::engine::session_store::SessionStore;
10use crate::types::{
11    AgentError, AgentResult, MessageRole, SessionConfig, SessionId, SessionIdGenerator,
12};
13
14#[derive(Clone)]
15pub struct SessionManager {
16    session_id_generator: Arc<dyn SessionIdGenerator>,
17    sessions: Arc<RwLock<HashMap<SessionId, AgentSession>>>,
18    /// Separate LRU timestamp map — protected by a lightweight Mutex,
19    /// so session reads (RwLock::read) don't need exclusive access just to
20    /// update the last-active time.
21    lru_times: Arc<Mutex<HashMap<SessionId, Instant>>>,
22    session_store: Arc<dyn SessionStore>,
23    config: SessionConfig,
24}
25
26impl SessionManager {
27    pub fn new(
28        session_id_generator: Arc<dyn SessionIdGenerator>,
29        session_store: Arc<dyn SessionStore>,
30        config: SessionConfig,
31    ) -> Self {
32        Self {
33            session_id_generator,
34            sessions: Arc::new(RwLock::new(HashMap::new())),
35            lru_times: Arc::new(Mutex::new(HashMap::new())),
36            session_store,
37            config,
38        }
39    }
40
41    pub async fn create_session(&self, system_prompt: Option<&str>) -> SessionId {
42        // Eviction is best-effort — if it fails, we still create the session
43        if let Err(e) = self.evict_if_needed().await {
44            tracing::warn!(error = %e, "session eviction failed, proceeding with creation");
45        }
46
47        let id = self.session_id_generator.generate();
48        let mut session = AgentSession::new(id.clone());
49        if let Some(prompt) = system_prompt {
50            session.push_message(MessageRole::System, prompt);
51        }
52        {
53            let mut sessions = self.sessions.write().await;
54            sessions.insert(id.clone(), session);
55        }
56        {
57            let mut lru = self.lru_times.lock().await;
58            lru.insert(id.clone(), Instant::now());
59        }
60        tracing::debug!(session_id = id.id, "session created");
61        id
62    }
63
64    pub async fn restore_session(&self, session_id: &SessionId) -> Option<AgentSession> {
65        {
66            let sessions = self.sessions.read().await;
67            if sessions.contains_key(session_id) {
68                let mut lru = self.lru_times.lock().await;
69                lru.insert(session_id.clone(), Instant::now());
70                tracing::debug!(session_id = session_id.id, "session restore cache hit");
71                return sessions.get(session_id).cloned();
72            }
73        }
74        match self.session_store.load(session_id).await {
75            Ok(Some(session)) => {
76                let msg_count = session.chat_messages().len();
77                // Validate the restored message sequence — corrupt persisted data
78                // would cause LLM API errors downstream, so warn early.
79                if let Err(e) =
80                    crate::engine::session::validate_message_sequence(session.chat_messages())
81                {
82                    tracing::warn!(session_id = session_id.id, error = %e, "restored session has invalid message sequence");
83                }
84                // Evict before inserting to make room if needed
85                self.evict_if_needed().await.ok();
86                {
87                    let mut sessions = self.sessions.write().await;
88                    sessions.insert(session_id.clone(), session.clone());
89                }
90                {
91                    let mut lru = self.lru_times.lock().await;
92                    lru.insert(session_id.clone(), Instant::now());
93                }
94                tracing::debug!(
95                    session_id = session_id.id,
96                    msg_count,
97                    "session restored from store"
98                );
99                Some(session)
100            }
101            Ok(None) => {
102                tracing::debug!(session_id = session_id.id, "session not found in store");
103                None
104            }
105            Err(e) => {
106                tracing::warn!(session_id = session_id.id, error = %e, "session restore failed");
107                None
108            }
109        }
110    }
111
112    pub async fn session(&self, session_id: &SessionId) -> Option<AgentSession> {
113        let sessions = self.sessions.read().await;
114        let result = sessions.get(session_id).cloned();
115        if result.is_some() {
116            let mut lru = self.lru_times.lock().await;
117            lru.insert(session_id.clone(), Instant::now());
118        }
119        result
120    }
121
122    pub async fn session_or_err(&self, session_id: &SessionId) -> AgentResult<AgentSession> {
123        let sessions = self.sessions.read().await;
124        let result = sessions
125            .get(session_id)
126            .cloned()
127            .ok_or_else(|| AgentError::session_not_found(session_id.id));
128        if result.is_ok() {
129            let mut lru = self.lru_times.lock().await;
130            lru.insert(session_id.clone(), Instant::now());
131        }
132        result
133    }
134
135    pub async fn with_session_mut<F, R>(&self, session_id: &SessionId, f: F) -> AgentResult<R>
136    where
137        F: FnOnce(&mut AgentSession) -> R,
138    {
139        // The write lock on `sessions` is held only for the closure execution.
140        // It MUST be dropped before `enforce_session_limits` — that method may
141        // call `save_session` → `session_or_err` which re-acquires a (read) lock
142        // on the same `sessions` map. Holding the write lock across that call
143        // would deadlock.
144        let result = {
145            let mut sessions = self.sessions.write().await;
146            let session = sessions
147                .get_mut(session_id)
148                .ok_or_else(|| AgentError::session_not_found(session_id.id))?;
149            f(session)
150        };
151        // Update LRU timestamp (lightweight Mutex, not RwLock)
152        {
153            let mut lru = self.lru_times.lock().await;
154            lru.insert(session_id.clone(), Instant::now());
155        }
156        self.enforce_session_limits(session_id).await;
157        Ok(result)
158    }
159
160    pub async fn cached_approval(&self, session_id: &SessionId, action_key: &str) -> bool {
161        let sessions = self.sessions.read().await;
162        sessions
163            .get(session_id)
164            .is_some_and(|session| session.is_action_allowed(action_key))
165    }
166
167    pub async fn cache_approval(&self, session_id: &SessionId, action_key: String) {
168        let mut sessions = self.sessions.write().await;
169        if let Some(session) = sessions.get_mut(session_id) {
170            session.allow_action(action_key);
171        }
172    }
173
174    pub async fn save_session(&self, session_id: &SessionId) -> AgentResult<()> {
175        let session = self.session_or_err(session_id).await?;
176        let msg_count = session.chat_messages().len();
177        tracing::debug!(session_id = session_id.id, msg_count, "saving session");
178        self.session_store
179            .save(&session)
180            .await
181            .map_err(|e| AgentError::internal(format!("Session persistence failed: {e}")))
182    }
183
184    pub fn session_store(&self) -> &Arc<dyn SessionStore> {
185        &self.session_store
186    }
187
188    /// Evict the least recently used session if at capacity.
189    ///
190    /// Note: there is a benign TOCTOU window between the read-lock capacity
191    /// check and the write-lock removal — another task may concurrently create
192    /// a session and also trigger eviction. The worst case is one extra eviction,
193    /// which is harmless since `restore_session` transparently reloads from store.
194    async fn evict_if_needed(&self) -> AgentResult<()> {
195        let max = match self.config.max_sessions {
196            Some(m) => m,
197            None => return Ok(()),
198        };
199
200        // Find the LRU victim (read lock on sessions, Mutex on lru_times)
201        let victim = {
202            let sessions = self.sessions.read().await;
203            if sessions.len() < max {
204                return Ok(());
205            }
206            let lru = self.lru_times.lock().await;
207            sessions
208                .keys()
209                .min_by_key(|id| lru.get(*id).copied().unwrap_or(Instant::now()))
210                .cloned()
211        };
212
213        let Some(victim_id) = victim else {
214            return Ok(());
215        };
216
217        // Persist before evicting
218        if let Err(e) = self.save_session(&victim_id).await {
219            tracing::warn!(session_id = victim_id.id, error = %e, "failed to persist session before eviction");
220        }
221
222        // Remove from both maps
223        {
224            let mut sessions = self.sessions.write().await;
225            sessions.remove(&victim_id);
226        }
227        {
228            let mut lru = self.lru_times.lock().await;
229            lru.remove(&victim_id);
230        }
231        tracing::info!(session_id = victim_id.id, "session evicted (LRU)");
232
233        Ok(())
234    }
235
236    /// Enforce turn count and message size limits after a session mutation.
237    async fn enforce_session_limits(&self, session_id: &SessionId) {
238        // Layer 2: Turn count trimming
239        if let Some(max_turns) = self.config.max_turns_per_session {
240            let needs_trim = {
241                let sessions = self.sessions.read().await;
242                sessions
243                    .get(session_id)
244                    .is_some_and(|session| session.turn_count() > max_turns)
245            };
246
247            if needs_trim {
248                // Persist full history before trimming
249                if let Err(e) = self.save_session(session_id).await {
250                    tracing::warn!(session_id = session_id.id, error = %e, "failed to persist before turn trim");
251                }
252                // Trim under write lock
253                let mut sessions = self.sessions.write().await;
254                if let Some(session) = sessions.get_mut(session_id) {
255                    let before = session.turn_count();
256                    session.trim_oldest_turns(max_turns);
257                    tracing::info!(
258                        session_id = session_id.id,
259                        before,
260                        after = session.turn_count(),
261                        max_turns,
262                        "session turns trimmed"
263                    );
264                }
265            }
266        }
267
268        // Layer 3: Oversized message safety valve
269        if let Some(max_tokens) = self.config.max_message_tokens {
270            let mut sessions = self.sessions.write().await;
271            if let Some(session) = sessions.get_mut(session_id)
272                && let Some(last) = session.chat_messages().last()
273            {
274                let tokens = ContextWindowManager::message_tokens(last);
275                if tokens > max_tokens {
276                    session.pop_last_message();
277                    tracing::warn!(
278                        session_id = session_id.id,
279                        tokens,
280                        max_tokens,
281                        "oversized message removed from session (safety valve)"
282                    );
283                }
284            }
285        }
286    }
287}