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