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
14pub type EdgeCondition = Arc<dyn Fn(&Context) -> bool + Send + Sync>;
16
17#[derive(Clone)]
19pub struct Edge {
20 pub from: String,
21 pub to: String,
22 pub condition: Option<EdgeCondition>,
23}
24
25pub struct Graph {
27 pub id: String,
28 tasks: DashMap<String, Arc<dyn Task>>,
29 edges: DashMap<String, Vec<Edge>>,
33 start_task_id: Mutex<Option<String>>,
34 task_timeout: Duration,
35 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), max_execution_steps: None,
49 }
50 }
51
52 pub fn set_task_timeout(&mut self, timeout: Duration) {
54 self.task_timeout = timeout;
55 }
56
57 pub fn set_max_execution_steps(&mut self, max: Option<usize>) {
61 self.max_execution_steps = max;
62 }
63
64 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 if is_first {
72 *self.start_task_id.lock().unwrap() = Some(task_id);
73 }
74
75 self
76 }
77
78 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 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 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 bucket.push(Edge {
125 from: from.clone(),
126 to: yes.into(),
127 condition: Some(predicate),
128 });
129
130 bucket.push(Edge {
132 from,
133 to: no.into(),
134 condition: None,
135 });
136
137 self
138 }
139
140 #[allow(deprecated)] 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 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 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 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 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 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 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 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 result.task_id = task_id.to_string();
282
283 Ok(result)
284 }
285
286 #[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 match &result.next_action {
302 NextAction::Continue => {
303 if result.response.is_some() {
306 Ok(result)
307 } else {
308 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 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 pub fn start_task_id(&self) -> Option<String> {
344 self.start_task_id.lock().unwrap().clone()
345 }
346
347 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
353pub 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 pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
396 self.graph.set_task_timeout(timeout);
397 self
398 }
399
400 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 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 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 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#[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 {
459 next_task_id: String,
460 reason: String,
461 },
462 WaitingForInput,
464 Completed,
466 Error(String),
472}