Skip to content

Commit 2dc8f1d

Browse files
committed
[Improvement] GraphWorkflow: per-node retry policy and explicit failure semantics (kyegomez#1758)
When an agent raised, run() turned the exception into a string and fed it to every downstream agent as if it were an answer: output = f"[ERROR] Agent {agent_name} failed: {e}" Downstream agents received that under "Output from {pred}:" next to instructions to verify the findings and build on the work. One transient rate limit poisoned the whole subgraph, run() returned normally, and the caller's only signal was substring-matching every value for [ERROR] -- while still paying for every downstream LLM call on garbage input. Three parts: RetryPolicy(max_attempts, backoff, base_delay, max_delay, retry_on), settable per node or as a workflow default. Applied by wrapping the node-invocation callable, so the inline path and the thread-pool path get it from one place and each retry runs on the worker thread that owns the node rather than blocking the layer. on_node_failure decides what happens once attempts are exhausted: skip_downstream (new default) prunes dependents so nobody builds on a failure; fail_fast raises; propagate_error is the old behaviour, kept for callers who depend on it. failed_nodes maps node id to error string after a run, so failures are inspectable without string matching.
1 parent 5fa2adb commit 2dc8f1d

2 files changed

Lines changed: 401 additions & 13 deletions

File tree

swarms/structs/graph_workflow.py

Lines changed: 221 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
712783
class 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

Comments
 (0)