1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", content = "data")]
17pub enum ExecutionResult {
18 Navigation(NavigationResultSerde),
20 Expansion(ExpansionResultSerde),
22 Search(SearchResultSerde),
24 Find(FindResultSerde),
26 View(ViewResultSerde),
28 Context(ContextResultSerde),
30 Path(PathResultSerde),
32 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
110pub 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 pub async fn execute(
122 &self,
123 session_id: &AgentSessionId,
124 command: Command,
125 ) -> Result<ExecutionResult> {
126 match command {
127 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 Command::Context(cmd) => self.execute_context(session_id, cmd).await,
139
140 _ => Err(AgentError::OperationNotPermitted {
142 operation: "non-traversal UCL command".to_string(),
143 }),
144 }
145 }
146
147 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 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 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 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 let result = self.traversal.navigate_to(session_id, source_id)?;
246
247 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 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 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 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 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
574fn 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
582pub 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 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}