Skip to content

Commit 0915d4a

Browse files
committed
Add cycle detection to AI flow config (#23589)
* Add cycles detection in flow config * Add two edge-cases tests * Remove _detect_cycle tests and add one for disjoined graphs * Find all possible cycles in _detect_cycles * Changes in _detect_cycles: change comment, add cycle limit and tighten test
1 parent 509c2c4 commit 0915d4a

2 files changed

Lines changed: 162 additions & 4 deletions

File tree

ddev/src/ddev/ai/phases/config.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
44

5+
from __future__ import annotations
6+
57
from pathlib import Path
68

79
import yaml
@@ -14,14 +16,49 @@ class FlowConfigError(Exception):
1416
"""Wraps Pydantic ValidationError or YAML errors with a user-friendly message."""
1517

1618

19+
def _detect_cycles(
20+
dependency_map: dict[str, list[str]],
21+
limit: int = 50,
22+
) -> tuple[list[list[str]], bool]:
23+
"""Return every simple cycle in the dependency graph, each as an ordered list of phase IDs."""
24+
# Enumerate every simple cycle exactly once: from each node, DFS only through
25+
# higher-ranked nodes, so each cycle is reported only when started from its
26+
# lowest-ranked member. (Tiernan-style enumeration with rank canonicalization.)
27+
rank = {n: i for i, n in enumerate(dependency_map)}
28+
cycles: list[list[str]] = []
29+
30+
class _LimitReached(Exception):
31+
"""Raised when the cycle limit is reached."""
32+
33+
pass
34+
35+
def dfs(start: str, current: str, path: list[str], on_path: set[str]):
36+
for dep in dependency_map.get(current, []):
37+
if dep == start:
38+
cycles.append(path + [start])
39+
if len(cycles) >= limit:
40+
raise _LimitReached
41+
elif dep in rank and rank[dep] > rank[start] and dep not in on_path:
42+
on_path.add(dep)
43+
dfs(start, dep, path + [dep], on_path)
44+
on_path.discard(dep)
45+
46+
try:
47+
for start in dependency_map:
48+
dfs(start, start, [start], {start})
49+
except _LimitReached:
50+
return cycles, True
51+
return cycles, False
52+
53+
1754
class TaskConfig(BaseModel):
1855
model_config = ConfigDict(extra="forbid")
1956
name: str
2057
prompt_path: Path | None = None
2158
prompt: str | None = None
2259

2360
@model_validator(mode="after")
24-
def exactly_one_source(self) -> "TaskConfig":
61+
def exactly_one_source(self) -> TaskConfig:
2562
if (self.prompt_path is None) == (self.prompt is None):
2663
raise ValueError("Exactly one of 'prompt_path' or 'prompt' must be set")
2764
return self
@@ -35,7 +72,7 @@ class CheckpointConfig(BaseModel):
3572
memory_prompt_path: Path | None = None
3673

3774
@model_validator(mode="after")
38-
def exactly_one_source(self) -> "CheckpointConfig":
75+
def exactly_one_source(self) -> CheckpointConfig:
3976
if (self.memory_prompt is None) == (self.memory_prompt_path is None):
4077
raise ValueError("Exactly one of 'memory_prompt' or 'memory_prompt_path' must be set")
4178
return self
@@ -86,7 +123,7 @@ class FlowConfig(BaseModel):
86123
flow: list[FlowEntry]
87124

88125
@model_validator(mode="after")
89-
def cross_references(self) -> "FlowConfig":
126+
def cross_references(self) -> FlowConfig:
90127
"""Validate all cross-references between agents, phases, and dependencies."""
91128
scheduled = {entry.phase for entry in self.flow}
92129
seen: set[str] = set()
@@ -105,10 +142,18 @@ def cross_references(self) -> "FlowConfig":
105142
for phase_id, phase in self.phases.items():
106143
if phase.agent not in self.agents:
107144
raise ValueError(f"Phase {phase_id!r} references unknown agent: {phase.agent!r}")
145+
146+
dependency_map = {entry.phase: entry.dependencies for entry in self.flow}
147+
cycles, truncated = _detect_cycles(dependency_map)
148+
if cycles:
149+
formatted = "\n ".join(" → ".join(c) for c in cycles)
150+
suffix = f"\n (showing first {len(cycles)}; more cycles exist)" if truncated else ""
151+
raise ValueError(f"Cycle(s) detected in flow:\n {formatted}{suffix}")
152+
108153
return self
109154

110155
@classmethod
111-
def from_yaml(cls, path: Path, config_dir: Path) -> "FlowConfig":
156+
def from_yaml(cls, path: Path, config_dir: Path) -> FlowConfig:
112157
"""Load, parse, and validate flow.yaml. Raises FlowConfigError on any problem."""
113158
try:
114159
raw = yaml.safe_load(path.read_text())

ddev/tests/ai/phases/test_config.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,13 @@ def test_flow_config_dependency_not_scheduled_in_flow():
170170
FlowConfig.model_validate(raw)
171171

172172

173+
def test_flow_config_duplicate_phase_raises():
174+
raw = _minimal_config()
175+
raw["flow"] = [{"phase": "p1"}, {"phase": "p1"}]
176+
with pytest.raises(ValidationError, match="Duplicate phase"):
177+
FlowConfig.model_validate(raw)
178+
179+
173180
def test_flow_config_unknown_agent_in_phase():
174181
raw = _minimal_config()
175182
raw["phases"]["p1"]["agent"] = "nonexistent"
@@ -295,3 +302,109 @@ def test_from_yaml_invalid_yaml(tmp_path):
295302
def test_from_yaml_missing_file(tmp_path):
296303
with pytest.raises(FlowConfigError, match="Failed to load"):
297304
FlowConfig.from_yaml(tmp_path / "nonexistent.yaml", tmp_path)
305+
306+
307+
# ---------------------------------------------------------------------------
308+
# FlowConfig cycle detection via model_validate
309+
# ---------------------------------------------------------------------------
310+
311+
312+
def _three_phase_config() -> dict:
313+
agent = {"tools": []}
314+
task = {"name": "t", "prompt": "Do it."}
315+
return {
316+
"agents": {"writer": agent},
317+
"phases": {
318+
"p1": {"agent": "writer", "tasks": [task]},
319+
"p2": {"agent": "writer", "tasks": [task]},
320+
"p3": {"agent": "writer", "tasks": [task]},
321+
},
322+
}
323+
324+
325+
def test_flow_config_direct_cycle_raises():
326+
raw = _three_phase_config()
327+
raw["flow"] = [
328+
{"phase": "p1", "dependencies": ["p2"]},
329+
{"phase": "p2", "dependencies": ["p1"]},
330+
]
331+
with pytest.raises(ValidationError, match="Cycle"):
332+
FlowConfig.model_validate(raw)
333+
334+
335+
def test_flow_config_three_node_cycle_raises():
336+
raw = _three_phase_config()
337+
raw["flow"] = [
338+
{"phase": "p1", "dependencies": ["p3"]},
339+
{"phase": "p2", "dependencies": ["p1"]},
340+
{"phase": "p3", "dependencies": ["p2"]},
341+
]
342+
with pytest.raises(ValidationError, match="Cycle"):
343+
FlowConfig.model_validate(raw)
344+
345+
346+
def test_flow_config_acyclic_chain_ok():
347+
raw = _three_phase_config()
348+
raw["flow"] = [
349+
{"phase": "p1"},
350+
{"phase": "p2", "dependencies": ["p1"]},
351+
{"phase": "p3", "dependencies": ["p1"]},
352+
]
353+
config = FlowConfig.model_validate(raw)
354+
assert len(config.flow) == 3
355+
356+
357+
def test_flow_disjoined_graphs_ok():
358+
agent = {"tools": []}
359+
task = {"name": "t", "prompt": "Do it."}
360+
raw = {
361+
"agents": {"writer": agent},
362+
"phases": {
363+
"p1": {"agent": "writer", "tasks": [task]},
364+
"p2": {"agent": "writer", "tasks": [task]},
365+
"p3": {"agent": "writer", "tasks": [task]},
366+
"p4": {"agent": "writer", "tasks": [task]},
367+
},
368+
"flow": [
369+
{"phase": "p1"},
370+
{"phase": "p2", "dependencies": ["p1"]},
371+
{"phase": "p3"},
372+
{"phase": "p4", "dependencies": ["p3"]},
373+
],
374+
}
375+
config = FlowConfig.model_validate(raw)
376+
assert len(config.flow) == 4
377+
378+
379+
def test_flow_config_self_dependency_raises():
380+
raw = _minimal_config()
381+
raw["flow"] = [{"phase": "p1", "dependencies": ["p1"]}]
382+
with pytest.raises(ValidationError, match="Cycle"):
383+
FlowConfig.model_validate(raw)
384+
385+
386+
def test_flow_config_two_independent_cycles_reports_both():
387+
agent = {"tools": []}
388+
task = {"name": "t", "prompt": "Do it."}
389+
raw = {
390+
"agents": {"writer": agent},
391+
"phases": {
392+
"p1": {"agent": "writer", "tasks": [task]},
393+
"p2": {"agent": "writer", "tasks": [task]},
394+
"p3": {"agent": "writer", "tasks": [task]},
395+
"p4": {"agent": "writer", "tasks": [task]},
396+
},
397+
"flow": [
398+
# dependency edges: p1→p3→p2→p1 and p1→p4→p2→p1
399+
{"phase": "p1", "dependencies": ["p3", "p4"]},
400+
{"phase": "p2", "dependencies": ["p1"]},
401+
{"phase": "p3", "dependencies": ["p2"]},
402+
{"phase": "p4", "dependencies": ["p2"]},
403+
],
404+
}
405+
with pytest.raises(ValidationError) as exc_info:
406+
FlowConfig.model_validate(raw)
407+
error = str(exc_info.value)
408+
assert "Cycle" in error
409+
assert "p1 → p3 → p2 → p1" in error
410+
assert "p1 → p4 → p2 → p1" in error

0 commit comments

Comments
 (0)