Skip to content

Commit 57c9e23

Browse files
committed
[bugf][graph-workflow][report cycles as an explicit error instead of silently flattening]
topological_generations() hits the cyclic case and falls back to a Kahn layering that tolerates cycles, so a cycle collapses into one parallel layer: the nodes run once, concurrently, and the declared edge ordering between them is discarded. The only signals were a validate() warning and a compile() line gated on verbose, so a default run printed 'compiled successfully'. Cycles are now an error naming the offending nodes and the consequence. compile() logs errors unconditionally, so a non-verbose run says so. The cheap path uses a Kahn peel to name the nodes on a cycle without enumerating every simple cycle, which is exponential in the cycle count. validate()'s enumerate_cycles path keeps listing the cycles themselves. Rebased onto kyegomez#2017, which consolidated validate() and _fast_validate() behind _structural_checks() — the original two edit sites are now one.
1 parent 27c47f1 commit 57c9e23

1 file changed

Lines changed: 56 additions & 6 deletions

File tree

swarms/structs/graph_workflow.py

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,15 +1137,33 @@ def _structural_checks(
11371137
f"Found {len(isolated)} isolated nodes: {isolated}"
11381138
)
11391139

1140+
# An error, not a warning: execution does not run cycles as loops. It
1141+
# flattens them into one parallel layer, runs each node once, and
1142+
# ignores the declared edge ordering inside the cycle.
1143+
_CYCLE_CONSEQUENCE = (
1144+
"Cycles are not executed as loops: these nodes are flattened "
1145+
"into a single parallel layer, run once concurrently, and the "
1146+
"edge ordering between them is ignored. Break the cycle, or "
1147+
"re-run the whole graph iteratively with max_loops."
1148+
)
11401149
try:
11411150
if enumerate_cycles:
11421151
cycles = self.graph_backend.simple_cycles()
11431152
if cycles:
1144-
warnings.append(
1145-
f"Found {len(cycles)} cycles in workflow"
1153+
errors.append(
1154+
f"Found {len(cycles)} cycle(s) in workflow: "
1155+
f"{cycles}. {_CYCLE_CONSEQUENCE}"
1156+
)
1157+
else:
1158+
# Kahn peel doubles as the acyclicity test and names the
1159+
# offending nodes, without enumerating every simple cycle,
1160+
# which is exponential in the number of cycles.
1161+
cyclic_nodes = self._nodes_on_cycles(succ, pred)
1162+
if cyclic_nodes:
1163+
errors.append(
1164+
f"Found cycle(s) involving nodes {cyclic_nodes}. "
1165+
f"{_CYCLE_CONSEQUENCE}"
11461166
)
1147-
elif not self.graph_backend.is_dag():
1148-
warnings.append("Found cycles in workflow")
11491167
except Exception as e:
11501168
warnings.append(f"Could not check for cycles: {e}")
11511169

@@ -1199,6 +1217,37 @@ def _fast_validate(
11991217
errors, warnings, _ = self._structural_checks(succ, pred)
12001218
return errors, warnings
12011219

1220+
def _nodes_on_cycles(
1221+
self,
1222+
succ: Dict[str, List[str]],
1223+
pred: Dict[str, List[str]],
1224+
) -> List[str]:
1225+
"""
1226+
Return the sorted node ids that sit on at least one directed cycle.
1227+
1228+
Kahn's peel: repeatedly remove in-degree-0 nodes; whatever survives is
1229+
on a cycle. O(V+E), no cycle enumeration.
1230+
1231+
Args:
1232+
succ (Dict[str, List[str]]): Successor map from ``adjacency()``.
1233+
pred (Dict[str, List[str]]): Predecessor map from ``adjacency()``.
1234+
1235+
Returns:
1236+
List[str]: Node ids on a cycle, sorted; empty for a DAG.
1237+
"""
1238+
indegree = {
1239+
node_id: len(pred.get(node_id, ()))
1240+
for node_id in self.nodes
1241+
}
1242+
stack = [n for n, d in indegree.items() if d == 0]
1243+
while stack:
1244+
node_id = stack.pop()
1245+
for child in succ.get(node_id, ()):
1246+
indegree[child] -= 1
1247+
if indegree[child] == 0:
1248+
stack.append(child)
1249+
return sorted(n for n, d in indegree.items() if d > 0)
1250+
12021251
def add_node(
12031252
self,
12041253
agent: Union[Agent, "GraphWorkflow"],
@@ -3629,9 +3678,10 @@ def validate(
36293678
f"to exit points"
36303679
)
36313680

3681+
# Cycles are reported as errors above, so only reachability
3682+
# remains warning-severity here.
36323683
has_serious_warnings = any(
3633-
"cycle" in warning.lower()
3634-
or "unreachable" in warning.lower()
3684+
"unreachable" in warning.lower()
36353685
for warning in result["warnings"]
36363686
)
36373687

0 commit comments

Comments
 (0)