Compare commits

..

1 Commits

Author SHA1 Message Date
Devin AI
f2b4429c45 fix: use uv directly in pre-commit hooks for cross-platform support
Fixes #4322

The previous pre-commit config used bash-specific commands that don't work
on Windows:
- bash -c '...' wrapper
- source command
- Unix-style paths (.venv/bin/activate)

This change simplifies the entry commands to use 'uv run' directly, which:
- Works on Windows, Linux, and macOS
- Automatically handles virtual environment activation
- Removes the need for bash-specific syntax

Co-Authored-By: João <joao@crewai.com>
2026-01-31 23:17:28 +00:00
22 changed files with 100 additions and 459 deletions

View File

@@ -1,63 +0,0 @@
name: Generate Tool Specifications
on:
pull_request:
branches:
- main
paths:
- 'lib/crewai-tools/src/crewai_tools/**'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
generate-specs:
runs-on: ubuntu-latest
env:
PYTHONUNBUFFERED: 1
steps:
- name: Generate GitHub App token
id: app-token
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.CREWAI_TOOL_SPECS_APP_ID }}
private_key: ${{ secrets.CREWAI_TOOL_SPECS_PRIVATE_KEY }}
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
token: ${{ steps.app-token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: "0.8.4"
python-version: "3.12"
enable-cache: true
- name: Install the project
working-directory: lib/crewai-tools
run: uv sync --dev --all-extras
- name: Generate tool specifications
working-directory: lib/crewai-tools
run: uv run python src/crewai_tools/generate_tool_specs.py
- name: Check for changes and commit
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add lib/crewai-tools/tool.specs.json
if git diff --quiet --staged; then
echo "No changes detected in tool.specs.json"
else
echo "Changes detected in tool.specs.json, committing..."
git commit -m "chore: update tool specifications"
git push
fi

View File

@@ -3,19 +3,19 @@ repos:
hooks:
- id: ruff
name: ruff
entry: bash -c 'source .venv/bin/activate && uv run ruff check --config pyproject.toml "$@"' --
entry: uv run ruff check --config pyproject.toml
language: system
pass_filenames: true
types: [python]
- id: ruff-format
name: ruff-format
entry: bash -c 'source .venv/bin/activate && uv run ruff format --config pyproject.toml "$@"' --
entry: uv run ruff format --config pyproject.toml
language: system
pass_filenames: true
types: [python]
- id: mypy
name: mypy
entry: bash -c 'source .venv/bin/activate && uv run mypy --config-file pyproject.toml "$@"' --
entry: uv run mypy --config-file pyproject.toml
language: system
pass_filenames: true
types: [python]
@@ -30,4 +30,3 @@ repos:
- id: commitizen
- id: commitizen-branch
stages: [ pre-push ]

View File

@@ -137,7 +137,6 @@ class StagehandTool(BaseTool):
- 'observe': For finding elements in a specific area
"""
args_schema: type[BaseModel] = StagehandToolSchema
package_dependencies: list[str] = Field(default_factory=lambda: ["stagehand"])
# Stagehand configuration
api_key: str | None = None

View File

@@ -8,13 +8,11 @@ Example:
from crewai.flow import Flow, start, human_feedback
from crewai.flow.async_feedback import HumanFeedbackProvider, HumanFeedbackPending
class SlackProvider(HumanFeedbackProvider):
def request_feedback(self, context, flow):
self.send_slack_notification(context)
raise HumanFeedbackPending(context=context)
class MyFlow(Flow):
@start()
@human_feedback(
@@ -28,13 +26,12 @@ Example:
```
"""
from crewai.flow.async_feedback.providers import ConsoleProvider
from crewai.flow.async_feedback.types import (
HumanFeedbackPending,
HumanFeedbackProvider,
PendingFeedbackContext,
)
from crewai.flow.async_feedback.providers import ConsoleProvider
__all__ = [
"ConsoleProvider",

View File

@@ -6,11 +6,10 @@ provider that collects feedback via console input.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from crewai.flow.async_feedback.types import PendingFeedbackContext
if TYPE_CHECKING:
from crewai.flow.flow import Flow
@@ -28,7 +27,6 @@ class ConsoleProvider:
```python
from crewai.flow.async_feedback import ConsoleProvider
# Explicitly use console provider
@human_feedback(
message="Review this:",
@@ -51,7 +49,7 @@ class ConsoleProvider:
def request_feedback(
self,
context: PendingFeedbackContext,
flow: Flow[Any],
flow: Flow,
) -> str:
"""Request feedback via console input (blocking).

View File

@@ -10,7 +10,6 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from crewai.flow.flow import Flow
@@ -156,7 +155,7 @@ class HumanFeedbackPending(Exception): # noqa: N818 - Not an error, a control f
callback_info={
"slack_channel": "#reviews",
"thread_id": ticket_id,
},
}
)
```
"""
@@ -233,7 +232,7 @@ class HumanFeedbackProvider(Protocol):
callback_info={
"channel": self.channel,
"thread_id": thread_id,
},
}
)
```
"""
@@ -241,7 +240,7 @@ class HumanFeedbackProvider(Protocol):
def request_feedback(
self,
context: PendingFeedbackContext,
flow: Flow[Any],
flow: Flow,
) -> str:
"""Request feedback from a human.

View File

@@ -1,5 +1,4 @@
from typing import Final, Literal
AND_CONDITION: Final[Literal["AND"]] = "AND"
OR_CONDITION: Final[Literal["OR"]] = "OR"

View File

@@ -58,7 +58,6 @@ from crewai.events.types.flow_events import (
MethodExecutionStartedEvent,
)
from crewai.flow.constants import AND_CONDITION, OR_CONDITION
from crewai.flow.flow_context import current_flow_id, current_flow_request_id
from crewai.flow.flow_wrappers import (
FlowCondition,
FlowConditions,
@@ -513,17 +512,11 @@ class FlowMeta(type):
and attr_value.__is_router__
):
routers.add(attr_name)
if (
hasattr(attr_value, "__router_paths__")
and attr_value.__router_paths__
):
router_paths[attr_name] = attr_value.__router_paths__
possible_returns = get_possible_return_constants(attr_value)
if possible_returns:
router_paths[attr_name] = possible_returns
else:
possible_returns = get_possible_return_constants(attr_value)
if possible_returns:
router_paths[attr_name] = possible_returns
else:
router_paths[attr_name] = []
router_paths[attr_name] = []
# Handle start methods that are also routers (e.g., @human_feedback with emit)
if (
@@ -1547,13 +1540,6 @@ class Flow(Generic[T], metaclass=FlowMeta):
ctx = baggage.set_baggage("flow_input_files", input_files or {}, context=ctx)
flow_token = attach(ctx)
flow_id_token = None
request_id_token = None
if current_flow_id.get() is None:
flow_id_token = current_flow_id.set(self.flow_id)
if current_flow_request_id.get() is None:
request_id_token = current_flow_request_id.set(self.flow_id)
try:
# Reset flow state for fresh execution unless restoring from persistence
is_restoring = inputs and "id" in inputs and self._persistence is not None
@@ -1731,10 +1717,6 @@ class Flow(Generic[T], metaclass=FlowMeta):
return final_output
finally:
if request_id_token is not None:
current_flow_request_id.reset(request_id_token)
if flow_id_token is not None:
current_flow_id.reset(flow_id_token)
detach(flow_token)
async def akickoff(

View File

@@ -8,7 +8,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from crewai.flow.async_feedback.types import HumanFeedbackProvider

View File

@@ -1,16 +0,0 @@
"""Flow execution context management.
This module provides context variables for tracking flow execution state across
async boundaries and nested function calls.
"""
import contextvars
current_flow_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"flow_request_id", default=None
)
current_flow_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"flow_id", default=None
)

View File

@@ -1,22 +1,46 @@
from pydantic import BaseModel, model_validator
import inspect
from typing import Any
from pydantic import BaseModel, Field, InstanceOf, model_validator
from typing_extensions import Self
from crewai.flow.flow_context import current_flow_id, current_flow_request_id
from crewai.flow.flow import Flow
class FlowTrackable(BaseModel):
"""Mixin that tracks flow execution context for objects created within flows.
"""Mixin that tracks the Flow instance that instantiated the object, e.g. a
Flow instance that created a Crew or Agent.
When a Crew or Agent is instantiated inside a flow execution, this mixin
automatically captures the flow ID and request ID from context variables,
enabling proper tracking and association with the parent flow execution.
Automatically finds and stores a reference to the parent Flow instance by
inspecting the call stack.
"""
parent_flow: InstanceOf[Flow[Any]] | None = Field(
default=None,
description="The parent flow of the instance, if it was created inside a flow.",
)
@model_validator(mode="after")
def _set_flow_context(self) -> Self:
request_id = current_flow_request_id.get()
if request_id:
self._request_id = request_id
self._flow_id = current_flow_id.get()
def _set_parent_flow(self) -> Self:
max_depth = 8
frame = inspect.currentframe()
try:
if frame is None:
return self
frame = frame.f_back
for _ in range(max_depth):
if frame is None:
break
candidate = frame.f_locals.get("self")
if isinstance(candidate, Flow):
self.parent_flow = candidate
break
frame = frame.f_back
finally:
del frame
return self

View File

@@ -11,7 +11,6 @@ Example (synchronous, default):
```python
from crewai.flow import Flow, start, listen, human_feedback
class ReviewFlow(Flow):
@start()
@human_feedback(
@@ -33,13 +32,11 @@ Example (asynchronous with custom provider):
from crewai.flow import Flow, start, human_feedback
from crewai.flow.async_feedback import HumanFeedbackProvider, HumanFeedbackPending
class SlackProvider(HumanFeedbackProvider):
def request_feedback(self, context, flow):
self.send_notification(context)
raise HumanFeedbackPending(context=context)
class ReviewFlow(Flow):
@start()
@human_feedback(
@@ -232,7 +229,6 @@ def human_feedback(
def review_document(self):
return document_content
@listen("approved")
def publish(self):
print(f"Publishing: {self.last_human_feedback.output}")
@@ -269,7 +265,7 @@ def human_feedback(
def decorator(func: F) -> F:
"""Inner decorator that wraps the function."""
def _request_feedback(flow_instance: Flow[Any], method_output: Any) -> str:
def _request_feedback(flow_instance: Flow, method_output: Any) -> str:
"""Request feedback using provider or default console."""
from crewai.flow.async_feedback.types import PendingFeedbackContext
@@ -295,16 +291,19 @@ def human_feedback(
effective_provider = flow_config.hitl_provider
if effective_provider is not None:
# Use provider (may raise HumanFeedbackPending for async providers)
return effective_provider.request_feedback(context, flow_instance)
return flow_instance._request_human_feedback(
message=message,
output=method_output,
metadata=metadata,
emit=emit,
)
else:
# Use default console input (local development)
return flow_instance._request_human_feedback(
message=message,
output=method_output,
metadata=metadata,
emit=emit,
)
def _process_feedback(
flow_instance: Flow[Any],
flow_instance: Flow,
method_output: Any,
raw_feedback: str,
) -> HumanFeedbackResult | str:
@@ -320,14 +319,12 @@ def human_feedback(
# No default and no feedback - use first outcome
collapsed_outcome = emit[0]
elif emit:
if llm is not None:
collapsed_outcome = flow_instance._collapse_to_outcome(
feedback=raw_feedback,
outcomes=emit,
llm=llm,
)
else:
collapsed_outcome = emit[0]
# Collapse feedback to outcome using LLM
collapsed_outcome = flow_instance._collapse_to_outcome(
feedback=raw_feedback,
outcomes=emit,
llm=llm,
)
# Create result
result = HumanFeedbackResult(
@@ -352,7 +349,7 @@ def human_feedback(
if asyncio.iscoroutinefunction(func):
# Async wrapper
@wraps(func)
async def async_wrapper(self: Flow[Any], *args: Any, **kwargs: Any) -> Any:
async def async_wrapper(self: Flow, *args: Any, **kwargs: Any) -> Any:
# Execute the original method
method_output = await func(self, *args, **kwargs)
@@ -366,7 +363,7 @@ def human_feedback(
else:
# Sync wrapper
@wraps(func)
def sync_wrapper(self: Flow[Any], *args: Any, **kwargs: Any) -> Any:
def sync_wrapper(self: Flow, *args: Any, **kwargs: Any) -> Any:
# Execute the original method
method_output = func(self, *args, **kwargs)
@@ -400,10 +397,11 @@ def human_feedback(
)
wrapper.__is_flow_method__ = True
# Make it a router if emit specified
if emit:
wrapper.__is_router__ = True
wrapper.__router_paths__ = list(emit)
return wrapper # type: ignore[no-any-return]
return wrapper # type: ignore[return-value]
return decorator

View File

@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
if TYPE_CHECKING:
from crewai.flow.async_feedback.types import PendingFeedbackContext
@@ -104,3 +103,4 @@ class FlowPersistence(ABC):
Args:
flow_uuid: Unique identifier for the flow instance
"""
pass

View File

@@ -15,7 +15,6 @@ from pydantic import BaseModel
from crewai.flow.persistence.base import FlowPersistence
from crewai.utilities.paths import db_storage_path
if TYPE_CHECKING:
from crewai.flow.async_feedback.types import PendingFeedbackContext
@@ -177,8 +176,7 @@ class SQLiteFlowPersistence(FlowPersistence):
row = cursor.fetchone()
if row:
result = json.loads(row[0])
return result if isinstance(result, dict) else None
return json.loads(row[0])
return None
def save_pending_feedback(
@@ -198,6 +196,7 @@ class SQLiteFlowPersistence(FlowPersistence):
state_data: Current state data
"""
# Import here to avoid circular imports
from crewai.flow.async_feedback.types import PendingFeedbackContext
# Convert state_data to dict
if isinstance(state_data, BaseModel):

View File

@@ -1025,7 +1025,7 @@ class TriggeredByHighlighter {
const isAndOrRouter = edge.dashes || edge.label === "AND";
const highlightColor = isAndOrRouter
? (edge.color?.color || "{{ CREWAI_ORANGE }}")
? "{{ 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
? (edge.color?.color || "{{ CREWAI_ORANGE }}")
? "{{ 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"
? (edge.color?.color || "{{ CREWAI_ORANGE }}")
? "{{ 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"
? (edge.color?.color || "{{ CREWAI_ORANGE }}")
? "{{ 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 = edge.color?.color || "{{ CREWAI_ORANGE }}";
edgeColor = "{{ CREWAI_ORANGE }}";
} else {
edgeColor = orEdgeColor;
}

View File

@@ -129,7 +129,7 @@ def _create_edges_from_condition(
edges: list[StructureEdge] = []
if isinstance(condition, str):
if condition in nodes and condition != target:
if condition in nodes:
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 and method_name != target:
if method_name in nodes:
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 and trigger != target
if trigger in nodes
)
else:
for sub_cond in conditions_list:
@@ -196,34 +196,9 @@ 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
if "is_human_feedback" not in node_metadata:
node_metadata["type"] = "router"
else:
node_metadata["type"] = "human_feedback"
node_metadata["type"] = "router"
router_methods.append(method_name)
if method_name in flow._router_paths:
@@ -342,7 +317,7 @@ def build_flow_structure(flow: Flow[Any]) -> FlowStructure:
is_router_path=False,
)
for trigger_method in methods
if str(trigger_method) in nodes and str(trigger_method) != listener_name
if str(trigger_method) in nodes
)
elif is_flow_condition_dict(condition_data):
edges.extend(

View File

@@ -81,7 +81,6 @@ class JSExtension(Extension):
CREWAI_ORANGE = "#FF5A50"
HITL_BLUE = "#4A90E2"
DARK_GRAY = "#333333"
WHITE = "#FFFFFF"
GRAY = "#666666"
@@ -226,7 +225,6 @@ 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
@@ -243,17 +241,6 @@ 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)",
@@ -279,57 +266,16 @@ def render_interactive(
title_parts: list[str] = []
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
type_badge_bg: str = (
CREWAI_ORANGE if node_type in ["start", "router"] else DARK_GRAY
)
title_parts.append(f"""
<div style="border-bottom: 1px solid rgba(102,102,102,0.15); padding-bottom: 8px; margin-bottom: 10px;">
<div style="font-size: 13px; font-weight: 700; color: {DARK_GRAY}; margin-bottom: 6px;">{name}</div>
<span style="display: inline-block; background: {type_badge_bg}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">{display_type}</span>
<span style="display: inline-block; background: {type_badge_bg}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">{node_type}</span>
</div>
""")
if is_human_feedback:
feedback_msg = metadata.get("human_feedback_message", "")
if feedback_msg:
title_parts.append(f"""
<div style="margin-bottom: 8px;">
<div style="font-size: 10px; text-transform: uppercase; color: {GRAY}; letter-spacing: 0.5px; margin-bottom: 4px; font-weight: 600;">👤 Human Feedback</div>
<div style="background: rgba(74,144,226,0.08); padding: 6px 8px; border-radius: 4px; font-size: 11px; color: {DARK_GRAY}; border: 1px solid rgba(74,144,226,0.2); line-height: 1.4;">{feedback_msg}</div>
</div>
""")
if metadata.get("human_feedback_emit"):
emit_options = metadata["human_feedback_emit"]
emit_items = "".join(
[
f'<li style="margin: 3px 0;"><code style="background: rgba(74,144,226,0.08); padding: 2px 6px; border-radius: 3px; font-size: 10px; color: {HITL_BLUE}; border: 1px solid rgba(74,144,226,0.2); font-weight: 600;">{opt}</code></li>'
for opt in emit_options
]
)
title_parts.append(f"""
<div style="margin-bottom: 8px;">
<div style="font-size: 10px; text-transform: uppercase; color: {GRAY}; letter-spacing: 0.5px; margin-bottom: 4px; font-weight: 600;">Outcomes</div>
<ul style="list-style: none; padding: 0; margin: 0;">{emit_items}</ul>
</div>
""")
if metadata.get("human_feedback_llm"):
llm_model = metadata["human_feedback_llm"]
title_parts.append(f"""
<div style="margin-bottom: 8px;">
<div style="font-size: 10px; text-transform: uppercase; color: {GRAY}; letter-spacing: 0.5px; margin-bottom: 3px; font-weight: 600;">LLM</div>
<span style="display: inline-block; background: rgba(102,102,102,0.08); padding: 3px 8px; border-radius: 4px; font-size: 10px; color: {DARK_GRAY}; border: 1px solid rgba(102,102,102,0.12);">{llm_model}</span>
</div>
""")
if metadata.get("condition_type"):
condition = metadata["condition_type"]
if condition == "AND":
@@ -363,7 +309,7 @@ def render_interactive(
</div>
""")
if metadata.get("router_paths") and not is_human_feedback:
if metadata.get("router_paths"):
paths = metadata["router_paths"]
paths_items = "".join(
[
@@ -419,11 +365,7 @@ def render_interactive(
edge_dashes: bool | list[int] = False
if edge["is_router_path"]:
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_color = CREWAI_ORANGE
edge_dashes = [15, 10]
if "router_path_label" in edge:
edge_label = edge["router_path_label"]
@@ -475,7 +417,6 @@ 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")
@@ -489,7 +430,6 @@ 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))
@@ -501,7 +441,6 @@ def render_interactive(
html_content = template.render(
CREWAI_ORANGE=CREWAI_ORANGE,
HITL_BLUE=HITL_BLUE,
DARK_GRAY=DARK_GRAY,
WHITE=WHITE,
GRAY=GRAY,

View File

@@ -21,11 +21,6 @@ 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):

View File

@@ -299,16 +299,14 @@ class TestFlow(Flow):
return agent.kickoff("Test query")
def verify_agent_flow_context(result, agent, flow):
"""Verify that both the result and agent have the correct flow context."""
assert result._flow_id == flow.flow_id # type: ignore[attr-defined]
assert result._request_id == flow.flow_id # type: ignore[attr-defined]
def verify_agent_parent_flow(result, agent, flow):
"""Verify that both the result and agent have the correct parent flow."""
assert result.parent_flow is flow
assert agent is not None
assert agent._flow_id == flow.flow_id # type: ignore[attr-defined]
assert agent._request_id == flow.flow_id # type: ignore[attr-defined]
assert agent.parent_flow is flow
def test_sets_flow_context_when_inside_flow():
def test_sets_parent_flow_when_inside_flow():
"""Test that an Agent can be created and executed inside a Flow context."""
captured_event = None

View File

@@ -4520,7 +4520,7 @@ def test_crew_copy_with_memory():
pytest.fail(f"Copying crew raised an unexpected exception: {e}")
def test_sets_flow_context_when_using_crewbase_pattern_inside_flow():
def test_sets_parent_flow_when_using_crewbase_pattern_inside_flow():
@CrewBase
class TestCrew:
agents_config = None
@@ -4582,11 +4582,10 @@ def test_sets_flow_context_when_using_crewbase_pattern_inside_flow():
flow.kickoff()
assert captured_crew is not None
assert captured_crew._flow_id == flow.flow_id # type: ignore[attr-defined]
assert captured_crew._request_id == flow.flow_id # type: ignore[attr-defined]
assert captured_crew.parent_flow is flow
def test_sets_flow_context_when_outside_flow(researcher, writer):
def test_sets_parent_flow_when_outside_flow(researcher, writer):
crew = Crew(
agents=[researcher, writer],
process=Process.sequential,
@@ -4595,12 +4594,11 @@ def test_sets_flow_context_when_outside_flow(researcher, writer):
Task(description="Task 2", expected_output="output", agent=writer),
],
)
assert not hasattr(crew, "_flow_id")
assert not hasattr(crew, "_request_id")
assert crew.parent_flow is None
@pytest.mark.vcr()
def test_sets_flow_context_when_inside_flow(researcher, writer):
def test_sets_parent_flow_when_inside_flow(researcher, writer):
class MyFlow(Flow):
@start()
def start(self):
@@ -4617,8 +4615,7 @@ def test_sets_flow_context_when_inside_flow(researcher, writer):
flow = MyFlow()
result = flow.kickoff()
assert result._flow_id == flow.flow_id # type: ignore[attr-defined]
assert result._request_id == flow.flow_id # type: ignore[attr-defined]
assert result.parent_flow is flow
def test_reset_knowledge_with_no_crew_knowledge(researcher, writer):

View File

@@ -8,7 +8,6 @@ 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,
@@ -668,180 +667,4 @@ 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)
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:"
assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages)