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
13pub type EdgeCondition = Arc<dyn Fn(&Context) -> bool + Send + Sync>;
15
16#[derive(Clone)]
18pub struct Edge {
19 pub from: String,
20 pub to: String,
21 pub condition: Option<EdgeCondition>,
22}
23
24pub struct Graph {
30 pub id: String,
31 tasks: HashMap<String, Arc<dyn Task>>,
32 edges: HashMap<String, Vec<Edge>>,
36 start_task_id: Option<String>,
37 task_timeout: Duration,
38 max_execution_steps: Option<usize>,
41}
42
43impl Graph {
44 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), max_execution_steps: None,
56 }
57 }
58
59 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 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 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 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 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 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 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 result.task_id = task_id.to_string();
192
193 Ok(result)
194 }
195
196 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 pub fn start_task_id(&self) -> Option<&str> {
213 self.start_task_id.as_deref()
214 }
215
216 pub fn get_task(&self, task_id: &str) -> Option<Arc<dyn Task>> {
218 self.tasks.get(task_id).cloned()
219 }
220}
221
222pub struct GraphBuilder {
227 id: String,
228 tasks: HashMap<String, Arc<dyn Task>>,
229 edges: HashMap<String, Vec<Edge>>,
230 first_task_id: Option<String>,
232 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 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 bucket.push(Edge {
287 from: from.clone(),
288 to: yes.into(),
289 condition: Some(Arc::new(condition)),
290 });
291
292 bucket.push(Edge {
294 from,
295 to: no.into(),
296 condition: None,
297 });
298
299 self
300 }
301
302 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 pub fn with_task_timeout(mut self, timeout: Duration) -> Self {
311 self.task_timeout = timeout;
312 self
313 }
314
315 pub fn with_max_execution_steps(mut self, max: usize) -> Self {
319 self.max_execution_steps = Some(max);
320 self
321 }
322
323 pub fn build(self) -> Result<Graph> {
331 if self.tasks.is_empty() {
332 tracing::warn!("Building graph with no tasks");
333 }
334
335 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 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 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#[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 {
397 next_task_id: String,
398 reason: String,
399 },
400 WaitingForInput,
402 Completed,
404}