Skip to main content

klieo_flows/
graph.rs

1//! DAG of flows with conditional edges. Walked iteratively (no recursion)
2//! to avoid stack overflow on deep graphs.
3
4use crate::error::FlowError;
5use crate::flow::Flow;
6use async_trait::async_trait;
7use klieo_core::agent::AgentContext;
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::Arc;
11
12const DEFAULT_MAX_STEPS: u32 = 32;
13
14/// Outgoing edge from a graph node.
15pub enum Edge {
16    /// Always route to `to` regardless of node output.
17    Always {
18        /// Target node name.
19        to: String,
20    },
21    /// Branch on a predicate over the source node's output.
22    Conditional {
23        /// Predicate. Receives the source node's output.
24        predicate: Arc<dyn Fn(&Value) -> bool + Send + Sync>,
25        /// Target when predicate returns `true`.
26        on_true: String,
27        /// Target when predicate returns `false`.
28        on_false: String,
29    },
30}
31
32/// DAG of flows. Each node is itself a flow; edges route based on node
33/// output. A node without an outgoing edge is terminal — its output is
34/// the graph's output.
35pub struct GraphFlow {
36    name: String,
37    entry: String,
38    nodes: HashMap<String, Arc<dyn Flow>>,
39    edges: HashMap<String, Edge>,
40    max_steps: u32,
41}
42
43impl GraphFlow {
44    /// New empty graph rooted at `entry`. The entry node must be added via
45    /// [`GraphFlow::node`] before [`GraphFlow::run`] is called.
46    pub fn new(name: impl Into<String>, entry: impl Into<String>) -> Self {
47        Self {
48            name: name.into(),
49            entry: entry.into(),
50            nodes: HashMap::new(),
51            edges: HashMap::new(),
52            max_steps: DEFAULT_MAX_STEPS,
53        }
54    }
55
56    /// Insert a node.
57    pub fn node(mut self, name: impl Into<String>, flow: Arc<dyn Flow>) -> Self {
58        self.nodes.insert(name.into(), flow);
59        self
60    }
61
62    /// Convenience builder that wraps a concrete [`klieo_core::agent::Agent`]
63    /// in [`crate::flow::AgentFlow`] + upcasts to `Arc<dyn Flow>` in one
64    /// call.
65    pub fn node_agent<A>(self, name: impl Into<String>, agent: A) -> Self
66    where
67        A: klieo_core::agent::Agent + 'static,
68    {
69        self.node(
70            name,
71            std::sync::Arc::new(crate::flow::AgentFlow::new(agent))
72                as std::sync::Arc<dyn crate::flow::Flow>,
73        )
74    }
75
76    /// Add an unconditional edge `from -> to`.
77    pub fn edge_to(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
78        self.edges
79            .insert(from.into(), Edge::Always { to: to.into() });
80        self
81    }
82
83    /// Add a conditional edge from `from` that branches on `predicate`.
84    pub fn edge_branch<F>(
85        mut self,
86        from: impl Into<String>,
87        predicate: F,
88        on_true: impl Into<String>,
89        on_false: impl Into<String>,
90    ) -> Self
91    where
92        F: Fn(&Value) -> bool + Send + Sync + 'static,
93    {
94        self.edges.insert(
95            from.into(),
96            Edge::Conditional {
97                predicate: Arc::new(predicate),
98                on_true: on_true.into(),
99                on_false: on_false.into(),
100            },
101        );
102        self
103    }
104
105    /// Override the default step cap (32). Hitting it returns
106    /// `FlowError::Graph("max_steps exceeded; possible cycle")`.
107    pub fn with_max_steps(mut self, n: u32) -> Self {
108        self.max_steps = n;
109        self
110    }
111
112    /// Validate the graph at run-start: entry exists, every edge target
113    /// exists. Cheap O(edges) check.
114    fn validate(&self) -> Result<(), FlowError> {
115        if !self.nodes.contains_key(&self.entry) {
116            return Err(FlowError::Graph(format!("missing node: {}", self.entry)));
117        }
118        for (from, edge) in &self.edges {
119            if !self.nodes.contains_key(from) {
120                return Err(FlowError::Graph(format!("missing node: {from}")));
121            }
122            match edge {
123                Edge::Always { to } => {
124                    if !self.nodes.contains_key(to) {
125                        return Err(FlowError::Graph(format!("missing node: {to}")));
126                    }
127                }
128                Edge::Conditional {
129                    on_true, on_false, ..
130                } => {
131                    if !self.nodes.contains_key(on_true) {
132                        return Err(FlowError::Graph(format!("missing node: {on_true}")));
133                    }
134                    if !self.nodes.contains_key(on_false) {
135                        return Err(FlowError::Graph(format!("missing node: {on_false}")));
136                    }
137                }
138            }
139        }
140        Ok(())
141    }
142}
143
144#[async_trait]
145impl Flow for GraphFlow {
146    fn name(&self) -> &str {
147        &self.name
148    }
149
150    async fn run(&self, ctx: AgentContext, input: Value) -> Result<Value, FlowError> {
151        let span = tracing::info_span!(
152            "graph_flow.run",
153            flow = %self.name,
154            entry = %self.entry
155        );
156        let _guard = span.enter();
157
158        self.validate()?;
159
160        let mut current_node = self.entry.clone();
161        let mut current_input = input;
162
163        // note: walked in a `for` loop (NOT async-recursion) so deep DAGs
164        // can't overflow the stack. Hard requirement #6.
165        for _ in 0..self.max_steps {
166            crate::flow::bail_if_cancelled(&ctx)?;
167            let flow = self
168                .nodes
169                .get(&current_node)
170                .ok_or_else(|| FlowError::Graph(format!("missing node: {current_node}")))?;
171            let output = flow.run(ctx.clone(), current_input).await?;
172
173            let next = match self.edges.get(&current_node) {
174                None => return Ok(output), // terminal
175                Some(Edge::Always { to }) => to.clone(),
176                Some(Edge::Conditional {
177                    predicate,
178                    on_true,
179                    on_false,
180                }) => {
181                    if predicate(&output) {
182                        on_true.clone()
183                    } else {
184                        on_false.clone()
185                    }
186                }
187            };
188            current_node = next;
189            current_input = output;
190        }
191
192        Err(FlowError::Graph(
193            "max_steps exceeded; possible cycle".into(),
194        ))
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::test_helpers::{cancelled_ctx, ctx};
202
203    struct Boom;
204    #[async_trait]
205    impl Flow for Boom {
206        fn name(&self) -> &str {
207            "boom"
208        }
209        async fn run(&self, _c: AgentContext, _i: Value) -> Result<Value, FlowError> {
210            panic!("node must not run under a cancelled context")
211        }
212    }
213
214    #[tokio::test]
215    async fn cancelled_ctx_returns_cancelled_before_any_node() {
216        let f = GraphFlow::new("g", "start").node("start", Arc::new(Boom));
217        let err = f
218            .run(cancelled_ctx(), serde_json::json!({}))
219            .await
220            .unwrap_err();
221        assert!(matches!(err, FlowError::Cancelled), "got {err:?}");
222    }
223
224    struct ConstFlow {
225        name: String,
226        value: Value,
227    }
228
229    #[async_trait]
230    impl Flow for ConstFlow {
231        fn name(&self) -> &str {
232            &self.name
233        }
234        async fn run(&self, _ctx: AgentContext, _input: Value) -> Result<Value, FlowError> {
235            Ok(self.value.clone())
236        }
237    }
238
239    struct IncrementFlow {
240        name: String,
241    }
242
243    #[async_trait]
244    impl Flow for IncrementFlow {
245        fn name(&self) -> &str {
246            &self.name
247        }
248        async fn run(&self, _ctx: AgentContext, input: Value) -> Result<Value, FlowError> {
249            let n = input.get("n").and_then(|v| v.as_u64()).unwrap_or(0);
250            Ok(serde_json::json!({ "n": n + 1 }))
251        }
252    }
253
254    #[tokio::test]
255    async fn linear_three_node_graph() {
256        let g = GraphFlow::new("linear", "a")
257            .node("a", Arc::new(IncrementFlow { name: "a".into() }))
258            .node("b", Arc::new(IncrementFlow { name: "b".into() }))
259            .node("c", Arc::new(IncrementFlow { name: "c".into() }))
260            .edge_to("a", "b")
261            .edge_to("b", "c");
262        let out = g.run(ctx(), serde_json::json!({"n": 0})).await.unwrap();
263        assert_eq!(out, serde_json::json!({"n": 3}));
264    }
265
266    #[tokio::test]
267    async fn conditional_edge_routes_on_true() {
268        let g = GraphFlow::new("cond_t", "start")
269            .node(
270                "start",
271                Arc::new(ConstFlow {
272                    name: "s".into(),
273                    value: serde_json::json!({"big": true}),
274                }),
275            )
276            .node(
277                "yes",
278                Arc::new(ConstFlow {
279                    name: "y".into(),
280                    value: serde_json::json!("yes"),
281                }),
282            )
283            .node(
284                "no",
285                Arc::new(ConstFlow {
286                    name: "n".into(),
287                    value: serde_json::json!("no"),
288                }),
289            )
290            .edge_branch(
291                "start",
292                |v| v.get("big").and_then(|b| b.as_bool()).unwrap_or(false),
293                "yes",
294                "no",
295            );
296        let out = g.run(ctx(), serde_json::json!(null)).await.unwrap();
297        assert_eq!(out, serde_json::json!("yes"));
298    }
299
300    #[tokio::test]
301    async fn conditional_edge_routes_on_false() {
302        let g = GraphFlow::new("cond_f", "start")
303            .node(
304                "start",
305                Arc::new(ConstFlow {
306                    name: "s".into(),
307                    value: serde_json::json!({"big": false}),
308                }),
309            )
310            .node(
311                "yes",
312                Arc::new(ConstFlow {
313                    name: "y".into(),
314                    value: serde_json::json!("yes"),
315                }),
316            )
317            .node(
318                "no",
319                Arc::new(ConstFlow {
320                    name: "n".into(),
321                    value: serde_json::json!("no"),
322                }),
323            )
324            .edge_branch(
325                "start",
326                |v| v.get("big").and_then(|b| b.as_bool()).unwrap_or(false),
327                "yes",
328                "no",
329            );
330        let out = g.run(ctx(), serde_json::json!(null)).await.unwrap();
331        assert_eq!(out, serde_json::json!("no"));
332    }
333
334    #[tokio::test]
335    async fn cycle_detection_via_max_steps() {
336        let g = GraphFlow::new("cycle", "a")
337            .node("a", Arc::new(IncrementFlow { name: "a".into() }))
338            .node("b", Arc::new(IncrementFlow { name: "b".into() }))
339            .edge_to("a", "b")
340            .edge_to("b", "a")
341            .with_max_steps(4);
342        let err = g.run(ctx(), serde_json::json!({"n": 0})).await.unwrap_err();
343        match err {
344            FlowError::Graph(s) => assert!(s.contains("max_steps")),
345            other => panic!("expected Graph, got {other:?}"),
346        }
347    }
348
349    #[tokio::test]
350    async fn missing_entry_node() {
351        let g = GraphFlow::new("missing_entry", "absent")
352            .node("a", Arc::new(IncrementFlow { name: "a".into() }));
353        let err = g.run(ctx(), serde_json::json!({"n": 0})).await.unwrap_err();
354        match err {
355            FlowError::Graph(s) => assert!(s.contains("absent")),
356            other => panic!("expected Graph, got {other:?}"),
357        }
358    }
359
360    #[tokio::test]
361    async fn missing_edge_target() {
362        let g = GraphFlow::new("dangling", "a")
363            .node("a", Arc::new(IncrementFlow { name: "a".into() }))
364            .edge_to("a", "ghost");
365        let err = g.run(ctx(), serde_json::json!({"n": 0})).await.unwrap_err();
366        match err {
367            FlowError::Graph(s) => assert!(s.contains("ghost")),
368            other => panic!("expected Graph, got {other:?}"),
369        }
370    }
371
372    #[tokio::test]
373    async fn terminal_node_returns_its_output() {
374        let g = GraphFlow::new("solo", "only").node(
375            "only",
376            Arc::new(ConstFlow {
377                name: "only".into(),
378                value: serde_json::json!(42),
379            }),
380        );
381        let out = g.run(ctx(), serde_json::json!(null)).await.unwrap();
382        assert_eq!(out, serde_json::json!(42));
383    }
384}