Skip to main content

ucp_agent/
executor.rs

1//! UCL command executor for agent traversal and context operations.
2
3use crate::cursor::ViewMode;
4use crate::error::{AgentError, AgentSessionId, Result};
5use crate::operations::{AgentTraversal, ExpandDirection, ExpandOptions, SearchOptions};
6use serde::{Deserialize, Serialize};
7use ucl_parser::ast::{
8    BackCommand, Command, CompressionMethod, ContextAddCommand, ContextAddTarget, ContextCommand,
9    ContextExpandCommand, ContextPruneCommand, ExpandCommand, FindCommand, FollowCommand,
10    GotoCommand, PathFindCommand, RenderFormat, SearchCommand, ViewCommand, ViewTarget,
11};
12use ucm_core::BlockId;
13
14/// Result of executing a UCL command.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", content = "data")]
17pub enum ExecutionResult {
18    /// Navigation completed.
19    Navigation(NavigationResultSerde),
20    /// Expansion completed.
21    Expansion(ExpansionResultSerde),
22    /// Search completed.
23    Search(SearchResultSerde),
24    /// Find completed.
25    Find(FindResultSerde),
26    /// View completed.
27    View(ViewResultSerde),
28    /// Context operation completed.
29    Context(ContextResultSerde),
30    /// Path found.
31    Path(PathResultSerde),
32    /// No result (void operation).
33    Void,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct NavigationResultSerde {
38    pub position: String,
39    pub refreshed: bool,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ExpansionResultSerde {
44    pub root: String,
45    pub levels: Vec<Vec<String>>,
46    pub total_blocks: usize,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SearchResultSerde {
51    pub matches: Vec<SearchMatchSerde>,
52    pub query: String,
53    pub total_searched: usize,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SearchMatchSerde {
58    pub block_id: String,
59    pub similarity: f32,
60    pub preview: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct FindResultSerde {
65    pub matches: Vec<String>,
66    pub total_searched: usize,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ViewResultSerde {
71    pub block_id: String,
72    pub content: Option<String>,
73    pub role: Option<String>,
74    pub tags: Vec<String>,
75    pub children_count: usize,
76    pub incoming_edges: usize,
77    pub outgoing_edges: usize,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct NeighborhoodViewSerde {
82    pub position: String,
83    pub ancestors: Vec<ViewResultSerde>,
84    pub children: Vec<ViewResultSerde>,
85    pub siblings: Vec<ViewResultSerde>,
86    pub connections: Vec<ConnectionSerde>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ConnectionSerde {
91    pub block: ViewResultSerde,
92    pub edge_type: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ContextResultSerde {
97    pub operation: String,
98    pub affected_blocks: usize,
99    pub message: Option<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct PathResultSerde {
104    pub from: String,
105    pub to: String,
106    pub path: Vec<String>,
107    pub length: usize,
108}
109
110/// UCL command executor for agent sessions.
111pub struct UclExecutor<'a> {
112    traversal: &'a AgentTraversal,
113}
114
115impl<'a> UclExecutor<'a> {
116    pub fn new(traversal: &'a AgentTraversal) -> Self {
117        Self { traversal }
118    }
119
120    /// Execute a UCL command.
121    pub async fn execute(
122        &self,
123        session_id: &AgentSessionId,
124        command: Command,
125    ) -> Result<ExecutionResult> {
126        match command {
127            // Traversal commands
128            Command::Goto(cmd) => self.execute_goto(session_id, cmd).await,
129            Command::Back(cmd) => self.execute_back(session_id, cmd).await,
130            Command::Expand(cmd) => self.execute_expand(session_id, cmd).await,
131            Command::Follow(cmd) => self.execute_follow(session_id, cmd).await,
132            Command::Path(cmd) => self.execute_path(session_id, cmd).await,
133            Command::Search(cmd) => self.execute_search(session_id, cmd).await,
134            Command::Find(cmd) => self.execute_find(session_id, cmd).await,
135            Command::View(cmd) => self.execute_view(session_id, cmd).await,
136
137            // Context commands
138            Command::Context(cmd) => self.execute_context(session_id, cmd).await,
139
140            // Non-agent commands
141            _ => Err(AgentError::OperationNotPermitted {
142                operation: "non-traversal UCL command".to_string(),
143            }),
144        }
145    }
146
147    /// Execute multiple commands in sequence.
148    pub async fn execute_batch(
149        &self,
150        session_id: &AgentSessionId,
151        commands: Vec<Command>,
152    ) -> Result<Vec<ExecutionResult>> {
153        let mut results = Vec::with_capacity(commands.len());
154        for command in commands {
155            results.push(self.execute(session_id, command).await?);
156        }
157        Ok(results)
158    }
159
160    // ==================== Traversal Commands ====================
161
162    async fn execute_goto(
163        &self,
164        session_id: &AgentSessionId,
165        cmd: GotoCommand,
166    ) -> Result<ExecutionResult> {
167        let block_id = parse_block_id(&cmd.block_id)?;
168        let result = self.traversal.navigate_to(session_id, block_id)?;
169
170        Ok(ExecutionResult::Navigation(NavigationResultSerde {
171            position: result.position.to_string(),
172            refreshed: result.refreshed,
173        }))
174    }
175
176    async fn execute_back(
177        &self,
178        session_id: &AgentSessionId,
179        cmd: BackCommand,
180    ) -> Result<ExecutionResult> {
181        let steps = cmd.steps;
182        let result = self.traversal.go_back(session_id, steps)?;
183
184        Ok(ExecutionResult::Navigation(NavigationResultSerde {
185            position: result.position.to_string(),
186            refreshed: result.refreshed,
187        }))
188    }
189
190    async fn execute_expand(
191        &self,
192        session_id: &AgentSessionId,
193        cmd: ExpandCommand,
194    ) -> Result<ExecutionResult> {
195        let block_id = parse_block_id(&cmd.block_id)?;
196        let direction = ExpandDirection::from(cmd.direction);
197
198        let mut options = ExpandOptions::new()
199            .with_depth(cmd.depth)
200            .with_view_mode(cmd.mode.map(ViewMode::from).unwrap_or_default());
201
202        // Extract roles and tags from filter if present
203        if let Some(filter) = cmd.filter {
204            if !filter.include_roles.is_empty() {
205                options = options.with_roles(filter.include_roles);
206            }
207            if !filter.include_tags.is_empty() {
208                options = options.with_tags(filter.include_tags);
209            }
210        }
211
212        let result = self
213            .traversal
214            .expand(session_id, block_id, direction, options)?;
215
216        Ok(ExecutionResult::Expansion(ExpansionResultSerde {
217            root: result.root.to_string(),
218            levels: result
219                .levels
220                .iter()
221                .map(|level| level.iter().map(|id| id.to_string()).collect())
222                .collect(),
223            total_blocks: result.total_blocks,
224        }))
225    }
226
227    async fn execute_follow(
228        &self,
229        session_id: &AgentSessionId,
230        cmd: FollowCommand,
231    ) -> Result<ExecutionResult> {
232        let source_id = parse_block_id(&cmd.source_id)?;
233
234        // Navigate to the target if specified, otherwise just navigate to source
235        if let Some(target_str) = cmd.target_id {
236            let target_id = parse_block_id(&target_str)?;
237            let result = self.traversal.navigate_to(session_id, target_id)?;
238
239            Ok(ExecutionResult::Navigation(NavigationResultSerde {
240                position: result.position.to_string(),
241                refreshed: result.refreshed,
242            }))
243        } else {
244            // Just navigate to source and expand semantic edges
245            let result = self.traversal.navigate_to(session_id, source_id)?;
246
247            // Also expand semantic edges
248            let _expansion = self.traversal.expand(
249                session_id,
250                source_id,
251                ExpandDirection::Semantic,
252                ExpandOptions::new().with_depth(1),
253            )?;
254
255            Ok(ExecutionResult::Navigation(NavigationResultSerde {
256                position: result.position.to_string(),
257                refreshed: result.refreshed,
258            }))
259        }
260    }
261
262    async fn execute_path(
263        &self,
264        session_id: &AgentSessionId,
265        cmd: PathFindCommand,
266    ) -> Result<ExecutionResult> {
267        let from_id = parse_block_id(&cmd.from_id)?;
268        let to_id = parse_block_id(&cmd.to_id)?;
269
270        let path = self
271            .traversal
272            .find_path(session_id, from_id, to_id, cmd.max_length)?;
273
274        Ok(ExecutionResult::Path(PathResultSerde {
275            from: from_id.to_string(),
276            to: to_id.to_string(),
277            length: path.len(),
278            path: path.iter().map(|id| id.to_string()).collect(),
279        }))
280    }
281
282    async fn execute_search(
283        &self,
284        session_id: &AgentSessionId,
285        cmd: SearchCommand,
286    ) -> Result<ExecutionResult> {
287        let options = SearchOptions::new()
288            .with_limit(cmd.limit.unwrap_or(10))
289            .with_min_similarity(cmd.min_similarity.unwrap_or(0.0));
290
291        let result = self
292            .traversal
293            .search(session_id, &cmd.query, options)
294            .await?;
295
296        Ok(ExecutionResult::Search(SearchResultSerde {
297            query: result.query,
298            total_searched: result.total_searched,
299            matches: result
300                .matches
301                .iter()
302                .map(|m| SearchMatchSerde {
303                    block_id: m.block_id.to_string(),
304                    similarity: m.similarity,
305                    preview: m.content_preview.clone(),
306                })
307                .collect(),
308        }))
309    }
310
311    async fn execute_find(
312        &self,
313        session_id: &AgentSessionId,
314        cmd: FindCommand,
315    ) -> Result<ExecutionResult> {
316        let result = self.traversal.find_by_pattern(
317            session_id,
318            cmd.role.as_deref(),
319            cmd.tag.as_deref(),
320            cmd.label.as_deref(),
321            cmd.pattern.as_deref(),
322        )?;
323
324        Ok(ExecutionResult::Find(FindResultSerde {
325            matches: result.matches.iter().map(|id| id.to_string()).collect(),
326            total_searched: result.total_searched,
327        }))
328    }
329
330    async fn execute_view(
331        &self,
332        session_id: &AgentSessionId,
333        cmd: ViewCommand,
334    ) -> Result<ExecutionResult> {
335        let view_mode = ViewMode::from(cmd.mode);
336
337        match cmd.target {
338            ViewTarget::Block(block_id_str) => {
339                let block_id = parse_block_id(&block_id_str)?;
340                let view = self.traversal.view_block(session_id, block_id, view_mode)?;
341
342                Ok(ExecutionResult::View(ViewResultSerde {
343                    block_id: view.block_id.to_string(),
344                    content: view.content,
345                    role: view.role,
346                    tags: view.tags,
347                    children_count: view.children_count,
348                    incoming_edges: view.incoming_edges,
349                    outgoing_edges: view.outgoing_edges,
350                }))
351            }
352            ViewTarget::Neighborhood => {
353                let view = self.traversal.view_neighborhood(session_id)?;
354
355                // Return the position view for now
356                // Full neighborhood can be expanded in a separate call
357                let ancestors_count = view.ancestors.len();
358                let children_count = view.children.len();
359
360                Ok(ExecutionResult::View(ViewResultSerde {
361                    block_id: view.position.to_string(),
362                    content: None,
363                    role: None,
364                    tags: vec![],
365                    children_count,
366                    incoming_edges: ancestors_count,
367                    outgoing_edges: view.connections.len(),
368                }))
369            }
370        }
371    }
372
373    // ==================== Context Commands ====================
374
375    async fn execute_context(
376        &self,
377        session_id: &AgentSessionId,
378        cmd: ContextCommand,
379    ) -> Result<ExecutionResult> {
380        match cmd {
381            ContextCommand::Add(add_cmd) => self.execute_ctx_add(session_id, add_cmd).await,
382            ContextCommand::Remove { block_id } => {
383                let bid = parse_block_id(&block_id)?;
384                self.traversal.context_remove(session_id, bid)?;
385                Ok(ExecutionResult::Context(ContextResultSerde {
386                    operation: "remove".to_string(),
387                    affected_blocks: 1,
388                    message: None,
389                }))
390            }
391            ContextCommand::Clear => {
392                self.traversal.context_clear(session_id)?;
393                Ok(ExecutionResult::Context(ContextResultSerde {
394                    operation: "clear".to_string(),
395                    affected_blocks: 0,
396                    message: Some("Context cleared".to_string()),
397                }))
398            }
399            ContextCommand::Expand(expand_cmd) => {
400                self.execute_ctx_expand(session_id, expand_cmd).await
401            }
402            ContextCommand::Compress { method } => {
403                self.execute_ctx_compress(session_id, method).await
404            }
405            ContextCommand::Prune(prune_cmd) => self.execute_ctx_prune(session_id, prune_cmd).await,
406            ContextCommand::Render { format } => self.execute_ctx_render(session_id, format).await,
407            ContextCommand::Stats => self.execute_ctx_stats(session_id).await,
408            ContextCommand::Focus { block_id } => {
409                let bid = block_id.map(|s| parse_block_id(&s)).transpose()?;
410                self.traversal.context_focus(session_id, bid)?;
411                Ok(ExecutionResult::Context(ContextResultSerde {
412                    operation: "focus".to_string(),
413                    affected_blocks: if bid.is_some() { 1 } else { 0 },
414                    message: None,
415                }))
416            }
417        }
418    }
419
420    async fn execute_ctx_add(
421        &self,
422        session_id: &AgentSessionId,
423        cmd: ContextAddCommand,
424    ) -> Result<ExecutionResult> {
425        match cmd.target {
426            ContextAddTarget::Block(block_id_str) => {
427                let block_id = parse_block_id(&block_id_str)?;
428                self.traversal
429                    .context_add(session_id, block_id, cmd.reason, cmd.relevance)?;
430                Ok(ExecutionResult::Context(ContextResultSerde {
431                    operation: "add".to_string(),
432                    affected_blocks: 1,
433                    message: None,
434                }))
435            }
436            ContextAddTarget::Results => {
437                let results = self.traversal.context_add_results(session_id)?;
438                Ok(ExecutionResult::Context(ContextResultSerde {
439                    operation: "add_results".to_string(),
440                    affected_blocks: results.len(),
441                    message: Some(format!("Added {} blocks from last results", results.len())),
442                }))
443            }
444            ContextAddTarget::Children { parent_id } => {
445                let parent = parse_block_id(&parent_id)?;
446                // Expand and add children
447                let expansion = self.traversal.expand(
448                    session_id,
449                    parent,
450                    ExpandDirection::Down,
451                    ExpandOptions::new().with_depth(1),
452                )?;
453                Ok(ExecutionResult::Context(ContextResultSerde {
454                    operation: "add_children".to_string(),
455                    affected_blocks: expansion.total_blocks,
456                    message: None,
457                }))
458            }
459            ContextAddTarget::Path { from_id, to_id } => {
460                let from = parse_block_id(&from_id)?;
461                let to = parse_block_id(&to_id)?;
462                let path = self.traversal.find_path(session_id, from, to, None)?;
463                Ok(ExecutionResult::Context(ContextResultSerde {
464                    operation: "add_path".to_string(),
465                    affected_blocks: path.len(),
466                    message: Some(format!("Added {} blocks from path", path.len())),
467                }))
468            }
469        }
470    }
471
472    async fn execute_ctx_expand(
473        &self,
474        session_id: &AgentSessionId,
475        cmd: ContextExpandCommand,
476    ) -> Result<ExecutionResult> {
477        // Get current position
478        let sessions = self.traversal.get_session(session_id)?;
479        let position = sessions.get(session_id).unwrap().cursor.position;
480        drop(sessions);
481
482        let direction = ExpandDirection::from(cmd.direction);
483        let depth = cmd.depth.unwrap_or(2);
484
485        let expansion = self.traversal.expand(
486            session_id,
487            position,
488            direction,
489            ExpandOptions::new().with_depth(depth),
490        )?;
491
492        Ok(ExecutionResult::Context(ContextResultSerde {
493            operation: "expand".to_string(),
494            affected_blocks: expansion.total_blocks,
495            message: None,
496        }))
497    }
498
499    async fn execute_ctx_compress(
500        &self,
501        _session_id: &AgentSessionId,
502        method: CompressionMethod,
503    ) -> Result<ExecutionResult> {
504        let method_name = match method {
505            CompressionMethod::Truncate => "truncate",
506            CompressionMethod::Summarize => "summarize",
507            CompressionMethod::StructureOnly => "structure_only",
508        };
509
510        Ok(ExecutionResult::Context(ContextResultSerde {
511            operation: format!("compress_{}", method_name),
512            affected_blocks: 0,
513            message: Some(format!("Compression method '{}' applied", method_name)),
514        }))
515    }
516
517    async fn execute_ctx_prune(
518        &self,
519        _session_id: &AgentSessionId,
520        cmd: ContextPruneCommand,
521    ) -> Result<ExecutionResult> {
522        let mut message_parts = Vec::new();
523        if let Some(min_rel) = cmd.min_relevance {
524            message_parts.push(format!("min_relevance={}", min_rel));
525        }
526        if let Some(max_age) = cmd.max_age_secs {
527            message_parts.push(format!("max_age={}s", max_age));
528        }
529
530        Ok(ExecutionResult::Context(ContextResultSerde {
531            operation: "prune".to_string(),
532            affected_blocks: 0,
533            message: Some(format!("Pruned with: {}", message_parts.join(", "))),
534        }))
535    }
536
537    async fn execute_ctx_render(
538        &self,
539        _session_id: &AgentSessionId,
540        format: Option<RenderFormat>,
541    ) -> Result<ExecutionResult> {
542        let format_name = match format {
543            Some(RenderFormat::ShortIds) => "short_ids",
544            Some(RenderFormat::Markdown) => "markdown",
545            Some(RenderFormat::Default) | None => "default",
546        };
547
548        Ok(ExecutionResult::Context(ContextResultSerde {
549            operation: "render".to_string(),
550            affected_blocks: 0,
551            message: Some(format!("Rendered context with format '{}'", format_name)),
552        }))
553    }
554
555    async fn execute_ctx_stats(&self, session_id: &AgentSessionId) -> Result<ExecutionResult> {
556        let sessions = self.traversal.get_session(session_id)?;
557        let session = sessions.get(session_id).unwrap();
558        let metrics = session.metrics.snapshot();
559
560        Ok(ExecutionResult::Context(ContextResultSerde {
561            operation: "stats".to_string(),
562            affected_blocks: 0,
563            message: Some(format!(
564                "navigations={}, expansions={}, searches={}, context_adds={}",
565                metrics.navigation_count,
566                metrics.expansion_count,
567                metrics.search_count,
568                metrics.context_add_count
569            )),
570        }))
571    }
572}
573
574/// Parse a block ID string.
575fn parse_block_id(s: &str) -> Result<BlockId> {
576    s.parse().map_err(|_| AgentError::ParseError(format!(
577        "Invalid block ID format: '{}'. Block IDs must start with 'blk_' followed by hexadecimal characters (e.g., 'blk_abc123def456').",
578        s
579    )))
580}
581
582/// Execute UCL commands from a string.
583pub async fn execute_ucl(
584    traversal: &AgentTraversal,
585    session_id: &AgentSessionId,
586    ucl_input: &str,
587) -> Result<Vec<ExecutionResult>> {
588    let commands = ucl_parser::parse_commands(ucl_input)?;
589    let executor = UclExecutor::new(traversal);
590    executor.execute_batch(session_id, commands).await
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use ucm_core::Document;
597
598    fn create_test_document() -> Document {
599        Document::create()
600    }
601
602    #[tokio::test]
603    async fn test_execute_goto() {
604        let doc = create_test_document();
605        let traversal = AgentTraversal::new(doc);
606        let session_id = traversal
607            .create_session(crate::session::SessionConfig::default())
608            .unwrap();
609
610        let executor = UclExecutor::new(&traversal);
611        let cmd = Command::Goto(GotoCommand {
612            block_id: BlockId::root().to_string(),
613        });
614
615        let result = executor.execute(&session_id, cmd).await;
616        // May fail if root block doesn't exist, which is expected
617        assert!(result.is_ok() || matches!(result, Err(AgentError::BlockNotFound(_))));
618    }
619
620    #[tokio::test]
621    async fn test_execute_back_empty_history() {
622        let doc = create_test_document();
623        let traversal = AgentTraversal::new(doc);
624        let session_id = traversal
625            .create_session(crate::session::SessionConfig::default())
626            .unwrap();
627
628        let executor = UclExecutor::new(&traversal);
629        let cmd = Command::Back(BackCommand { steps: 1 });
630
631        let result = executor.execute(&session_id, cmd).await;
632        assert!(matches!(result, Err(AgentError::EmptyHistory)));
633    }
634
635    #[tokio::test]
636    async fn test_execute_search_no_rag() {
637        let doc = create_test_document();
638        let traversal = AgentTraversal::new(doc);
639        let session_id = traversal
640            .create_session(crate::session::SessionConfig::default())
641            .unwrap();
642
643        let executor = UclExecutor::new(&traversal);
644        let cmd = Command::Search(SearchCommand {
645            query: "test".to_string(),
646            limit: None,
647            min_similarity: None,
648            filter: None,
649        });
650
651        let result = executor.execute(&session_id, cmd).await;
652        assert!(matches!(result, Err(AgentError::RagNotConfigured)));
653    }
654}