diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py
index 1b4665620..45327487b 100644
--- a/lib/crewai/src/crewai/flow/flow.py
+++ b/lib/crewai/src/crewai/flow/flow.py
@@ -513,11 +513,17 @@ class FlowMeta(type):
and attr_value.__is_router__
):
routers.add(attr_name)
- possible_returns = get_possible_return_constants(attr_value)
- if possible_returns:
- router_paths[attr_name] = possible_returns
+ if (
+ hasattr(attr_value, "__router_paths__")
+ and attr_value.__router_paths__
+ ):
+ router_paths[attr_name] = attr_value.__router_paths__
else:
- router_paths[attr_name] = []
+ possible_returns = get_possible_return_constants(attr_value)
+ if possible_returns:
+ router_paths[attr_name] = possible_returns
+ else:
+ router_paths[attr_name] = []
# Handle start methods that are also routers (e.g., @human_feedback with emit)
if (
diff --git a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js
index 10788727f..bbfa39ff1 100644
--- a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js
+++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js
@@ -1025,7 +1025,7 @@ class TriggeredByHighlighter {
const isAndOrRouter = edge.dashes || edge.label === "AND";
const highlightColor = isAndOrRouter
- ? "{{ CREWAI_ORANGE }}"
+ ? (edge.color?.color || "{{ CREWAI_ORANGE }}")
: getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
const updateData = {
@@ -1080,7 +1080,7 @@ class TriggeredByHighlighter {
// Keep the original edge color instead of turning gray
const isAndOrRouter = edge.dashes || edge.label === "AND";
const baseColor = isAndOrRouter
- ? "{{ CREWAI_ORANGE }}"
+ ? (edge.color?.color || "{{ CREWAI_ORANGE }}")
: getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
// Convert color to rgba with opacity for vis.js
@@ -1142,7 +1142,7 @@ class TriggeredByHighlighter {
const defaultColor =
edge.dashes || edge.label === "AND"
- ? "{{ CREWAI_ORANGE }}"
+ ? (edge.color?.color || "{{ CREWAI_ORANGE }}")
: getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0;
const currentWidth =
@@ -1253,7 +1253,7 @@ class TriggeredByHighlighter {
const defaultColor =
edge.dashes || edge.label === "AND"
- ? "{{ CREWAI_ORANGE }}"
+ ? (edge.color?.color || "{{ CREWAI_ORANGE }}")
: getComputedStyle(document.documentElement).getPropertyValue('--edge-or-color').trim();
const currentOpacity = edge.opacity !== undefined ? edge.opacity : 1.0;
const currentWidth =
@@ -2370,7 +2370,7 @@ class NetworkManager {
this.edges.forEach((edge) => {
let edgeColor;
if (edge.dashes || edge.label === "AND") {
- edgeColor = "{{ CREWAI_ORANGE }}";
+ edgeColor = edge.color?.color || "{{ CREWAI_ORANGE }}";
} else {
edgeColor = orEdgeColor;
}
diff --git a/lib/crewai/src/crewai/flow/visualization/builder.py b/lib/crewai/src/crewai/flow/visualization/builder.py
index e277c1bbc..4a53d37f7 100644
--- a/lib/crewai/src/crewai/flow/visualization/builder.py
+++ b/lib/crewai/src/crewai/flow/visualization/builder.py
@@ -129,7 +129,7 @@ def _create_edges_from_condition(
edges: list[StructureEdge] = []
if isinstance(condition, str):
- if condition in nodes:
+ if condition in nodes and condition != target:
edges.append(
StructureEdge(
source=condition,
@@ -140,7 +140,7 @@ def _create_edges_from_condition(
)
elif callable(condition) and hasattr(condition, "__name__"):
method_name = condition.__name__
- if method_name in nodes:
+ if method_name in nodes and method_name != target:
edges.append(
StructureEdge(
source=method_name,
@@ -163,7 +163,7 @@ def _create_edges_from_condition(
is_router_path=False,
)
for trigger in triggers
- if trigger in nodes
+ if trigger in nodes and trigger != target
)
else:
for sub_cond in conditions_list:
@@ -196,9 +196,34 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
node_metadata["type"] = "start"
start_methods.append(method_name)
+ if (
+ hasattr(method, "__human_feedback_config__")
+ and method.__human_feedback_config__
+ ):
+ config = method.__human_feedback_config__
+ node_metadata["is_human_feedback"] = True
+ node_metadata["human_feedback_message"] = config.message
+
+ if config.emit:
+ node_metadata["human_feedback_emit"] = list(config.emit)
+
+ if config.llm:
+ llm_str = (
+ config.llm
+ if isinstance(config.llm, str)
+ else str(type(config.llm).__name__)
+ )
+ node_metadata["human_feedback_llm"] = llm_str
+
+ if config.default_outcome:
+ node_metadata["human_feedback_default_outcome"] = config.default_outcome
+
if hasattr(method, "__is_router__") and method.__is_router__:
node_metadata["is_router"] = True
- node_metadata["type"] = "router"
+ if "is_human_feedback" not in node_metadata:
+ node_metadata["type"] = "router"
+ else:
+ node_metadata["type"] = "human_feedback"
router_methods.append(method_name)
if method_name in flow._router_paths:
@@ -317,7 +342,7 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
is_router_path=False,
)
for trigger_method in methods
- if str(trigger_method) in nodes
+ if str(trigger_method) in nodes and str(trigger_method) != listener_name
)
elif is_flow_condition_dict(condition_data):
edges.extend(
diff --git a/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py b/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py
index 88242bea6..b2b3c8730 100644
--- a/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py
+++ b/lib/crewai/src/crewai/flow/visualization/renderers/interactive.py
@@ -81,6 +81,7 @@ class JSExtension(Extension):
CREWAI_ORANGE = "#FF5A50"
+HITL_BLUE = "#4A90E2"
DARK_GRAY = "#333333"
WHITE = "#FFFFFF"
GRAY = "#666666"
@@ -225,6 +226,7 @@ def render_interactive(
nodes_list: list[dict[str, Any]] = []
for name, metadata in dag["nodes"].items():
node_type: str = metadata.get("type", "listen")
+ is_human_feedback: bool = metadata.get("is_human_feedback", False)
color_config: dict[str, Any]
font_color: str
@@ -241,6 +243,17 @@ def render_interactive(
}
font_color = "var(--node-text-color)"
border_width = 3
+ elif node_type == "human_feedback":
+ color_config = {
+ "background": "var(--node-bg-router)",
+ "border": HITL_BLUE,
+ "highlight": {
+ "background": "var(--node-bg-router)",
+ "border": HITL_BLUE,
+ },
+ }
+ font_color = "var(--node-text-color)"
+ border_width = 3
elif node_type == "router":
color_config = {
"background": "var(--node-bg-router)",
@@ -266,16 +279,57 @@ def render_interactive(
title_parts: list[str] = []
- type_badge_bg: str = (
- CREWAI_ORANGE if node_type in ["start", "router"] else DARK_GRAY
- )
+ display_type = node_type
+ type_badge_bg: str
+ if node_type == "human_feedback":
+ type_badge_bg = HITL_BLUE
+ display_type = "HITL"
+ elif node_type in ["start", "router"]:
+ type_badge_bg = CREWAI_ORANGE
+ else:
+ type_badge_bg = DARK_GRAY
+
title_parts.append(f"""
{name}
-
{node_type}
+
{display_type}
""")
+ if is_human_feedback:
+ feedback_msg = metadata.get("human_feedback_message", "")
+ if feedback_msg:
+ title_parts.append(f"""
+
+
👤 Human Feedback
+
{feedback_msg}
+
+ """)
+
+ if metadata.get("human_feedback_emit"):
+ emit_options = metadata["human_feedback_emit"]
+ emit_items = "".join(
+ [
+ f'{opt}'
+ for opt in emit_options
+ ]
+ )
+ title_parts.append(f"""
+
+ """)
+
+ if metadata.get("human_feedback_llm"):
+ llm_model = metadata["human_feedback_llm"]
+ title_parts.append(f"""
+
+ """)
+
if metadata.get("condition_type"):
condition = metadata["condition_type"]
if condition == "AND":
@@ -309,7 +363,7 @@ def render_interactive(
""")
- if metadata.get("router_paths"):
+ if metadata.get("router_paths") and not is_human_feedback:
paths = metadata["router_paths"]
paths_items = "".join(
[
@@ -365,7 +419,11 @@ def render_interactive(
edge_dashes: bool | list[int] = False
if edge["is_router_path"]:
- edge_color = CREWAI_ORANGE
+ source_node = dag["nodes"].get(edge["source"], {})
+ if source_node.get("is_human_feedback", False):
+ edge_color = HITL_BLUE
+ else:
+ edge_color = CREWAI_ORANGE
edge_dashes = [15, 10]
if "router_path_label" in edge:
edge_label = edge["router_path_label"]
@@ -417,6 +475,7 @@ def render_interactive(
css_content = css_content.replace("'{{ DARK_GRAY }}'", DARK_GRAY)
css_content = css_content.replace("'{{ GRAY }}'", GRAY)
css_content = css_content.replace("'{{ CREWAI_ORANGE }}'", CREWAI_ORANGE)
+ css_content = css_content.replace("'{{ HITL_BLUE }}'", HITL_BLUE)
css_output_path.write_text(css_content, encoding="utf-8")
@@ -430,6 +489,7 @@ def render_interactive(
js_content = js_content.replace("{{ DARK_GRAY }}", DARK_GRAY)
js_content = js_content.replace("{{ GRAY }}", GRAY)
js_content = js_content.replace("{{ CREWAI_ORANGE }}", CREWAI_ORANGE)
+ js_content = js_content.replace("{{ HITL_BLUE }}", HITL_BLUE)
js_content = js_content.replace("'{{ nodeData }}'", dag_nodes_json)
js_content = js_content.replace("'{{ dagData }}'", dag_full_json)
js_content = js_content.replace("'{{ nodes_list_json }}'", json.dumps(nodes_list))
@@ -441,6 +501,7 @@ def render_interactive(
html_content = template.render(
CREWAI_ORANGE=CREWAI_ORANGE,
+ HITL_BLUE=HITL_BLUE,
DARK_GRAY=DARK_GRAY,
WHITE=WHITE,
GRAY=GRAY,
diff --git a/lib/crewai/src/crewai/flow/visualization/types.py b/lib/crewai/src/crewai/flow/visualization/types.py
index 6ce57069e..f932e94cb 100644
--- a/lib/crewai/src/crewai/flow/visualization/types.py
+++ b/lib/crewai/src/crewai/flow/visualization/types.py
@@ -21,6 +21,11 @@ class NodeMetadata(TypedDict, total=False):
class_signature: str
class_name: str
class_line_number: int
+ is_human_feedback: bool
+ human_feedback_message: str
+ human_feedback_emit: list[str]
+ human_feedback_llm: str
+ human_feedback_default_outcome: str
class StructureEdge(TypedDict, total=False):
diff --git a/lib/crewai/tests/test_flow_visualization.py b/lib/crewai/tests/test_flow_visualization.py
index d55e98bac..f90b28f2b 100644
--- a/lib/crewai/tests/test_flow_visualization.py
+++ b/lib/crewai/tests/test_flow_visualization.py
@@ -8,6 +8,7 @@ from pathlib import Path
import pytest
from crewai.flow.flow import Flow, and_, listen, or_, router, start
+from crewai.flow.human_feedback import human_feedback
from crewai.flow.visualization import (
build_flow_structure,
visualize_flow_structure,
@@ -667,4 +668,180 @@ def test_no_warning_for_properly_typed_router(caplog):
# No warnings should be logged
warning_messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
assert not any("Could not determine return paths" in msg for msg in warning_messages)
- assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages)
\ No newline at end of file
+ assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages)
+
+
+def test_human_feedback_node_metadata():
+ """Test that human feedback nodes have correct metadata."""
+ from typing import Literal
+
+ class HITLFlow(Flow):
+ """Flow with human-in-the-loop feedback."""
+
+ @start()
+ @human_feedback(
+ message="Please review the output:",
+ emit=["approved", "rejected"],
+ llm="gpt-4o-mini",
+ )
+ def review_content(self) -> Literal["approved", "rejected"]:
+ return "approved"
+
+ @listen("approved")
+ def on_approved(self):
+ return "published"
+
+ @listen("rejected")
+ def on_rejected(self):
+ return "discarded"
+
+ flow = HITLFlow()
+ structure = build_flow_structure(flow)
+
+ review_node = structure["nodes"]["review_content"]
+ assert review_node["is_human_feedback"] is True
+ assert review_node["type"] == "human_feedback"
+ assert review_node["human_feedback_message"] == "Please review the output:"
+ assert review_node["human_feedback_emit"] == ["approved", "rejected"]
+ assert review_node["human_feedback_llm"] == "gpt-4o-mini"
+
+
+def test_human_feedback_visualization_includes_hitl_data():
+ """Test that visualization includes human feedback data in HTML."""
+ from typing import Literal
+
+ class HITLFlow(Flow):
+ """Flow with human-in-the-loop feedback."""
+
+ @start()
+ @human_feedback(
+ message="Please review the output:",
+ emit=["approved", "rejected"],
+ llm="gpt-4o-mini",
+ )
+ def review_content(self) -> Literal["approved", "rejected"]:
+ return "approved"
+
+ @listen("approved")
+ def on_approved(self):
+ return "published"
+
+ flow = HITLFlow()
+ structure = build_flow_structure(flow)
+
+ html_file = visualize_flow_structure(structure, "test_hitl.html", show=False)
+ html_path = Path(html_file)
+
+ js_file = html_path.parent / f"{html_path.stem}_script.js"
+ js_content = js_file.read_text(encoding="utf-8")
+
+ assert "HITL" in js_content
+ assert "Please review the output:" in js_content
+ assert "approved" in js_content
+ assert "rejected" in js_content
+ assert "#4A90E2" in js_content
+
+
+def test_human_feedback_without_emit_metadata():
+ """Test that human feedback without emit has correct metadata."""
+
+ class HITLSimpleFlow(Flow):
+ """Flow with simple human feedback (no routing)."""
+
+ @start()
+ @human_feedback(message="Please provide feedback:")
+ def review_step(self):
+ return "content"
+
+ @listen(review_step)
+ def next_step(self):
+ return "done"
+
+ flow = HITLSimpleFlow()
+ structure = build_flow_structure(flow)
+
+ review_node = structure["nodes"]["review_step"]
+ assert review_node["is_human_feedback"] is True
+ assert "is_router" not in review_node or review_node["is_router"] is False
+ assert review_node["type"] == "start"
+ assert review_node["human_feedback_message"] == "Please provide feedback:"
+
+
+def test_human_feedback_with_default_outcome():
+ """Test that human feedback with default outcome includes it in metadata."""
+ from typing import Literal
+
+ class HITLDefaultFlow(Flow):
+ """Flow with human feedback that has a default outcome."""
+
+ @start()
+ @human_feedback(
+ message="Review this:",
+ emit=["approved", "needs_work"],
+ llm="gpt-4o-mini",
+ default_outcome="needs_work",
+ )
+ def review(self) -> Literal["approved", "needs_work"]:
+ return "approved"
+
+ @listen("approved")
+ def on_approved(self):
+ return "published"
+
+ @listen("needs_work")
+ def on_needs_work(self):
+ return "revised"
+
+ flow = HITLDefaultFlow()
+ structure = build_flow_structure(flow)
+
+ review_node = structure["nodes"]["review"]
+ assert review_node["is_human_feedback"] is True
+ assert review_node["human_feedback_default_outcome"] == "needs_work"
+
+
+def test_mixed_router_and_human_feedback():
+ """Test flow with both regular routers and human feedback routers."""
+ from typing import Literal
+
+ class MixedFlow(Flow):
+ """Flow with both regular routers and HITL."""
+
+ @start()
+ def init(self):
+ return "initialized"
+
+ @router(init)
+ def auto_decision(self) -> Literal["path_a", "path_b"]:
+ return "path_a"
+
+ @listen("path_a")
+ @human_feedback(
+ message="Review this step:",
+ emit=["continue", "stop"],
+ llm="gpt-4o-mini",
+ )
+ def human_review(self) -> Literal["continue", "stop"]:
+ return "continue"
+
+ @listen("continue")
+ def proceed(self):
+ return "done"
+
+ @listen("stop")
+ def halt(self):
+ return "halted"
+
+ flow = MixedFlow()
+ structure = build_flow_structure(flow)
+
+ auto_node = structure["nodes"]["auto_decision"]
+ assert auto_node["type"] == "router"
+ assert auto_node["is_router"] is True
+ assert "is_human_feedback" not in auto_node or auto_node["is_human_feedback"] is False
+
+ human_node = structure["nodes"]["human_review"]
+ assert human_node["type"] == "human_feedback"
+ assert human_node["is_router"] is True
+ assert human_node["is_human_feedback"] is True
+ assert human_node["human_feedback_message"] == "Review this step:"
\ No newline at end of file