@@ -709,6 +709,77 @@ class NodeType(str, Enum):
709709 SUBGRAPH = "subgraph"
710710
711711
712+ class RetryPolicy :
713+ """
714+ How many times to retry a node, and how long to wait between attempts.
715+
716+ Transient failures — a rate limit, a timeout, a dropped connection — are
717+ the common case for LLM calls, not an edge case. Without retries a single
718+ blip poisons the whole downstream subgraph with an error string that
719+ reads, to the next agent, like a real answer.
720+
721+ Attributes:
722+ max_attempts (int): Total attempts including the first. 1 disables retrying.
723+ backoff (str): ``"none"``, ``"linear"`` or ``"exponential"``.
724+ base_delay (float): Seconds for the first wait; scaled by the backoff.
725+ max_delay (float): Ceiling on any single wait.
726+ retry_on (tuple): Exception types to retry. Defaults to ``Exception``,
727+ because provider SDKs raise their own error classes and hardcoding
728+ a list here would silently fail to retry the ones we forgot.
729+ """
730+
731+ def __init__ (
732+ self ,
733+ max_attempts : int = 3 ,
734+ backoff : str = "exponential" ,
735+ base_delay : float = 1.0 ,
736+ max_delay : float = 30.0 ,
737+ retry_on : Tuple [type , ...] = (Exception ,),
738+ ):
739+ if max_attempts < 1 :
740+ raise ValueError (
741+ f"max_attempts must be at least 1, got { max_attempts } "
742+ )
743+ if backoff not in ("none" , "linear" , "exponential" ):
744+ raise ValueError (
745+ f"backoff must be 'none', 'linear' or 'exponential', "
746+ f"got { backoff !r} "
747+ )
748+ self .max_attempts = max_attempts
749+ self .backoff = backoff
750+ self .base_delay = base_delay
751+ self .max_delay = max_delay
752+ self .retry_on = retry_on
753+
754+ def delay_for (self , attempt : int ) -> float :
755+ """
756+ Seconds to wait before ``attempt`` (1-based, so attempt 2 is the first retry).
757+
758+ Args:
759+ attempt (int): The attempt about to be made.
760+
761+ Returns:
762+ float: Delay in seconds, capped at ``max_delay``.
763+ """
764+ if self .backoff == "none" :
765+ return 0.0
766+ if self .backoff == "linear" :
767+ delay = self .base_delay * (attempt - 1 )
768+ else :
769+ delay = self .base_delay * (2 ** (attempt - 2 ))
770+ return min (max (0.0 , delay ), self .max_delay )
771+
772+ def should_retry (self , exc : BaseException ) -> bool :
773+ """Whether ``exc`` is one this policy retries."""
774+ return isinstance (exc , self .retry_on )
775+
776+ def __repr__ (self ) -> str :
777+ return (
778+ f"RetryPolicy(max_attempts={ self .max_attempts } , "
779+ f"backoff={ self .backoff !r} , base_delay={ self .base_delay } )"
780+ )
781+
782+
712783class Node :
713784 """
714785 Represents a node in a graph workflow. A node can be either an Agent or
@@ -727,6 +798,7 @@ def __init__(
727798 type : NodeType = NodeType .AGENT ,
728799 agent : Any = None ,
729800 metadata : Dict [str , Any ] = None ,
801+ retry : Optional ["RetryPolicy" ] = None ,
730802 ):
731803 """
732804 Initialize a Node.
@@ -741,6 +813,10 @@ def __init__(
741813 self .type = type
742814 self .agent = agent
743815 self .metadata = metadata or {}
816+ # Per-node retry policy. None means "use the workflow default",
817+ # resolved at run time so a workflow-level policy can be set after
818+ # the nodes were added.
819+ self .retry = retry
744820
745821 if not self .id :
746822 if self .agent is not None :
@@ -968,6 +1044,8 @@ def __init__(
9681044 verbose : bool = False ,
9691045 backend : str = "networkx" ,
9701046 checkpoint_dir : Optional [str ] = None ,
1047+ retry_policy : Optional ["RetryPolicy" ] = None ,
1048+ on_node_failure : str = "skip_downstream" ,
9711049 on_node_complete : Optional [Callable [[str , Any ], None ]] = None ,
9721050 max_parallel_nodes : Optional [int ] = None ,
9731051 ):
@@ -1010,6 +1088,22 @@ def __init__(
10101088
10111089 # Checkpoint configuration
10121090 self .checkpoint_dir = checkpoint_dir
1091+ # Default policy for nodes that don't carry their own. None means no
1092+ # retrying, which is the historical behaviour.
1093+ self .retry_policy = retry_policy
1094+ if on_node_failure not in (
1095+ "skip_downstream" ,
1096+ "fail_fast" ,
1097+ "propagate_error" ,
1098+ ):
1099+ raise ValueError (
1100+ "on_node_failure must be 'skip_downstream', 'fail_fast' or "
1101+ f"'propagate_error', got { on_node_failure !r} "
1102+ )
1103+ self .on_node_failure = on_node_failure
1104+ # node_id -> error string, populated per run. Lets a caller inspect
1105+ # failures without substring-matching every output for '[ERROR]'.
1106+ self .failed_nodes : Dict [str , str ] = {}
10131107
10141108 # Private optimization attributes
10151109 self ._compiled = False
@@ -2004,6 +2098,106 @@ def _get_predecessors(self, node_id: str) -> Tuple[str, ...]:
20042098 cache [node_id ] = preds
20052099 return preds
20062100
2101+ def _policy_for (self , node_id : str ) -> Optional ["RetryPolicy" ]:
2102+ """The retry policy governing a node: its own, else the workflow default."""
2103+ node = self .nodes .get (node_id )
2104+ if node is not None and node .retry is not None :
2105+ return node .retry
2106+ return self .retry_policy
2107+
2108+ def _with_retries (
2109+ self , node_id : str , agent_name : str , call : Callable [[], Any ]
2110+ ) -> Callable [[], Any ]:
2111+ """
2112+ Wrap a node's zero-arg invocation so transient failures are retried.
2113+
2114+ Wrapping the callable rather than the call site means both the inline
2115+ single-node path and the thread-pool path get retries from one place,
2116+ and each retry happens on the worker thread that owns the node instead
2117+ of blocking the layer.
2118+
2119+ Args:
2120+ node_id (str): Node being invoked.
2121+ agent_name (str): Display name, for logs.
2122+ call (Callable[[], Any]): The unwrapped invocation.
2123+
2124+ Returns:
2125+ Callable[[], Any]: Either ``call`` itself when no policy applies,
2126+ or a wrapper that retries per the policy and re-raises the last
2127+ exception once attempts are exhausted.
2128+ """
2129+ policy = self ._policy_for (node_id )
2130+ if policy is None or policy .max_attempts <= 1 :
2131+ return call
2132+
2133+ def _retrying ():
2134+ last_exc = None
2135+ for attempt in range (1 , policy .max_attempts + 1 ):
2136+ if attempt > 1 :
2137+ delay = policy .delay_for (attempt )
2138+ if delay :
2139+ time .sleep (delay )
2140+ logger .warning (
2141+ f"Retrying node { node_id } ({ agent_name } ), attempt "
2142+ f"{ attempt } /{ policy .max_attempts } after: { last_exc } "
2143+ )
2144+ try :
2145+ return call ()
2146+ except Exception as e :
2147+ last_exc = e
2148+ if not policy .should_retry (e ):
2149+ raise
2150+ raise last_exc
2151+
2152+ return _retrying
2153+
2154+ def _handle_node_failure (
2155+ self ,
2156+ node_id : str ,
2157+ agent_name : str ,
2158+ exc : BaseException ,
2159+ skipped : Set [str ],
2160+ ) -> Optional [str ]:
2161+ """
2162+ Apply the workflow's failure policy to a node that raised.
2163+
2164+ Args:
2165+ node_id (str): The node that failed.
2166+ agent_name (str): Display name, for logs and the error string.
2167+ exc (BaseException): The exception, after retries were exhausted.
2168+ skipped (Set[str]): Skip set for the current loop, mutated here.
2169+
2170+ Returns:
2171+ Optional[str]: An output string to record for the node under
2172+ ``propagate_error``, or None when the node should be treated as
2173+ not having produced anything.
2174+
2175+ Raises:
2176+ RuntimeError: Under ``fail_fast``, chaining the original error.
2177+ """
2178+ self .failed_nodes [node_id ] = f"{ type (exc ).__name__ } : { exc } "
2179+ logger .exception (
2180+ f"Error in GraphWorkflow agent execution for { agent_name } : { exc } "
2181+ )
2182+
2183+ if self .on_node_failure == "fail_fast" :
2184+ raise RuntimeError (
2185+ f"GraphWorkflow node { node_id } ({ agent_name } ) failed: { exc } "
2186+ ) from exc
2187+
2188+ if self .on_node_failure == "propagate_error" :
2189+ return f"[ERROR] Agent { agent_name } failed: { exc } "
2190+
2191+ # skip_downstream: the node produced nothing, so dependents are
2192+ # pruned rather than being handed an error string that reads like an
2193+ # answer.
2194+ skipped .add (node_id )
2195+ logger .warning (
2196+ f"Node { node_id } failed; skipping its dependents "
2197+ f"(on_node_failure='skip_downstream')"
2198+ )
2199+ return None
2200+
20072201 def _node_is_eligible (
20082202 self ,
20092203 node_id : str ,
@@ -2302,6 +2496,7 @@ def _get_executor() -> ContextThreadPoolExecutor:
23022496 # Reset per loop: a node skipped on one iteration may well be
23032497 # the one that runs on the next, once upstream output changes.
23042498 skipped_nodes : Set [str ] = set ()
2499+ self .failed_nodes = {}
23052500
23062501 # Derive a deterministic key for this task so checkpoints
23072502 # survive process restarts (Python's hash() is salted and
@@ -2382,7 +2577,7 @@ def _get_executor() -> ContextThreadPoolExecutor:
23822577 # edges all declined to fire. Entry points and nodes in
23832578 # graphs without conditions are never gated, so an
23842579 # unconditional graph takes the same path it always did.
2385- if self ._has_conditions :
2580+ if self ._has_conditions or skipped_nodes :
23862581 eligible_layer = []
23872582 for entry in layer :
23882583 if self ._node_is_eligible (
@@ -2543,14 +2738,19 @@ def _record(
25432738 prompt ,
25442739 ) = layer_data [0 ]
25452740 try :
2546- output = _make_call (
2547- node_id , agent , node_type , prompt
2741+ output = self ._with_retries (
2742+ node_id ,
2743+ agent_name ,
2744+ _make_call (
2745+ node_id , agent , node_type , prompt
2746+ ),
25482747 )()
25492748 except Exception as e :
2550- output = f"[ERROR] Agent { agent_name } failed: { e } "
2551- logger .exception (
2552- f"Error in GraphWorkflow agent execution for { agent_name } : { e } "
2749+ output = self ._handle_node_failure (
2750+ node_id , agent_name , e , skipped_nodes
25532751 )
2752+ if output is None :
2753+ continue
25542754 _record (
25552755 node_id , agent_name , node_type , output
25562756 )
@@ -2568,11 +2768,15 @@ def _record(
25682768 ) in layer_data :
25692769 try :
25702770 future = pool .submit (
2571- _make_call (
2771+ self . _with_retries (
25722772 node_id ,
2573- agent ,
2574- node_type ,
2575- prompt ,
2773+ agent_name ,
2774+ _make_call (
2775+ node_id ,
2776+ agent ,
2777+ node_type ,
2778+ prompt ,
2779+ ),
25762780 )
25772781 )
25782782 future_to_data [future ] = (
@@ -2608,10 +2812,14 @@ def _record(
26082812 f"({ completed_count } /{ len (layer_data )} )"
26092813 )
26102814 except Exception as e :
2611- output = f"[ERROR] Agent { agent_name } failed: { e } "
2612- logger .exception (
2613- f"Error in GraphWorkflow agent execution for { agent_name } : { e } "
2815+ output = self ._handle_node_failure (
2816+ node_id ,
2817+ agent_name ,
2818+ e ,
2819+ skipped_nodes ,
26142820 )
2821+ if output is None :
2822+ continue
26152823
26162824 _record (
26172825 node_id , agent_name , node_type , output
0 commit comments