11import logging
22import asyncio
3+ import time
34import json
45from typing import Dict , Optional , List , Any , AsyncGenerator
56from pydantic import BaseModel , ConfigDict
@@ -25,6 +26,8 @@ def __init__(
2526 manager_llm : Optional [str ] = None ,
2627 verbose : bool = False ,
2728 max_iter : int = 10 ,
29+ max_retries : int = 3 ,
30+ workflow_timeout : Optional [int ] = None , # seconds, None = no timeout
2831 output : Optional [str ] = None ,
2932 ):
3033 logging .debug (f"=== Initializing Process ===" )
@@ -37,6 +40,8 @@ def __init__(
3740 self .agents = agents
3841 self .manager_llm = manager_llm
3942 self .max_iter = max_iter
43+ self .max_retries = max_retries
44+ self .workflow_timeout = workflow_timeout
4045 self .task_retry_counter : Dict [str , int ] = {} # Initialize retry counter
4146 self .workflow_finished = False # ADDED: Workflow finished flag
4247 self ._state_lock = asyncio .Lock () # Async lock for shared state protection
@@ -233,7 +238,7 @@ def _find_next_not_started_task(self) -> Optional[Task]:
233238 if hasattr (task , 'description' ) and 'Input data from previous tasks:' in task .description :
234239 task .description = task .description .split ('Input data from previous tasks:' )[0 ].strip ()
235240
236- while fallback_attempts < Process . DEFAULT_RETRY_LIMIT and not temp_current_task :
241+ while fallback_attempts < self . max_retries and not temp_current_task :
237242 fallback_attempts += 1
238243 logging .debug (f"Fallback attempt { fallback_attempts } : Trying to find next 'not started' task." )
239244 for task_candidate in self .tasks .values ():
@@ -248,13 +253,13 @@ def _find_next_not_started_task(self) -> Optional[Task]:
248253 if not leads_to_task and not task_candidate .next_tasks :
249254 continue # Skip if no valid path exists
250255
251- if self .task_retry_counter .get (task_candidate .id , 0 ) < Process . DEFAULT_RETRY_LIMIT :
256+ if self .task_retry_counter .get (task_candidate .id , 0 ) < self . max_retries :
252257 self .task_retry_counter [task_candidate .id ] = self .task_retry_counter .get (task_candidate .id , 0 ) + 1
253258 temp_current_task = task_candidate
254259 logging .debug (f"Fallback attempt { fallback_attempts } : Found 'not started' task: { temp_current_task .name } , retry count: { self .task_retry_counter [temp_current_task .id ]} " )
255260 return temp_current_task # Return the found task immediately
256261 else :
257- logging .debug (f"Max retries reached for task { task_candidate .name } in fallback mode, marking as failed." )
262+ logging .debug (f"Max retries ( { self . max_retries } ) reached for task { task_candidate .name } in fallback mode, marking as failed." )
258263 task_candidate .status = "failed"
259264 if not temp_current_task :
260265 logging .debug (f"Fallback attempt { fallback_attempts } : No 'not started' task found within retry limit." )
@@ -383,6 +388,7 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
383388 """Async version of workflow method"""
384389 logging .debug ("=== Starting Async Workflow ===" )
385390 current_iter = 0 # Track how many times we've looped
391+ workflow_start = time .monotonic () # For timeout enforcement
386392 # Build workflow relationships first
387393 logging .debug ("Building workflow relationships..." )
388394 for task in self .tasks .values ():
@@ -418,6 +424,13 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
418424 logging .info (f"Max iteration limit { self .max_iter } reached, ending workflow." )
419425 break
420426
427+ # Enforce workflow timeout if set
428+ if self .workflow_timeout is not None :
429+ elapsed = time .monotonic () - workflow_start
430+ if elapsed > self .workflow_timeout :
431+ logging .warning (f"Workflow timeout ({ self .workflow_timeout } s) exceeded after { elapsed :.1f} s, stopping." )
432+ break
433+
421434 # ADDED: Check workflow finished flag at the start of each cycle
422435 if self .workflow_finished :
423436 logging .info ("Workflow finished early as all tasks are completed." )
@@ -455,11 +468,13 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
455468Description length: { len (current_task .description )}
456469 """ )
457470
458- # Add context from previous tasks to description
471+ # Build context and set description for this execution pass only
459472 context = self ._build_task_context (current_task )
460- if context :
461- # Update task description with context
462- current_task .description = current_task .description + context
473+ # Store original description if not already stored
474+ if not hasattr (current_task , '_original_description' ):
475+ current_task ._original_description = current_task .description
476+ # Set description with context for execution; reset after yield to prevent accumulation
477+ current_task .description = current_task ._original_description + (context if context else "" )
463478
464479 # Skip execution for loop tasks, only process their subtasks
465480 if current_task .task_type == "loop" :
@@ -566,6 +581,9 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
566581 logging .debug (f"Task next_tasks: { current_task .next_tasks } " )
567582 yield task_id
568583 visited_tasks .add (task_id )
584+ # Reset description to original after execution to prevent context accumulation
585+ if hasattr (current_task , '_original_description' ):
586+ current_task .description = current_task ._original_description
569587
570588 # Only end workflow if no next_tasks AND no conditions
571589 if not current_task .next_tasks and not current_task .condition and not any (
@@ -577,28 +595,29 @@ async def aworkflow(self) -> AsyncGenerator[str, None]:
577595 current_task = None
578596 break
579597
580- # Reset completed task to "not started" so it can run again
581- if self .tasks [task_id ].status == "completed" :
582- # Never reset loop tasks, decision tasks, or their subtasks if rerun is False
583- subtask_name = self .tasks [task_id ].name
584- task_to_check = self .tasks [task_id ]
585- logging .debug (f"=== Checking reset for completed task: { subtask_name } ===" )
586- logging .debug (f"Task type: { task_to_check .task_type } " )
587- logging .debug (f"Task status before reset check: { task_to_check .status } " )
588- logging .debug (f"Task rerun: { getattr (task_to_check , 'rerun' , True )} " ) # default to True if not set
589- logging .debug (f"Task async_execution: { task_to_check .async_execution } " )
590-
591- if (getattr (task_to_check , 'rerun' , True ) and # Corrected condition - reset only if rerun is True (or default True)
592- task_to_check .task_type != "loop" and # Removed "decision" from exclusion
593- not any (t .task_type == "loop" and subtask_name .startswith (t .name + "_" )
594- for t in self .tasks .values ()) and
595- not task_to_check .async_execution ): # Don't reset async parallel tasks
596- logging .debug (f"=== Resetting non-loop, non-decision, non-parallel task { subtask_name } to 'not started' ===" )
597- self .tasks [task_id ].status = "not started"
598- logging .debug (f"Task status after reset: { self .tasks [task_id ].status } " )
599- else :
600- logging .debug (f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: { subtask_name } ===" )
601- logging .debug (f"Keeping status as: { self .tasks [task_id ].status } " )
598+ # Reset completed task to "not started" so it can run again (atomic operation)
599+ async with self ._state_lock :
600+ if self .tasks [task_id ].status == "completed" :
601+ # Never reset loop tasks, decision tasks, or their subtasks if rerun is False
602+ subtask_name = self .tasks [task_id ].name
603+ task_to_check = self .tasks [task_id ]
604+ logging .debug (f"=== Checking reset for completed task: { subtask_name } ===" )
605+ logging .debug (f"Task type: { task_to_check .task_type } " )
606+ logging .debug (f"Task status before reset check: { task_to_check .status } " )
607+ logging .debug (f"Task rerun: { getattr (task_to_check , 'rerun' , True )} " ) # default to True if not set
608+ logging .debug (f"Task async_execution: { task_to_check .async_execution } " )
609+
610+ if (getattr (task_to_check , 'rerun' , True ) and # Corrected condition - reset only if rerun is True (or default True)
611+ task_to_check .task_type != "loop" and # Removed "decision" from exclusion
612+ not any (t .task_type == "loop" and subtask_name .startswith (t .name + "_" )
613+ for t in self .tasks .values ()) and
614+ not task_to_check .async_execution ): # Don't reset async parallel tasks
615+ logging .debug (f"=== Resetting non-loop, non-decision, non-parallel task { subtask_name } to 'not started' ===" )
616+ self .tasks [task_id ].status = "not started"
617+ logging .debug (f"Task status after reset: { self .tasks [task_id ].status } " )
618+ else :
619+ logging .debug (f"=== Skipping reset for loop/decision/subtask/parallel or rerun=False: { subtask_name } ===" )
620+ logging .debug (f"Keeping status as: { self .tasks [task_id ].status } " )
602621
603622 # Handle loop progression
604623 if current_task .task_type == "loop" :
@@ -1116,11 +1135,13 @@ def workflow(self):
11161135Description length: { len (current_task .description )}
11171136 """ )
11181137
1119- # Add context from previous tasks to description
1138+ # Build context and set description for this execution pass only
11201139 context = self ._build_task_context (current_task )
1121- if context :
1122- # Update task description with context
1123- current_task .description = current_task .description + context
1140+ # Store original description if not already stored
1141+ if not hasattr (current_task , '_original_description' ):
1142+ current_task ._original_description = current_task .description
1143+ # Set description with context for execution; reset after yield to prevent accumulation
1144+ current_task .description = current_task ._original_description + (context if context else "" )
11241145
11251146 # Skip execution for loop tasks, only process their subtasks
11261147 if current_task .task_type == "loop" :
@@ -1227,6 +1248,9 @@ def workflow(self):
12271248 logging .debug (f"Task next_tasks: { current_task .next_tasks } " )
12281249 yield task_id
12291250 visited_tasks .add (task_id )
1251+ # Reset description to original after execution to prevent context accumulation
1252+ if hasattr (current_task , '_original_description' ):
1253+ current_task .description = current_task ._original_description
12301254
12311255 # Only end workflow if no next_tasks AND no conditions
12321256 if not current_task .next_tasks and not current_task .condition and not any (
0 commit comments