Skip to main content

graph_flow/
graph.rs

1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3use std::time::Duration;
4use tokio::time::timeout;
5
6use crate::{
7    context::Context,
8    error::{GraphError, Result},
9    storage::Session,
10    task::{NextAction, Task, TaskResult},
11};
12
13/// Type alias for edge condition functions
14pub type EdgeCondition = Arc<dyn Fn(&Context) -> bool + Send + Sync>;
15
16/// Edge between tasks in the graph
17#[derive(Clone)]
18pub struct Edge {
19    pub from: String,
20    pub to: String,
21    pub condition: Option<EdgeCondition>,
22}
23
24/// A graph of tasks that can be executed.
25///
26/// A `Graph` is immutable: it is assembled with [`GraphBuilder`] and validated
27/// by [`GraphBuilder::build`]. Because nothing can change after construction,
28/// lookups are plain `HashMap` reads with no locking.
29pub struct Graph {
30    pub id: String,
31    tasks: HashMap<String, Arc<dyn Task>>,
32    /// Outgoing edges indexed by source task id. Within a bucket, edges keep
33    /// insertion order so the first matching conditional edge (or the first
34    /// unconditional fallback) wins deterministically.
35    edges: HashMap<String, Vec<Edge>>,
36    start_task_id: Option<String>,
37    task_timeout: Duration,
38    /// Maximum number of chained `ContinueAndExecute` steps allowed within a
39    /// single `execute_session` call. `None` (default) means unlimited.
40    max_execution_steps: Option<usize>,
41}
42
43impl Graph {
44    /// Create an empty graph with the given id.
45    ///
46    /// Useful as a placeholder (e.g. in tests). Real graphs are built with
47    /// [`GraphBuilder`].
48    pub fn new(id: impl Into<String>) -> Self {
49        Self {
50            id: id.into(),
51            tasks: HashMap::new(),
52            edges: HashMap::new(),
53            start_task_id: None,
54            task_timeout: Duration::from_secs(300), // Default 5 minute timeout
55            max_execution_steps: None,
56        }
57    }
58
59    /// Execute the graph with session management.
60    ///
61    /// Runs the session's current task. If the task returns
62    /// [`NextAction::ContinueAndExecute`], execution proceeds to the next task
63    /// within the same call, repeating until a task pauses, waits for input,
64    /// ends, or the configured `max_execution_steps` limit is hit.
65    ///
66    /// Note: the session is only persisted by the *caller* (e.g.
67    /// [`crate::FlowRunner::run`]) after this method returns, so context
68    /// updates made by intermediate `ContinueAndExecute` steps are not durable
69    /// until the whole chain finishes.
70    pub async fn execute_session(&self, session: &mut Session) -> Result<ExecutionResult> {
71        tracing::info!(
72            graph_id = %self.id,
73            session_id = %session.id,
74            current_task = %session.current_task_id,
75            "Starting graph execution"
76        );
77
78        let mut steps = 0usize;
79
80        loop {
81            let result = self
82                .execute_single_task(&session.current_task_id, session.context.clone())
83                .await?;
84
85            // Update session status message if provided
86            session.status_message = result.status_message.clone();
87
88            match &result.next_action {
89                NextAction::Continue | NextAction::ContinueAndExecute => {
90                    let Some(next_task_id) = self.find_next_task(&result.task_id, &session.context)
91                    else {
92                        // No outgoing edge found, stay at current task
93                        session.current_task_id = result.task_id.clone();
94                        return Ok(ExecutionResult {
95                            response: result.response,
96                            status: ExecutionStatus::Paused {
97                                next_task_id: result.task_id.clone(),
98                                reason: "No outgoing edge found from current task".to_string(),
99                            },
100                        });
101                    };
102
103                    session.current_task_id = next_task_id.clone();
104
105                    if result.next_action == NextAction::ContinueAndExecute {
106                        steps += 1;
107                        if let Some(max) = self.max_execution_steps
108                            && steps >= max
109                        {
110                            return Err(GraphError::TaskExecutionFailed(format!(
111                                "Aborted after {} chained ContinueAndExecute steps \
112                                 (max_execution_steps = {}). Possible cycle in graph '{}'",
113                                steps, max, self.id
114                            )));
115                        }
116                        // Execute the next task immediately within this call
117                        continue;
118                    }
119
120                    return Ok(ExecutionResult {
121                        response: result.response,
122                        status: ExecutionStatus::Paused {
123                            next_task_id,
124                            reason: "Task completed, continuing to next task".to_string(),
125                        },
126                    });
127                }
128                NextAction::WaitForInput => {
129                    // Stay at the current task
130                    session.current_task_id = result.task_id.clone();
131                    return Ok(ExecutionResult {
132                        response: result.response,
133                        status: ExecutionStatus::WaitingForInput,
134                    });
135                }
136                NextAction::End => {
137                    session.current_task_id = result.task_id.clone();
138                    return Ok(ExecutionResult {
139                        response: result.response,
140                        status: ExecutionStatus::Completed,
141                    });
142                }
143                NextAction::GoTo(target_id) => {
144                    if !self.tasks.contains_key(target_id) {
145                        return Err(GraphError::TaskNotFound(target_id.clone()));
146                    }
147                    session.current_task_id = target_id.clone();
148                    return Ok(ExecutionResult {
149                        response: result.response,
150                        status: ExecutionStatus::Paused {
151                            next_task_id: target_id.clone(),
152                            reason: "Task requested jump to specific task".to_string(),
153                        },
154                    });
155                }
156            }
157        }
158    }
159
160    /// Execute a single task without following Continue actions
161    async fn execute_single_task(&self, task_id: &str, context: Context) -> Result<TaskResult> {
162        tracing::debug!(
163            task_id = %task_id,
164            "Executing single task"
165        );
166
167        let task = self
168            .tasks
169            .get(task_id)
170            .ok_or_else(|| GraphError::TaskNotFound(task_id.to_string()))?
171            .clone();
172
173        // Execute task with timeout
174        let mut result = match timeout(self.task_timeout, task.run(context)).await {
175            Ok(Ok(result)) => result,
176            Ok(Err(e)) => {
177                return Err(GraphError::TaskExecutionFailed(format!(
178                    "Task '{}' failed: {}",
179                    task_id, e
180                )));
181            }
182            Err(_) => {
183                return Err(GraphError::TaskExecutionFailed(format!(
184                    "Task '{}' timed out after {:?}",
185                    task_id, self.task_timeout
186                )));
187            }
188        };
189
190        // Set the task_id in the result to track which task generated it
191        result.task_id = task_id.to_string();
192
193        Ok(result)
194    }
195
196    /// Find the next task based on edges and conditions
197    pub fn find_next_task(&self, current_task_id: &str, context: &Context) -> Option<String> {
198        let edges = self.edges.get(current_task_id)?;
199
200        let mut fallback: Option<&Edge> = None;
201        for edge in edges {
202            match &edge.condition {
203                Some(pred) if pred(context) => return Some(edge.to.clone()),
204                None if fallback.is_none() => fallback = Some(edge),
205                _ => {}
206            }
207        }
208        fallback.map(|edge| edge.to.clone())
209    }
210
211    /// Get the start task ID
212    pub fn start_task_id(&self) -> Option<&str> {
213        self.start_task_id.as_deref()
214    }
215
216    /// Get a task by ID
217    pub fn get_task(&self, task_id: &str) -> Option<Arc<dyn Task>> {
218        self.tasks.get(task_id).cloned()
219    }
220}
221
222/// Builder for creating graphs.
223///
224/// The builder owns all graph data until [`GraphBuilder::build`] validates it
225/// and produces an immutable [`Graph`].
226pub struct GraphBuilder {
227    id: String,
228    tasks: HashMap<String, Arc<dyn Task>>,
229    edges: HashMap<String, Vec<Edge>>,
230    /// First task added; used as the start task unless overridden.
231    first_task_id: Option<String>,
232    /// Explicit start task set via `set_start_task`.
233    start_task_id: Option<String>,
234    task_timeout: Duration,
235    max_execution_steps: Option<usize>,
236}
237
238impl GraphBuilder {
239    pub fn new(id: impl Into<String>) -> Self {
240        Self {
241            id: id.into(),
242            tasks: HashMap::new(),
243            edges: HashMap::new(),
244            first_task_id: None,
245            start_task_id: None,
246            task_timeout: Duration::from_secs(300),
247            max_execution_steps: None,
248        }
249    }
250
251    pub fn add_task(mut self, task: Arc<dyn Task>) -> Self {
252        let task_id = task.id().to_string();
253        if self.first_task_id.is_none() {
254            self.first_task_id = Some(task_id.clone());
255        }
256        self.tasks.insert(task_id, task);
257        self
258    }
259
260    pub fn add_edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
261        let from = from.into();
262        self.edges.entry(from.clone()).or_default().push(Edge {
263            from,
264            to: to.into(),
265            condition: None,
266        });
267        self
268    }
269
270    /// Add a conditional edge with an explicit `else` branch.
271    /// `yes` is taken when `condition(ctx)` returns `true`; otherwise `no` is chosen.
272    pub fn add_conditional_edge<F>(
273        mut self,
274        from: impl Into<String>,
275        condition: F,
276        yes: impl Into<String>,
277        no: impl Into<String>,
278    ) -> Self
279    where
280        F: Fn(&Context) -> bool + Send + Sync + 'static,
281    {
282        let from = from.into();
283        let bucket = self.edges.entry(from.clone()).or_default();
284
285        // "yes" branch
286        bucket.push(Edge {
287            from: from.clone(),
288            to: yes.into(),
289            condition: Some(Arc::new(condition)),
290        });
291
292        // "else" branch (unconditional fallback)
293        bucket.push(Edge {
294            from,
295            to: no.into(),
296            condition: None,
297        });
298
299        self
300    }
301
302    /// Set the starting task. Validated in [`GraphBuilder::build`]; defaults
303    /// to the first task added.
304    pub fn set_start_task(mut self, task_id: impl Into<String>) -> Self {
305        self.start_task_id = Some(task_id.into());
306        self
307    }
308
309    /// Set the timeout applied to each task execution (default: 5 minutes).
310    pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
311        self.task_timeout = timeout;
312        self
313    }
314
315    /// Limit the number of chained `ContinueAndExecute` steps within a single
316    /// `execute_session` call, guarding against infinite loops in cyclic
317    /// graphs (default: unlimited).
318    pub fn with_max_execution_steps(mut self, max: usize) -> Self {
319        self.max_execution_steps = Some(max);
320        self
321    }
322
323    /// Validate the graph and produce an immutable [`Graph`].
324    ///
325    /// # Errors
326    ///
327    /// - [`GraphError::InvalidEdge`] if an edge references a task that was
328    ///   never added
329    /// - [`GraphError::TaskNotFound`] if `set_start_task` names an unknown task
330    pub fn build(self) -> Result<Graph> {
331        if self.tasks.is_empty() {
332            tracing::warn!("Building graph with no tasks");
333        }
334
335        // Every edge endpoint must be a known task
336        let mut connected_tasks = HashSet::new();
337        for edge in self.edges.values().flatten() {
338            for endpoint in [&edge.from, &edge.to] {
339                if !self.tasks.contains_key(endpoint) {
340                    return Err(GraphError::InvalidEdge(format!(
341                        "Edge '{}' -> '{}' references task '{}' which was not added to graph '{}'",
342                        edge.from, edge.to, endpoint, self.id
343                    )));
344                }
345                connected_tasks.insert(endpoint.clone());
346            }
347        }
348
349        // An explicit start task must exist
350        let start_task_id = match self.start_task_id {
351            Some(id) => {
352                if !self.tasks.contains_key(&id) {
353                    return Err(GraphError::TaskNotFound(format!(
354                        "Start task '{}' was not added to graph '{}'",
355                        id, self.id
356                    )));
357                }
358                Some(id)
359            }
360            None => self.first_task_id,
361        };
362
363        // Warn about orphaned tasks (no incoming or outgoing edges)
364        if self.tasks.len() > 1 {
365            for task_id in self.tasks.keys() {
366                if !connected_tasks.contains(task_id) {
367                    tracing::warn!(
368                        task_id = %task_id,
369                        "Task has no edges - it may be unreachable"
370                    );
371                }
372            }
373        }
374
375        Ok(Graph {
376            id: self.id,
377            tasks: self.tasks,
378            edges: self.edges,
379            start_task_id,
380            task_timeout: self.task_timeout,
381            max_execution_steps: self.max_execution_steps,
382        })
383    }
384}
385
386/// Status of graph execution
387#[derive(Debug, Clone)]
388pub struct ExecutionResult {
389    pub response: Option<String>,
390    pub status: ExecutionStatus,
391}
392
393#[derive(Debug, Clone)]
394pub enum ExecutionStatus {
395    /// Paused, will continue automatically to the specified next task
396    Paused {
397        next_task_id: String,
398        reason: String,
399    },
400    /// Waiting for user input to continue
401    WaitingForInput,
402    /// Workflow completed successfully
403    Completed,
404}