Skip to main content

graph_flow/
graph.rs

1use dashmap::DashMap;
2use std::sync::Mutex;
3use std::sync::Arc;
4use std::time::Duration;
5use tokio::time::timeout;
6
7use crate::{
8    context::Context,
9    error::{GraphError, Result},
10    storage::Session,
11    task::{NextAction, Task, TaskResult},
12};
13
14/// Type alias for edge condition functions
15pub type EdgeCondition = Arc<dyn Fn(&Context) -> bool + Send + Sync>;
16
17/// Edge between tasks in the graph
18#[derive(Clone)]
19pub struct Edge {
20    pub from: String,
21    pub to: String,
22    pub condition: Option<EdgeCondition>,
23}
24
25/// A graph of tasks that can be executed
26pub struct Graph {
27    pub id: String,
28    tasks: DashMap<String, Arc<dyn Task>>,
29    /// Outgoing edges indexed by source task id. Within a bucket, edges keep
30    /// insertion order so the first matching conditional edge (or the first
31    /// unconditional fallback) wins deterministically.
32    edges: DashMap<String, Vec<Edge>>,
33    start_task_id: Mutex<Option<String>>,
34    task_timeout: Duration,
35    /// Maximum number of chained `ContinueAndExecute` steps allowed within a
36    /// single `execute_session` call. `None` (default) means unlimited.
37    max_execution_steps: Option<usize>,
38}
39
40impl Graph {
41    pub fn new(id: impl Into<String>) -> Self {
42        Self {
43            id: id.into(),
44            tasks: DashMap::new(),
45            edges: DashMap::new(),
46            start_task_id: Mutex::new(None),
47            task_timeout: Duration::from_secs(300), // Default 5 minute timeout
48            max_execution_steps: None,
49        }
50    }
51
52    /// Set the timeout duration for task execution
53    pub fn set_task_timeout(&mut self, timeout: Duration) {
54        self.task_timeout = timeout;
55    }
56
57    /// Set the maximum number of chained `ContinueAndExecute` steps allowed
58    /// within a single `execute_session` call. Guards against accidental
59    /// infinite loops in cyclic graphs. `None` means unlimited.
60    pub fn set_max_execution_steps(&mut self, max: Option<usize>) {
61        self.max_execution_steps = max;
62    }
63
64    /// Add a task to the graph
65    pub fn add_task(&self, task: Arc<dyn Task>) -> &Self {
66        let task_id = task.id().to_string();
67        let is_first = self.tasks.is_empty();
68        self.tasks.insert(task_id.clone(), task);
69
70        // Set as start task if it's the first one
71        if is_first {
72            *self.start_task_id.lock().unwrap() = Some(task_id);
73        }
74
75        self
76    }
77
78    /// Set the starting task.
79    ///
80    /// Logs a warning and leaves the current start task unchanged if the
81    /// given task id has not been added to the graph.
82    pub fn set_start_task(&self, task_id: impl Into<String>) -> &Self {
83        let task_id = task_id.into();
84        if self.tasks.contains_key(&task_id) {
85            *self.start_task_id.lock().unwrap() = Some(task_id);
86        } else {
87            tracing::warn!(
88                task_id = %task_id,
89                "set_start_task called with unknown task id - start task unchanged"
90            );
91        }
92        self
93    }
94
95    /// Add an edge between tasks
96    pub fn add_edge(&self, from: impl Into<String>, to: impl Into<String>) -> &Self {
97        let from = from.into();
98        self.edges.entry(from.clone()).or_default().push(Edge {
99            from,
100            to: to.into(),
101            condition: None,
102        });
103        self
104    }
105
106    /// Add a conditional edge with an explicit `else` branch.
107    /// `yes` is taken when `condition(ctx)` returns `true`; otherwise `no` is chosen.
108    pub fn add_conditional_edge<F>(
109        &self,
110        from: impl Into<String>,
111        condition: F,
112        yes: impl Into<String>,
113        no: impl Into<String>,
114    ) -> &Self
115    where
116        F: Fn(&Context) -> bool + Send + Sync + 'static,
117    {
118        let from = from.into();
119        let predicate: EdgeCondition = Arc::new(condition);
120
121        let mut bucket = self.edges.entry(from.clone()).or_default();
122
123        // "yes" branch
124        bucket.push(Edge {
125            from: from.clone(),
126            to: yes.into(),
127            condition: Some(predicate),
128        });
129
130        // "else" branch (unconditional fallback)
131        bucket.push(Edge {
132            from,
133            to: no.into(),
134            condition: None,
135        });
136
137        self
138    }
139
140    /// Execute the graph with session management.
141    ///
142    /// Runs the session's current task. If the task returns
143    /// [`NextAction::ContinueAndExecute`], execution proceeds to the next task
144    /// within the same call, repeating until a task pauses, waits for input,
145    /// ends, or the configured `max_execution_steps` limit is hit.
146    ///
147    /// Note: the session is only persisted by the *caller* (e.g.
148    /// [`crate::FlowRunner::run`]) after this method returns, so context
149    /// updates made by intermediate `ContinueAndExecute` steps are not durable
150    /// until the whole chain finishes.
151    #[allow(deprecated)] // NextAction::GoBack must still be matched until removed
152    pub async fn execute_session(&self, session: &mut Session) -> Result<ExecutionResult> {
153        tracing::info!(
154            graph_id = %self.id,
155            session_id = %session.id,
156            current_task = %session.current_task_id,
157            "Starting graph execution"
158        );
159
160        let mut steps = 0usize;
161
162        loop {
163            let result = self
164                .execute_single_task(&session.current_task_id, session.context.clone())
165                .await?;
166
167            // Update session status message if provided
168            session.status_message = result.status_message.clone();
169
170            match &result.next_action {
171                NextAction::Continue | NextAction::ContinueAndExecute => {
172                    let Some(next_task_id) = self.find_next_task(&result.task_id, &session.context)
173                    else {
174                        // No outgoing edge found, stay at current task
175                        session.current_task_id = result.task_id.clone();
176                        return Ok(ExecutionResult {
177                            response: result.response,
178                            status: ExecutionStatus::Paused {
179                                next_task_id: result.task_id.clone(),
180                                reason: "No outgoing edge found from current task".to_string(),
181                            },
182                        });
183                    };
184
185                    session.current_task_id = next_task_id.clone();
186
187                    if result.next_action == NextAction::ContinueAndExecute {
188                        steps += 1;
189                        if let Some(max) = self.max_execution_steps
190                            && steps >= max
191                        {
192                            return Err(GraphError::TaskExecutionFailed(format!(
193                                "Aborted after {} chained ContinueAndExecute steps \
194                                 (max_execution_steps = {}). Possible cycle in graph '{}'",
195                                steps, max, self.id
196                            )));
197                        }
198                        // Execute the next task immediately within this call
199                        continue;
200                    }
201
202                    return Ok(ExecutionResult {
203                        response: result.response,
204                        status: ExecutionStatus::Paused {
205                            next_task_id,
206                            reason: "Task completed, continuing to next task".to_string(),
207                        },
208                    });
209                }
210                NextAction::WaitForInput => {
211                    // Stay at the current task
212                    session.current_task_id = result.task_id.clone();
213                    return Ok(ExecutionResult {
214                        response: result.response,
215                        status: ExecutionStatus::WaitingForInput,
216                    });
217                }
218                NextAction::End => {
219                    session.current_task_id = result.task_id.clone();
220                    return Ok(ExecutionResult {
221                        response: result.response,
222                        status: ExecutionStatus::Completed,
223                    });
224                }
225                NextAction::GoTo(target_id) => {
226                    if !self.tasks.contains_key(target_id) {
227                        return Err(GraphError::TaskNotFound(target_id.clone()));
228                    }
229                    session.current_task_id = target_id.clone();
230                    return Ok(ExecutionResult {
231                        response: result.response,
232                        status: ExecutionStatus::Paused {
233                            next_task_id: target_id.clone(),
234                            reason: "Task requested jump to specific task".to_string(),
235                        },
236                    });
237                }
238                NextAction::GoBack => {
239                    // Not implemented: stay at current task and wait for input
240                    session.current_task_id = result.task_id.clone();
241                    return Ok(ExecutionResult {
242                        response: result.response,
243                        status: ExecutionStatus::WaitingForInput,
244                    });
245                }
246            }
247        }
248    }
249
250    /// Execute a single task without following Continue actions
251    async fn execute_single_task(&self, task_id: &str, context: Context) -> Result<TaskResult> {
252        tracing::debug!(
253            task_id = %task_id,
254            "Executing single task"
255        );
256
257        let task = self
258            .tasks
259            .get(task_id)
260            .ok_or_else(|| GraphError::TaskNotFound(task_id.to_string()))?
261            .clone();
262
263        // Execute task with timeout
264        let mut result = match timeout(self.task_timeout, task.run(context)).await {
265            Ok(Ok(result)) => result,
266            Ok(Err(e)) => {
267                return Err(GraphError::TaskExecutionFailed(format!(
268                    "Task '{}' failed: {}",
269                    task_id, e
270                )));
271            }
272            Err(_) => {
273                return Err(GraphError::TaskExecutionFailed(format!(
274                    "Task '{}' timed out after {:?}",
275                    task_id, self.task_timeout
276                )));
277            }
278        };
279
280        // Set the task_id in the result to track which task generated it
281        result.task_id = task_id.to_string();
282
283        Ok(result)
284    }
285
286    /// Execute the graph starting from a specific task.
287    ///
288    /// Note that this method's `NextAction` semantics differ from
289    /// [`Graph::execute_session`]: `Continue` recurses into the next task when
290    /// the current task produced no response, and `ContinueAndExecute` stops.
291    #[deprecated(
292        since = "0.5.2",
293        note = "use execute_session (or FlowRunner::run) instead; this method's NextAction \
294                semantics are inconsistent with the documented behavior and it will be removed \
295                in a future release"
296    )]
297    pub async fn execute(&self, task_id: &str, context: Context) -> Result<TaskResult> {
298        let result = self.execute_single_task(task_id, context.clone()).await?;
299
300        // Handle next action
301        match &result.next_action {
302            NextAction::Continue => {
303                // If this task has a response, stop here and don't continue to next task
304                // This allows the response to be returned to the user
305                if result.response.is_some() {
306                    Ok(result)
307                } else {
308                    // Find the next task based on edges
309                    if let Some(next_task_id) = self.find_next_task(task_id, &context) {
310                        Box::pin(self.execute(&next_task_id, context)).await
311                    } else {
312                        Ok(result)
313                    }
314                }
315            }
316            NextAction::GoTo(target_id) => {
317                if self.tasks.contains_key(target_id) {
318                    Box::pin(self.execute(target_id, context)).await
319                } else {
320                    Err(GraphError::TaskNotFound(target_id.clone()))
321                }
322            }
323            _ => Ok(result),
324        }
325    }
326
327    /// Find the next task based on edges and conditions
328    pub fn find_next_task(&self, current_task_id: &str, context: &Context) -> Option<String> {
329        let edges = self.edges.get(current_task_id)?;
330
331        let mut fallback: Option<&Edge> = None;
332        for edge in edges.iter() {
333            match &edge.condition {
334                Some(pred) if pred(context) => return Some(edge.to.clone()),
335                None if fallback.is_none() => fallback = Some(edge),
336                _ => {}
337            }
338        }
339        fallback.map(|edge| edge.to.clone())
340    }
341
342    /// Get the start task ID
343    pub fn start_task_id(&self) -> Option<String> {
344        self.start_task_id.lock().unwrap().clone()
345    }
346
347    /// Get a task by ID
348    pub fn get_task(&self, task_id: &str) -> Option<Arc<dyn Task>> {
349        self.tasks.get(task_id).map(|entry| entry.clone())
350    }
351}
352
353/// Builder for creating graphs
354pub struct GraphBuilder {
355    graph: Graph,
356}
357
358impl GraphBuilder {
359    pub fn new(id: impl Into<String>) -> Self {
360        Self {
361            graph: Graph::new(id),
362        }
363    }
364
365    pub fn add_task(self, task: Arc<dyn Task>) -> Self {
366        self.graph.add_task(task);
367        self
368    }
369
370    pub fn add_edge(self, from: impl Into<String>, to: impl Into<String>) -> Self {
371        self.graph.add_edge(from, to);
372        self
373    }
374
375    pub fn add_conditional_edge<F>(
376        self,
377        from: impl Into<String>,
378        condition: F,
379        yes: impl Into<String>,
380        no: impl Into<String>,
381    ) -> Self
382    where
383        F: Fn(&Context) -> bool + Send + Sync + 'static,
384    {
385        self.graph.add_conditional_edge(from, condition, yes, no);
386        self
387    }
388
389    pub fn set_start_task(self, task_id: impl Into<String>) -> Self {
390        self.graph.set_start_task(task_id);
391        self
392    }
393
394    /// Set the timeout applied to each task execution (default: 5 minutes).
395    pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
396        self.graph.set_task_timeout(timeout);
397        self
398    }
399
400    /// Limit the number of chained `ContinueAndExecute` steps within a single
401    /// `execute_session` call, guarding against infinite loops in cyclic
402    /// graphs (default: unlimited).
403    pub fn with_max_execution_steps(mut self, max: usize) -> Self {
404        self.graph.set_max_execution_steps(Some(max));
405        self
406    }
407
408    pub fn build(self) -> Graph {
409        // Validate the graph before returning
410        if self.graph.tasks.is_empty() {
411            tracing::warn!("Building graph with no tasks");
412        }
413
414        let mut connected_tasks = std::collections::HashSet::new();
415        for bucket in self.graph.edges.iter() {
416            for edge in bucket.value() {
417                connected_tasks.insert(edge.from.clone());
418                connected_tasks.insert(edge.to.clone());
419
420                // Warn about edges pointing at tasks that were never added
421                for endpoint in [&edge.from, &edge.to] {
422                    if !self.graph.tasks.contains_key(endpoint) {
423                        tracing::warn!(
424                            task_id = %endpoint,
425                            "Edge references a task that has not been added to the graph"
426                        );
427                    }
428                }
429            }
430        }
431
432        // Check for orphaned tasks (tasks with no incoming or outgoing edges)
433        if self.graph.tasks.len() > 1 {
434            for task in self.graph.tasks.iter() {
435                if !connected_tasks.contains(task.key()) {
436                    tracing::warn!(
437                        task_id = %task.key(),
438                        "Task has no edges - it may be unreachable"
439                    );
440                }
441            }
442        }
443
444        self.graph
445    }
446}
447
448/// Status of graph execution
449#[derive(Debug, Clone)]
450pub struct ExecutionResult {
451    pub response: Option<String>,
452    pub status: ExecutionStatus,
453}
454
455#[derive(Debug, Clone)]
456pub enum ExecutionStatus {
457    /// Paused, will continue automatically to the specified next task
458    Paused {
459        next_task_id: String,
460        reason: String,
461    },
462    /// Waiting for user input to continue
463    WaitingForInput,
464    /// Workflow completed successfully
465    Completed,
466    /// Error occurred during execution
467    ///
468    /// Note: the engine currently reports failures via `Err(GraphError)`
469    /// rather than this variant; it is kept for API compatibility and may be
470    /// removed in a future major release.
471    Error(String),
472}