Skip to main content

busbar_sf_agentscript/graph/
builder.rs

1//! Builder for constructing a RefGraph from an AST.
2
3use super::edges::RefEdge;
4use super::error::{GraphBuildError, ValidationError};
5use super::nodes::RefNode;
6use super::RefGraph;
7use crate::ast::{
8    Expr, InstructionPart, Instructions, ReasoningAction, ReasoningActionTarget, Reference, Type,
9    VariableKind,
10};
11use crate::AgentFile;
12use petgraph::graph::{DiGraph, NodeIndex};
13use std::collections::HashMap;
14
15/// Builder for constructing a reference graph from an AST.
16pub struct RefGraphBuilder {
17    graph: DiGraph<RefNode, RefEdge>,
18    topics: HashMap<String, NodeIndex>,
19    action_defs: HashMap<(String, String), NodeIndex>,
20    reasoning_actions: HashMap<(String, String), NodeIndex>,
21    variables: HashMap<String, NodeIndex>,
22    /// Maps variable names to their declared types for property-access validation.
23    variable_types: HashMap<String, Type>,
24    start_agent: Option<NodeIndex>,
25    unresolved_references: Vec<ValidationError>,
26}
27
28impl RefGraphBuilder {
29    /// Create a new builder.
30    pub fn new() -> Self {
31        Self {
32            graph: DiGraph::new(),
33            topics: HashMap::new(),
34            action_defs: HashMap::new(),
35            reasoning_actions: HashMap::new(),
36            variables: HashMap::new(),
37            variable_types: HashMap::new(),
38            start_agent: None,
39            unresolved_references: Vec::new(),
40        }
41    }
42
43    /// Build a RefGraph from an AgentFile AST.
44    pub fn build(mut self, ast: &AgentFile) -> Result<RefGraph, GraphBuildError> {
45        // Phase 1: Add all definition nodes
46        self.add_variables(ast)?;
47        self.add_start_agent(ast)?;
48        self.add_topics(ast)?;
49
50        // Phase 2: Add all reference edges
51        self.add_start_agent_edges(ast)?;
52        self.add_topic_edges(ast)?;
53
54        Ok(RefGraph {
55            graph: self.graph,
56            topics: self.topics,
57            action_defs: self.action_defs,
58            reasoning_actions: self.reasoning_actions,
59            variables: self.variables,
60            start_agent: self.start_agent,
61            unresolved_references: self.unresolved_references,
62        })
63    }
64
65    /// Add variable definition nodes.
66    fn add_variables(&mut self, ast: &AgentFile) -> Result<(), GraphBuildError> {
67        if let Some(variables) = &ast.variables {
68            for var in &variables.node.variables {
69                let name = var.node.name.node.clone();
70                let mutable = matches!(var.node.kind, VariableKind::Mutable);
71                let span = (var.span.start, var.span.end);
72
73                let node = RefNode::Variable {
74                    name: name.clone(),
75                    mutable,
76                    span,
77                };
78
79                let idx = self.graph.add_node(node);
80                self.variable_types
81                    .insert(name.clone(), var.node.ty.node.clone());
82                self.variables.insert(name, idx);
83            }
84        }
85        Ok(())
86    }
87
88    /// Add the start_agent node.
89    fn add_start_agent(&mut self, ast: &AgentFile) -> Result<(), GraphBuildError> {
90        if let Some(start) = &ast.start_agent {
91            let span = (start.span.start, start.span.end);
92            let node = RefNode::StartAgent { span };
93            let idx = self.graph.add_node(node);
94            self.start_agent = Some(idx);
95
96            // Add action definitions from start_agent
97            if let Some(actions) = &start.node.actions {
98                for action in &actions.node.actions {
99                    let action_name = action.node.name.node.clone();
100                    let action_span = (action.span.start, action.span.end);
101
102                    let action_node = RefNode::ActionDef {
103                        name: action_name.clone(),
104                        topic: "start_agent".to_string(),
105                        span: action_span,
106                    };
107                    let action_idx = self.graph.add_node(action_node);
108                    self.action_defs
109                        .insert(("start_agent".to_string(), action_name), action_idx);
110                }
111            }
112
113            // Add reasoning actions from start_agent
114            if let Some(reasoning) = &start.node.reasoning {
115                if let Some(actions) = &reasoning.node.actions {
116                    for action in &actions.node {
117                        let action_name = action.node.name.node.clone();
118                        let action_span = (action.span.start, action.span.end);
119                        let target = Self::extract_target(&action.node.target.node);
120
121                        let reasoning_node = RefNode::ReasoningAction {
122                            name: action_name.clone(),
123                            topic: "start_agent".to_string(),
124                            target,
125                            span: action_span,
126                        };
127                        let reasoning_idx = self.graph.add_node(reasoning_node);
128                        self.reasoning_actions
129                            .insert(("start_agent".to_string(), action_name), reasoning_idx);
130                    }
131                }
132            }
133        }
134        Ok(())
135    }
136
137    /// Add topic nodes and their child action nodes.
138    fn add_topics(&mut self, ast: &AgentFile) -> Result<(), GraphBuildError> {
139        for topic in &ast.topics {
140            let topic_name = topic.node.name.node.clone();
141            let span = (topic.span.start, topic.span.end);
142
143            // Add topic node
144            let topic_node = RefNode::Topic {
145                name: topic_name.clone(),
146                span,
147            };
148            let topic_idx = self.graph.add_node(topic_node);
149            self.topics.insert(topic_name.clone(), topic_idx);
150
151            // Add action definition nodes
152            if let Some(actions) = &topic.node.actions {
153                for action in &actions.node.actions {
154                    let action_name = action.node.name.node.clone();
155                    let action_span = (action.span.start, action.span.end);
156
157                    let action_node = RefNode::ActionDef {
158                        name: action_name.clone(),
159                        topic: topic_name.clone(),
160                        span: action_span,
161                    };
162                    let action_idx = self.graph.add_node(action_node);
163                    self.action_defs
164                        .insert((topic_name.clone(), action_name), action_idx);
165                }
166            }
167
168            // Add reasoning action nodes
169            if let Some(reasoning) = &topic.node.reasoning {
170                if let Some(actions) = &reasoning.node.actions {
171                    for action in &actions.node {
172                        let action_name = action.node.name.node.clone();
173                        let action_span = (action.span.start, action.span.end);
174                        let target = Self::extract_target(&action.node.target.node);
175
176                        let reasoning_node = RefNode::ReasoningAction {
177                            name: action_name.clone(),
178                            topic: topic_name.clone(),
179                            target,
180                            span: action_span,
181                        };
182                        let reasoning_idx = self.graph.add_node(reasoning_node);
183                        self.reasoning_actions
184                            .insert((topic_name.clone(), action_name), reasoning_idx);
185                    }
186                }
187            }
188        }
189        Ok(())
190    }
191
192    /// Extract the target string from a ReasoningActionTarget.
193    fn extract_target(target: &ReasoningActionTarget) -> Option<String> {
194        match target {
195            ReasoningActionTarget::Action(reference) => Some(reference.full_path()),
196            ReasoningActionTarget::TransitionTo(reference) => Some(reference.full_path()),
197            ReasoningActionTarget::TopicDelegate(reference) => Some(reference.full_path()),
198            ReasoningActionTarget::Escalate => Some("@utils.escalate".to_string()),
199            ReasoningActionTarget::SetVariables => Some("@utils.setVariables".to_string()),
200        }
201    }
202
203    /// Add edges from start_agent to routed topics.
204    fn add_start_agent_edges(&mut self, ast: &AgentFile) -> Result<(), GraphBuildError> {
205        let start_idx = match self.start_agent {
206            Some(idx) => idx,
207            None => return Ok(()),
208        };
209
210        if let Some(start) = &ast.start_agent {
211            // Extract topic transitions from reasoning actions
212            if let Some(reasoning) = &start.node.reasoning {
213                if let Some(instructions) = &reasoning.node.instructions {
214                    self.scan_instructions(start_idx, &instructions.node);
215                }
216
217                if let Some(actions) = &reasoning.node.actions {
218                    for action in &actions.node {
219                        // Both TransitionTo and TopicDelegate route to a topic from start_agent
220                        let routing_ref = match &action.node.target.node {
221                            ReasoningActionTarget::TransitionTo(r)
222                            | ReasoningActionTarget::TopicDelegate(r) => Some(r),
223                            _ => None,
224                        };
225                        if let Some(reference) = routing_ref {
226                            if let Some(topic_name) = Self::extract_topic_from_ref(reference) {
227                                if let Some(&topic_idx) = self.topics.get(&topic_name) {
228                                    self.graph.add_edge(start_idx, topic_idx, RefEdge::Routes);
229                                } else {
230                                    self.unresolved_references.push(
231                                        ValidationError::UnresolvedReference {
232                                            reference: reference.full_path(),
233                                            namespace: "topic".to_string(),
234                                            span: (
235                                                action.node.target.span.start,
236                                                action.node.target.span.end,
237                                            ),
238                                            context: "start_agent".to_string(),
239                                        },
240                                    );
241                                }
242                            }
243                        }
244                    }
245                }
246            }
247        }
248        Ok(())
249    }
250
251    /// Add edges within and between topics.
252    fn add_topic_edges(&mut self, ast: &AgentFile) -> Result<(), GraphBuildError> {
253        for topic in &ast.topics {
254            let topic_name = &topic.node.name.node;
255            let topic_idx = self.topics[topic_name];
256
257            // Add edges from reasoning actions to their targets
258            if let Some(reasoning) = &topic.node.reasoning {
259                if let Some(instructions) = &reasoning.node.instructions {
260                    self.scan_instructions(topic_idx, &instructions.node);
261                }
262
263                if let Some(actions) = &reasoning.node.actions {
264                    self.add_reasoning_action_edges(topic_name, topic_idx, &actions.node)?;
265                }
266            }
267        }
268        Ok(())
269    }
270
271    /// Add edges for reasoning actions in a topic.
272    fn add_reasoning_action_edges(
273        &mut self,
274        topic_name: &str,
275        topic_idx: NodeIndex,
276        actions: &[crate::Spanned<ReasoningAction>],
277    ) -> Result<(), GraphBuildError> {
278        for action in actions {
279            let action_name = &action.node.name.node;
280            let reasoning_idx =
281                self.reasoning_actions[&(topic_name.to_string(), action_name.clone())];
282
283            match &action.node.target.node {
284                ReasoningActionTarget::Action(reference) => {
285                    // Reasoning action invokes an action definition
286                    if let Some(action_ref) = Self::extract_action_name(reference) {
287                        if let Some(&target_idx) = self
288                            .action_defs
289                            .get(&(topic_name.to_string(), action_ref.clone()))
290                        {
291                            self.graph
292                                .add_edge(reasoning_idx, target_idx, RefEdge::Invokes);
293                        } else {
294                            self.unresolved_references
295                                .push(ValidationError::UnresolvedReference {
296                                    reference: reference.full_path(),
297                                    namespace: "actions".to_string(),
298                                    span: (
299                                        action.node.target.span.start,
300                                        action.node.target.span.end,
301                                    ),
302                                    context: format!("topic {}", topic_name),
303                                });
304                        }
305                    }
306                }
307                ReasoningActionTarget::TransitionTo(reference) => {
308                    // Transition to another topic
309                    if let Some(target_topic) = Self::extract_topic_from_ref(reference) {
310                        if let Some(&target_idx) = self.topics.get(&target_topic) {
311                            self.graph
312                                .add_edge(topic_idx, target_idx, RefEdge::TransitionsTo);
313                        } else {
314                            self.unresolved_references
315                                .push(ValidationError::UnresolvedReference {
316                                    reference: reference.full_path(),
317                                    namespace: "topic".to_string(),
318                                    span: (
319                                        action.node.target.span.start,
320                                        action.node.target.span.end,
321                                    ),
322                                    context: format!("topic {}", topic_name),
323                                });
324                        }
325                    }
326                }
327                ReasoningActionTarget::TopicDelegate(reference) => {
328                    // Delegate to another topic
329                    if let Some(target_topic) = Self::extract_topic_from_ref(reference) {
330                        if let Some(&target_idx) = self.topics.get(&target_topic) {
331                            self.graph
332                                .add_edge(topic_idx, target_idx, RefEdge::Delegates);
333                        } else {
334                            self.unresolved_references
335                                .push(ValidationError::UnresolvedReference {
336                                    reference: reference.full_path(),
337                                    namespace: "topic".to_string(),
338                                    span: (
339                                        action.node.target.span.start,
340                                        action.node.target.span.end,
341                                    ),
342                                    context: format!("topic {}", topic_name),
343                                });
344                        }
345                    }
346                }
347                ReasoningActionTarget::Escalate | ReasoningActionTarget::SetVariables => {
348                    // Built-in utilities, no edges to add
349                }
350            }
351
352            // Add edges for with_clauses (reading variables)
353            for clause in &action.node.with_clauses {
354                self.add_with_value_edges(reasoning_idx, &clause.node.value);
355            }
356
357            // Add edges for set_clauses (writing variables)
358            for clause in &action.node.set_clauses {
359                let target_ref = &clause.node.target.node;
360                if target_ref.namespace == "variables" {
361                    // Use first path segment as the variable name; additional
362                    // segments are property accesses on object-typed variables
363                    // (e.g. @variables.user_stats.completed_tasks → "user_stats").
364                    let var_name = target_ref
365                        .path
366                        .first()
367                        .map_or_else(|| target_ref.path.join("."), |first| first.clone());
368                    if let Some(&var_idx) = self.variables.get(&var_name) {
369                        self.graph.add_edge(reasoning_idx, var_idx, RefEdge::Writes);
370                        // Validate property access: dot notation only valid on object types
371                        if target_ref.path.len() > 1 {
372                            if let Some(ty) = self.variable_types.get(&var_name) {
373                                if *ty != Type::Object {
374                                    self.unresolved_references.push(
375                                        ValidationError::InvalidPropertyAccess {
376                                            reference: target_ref.full_path(),
377                                            variable: var_name,
378                                            variable_type: Self::type_display_name(ty),
379                                            span: (
380                                                clause.node.target.span.start,
381                                                clause.node.target.span.end,
382                                            ),
383                                        },
384                                    );
385                                }
386                            }
387                        }
388                    } else {
389                        self.unresolved_references
390                            .push(ValidationError::UnresolvedReference {
391                                reference: target_ref.full_path(),
392                                namespace: "variables".to_string(),
393                                span: (clause.node.target.span.start, clause.node.target.span.end),
394                                context: format!("set clause in topic {}", topic_name),
395                            });
396                    }
397                }
398            }
399        }
400        Ok(())
401    }
402
403    /// Scan instructions for variable and action references.
404    fn scan_instructions(&mut self, node_idx: NodeIndex, instructions: &Instructions) {
405        match instructions {
406            Instructions::Simple(_) | Instructions::Static(_) => {
407                // Simple/static instructions don't contain references
408            }
409            Instructions::Dynamic(parts) => {
410                for part in parts {
411                    self.scan_instruction_part(node_idx, part);
412                }
413            }
414        }
415    }
416
417    fn scan_instruction_part(
418        &mut self,
419        node_idx: NodeIndex,
420        part: &crate::Spanned<InstructionPart>,
421    ) {
422        match &part.node {
423            InstructionPart::Text(_) => {}
424            InstructionPart::Interpolation(expr) => {
425                let spanned_expr = crate::Spanned {
426                    node: expr.clone(),
427                    span: part.span.clone(),
428                };
429                self.add_expression_edges(node_idx, &spanned_expr);
430            }
431            InstructionPart::Conditional {
432                condition,
433                then_parts,
434                else_parts,
435            } => {
436                self.add_expression_edges(node_idx, condition);
437                for p in then_parts {
438                    self.scan_instruction_part(node_idx, p);
439                }
440                if let Some(parts) = else_parts {
441                    for p in parts {
442                        self.scan_instruction_part(node_idx, p);
443                    }
444                }
445            }
446        }
447    }
448
449    /// Add edges for variable reads within a with clause value.
450    fn add_with_value_edges(
451        &mut self,
452        from_idx: NodeIndex,
453        value: &crate::Spanned<crate::ast::WithValue>,
454    ) {
455        match &value.node {
456            crate::ast::WithValue::Expr(expr) => {
457                let spanned_expr = crate::Spanned {
458                    node: expr.clone(),
459                    span: value.span.clone(),
460                };
461                self.add_expression_edges(from_idx, &spanned_expr);
462            }
463        }
464    }
465
466    /// Add edges for variable reads within an expression.
467    fn add_expression_edges(&mut self, from_idx: NodeIndex, expr: &crate::Spanned<Expr>) {
468        match &expr.node {
469            Expr::Reference(reference) => {
470                if reference.namespace == "variables" {
471                    // Use first path segment as the variable name; additional
472                    // segments are property accesses on object-typed variables
473                    // (e.g. @variables.user_stats.completed_tasks → "user_stats").
474                    let var_name = reference
475                        .path
476                        .first()
477                        .map_or_else(|| reference.path.join("."), |first| first.clone());
478                    if let Some(&var_idx) = self.variables.get(&var_name) {
479                        self.graph.add_edge(from_idx, var_idx, RefEdge::Reads);
480                        // Validate property access: dot notation only valid on object types
481                        if reference.path.len() > 1 {
482                            if let Some(ty) = self.variable_types.get(&var_name) {
483                                if *ty != Type::Object {
484                                    self.unresolved_references.push(
485                                        ValidationError::InvalidPropertyAccess {
486                                            reference: reference.full_path(),
487                                            variable: var_name,
488                                            variable_type: Self::type_display_name(ty),
489                                            span: (expr.span.start, expr.span.end),
490                                        },
491                                    );
492                                }
493                            }
494                        }
495                    } else {
496                        self.unresolved_references
497                            .push(ValidationError::UnresolvedReference {
498                                reference: reference.full_path(),
499                                namespace: "variables".to_string(),
500                                span: (expr.span.start, expr.span.end),
501                                context: "variable read".to_string(),
502                            });
503                    }
504                } else if reference.namespace == "actions" {
505                    let topic_name = match self.graph.node_weight(from_idx) {
506                        Some(RefNode::Topic { name, .. }) => Some(name.clone()),
507                        Some(RefNode::StartAgent { .. }) => Some("start_agent".to_string()),
508                        Some(RefNode::ReasoningAction { topic, .. }) => Some(topic.clone()),
509                        _ => None,
510                    };
511
512                    if let Some(topic_name) = topic_name {
513                        if let Some(action_ref) = Self::extract_action_name(reference) {
514                            if let Some(&action_idx) = self
515                                .action_defs
516                                .get(&(topic_name.clone(), action_ref.clone()))
517                            {
518                                self.graph.add_edge(from_idx, action_idx, RefEdge::Invokes);
519                            } else {
520                                self.unresolved_references.push(
521                                    ValidationError::UnresolvedReference {
522                                        reference: reference.full_path(),
523                                        namespace: "actions".to_string(),
524                                        span: (expr.span.start, expr.span.end),
525                                        context: format!("topic {}", topic_name),
526                                    },
527                                );
528                            }
529                        }
530                    }
531                }
532            }
533            Expr::BinOp { left, right, .. } => {
534                self.add_expression_edges(from_idx, left);
535                self.add_expression_edges(from_idx, right);
536            }
537            Expr::UnaryOp { operand, .. } => {
538                self.add_expression_edges(from_idx, operand);
539            }
540            Expr::Ternary {
541                condition,
542                then_expr,
543                else_expr,
544            } => {
545                self.add_expression_edges(from_idx, condition);
546                self.add_expression_edges(from_idx, then_expr);
547                self.add_expression_edges(from_idx, else_expr);
548            }
549            Expr::List(items) => {
550                for item in items {
551                    self.add_expression_edges(from_idx, item);
552                }
553            }
554            Expr::Object(entries) => {
555                for (_, value) in entries {
556                    self.add_expression_edges(from_idx, value);
557                }
558            }
559            Expr::Property { object, .. } => {
560                self.add_expression_edges(from_idx, object);
561            }
562            Expr::Index { object, index } => {
563                self.add_expression_edges(from_idx, object);
564                self.add_expression_edges(from_idx, index);
565            }
566            // Literals and slot-fill don't have references
567            Expr::String(_) | Expr::Number(_) | Expr::Bool(_) | Expr::None | Expr::SlotFill => {}
568        }
569    }
570
571    /// Extract topic name from a @topic.name reference.
572    fn extract_topic_from_ref(reference: &Reference) -> Option<String> {
573        if reference.namespace == "topic" && !reference.path.is_empty() {
574            Some(reference.path[0].clone())
575        } else {
576            None
577        }
578    }
579
580    /// Extract action name from a @actions.name reference.
581    fn extract_action_name(reference: &Reference) -> Option<String> {
582        if reference.namespace == "actions" && !reference.path.is_empty() {
583            Some(reference.path[0].clone())
584        } else {
585            None
586        }
587    }
588
589    /// Return a human-readable name for a variable type.
590    fn type_display_name(ty: &Type) -> String {
591        match ty {
592            Type::String => "string".to_string(),
593            Type::Number => "number".to_string(),
594            Type::Boolean => "boolean".to_string(),
595            Type::Object => "object".to_string(),
596            Type::Date => "date".to_string(),
597            Type::Timestamp => "timestamp".to_string(),
598            Type::Currency => "currency".to_string(),
599            Type::Id => "id".to_string(),
600            Type::Datetime => "datetime".to_string(),
601            Type::Time => "time".to_string(),
602            Type::Integer => "integer".to_string(),
603            Type::Long => "long".to_string(),
604            Type::List(inner) => format!("list[{}]", Self::type_display_name(inner)),
605        }
606    }
607}
608
609impl Default for RefGraphBuilder {
610    fn default() -> Self {
611        Self::new()
612    }
613}