Skip to main content

ucp_agent/
session.rs

1//! Agent session management.
2
3use crate::cursor::{TraversalCursor, ViewMode};
4use crate::error::{AgentError, AgentSessionId, Result};
5use crate::metrics::SessionMetrics;
6use crate::safety::{BudgetTracker, SessionLimits};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10use ucm_core::{BlockId, EdgeType};
11
12/// Session state.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15#[derive(Default)]
16pub enum SessionState {
17    /// Session is active and accepting commands.
18    #[default]
19    Active,
20    /// Session is temporarily paused.
21    Paused,
22    /// Session completed successfully.
23    Completed,
24    /// Session timed out.
25    TimedOut,
26    /// Session ended with error.
27    Error { reason: String },
28}
29
30/// Agent capabilities define what operations are permitted.
31#[derive(Debug, Clone)]
32pub struct AgentCapabilities {
33    /// Can traverse the graph.
34    pub can_traverse: bool,
35    /// Can execute semantic search via RAG.
36    pub can_search: bool,
37    /// Can modify context window.
38    pub can_modify_context: bool,
39    /// Can coordinate with other agents.
40    pub can_coordinate: bool,
41    /// Allowed edge types for traversal.
42    pub allowed_edge_types: HashSet<EdgeType>,
43    /// Maximum expansion depth per operation.
44    pub max_expand_depth: usize,
45}
46
47impl Default for AgentCapabilities {
48    fn default() -> Self {
49        Self {
50            can_traverse: true,
51            can_search: true,
52            can_modify_context: true,
53            can_coordinate: true,
54            allowed_edge_types: HashSet::new(), // Empty means all allowed
55            max_expand_depth: 10,
56        }
57    }
58}
59
60impl AgentCapabilities {
61    /// Check if an edge type is allowed.
62    pub fn is_edge_allowed(&self, edge_type: &EdgeType) -> bool {
63        self.allowed_edge_types.is_empty() || self.allowed_edge_types.contains(edge_type)
64    }
65
66    /// Create capabilities with all permissions.
67    pub fn full() -> Self {
68        Self::default()
69    }
70
71    /// Create read-only capabilities (traverse only, no context modification).
72    pub fn read_only() -> Self {
73        Self {
74            can_traverse: true,
75            can_search: true,
76            can_modify_context: false,
77            can_coordinate: false,
78            ..Default::default()
79        }
80    }
81}
82
83/// Configuration for creating a new session.
84#[derive(Debug, Clone, Default)]
85pub struct SessionConfig {
86    /// Human-readable session name.
87    pub name: Option<String>,
88    /// Starting block ID (defaults to document root).
89    pub start_block: Option<BlockId>,
90    /// Session limits.
91    pub limits: SessionLimits,
92    /// Agent capabilities.
93    pub capabilities: AgentCapabilities,
94    /// Initial view mode.
95    pub view_mode: ViewMode,
96}
97
98impl SessionConfig {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    pub fn with_name(mut self, name: &str) -> Self {
104        self.name = Some(name.to_string());
105        self
106    }
107
108    pub fn with_start_block(mut self, block: BlockId) -> Self {
109        self.start_block = Some(block);
110        self
111    }
112
113    pub fn with_limits(mut self, limits: SessionLimits) -> Self {
114        self.limits = limits;
115        self
116    }
117
118    pub fn with_capabilities(mut self, capabilities: AgentCapabilities) -> Self {
119        self.capabilities = capabilities;
120        self
121    }
122
123    pub fn with_view_mode(mut self, mode: ViewMode) -> Self {
124        self.view_mode = mode;
125        self
126    }
127}
128
129/// Agent session state - tracks individual agent's position and history.
130pub struct AgentSession {
131    /// Unique session identifier.
132    pub id: AgentSessionId,
133    /// Human-readable session name.
134    pub name: Option<String>,
135    /// Current cursor position in the graph.
136    pub cursor: TraversalCursor,
137    /// Agent capabilities.
138    pub capabilities: AgentCapabilities,
139    /// Safety limits for this session.
140    pub limits: SessionLimits,
141    /// Budget tracker.
142    pub budget: BudgetTracker,
143    /// Metrics and telemetry.
144    pub metrics: SessionMetrics,
145    /// Session state.
146    pub state: SessionState,
147    /// Creation timestamp.
148    pub created_at: DateTime<Utc>,
149    /// Last activity timestamp.
150    pub last_active: DateTime<Utc>,
151    /// Last search/find results for CTX ADD RESULTS.
152    pub last_results: Vec<BlockId>,
153    /// Focus block for context (protected from pruning).
154    pub focus_block: Option<BlockId>,
155}
156
157impl AgentSession {
158    pub fn new(start_block: BlockId, config: SessionConfig) -> Self {
159        let now = Utc::now();
160        Self {
161            id: AgentSessionId::new(),
162            name: config.name,
163            cursor: TraversalCursor::new(start_block, config.limits.max_history_size),
164            capabilities: config.capabilities,
165            limits: config.limits,
166            budget: BudgetTracker::new(),
167            metrics: SessionMetrics::new(),
168            state: SessionState::Active,
169            created_at: now,
170            last_active: now,
171            last_results: Vec::new(),
172            focus_block: None,
173        }
174    }
175
176    /// Check if session is active.
177    pub fn is_active(&self) -> bool {
178        matches!(self.state, SessionState::Active)
179    }
180
181    /// Check if session has timed out.
182    pub fn is_timed_out(&self) -> bool {
183        let elapsed = Utc::now()
184            .signed_duration_since(self.last_active)
185            .to_std()
186            .unwrap_or_default();
187        elapsed >= self.limits.session_timeout
188    }
189
190    /// Update last activity timestamp.
191    pub fn touch(&mut self) {
192        self.last_active = Utc::now();
193    }
194
195    /// Mark session as completed.
196    pub fn complete(&mut self) {
197        self.state = SessionState::Completed;
198    }
199
200    /// Mark session as errored.
201    pub fn error(&mut self, reason: String) {
202        self.state = SessionState::Error { reason };
203    }
204
205    /// Pause the session.
206    pub fn pause(&mut self) {
207        self.state = SessionState::Paused;
208    }
209
210    /// Resume the session.
211    pub fn resume(&mut self) -> Result<()> {
212        match &self.state {
213            SessionState::Paused => {
214                self.state = SessionState::Active;
215                self.touch();
216                Ok(())
217            }
218            SessionState::Active => Ok(()),
219            _ => Err(AgentError::SessionClosed(self.id.clone())),
220        }
221    }
222
223    /// Check if session can perform an operation.
224    pub fn check_active(&self) -> Result<()> {
225        if !self.is_active() {
226            return Err(AgentError::SessionClosed(self.id.clone()));
227        }
228        if self.is_timed_out() {
229            return Err(AgentError::SessionExpired(self.id.clone()));
230        }
231        Ok(())
232    }
233
234    /// Check if session can traverse.
235    pub fn check_can_traverse(&self) -> Result<()> {
236        self.check_active()?;
237        if !self.capabilities.can_traverse {
238            return Err(AgentError::OperationNotPermitted {
239                operation: "traverse".to_string(),
240            });
241        }
242        Ok(())
243    }
244
245    /// Check if session can search.
246    pub fn check_can_search(&self) -> Result<()> {
247        self.check_active()?;
248        if !self.capabilities.can_search {
249            return Err(AgentError::OperationNotPermitted {
250                operation: "search".to_string(),
251            });
252        }
253        Ok(())
254    }
255
256    /// Check if session can modify context.
257    pub fn check_can_modify_context(&self) -> Result<()> {
258        self.check_active()?;
259        if !self.capabilities.can_modify_context {
260            return Err(AgentError::OperationNotPermitted {
261                operation: "modify_context".to_string(),
262            });
263        }
264        Ok(())
265    }
266
267    /// Store last search/find results.
268    pub fn store_results(&mut self, results: Vec<BlockId>) {
269        self.last_results = results;
270    }
271
272    /// Get last results (for CTX ADD RESULTS).
273    pub fn get_last_results(&self) -> Result<&[BlockId]> {
274        if self.last_results.is_empty() {
275            return Err(AgentError::NoResultsAvailable);
276        }
277        Ok(&self.last_results)
278    }
279
280    /// Set focus block.
281    pub fn set_focus(&mut self, block_id: Option<BlockId>) {
282        self.focus_block = block_id;
283    }
284
285    /// Get session info as serializable struct.
286    pub fn info(&self) -> SessionInfo {
287        SessionInfo {
288            id: self.id.to_string(),
289            name: self.name.clone(),
290            position: self.cursor.position.to_string(),
291            state: self.state.clone(),
292            created_at: self.created_at,
293            last_active: self.last_active,
294            history_depth: self.cursor.history_depth(),
295            metrics: self.metrics.snapshot(),
296        }
297    }
298}
299
300/// Serializable session info.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SessionInfo {
303    pub id: String,
304    pub name: Option<String>,
305    pub position: String,
306    pub state: SessionState,
307    pub created_at: DateTime<Utc>,
308    pub last_active: DateTime<Utc>,
309    pub history_depth: usize,
310    pub metrics: crate::metrics::MetricsSnapshot,
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn block_id(s: &str) -> BlockId {
318        s.parse().unwrap_or_else(|_| {
319            // Create a deterministic ID from the input string for testing
320            let mut bytes = [0u8; 12];
321            let s_bytes = s.as_bytes();
322            for (i, b) in s_bytes.iter().enumerate() {
323                bytes[i % 12] ^= *b;
324            }
325            BlockId::from_bytes(bytes)
326        })
327    }
328
329    #[test]
330    fn test_session_creation() {
331        let session = AgentSession::new(
332            block_id("blk_000000000001"),
333            SessionConfig::new().with_name("test"),
334        );
335
336        assert!(session.is_active());
337        assert_eq!(session.name, Some("test".to_string()));
338    }
339
340    #[test]
341    fn test_session_state_transitions() {
342        let mut session = AgentSession::new(block_id("blk_000000000001"), SessionConfig::default());
343
344        assert!(session.is_active());
345
346        session.pause();
347        assert!(!session.is_active());
348        assert!(matches!(session.state, SessionState::Paused));
349
350        session.resume().unwrap();
351        assert!(session.is_active());
352
353        session.complete();
354        assert!(!session.is_active());
355        assert!(session.resume().is_err());
356    }
357
358    #[test]
359    fn test_capabilities_check() {
360        let session = AgentSession::new(
361            block_id("blk_000000000001"),
362            SessionConfig::new().with_capabilities(AgentCapabilities::read_only()),
363        );
364
365        assert!(session.check_can_traverse().is_ok());
366        assert!(session.check_can_search().is_ok());
367        assert!(session.check_can_modify_context().is_err());
368    }
369
370    #[test]
371    fn test_last_results() {
372        let mut session = AgentSession::new(block_id("blk_000000000001"), SessionConfig::default());
373
374        // Initially no results
375        assert!(session.get_last_results().is_err());
376
377        // Store results
378        session.store_results(vec![
379            block_id("blk_000000000002"),
380            block_id("blk_000000000003"),
381        ]);
382
383        let results = session.get_last_results().unwrap();
384        assert_eq!(results.len(), 2);
385    }
386}