busbar_sf_agentscript/graph/
queries.rs1use super::edges::RefEdge;
4use super::nodes::RefNode;
5use super::RefGraph;
6use petgraph::algo::toposort;
7use petgraph::graph::NodeIndex;
8use petgraph::visit::EdgeRef;
9use petgraph::Direction;
10
11#[derive(Debug, Clone)]
13pub struct QueryResult {
14 pub nodes: Vec<NodeIndex>,
16}
17
18impl QueryResult {
19 pub fn is_empty(&self) -> bool {
21 self.nodes.is_empty()
22 }
23
24 pub fn len(&self) -> usize {
26 self.nodes.len()
27 }
28}
29
30impl RefGraph {
31 pub fn find_usages(&self, target: NodeIndex) -> QueryResult {
35 let nodes = self
36 .graph
37 .edges_directed(target, Direction::Incoming)
38 .map(|e| e.source())
39 .collect();
40
41 QueryResult { nodes }
42 }
43
44 pub fn find_dependencies(&self, source: NodeIndex) -> QueryResult {
48 let nodes = self
49 .graph
50 .edges_directed(source, Direction::Outgoing)
51 .map(|e| e.target())
52 .collect();
53
54 QueryResult { nodes }
55 }
56
57 pub fn find_incoming_transitions(&self, topic: NodeIndex) -> QueryResult {
59 let nodes = self
60 .graph
61 .edges_directed(topic, Direction::Incoming)
62 .filter(|e| {
63 matches!(e.weight(), RefEdge::TransitionsTo | RefEdge::Delegates | RefEdge::Routes)
64 })
65 .map(|e| e.source())
66 .collect();
67
68 QueryResult { nodes }
69 }
70
71 pub fn find_outgoing_transitions(&self, topic: NodeIndex) -> QueryResult {
73 let nodes = self
74 .graph
75 .edges_directed(topic, Direction::Outgoing)
76 .filter(|e| matches!(e.weight(), RefEdge::TransitionsTo | RefEdge::Delegates))
77 .map(|e| e.target())
78 .collect();
79
80 QueryResult { nodes }
81 }
82
83 pub fn find_action_invokers(&self, action_def: NodeIndex) -> QueryResult {
85 let nodes = self
86 .graph
87 .edges_directed(action_def, Direction::Incoming)
88 .filter(|e| matches!(e.weight(), RefEdge::Invokes))
89 .map(|e| e.source())
90 .collect();
91
92 QueryResult { nodes }
93 }
94
95 pub fn find_variable_readers(&self, variable: NodeIndex) -> QueryResult {
97 let nodes = self
98 .graph
99 .edges_directed(variable, Direction::Incoming)
100 .filter(|e| matches!(e.weight(), RefEdge::Reads))
101 .map(|e| e.source())
102 .collect();
103
104 QueryResult { nodes }
105 }
106
107 pub fn find_variable_writers(&self, variable: NodeIndex) -> QueryResult {
109 let nodes = self
110 .graph
111 .edges_directed(variable, Direction::Incoming)
112 .filter(|e| matches!(e.weight(), RefEdge::Writes))
113 .map(|e| e.source())
114 .collect();
115
116 QueryResult { nodes }
117 }
118
119 pub fn topic_execution_order(&self) -> Option<Vec<NodeIndex>> {
123 let topic_indices: Vec<_> = self.topics.values().copied().collect();
125
126 toposort(&self.graph, None).ok().map(|sorted| {
128 sorted
129 .into_iter()
130 .filter(|idx| topic_indices.contains(idx))
131 .collect()
132 })
133 }
134
135 pub fn get_topic_reasoning_actions(&self, topic_name: &str) -> Vec<NodeIndex> {
137 self.reasoning_actions
138 .iter()
139 .filter_map(|((t, _), &idx)| if t == topic_name { Some(idx) } else { None })
140 .collect()
141 }
142
143 pub fn get_topic_action_defs(&self, topic_name: &str) -> Vec<NodeIndex> {
145 self.action_defs
146 .iter()
147 .filter_map(|((t, _), &idx)| if t == topic_name { Some(idx) } else { None })
148 .collect()
149 }
150
151 pub fn stats(&self) -> GraphStats {
153 let mut stats = GraphStats::default();
154
155 for idx in self.graph.node_indices() {
156 match self.graph.node_weight(idx) {
157 Some(RefNode::Topic { .. }) => stats.topics += 1,
158 Some(RefNode::ActionDef { .. }) => stats.action_defs += 1,
159 Some(RefNode::ReasoningAction { .. }) => stats.reasoning_actions += 1,
160 Some(RefNode::Variable { .. }) => stats.variables += 1,
161 Some(RefNode::StartAgent { .. }) => stats.has_start_agent = true,
162 Some(RefNode::Connection { .. }) => stats.connections += 1,
163 None => {}
164 }
165 }
166
167 for edge in self.graph.edge_references() {
168 match edge.weight() {
169 RefEdge::TransitionsTo | RefEdge::Delegates => stats.transitions += 1,
170 RefEdge::Invokes => stats.invocations += 1,
171 RefEdge::Reads => stats.reads += 1,
172 RefEdge::Writes => stats.writes += 1,
173 _ => {}
174 }
175 }
176
177 stats
178 }
179}
180
181#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
183pub struct GraphStats {
184 pub topics: usize,
185 pub action_defs: usize,
186 pub reasoning_actions: usize,
187 pub variables: usize,
188 pub connections: usize,
189 pub has_start_agent: bool,
190 pub transitions: usize,
191 pub invocations: usize,
192 pub reads: usize,
193 pub writes: usize,
194}
195
196impl GraphStats {
197 pub fn total_definitions(&self) -> usize {
199 self.topics + self.action_defs + self.reasoning_actions + self.variables + self.connections
200 }
201
202 pub fn total_edges(&self) -> usize {
204 self.transitions + self.invocations + self.reads + self.writes
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use crate::graph::RefGraph;
211
212 fn parse_and_build(source: &str) -> RefGraph {
213 let ast = crate::parse(source).expect("Failed to parse");
214 RefGraph::from_ast(&ast).expect("Failed to build graph")
215 }
216
217 fn two_topic_source() -> &'static str {
219 r#"config:
220 agent_name: "Test"
221
222start_agent selector:
223 description: "Route"
224 reasoning:
225 instructions: "Select"
226 actions:
227 go_a: @utils.transition to @topic.topic_a
228 description: "Go to A"
229
230topic topic_a:
231 description: "Topic A"
232 reasoning:
233 instructions: "In A"
234 actions:
235 go_b: @utils.transition to @topic.topic_b
236 description: "Go to B"
237
238topic topic_b:
239 description: "Topic B"
240 reasoning:
241 instructions: "In B"
242"#
243 }
244
245 #[test]
246 fn test_find_outgoing_transitions_from_topic_a() {
247 let graph = parse_and_build(two_topic_source());
250 let topic_a_idx = graph.get_topic("topic_a").expect("topic_a not found");
251 let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
252
253 let result = graph.find_outgoing_transitions(topic_a_idx);
254 assert_eq!(result.len(), 1, "Expected exactly 1 outgoing transition from topic_a");
255 assert_eq!(result.nodes[0], topic_b_idx, "Expected transition target to be topic_b");
256 }
257
258 #[test]
259 fn test_find_incoming_transitions_to_topic_b() {
260 let graph = parse_and_build(two_topic_source());
263 let topic_a_idx = graph.get_topic("topic_a").expect("topic_a not found");
264 let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
265
266 let result = graph.find_incoming_transitions(topic_b_idx);
267 assert_eq!(result.len(), 1, "Expected exactly 1 incoming transition to topic_b");
268 assert_eq!(result.nodes[0], topic_a_idx, "Expected transition source to be topic_a");
269 }
270
271 #[test]
272 fn test_find_outgoing_transitions_empty_for_leaf_topic() {
273 let graph = parse_and_build(two_topic_source());
275 let topic_b_idx = graph.get_topic("topic_b").expect("topic_b not found");
276
277 let result = graph.find_outgoing_transitions(topic_b_idx);
278 assert!(result.is_empty(), "Expected no outgoing transitions from leaf topic_b");
279 }
280
281 #[test]
282 fn test_topic_execution_order_for_acyclic_graph() {
283 let graph = parse_and_build(two_topic_source());
286 let order = graph.topic_execution_order();
287 assert!(order.is_some(), "Expected a valid topological order for an acyclic graph");
288
289 let order = order.unwrap();
290 let topic_a_pos = order
291 .iter()
292 .position(|&idx| idx == graph.get_topic("topic_a").unwrap());
293 let topic_b_pos = order
294 .iter()
295 .position(|&idx| idx == graph.get_topic("topic_b").unwrap());
296
297 assert!(topic_a_pos.is_some(), "topic_a should appear in execution order");
298 assert!(topic_b_pos.is_some(), "topic_b should appear in execution order");
299 assert!(
300 topic_a_pos.unwrap() < topic_b_pos.unwrap(),
301 "topic_a should come before topic_b in topological order"
302 );
303 }
304
305 #[test]
306 fn test_stats_counts_nodes_correctly() {
307 let source = r#"config:
309 agent_name: "Test"
310
311variables:
312 order_id: mutable string = ""
313 description: "Order ID"
314
315start_agent selector:
316 description: "Route"
317 reasoning:
318 instructions: "Select"
319 actions:
320 go_main: @utils.transition to @topic.main
321 description: "Go to main"
322
323topic main:
324 description: "Main topic"
325
326 actions:
327 get_order:
328 description: "Gets an order"
329 inputs:
330 id: string
331 description: "Order identifier"
332 outputs:
333 status: string
334 description: "Order status"
335 target: "flow://GetOrder"
336
337 reasoning:
338 instructions: "Help"
339"#;
340 let graph = parse_and_build(source);
341 let stats = graph.stats();
342
343 assert_eq!(stats.topics, 1, "Expected 1 topic");
344 assert!(stats.has_start_agent, "Expected start_agent to be present");
345 assert_eq!(stats.action_defs, 1, "Expected 1 action def (get_order)");
346 assert_eq!(stats.variables, 1, "Expected 1 variable (order_id)");
347 assert!(graph.edge_count() > 0, "Expected at least one edge in the graph");
349 }
350}