fix: make plot node selection smoother
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Notify Downstream / notify-downstream (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled

This commit is contained in:
Greyson LaLonde
2025-11-03 07:49:31 -05:00
committed by GitHub
parent 60332e0b19
commit 329567153b
9 changed files with 1725 additions and 1735 deletions

View File

@@ -19,6 +19,7 @@ repos:
language: system
pass_filenames: true
types: [python]
exclude: ^(lib/crewai/src/crewai/cli/templates/|lib/crewai/tests/|lib/crewai-tools/tests/)
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.9.3
hooks:

View File

@@ -1,12 +1,10 @@
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.persistence import persist
from crewai.flow.visualization import (
FlowStructure,
build_flow_structure,
print_structure_summary,
structure_to_dict,
visualize_flow_structure,
)
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.persistence import persist
__all__ = [
@@ -17,9 +15,7 @@ __all__ = [
"listen",
"or_",
"persist",
"print_structure_summary",
"router",
"start",
"structure_to_dict",
"visualize_flow_structure",
]

View File

@@ -3,8 +3,6 @@
from crewai.flow.visualization.builder import (
build_flow_structure,
calculate_execution_paths,
print_structure_summary,
structure_to_dict,
)
from crewai.flow.visualization.renderers import render_interactive
from crewai.flow.visualization.types import FlowStructure, NodeMetadata, StructureEdge
@@ -18,8 +16,6 @@ __all__ = [
"StructureEdge",
"build_flow_structure",
"calculate_execution_paths",
"print_structure_summary",
"render_interactive",
"structure_to_dict",
"visualize_flow_structure",
]

File diff suppressed because it is too large Load Diff

View File

@@ -66,19 +66,19 @@
<div class="legend-title">Edge Types</div>
<div class="legend-item">
<svg width="24" height="12" style="margin-right: 12px;">
<line x1="0" y1="6" x2="24" y2="6" stroke="'{{ CREWAI_ORANGE }}'" stroke-width="2"/>
<line x1="0" y1="6" x2="24" y2="6" stroke="'{{ CREWAI_ORANGE }}'" stroke-width="2" stroke-dasharray="5,5"/>
</svg>
<span>Router Paths</span>
</div>
<div class="legend-item">
<svg width="24" height="12" style="margin-right: 12px;">
<line x1="0" y1="6" x2="24" y2="6" stroke="'{{ GRAY }}'" stroke-width="2"/>
<svg width="24" height="12" style="margin-right: 12px;" class="legend-or-line">
<line x1="0" y1="6" x2="24" y2="6" stroke="var(--edge-or-color)" stroke-width="2"/>
</svg>
<span>OR Conditions</span>
</div>
<div class="legend-item">
<svg width="24" height="12" style="margin-right: 12px;">
<line x1="0" y1="6" x2="24" y2="6" stroke="'{{ CREWAI_ORANGE }}'" stroke-width="2" stroke-dasharray="5,5"/>
<line x1="0" y1="6" x2="24" y2="6" stroke="'{{ CREWAI_ORANGE }}'" stroke-width="2"/>
</svg>
<span>AND Conditions</span>
</div>

View File

@@ -12,6 +12,7 @@
--shadow-strong: rgba(0, 0, 0, 0.15);
--edge-label-text: '{{ GRAY }}';
--edge-label-bg: rgba(255, 255, 255, 0.8);
--edge-or-color: #000000;
}
[data-theme="dark"] {
@@ -28,6 +29,7 @@
--shadow-strong: rgba(0, 0, 0, 0.5);
--edge-label-text: #c9d1d9;
--edge-label-bg: rgba(22, 27, 34, 0.9);
--edge-or-color: #ffffff;
}
@keyframes dash {

View File

@@ -1,14 +1,15 @@
"""Flow structure builder for analyzing Flow execution."""
from __future__ import annotations
from collections import defaultdict
import inspect
from typing import TYPE_CHECKING, Any
from crewai.flow.constants import OR_CONDITION
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
from crewai.flow.flow_wrappers import FlowCondition
from crewai.flow.types import FlowMethodName
from crewai.flow.utils import (
_extract_all_methods_recursive,
is_flow_condition_dict,
is_simple_flow_condition,
)
@@ -21,7 +22,7 @@ if TYPE_CHECKING:
def _extract_direct_or_triggers(
condition: str | dict[str, Any] | list[Any],
condition: str | dict[str, Any] | list[Any] | FlowCondition,
) -> list[str]:
"""Extract direct OR-level trigger strings from a condition.
@@ -43,16 +44,15 @@ def _extract_direct_or_triggers(
if isinstance(condition, str):
return [condition]
if isinstance(condition, dict):
cond_type = condition.get("type", "OR")
cond_type = condition.get("type", OR_CONDITION)
conditions_list = condition.get("conditions", [])
if cond_type == "OR":
if cond_type == OR_CONDITION:
strings = []
for sub_cond in conditions_list:
strings.extend(_extract_direct_or_triggers(sub_cond))
return strings
else:
return []
return []
if isinstance(condition, list):
strings = []
for item in condition:
@@ -64,7 +64,7 @@ def _extract_direct_or_triggers(
def _extract_all_trigger_names(
condition: str | dict[str, Any] | list[Any],
condition: str | dict[str, Any] | list[Any] | FlowCondition,
) -> list[str]:
"""Extract ALL trigger names from a condition for display purposes.
@@ -101,6 +101,76 @@ def _extract_all_trigger_names(
return []
def _create_edges_from_condition(
condition: str | dict[str, Any] | list[Any] | FlowCondition,
target: str,
nodes: dict[str, NodeMetadata],
) -> list[StructureEdge]:
"""Create edges from a condition tree, preserving AND/OR semantics.
This function recursively processes the condition tree and creates edges
with the appropriate condition_type for each trigger.
For AND conditions, all triggers get edges with condition_type="AND".
For OR conditions, triggers get edges with condition_type="OR".
Args:
condition: The condition tree (string, dict, or list).
target: The target node name.
nodes: Dictionary of all nodes for validation.
Returns:
List of StructureEdge objects representing the condition.
"""
edges: list[StructureEdge] = []
if isinstance(condition, str):
if condition in nodes:
edges.append(
StructureEdge(
source=condition,
target=target,
condition_type=OR_CONDITION,
is_router_path=False,
)
)
elif callable(condition) and hasattr(condition, "__name__"):
method_name = condition.__name__
if method_name in nodes:
edges.append(
StructureEdge(
source=method_name,
target=target,
condition_type=OR_CONDITION,
is_router_path=False,
)
)
elif isinstance(condition, dict):
cond_type = condition.get("type", OR_CONDITION)
conditions_list = condition.get("conditions", [])
if cond_type == AND_CONDITION:
triggers = _extract_all_trigger_names(condition)
edges.extend(
StructureEdge(
source=trigger,
target=target,
condition_type=AND_CONDITION,
is_router_path=False,
)
for trigger in triggers
if trigger in nodes
)
else:
for sub_cond in conditions_list:
edges.extend(_create_edges_from_condition(sub_cond, target, nodes))
elif isinstance(condition, list):
for item in condition:
edges.extend(_create_edges_from_condition(item, target, nodes))
return edges
def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
"""Build a structure representation of a Flow's execution.
@@ -228,28 +298,22 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
nodes[method_name] = node_metadata
for listener_name, condition_data in flow._listeners.items():
condition_type: str | None = None
trigger_methods_list: list[str] = []
if is_simple_flow_condition(condition_data):
cond_type, methods = condition_data
condition_type = cond_type
trigger_methods_list = [str(m) for m in methods]
elif is_flow_condition_dict(condition_data):
condition_type = condition_data.get("type", OR_CONDITION)
methods_recursive = _extract_all_methods_recursive(condition_data, flow)
trigger_methods_list = [str(m) for m in methods_recursive]
edges.extend(
StructureEdge(
source=str(trigger_method),
target=str(listener_name),
condition_type=condition_type,
is_router_path=False,
edges.extend(
StructureEdge(
source=str(trigger_method),
target=str(listener_name),
condition_type=cond_type,
is_router_path=False,
)
for trigger_method in methods
if str(trigger_method) in nodes
)
elif is_flow_condition_dict(condition_data):
edges.extend(
_create_edges_from_condition(condition_data, str(listener_name), nodes)
)
for trigger_method in trigger_methods_list
if trigger_method in nodes
)
for router_method_name in router_methods:
if router_method_name not in flow._router_paths:
@@ -299,76 +363,6 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
)
def structure_to_dict(structure: FlowStructure) -> dict[str, Any]:
"""Convert FlowStructure to plain dictionary for serialization.
Args:
structure: FlowStructure to convert.
Returns:
Plain dictionary representation.
"""
return {
"nodes": dict(structure["nodes"]),
"edges": list(structure["edges"]),
"start_methods": list(structure["start_methods"]),
"router_methods": list(structure["router_methods"]),
}
def print_structure_summary(structure: FlowStructure) -> str:
"""Generate human-readable summary of Flow structure.
Args:
structure: FlowStructure to summarize.
Returns:
Formatted string summary.
"""
lines: list[str] = []
lines.append("Flow Execution Structure")
lines.append("=" * 50)
lines.append(f"Total nodes: {len(structure['nodes'])}")
lines.append(f"Total edges: {len(structure['edges'])}")
lines.append(f"Start methods: {len(structure['start_methods'])}")
lines.append(f"Router methods: {len(structure['router_methods'])}")
lines.append("")
if structure["start_methods"]:
lines.append("Start Methods:")
for method_name in structure["start_methods"]:
node = structure["nodes"][method_name]
lines.append(f" - {method_name}")
if node.get("condition_type"):
lines.append(f" Condition: {node['condition_type']}")
if node.get("trigger_methods"):
lines.append(f" Triggers on: {', '.join(node['trigger_methods'])}")
lines.append("")
if structure["router_methods"]:
lines.append("Router Methods:")
for method_name in structure["router_methods"]:
node = structure["nodes"][method_name]
lines.append(f" - {method_name}")
if node.get("router_paths"):
lines.append(f" Paths: {', '.join(node['router_paths'])}")
lines.append("")
if structure["edges"]:
lines.append("Connections:")
for edge in structure["edges"]:
edge_type = ""
if edge["is_router_path"]:
edge_type = " [Router Path]"
elif edge["condition_type"]:
edge_type = f" [{edge['condition_type']}]"
lines.append(f" {edge['source']} -> {edge['target']}{edge_type}")
lines.append("")
return "\n".join(lines)
def calculate_execution_paths(structure: FlowStructure) -> int:
"""Calculate number of possible execution paths through the flow.
@@ -396,6 +390,15 @@ def calculate_execution_paths(structure: FlowStructure) -> int:
return 0
def count_paths_from(node: str, visited: set[str]) -> int:
"""Recursively count execution paths from a given node.
Args:
node: Node name to start counting from.
visited: Set of already visited nodes to prevent cycles.
Returns:
Number of execution paths from this node to terminal nodes.
"""
if node in terminal_nodes:
return 1

View File

@@ -10,8 +10,6 @@ import pytest
from crewai.flow.flow import Flow, and_, listen, or_, router, start
from crewai.flow.visualization import (
build_flow_structure,
print_structure_summary,
structure_to_dict,
visualize_flow_structure,
)
@@ -144,65 +142,6 @@ def test_build_flow_structure_with_and_or_conditions():
assert len(or_edges) == 2
def test_structure_to_dict():
"""Test converting flow structure to dictionary format."""
flow = SimpleFlow()
structure = build_flow_structure(flow)
dag_dict = structure_to_dict(structure)
assert "nodes" in dag_dict
assert "edges" in dag_dict
assert "start_methods" in dag_dict
assert "router_methods" in dag_dict
assert "begin" in dag_dict["nodes"]
assert "process" in dag_dict["nodes"]
begin_node = dag_dict["nodes"]["begin"]
assert begin_node["type"] == "start"
assert "method_signature" in begin_node
assert "source_code" in begin_node
assert len(dag_dict["edges"]) == 1
edge = dag_dict["edges"][0]
assert "source" in edge
assert "target" in edge
assert "condition_type" in edge
assert "is_router_path" in edge
def test_structure_to_dict_with_router():
"""Test dictionary conversion for flow with router."""
flow = RouterFlow()
structure = build_flow_structure(flow)
dag_dict = structure_to_dict(structure)
decide_node = dag_dict["nodes"]["decide"]
assert decide_node["type"] == "router"
assert decide_node["is_router"] is True
if "router_paths" in decide_node:
assert len(decide_node["router_paths"]) >= 1
router_edges = [edge for edge in dag_dict["edges"] if edge["is_router_path"]]
assert len(router_edges) >= 1
def test_structure_to_dict_with_complex_conditions():
"""Test dictionary conversion for flow with complex conditions."""
flow = ComplexFlow()
structure = build_flow_structure(flow)
dag_dict = structure_to_dict(structure)
converge_and_node = dag_dict["nodes"]["converge_and"]
assert converge_and_node["condition_type"] == "AND"
assert "trigger_condition" in converge_and_node
assert converge_and_node["trigger_condition"]["type"] == "AND"
converge_or_node = dag_dict["nodes"]["converge_or"]
assert converge_or_node["condition_type"] == "OR"
def test_visualize_flow_structure_creates_html():
"""Test that visualization generates valid HTML file."""
flow = SimpleFlow()
@@ -243,7 +182,7 @@ def test_visualize_flow_structure_creates_assets():
js_content = js_file.read_text(encoding="utf-8")
assert len(js_content) > 0
assert "var nodes" in js_content or "const nodes" in js_content
assert "NetworkManager" in js_content
def test_visualize_flow_structure_json_data():
@@ -268,22 +207,6 @@ def test_visualize_flow_structure_json_data():
assert "path_b" in js_content
def test_print_structure_summary():
"""Test printing flow structure summary."""
flow = ComplexFlow()
structure = build_flow_structure(flow)
output = print_structure_summary(structure)
assert "Total nodes:" in output
assert "Total edges:" in output
assert "Start methods:" in output
assert "Router methods:" in output
assert "start_a" in output
assert "start_b" in output
def test_node_metadata_includes_source_info():
"""Test that nodes include source code and line number information."""
flow = SimpleFlow()
@@ -364,8 +287,7 @@ def test_visualization_handles_special_characters():
assert len(structure["nodes"]) == 2
dag_dict = structure_to_dict(structure)
json_str = json.dumps(dag_dict)
json_str = json.dumps(structure)
assert json_str is not None
assert "method_with_underscore" in json_str
assert "another_method_123" in json_str
@@ -390,7 +312,6 @@ def test_topological_path_counting():
"""Test that topological path counting is accurate."""
flow = ComplexFlow()
structure = build_flow_structure(flow)
dag_dict = structure_to_dict(structure)
assert len(structure["nodes"]) > 0
assert len(structure["edges"]) > 0

View File

@@ -117,13 +117,7 @@ warn_return_any = true
show_error_codes = true
warn_unused_ignores = true
python_version = "3.12"
exclude = [
"lib/crewai/src/crewai/cli/templates",
"lib/crewai/tests/",
# crewai-tools
"lib/crewai-tools/tests/",
"lib/crewai/src/crewai/experimental/a2a"
]
exclude = "(?x)(^lib/crewai/src/crewai/cli/templates/ | ^lib/crewai/tests/ | ^lib/crewai-tools/tests/)"
plugins = ["pydantic.mypy", "crewai.mypy"]