1use 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
14pub enum Edge {
16 Always {
18 to: String,
20 },
21 Conditional {
23 predicate: Arc<dyn Fn(&Value) -> bool + Send + Sync>,
25 on_true: String,
27 on_false: String,
29 },
30}
31
32pub 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 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 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 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 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 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 pub fn with_max_steps(mut self, n: u32) -> Self {
108 self.max_steps = n;
109 self
110 }
111
112 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 for _ in 0..self.max_steps {
166 crate::flow::bail_if_cancelled(&ctx)?;
167 let flow = self
168 .nodes
169 .get(¤t_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(¤t_node) {
174 None => return Ok(output), 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}