From 1aba9fe41556d00a16bed2c31e0217196325c1ba Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Mon, 1 Jun 2026 18:37:10 -0300 Subject: [PATCH] Split `flow.py` into DSL, definition, and runtime (#5997) This commit separates the monolithic `flow.py` into three modules, each with one job: - `dsl.py` - the Python DSL for flows (@start/@listen/@router, or_/and_) - `flow_definition.py` - the structural model extracted from the DSL - `runtime.py` - the execution engine and state for flows This phase moves code only and should not have any breaking changes. --- lib/crewai/src/crewai/flow/dsl.py | 320 ++ lib/crewai/src/crewai/flow/flow.py | 3652 +---------------- lib/crewai/src/crewai/flow/flow_definition.py | 1036 +++++ lib/crewai/src/crewai/flow/runtime.py | 3272 +++++++++++++++ lib/crewai/src/crewai/flow/utils.py | 995 +---- lib/crewai/tests/test_async_human_feedback.py | 24 +- 6 files changed, 4720 insertions(+), 4579 deletions(-) create mode 100644 lib/crewai/src/crewai/flow/dsl.py create mode 100644 lib/crewai/src/crewai/flow/flow_definition.py create mode 100644 lib/crewai/src/crewai/flow/runtime.py diff --git a/lib/crewai/src/crewai/flow/dsl.py b/lib/crewai/src/crewai/flow/dsl.py new file mode 100644 index 000000000..3181acd50 --- /dev/null +++ b/lib/crewai/src/crewai/flow/dsl.py @@ -0,0 +1,320 @@ +"""Flow authoring DSL: the ``@start`` / ``@listen`` / ``@router`` decorators +plus the ``or_`` / ``and_`` condition combinators. + +These decorators wrap user methods into the typed wrappers defined in +``flow_wrappers`` and record their trigger conditions. The structural model +those conditions feed is built in ``flow_definition``; execution happens in +``runtime``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, ParamSpec, TypeVar + +from crewai.flow.constants import AND_CONDITION, OR_CONDITION +from crewai.flow.flow_definition import ( + _extract_all_methods, + is_flow_condition_dict, + is_flow_method_callable, + is_flow_method_name, +) +from crewai.flow.flow_wrappers import ( + FlowCondition, + FlowConditions, + ListenMethod, + RouterMethod, + StartMethod, +) + + +P = ParamSpec("P") +R = TypeVar("R") + + +def start( + condition: str | FlowCondition | Callable[..., Any] | None = None, +) -> Callable[[Callable[P, R]], StartMethod[P, R]]: + """Marks a method as a flow's starting point. + + This decorator designates a method as an entry point for the flow execution. + It can optionally specify conditions that trigger the start based on other + method executions. + + Args: + condition: Defines when the start method should execute. Can be: + - str: Name of a method that triggers this start + - FlowCondition: Result from or_() or and_(), including nested conditions + - Callable[..., Any]: A method reference that triggers this start + Default is None, meaning unconditional start. + + Returns: + A decorator function that wraps the method as a flow start point and preserves its signature. + + Raises: + ValueError: If the condition format is invalid. + + Examples: + >>> @start() # Unconditional start + >>> def begin_flow(self): + ... pass + + >>> @start("method_name") # Start after specific method + >>> def conditional_start(self): + ... pass + + >>> @start(and_("method1", "method2")) # Start after multiple methods + >>> def complex_start(self): + ... pass + """ + + def decorator(func: Callable[P, R]) -> StartMethod[P, R]: + """Decorator that wraps a function as a start method. + + Args: + func: The function to wrap as a start method. + + Returns: + A StartMethod wrapper around the function. + """ + wrapper = StartMethod(func) + + if condition is not None: + if is_flow_method_name(condition): + wrapper.__trigger_methods__ = [condition] + wrapper.__condition_type__ = OR_CONDITION + elif is_flow_condition_dict(condition): + if "conditions" in condition: + wrapper.__trigger_condition__ = condition + wrapper.__trigger_methods__ = _extract_all_methods(condition) + wrapper.__condition_type__ = condition["type"] + elif "methods" in condition: + wrapper.__trigger_methods__ = condition["methods"] + wrapper.__condition_type__ = condition["type"] + else: + raise ValueError( + "Condition dict must contain 'conditions' or 'methods'" + ) + elif is_flow_method_callable(condition): + wrapper.__trigger_methods__ = [condition.__name__] + wrapper.__condition_type__ = OR_CONDITION + else: + raise ValueError( + "Condition must be a method, string, or a result of or_() or and_()" + ) + return wrapper + + return decorator + + +def listen( + condition: str | FlowCondition | Callable[..., Any], +) -> Callable[[Callable[P, R]], ListenMethod[P, R]]: + """Creates a listener that executes when specified conditions are met. + + This decorator sets up a method to execute in response to other method + executions in the flow. It supports both simple and complex triggering + conditions. + + Args: + condition: Specifies when the listener should execute. + + Returns: + A decorator function that wraps the method as a flow listener and preserves its signature. + + Raises: + ValueError: If the condition format is invalid. + + Examples: + >>> @listen("process_data") + >>> def handle_processed_data(self): + ... pass + + >>> @listen("method_name") + >>> def handle_completion(self): + ... pass + """ + + def decorator(func: Callable[P, R]) -> ListenMethod[P, R]: + """Decorator that wraps a function as a listener method. + + Args: + func: The function to wrap as a listener method. + + Returns: + A ListenMethod wrapper around the function. + """ + wrapper = ListenMethod(func) + + if is_flow_method_name(condition): + wrapper.__trigger_methods__ = [condition] + wrapper.__condition_type__ = OR_CONDITION + elif is_flow_condition_dict(condition): + if "conditions" in condition: + wrapper.__trigger_condition__ = condition + wrapper.__trigger_methods__ = _extract_all_methods(condition) + wrapper.__condition_type__ = condition["type"] + elif "methods" in condition: + wrapper.__trigger_methods__ = condition["methods"] + wrapper.__condition_type__ = condition["type"] + else: + raise ValueError( + "Condition dict must contain 'conditions' or 'methods'" + ) + elif is_flow_method_callable(condition): + wrapper.__trigger_methods__ = [condition.__name__] + wrapper.__condition_type__ = OR_CONDITION + else: + raise ValueError( + "Condition must be a method, string, or a result of or_() or and_()" + ) + return wrapper + + return decorator + + +def router( + condition: str | FlowCondition | Callable[..., Any], +) -> Callable[[Callable[P, R]], RouterMethod[P, R]]: + """Creates a routing method that directs flow execution based on conditions. + + This decorator marks a method as a router, which can dynamically determine + the next steps in the flow based on its return value. Routers are triggered + by specified conditions and can return constants that determine which path + the flow should take. + + Args: + condition: Specifies when the router should execute. Can be: + - str: Name of a method that triggers this router + - FlowCondition: Result from or_() or and_(), including nested conditions + - Callable[..., Any]: A method reference that triggers this router + + Returns: + A decorator function that wraps the method as a router and preserves its signature. + + Raises: + ValueError: If the condition format is invalid. + + Examples: + >>> @router("check_status") + >>> def route_based_on_status(self): + ... if self.state.status == "success": + ... return "SUCCESS" + ... return "FAILURE" + + >>> @router(and_("validate", "process")) + >>> def complex_routing(self): + ... if all([self.state.valid, self.state.processed]): + ... return "CONTINUE" + ... return "STOP" + """ + + def decorator(func: Callable[P, R]) -> RouterMethod[P, R]: + """Decorator that wraps a function as a router method. + + Args: + func: The function to wrap as a router method. + + Returns: + A RouterMethod wrapper around the function. + """ + wrapper = RouterMethod(func) + + if is_flow_method_name(condition): + wrapper.__trigger_methods__ = [condition] + wrapper.__condition_type__ = OR_CONDITION + elif is_flow_condition_dict(condition): + if "conditions" in condition: + wrapper.__trigger_condition__ = condition + wrapper.__trigger_methods__ = _extract_all_methods(condition) + wrapper.__condition_type__ = condition["type"] + elif "methods" in condition: + wrapper.__trigger_methods__ = condition["methods"] + wrapper.__condition_type__ = condition["type"] + else: + raise ValueError( + "Condition dict must contain 'conditions' or 'methods'" + ) + elif is_flow_method_callable(condition): + wrapper.__trigger_methods__ = [condition.__name__] + wrapper.__condition_type__ = OR_CONDITION + else: + raise ValueError( + "Condition must be a method, string, or a result of or_() or and_()" + ) + return wrapper + + return decorator + + +def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: + """Combines multiple conditions with OR logic for flow control. + + Creates a condition that is satisfied when any of the specified conditions + are met. This is used with @start, @listen, or @router decorators to create + complex triggering conditions. + + Args: + conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. + + Returns: + A condition dictionary with format {"type": "OR", "conditions": list_of_conditions} where each condition can be a string (method name) or a nested dict + + Raises: + ValueError: If condition format is invalid. + + Examples: + >>> @listen(or_("success", "timeout")) + >>> def handle_completion(self): + ... pass + + >>> @listen(or_(and_("step1", "step2"), "step3")) + >>> def handle_nested(self): + ... pass + """ + processed_conditions: FlowConditions = [] + for condition in conditions: + if is_flow_condition_dict(condition) or is_flow_method_name(condition): + processed_conditions.append(condition) + elif is_flow_method_callable(condition): + processed_conditions.append(condition.__name__) + else: + raise ValueError("Invalid condition in or_()") + return {"type": OR_CONDITION, "conditions": processed_conditions} + + +def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: + """Combines multiple conditions with AND logic for flow control. + + Creates a condition that is satisfied only when all specified conditions + are met. This is used with @start, @listen, or @router decorators to create + complex triggering conditions. + + Args: + *conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. + + Returns: + A condition dictionary with format {"type": "AND", "conditions": list_of_conditions} + where each condition can be a string (method name) or a nested dict + + Raises: + ValueError: If any condition is invalid. + + Examples: + >>> @listen(and_("validated", "processed")) + >>> def handle_complete_data(self): + ... pass + + >>> @listen(and_(or_("step1", "step2"), "step3")) + >>> def handle_nested(self): + ... pass + """ + processed_conditions: FlowConditions = [] + for condition in conditions: + if is_flow_condition_dict(condition) or is_flow_method_name(condition): + processed_conditions.append(condition) + elif is_flow_method_callable(condition): + processed_conditions.append(condition.__name__) + else: + raise ValueError("Invalid condition in and_()") + return {"type": AND_CONDITION, "conditions": processed_conditions} diff --git a/lib/crewai/src/crewai/flow/flow.py b/lib/crewai/src/crewai/flow/flow.py index 1ac8b9fde..3cac04521 100644 --- a/lib/crewai/src/crewai/flow/flow.py +++ b/lib/crewai/src/crewai/flow/flow.py @@ -1,3625 +1,39 @@ -"""Core flow execution framework with decorators and state management. +"""Backwards-compatible re-export surface for the Flow framework. -This module provides the Flow class and decorators (@start, @listen, @router) -for building event-driven workflows with conditional execution and routing. +The implementation now lives in three modules, split by concern: + +- ``crewai.flow.dsl`` -- authoring decorators (``@start`` / ``@listen`` / + ``@router``, ``or_`` / ``and_``) +- ``crewai.flow.flow_definition`` -- the structural model extracted from the DSL +- ``crewai.flow.runtime`` -- the Flow execution engine and state + +Prefer importing from those modules in new code; this module preserves the +historical ``crewai.flow.flow`` import path. """ -from __future__ import annotations - -import asyncio -from collections.abc import ( - Callable, - ItemsView, - Iterable, - Iterator, - KeysView, - Sequence, - ValuesView, -) -from concurrent.futures import Future, ThreadPoolExecutor -import contextvars -import copy -import enum -import inspect -import logging -import threading -from typing import ( - TYPE_CHECKING, - Annotated, - Any, - ClassVar, - Generic, - Literal, - ParamSpec, - SupportsIndex, - TypeVar, - cast, - overload, -) -from uuid import uuid4 - -from opentelemetry import baggage -from opentelemetry.context import attach, detach -from pydantic import ( - BaseModel, - BeforeValidator, - ConfigDict, - Field, - PlainSerializer, - PrivateAttr, - SerializeAsAny, - ValidationError, -) -from pydantic._internal._model_construction import ModelMetaclass -from rich.console import Console -from rich.panel import Panel - -from crewai.events.base_events import reset_emission_counter -from crewai.events.event_bus import crewai_event_bus -from crewai.events.event_context import ( - get_current_parent_id, - reset_last_event_id, - restore_event_scope, - triggered_by_scope, -) -from crewai.events.listeners.tracing.trace_listener import ( - TraceCollectionListener, -) -from crewai.events.listeners.tracing.utils import ( - has_user_declined_tracing, - set_tracing_enabled, - should_enable_tracing, - should_suppress_tracing_messages, -) -from crewai.events.types.flow_events import ( - FlowCreatedEvent, - FlowFinishedEvent, - FlowPausedEvent, - FlowPlotEvent, - FlowStartedEvent, - MethodExecutionFailedEvent, - MethodExecutionFinishedEvent, - MethodExecutionPausedEvent, - 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, - FlowMethod, - ListenMethod, - RouterMethod, - SimpleFlowCondition, - StartMethod, -) -from crewai.flow.human_feedback import HumanFeedbackResult -from crewai.flow.input_provider import InputProvider -from crewai.flow.persistence.base import FlowPersistence -from crewai.flow.types import ( - FlowExecutionData, - FlowMethodName, - InputHistoryEntry, - PendingListenerKey, -) -from crewai.flow.utils import ( - _extract_all_methods, - _extract_all_methods_recursive, - _normalize_condition, - get_possible_return_constants, - is_flow_condition_dict, - is_flow_method, - is_flow_method_callable, - is_flow_method_name, - is_simple_flow_condition, -) -from crewai.memory.memory_scope import MemoryScope, MemorySlice, _ensure_memory_kind -from crewai.memory.unified_memory import Memory -from crewai.state.checkpoint_config import ( - CheckpointConfig, - _coerce_checkpoint, - apply_checkpoint, +from crewai.flow.dsl import and_, listen, or_, router, start +from crewai.flow.runtime import ( + _INITIAL_STATE_CLASS_MARKER, + Flow, + FlowMeta, + FlowState, + LockedDictProxy, + LockedListProxy, + StateProxy, ) -if TYPE_CHECKING: - from crewai_files import FileInput - - from crewai.context import ExecutionContext - from crewai.flow.async_feedback.types import PendingFeedbackContext - from crewai.llms.base_llm import BaseLLM - -from crewai.flow.visualization import build_flow_structure, render_interactive -from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput -from crewai.utilities.env import get_env_context -from crewai.utilities.streaming import ( - TaskInfo, - create_async_chunk_generator, - create_chunk_generator, - create_streaming_state, - register_cleanup, - signal_end, - signal_error, -) - - -logger = logging.getLogger(__name__) - - -def _resolve_persistence(value: Any) -> Any: - if value is None or isinstance(value, FlowPersistence): - return value - if isinstance(value, dict): - from crewai.flow.persistence.base import _persistence_registry - - type_name = value.get("persistence_type", "SQLiteFlowPersistence") - cls = _persistence_registry.get(type_name) - if cls is not None: - return cls.model_validate(value) - return value - - -def _serialize_persistence(value: Any) -> dict[str, Any] | None: - if value is None: - return None - if isinstance(value, FlowPersistence): - return value.model_dump(mode="json") - raise TypeError( - f"Cannot serialize Flow.persistence of type {type(value).__name__}: " - "expected FlowPersistence or None." - ) - - -def _validate_input_provider(value: Any) -> Any: - if value is None or isinstance(value, InputProvider): - return value - from crewai.types.callback import _dotted_path_to_instance - - resolved = _dotted_path_to_instance(value) - if resolved is None or isinstance(resolved, InputProvider): - return resolved - raise ValueError( - f"Resolved input_provider {resolved!r} does not implement the " - "InputProvider protocol (missing request_input)." - ) - - -def _serialize_input_provider(value: Any) -> str | None: - if value is None: - return None - from crewai.types.callback import _instance_to_dotted_path - - return _instance_to_dotted_path(value) - - -_INITIAL_STATE_CLASS_MARKER = "__crewai_pydantic_class_schema__" - - -def _serialize_initial_state(value: Any) -> Any: - """Make ``initial_state`` safe for JSON checkpoint serialization. - - ``BaseModel`` class refs are emitted as their JSON schema under a sentinel - marker key so deserialization can round-trip them back to a class. - ``BaseModel`` instances are dumped to JSON (round-trip as plain dicts, - which ``_create_initial_state`` accepts). Bare ``type`` values that are - not ``BaseModel`` subclasses (e.g. ``dict``) are dropped since they - can't be represented in JSON. - """ - if isinstance(value, type): - if issubclass(value, BaseModel): - return {_INITIAL_STATE_CLASS_MARKER: value.model_json_schema()} - return None - if isinstance(value, BaseModel): - return value.model_dump(mode="json") - return value - - -def _deserialize_initial_state(value: Any) -> Any: - """Rehydrate a class ref serialized by :func:`_serialize_initial_state`.""" - if isinstance(value, dict) and _INITIAL_STATE_CLASS_MARKER in value: - from crewai.utilities.pydantic_schema_utils import create_model_from_schema - - return create_model_from_schema(value[_INITIAL_STATE_CLASS_MARKER]) - return value - - -class FlowState(BaseModel): - """Base model for all flow states, ensuring each state has a unique ID.""" - - id: str = Field( - default_factory=lambda: str(uuid4()), - description="Unique identifier for the flow state", - ) - - -T = TypeVar("T", bound=dict[str, Any] | BaseModel) -P = ParamSpec("P") -R = TypeVar("R") -F = TypeVar("F", bound=Callable[..., Any]) - - -def start( - condition: str | FlowCondition | Callable[..., Any] | None = None, -) -> Callable[[Callable[P, R]], StartMethod[P, R]]: - """Marks a method as a flow's starting point. - - This decorator designates a method as an entry point for the flow execution. - It can optionally specify conditions that trigger the start based on other - method executions. - - Args: - condition: Defines when the start method should execute. Can be: - - str: Name of a method that triggers this start - - FlowCondition: Result from or_() or and_(), including nested conditions - - Callable[..., Any]: A method reference that triggers this start - Default is None, meaning unconditional start. - - Returns: - A decorator function that wraps the method as a flow start point and preserves its signature. - - Raises: - ValueError: If the condition format is invalid. - - Examples: - >>> @start() # Unconditional start - >>> def begin_flow(self): - ... pass - - >>> @start("method_name") # Start after specific method - >>> def conditional_start(self): - ... pass - - >>> @start(and_("method1", "method2")) # Start after multiple methods - >>> def complex_start(self): - ... pass - """ - - def decorator(func: Callable[P, R]) -> StartMethod[P, R]: - """Decorator that wraps a function as a start method. - - Args: - func: The function to wrap as a start method. - - Returns: - A StartMethod wrapper around the function. - """ - wrapper = StartMethod(func) - - if condition is not None: - if is_flow_method_name(condition): - wrapper.__trigger_methods__ = [condition] - wrapper.__condition_type__ = OR_CONDITION - elif is_flow_condition_dict(condition): - if "conditions" in condition: - wrapper.__trigger_condition__ = condition - wrapper.__trigger_methods__ = _extract_all_methods(condition) - wrapper.__condition_type__ = condition["type"] - elif "methods" in condition: - wrapper.__trigger_methods__ = condition["methods"] - wrapper.__condition_type__ = condition["type"] - else: - raise ValueError( - "Condition dict must contain 'conditions' or 'methods'" - ) - elif is_flow_method_callable(condition): - wrapper.__trigger_methods__ = [condition.__name__] - wrapper.__condition_type__ = OR_CONDITION - else: - raise ValueError( - "Condition must be a method, string, or a result of or_() or and_()" - ) - return wrapper - - return decorator - - -def listen( - condition: str | FlowCondition | Callable[..., Any], -) -> Callable[[Callable[P, R]], ListenMethod[P, R]]: - """Creates a listener that executes when specified conditions are met. - - This decorator sets up a method to execute in response to other method - executions in the flow. It supports both simple and complex triggering - conditions. - - Args: - condition: Specifies when the listener should execute. - - Returns: - A decorator function that wraps the method as a flow listener and preserves its signature. - - Raises: - ValueError: If the condition format is invalid. - - Examples: - >>> @listen("process_data") - >>> def handle_processed_data(self): - ... pass - - >>> @listen("method_name") - >>> def handle_completion(self): - ... pass - """ - - def decorator(func: Callable[P, R]) -> ListenMethod[P, R]: - """Decorator that wraps a function as a listener method. - - Args: - func: The function to wrap as a listener method. - - Returns: - A ListenMethod wrapper around the function. - """ - wrapper = ListenMethod(func) - - if is_flow_method_name(condition): - wrapper.__trigger_methods__ = [condition] - wrapper.__condition_type__ = OR_CONDITION - elif is_flow_condition_dict(condition): - if "conditions" in condition: - wrapper.__trigger_condition__ = condition - wrapper.__trigger_methods__ = _extract_all_methods(condition) - wrapper.__condition_type__ = condition["type"] - elif "methods" in condition: - wrapper.__trigger_methods__ = condition["methods"] - wrapper.__condition_type__ = condition["type"] - else: - raise ValueError( - "Condition dict must contain 'conditions' or 'methods'" - ) - elif is_flow_method_callable(condition): - wrapper.__trigger_methods__ = [condition.__name__] - wrapper.__condition_type__ = OR_CONDITION - else: - raise ValueError( - "Condition must be a method, string, or a result of or_() or and_()" - ) - return wrapper - - return decorator - - -def router( - condition: str | FlowCondition | Callable[..., Any], -) -> Callable[[Callable[P, R]], RouterMethod[P, R]]: - """Creates a routing method that directs flow execution based on conditions. - - This decorator marks a method as a router, which can dynamically determine - the next steps in the flow based on its return value. Routers are triggered - by specified conditions and can return constants that determine which path - the flow should take. - - Args: - condition: Specifies when the router should execute. Can be: - - str: Name of a method that triggers this router - - FlowCondition: Result from or_() or and_(), including nested conditions - - Callable[..., Any]: A method reference that triggers this router - - Returns: - A decorator function that wraps the method as a router and preserves its signature. - - Raises: - ValueError: If the condition format is invalid. - - Examples: - >>> @router("check_status") - >>> def route_based_on_status(self): - ... if self.state.status == "success": - ... return "SUCCESS" - ... return "FAILURE" - - >>> @router(and_("validate", "process")) - >>> def complex_routing(self): - ... if all([self.state.valid, self.state.processed]): - ... return "CONTINUE" - ... return "STOP" - """ - - def decorator(func: Callable[P, R]) -> RouterMethod[P, R]: - """Decorator that wraps a function as a router method. - - Args: - func: The function to wrap as a router method. - - Returns: - A RouterMethod wrapper around the function. - """ - wrapper = RouterMethod(func) - - if is_flow_method_name(condition): - wrapper.__trigger_methods__ = [condition] - wrapper.__condition_type__ = OR_CONDITION - elif is_flow_condition_dict(condition): - if "conditions" in condition: - wrapper.__trigger_condition__ = condition - wrapper.__trigger_methods__ = _extract_all_methods(condition) - wrapper.__condition_type__ = condition["type"] - elif "methods" in condition: - wrapper.__trigger_methods__ = condition["methods"] - wrapper.__condition_type__ = condition["type"] - else: - raise ValueError( - "Condition dict must contain 'conditions' or 'methods'" - ) - elif is_flow_method_callable(condition): - wrapper.__trigger_methods__ = [condition.__name__] - wrapper.__condition_type__ = OR_CONDITION - else: - raise ValueError( - "Condition must be a method, string, or a result of or_() or and_()" - ) - return wrapper - - return decorator - - -def or_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: - """Combines multiple conditions with OR logic for flow control. - - Creates a condition that is satisfied when any of the specified conditions - are met. This is used with @start, @listen, or @router decorators to create - complex triggering conditions. - - Args: - conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. - - Returns: - A condition dictionary with format {"type": "OR", "conditions": list_of_conditions} where each condition can be a string (method name) or a nested dict - - Raises: - ValueError: If condition format is invalid. - - Examples: - >>> @listen(or_("success", "timeout")) - >>> def handle_completion(self): - ... pass - - >>> @listen(or_(and_("step1", "step2"), "step3")) - >>> def handle_nested(self): - ... pass - """ - processed_conditions: FlowConditions = [] - for condition in conditions: - if is_flow_condition_dict(condition) or is_flow_method_name(condition): - processed_conditions.append(condition) - elif is_flow_method_callable(condition): - processed_conditions.append(condition.__name__) - else: - raise ValueError("Invalid condition in or_()") - return {"type": OR_CONDITION, "conditions": processed_conditions} - - -def and_(*conditions: str | FlowCondition | Callable[..., Any]) -> FlowCondition: - """Combines multiple conditions with AND logic for flow control. - - Creates a condition that is satisfied only when all specified conditions - are met. This is used with @start, @listen, or @router decorators to create - complex triggering conditions. - - Args: - *conditions: Variable number of conditions that can be method names, existing condition dictionaries, or method references. - - Returns: - A condition dictionary with format {"type": "AND", "conditions": list_of_conditions} - where each condition can be a string (method name) or a nested dict - - Raises: - ValueError: If any condition is invalid. - - Examples: - >>> @listen(and_("validated", "processed")) - >>> def handle_complete_data(self): - ... pass - - >>> @listen(and_(or_("step1", "step2"), "step3")) - >>> def handle_nested(self): - ... pass - """ - processed_conditions: FlowConditions = [] - for condition in conditions: - if is_flow_condition_dict(condition) or is_flow_method_name(condition): - processed_conditions.append(condition) - elif is_flow_method_callable(condition): - processed_conditions.append(condition.__name__) - else: - raise ValueError("Invalid condition in and_()") - return {"type": AND_CONDITION, "conditions": processed_conditions} - - -class LockedListProxy(list, Generic[T]): # type: ignore[type-arg] - """Thread-safe proxy for list operations. - - Subclasses ``list`` so that ``isinstance(proxy, list)`` returns True, - which is required by libraries like LanceDB and Pydantic that do strict - type checks. All mutations go through the lock; reads delegate to the - underlying list. - """ - - def __init__(self, lst: list[T], lock: threading.Lock) -> None: - super().__init__() # empty builtin list; all access goes through self._list - self._list = lst - self._lock = lock - - def append(self, item: T) -> None: - with self._lock: - self._list.append(item) - - def extend(self, items: Iterable[T]) -> None: - with self._lock: - self._list.extend(items) - - def insert(self, index: SupportsIndex, item: T) -> None: - with self._lock: - self._list.insert(index, item) - - def remove(self, item: T) -> None: - with self._lock: - self._list.remove(item) - - def pop(self, index: SupportsIndex = -1) -> T: - with self._lock: - return self._list.pop(index) - - def clear(self) -> None: - with self._lock: - self._list.clear() - - @overload - def __setitem__(self, index: SupportsIndex, value: T) -> None: ... - @overload - def __setitem__(self, index: slice, value: Iterable[T]) -> None: ... - def __setitem__(self, index: Any, value: Any) -> None: - with self._lock: - self._list[index] = value - - def __delitem__(self, index: SupportsIndex | slice) -> None: - with self._lock: - del self._list[index] - - @overload - def __getitem__(self, index: SupportsIndex) -> T: ... - @overload - def __getitem__(self, index: slice) -> list[T]: ... - def __getitem__(self, index: Any) -> Any: - return self._list[index] - - def __len__(self) -> int: - return len(self._list) - - def __iter__(self) -> Iterator[T]: - return iter(self._list) - - def __contains__(self, item: object) -> bool: - return item in self._list - - def __repr__(self) -> str: - return repr(self._list) - - def __bool__(self) -> bool: - return bool(self._list) - - def index( - self, value: T, start: SupportsIndex = 0, stop: SupportsIndex | None = None - ) -> int: - if stop is None: - return self._list.index(value, start) - return self._list.index(value, start, stop) - - def count(self, value: T) -> int: - return self._list.count(value) - - def sort(self, *, key: Any = None, reverse: bool = False) -> None: - with self._lock: - self._list.sort(key=key, reverse=reverse) - - def reverse(self) -> None: - with self._lock: - self._list.reverse() - - def copy(self) -> list[T]: - return self._list.copy() - - def __add__(self, other: list[T]) -> list[T]: # type: ignore[override] - return self._list + other - - def __radd__(self, other: list[T]) -> list[T]: - return other + self._list - - def __iadd__(self, other: Iterable[T]) -> LockedListProxy[T]: # type: ignore[override] - with self._lock: - self._list += list(other) - return self - - def __mul__(self, n: SupportsIndex) -> list[T]: - return self._list * n - - def __rmul__(self, n: SupportsIndex) -> list[T]: - return self._list * n - - def __imul__(self, n: SupportsIndex) -> LockedListProxy[T]: - with self._lock: - self._list *= n - return self - - def __reversed__(self) -> Iterator[T]: - return reversed(self._list) - - def __eq__(self, other: object) -> bool: - """Compare based on the underlying list contents.""" - if isinstance(other, LockedListProxy): - # Avoid deadlocks by acquiring locks in a consistent order. - first, second = (self, other) if id(self) <= id(other) else (other, self) - with first._lock: - with second._lock: - return first._list == second._list - with self._lock: - return self._list == other - - def __ne__(self, other: object) -> bool: - return not self.__eq__(other) - - -class LockedDictProxy(dict, Generic[T]): # type: ignore[type-arg] - """Thread-safe proxy for dict operations. - - Subclasses ``dict`` so that ``isinstance(proxy, dict)`` returns True, - which is required by libraries like Pydantic that do strict type checks. - All mutations go through the lock; reads delegate to the underlying dict. - """ - - def __init__(self, d: dict[str, T], lock: threading.Lock) -> None: - super().__init__() # empty builtin dict; all access goes through self._dict - self._dict = d - self._lock = lock - - def __setitem__(self, key: str, value: T) -> None: - with self._lock: - self._dict[key] = value - - def __delitem__(self, key: str) -> None: - with self._lock: - del self._dict[key] - - def pop(self, key: str, *default: T) -> T: # type: ignore[override] - with self._lock: - return self._dict.pop(key, *default) - - def update(self, other: dict[str, T]) -> None: # type: ignore[override] - with self._lock: - self._dict.update(other) - - def clear(self) -> None: - with self._lock: - self._dict.clear() - - def setdefault(self, key: str, default: T) -> T: # type: ignore[override] - with self._lock: - return self._dict.setdefault(key, default) - - def __getitem__(self, key: str) -> T: - return self._dict[key] - - def __len__(self) -> int: - return len(self._dict) - - def __iter__(self) -> Iterator[str]: - return iter(self._dict) - - def __contains__(self, key: object) -> bool: - return key in self._dict - - def keys(self) -> KeysView[str]: # type: ignore[override] - return self._dict.keys() - - def values(self) -> ValuesView[T]: # type: ignore[override] - return self._dict.values() - - def items(self) -> ItemsView[str, T]: # type: ignore[override] - return self._dict.items() - - def get(self, key: str, default: T | None = None) -> T | None: # type: ignore[override] - return self._dict.get(key, default) - - def __repr__(self) -> str: - return repr(self._dict) - - def __bool__(self) -> bool: - return bool(self._dict) - - def copy(self) -> dict[str, T]: - return self._dict.copy() - - def __or__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override] - return self._dict | other - - def __ror__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override] - return other | self._dict - - def __ior__(self, other: dict[str, T]) -> LockedDictProxy[T]: # type: ignore[override] - with self._lock: - self._dict |= other - return self - - def __reversed__(self) -> Iterator[str]: - return reversed(self._dict) - - def __eq__(self, other: object) -> bool: - """Compare based on the underlying dict contents.""" - if isinstance(other, LockedDictProxy): - # Avoid deadlocks by acquiring locks in a consistent order. - first, second = (self, other) if id(self) <= id(other) else (other, self) - with first._lock: - with second._lock: - return first._dict == second._dict - with self._lock: - return self._dict == other - - def __ne__(self, other: object) -> bool: - return not self.__eq__(other) - - -class StateProxy(Generic[T]): - """Proxy that provides thread-safe access to flow state. - - Wraps state objects (dict or BaseModel) and uses a lock for all write - operations to prevent race conditions when parallel listeners modify state. - """ - - __slots__ = ("_proxy_lock", "_proxy_state") - - def __init__(self, state: T, lock: threading.Lock) -> None: - object.__setattr__(self, "_proxy_state", state) - object.__setattr__(self, "_proxy_lock", lock) - - def __getattr__(self, name: str) -> Any: - value = getattr(object.__getattribute__(self, "_proxy_state"), name) - lock = object.__getattribute__(self, "_proxy_lock") - if isinstance(value, list): - return LockedListProxy(value, lock) - if isinstance(value, dict): - return LockedDictProxy(value, lock) - return value - - def __setattr__(self, name: str, value: Any) -> None: - if name in ("_proxy_state", "_proxy_lock"): - object.__setattr__(self, name, value) - else: - if isinstance(value, LockedListProxy): - value = value._list - elif isinstance(value, LockedDictProxy): - value = value._dict - with object.__getattribute__(self, "_proxy_lock"): - setattr(object.__getattribute__(self, "_proxy_state"), name, value) - - def __getitem__(self, key: str) -> Any: - return object.__getattribute__(self, "_proxy_state")[key] - - def __setitem__(self, key: str, value: Any) -> None: - with object.__getattribute__(self, "_proxy_lock"): - object.__getattribute__(self, "_proxy_state")[key] = value - - def __delitem__(self, key: str) -> None: - with object.__getattribute__(self, "_proxy_lock"): - del object.__getattribute__(self, "_proxy_state")[key] - - def __contains__(self, key: str) -> bool: - return key in object.__getattribute__(self, "_proxy_state") - - def __repr__(self) -> str: - return repr(object.__getattribute__(self, "_proxy_state")) - - def _unwrap(self) -> T: - """Return the underlying state object.""" - return cast(T, object.__getattribute__(self, "_proxy_state")) - - def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: - """Return state as a dictionary. - - Works for both dict and BaseModel underlying states. - """ - state = object.__getattribute__(self, "_proxy_state") - if isinstance(state, dict): - return state - result: dict[str, Any] = state.model_dump(*args, **kwargs) - return result - - -class FlowMeta(ModelMetaclass): - def __new__( - mcs, - name: str, - bases: tuple[type, ...], - namespace: dict[str, Any], - **kwargs: Any, - ) -> type: - parent_fields: set[str] = set() - for base in bases: - if hasattr(base, "model_fields"): - parent_fields.update(base.model_fields) - - annotations = namespace.get("__annotations__", {}) - _skip_types = (classmethod, staticmethod, property) - - for base in bases: - if isinstance(base, ModelMetaclass): - continue - for attr_name in getattr(base, "__annotations__", {}): - if attr_name not in annotations and attr_name not in namespace: - annotations[attr_name] = ClassVar - - for attr_name, attr_value in namespace.items(): - if isinstance(attr_value, property) and attr_name not in annotations: - for base in bases: - base_ann = getattr(base, "__annotations__", {}) - if attr_name in base_ann: - annotations[attr_name] = ClassVar - - for attr_name, attr_value in list(namespace.items()): - if attr_name in annotations or attr_name.startswith("_"): - continue - if attr_name in parent_fields: - annotations[attr_name] = Any - if isinstance(attr_value, BaseModel): - namespace[attr_name] = Field( - default_factory=lambda v=attr_value: v, exclude=True - ) - continue - if callable(attr_value) or isinstance( - attr_value, (*_skip_types, FlowMethod) - ): - continue - annotations[attr_name] = ClassVar[type(attr_value)] - namespace["__annotations__"] = annotations - - cls = super().__new__(mcs, name, bases, namespace) - - start_methods = [] - listeners = {} - router_paths = {} - routers = set() - - for attr_name, attr_value in namespace.items(): - if ( - hasattr(attr_value, "__is_flow_method__") - or hasattr(attr_value, "__is_start_method__") - or hasattr(attr_value, "__trigger_methods__") - or hasattr(attr_value, "__is_router__") - ): - if hasattr(attr_value, "__is_start_method__"): - start_methods.append(attr_name) - - if ( - hasattr(attr_value, "__trigger_methods__") - and attr_value.__trigger_methods__ is not None - ): - methods = attr_value.__trigger_methods__ - condition_type = getattr( - attr_value, "__condition_type__", OR_CONDITION - ) - - if ( - hasattr(attr_value, "__trigger_condition__") - and attr_value.__trigger_condition__ is not None - ): - listeners[attr_name] = attr_value.__trigger_condition__ - else: - listeners[attr_name] = (condition_type, methods) - - if ( - hasattr(attr_value, "__is_router__") - and attr_value.__is_router__ - ): - routers.add(attr_name) - # Explicit __router_paths__ set by @human_feedback(emit=[...]) takes priority over source analysis - if ( - hasattr(attr_value, "__router_paths__") - and attr_value.__router_paths__ - ): - router_paths[attr_name] = attr_value.__router_paths__ - else: - 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 ( - hasattr(attr_value, "__is_start_method__") - and hasattr(attr_value, "__is_router__") - 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__ - else: - possible_returns = get_possible_return_constants(attr_value) - if possible_returns: - router_paths[attr_name] = possible_returns - else: - router_paths[attr_name] = [] - - cls._start_methods = start_methods # type: ignore[attr-defined] - cls._listeners = listeners # type: ignore[attr-defined] - cls._routers = routers # type: ignore[attr-defined] - cls._router_paths = router_paths # type: ignore[attr-defined] - - return cls - - -class Flow(BaseModel, Generic[T], metaclass=FlowMeta): - """Base class for all flows. - - type parameter T must be either dict[str, Any] or a subclass of BaseModel.""" - - model_config = ConfigDict( - arbitrary_types_allowed=True, - ignored_types=(StartMethod, ListenMethod, RouterMethod), - revalidate_instances="never", - ) - __hash__ = object.__hash__ - - _start_methods: ClassVar[list[FlowMethodName]] = [] - _listeners: ClassVar[dict[FlowMethodName, SimpleFlowCondition | FlowCondition]] = {} - _routers: ClassVar[set[FlowMethodName]] = set() - _router_paths: ClassVar[dict[FlowMethodName, list[FlowMethodName]]] = {} - - entity_type: Literal["flow"] = "flow" - - initial_state: Annotated[ # type: ignore[type-arg] - type[BaseModel] | type[dict] | dict[str, Any] | BaseModel | None, - BeforeValidator(_deserialize_initial_state), - PlainSerializer(_serialize_initial_state, return_type=Any, when_used="json"), - ] = Field(default=None) - name: str | None = Field(default=None) - tracing: bool | None = Field(default=None) - stream: bool = Field(default=False) - memory: Annotated[ - Annotated[ - Memory | MemoryScope | MemorySlice, Field(discriminator="memory_kind") - ] - | None, - BeforeValidator(_ensure_memory_kind), - ] = Field(default=None) - input_provider: Annotated[ - InputProvider | None, - BeforeValidator(_validate_input_provider), - PlainSerializer( - _serialize_input_provider, return_type=str | None, when_used="json" - ), - ] = Field(default=None) - suppress_flow_events: bool = Field(default=False) - human_feedback_history: list[HumanFeedbackResult] = Field(default_factory=list) - last_human_feedback: HumanFeedbackResult | None = Field(default=None) - - persistence: Annotated[ - SerializeAsAny[FlowPersistence] | None, - BeforeValidator(lambda v, _: _resolve_persistence(v)), - PlainSerializer( - _serialize_persistence, return_type=dict | None, when_used="json" - ), - ] = Field(default=None) - max_method_calls: int = Field(default=100) - - execution_context: ExecutionContext | None = Field(default=None) - checkpoint: Annotated[ - CheckpointConfig | bool | None, - BeforeValidator(_coerce_checkpoint), - ] = Field(default=None) - - @classmethod - def from_checkpoint(cls, config: CheckpointConfig) -> Flow: # type: ignore[type-arg] - """Restore a Flow from a checkpoint. - - Args: - config: Checkpoint configuration with ``restore_from`` set to - the path of the checkpoint to load. - - Returns: - A Flow instance ready to resume. - """ - from crewai.context import apply_execution_context - from crewai.events.event_bus import crewai_event_bus - from crewai.state.runtime import RuntimeState - - state = RuntimeState.from_checkpoint(config, context={"from_checkpoint": True}) - crewai_event_bus.set_runtime_state(state) - for entity in state.root: - if not isinstance(entity, Flow): - continue - if entity.execution_context is not None: - apply_execution_context(entity.execution_context) - if isinstance(entity, cls): - entity._restore_from_checkpoint() - return entity - instance = cls() - instance.checkpoint_completed_methods = entity.checkpoint_completed_methods - instance.checkpoint_method_outputs = entity.checkpoint_method_outputs - instance.checkpoint_method_counts = entity.checkpoint_method_counts - instance.checkpoint_state = entity.checkpoint_state - instance._restore_from_checkpoint() - return instance - raise ValueError(f"No Flow found in checkpoint: {config.restore_from}") - - @classmethod - def fork( - cls, - config: CheckpointConfig, - branch: str | None = None, - ) -> Flow: # type: ignore[type-arg] - """Fork a Flow from a checkpoint, creating a new execution branch. - - Args: - config: Checkpoint configuration with ``restore_from`` set. - branch: Branch label for the fork. Auto-generated if not provided. - - Returns: - A Flow instance on the new branch. Call kickoff() to run. - """ - flow = cls.from_checkpoint(config) - state = crewai_event_bus.runtime_state - if state is None: - raise RuntimeError( - "Cannot fork: no runtime state on the event bus. " - "Ensure from_checkpoint() succeeded before calling fork()." - ) - state.fork(branch) - new_id = str(uuid4()) - if isinstance(flow._state, dict): - flow._state["id"] = new_id - else: - object.__setattr__(flow._state, "id", new_id) - return flow - - checkpoint_completed_methods: set[str] | None = Field(default=None) - checkpoint_method_outputs: list[Any] | None = Field(default=None) - checkpoint_method_counts: dict[str, int] | None = Field(default=None) - checkpoint_state: dict[str, Any] | None = Field(default=None) - - def _restore_from_checkpoint(self) -> None: - """Restore private execution state from checkpoint fields.""" - if self.checkpoint_completed_methods is not None: - self._completed_methods = { - FlowMethodName(m) for m in self.checkpoint_completed_methods - } - if self.checkpoint_method_outputs is not None: - self._method_outputs = list(self.checkpoint_method_outputs) - if self.checkpoint_method_counts is not None: - self._method_execution_counts = { - FlowMethodName(k): v for k, v in self.checkpoint_method_counts.items() - } - if self.checkpoint_state is not None: - self._restore_state(self.checkpoint_state) - if ( - isinstance(self.memory, MemoryScope | MemorySlice) - and self.memory._memory is None - ): - self.memory.bind(Memory()) - restore_event_scope(()) - reset_last_event_id() - - _methods: dict[FlowMethodName, FlowMethod[Any, Any]] = PrivateAttr( - default_factory=dict - ) - _method_execution_counts: dict[FlowMethodName, int] = PrivateAttr( - default_factory=dict - ) - _pending_and_listeners: dict[PendingListenerKey, set[FlowMethodName]] = PrivateAttr( - default_factory=dict - ) - _fired_or_listeners: set[FlowMethodName] = PrivateAttr(default_factory=set) - _method_outputs: list[Any] = PrivateAttr(default_factory=list) - _state_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) - _or_listeners_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) - _completed_methods: set[FlowMethodName] = PrivateAttr(default_factory=set) - _method_call_counts: dict[FlowMethodName, int] = PrivateAttr(default_factory=dict) - _is_execution_resuming: bool = PrivateAttr(default=False) - _event_futures: list[Future[None]] = PrivateAttr(default_factory=list) - _pending_feedback_context: PendingFeedbackContext | None = PrivateAttr(default=None) - _human_feedback_method_outputs: dict[str, Any] = PrivateAttr(default_factory=dict) - _input_history: list[InputHistoryEntry] = PrivateAttr(default_factory=list) - _state: Any = PrivateAttr(default=None) - - def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]: # type: ignore[override] - class _FlowGeneric(cls): # type: ignore[valid-type,misc] - pass - - _FlowGeneric.__name__ = f"{cls.__name__}[{item.__name__}]" - _FlowGeneric._initial_state_t = item - return _FlowGeneric - - def __setattr__(self, name: str, value: Any) -> None: - """Allow arbitrary attribute assignment for backward compat with plain class.""" - if name in self.model_fields or name in self.__private_attributes__: - super().__setattr__(name, value) - else: - object.__setattr__(self, name, value) - - def model_post_init(self, __context: Any) -> None: - self._flow_post_init() - - def _flow_post_init(self) -> None: - """Heavy initialization: state creation, events, memory, method registration.""" - if getattr(self, "_flow_post_init_done", False): - return - object.__setattr__(self, "_flow_post_init_done", True) - - if self._state is None: - self._state = self._create_initial_state() - - tracing_enabled = should_enable_tracing(override=self.tracing) - set_tracing_enabled(tracing_enabled) - - trace_listener = TraceCollectionListener() - trace_listener.setup_listeners(crewai_event_bus) - - if not self.suppress_flow_events: - crewai_event_bus.emit( - self, - FlowCreatedEvent( - type="flow_created", - flow_name=self.name or self.__class__.__name__, - ), - ) - - # Auto-create memory if not provided at class or instance level. - # Internal flows (RecallFlow, EncodingFlow) set _skip_auto_memory - # to avoid creating a wasteful standalone Memory instance. - if self.memory is None and not getattr(self, "_skip_auto_memory", False): - from crewai.memory.utils import sanitize_scope_name - - flow_name = sanitize_scope_name(self.name or self.__class__.__name__) - self.memory = Memory(root_scope=f"/flow/{flow_name}") - - for method_name in dir(self): - if not method_name.startswith("_"): - method = getattr(self, method_name) - if is_flow_method(method): - if not hasattr(method, "__self__"): - method = method.__get__(self, self.__class__) - self._methods[method.__name__] = method - - def recall(self, query: str, **kwargs: Any) -> Any: - """Recall relevant memories. Delegates to this flow's memory. - - Args: - query: Natural language query. - **kwargs: Passed to memory.recall (e.g. scope, categories, limit, depth). - - Returns: - Result of memory.recall(query, **kwargs). - - Raises: - ValueError: If no memory is configured for this flow. - """ - if self.memory is None: - raise ValueError("No memory configured for this flow") - return self.memory.recall(query, **kwargs) - - def remember(self, content: str | list[str], **kwargs: Any) -> Any: - """Store one or more items in memory. - - Pass a single string for synchronous save (returns the MemoryRecord). - Pass a list of strings for non-blocking batch save (returns immediately). - - Args: - content: Text or list of texts to remember. - **kwargs: Passed to memory.remember / remember_many - (e.g. scope, categories, metadata, importance). - - Returns: - MemoryRecord for single item, empty list for batch (background save). - - Raises: - ValueError: If no memory is configured for this flow. - TypeError: If batch remember is attempted on a MemoryScope or MemorySlice. - """ - if self.memory is None: - raise ValueError("No memory configured for this flow") - if isinstance(content, list): - if not isinstance(self.memory, Memory): - raise TypeError( - "Batch remember requires a Memory instance, " - f"got {type(self.memory).__name__}" - ) - return self.memory.remember_many(content, **kwargs) - return self.memory.remember(content, **kwargs) - - def extract_memories(self, content: str) -> list[str]: - """Extract discrete memories from content. Delegates to this flow's memory. - - Args: - content: Raw text (e.g. task + result dump). - - Returns: - List of short, self-contained memory statements. - - Raises: - ValueError: If no memory is configured for this flow. - """ - if self.memory is None: - raise ValueError("No memory configured for this flow") - result: list[str] = self.memory.extract_memories(content) - return result - - def _mark_or_listener_fired(self, listener_name: FlowMethodName) -> bool: - """Mark an OR listener as fired atomically. - - Args: - listener_name: The name of the OR listener to mark. - - Returns: - True if this call was the first to fire the listener. - False if the listener was already fired. - """ - with self._or_listeners_lock: - if listener_name in self._fired_or_listeners: - return False - self._fired_or_listeners.add(listener_name) - return True - - def _clear_or_listeners(self) -> None: - """Clear fired OR listeners for cyclic flows.""" - with self._or_listeners_lock: - self._fired_or_listeners.clear() - - def _discard_or_listener(self, listener_name: FlowMethodName) -> None: - """Discard a single OR listener from the fired set.""" - with self._or_listeners_lock: - self._fired_or_listeners.discard(listener_name) - - def _build_racing_groups(self) -> dict[frozenset[FlowMethodName], FlowMethodName]: - """Identify groups of methods that race for the same OR listener. - - Analyzes the flow graph to find listeners with OR conditions that have - multiple trigger methods. These trigger methods form a "racing group" - where only the first to complete should trigger the OR listener. - - Only methods that are EXCLUSIVELY sources for the OR listener are included - in the racing group. Methods that are also triggers for other listeners - (e.g., AND conditions) are not cancelled when another racing source wins. - - Returns: - Dictionary mapping frozensets of racing method names to their - shared OR listener name. - - Example: - If we have `@listen(or_(method_a, method_b))` on `handler`, - and method_a/method_b aren't used elsewhere, - this returns: {frozenset({'method_a', 'method_b'}): 'handler'} - """ - racing_groups: dict[frozenset[FlowMethodName], FlowMethodName] = {} - - method_to_listeners: dict[FlowMethodName, set[FlowMethodName]] = {} - for listener_name, condition_data in self._listeners.items(): - if is_simple_flow_condition(condition_data): - _, methods = condition_data - for m in methods: - method_to_listeners.setdefault(m, set()).add(listener_name) - elif is_flow_condition_dict(condition_data): - all_methods = _extract_all_methods_recursive(condition_data) - for m in all_methods: - method_name = FlowMethodName(m) if isinstance(m, str) else m - method_to_listeners.setdefault(method_name, set()).add( - listener_name - ) - - for listener_name, condition_data in self._listeners.items(): - if listener_name in self._routers: - continue - - trigger_methods: set[FlowMethodName] = set() - - if is_simple_flow_condition(condition_data): - condition_type, methods = condition_data - if condition_type == OR_CONDITION and len(methods) > 1: - trigger_methods = set(methods) - - elif is_flow_condition_dict(condition_data): - top_level_type = condition_data.get("type", OR_CONDITION) - if top_level_type == OR_CONDITION: - all_methods = _extract_all_methods_recursive(condition_data) - if len(all_methods) > 1: - trigger_methods = set( - FlowMethodName(m) if isinstance(m, str) else m - for m in all_methods - ) - - if trigger_methods: - exclusive_methods = { - m - for m in trigger_methods - if method_to_listeners.get(m, set()) == {listener_name} - } - if len(exclusive_methods) > 1: - racing_groups[frozenset(exclusive_methods)] = listener_name - - return racing_groups - - def _get_racing_group_for_listeners( - self, - listener_names: list[FlowMethodName], - ) -> tuple[frozenset[FlowMethodName], FlowMethodName] | None: - """Check if the given listeners form a racing group. - - Args: - listener_names: List of listener method names being executed. - - Returns: - Tuple of (racing_members, or_listener_name) if these listeners race, - None otherwise. - """ - if not hasattr(self, "_racing_groups_cache"): - self._racing_groups_cache = self._build_racing_groups() - - listener_set = set(listener_names) - - for racing_members, or_listener in self._racing_groups_cache.items(): - if racing_members & listener_set: - racing_subset = racing_members & listener_set - if len(racing_subset) > 1: - return (frozenset(racing_subset), or_listener) - - return None - - async def _execute_racing_listeners( - self, - racing_listeners: frozenset[FlowMethodName], - other_listeners: list[FlowMethodName], - result: Any, - triggering_event_id: str | None = None, - ) -> None: - """Execute racing listeners with first-wins semantics. - - Racing listeners are executed in parallel, but once the first one - completes, the others are cancelled. Non-racing listeners in the - same batch are executed normally in parallel. - - Args: - racing_listeners: Set of listener names that race for an OR condition. - other_listeners: Other listeners to execute in parallel (not racing). - result: The result from the triggering method. - triggering_event_id: The event_id of the event that triggered these listeners. - """ - racing_tasks = [ - asyncio.create_task( - self._execute_single_listener(name, result, triggering_event_id), - name=str(name), - ) - for name in racing_listeners - ] - - other_tasks = [ - asyncio.create_task( - self._execute_single_listener(name, result, triggering_event_id), - name=str(name), - ) - for name in other_listeners - ] - - if racing_tasks: - for coro in asyncio.as_completed(racing_tasks): - try: - await coro - except Exception as e: - logger.debug(f"Racing listener failed: {e}") - continue - break - - for task in racing_tasks: - if not task.done(): - task.cancel() - - if other_tasks: - await asyncio.gather(*other_tasks, return_exceptions=True) - - @classmethod - def from_pending( - cls, - flow_id: str, - persistence: FlowPersistence | None = None, - **kwargs: Any, - ) -> Flow[Any]: - """Create a Flow instance from a pending feedback state. - - This classmethod is used to restore a flow that was paused waiting - for async human feedback. It loads the persisted state and pending - feedback context, then returns a flow instance ready to resume. - - Args: - flow_id: The unique identifier of the paused flow (from state.id) - persistence: The persistence backend where the state was saved. - If not provided, defaults to SQLiteFlowPersistence(). - **kwargs: Additional keyword arguments passed to the Flow constructor - - Returns: - A new Flow instance with restored state, ready to call resume() - - Raises: - ValueError: If no pending feedback exists for the given flow_id - - Example: - ```python - # Simple usage with default persistence: - flow = MyFlow.from_pending("abc-123") - result = flow.resume("looks good!") - - # Or with custom persistence: - persistence = SQLiteFlowPersistence("custom.db") - flow = MyFlow.from_pending("abc-123", persistence) - result = flow.resume("looks good!") - ``` - """ - if persistence is None: - from crewai.flow.persistence import SQLiteFlowPersistence - - persistence = SQLiteFlowPersistence() - - loaded = persistence.load_pending_feedback(flow_id) - if loaded is None: - raise ValueError(f"No pending feedback found for flow_id: {flow_id}") - - state_data, pending_context = loaded - - instance = cls(persistence=persistence, **kwargs) - instance._initialize_state(state_data) - instance._pending_feedback_context = pending_context - instance._is_execution_resuming = True - - return instance - - @property - def pending_feedback(self) -> PendingFeedbackContext | None: - """Get the pending feedback context if this flow is waiting for feedback. - - Returns: - The PendingFeedbackContext if the flow is paused waiting for feedback, - None otherwise. - - Example: - ```python - flow = MyFlow.from_pending("abc-123", persistence) - if flow.pending_feedback: - print(f"Waiting for feedback on: {flow.pending_feedback.method_name}") - ``` - """ - return self._pending_feedback_context - - def resume(self, feedback: str = "") -> Any: - """Resume flow execution, optionally with human feedback. - - This method continues flow execution after a flow was paused for - async human feedback. It processes the feedback (including LLM-based - outcome collapsing if emit was specified), stores the result, and - triggers downstream listeners. - - Note: - If called from within an async context (running event loop), - use `await flow.resume_async(feedback)` instead. - - Args: - feedback: The human's feedback as a string. If empty, uses - default_outcome or the first emit option. - - Returns: - The final output from the flow execution, or HumanFeedbackPending - if another feedback point is reached. - - Raises: - ValueError: If no pending feedback context exists (flow wasn't paused) - RuntimeError: If called from within a running event loop (use resume_async instead) - - Example: - ```python - # In a sync webhook handler: - def handle_feedback(flow_id: str, feedback: str): - flow = MyFlow.from_pending(flow_id) - result = flow.resume(feedback) - return result - - - # In an async handler, use resume_async instead: - async def handle_feedback_async(flow_id: str, feedback: str): - flow = MyFlow.from_pending(flow_id) - result = await flow.resume_async(feedback) - return result - ``` - """ - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if loop is not None: - raise RuntimeError( - "resume() cannot be called from within an async context. " - "Use 'await flow.resume_async(feedback)' instead." - ) - - return asyncio.run(self.resume_async(feedback)) - - async def resume_async(self, feedback: str = "") -> Any: - """Async version of resume. - - Resume flow execution, optionally with human feedback asynchronously. - - Args: - feedback: The human's feedback as a string. If empty, uses - default_outcome or the first emit option. - - Returns: - The final output from the flow execution, or HumanFeedbackPending - if another feedback point is reached. - - Raises: - ValueError: If no pending feedback context exists - """ - from datetime import datetime - - from crewai.flow.human_feedback import HumanFeedbackResult - - if self._pending_feedback_context is None: - raise ValueError( - "No pending feedback context. Use from_pending() to restore a paused flow." - ) - - if get_current_parent_id() is None: - reset_emission_counter() - reset_last_event_id() - - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - FlowStartedEvent( - type="flow_started", - flow_name=self.name or self.__class__.__name__, - inputs=None, - ), - ) - if future and isinstance(future, Future): - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning("FlowStartedEvent handler failed", exc_info=True) - - get_env_context() - - context = self._pending_feedback_context - emit = context.emit - default_outcome = context.default_outcome - - # Try to get the live LLM from the re-imported decorator first. - # This preserves the fully-configured object (credentials, safety_settings, etc.) - # for same-process resume. For cross-process resume, fall back to the - # serialized context.llm which is now a dict with full config (or a legacy string). - from crewai.flow.human_feedback import _deserialize_llm_from_context - - llm = None - method = self._methods.get(FlowMethodName(context.method_name)) - if method is not None: - live_llm = getattr(method, "_hf_llm", None) - if live_llm is not None: - from crewai.llms.base_llm import BaseLLM as BaseLLMClass - - if isinstance(live_llm, BaseLLMClass): - llm = live_llm - - if llm is None: - llm = _deserialize_llm_from_context(context.llm) - - collapsed_outcome: str | None = None - - if not feedback.strip(): - if default_outcome: - collapsed_outcome = default_outcome - elif emit: - collapsed_outcome = emit[0] - elif emit: - if llm is not None: - collapsed_outcome = self._collapse_to_outcome( - feedback=feedback, - outcomes=emit, - llm=llm, - ) - else: - collapsed_outcome = emit[0] - - result = HumanFeedbackResult( - output=context.method_output, - feedback=feedback, - outcome=collapsed_outcome, - timestamp=datetime.now(), - method_name=context.method_name, - metadata=context.metadata, - ) - - self.human_feedback_history.append(result) - self.last_human_feedback = result - - self._completed_methods.add(FlowMethodName(context.method_name)) - - self._pending_feedback_context = None - - if self.persistence: - self.persistence.clear_pending_feedback(context.flow_id) - - crewai_event_bus.emit( - self, - MethodExecutionFinishedEvent( - type="method_execution_finished", - flow_name=self.name or self.__class__.__name__, - method_name=context.method_name, - result=collapsed_outcome if emit else result, - state=self._state, - ), - ) - - # Clear resumption flag before triggering listeners - # This allows methods to re-execute in loops (e.g., implement_changes → suggest_changes → implement_changes) - self._is_execution_resuming = False - - if emit and collapsed_outcome is None: - collapsed_outcome = default_outcome or emit[0] - result.outcome = collapsed_outcome - - try: - if emit and collapsed_outcome: - self._method_outputs.append(collapsed_outcome) - await self._execute_listeners( - FlowMethodName(collapsed_outcome), - result, - ) - else: - await self._execute_listeners( - FlowMethodName(context.method_name), - result, - ) - except Exception as e: - # Check if flow was paused again for human feedback (loop case) - from crewai.flow.async_feedback.types import HumanFeedbackPending - - if isinstance(e, HumanFeedbackPending): - self._pending_feedback_context = e.context - - if self.persistence is None: - from crewai.flow.persistence import SQLiteFlowPersistence - - self.persistence = SQLiteFlowPersistence() - - state_data = ( - self._state - if isinstance(self._state, dict) - else self._state.model_dump() - ) - self.persistence.save_pending_feedback( - flow_uuid=e.context.flow_id, - context=e.context, - state_data=state_data, - ) - - crewai_event_bus.emit( - self, - FlowPausedEvent( - type="flow_paused", - flow_name=self.name or self.__class__.__name__, - flow_id=e.context.flow_id, - method_name=e.context.method_name, - state=self._copy_and_serialize_state(), - message=e.context.message, - emit=e.context.emit, - ), - ) - return e - raise - - final_result = self._method_outputs[-1] if self._method_outputs else result - - if self._event_futures: - await asyncio.gather( - *[ - asyncio.wrap_future(f) - for f in self._event_futures - if isinstance(f, Future) - ] - ) - self._event_futures.clear() - - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - FlowFinishedEvent( - type="flow_finished", - flow_name=self.name or self.__class__.__name__, - result=final_result, - state=self._copy_and_serialize_state(), - ), - ) - if future and isinstance(future, Future): - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning("FlowFinishedEvent handler failed", exc_info=True) - - trace_listener = TraceCollectionListener() - if trace_listener.batch_manager.batch_owner_type == "flow": - if trace_listener.first_time_handler.is_first_time: - trace_listener.first_time_handler.mark_events_collected() - trace_listener.first_time_handler.handle_execution_completion() - else: - trace_listener.batch_manager.finalize_batch() - - return final_result - - def _create_initial_state(self) -> T: - """Create and initialize flow state with UUID and default values. - - Returns: - New state instance with UUID and default values initialized - - Raises: - ValueError: If structured state model lacks 'id' field - TypeError: If state is neither BaseModel nor dictionary - """ - init_state = self.initial_state - - if init_state is None and hasattr(self, "_initial_state_t"): - state_type = self._initial_state_t - if isinstance(state_type, type): - if issubclass(state_type, FlowState): - instance = state_type() - if not getattr(instance, "id", None): - object.__setattr__(instance, "id", str(uuid4())) - return cast(T, instance) - if issubclass(state_type, BaseModel): - - class StateWithId(FlowState, state_type): # type: ignore - pass - - instance = StateWithId() - if not getattr(instance, "id", None): - object.__setattr__(instance, "id", str(uuid4())) - return cast(T, instance) - if state_type is dict: - return cast(T, {"id": str(uuid4())}) - - if init_state is None: - return cast(T, {"id": str(uuid4())}) - - if isinstance(init_state, type): - state_class = init_state - if issubclass(state_class, FlowState): - return cast(T, state_class()) - if issubclass(state_class, BaseModel): - model_fields = getattr(state_class, "model_fields", None) - if not model_fields or "id" not in model_fields: - raise ValueError("Flow state model must have an 'id' field") - model_instance = state_class() - if not getattr(model_instance, "id", None): - object.__setattr__(model_instance, "id", str(uuid4())) - return cast(T, model_instance) - if init_state is dict: - return cast(T, {"id": str(uuid4())}) - - if isinstance(init_state, dict): - new_state = dict(init_state) # Copy to avoid mutations - if "id" not in new_state: - new_state["id"] = str(uuid4()) - return cast(T, new_state) - - if isinstance(init_state, BaseModel): - model = init_state - if hasattr(model, "id"): - state_dict = model.model_dump() - if not state_dict.get("id"): - state_dict["id"] = str(uuid4()) - model_class = type(model) - return cast(T, model_class(**state_dict)) - - class StateWithId(FlowState, type(model)): # type: ignore - pass - - state_dict = model.model_dump() - state_dict["id"] = str(uuid4()) - return cast(T, StateWithId(**state_dict)) - raise TypeError( - f"Initial state must be dict or BaseModel, got {type(self.initial_state)}" - ) - - def _copy_state(self) -> T: - """Create a copy of the current state. - - Returns: - A copy of the current state - """ - if isinstance(self._state, BaseModel): - try: - return cast(T, self._state.model_copy(deep=True)) - except (TypeError, AttributeError): - try: - state_dict = self._state.model_dump() - model_class = type(self._state) - return cast(T, model_class(**state_dict)) - except Exception: - return cast(T, self._state.model_copy(deep=False)) - else: - try: - return cast(T, copy.deepcopy(self._state)) - except (TypeError, AttributeError): - return cast(T, self._state.copy()) - - @property - def state(self) -> T: - return StateProxy(self._state, self._state_lock) # type: ignore[return-value] - - @property - def method_outputs(self) -> list[Any]: - """Returns the list of all outputs from executed methods.""" - return self._method_outputs - - @property - def flow_id(self) -> str: - """Returns the unique identifier of this flow instance. - - This property provides a consistent way to access the flow's unique identifier - regardless of the underlying state implementation (dict or BaseModel). - - Returns: - str: The flow's unique identifier, or an empty string if not found - - Note: - This property safely handles both dictionary and BaseModel state types, - returning an empty string if the ID cannot be retrieved rather than raising - an exception. - - Example: - ```python - flow = MyFlow() - print(f"Current flow ID: {flow.flow_id}") # Safely get flow ID - ``` - """ - try: - if not hasattr(self, "_state"): - return "" - - if isinstance(self._state, dict): - return str(self._state.get("id", "")) - if isinstance(self._state, BaseModel): - return str(getattr(self._state, "id", "")) - return "" - except (AttributeError, TypeError): - return "" # Safely handle any unexpected attribute access issues - - def _initialize_state(self, inputs: dict[str, Any]) -> None: - """Initialize or update flow state with new inputs. - - Args: - inputs: Dictionary of state values to set/update - - Raises: - ValueError: If validation fails for structured state - TypeError: If state is neither BaseModel nor dictionary - """ - if isinstance(self._state, dict): - # If inputs contains an id, use it (for restoring from persistence); - # otherwise preserve the current id or generate a new one. - current_id = self._state.get("id") - inputs_has_id = "id" in inputs - - for k, v in inputs.items(): - self._state[k] = v - - if not inputs_has_id: - if current_id: - self._state["id"] = current_id - elif "id" not in self._state: - self._state["id"] = str(uuid4()) - elif isinstance(self._state, BaseModel): - try: - model = self._state - if hasattr(model, "model_dump"): - current_state = model.model_dump() - elif hasattr(model, "dict"): - current_state = model.dict() - else: - current_state = { - k: v for k, v in model.__dict__.items() if not k.startswith("_") - } - - new_state = {**current_state, **inputs} - - model_class = type(model) - if hasattr(model_class, "model_validate"): - self._state = cast(T, model_class.model_validate(new_state)) - elif hasattr(model_class, "parse_obj"): - self._state = cast(T, model_class.parse_obj(new_state)) - else: - self._state = cast(T, model_class(**new_state)) - except ValidationError as e: - raise ValueError(f"Invalid inputs for structured state: {e}") from e - else: - raise TypeError("State must be a BaseModel instance or a dictionary.") - - def _restore_state(self, stored_state: dict[str, Any]) -> None: - """Restore flow state from persistence. - - Args: - stored_state: Previously stored state to restore - - Raises: - ValueError: If validation fails for structured state - TypeError: If state is neither BaseModel nor dictionary - """ - stored_id = stored_state.get("id") - if not stored_id: - raise ValueError("Stored state must have an 'id' field") - - if isinstance(self._state, dict): - self._state.clear() - self._state.update(stored_state) - elif isinstance(self._state, BaseModel): - model = self._state - if hasattr(model, "model_validate"): - self._state = cast(T, type(model).model_validate(stored_state)) - elif hasattr(model, "parse_obj"): - self._state = cast(T, type(model).parse_obj(stored_state)) - else: - self._state = cast(T, type(model)(**stored_state)) - else: - raise TypeError(f"State must be dict or BaseModel, got {type(self._state)}") - - def reload(self, execution_data: FlowExecutionData) -> None: - """Reloads the flow from an execution data dict. - - This method restores the flow's execution ID, completed methods, and state, - allowing it to resume from where it left off. - - Args: - execution_data: Flow execution data containing: - - id: Flow execution ID - - flow: Flow structure - - completed_methods: list of successfully completed methods - - execution_methods: All execution methods with their status - """ - flow_id = execution_data.get("id") - if flow_id: - self._update_state_field("id", flow_id) - - self._completed_methods = { - cast(FlowMethodName, name) - for method_data in execution_data.get("completed_methods", []) - if (name := method_data.get("flow_method", {}).get("name")) is not None - } - - execution_methods = execution_data.get("execution_methods", []) - if not execution_methods: - return - - sorted_methods = sorted( - execution_methods, - key=lambda m: m.get("started_at", ""), - ) - - state_to_apply = None - for method in reversed(sorted_methods): - if method.get("final_state"): - state_to_apply = method["final_state"] - break - - if not state_to_apply and sorted_methods: - last_method = sorted_methods[-1] - if last_method.get("initial_state"): - state_to_apply = last_method["initial_state"] - - if state_to_apply: - self._apply_state_updates(state_to_apply) - - for method in sorted_methods[:-1]: - method_name = cast( - FlowMethodName | None, method.get("flow_method", {}).get("name") - ) - if method_name: - self._completed_methods.add(method_name) - - def _update_state_field(self, field_name: str, value: Any) -> None: - """Update a single field in the state.""" - if isinstance(self._state, dict): - self._state[field_name] = value - elif hasattr(self._state, field_name): - object.__setattr__(self._state, field_name, value) - - def _apply_state_updates(self, updates: dict[str, Any]) -> None: - """Apply multiple state updates efficiently.""" - if isinstance(self._state, dict): - self._state.update(updates) - elif hasattr(self._state, "__dict__"): - for key, value in updates.items(): - if hasattr(self._state, key): - object.__setattr__(self._state, key, value) - - def kickoff( - self, - inputs: dict[str, Any] | None = None, - input_files: dict[str, FileInput] | None = None, - from_checkpoint: CheckpointConfig | None = None, - restore_from_state_id: str | None = None, - ) -> Any | FlowStreamingOutput: - """Start the flow execution in a synchronous context. - - This method wraps kickoff_async so that all state initialization and event - emission is handled in the asynchronous method. - - Args: - inputs: Optional dictionary containing input values and/or a state ID. - input_files: Optional dict of named file inputs for the flow. - from_checkpoint: Optional checkpoint config. If ``restore_from`` - is set, the flow resumes from that checkpoint. - restore_from_state_id: Optional UUID of a previously-persisted flow - whose latest snapshot should hydrate this run's state. The new - run is assigned a fresh ``state.id`` (or ``inputs["id"]`` if - pinned), so its ``@persist`` writes land under a separate - persistence key and the source flow's history is preserved. - If the referenced state is not found, the kickoff falls back - silently to baseline behavior. Cannot be combined with - ``from_checkpoint``; passing both raises ``ValueError``. - - Returns: - The final output from the flow or FlowStreamingOutput if streaming. - """ - if from_checkpoint is not None and restore_from_state_id is not None: - raise ValueError( - "Cannot combine `from_checkpoint` and `restore_from_state_id`. " - "These parameters target different state systems " - "(Checkpointing and @persist) and cannot be used together." - ) - restored = apply_checkpoint(self, from_checkpoint) - if restored is not None: - return restored.kickoff(inputs=inputs, input_files=input_files) - if self.stream: - result_holder: list[Any] = [] - current_task_info: TaskInfo = { - "index": 0, - "name": "", - "id": "", - "agent_role": "", - "agent_id": "", - } - - state = create_streaming_state( - current_task_info, result_holder, use_async=False - ) - output_holder: list[CrewStreamingOutput | FlowStreamingOutput] = [] - - def run_flow() -> None: - try: - self.stream = False - result = self.kickoff( - inputs=inputs, - input_files=input_files, - restore_from_state_id=restore_from_state_id, - ) - result_holder.append(result) - except Exception as e: - # HumanFeedbackPending is expected control flow, not an error - from crewai.flow.async_feedback.types import HumanFeedbackPending - - if isinstance(e, HumanFeedbackPending): - result_holder.append(e) - else: - signal_error(state, e) - finally: - self.stream = True - signal_end(state) - - streaming_output = FlowStreamingOutput( - sync_iterator=create_chunk_generator(state, run_flow, output_holder) - ) - register_cleanup(streaming_output, state) - output_holder.append(streaming_output) - - return streaming_output - - async def _run_flow() -> Any: - return await self.kickoff_async( - inputs, - input_files, - restore_from_state_id=restore_from_state_id, - ) - - try: - asyncio.get_running_loop() - ctx = contextvars.copy_context() - with ThreadPoolExecutor(max_workers=1) as pool: - return pool.submit(ctx.run, asyncio.run, _run_flow()).result() - except RuntimeError: - return asyncio.run(_run_flow()) - - async def kickoff_async( - self, - inputs: dict[str, Any] | None = None, - input_files: dict[str, FileInput] | None = None, - from_checkpoint: CheckpointConfig | None = None, - restore_from_state_id: str | None = None, - ) -> Any | FlowStreamingOutput: - """Start the flow execution asynchronously. - - This method performs state restoration (if an 'id' is provided and persistence is available) - and updates the flow state with any additional inputs. It then emits the FlowStartedEvent, - logs the flow startup, and executes all start methods. Once completed, it emits the - FlowFinishedEvent and returns the final output. - - Args: - inputs: Optional dictionary containing input values and/or a state ID for restoration. - input_files: Optional dict of named file inputs for the flow. - from_checkpoint: Optional checkpoint config. If ``restore_from`` - is set, the flow resumes from that checkpoint. - restore_from_state_id: Optional UUID of a previously-persisted flow - whose latest snapshot should hydrate this run's state. The new - run is assigned a fresh ``state.id`` (or ``inputs["id"]`` if - pinned), so subsequent ``@persist`` writes land under a - separate persistence key. If the referenced state is not - found, falls back silently to baseline. Cannot be combined - with ``from_checkpoint``; passing both raises ``ValueError``. - - Returns: - The final output from the flow, which is the result of the last executed method. - """ - if from_checkpoint is not None and restore_from_state_id is not None: - raise ValueError( - "Cannot combine `from_checkpoint` and `restore_from_state_id`. " - "These parameters target different state systems " - "(Checkpointing and @persist) and cannot be used together." - ) - restored = apply_checkpoint(self, from_checkpoint) - if restored is not None: - return await restored.kickoff_async(inputs=inputs, input_files=input_files) - if self.stream: - result_holder: list[Any] = [] - current_task_info: TaskInfo = { - "index": 0, - "name": "", - "id": "", - "agent_role": "", - "agent_id": "", - } - - state = create_streaming_state( - current_task_info, result_holder, use_async=True - ) - output_holder: list[CrewStreamingOutput | FlowStreamingOutput] = [] - - async def run_flow() -> None: - try: - self.stream = False - result = await self.kickoff_async( - inputs=inputs, - input_files=input_files, - restore_from_state_id=restore_from_state_id, - ) - result_holder.append(result) - except Exception as e: - # HumanFeedbackPending is expected control flow, not an error - from crewai.flow.async_feedback.types import HumanFeedbackPending - - if isinstance(e, HumanFeedbackPending): - result_holder.append(e) - else: - signal_error(state, e, is_async=True) - finally: - self.stream = True - signal_end(state, is_async=True) - - streaming_output = FlowStreamingOutput( - async_iterator=create_async_chunk_generator( - state, run_flow, output_holder - ) - ) - register_cleanup(streaming_output, state) - output_holder.append(streaming_output) - - return streaming_output - - ctx = baggage.set_baggage("flow_inputs", inputs or {}) - 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 - ) or self.checkpoint_completed_methods is not None - if not is_restoring: - # Clear completed methods and outputs for a fresh start - self._completed_methods.clear() - self._method_outputs.clear() - self._pending_and_listeners.clear() - self._clear_or_listeners() - self._method_call_counts.clear() - else: - # Only enter resumption mode if there are completed methods to - # replay. When _completed_methods is empty (e.g. a pure - # state-reload via kickoff(inputs={"id": ...})), the flow - # executes from scratch and the flag would incorrectly - # suppress cyclic re-execution on the second iteration. - if self._completed_methods: - self._is_execution_resuming = True - - # Fork hydration: when restore_from_state_id is set and persistence is - # available, hydrate self._state from the source UUID's latest snapshot - # and reassign state.id to a fresh value so subsequent @persist writes - # don't extend the source flow's history. If the source state is not - # found, fall through silently to the existing inputs handling. - fork_succeeded = False - if restore_from_state_id is not None and self.persistence is not None: - stored_state = self.persistence.load_state(restore_from_state_id) - if stored_state: - self._log_flow_event( - f"Forking flow state from UUID: {restore_from_state_id}" - ) - self._restore_state(stored_state) - # Pin to inputs["id"] when provided, otherwise mint a fresh - # UUID. NOTE: pinning inputs.id while forking shares a - # persistence key with another flow — usually you want only - # restore_from_state_id. - new_state_id = (inputs.get("id") if inputs else None) or str( - uuid4() - ) - if isinstance(self._state, dict): - self._state["id"] = new_state_id - elif isinstance(self._state, BaseModel): - setattr(self._state, "id", new_state_id) # noqa: B010 - fork_succeeded = True - else: - self._log_flow_event( - "No flow state found for restore_from_state_id: " - f"{restore_from_state_id}; proceeding without hydration", - color="yellow", - ) - - if inputs: - # Override the id in the state if it exists in inputs. - # Skip when the fork already assigned state.id above. - if "id" in inputs and not fork_succeeded: - if isinstance(self._state, dict): - self._state["id"] = inputs["id"] - elif isinstance(self._state, BaseModel): - setattr(self._state, "id", inputs["id"]) # noqa: B010 - - # If persistence is enabled, attempt to restore the stored state using the provided id. - # Skip when the fork already restored self._state above. - if ( - "id" in inputs - and self.persistence is not None - and not fork_succeeded - ): - restore_uuid = inputs["id"] - stored_state = self.persistence.load_state(restore_uuid) - if stored_state: - self._log_flow_event( - f"Loading flow state from memory for UUID: {restore_uuid}" - ) - self._restore_state(stored_state) - else: - self._log_flow_event( - f"No flow state found for UUID: {restore_uuid}", color="red" - ) - - # Update state with any additional inputs (ignoring the 'id' key) - filtered_inputs = {k: v for k, v in inputs.items() if k != "id"} - if filtered_inputs: - self._initialize_state(filtered_inputs) - - if get_current_parent_id() is None: - reset_emission_counter() - reset_last_event_id() - - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - FlowStartedEvent( - type="flow_started", - flow_name=self.name or self.__class__.__name__, - inputs=inputs, - ), - ) - if future: - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning("FlowStartedEvent handler failed", exc_info=True) - self._log_flow_event( - f"Flow started with ID: {self.flow_id}", color="bold magenta" - ) - - # After FlowStarted (when not suppressed): env events must not pre-empt - # trace batch init with implicit "crew" execution_type. - get_env_context() - - if inputs is not None and "id" not in inputs: - self._initialize_state(inputs) - - if self._is_execution_resuming: - await self._replay_recorded_events() - - try: - # Determine which start methods to execute at kickoff - # Conditional start methods (with __trigger_methods__) are only triggered by their conditions - # UNLESS there are no unconditional starts (then all starts run as entry points) - unconditional_starts = [ - start_method - for start_method in self._start_methods - if not getattr( - self._methods.get(start_method), "__trigger_methods__", None - ) - ] - # If there are unconditional starts, only run those at kickoff - # If there are NO unconditional starts, run all starts (including conditional ones) - starts_to_execute = ( - unconditional_starts - if unconditional_starts - else self._start_methods - ) - tasks = [ - self._execute_start_method(start_method) - for start_method in starts_to_execute - ] - await asyncio.gather(*tasks) - except Exception as e: - # Check if flow was paused for human feedback - from crewai.flow.async_feedback.types import HumanFeedbackPending - - if isinstance(e, HumanFeedbackPending): - # Auto-save pending feedback (create default persistence if needed) - if self.persistence is None: - from crewai.flow.persistence import SQLiteFlowPersistence - - self.persistence = SQLiteFlowPersistence() - - state_data = ( - self._state - if isinstance(self._state, dict) - else self._state.model_dump() - ) - self.persistence.save_pending_feedback( - flow_uuid=e.context.flow_id, - context=e.context, - state_data=state_data, - ) - - # Emit flow paused event - future = crewai_event_bus.emit( - self, - FlowPausedEvent( - type="flow_paused", - flow_name=self.name or self.__class__.__name__, - flow_id=e.context.flow_id, - method_name=e.context.method_name, - state=self._copy_and_serialize_state(), - message=e.context.message, - emit=e.context.emit, - ), - ) - if future and isinstance(future, Future): - self._event_futures.append(future) - - # Wait for events to be processed - if self._event_futures: - await asyncio.gather( - *[ - asyncio.wrap_future(f) - for f in self._event_futures - if isinstance(f, Future) - ] - ) - self._event_futures.clear() - - # Return the pending exception instead of raising - # This allows the caller to handle the paused state gracefully - return e - - # Re-raise other exceptions - raise - - # Clear the resumption flag after initial execution completes - self._is_execution_resuming = False - - final_output = self._method_outputs[-1] if self._method_outputs else None - - if self._event_futures: - await asyncio.gather( - *[asyncio.wrap_future(f) for f in self._event_futures] - ) - self._event_futures.clear() - - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - FlowFinishedEvent( - type="flow_finished", - flow_name=self.name or self.__class__.__name__, - result=final_output, - state=self._copy_and_serialize_state(), - ), - ) - if future: - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning( - "FlowFinishedEvent handler failed", exc_info=True - ) - - if not self.suppress_flow_events: - trace_listener = TraceCollectionListener() - if trace_listener.batch_manager.batch_owner_type == "flow": - if trace_listener.first_time_handler.is_first_time: - trace_listener.first_time_handler.mark_events_collected() - trace_listener.first_time_handler.handle_execution_completion() - else: - trace_listener.batch_manager.finalize_batch() - - return final_output - finally: - # Ensure all background memory saves complete before returning - if self.memory is not None and hasattr(self.memory, "drain_writes"): - self.memory.drain_writes() - 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( - self, - inputs: dict[str, Any] | None = None, - input_files: dict[str, FileInput] | None = None, - from_checkpoint: CheckpointConfig | None = None, - restore_from_state_id: str | None = None, - ) -> Any | FlowStreamingOutput: - """Native async method to start the flow execution. Alias for kickoff_async. - - Args: - inputs: Optional dictionary containing input values and/or a state ID for restoration. - input_files: Optional dict of named file inputs for the flow. - from_checkpoint: Optional checkpoint config. If ``restore_from`` - is set, the flow resumes from that checkpoint. - restore_from_state_id: Optional UUID of a previously-persisted flow - whose latest snapshot should hydrate this run's state. See - ``kickoff_async`` for full semantics. - - Returns: - The final output from the flow, which is the result of the last executed method. - """ - return await self.kickoff_async( - inputs, - input_files, - from_checkpoint, - restore_from_state_id=restore_from_state_id, - ) - - async def _replay_recorded_events(self) -> None: - """Dispatch recorded ``MethodExecution*`` events from the event record.""" - state = crewai_event_bus.runtime_state - if state is None: - return - record = state.event_record - if len(record) == 0: - return - - replayable = ( - MethodExecutionStartedEvent, - MethodExecutionFinishedEvent, - MethodExecutionFailedEvent, - ) - flow_name = self.name or self.__class__.__name__ - nodes = sorted( - ( - n - for n in record.all_nodes() - if isinstance(n.event, replayable) - and n.event.flow_name == flow_name - and n.event.method_name in self._completed_methods - ), - key=lambda n: n.event.emission_sequence or 0, - ) - - for node in nodes: - future = crewai_event_bus.replay(self, node.event) - if future is not None: - try: - await asyncio.wrap_future(future) - except Exception: - logger.warning( - "Replayed event handler failed: %s", - node.event.type, - exc_info=True, - ) - - async def _execute_start_method(self, start_method_name: FlowMethodName) -> None: - """Executes a flow's start method and its triggered listeners. - - This internal method handles the execution of methods marked with @start - decorator and manages the subsequent chain of listener executions. - - Args: - start_method_name: The name of the start method to execute. - - Note: - - Executes the start method and captures its result - - Triggers execution of any listeners waiting on this start method - - Part of the flow's initialization sequence - - Skips execution if method was already completed (e.g., after reload) - - Automatically injects crewai_trigger_payload if available in flow inputs - """ - if start_method_name in self._completed_methods: - if self._is_execution_resuming: - # During resumption, skip execution but continue listeners - last_output = self._method_outputs[-1] if self._method_outputs else None - await self._execute_listeners(start_method_name, last_output) - return - # For cyclic flows, clear from completed to allow re-execution - self._completed_methods.discard(start_method_name) - # Also clear fired OR listeners to allow them to fire again in new cycle - self._clear_or_listeners() - - method = self._methods[start_method_name] - enhanced_method = self._inject_trigger_payload_for_start_method(method) - - result, finished_event_id = await self._execute_method( - start_method_name, enhanced_method - ) - - # If start method is a router, use its result as an additional trigger - if start_method_name in self._routers and result is not None: - # Execute listeners for the start method name first - await self._execute_listeners(start_method_name, result, finished_event_id) - # Then execute listeners for the router result (e.g., "approved") - router_result_trigger = FlowMethodName(str(result)) - listener_result = ( - self.last_human_feedback - if self.last_human_feedback is not None - else result - ) - await self._execute_listeners( - router_result_trigger, listener_result, finished_event_id - ) - else: - await self._execute_listeners(start_method_name, result, finished_event_id) - - def _inject_trigger_payload_for_start_method( - self, original_method: Callable[..., Any] - ) -> Callable[..., Any]: - def prepare_kwargs( - *args: Any, **kwargs: Any - ) -> tuple[tuple[Any, ...], dict[str, Any]]: - inputs = cast(dict[str, Any], baggage.get_baggage("flow_inputs") or {}) - trigger_payload = inputs.get("crewai_trigger_payload") - - sig = inspect.signature(original_method) - accepts_trigger_payload = "crewai_trigger_payload" in sig.parameters - - if trigger_payload is not None and accepts_trigger_payload: - kwargs["crewai_trigger_payload"] = trigger_payload - elif trigger_payload is not None: - self._log_flow_event( - f"Trigger payload available but {original_method.__name__} doesn't accept crewai_trigger_payload parameter" - ) - return args, kwargs - - if asyncio.iscoroutinefunction(original_method): - - async def enhanced_method(*args: Any, **kwargs: Any) -> Any: - args, kwargs = prepare_kwargs(*args, **kwargs) - return await original_method(*args, **kwargs) - else: - - def enhanced_method(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] - args, kwargs = prepare_kwargs(*args, **kwargs) - return original_method(*args, **kwargs) - - enhanced_method.__name__ = original_method.__name__ - enhanced_method.__doc__ = original_method.__doc__ - - return enhanced_method - - async def _execute_method( - self, - method_name: FlowMethodName, - method: Callable[..., Any], - *args: Any, - **kwargs: Any, - ) -> tuple[Any, str | None]: - """Execute a method and emit events. - - Returns: - A tuple of (result, finished_event_id) where finished_event_id is - the event_id of the MethodExecutionFinishedEvent, or None if events - are suppressed. - """ - try: - dumped_params = {f"_{i}": arg for i, arg in enumerate(args)} | ( - kwargs or {} - ) - - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - MethodExecutionStartedEvent( - type="method_execution_started", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - params=dumped_params, - state=self._copy_and_serialize_state(), - ), - ) - if future: - self._event_futures.append(future) - - # Set method name in context so ask() can read it without - # stack inspection. Must happen before copy_context() so the - # value propagates into the thread pool for sync methods. - from crewai.flow.flow_context import current_flow_method_name - - method_name_token = current_flow_method_name.set(method_name) - try: - if asyncio.iscoroutinefunction(method): - result = await method(*args, **kwargs) - else: - # Run sync methods in thread pool for isolation - # This allows Agent.kickoff() to work synchronously inside Flow methods - ctx = contextvars.copy_context() - result = await asyncio.to_thread(ctx.run, method, *args, **kwargs) - finally: - current_flow_method_name.reset(method_name_token) - - # Auto-await coroutines returned from sync methods (enables AgentExecutor pattern) - if asyncio.iscoroutine(result): - result = await result - - self._method_outputs.append(result) - - # For @human_feedback methods with emit, the result is the collapsed outcome - # (e.g., "approved") used for routing. But we want the actual method output - # to be the stored result (for final flow output). Replace the last entry - # if a stashed output exists. Dict-based stash is concurrency-safe and - # handles None return values (presence in dict = stashed, not value). - if method_name in self._human_feedback_method_outputs: - self._method_outputs[-1] = self._human_feedback_method_outputs.pop( - method_name - ) - - self._method_execution_counts[method_name] = ( - self._method_execution_counts.get(method_name, 0) + 1 - ) - - self._completed_methods.add(method_name) - - finished_event_id: str | None = None - if not self.suppress_flow_events: - finished_event = MethodExecutionFinishedEvent( - type="method_execution_finished", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - state=self._copy_and_serialize_state(), - result=result, - ) - finished_event_id = finished_event.event_id - future = crewai_event_bus.emit(self, finished_event) - if future: - self._event_futures.append(future) - - return result, finished_event_id - except Exception as e: - # Check if this is a HumanFeedbackPending exception (paused, not failed) - from crewai.flow.async_feedback.types import HumanFeedbackPending - - if isinstance(e, HumanFeedbackPending): - e.context.method_name = method_name - - if self.persistence is None: - from crewai.flow.persistence import SQLiteFlowPersistence - - self.persistence = SQLiteFlowPersistence() - - # Emit paused event (not failed) - if not self.suppress_flow_events: - future = crewai_event_bus.emit( - self, - MethodExecutionPausedEvent( - type="method_execution_paused", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - state=self._copy_and_serialize_state(), - flow_id=e.context.flow_id, - message=e.context.message, - emit=e.context.emit, - ), - ) - if future: - self._event_futures.append(future) - elif not self.suppress_flow_events: - # Regular failure - emit failed event - future = crewai_event_bus.emit( - self, - MethodExecutionFailedEvent( - type="method_execution_failed", - method_name=method_name, - flow_name=self.name or self.__class__.__name__, - error=e, - ), - ) - if future: - self._event_futures.append(future) - raise e - - def _copy_and_serialize_state(self) -> dict[str, Any]: - state_copy = self._copy_state() - if isinstance(state_copy, BaseModel): - try: - return state_copy.model_dump(mode="json") - except Exception: - return state_copy.model_dump() - else: - return state_copy - - async def _execute_listeners( - self, - trigger_method: FlowMethodName, - result: Any, - triggering_event_id: str | None = None, - ) -> None: - """Executes all listeners and routers triggered by a method completion. - - This internal method manages the execution flow by: - 1. First executing all triggered routers sequentially - 2. Then executing all triggered listeners in parallel - - Args: - trigger_method: The name of the method that triggered these listeners. - result: The result from the triggering method, passed to listeners that accept parameters. - triggering_event_id: The event_id of the MethodExecutionFinishedEvent that - triggered these listeners, used for causal chain tracking. - - Note: - - Routers are executed sequentially to maintain flow control - - Each router's result becomes a new trigger_method - - Normal listeners are executed in parallel for efficiency - - Listeners can receive the trigger method's result as a parameter - """ - # First, handle routers repeatedly until no router triggers anymore - router_results = [] - router_result_to_feedback: dict[ - str, Any - ] = {} # Map outcome -> HumanFeedbackResult - current_trigger = trigger_method - current_result = result # Track the result to pass to each router - current_triggering_event_id = triggering_event_id - - while True: - routers_triggered = self._find_triggered_methods( - current_trigger, router_only=True - ) - if not routers_triggered: - break - - for router_name in routers_triggered: - # For routers triggered by a router outcome, pass the HumanFeedbackResult - router_input = router_result_to_feedback.get( - str(current_trigger), current_result - ) - ( - router_result, - current_triggering_event_id, - ) = await self._execute_single_listener( - router_name, router_input, current_triggering_event_id - ) - if router_result: # Only add non-None results - router_result_str = ( - router_result.value - if isinstance(router_result, enum.Enum) - else str(router_result) - ) - router_results.append(FlowMethodName(router_result_str)) - # If this was a human_feedback router, map the outcome to the feedback - if self.last_human_feedback is not None: - router_result_to_feedback[router_result_str] = ( - self.last_human_feedback - ) - current_trigger = ( - FlowMethodName( - router_result.value - if isinstance(router_result, enum.Enum) - else str(router_result) - ) - if router_result is not None - else FlowMethodName("") # Update for next iteration of router chain - ) - - # Now execute normal listeners for all router results and the original trigger - all_triggers = [trigger_method, *router_results] - - for current_trigger in all_triggers: - if current_trigger: # Skip None results - listeners_triggered = self._find_triggered_methods( - current_trigger, router_only=False - ) - if listeners_triggered: - # Determine what result to pass to listeners - # For router outcomes, pass the HumanFeedbackResult if available - listener_result = router_result_to_feedback.get( - str(current_trigger), result - ) - racing_group = self._get_racing_group_for_listeners( - listeners_triggered - ) - if racing_group: - racing_members, _ = racing_group - other_listeners = [ - name - for name in listeners_triggered - if name not in racing_members - ] - await self._execute_racing_listeners( - racing_members, - other_listeners, - listener_result, - current_triggering_event_id, - ) - else: - tasks = [ - self._execute_single_listener( - listener_name, - listener_result, - current_triggering_event_id, - ) - for listener_name in listeners_triggered - ] - await asyncio.gather(*tasks) - - if current_trigger in router_results: - for method_name in self._start_methods: - if method_name in self._listeners: - condition_data = self._listeners[method_name] - should_trigger = False - if is_simple_flow_condition(condition_data): - _, trigger_methods = condition_data - should_trigger = current_trigger in trigger_methods - elif isinstance(condition_data, dict): - all_methods = _extract_all_methods(condition_data) - should_trigger = current_trigger in all_methods - - if should_trigger: - if method_name in self._completed_methods: - # Cyclic re-execution: temporarily clear resumption flag so the method actually re-runs - was_resuming = self._is_execution_resuming - self._is_execution_resuming = False - await self._execute_start_method(method_name) - self._is_execution_resuming = was_resuming - else: - await self._execute_start_method(method_name) - - def _evaluate_condition( - self, - condition: FlowMethodName | FlowCondition, - trigger_method: FlowMethodName, - listener_name: FlowMethodName, - ) -> bool: - """Recursively evaluate a condition (simple or nested). - - Args: - condition: Can be a string (method name) or dict (nested condition) - trigger_method: The method that just completed - listener_name: Name of the listener being evaluated - - Returns: - True if the condition is satisfied, False otherwise - """ - if is_flow_method_name(condition): - return condition == trigger_method - - if is_flow_condition_dict(condition): - normalized = _normalize_condition(condition) - cond_type = normalized.get("type", OR_CONDITION) - sub_conditions = normalized.get("conditions", []) - - if cond_type == OR_CONDITION: - return any( - self._evaluate_condition(sub_cond, trigger_method, listener_name) - for sub_cond in sub_conditions - ) - - if cond_type == AND_CONDITION: - pending_key = PendingListenerKey(f"{listener_name}:{id(condition)}") - - if pending_key not in self._pending_and_listeners: - all_methods = set(_extract_all_methods(condition)) - self._pending_and_listeners[pending_key] = all_methods - - if trigger_method in self._pending_and_listeners[pending_key]: - self._pending_and_listeners[pending_key].discard(trigger_method) - - direct_methods_satisfied = not self._pending_and_listeners[pending_key] - - nested_conditions_satisfied = all( - ( - self._evaluate_condition( - sub_cond, trigger_method, listener_name - ) - if is_flow_condition_dict(sub_cond) - else True - ) - for sub_cond in sub_conditions - ) - - if direct_methods_satisfied and nested_conditions_satisfied: - self._pending_and_listeners.pop(pending_key, None) - return True - - return False - - return False - - def _find_triggered_methods( - self, trigger_method: FlowMethodName, router_only: bool - ) -> list[FlowMethodName]: - """Finds all methods that should be triggered based on conditions. - - This internal method evaluates both OR and AND conditions to determine - which methods should be executed next in the flow. Supports nested conditions. - - Args: - trigger_method: The name of the method that just completed execution. - router_only: If True, only consider router methods. If False, only consider non-router methods. - - Returns: - Names of methods that should be triggered. - - Note: - - Handles both OR and AND conditions, including nested combinations - - Maintains state for AND conditions using _pending_and_listeners - - Separates router and normal listener evaluation - """ - triggered: list[FlowMethodName] = [] - - for listener_name, condition_data in self._listeners.items(): - is_router = listener_name in self._routers - - if router_only != is_router: - continue - - if not router_only and listener_name in self._start_methods: - continue - - if is_simple_flow_condition(condition_data): - condition_type, methods = condition_data - - if condition_type == OR_CONDITION: - # Only trigger multi-source OR listeners (or_(A, B, C)) once - skip if already fired - # Simple single-method listeners fire every time their trigger occurs - # Routers also fire every time - they're decision points - has_multiple_triggers = len(methods) > 1 - should_check_fired = has_multiple_triggers and not is_router - - if ( - not should_check_fired - or listener_name not in self._fired_or_listeners - ): - if trigger_method in methods: - triggered.append(listener_name) - # Only track multi-source OR listeners (not single-method or routers) - if should_check_fired: - self._fired_or_listeners.add(listener_name) - elif condition_type == AND_CONDITION: - pending_key = PendingListenerKey(listener_name) - if pending_key not in self._pending_and_listeners: - self._pending_and_listeners[pending_key] = set(methods) - if trigger_method in self._pending_and_listeners[pending_key]: - self._pending_and_listeners[pending_key].discard(trigger_method) - - if not self._pending_and_listeners[pending_key]: - triggered.append(listener_name) - self._pending_and_listeners.pop(pending_key, None) - - elif is_flow_condition_dict(condition_data): - # For complex conditions, check if top-level is OR and track accordingly - top_level_type = condition_data.get("type", OR_CONDITION) - is_or_based = top_level_type == OR_CONDITION - - # Only track multi-source OR conditions (multiple sub-conditions), not routers - sub_conditions = condition_data.get("conditions", []) - has_multiple_triggers = is_or_based and len(sub_conditions) > 1 - should_check_fired = has_multiple_triggers and not is_router - - # Skip compound OR-based listeners that have already fired - if should_check_fired and listener_name in self._fired_or_listeners: - continue - - if self._evaluate_condition( - condition_data, trigger_method, listener_name - ): - triggered.append(listener_name) - # Track compound OR-based listeners so they only fire once - if should_check_fired: - self._fired_or_listeners.add(listener_name) - - return triggered - - async def _execute_single_listener( - self, - listener_name: FlowMethodName, - result: Any, - triggering_event_id: str | None = None, - ) -> tuple[Any, str | None]: - """Executes a single listener method with proper event handling. - - This internal method manages the execution of an individual listener, - including parameter inspection, event emission, and error handling. - - Args: - listener_name: The name of the listener method to execute. - result: The result from the triggering method, which may be passed to the listener if it accepts parameters. - triggering_event_id: The event_id of the event that triggered this listener, - used for causal chain tracking. - - Returns: - A tuple of (listener_result, event_id) where listener_result is the return - value of the listener method and event_id is the MethodExecutionFinishedEvent - id, or (None, None) if skipped during resumption. - - Note: - - Inspects method signature to determine if it accepts the trigger result - - Emits events for method execution start and finish - - Handles errors gracefully with detailed logging - - Recursively triggers listeners of this listener - - Supports both parameterized and parameter-less listeners - - Skips execution if method was already completed (e.g., after reload) - - Catches and logs any exceptions during execution, preventing individual listener failures from breaking the entire flow - """ - count = self._method_call_counts.get(listener_name, 0) + 1 - if count > self.max_method_calls: - raise RecursionError( - f"Method '{listener_name}' has been called {self.max_method_calls} times in " - f"this flow execution, which indicates an infinite loop. " - f"This commonly happens when a @listen label matches the " - f"method's own name." - ) - self._method_call_counts[listener_name] = count - - if listener_name in self._completed_methods: - if self._is_execution_resuming: - # During resumption, skip execution but continue listeners - await self._execute_listeners(listener_name, None) - - # For routers, also check if any conditional starts they triggered are completed - # If so, continue their chains - if listener_name in self._routers: - for start_method_name in self._start_methods: - if ( - start_method_name in self._listeners - and start_method_name in self._completed_methods - ): - # This conditional start was executed, continue its chain - await self._execute_start_method(start_method_name) - return (None, None) - # For cyclic flows, clear from completed to allow re-execution - self._completed_methods.discard(listener_name) - # Clear ALL fired OR listeners so they can fire again in the new cycle. - # This mirrors what _execute_start_method does for start-method cycles. - # Only discarding the individual listener is insufficient because - # downstream or_() listeners (e.g., method_a listening to - # or_(handler_a, handler_b)) would remain suppressed across iterations. - self._clear_or_listeners() - - try: - method = self._methods[listener_name] - - sig = inspect.signature(method) - params = list(sig.parameters.values()) - method_params = [p for p in params if p.name != "self"] - - if triggering_event_id: - with triggered_by_scope(triggering_event_id): - if method_params: - listener_result, finished_event_id = await self._execute_method( - listener_name, method, result - ) - else: - listener_result, finished_event_id = await self._execute_method( - listener_name, method - ) - else: - if method_params: - listener_result, finished_event_id = await self._execute_method( - listener_name, method, result - ) - else: - listener_result, finished_event_id = await self._execute_method( - listener_name, method - ) - - await self._execute_listeners( - listener_name, listener_result, finished_event_id - ) - - return (listener_result, finished_event_id) - - except Exception as e: - # Don't log HumanFeedbackPending as an error - it's expected control flow - from crewai.flow.async_feedback.types import HumanFeedbackPending - - if not isinstance(e, HumanFeedbackPending): - if not getattr(e, "_flow_listener_logged", False): - logger.error(f"Error executing listener {listener_name}: {e}") - e._flow_listener_logged = True # type: ignore[attr-defined] - raise - - def _resolve_input_provider(self) -> InputProvider: - """Resolve the input provider using the priority chain. - - Resolution order: - 1. ``self.input_provider`` (per-flow override) - 2. ``flow_config.input_provider`` (global default) - 3. ``ConsoleInputProvider()`` (built-in fallback) - - Returns: - An object implementing the ``InputProvider`` protocol. - """ - from crewai.flow.async_feedback.providers import ConsoleProvider - from crewai.flow.flow_config import flow_config - - if self.input_provider is not None: - return self.input_provider - if flow_config.input_provider is not None: - return flow_config.input_provider - return ConsoleProvider() - - def _checkpoint_state_for_ask(self) -> None: - """Auto-checkpoint flow state before waiting for user input. - - If persistence is configured, saves the current state so that - ``self.state`` is recoverable even if the process crashes while - waiting for input. - - This is best-effort: if persistence is not configured, this is a no-op. - """ - if self.persistence is None: - return - try: - state_data = ( - self._state - if isinstance(self._state, dict) - else self._state.model_dump() - ) - self.persistence.save_state( - flow_uuid=self.flow_id, - method_name="_ask_checkpoint", - state_data=state_data, - ) - except Exception: - logger.debug("Failed to checkpoint state before ask()", exc_info=True) - - def ask( - self, - message: str, - timeout: float | None = None, - metadata: dict[str, Any] | None = None, - ) -> str | None: - """Request input from the user during flow execution. - - Blocks the current thread until the user provides input or the - timeout expires. Works in both sync and async flow methods (the - flow framework runs sync methods in a thread pool via - ``asyncio.to_thread``, so the event loop stays free). - - Timeout ensures flows always terminate. When timeout expires, - ``None`` is returned, enabling the pattern:: - - while (msg := self.ask("You: ", timeout=300)) is not None: - process(msg) - - Before waiting for input, the current ``self.state`` is automatically - checkpointed to persistence (if configured) for durability. - - Args: - message: The question or prompt to display to the user. - timeout: Maximum seconds to wait for input. ``None`` means - wait indefinitely. When timeout expires, returns ``None``. - Note: timeout is best-effort for the provider call -- - ``ask()`` returns ``None`` promptly, but the underlying - ``request_input()`` may continue running in a background - thread until it completes naturally. Network providers - should implement their own internal timeouts. - metadata: Optional metadata to send to the input provider, - such as user ID, channel, session context. The provider - can use this to route the question to the right recipient. - - Returns: - The user's input as a string, or ``None`` on timeout, disconnect, - or provider error. Empty string ``""`` means the user pressed - Enter without typing (intentional empty input). - - Example: - ```python - class MyFlow(Flow): - @start() - def gather_info(self): - topic = self.ask( - "What topic should we research?", - metadata={"user_id": "u123", "channel": "#research"}, - ) - if topic is None: - return "No input received" - return topic - ``` - """ - from concurrent.futures import ( - ThreadPoolExecutor, - TimeoutError as FuturesTimeoutError, - ) - from datetime import datetime - - from crewai.events.types.flow_events import ( - FlowInputReceivedEvent, - FlowInputRequestedEvent, - ) - from crewai.flow.flow_context import current_flow_method_name - from crewai.flow.input_provider import InputResponse - - method_name = current_flow_method_name.get("unknown") - - crewai_event_bus.emit( - self, - FlowInputRequestedEvent( - type="flow_input_requested", - flow_name=self.name or self.__class__.__name__, - method_name=method_name, - message=message, - metadata=metadata, - ), - ) - - self._checkpoint_state_for_ask() - - provider = self._resolve_input_provider() - raw: str | InputResponse | None = None - - try: - if timeout is not None: - # Manual executor management to avoid shutdown(wait=True) - # deadlock when the provider call outlives the timeout. - executor = ThreadPoolExecutor(max_workers=1) - ctx = contextvars.copy_context() - future = executor.submit( - ctx.run, provider.request_input, message, self, metadata - ) - try: - raw = future.result(timeout=timeout) - except FuturesTimeoutError: - future.cancel() - raw = None - finally: - # wait=False so we don't block if the provider is still - # running (e.g. input() stuck waiting for user). - # cancel_futures=True cleans up any queued-but-not-started tasks. - executor.shutdown(wait=False, cancel_futures=True) - else: - raw = provider.request_input(message, self, metadata=metadata) - except KeyboardInterrupt: - raise - except Exception: - logger.debug("Input provider error in ask()", exc_info=True) - raw = None - - response: str | None = None - response_metadata: dict[str, Any] | None = None - - if isinstance(raw, InputResponse): - response = raw.text - response_metadata = raw.metadata - elif isinstance(raw, str): - response = raw - else: - response = None - - self._input_history.append( - { - "message": message, - "response": response, - "method_name": method_name, - "timestamp": datetime.now(), - "metadata": metadata, - "response_metadata": response_metadata, - } - ) - - crewai_event_bus.emit( - self, - FlowInputReceivedEvent( - type="flow_input_received", - flow_name=self.name or self.__class__.__name__, - method_name=method_name, - message=message, - response=response, - metadata=metadata, - response_metadata=response_metadata, - ), - ) - - return response - - def _request_human_feedback( - self, - message: str, - output: Any, - metadata: dict[str, Any] | None = None, - emit: Sequence[str] | None = None, - ) -> str: - """Request feedback from a human. - Args: - message: The message to display when requesting feedback. - output: The method output to show the human for review. - metadata: Optional metadata for enterprise integrations. - emit: Optional list of possible outcomes for routing. - - Returns: - The human's feedback as a string. Empty string if no feedback provided. - """ - from crewai.events.event_listener import event_listener - from crewai.events.types.flow_events import ( - HumanFeedbackReceivedEvent, - HumanFeedbackRequestedEvent, - ) - - crewai_event_bus.emit( - self, - HumanFeedbackRequestedEvent( - type="human_feedback_requested", - flow_name=self.name or self.__class__.__name__, - method_name="", # Will be set by decorator if needed - output=output, - message=message, - emit=list(emit) if emit else None, - ), - ) - - formatter = event_listener.formatter - formatter.pause_live_updates() - - try: - formatter.console.print("\n" + "═" * 50, style="bold cyan") - formatter.console.print(" OUTPUT FOR REVIEW", style="bold cyan") - formatter.console.print("═" * 50 + "\n", style="bold cyan") - formatter.console.print(output) - formatter.console.print("\n" + "═" * 50 + "\n", style="bold cyan") - - formatter.console.print(message, style="yellow") - formatter.console.print( - "(Press Enter to skip, or type your feedback)\n", style="cyan" - ) - - feedback = input("Your feedback: ").strip() - - crewai_event_bus.emit( - self, - HumanFeedbackReceivedEvent( - type="human_feedback_received", - flow_name=self.name or self.__class__.__name__, - method_name="", # Will be set by decorator if needed - feedback=feedback, - outcome=None, # Will be determined after collapsing - ), - ) - - return feedback - finally: - formatter.resume_live_updates() - - def _collapse_to_outcome( - self, - feedback: str, - outcomes: Sequence[str], - llm: str | BaseLLM, - ) -> str: - """Collapse free-form feedback to a predefined outcome using LLM. - - This method uses the specified LLM to interpret the human's feedback - and map it to one of the predefined outcomes for routing purposes. - - Uses structured outputs (function calling) when supported by the LLM - to guarantee the response is one of the valid outcomes. Falls back - to simple prompting if structured outputs fail. - - Args: - feedback: The raw human feedback text. - outcomes: Sequence of valid outcome strings to choose from. - llm: The LLM model to use. Can be a model string or BaseLLM instance. - - Returns: - One of the outcome strings that best matches the feedback intent. - """ - from typing import Literal - - from pydantic import BaseModel, Field - - from crewai.llm import LLM - from crewai.llms.base_llm import BaseLLM as BaseLLMClass - from crewai.utilities.i18n import I18N_DEFAULT - - llm_instance: BaseLLMClass - if isinstance(llm, str): - llm_instance = LLM(model=llm) - elif isinstance(llm, BaseLLMClass): - llm_instance = llm - else: - raise ValueError(f"Invalid llm type: {type(llm)}. Expected str or BaseLLM.") - - outcomes_tuple = tuple(outcomes) - - class FeedbackOutcome(BaseModel): - """The outcome that best matches the human's feedback intent.""" - - outcome: Literal[outcomes_tuple] = Field( # type: ignore[valid-type] - description=f"The outcome that best matches the feedback. Must be one of: {', '.join(outcomes)}" - ) - - prompt_template = I18N_DEFAULT.slice("human_feedback_collapse") - - prompt = prompt_template.format( - feedback=feedback, - outcomes=", ".join(outcomes), - ) - - try: - # NOTE: LLM.call with response_model returns JSON string, not a Pydantic model - response = llm_instance.call( - messages=[{"role": "user", "content": prompt}], - response_model=FeedbackOutcome, - ) - - if isinstance(response, str): - import json - - try: - parsed = json.loads(response) - return str(parsed.get("outcome", outcomes[0])) - except json.JSONDecodeError: - response_clean = response.strip() - for outcome in outcomes: - if outcome.lower() == response_clean.lower(): - return outcome - return outcomes[0] - elif isinstance(response, FeedbackOutcome): - return str(response.outcome) - elif hasattr(response, "outcome"): - return str(response.outcome) - else: - logger.warning(f"Unexpected response type: {type(response)}") - return outcomes[0] - - except Exception as e: - logger.warning( - f"Structured output failed, falling back to simple prompting: {e}" - ) - try: - response = llm_instance.call( - messages=[{"role": "user", "content": prompt}], - ) - response_clean = str(response).strip() - - for outcome in outcomes: - if outcome.lower() == response_clean.lower(): - return outcome - - # Partial match (longest wins, first on length ties) - response_lower = response_clean.lower() - best_outcome: str | None = None - best_len = -1 - for outcome in outcomes: - if outcome.lower() in response_lower and len(outcome) > best_len: - best_outcome = outcome - best_len = len(outcome) - if best_outcome is not None: - return best_outcome - - logger.warning( - f"Could not match LLM response '{response_clean}' to outcomes {list(outcomes)}. " - f"Falling back to first outcome: {outcomes[0]}" - ) - return outcomes[0] - - except Exception as fallback_err: - logger.warning( - f"Simple prompting also failed: {fallback_err}. " - f"Falling back to first outcome: {outcomes[0]}" - ) - return outcomes[0] - - def _log_flow_event( - self, - message: str, - color: str = "yellow", - level: Literal["info", "warning"] = "info", - ) -> None: - """Centralized logging method for flow events. - - This method provides a consistent interface for logging flow-related events, - combining both console output with colors and proper logging levels. - - Args: - message: The message to log - color: Rich style for console output (default: "yellow") - Examples: "yellow", "red", "bold green", "bold magenta" - level: Log level to use (default: info) - Supported levels: info, warning - - Note: - This method uses the centralized Rich console formatter for output - and the standard logging module for log level support. - """ - from crewai.events.event_listener import event_listener - - event_listener.formatter.console.print(message, style=color) - if level == "info": - logger.info(message) - else: - logger.warning(message) - - def plot(self, filename: str = "crewai_flow.html", show: bool = True) -> str: - """Create interactive HTML visualization of Flow structure. - - Args: - filename: Output HTML filename (default: "crewai_flow.html"). - show: Whether to open in browser (default: True). - - Returns: - Absolute path to generated HTML file. - """ - crewai_event_bus.emit( - self, - FlowPlotEvent( - type="flow_plot", - flow_name=self.name or self.__class__.__name__, - ), - ) - structure = build_flow_structure(self) - return render_interactive(structure, filename=filename, show=show) - - @staticmethod - def _show_tracing_disabled_message() -> None: - """Show a message when tracing is disabled.""" - if should_suppress_tracing_messages(): - return - - console = Console() - - if has_user_declined_tracing(): - message = """Info: Tracing is disabled. - -To enable tracing, do any one of these: -• Set tracing=True in your Flow code -• Set CREWAI_TRACING_ENABLED=true in your project's .env file -• Run: crewai traces enable""" - else: - message = """Info: Tracing is disabled. - -To enable tracing, do any one of these: -• Set tracing=True in your Flow code -• Set CREWAI_TRACING_ENABLED=true in your project's .env file -• Run: crewai traces enable""" - - panel = Panel( - message, - title="Tracing Status", - border_style="blue", - padding=(1, 2), - ) - console.print(panel) +__all__ = [ + "_INITIAL_STATE_CLASS_MARKER", + "Flow", + "FlowMeta", + "FlowState", + "LockedDictProxy", + "LockedListProxy", + "StateProxy", + "and_", + "listen", + "or_", + "router", + "start", +] diff --git a/lib/crewai/src/crewai/flow/flow_definition.py b/lib/crewai/src/crewai/flow/flow_definition.py new file mode 100644 index 000000000..cc0b2d9ff --- /dev/null +++ b/lib/crewai/src/crewai/flow/flow_definition.py @@ -0,0 +1,1036 @@ +""" +Flow definition: the structural model derived from the DSL. + +Condition predicates, condition decoding, AST-based router-path extraction, +graph/level analysis, and ``extract_flow_definition`` (the structural +registries the runtime metaclass attaches to a Flow class). Previously these +lived in ``crewai.flow.utils``, which now re-exports from here. + +This module provides core functionality for analyzing and manipulating flow structures, +including node level calculation, ancestor tracking, and return value analysis. +Functions in this module are primarily used by the visualization system to create +accurate and informative flow diagrams. + +Example +------- +>>> flow = Flow() +>>> node_levels = calculate_node_levels(flow) +>>> ancestors = build_ancestor_dict(flow) +""" + +from __future__ import annotations + +import ast +from collections import defaultdict, deque +from enum import Enum +import inspect +import textwrap +from typing import TYPE_CHECKING, Any + +from crewai_core.printer import PRINTER +from typing_extensions import TypeIs + +from crewai.flow.constants import AND_CONDITION, OR_CONDITION +from crewai.flow.flow_wrappers import ( + FlowCondition, + FlowConditions, + FlowMethod, + SimpleFlowCondition, +) +from crewai.flow.types import FlowMethodCallable, FlowMethodName + + +if TYPE_CHECKING: + from crewai.flow.flow import Flow + + +def _extract_string_literals_from_type_annotation( + node: ast.expr, + function_globals: dict[str, Any] | None = None, +) -> list[str]: + """Extract string literals from a type annotation AST node. + + Handles: + - Literal["a", "b", "c"] + - "a" | "b" | "c" (union of string literals) + - Just "a" (single string constant annotation) + - Enum types with string values (e.g., class MyEnum(str, Enum)) + + Args: + node: The AST node representing a type annotation. + function_globals: The globals dict from the function, used to resolve Enum types. + + Returns: + List of string literals found in the annotation. + """ + + strings: list[str] = [] + + if isinstance(node, ast.Constant) and isinstance(node.value, str): + strings.append(node.value) + + elif isinstance(node, ast.Name) and function_globals: + enum_class = function_globals.get(node.id) + if ( + enum_class is not None + and isinstance(enum_class, type) + and issubclass(enum_class, Enum) + ): + strings.extend( + member.value for member in enum_class if isinstance(member.value, str) + ) + + elif isinstance(node, ast.Attribute) and function_globals: + try: + if isinstance(node.value, ast.Name): + module = function_globals.get(node.value.id) + if module is not None: + enum_class = getattr(module, node.attr, None) + if ( + enum_class is not None + and isinstance(enum_class, type) + and issubclass(enum_class, Enum) + ): + strings.extend( + member.value + for member in enum_class + if isinstance(member.value, str) + ) + except (AttributeError, TypeError): + pass + + elif isinstance(node, ast.Subscript): + is_literal = False + if isinstance(node.value, ast.Name) and node.value.id == "Literal": + is_literal = True + elif isinstance(node.value, ast.Attribute) and node.value.attr == "Literal": + is_literal = True + + if is_literal: + if isinstance(node.slice, ast.Tuple): + strings.extend( + elt.value + for elt in node.slice.elts + if isinstance(elt, ast.Constant) and isinstance(elt.value, str) + ) + elif isinstance(node.slice, ast.Constant) and isinstance( + node.slice.value, str + ): + strings.append(node.slice.value) + + elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + strings.extend( + _extract_string_literals_from_type_annotation(node.left, function_globals) + ) + strings.extend( + _extract_string_literals_from_type_annotation(node.right, function_globals) + ) + + return strings + + +def _unwrap_function(function: Any) -> Any: + """Unwrap a function to get the original function with correct globals. + + Flow methods are wrapped by decorators like @router, @listen, etc. + This function unwraps them to get the original function which has + the correct __globals__ for resolving type annotations like Enums. + + Args: + function: The potentially wrapped function. + + Returns: + The unwrapped original function. + """ + if hasattr(function, "__func__"): + function = function.__func__ + + if hasattr(function, "__wrapped__"): + wrapped = function.__wrapped__ + if hasattr(wrapped, "unwrap"): + return wrapped.unwrap() + return wrapped + + return function + + +def get_possible_return_constants( + function: Any, verbose: bool = True +) -> list[str] | None: + """Extract possible string return values from a function using AST parsing. + + This function analyzes the source code of a router method to identify + all possible string values it might return. It handles: + - Return type annotations: -> Literal["a", "b"] or -> "a" | "b" | "c" + - Enum type annotations: -> MyEnum (extracts string values from members) + - Direct string literals: return "value" + - Variable assignments: x = "value"; return x + - Dictionary lookups: d = {"k": "v"}; return d[key] + - Conditional returns: return "a" if cond else "b" + - State attributes: return self.state.attr (infers from class context) + + Args: + function: The function to analyze. + + Returns: + List of possible string return values, or None if analysis fails. + """ + unwrapped = _unwrap_function(function) + + try: + source = inspect.getsource(function) + except OSError: + return None + except Exception as e: + if verbose: + PRINTER.print( + f"Error retrieving source code for function {function.__name__}: {e}", + color="red", + ) + return None + + try: + source = textwrap.dedent(source) + code_ast = ast.parse(source) + except IndentationError as e: + if verbose: + PRINTER.print( + f"IndentationError while parsing source code of {function.__name__}: {e}", + color="red", + ) + PRINTER.print(f"Source code:\n{source}", color="yellow") + return None + except SyntaxError as e: + if verbose: + PRINTER.print( + f"SyntaxError while parsing source code of {function.__name__}: {e}", + color="red", + ) + PRINTER.print(f"Source code:\n{source}", color="yellow") + return None + except Exception as e: + if verbose: + PRINTER.print( + f"Unexpected error while parsing source code of {function.__name__}: {e}", + color="red", + ) + PRINTER.print(f"Source code:\n{source}", color="yellow") + return None + + return_values: set[str] = set() + + function_globals = getattr(unwrapped, "__globals__", None) + + for node in ast.walk(code_ast): + if isinstance(node, ast.FunctionDef): + if node.returns: + annotation_values = _extract_string_literals_from_type_annotation( + node.returns, function_globals + ) + return_values.update(annotation_values) + break # Only process the first function definition + dict_definitions: dict[str, list[str]] = {} + variable_values: dict[str, list[str]] = {} + state_attribute_values: dict[str, list[str]] = {} + + def extract_string_constants(node: ast.expr) -> list[str]: + """Recursively extract all string constants from an AST node.""" + strings: list[str] = [] + if isinstance(node, ast.Constant) and isinstance(node.value, str): + strings.append(node.value) + elif isinstance(node, ast.IfExp): + strings.extend(extract_string_constants(node.body)) + strings.extend(extract_string_constants(node.orelse)) + elif isinstance(node, ast.Call): + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "get" + and len(node.args) >= 2 + ): + default_arg = node.args[1] + if isinstance(default_arg, ast.Constant) and isinstance( + default_arg.value, str + ): + strings.append(default_arg.value) + return strings + + class VariableAssignmentVisitor(ast.NodeVisitor): + def visit_Assign(self, node: ast.Assign) -> None: + if isinstance(node.value, ast.Dict) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Name): + var_name = target.id + dict_values = [ + val.value + for val in node.value.values + if isinstance(val, ast.Constant) and isinstance(val.value, str) + ] + if dict_values: + dict_definitions[var_name] = dict_values + + if len(node.targets) == 1: + target = node.targets[0] + var_name_alt: str | None = None + if isinstance(target, ast.Name): + var_name_alt = target.id + elif isinstance(target, ast.Attribute): + var_name_alt = f"{target.value.id if isinstance(target.value, ast.Name) else '_'}.{target.attr}" + + if var_name_alt: + strings = extract_string_constants(node.value) + if strings: + variable_values[var_name_alt] = strings + + self.generic_visit(node) + + def get_attribute_chain(node: ast.expr) -> str | None: + """Extract the full attribute chain from an AST node. + + Examples: + self.state.run_type -> "self.state.run_type" + x.y.z -> "x.y.z" + simple_var -> "simple_var" + """ + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + base = get_attribute_chain(node.value) + if base: + return f"{base}.{node.attr}" + return None + + class ReturnVisitor(ast.NodeVisitor): + def visit_Return(self, node: ast.Return) -> None: + if ( + node.value + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ): + return_values.add(node.value.value) + elif node.value and isinstance(node.value, ast.Subscript): + if isinstance(node.value.value, ast.Name): + var_name_dict = node.value.value.id + if var_name_dict in dict_definitions: + for v in dict_definitions[var_name_dict]: + return_values.add(v) + elif node.value: + var_name_ret = get_attribute_chain(node.value) + + if var_name_ret and var_name_ret in variable_values: + for v in variable_values[var_name_ret]: + return_values.add(v) + elif var_name_ret and var_name_ret in state_attribute_values: + for v in state_attribute_values[var_name_ret]: + return_values.add(v) + + self.generic_visit(node) + + def visit_If(self, node: ast.If) -> None: + self.generic_visit(node) + + try: + if hasattr(function, "__self__"): + class_obj = function.__self__.__class__ + elif hasattr(function, "__qualname__") and "." in function.__qualname__: + class_name = function.__qualname__.rsplit(".", 1)[0] + if hasattr(function, "__globals__"): + class_obj = function.__globals__.get(class_name) + else: + class_obj = None + else: + class_obj = None + + if class_obj is not None: + try: + class_source = inspect.getsource(class_obj) + class_source = textwrap.dedent(class_source) + class_ast = ast.parse(class_source) + + class StateAttributeVisitor(ast.NodeVisitor): + def visit_Compare(self, node: ast.Compare) -> None: + """Find comparisons like: self.state.attr == "value" """ + left_attr = get_attribute_chain(node.left) + + if left_attr: + for comparator in node.comparators: + if isinstance(comparator, ast.Constant) and isinstance( + comparator.value, str + ): + if left_attr not in state_attribute_values: + state_attribute_values[left_attr] = [] + if ( + comparator.value + not in state_attribute_values[left_attr] + ): + state_attribute_values[left_attr].append( + comparator.value + ) + + for comparator in node.comparators: + right_attr = get_attribute_chain(comparator) + if ( + right_attr + and isinstance(node.left, ast.Constant) + and isinstance(node.left.value, str) + ): + if right_attr not in state_attribute_values: + state_attribute_values[right_attr] = [] + if ( + node.left.value + not in state_attribute_values[right_attr] + ): + state_attribute_values[right_attr].append( + node.left.value + ) + + self.generic_visit(node) + + StateAttributeVisitor().visit(class_ast) + except Exception as e: + if verbose: + PRINTER.print( + f"Could not analyze class context for {function.__name__}: {e}", + color="yellow", + ) + except Exception as e: + if verbose: + PRINTER.print( + f"Could not introspect class for {function.__name__}: {e}", + color="yellow", + ) + + VariableAssignmentVisitor().visit(code_ast) + ReturnVisitor().visit(code_ast) + + return list(return_values) if return_values else None + + +def calculate_node_levels(flow: Any) -> dict[str, int]: + """ + Calculate the hierarchical level of each node in the flow. + + Performs a breadth-first traversal of the flow graph to assign levels + to nodes, starting with start methods at level 0. + + Parameters + ---------- + flow : Any + The flow instance containing methods, listeners, and router configurations. + + Returns + ------- + Dict[str, int] + Dictionary mapping method names to their hierarchical levels. + + Notes + ----- + - Start methods are assigned level 0 + - Each subsequent connected node is assigned level = parent_level + 1 + - Handles both OR and AND conditions for listeners + - Processes router paths separately + """ + levels: dict[str, int] = {} + queue: deque[str] = deque() + visited: set[str] = set() + pending_and_listeners: dict[str, set[str]] = {} + + for method_name, method in flow._methods.items(): + if hasattr(method, "__is_start_method__"): + levels[method_name] = 0 + queue.append(method_name) + + or_listeners = defaultdict(list) + and_listeners = defaultdict(set) + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + condition_type, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive(condition_data, flow) + condition_type = condition_data.get("type", "OR") + else: + continue + + if condition_type == "OR": + for method in trigger_methods: + or_listeners[method].append(listener_name) + elif condition_type == "AND": + and_listeners[listener_name] = set(trigger_methods) + + while queue: + current = queue.popleft() + current_level = levels[current] + visited.add(current) + + for listener_name in or_listeners[current]: + if listener_name not in levels or levels[listener_name] > current_level + 1: + levels[listener_name] = current_level + 1 + if listener_name not in visited: + queue.append(listener_name) + + for listener_name, required_methods in and_listeners.items(): + if current in required_methods: + if listener_name not in pending_and_listeners: + pending_and_listeners[listener_name] = set() + pending_and_listeners[listener_name].add(current) + + if required_methods == pending_and_listeners[listener_name]: + if ( + listener_name not in levels + or levels[listener_name] > current_level + 1 + ): + levels[listener_name] = current_level + 1 + if listener_name not in visited: + queue.append(listener_name) + + process_router_paths(flow, current, current_level, levels, queue) + + max_level = max(levels.values()) if levels else 0 + for method_name in flow._methods: + if method_name not in levels: + levels[method_name] = max_level + 1 + + return levels + + +def count_outgoing_edges(flow: Any) -> dict[str, int]: + """ + Count the number of outgoing edges for each method in the flow. + + Parameters + ---------- + flow : Any + The flow instance to analyze. + + Returns + ------- + Dict[str, int] + Dictionary mapping method names to their outgoing edge count. + """ + counts = {} + for method_name in flow._methods: + counts[method_name] = 0 + for condition_data in flow._listeners.values(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive(condition_data, flow) + else: + continue + + for trigger in trigger_methods: + if trigger in flow._methods: + counts[trigger] += 1 + return counts + + +def build_ancestor_dict(flow: Any) -> dict[str, set[str]]: + """ + Build a dictionary mapping each node to its ancestor nodes. + + Parameters + ---------- + flow : Any + The flow instance to analyze. + + Returns + ------- + Dict[str, Set[str]] + Dictionary mapping each node to a set of its ancestor nodes. + """ + ancestors: dict[str, set[str]] = {node: set() for node in flow._methods} + visited: set[str] = set() + for node in flow._methods: + if node not in visited: + dfs_ancestors(node, ancestors, visited, flow) + return ancestors + + +def dfs_ancestors( + node: str, ancestors: dict[str, set[str]], visited: set[str], flow: Any +) -> None: + """ + Perform depth-first search to build ancestor relationships. + + Parameters + ---------- + node : str + Current node being processed. + ancestors : Dict[str, Set[str]] + Dictionary tracking ancestor relationships. + visited : Set[str] + Set of already visited nodes. + flow : Any + The flow instance being analyzed. + + Notes + ----- + This function modifies the ancestors dictionary in-place to build + the complete ancestor graph. + """ + if node in visited: + return + visited.add(node) + + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive(condition_data, flow) + else: + continue + + if node in trigger_methods: + ancestors[listener_name].add(node) + ancestors[listener_name].update(ancestors[node]) + dfs_ancestors(listener_name, ancestors, visited, flow) + + if node in flow._routers: + router_method_name = node + paths = flow._router_paths.get(router_method_name, []) + for path in paths: + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive( + condition_data, flow + ) + else: + continue + + if path in trigger_methods: + ancestors[listener_name].update(ancestors[node]) + dfs_ancestors(listener_name, ancestors, visited, flow) + + +def is_ancestor( + node: str, ancestor_candidate: str, ancestors: dict[str, set[str]] +) -> bool: + """ + Check if one node is an ancestor of another. + + Parameters + ---------- + node : str + The node to check ancestors for. + ancestor_candidate : str + The potential ancestor node. + ancestors : Dict[str, Set[str]] + Dictionary containing ancestor relationships. + + Returns + ------- + bool + True if ancestor_candidate is an ancestor of node, False otherwise. + """ + return ancestor_candidate in ancestors.get(node, set()) + + +def build_parent_children_dict(flow: Any) -> dict[str, list[str]]: + """ + Build a dictionary mapping parent nodes to their children. + + Parameters + ---------- + flow : Any + The flow instance to analyze. + + Returns + ------- + Dict[str, List[str]] + Dictionary mapping parent method names to lists of their child method names. + + Notes + ----- + - Maps listeners to their trigger methods + - Maps router methods to their paths and listeners + - Children lists are sorted for consistent ordering + """ + parent_children: dict[str, list[str]] = {} + + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive(condition_data, flow) + else: + continue + + for trigger in trigger_methods: + if trigger not in parent_children: + parent_children[trigger] = [] + if listener_name not in parent_children[trigger]: + parent_children[trigger].append(listener_name) + + for router_method_name, paths in flow._router_paths.items(): + for path in paths: + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive( + condition_data, flow + ) + else: + continue + + if path in trigger_methods: + if router_method_name not in parent_children: + parent_children[router_method_name] = [] + if listener_name not in parent_children[router_method_name]: + parent_children[router_method_name].append(listener_name) + + return parent_children + + +def get_child_index( + parent: str, child: str, parent_children: dict[str, list[str]] +) -> int: + """ + Get the index of a child node in its parent's sorted children list. + + Parameters + ---------- + parent : str + The parent node name. + child : str + The child node name to find the index for. + parent_children : Dict[str, List[str]] + Dictionary mapping parents to their children lists. + + Returns + ------- + int + Zero-based index of the child in its parent's sorted children list. + """ + children = parent_children.get(parent, []) + children.sort() + return children.index(child) + + +def process_router_paths( + flow: Any, + current: str, + current_level: int, + levels: dict[str, int], + queue: deque[str], +) -> None: + """Handle the router connections for the current node.""" + if current in flow._routers: + paths = flow._router_paths.get(current, []) + for path in paths: + for listener_name, condition_data in flow._listeners.items(): + if isinstance(condition_data, tuple): + _condition_type, trigger_methods = condition_data + elif isinstance(condition_data, dict): + trigger_methods = _extract_all_methods_recursive( + condition_data, flow + ) + else: + continue + + if path in trigger_methods: + if ( + listener_name not in levels + or levels[listener_name] > current_level + 1 + ): + levels[listener_name] = current_level + 1 + queue.append(listener_name) + + +def is_flow_method_name(obj: Any) -> TypeIs[FlowMethodName]: + """Check if the object is a valid flow method name. + + Args: + obj: The object to check. + Returns: + True if the object is a valid flow method name, False otherwise. + """ + return isinstance(obj, str) + + +def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable[..., Any]]: + """Check if the object is a callable flow method. + + Args: + obj: The object to check. + + Returns: + True if the object is a callable, False otherwise. + """ + return callable(obj) and hasattr(obj, "__name__") + + +def is_flow_condition_list(obj: Any) -> TypeIs[FlowConditions]: + """Check if the object is a list of FlowCondition dictionaries. + + Args: + obj: The object to check. + + Returns: + True if the object is a list of FlowCondition dictionaries, False otherwise. + """ + if not isinstance(obj, list): + return False + + for item in obj: + if not (is_flow_method_name(item) or is_flow_condition_dict(item)): + return False + + return True + + +def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]: + """Check if the object is a simple flow condition tuple. + + Args: + obj: The object to check. + + Returns: + True if the object is a (condition_type, methods) tuple, False otherwise. + """ + return ( + isinstance(obj, tuple) + and len(obj) == 2 + and isinstance(obj[0], str) + and isinstance(obj[1], list) + ) + + +def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]: + """Check if the object is a flow method wrapper. + + Checks for attributes added by @start, @listen, or @router decorators. + + Args: + obj: The object to check. + + Returns: + True if the object is a FlowMethod subclass (StartMethod, ListenMethod, or RouterMethod). + """ + return ( + hasattr(obj, "__is_flow_method__") + or hasattr(obj, "__is_start_method__") + or hasattr(obj, "__trigger_methods__") + or hasattr(obj, "__is_router__") + ) + + +def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: + """Check if the object matches the FlowCondition structure. + + Args: + obj: The object to check. + + Returns: + True if the object is a valid FlowCondition dictionary, False otherwise. + """ + if not isinstance(obj, dict): + return False + + type_value = obj.get("type") + if type_value not in ("AND", "OR"): + return False + + if "conditions" in obj: + conditions = obj["conditions"] + if not isinstance(conditions, list): + return False + for cond in conditions: + if not ( + isinstance(cond, str) + or (isinstance(cond, dict) and is_flow_condition_dict(cond)) + ): + return False + + if "methods" in obj: + methods = obj["methods"] + if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)): + return False + + allowed_keys = {"type", "conditions", "methods"} + if not set(obj).issubset(allowed_keys): + return False + + return True + + +def _extract_all_methods_recursive( + condition: str | FlowCondition | dict[str, Any] | list[Any], + flow: Flow[Any] | None = None, +) -> list[FlowMethodName]: + """Extract ALL method names from a condition tree recursively. + + This function recursively extracts every method name from the entire + condition tree, regardless of nesting. Used for visualization and debugging. + + Note: Only extracts actual method names, not router output strings. + If flow is provided, it will filter out strings that are not in flow._methods. + + Args: + condition: Can be a string, dict, or list + flow: Optional flow instance to filter out non-method strings + + Returns: + List of all method names found in the condition tree + """ + if is_flow_method_name(condition): + if flow is not None: + if condition in flow._methods: + return [condition] + return [] + return [condition] + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + methods = [] + for sub_cond in normalized.get("conditions", []): + methods.extend(_extract_all_methods_recursive(sub_cond, flow)) + return methods + if isinstance(condition, list): + methods = [] + for item in condition: + methods.extend(_extract_all_methods_recursive(item, flow)) + return methods + return [] + + +def _normalize_condition( + condition: FlowConditions | FlowCondition | FlowMethodName, +) -> FlowCondition: + """Normalize a condition to standard format with 'conditions' key. + + Args: + condition: Can be a string (method name), dict (condition), or list + + Returns: + Normalized dict with 'type' and 'conditions' keys + """ + if is_flow_method_name(condition): + return {"type": OR_CONDITION, "conditions": [condition]} + if is_flow_condition_dict(condition): + if "conditions" in condition: + return condition + if "methods" in condition: + return {"type": condition["type"], "conditions": condition["methods"]} + return condition + if is_flow_condition_list(condition): + return {"type": OR_CONDITION, "conditions": condition} + + raise ValueError(f"Cannot normalize condition: {condition}") + + +def _extract_all_methods( + condition: str | FlowCondition | dict[str, Any] | list[Any], +) -> list[FlowMethodName]: + """Extract all method names from a condition (including nested). + + For AND conditions, this extracts methods that must ALL complete. + For OR conditions nested inside AND, we don't extract their methods + since only one branch of the OR needs to trigger, not all methods. + + This function is used for runtime execution logic, where we need to know + which methods must complete for AND conditions. For visualization purposes, + use _extract_all_methods_recursive() instead. + + Args: + condition: Can be a string, dict, or list + + Returns: + List of all method names in the condition tree that must complete + """ + if is_flow_method_name(condition): + return [condition] + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + cond_type = normalized.get("type", OR_CONDITION) + + if cond_type == AND_CONDITION: + return [ + sub_cond + for sub_cond in normalized.get("conditions", []) + if is_flow_method_name(sub_cond) + ] + return [] + if isinstance(condition, list): + methods = [] + for item in condition: + methods.extend(_extract_all_methods(item)) + return methods + return [] + + +def extract_flow_definition( + namespace: dict[str, Any], +) -> tuple[list[str], dict[str, Any], set[str], dict[str, Any]]: + """Extract the structural flow registries from a class namespace. + + Walks the decorated methods in ``namespace`` and returns the + ``(start_methods, listeners, routers, router_paths)`` registries that the + runtime metaclass attaches to a Flow class. This is the structural half of + what used to live inline in ``FlowMeta.__new__``. + """ + start_methods = [] + listeners = {} + router_paths = {} + routers = set() + + for attr_name, attr_value in namespace.items(): + if ( + hasattr(attr_value, "__is_flow_method__") + or hasattr(attr_value, "__is_start_method__") + or hasattr(attr_value, "__trigger_methods__") + or hasattr(attr_value, "__is_router__") + ): + if hasattr(attr_value, "__is_start_method__"): + start_methods.append(attr_name) + + if ( + hasattr(attr_value, "__trigger_methods__") + and attr_value.__trigger_methods__ is not None + ): + methods = attr_value.__trigger_methods__ + condition_type = getattr(attr_value, "__condition_type__", OR_CONDITION) + + if ( + hasattr(attr_value, "__trigger_condition__") + and attr_value.__trigger_condition__ is not None + ): + listeners[attr_name] = attr_value.__trigger_condition__ + else: + listeners[attr_name] = (condition_type, methods) + + if hasattr(attr_value, "__is_router__") and attr_value.__is_router__: + routers.add(attr_name) + # Explicit __router_paths__ set by @human_feedback(emit=[...]) takes priority over source analysis + if ( + hasattr(attr_value, "__router_paths__") + and attr_value.__router_paths__ + ): + router_paths[attr_name] = attr_value.__router_paths__ + else: + 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 ( + hasattr(attr_value, "__is_start_method__") + and hasattr(attr_value, "__is_router__") + 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__ + else: + possible_returns = get_possible_return_constants(attr_value) + if possible_returns: + router_paths[attr_name] = possible_returns + else: + router_paths[attr_name] = [] + + return start_methods, listeners, routers, router_paths diff --git a/lib/crewai/src/crewai/flow/runtime.py b/lib/crewai/src/crewai/flow/runtime.py new file mode 100644 index 000000000..65efb2900 --- /dev/null +++ b/lib/crewai/src/crewai/flow/runtime.py @@ -0,0 +1,3272 @@ +"""Flow runtime: the Flow execution engine, its metaclass, and state proxies. + +Holds the Flow class (kickoff/resume/listener dispatch), the FlowMeta +metaclass (Pydantic model construction; structural extraction is delegated to +``flow_definition.extract_flow_definition``), and the thread-safe state +proxies. The authoring decorators live in ``crewai.flow.dsl``. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import ( + Callable, + ItemsView, + Iterable, + Iterator, + KeysView, + Sequence, + ValuesView, +) +from concurrent.futures import Future, ThreadPoolExecutor +import contextvars +import copy +import enum +import inspect +import logging +import threading +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + ClassVar, + Generic, + Literal, + ParamSpec, + SupportsIndex, + TypeVar, + cast, + overload, +) +from uuid import uuid4 + +from opentelemetry import baggage +from opentelemetry.context import attach, detach +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PlainSerializer, + PrivateAttr, + SerializeAsAny, + ValidationError, +) +from pydantic._internal._model_construction import ModelMetaclass +from rich.console import Console +from rich.panel import Panel + +from crewai.events.base_events import reset_emission_counter +from crewai.events.event_bus import crewai_event_bus +from crewai.events.event_context import ( + get_current_parent_id, + reset_last_event_id, + restore_event_scope, + triggered_by_scope, +) +from crewai.events.listeners.tracing.trace_listener import ( + TraceCollectionListener, +) +from crewai.events.listeners.tracing.utils import ( + has_user_declined_tracing, + set_tracing_enabled, + should_enable_tracing, + should_suppress_tracing_messages, +) +from crewai.events.types.flow_events import ( + FlowCreatedEvent, + FlowFinishedEvent, + FlowPausedEvent, + FlowPlotEvent, + FlowStartedEvent, + MethodExecutionFailedEvent, + MethodExecutionFinishedEvent, + MethodExecutionPausedEvent, + 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_definition import ( + _extract_all_methods, + _extract_all_methods_recursive, + _normalize_condition, + extract_flow_definition, + is_flow_condition_dict, + is_flow_method, + is_flow_method_name, + is_simple_flow_condition, +) +from crewai.flow.flow_wrappers import ( + FlowCondition, + FlowMethod, + ListenMethod, + RouterMethod, + SimpleFlowCondition, + StartMethod, +) +from crewai.flow.human_feedback import HumanFeedbackResult +from crewai.flow.input_provider import InputProvider +from crewai.flow.persistence.base import FlowPersistence +from crewai.flow.types import ( + FlowExecutionData, + FlowMethodName, + InputHistoryEntry, + PendingListenerKey, +) +from crewai.memory.memory_scope import MemoryScope, MemorySlice, _ensure_memory_kind +from crewai.memory.unified_memory import Memory +from crewai.state.checkpoint_config import ( + CheckpointConfig, + _coerce_checkpoint, + apply_checkpoint, +) + + +if TYPE_CHECKING: + from crewai_files import FileInput + + from crewai.context import ExecutionContext + from crewai.flow.async_feedback.types import PendingFeedbackContext + from crewai.llms.base_llm import BaseLLM + +from crewai.flow.visualization import build_flow_structure, render_interactive +from crewai.types.streaming import CrewStreamingOutput, FlowStreamingOutput +from crewai.utilities.env import get_env_context +from crewai.utilities.streaming import ( + TaskInfo, + create_async_chunk_generator, + create_chunk_generator, + create_streaming_state, + register_cleanup, + signal_end, + signal_error, +) + + +logger = logging.getLogger(__name__) + + +def _resolve_persistence(value: Any) -> Any: + if value is None or isinstance(value, FlowPersistence): + return value + if isinstance(value, dict): + from crewai.flow.persistence.base import _persistence_registry + + type_name = value.get("persistence_type", "SQLiteFlowPersistence") + cls = _persistence_registry.get(type_name) + if cls is not None: + return cls.model_validate(value) + return value + + +def _serialize_persistence(value: Any) -> dict[str, Any] | None: + if value is None: + return None + if isinstance(value, FlowPersistence): + return value.model_dump(mode="json") + raise TypeError( + f"Cannot serialize Flow.persistence of type {type(value).__name__}: " + "expected FlowPersistence or None." + ) + + +def _validate_input_provider(value: Any) -> Any: + if value is None or isinstance(value, InputProvider): + return value + from crewai.types.callback import _dotted_path_to_instance + + resolved = _dotted_path_to_instance(value) + if resolved is None or isinstance(resolved, InputProvider): + return resolved + raise ValueError( + f"Resolved input_provider {resolved!r} does not implement the " + "InputProvider protocol (missing request_input)." + ) + + +def _serialize_input_provider(value: Any) -> str | None: + if value is None: + return None + from crewai.types.callback import _instance_to_dotted_path + + return _instance_to_dotted_path(value) + + +_INITIAL_STATE_CLASS_MARKER = "__crewai_pydantic_class_schema__" + + +def _serialize_initial_state(value: Any) -> Any: + """Make ``initial_state`` safe for JSON checkpoint serialization. + + ``BaseModel`` class refs are emitted as their JSON schema under a sentinel + marker key so deserialization can round-trip them back to a class. + ``BaseModel`` instances are dumped to JSON (round-trip as plain dicts, + which ``_create_initial_state`` accepts). Bare ``type`` values that are + not ``BaseModel`` subclasses (e.g. ``dict``) are dropped since they + can't be represented in JSON. + """ + if isinstance(value, type): + if issubclass(value, BaseModel): + return {_INITIAL_STATE_CLASS_MARKER: value.model_json_schema()} + return None + if isinstance(value, BaseModel): + return value.model_dump(mode="json") + return value + + +def _deserialize_initial_state(value: Any) -> Any: + """Rehydrate a class ref serialized by :func:`_serialize_initial_state`.""" + if isinstance(value, dict) and _INITIAL_STATE_CLASS_MARKER in value: + from crewai.utilities.pydantic_schema_utils import create_model_from_schema + + return create_model_from_schema(value[_INITIAL_STATE_CLASS_MARKER]) + return value + + +class FlowState(BaseModel): + """Base model for all flow states, ensuring each state has a unique ID.""" + + id: str = Field( + default_factory=lambda: str(uuid4()), + description="Unique identifier for the flow state", + ) + + +T = TypeVar("T", bound=dict[str, Any] | BaseModel) +P = ParamSpec("P") +R = TypeVar("R") +F = TypeVar("F", bound=Callable[..., Any]) + + +class LockedListProxy(list, Generic[T]): # type: ignore[type-arg] + """Thread-safe proxy for list operations. + + Subclasses ``list`` so that ``isinstance(proxy, list)`` returns True, + which is required by libraries like LanceDB and Pydantic that do strict + type checks. All mutations go through the lock; reads delegate to the + underlying list. + """ + + def __init__(self, lst: list[T], lock: threading.Lock) -> None: + super().__init__() # empty builtin list; all access goes through self._list + self._list = lst + self._lock = lock + + def append(self, item: T) -> None: + with self._lock: + self._list.append(item) + + def extend(self, items: Iterable[T]) -> None: + with self._lock: + self._list.extend(items) + + def insert(self, index: SupportsIndex, item: T) -> None: + with self._lock: + self._list.insert(index, item) + + def remove(self, item: T) -> None: + with self._lock: + self._list.remove(item) + + def pop(self, index: SupportsIndex = -1) -> T: + with self._lock: + return self._list.pop(index) + + def clear(self) -> None: + with self._lock: + self._list.clear() + + @overload + def __setitem__(self, index: SupportsIndex, value: T) -> None: ... + @overload + def __setitem__(self, index: slice, value: Iterable[T]) -> None: ... + def __setitem__(self, index: Any, value: Any) -> None: + with self._lock: + self._list[index] = value + + def __delitem__(self, index: SupportsIndex | slice) -> None: + with self._lock: + del self._list[index] + + @overload + def __getitem__(self, index: SupportsIndex) -> T: ... + @overload + def __getitem__(self, index: slice) -> list[T]: ... + def __getitem__(self, index: Any) -> Any: + return self._list[index] + + def __len__(self) -> int: + return len(self._list) + + def __iter__(self) -> Iterator[T]: + return iter(self._list) + + def __contains__(self, item: object) -> bool: + return item in self._list + + def __repr__(self) -> str: + return repr(self._list) + + def __bool__(self) -> bool: + return bool(self._list) + + def index( + self, value: T, start: SupportsIndex = 0, stop: SupportsIndex | None = None + ) -> int: + if stop is None: + return self._list.index(value, start) + return self._list.index(value, start, stop) + + def count(self, value: T) -> int: + return self._list.count(value) + + def sort(self, *, key: Any = None, reverse: bool = False) -> None: + with self._lock: + self._list.sort(key=key, reverse=reverse) + + def reverse(self) -> None: + with self._lock: + self._list.reverse() + + def copy(self) -> list[T]: + return self._list.copy() + + def __add__(self, other: list[T]) -> list[T]: # type: ignore[override] + return self._list + other + + def __radd__(self, other: list[T]) -> list[T]: + return other + self._list + + def __iadd__(self, other: Iterable[T]) -> LockedListProxy[T]: # type: ignore[override] + with self._lock: + self._list += list(other) + return self + + def __mul__(self, n: SupportsIndex) -> list[T]: + return self._list * n + + def __rmul__(self, n: SupportsIndex) -> list[T]: + return self._list * n + + def __imul__(self, n: SupportsIndex) -> LockedListProxy[T]: + with self._lock: + self._list *= n + return self + + def __reversed__(self) -> Iterator[T]: + return reversed(self._list) + + def __eq__(self, other: object) -> bool: + """Compare based on the underlying list contents.""" + if isinstance(other, LockedListProxy): + # Avoid deadlocks by acquiring locks in a consistent order. + first, second = (self, other) if id(self) <= id(other) else (other, self) + with first._lock: + with second._lock: + return first._list == second._list + with self._lock: + return self._list == other + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + +class LockedDictProxy(dict, Generic[T]): # type: ignore[type-arg] + """Thread-safe proxy for dict operations. + + Subclasses ``dict`` so that ``isinstance(proxy, dict)`` returns True, + which is required by libraries like Pydantic that do strict type checks. + All mutations go through the lock; reads delegate to the underlying dict. + """ + + def __init__(self, d: dict[str, T], lock: threading.Lock) -> None: + super().__init__() # empty builtin dict; all access goes through self._dict + self._dict = d + self._lock = lock + + def __setitem__(self, key: str, value: T) -> None: + with self._lock: + self._dict[key] = value + + def __delitem__(self, key: str) -> None: + with self._lock: + del self._dict[key] + + def pop(self, key: str, *default: T) -> T: # type: ignore[override] + with self._lock: + return self._dict.pop(key, *default) + + def update(self, other: dict[str, T]) -> None: # type: ignore[override] + with self._lock: + self._dict.update(other) + + def clear(self) -> None: + with self._lock: + self._dict.clear() + + def setdefault(self, key: str, default: T) -> T: # type: ignore[override] + with self._lock: + return self._dict.setdefault(key, default) + + def __getitem__(self, key: str) -> T: + return self._dict[key] + + def __len__(self) -> int: + return len(self._dict) + + def __iter__(self) -> Iterator[str]: + return iter(self._dict) + + def __contains__(self, key: object) -> bool: + return key in self._dict + + def keys(self) -> KeysView[str]: # type: ignore[override] + return self._dict.keys() + + def values(self) -> ValuesView[T]: # type: ignore[override] + return self._dict.values() + + def items(self) -> ItemsView[str, T]: # type: ignore[override] + return self._dict.items() + + def get(self, key: str, default: T | None = None) -> T | None: # type: ignore[override] + return self._dict.get(key, default) + + def __repr__(self) -> str: + return repr(self._dict) + + def __bool__(self) -> bool: + return bool(self._dict) + + def copy(self) -> dict[str, T]: + return self._dict.copy() + + def __or__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override] + return self._dict | other + + def __ror__(self, other: dict[str, T]) -> dict[str, T]: # type: ignore[override] + return other | self._dict + + def __ior__(self, other: dict[str, T]) -> LockedDictProxy[T]: # type: ignore[override] + with self._lock: + self._dict |= other + return self + + def __reversed__(self) -> Iterator[str]: + return reversed(self._dict) + + def __eq__(self, other: object) -> bool: + """Compare based on the underlying dict contents.""" + if isinstance(other, LockedDictProxy): + # Avoid deadlocks by acquiring locks in a consistent order. + first, second = (self, other) if id(self) <= id(other) else (other, self) + with first._lock: + with second._lock: + return first._dict == second._dict + with self._lock: + return self._dict == other + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + +class StateProxy(Generic[T]): + """Proxy that provides thread-safe access to flow state. + + Wraps state objects (dict or BaseModel) and uses a lock for all write + operations to prevent race conditions when parallel listeners modify state. + """ + + __slots__ = ("_proxy_lock", "_proxy_state") + + def __init__(self, state: T, lock: threading.Lock) -> None: + object.__setattr__(self, "_proxy_state", state) + object.__setattr__(self, "_proxy_lock", lock) + + def __getattr__(self, name: str) -> Any: + value = getattr(object.__getattribute__(self, "_proxy_state"), name) + lock = object.__getattribute__(self, "_proxy_lock") + if isinstance(value, list): + return LockedListProxy(value, lock) + if isinstance(value, dict): + return LockedDictProxy(value, lock) + return value + + def __setattr__(self, name: str, value: Any) -> None: + if name in ("_proxy_state", "_proxy_lock"): + object.__setattr__(self, name, value) + else: + if isinstance(value, LockedListProxy): + value = value._list + elif isinstance(value, LockedDictProxy): + value = value._dict + with object.__getattribute__(self, "_proxy_lock"): + setattr(object.__getattribute__(self, "_proxy_state"), name, value) + + def __getitem__(self, key: str) -> Any: + return object.__getattribute__(self, "_proxy_state")[key] + + def __setitem__(self, key: str, value: Any) -> None: + with object.__getattribute__(self, "_proxy_lock"): + object.__getattribute__(self, "_proxy_state")[key] = value + + def __delitem__(self, key: str) -> None: + with object.__getattribute__(self, "_proxy_lock"): + del object.__getattribute__(self, "_proxy_state")[key] + + def __contains__(self, key: str) -> bool: + return key in object.__getattribute__(self, "_proxy_state") + + def __repr__(self) -> str: + return repr(object.__getattribute__(self, "_proxy_state")) + + def _unwrap(self) -> T: + """Return the underlying state object.""" + return cast(T, object.__getattribute__(self, "_proxy_state")) + + def model_dump(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + """Return state as a dictionary. + + Works for both dict and BaseModel underlying states. + """ + state = object.__getattribute__(self, "_proxy_state") + if isinstance(state, dict): + return state + result: dict[str, Any] = state.model_dump(*args, **kwargs) + return result + + +class FlowMeta(ModelMetaclass): + def __new__( + mcs, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + **kwargs: Any, + ) -> type: + parent_fields: set[str] = set() + for base in bases: + if hasattr(base, "model_fields"): + parent_fields.update(base.model_fields) + + annotations = namespace.get("__annotations__", {}) + _skip_types = (classmethod, staticmethod, property) + + for base in bases: + if isinstance(base, ModelMetaclass): + continue + for attr_name in getattr(base, "__annotations__", {}): + if attr_name not in annotations and attr_name not in namespace: + annotations[attr_name] = ClassVar + + for attr_name, attr_value in namespace.items(): + if isinstance(attr_value, property) and attr_name not in annotations: + for base in bases: + base_ann = getattr(base, "__annotations__", {}) + if attr_name in base_ann: + annotations[attr_name] = ClassVar + + for attr_name, attr_value in list(namespace.items()): + if attr_name in annotations or attr_name.startswith("_"): + continue + if attr_name in parent_fields: + annotations[attr_name] = Any + if isinstance(attr_value, BaseModel): + namespace[attr_name] = Field( + default_factory=lambda v=attr_value: v, exclude=True + ) + continue + if callable(attr_value) or isinstance( + attr_value, (*_skip_types, FlowMethod) + ): + continue + annotations[attr_name] = ClassVar[type(attr_value)] + namespace["__annotations__"] = annotations + + cls = super().__new__(mcs, name, bases, namespace) + + start_methods, listeners, routers, router_paths = extract_flow_definition( + namespace + ) + + cls._start_methods = start_methods # type: ignore[attr-defined] + cls._listeners = listeners # type: ignore[attr-defined] + cls._routers = routers # type: ignore[attr-defined] + cls._router_paths = router_paths # type: ignore[attr-defined] + + return cls + + +class Flow(BaseModel, Generic[T], metaclass=FlowMeta): + """Base class for all flows. + + type parameter T must be either dict[str, Any] or a subclass of BaseModel.""" + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ignored_types=(StartMethod, ListenMethod, RouterMethod), + revalidate_instances="never", + ) + __hash__ = object.__hash__ + + _start_methods: ClassVar[list[FlowMethodName]] = [] + _listeners: ClassVar[dict[FlowMethodName, SimpleFlowCondition | FlowCondition]] = {} + _routers: ClassVar[set[FlowMethodName]] = set() + _router_paths: ClassVar[dict[FlowMethodName, list[FlowMethodName]]] = {} + + entity_type: Literal["flow"] = "flow" + + initial_state: Annotated[ # type: ignore[type-arg] + type[BaseModel] | type[dict] | dict[str, Any] | BaseModel | None, + BeforeValidator(_deserialize_initial_state), + PlainSerializer(_serialize_initial_state, return_type=Any, when_used="json"), + ] = Field(default=None) + name: str | None = Field(default=None) + tracing: bool | None = Field(default=None) + stream: bool = Field(default=False) + memory: Annotated[ + Annotated[ + Memory | MemoryScope | MemorySlice, Field(discriminator="memory_kind") + ] + | None, + BeforeValidator(_ensure_memory_kind), + ] = Field(default=None) + input_provider: Annotated[ + InputProvider | None, + BeforeValidator(_validate_input_provider), + PlainSerializer( + _serialize_input_provider, return_type=str | None, when_used="json" + ), + ] = Field(default=None) + suppress_flow_events: bool = Field(default=False) + human_feedback_history: list[HumanFeedbackResult] = Field(default_factory=list) + last_human_feedback: HumanFeedbackResult | None = Field(default=None) + + persistence: Annotated[ + SerializeAsAny[FlowPersistence] | None, + BeforeValidator(lambda v, _: _resolve_persistence(v)), + PlainSerializer( + _serialize_persistence, return_type=dict | None, when_used="json" + ), + ] = Field(default=None) + max_method_calls: int = Field(default=100) + + execution_context: ExecutionContext | None = Field(default=None) + checkpoint: Annotated[ + CheckpointConfig | bool | None, + BeforeValidator(_coerce_checkpoint), + ] = Field(default=None) + + @classmethod + def from_checkpoint(cls, config: CheckpointConfig) -> Flow: # type: ignore[type-arg] + """Restore a Flow from a checkpoint. + + Args: + config: Checkpoint configuration with ``restore_from`` set to + the path of the checkpoint to load. + + Returns: + A Flow instance ready to resume. + """ + from crewai.context import apply_execution_context + from crewai.events.event_bus import crewai_event_bus + from crewai.state.runtime import RuntimeState + + state = RuntimeState.from_checkpoint(config, context={"from_checkpoint": True}) + crewai_event_bus.set_runtime_state(state) + for entity in state.root: + if not isinstance(entity, Flow): + continue + if entity.execution_context is not None: + apply_execution_context(entity.execution_context) + if isinstance(entity, cls): + entity._restore_from_checkpoint() + return entity + instance = cls() + instance.checkpoint_completed_methods = entity.checkpoint_completed_methods + instance.checkpoint_method_outputs = entity.checkpoint_method_outputs + instance.checkpoint_method_counts = entity.checkpoint_method_counts + instance.checkpoint_state = entity.checkpoint_state + instance._restore_from_checkpoint() + return instance + raise ValueError(f"No Flow found in checkpoint: {config.restore_from}") + + @classmethod + def fork( + cls, + config: CheckpointConfig, + branch: str | None = None, + ) -> Flow: # type: ignore[type-arg] + """Fork a Flow from a checkpoint, creating a new execution branch. + + Args: + config: Checkpoint configuration with ``restore_from`` set. + branch: Branch label for the fork. Auto-generated if not provided. + + Returns: + A Flow instance on the new branch. Call kickoff() to run. + """ + flow = cls.from_checkpoint(config) + state = crewai_event_bus.runtime_state + if state is None: + raise RuntimeError( + "Cannot fork: no runtime state on the event bus. " + "Ensure from_checkpoint() succeeded before calling fork()." + ) + state.fork(branch) + new_id = str(uuid4()) + if isinstance(flow._state, dict): + flow._state["id"] = new_id + else: + object.__setattr__(flow._state, "id", new_id) + return flow + + checkpoint_completed_methods: set[str] | None = Field(default=None) + checkpoint_method_outputs: list[Any] | None = Field(default=None) + checkpoint_method_counts: dict[str, int] | None = Field(default=None) + checkpoint_state: dict[str, Any] | None = Field(default=None) + + def _restore_from_checkpoint(self) -> None: + """Restore private execution state from checkpoint fields.""" + if self.checkpoint_completed_methods is not None: + self._completed_methods = { + FlowMethodName(m) for m in self.checkpoint_completed_methods + } + if self.checkpoint_method_outputs is not None: + self._method_outputs = list(self.checkpoint_method_outputs) + if self.checkpoint_method_counts is not None: + self._method_execution_counts = { + FlowMethodName(k): v for k, v in self.checkpoint_method_counts.items() + } + if self.checkpoint_state is not None: + self._restore_state(self.checkpoint_state) + if ( + isinstance(self.memory, MemoryScope | MemorySlice) + and self.memory._memory is None + ): + self.memory.bind(Memory()) + restore_event_scope(()) + reset_last_event_id() + + _methods: dict[FlowMethodName, FlowMethod[Any, Any]] = PrivateAttr( + default_factory=dict + ) + _method_execution_counts: dict[FlowMethodName, int] = PrivateAttr( + default_factory=dict + ) + _pending_and_listeners: dict[PendingListenerKey, set[FlowMethodName]] = PrivateAttr( + default_factory=dict + ) + _fired_or_listeners: set[FlowMethodName] = PrivateAttr(default_factory=set) + _method_outputs: list[Any] = PrivateAttr(default_factory=list) + _state_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) + _or_listeners_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock) + _completed_methods: set[FlowMethodName] = PrivateAttr(default_factory=set) + _method_call_counts: dict[FlowMethodName, int] = PrivateAttr(default_factory=dict) + _is_execution_resuming: bool = PrivateAttr(default=False) + _event_futures: list[Future[None]] = PrivateAttr(default_factory=list) + _pending_feedback_context: PendingFeedbackContext | None = PrivateAttr(default=None) + _human_feedback_method_outputs: dict[str, Any] = PrivateAttr(default_factory=dict) + _input_history: list[InputHistoryEntry] = PrivateAttr(default_factory=list) + _state: Any = PrivateAttr(default=None) + + def __class_getitem__(cls: type[Flow[T]], item: type[T]) -> type[Flow[T]]: # type: ignore[override] + class _FlowGeneric(cls): # type: ignore[valid-type,misc] + pass + + _FlowGeneric.__name__ = f"{cls.__name__}[{item.__name__}]" + _FlowGeneric._initial_state_t = item + return _FlowGeneric + + def __setattr__(self, name: str, value: Any) -> None: + """Allow arbitrary attribute assignment for backward compat with plain class.""" + if name in self.model_fields or name in self.__private_attributes__: + super().__setattr__(name, value) + else: + object.__setattr__(self, name, value) + + def model_post_init(self, __context: Any) -> None: + self._flow_post_init() + + def _flow_post_init(self) -> None: + """Heavy initialization: state creation, events, memory, method registration.""" + if getattr(self, "_flow_post_init_done", False): + return + object.__setattr__(self, "_flow_post_init_done", True) + + if self._state is None: + self._state = self._create_initial_state() + + tracing_enabled = should_enable_tracing(override=self.tracing) + set_tracing_enabled(tracing_enabled) + + trace_listener = TraceCollectionListener() + trace_listener.setup_listeners(crewai_event_bus) + + if not self.suppress_flow_events: + crewai_event_bus.emit( + self, + FlowCreatedEvent( + type="flow_created", + flow_name=self.name or self.__class__.__name__, + ), + ) + + # Auto-create memory if not provided at class or instance level. + # Internal flows (RecallFlow, EncodingFlow) set _skip_auto_memory + # to avoid creating a wasteful standalone Memory instance. + if self.memory is None and not getattr(self, "_skip_auto_memory", False): + from crewai.memory.utils import sanitize_scope_name + + flow_name = sanitize_scope_name(self.name or self.__class__.__name__) + self.memory = Memory(root_scope=f"/flow/{flow_name}") + + for method_name in dir(self): + if not method_name.startswith("_"): + method = getattr(self, method_name) + if is_flow_method(method): + if not hasattr(method, "__self__"): + method = method.__get__(self, self.__class__) + self._methods[method.__name__] = method + + def recall(self, query: str, **kwargs: Any) -> Any: + """Recall relevant memories. Delegates to this flow's memory. + + Args: + query: Natural language query. + **kwargs: Passed to memory.recall (e.g. scope, categories, limit, depth). + + Returns: + Result of memory.recall(query, **kwargs). + + Raises: + ValueError: If no memory is configured for this flow. + """ + if self.memory is None: + raise ValueError("No memory configured for this flow") + return self.memory.recall(query, **kwargs) + + def remember(self, content: str | list[str], **kwargs: Any) -> Any: + """Store one or more items in memory. + + Pass a single string for synchronous save (returns the MemoryRecord). + Pass a list of strings for non-blocking batch save (returns immediately). + + Args: + content: Text or list of texts to remember. + **kwargs: Passed to memory.remember / remember_many + (e.g. scope, categories, metadata, importance). + + Returns: + MemoryRecord for single item, empty list for batch (background save). + + Raises: + ValueError: If no memory is configured for this flow. + TypeError: If batch remember is attempted on a MemoryScope or MemorySlice. + """ + if self.memory is None: + raise ValueError("No memory configured for this flow") + if isinstance(content, list): + if not isinstance(self.memory, Memory): + raise TypeError( + "Batch remember requires a Memory instance, " + f"got {type(self.memory).__name__}" + ) + return self.memory.remember_many(content, **kwargs) + return self.memory.remember(content, **kwargs) + + def extract_memories(self, content: str) -> list[str]: + """Extract discrete memories from content. Delegates to this flow's memory. + + Args: + content: Raw text (e.g. task + result dump). + + Returns: + List of short, self-contained memory statements. + + Raises: + ValueError: If no memory is configured for this flow. + """ + if self.memory is None: + raise ValueError("No memory configured for this flow") + result: list[str] = self.memory.extract_memories(content) + return result + + def _mark_or_listener_fired(self, listener_name: FlowMethodName) -> bool: + """Mark an OR listener as fired atomically. + + Args: + listener_name: The name of the OR listener to mark. + + Returns: + True if this call was the first to fire the listener. + False if the listener was already fired. + """ + with self._or_listeners_lock: + if listener_name in self._fired_or_listeners: + return False + self._fired_or_listeners.add(listener_name) + return True + + def _clear_or_listeners(self) -> None: + """Clear fired OR listeners for cyclic flows.""" + with self._or_listeners_lock: + self._fired_or_listeners.clear() + + def _discard_or_listener(self, listener_name: FlowMethodName) -> None: + """Discard a single OR listener from the fired set.""" + with self._or_listeners_lock: + self._fired_or_listeners.discard(listener_name) + + def _build_racing_groups(self) -> dict[frozenset[FlowMethodName], FlowMethodName]: + """Identify groups of methods that race for the same OR listener. + + Analyzes the flow graph to find listeners with OR conditions that have + multiple trigger methods. These trigger methods form a "racing group" + where only the first to complete should trigger the OR listener. + + Only methods that are EXCLUSIVELY sources for the OR listener are included + in the racing group. Methods that are also triggers for other listeners + (e.g., AND conditions) are not cancelled when another racing source wins. + + Returns: + Dictionary mapping frozensets of racing method names to their + shared OR listener name. + + Example: + If we have `@listen(or_(method_a, method_b))` on `handler`, + and method_a/method_b aren't used elsewhere, + this returns: {frozenset({'method_a', 'method_b'}): 'handler'} + """ + racing_groups: dict[frozenset[FlowMethodName], FlowMethodName] = {} + + method_to_listeners: dict[FlowMethodName, set[FlowMethodName]] = {} + for listener_name, condition_data in self._listeners.items(): + if is_simple_flow_condition(condition_data): + _, methods = condition_data + for m in methods: + method_to_listeners.setdefault(m, set()).add(listener_name) + elif is_flow_condition_dict(condition_data): + all_methods = _extract_all_methods_recursive(condition_data) + for m in all_methods: + method_name = FlowMethodName(m) if isinstance(m, str) else m + method_to_listeners.setdefault(method_name, set()).add( + listener_name + ) + + for listener_name, condition_data in self._listeners.items(): + if listener_name in self._routers: + continue + + trigger_methods: set[FlowMethodName] = set() + + if is_simple_flow_condition(condition_data): + condition_type, methods = condition_data + if condition_type == OR_CONDITION and len(methods) > 1: + trigger_methods = set(methods) + + elif is_flow_condition_dict(condition_data): + top_level_type = condition_data.get("type", OR_CONDITION) + if top_level_type == OR_CONDITION: + all_methods = _extract_all_methods_recursive(condition_data) + if len(all_methods) > 1: + trigger_methods = set( + FlowMethodName(m) if isinstance(m, str) else m + for m in all_methods + ) + + if trigger_methods: + exclusive_methods = { + m + for m in trigger_methods + if method_to_listeners.get(m, set()) == {listener_name} + } + if len(exclusive_methods) > 1: + racing_groups[frozenset(exclusive_methods)] = listener_name + + return racing_groups + + def _get_racing_group_for_listeners( + self, + listener_names: list[FlowMethodName], + ) -> tuple[frozenset[FlowMethodName], FlowMethodName] | None: + """Check if the given listeners form a racing group. + + Args: + listener_names: List of listener method names being executed. + + Returns: + Tuple of (racing_members, or_listener_name) if these listeners race, + None otherwise. + """ + if not hasattr(self, "_racing_groups_cache"): + self._racing_groups_cache = self._build_racing_groups() + + listener_set = set(listener_names) + + for racing_members, or_listener in self._racing_groups_cache.items(): + if racing_members & listener_set: + racing_subset = racing_members & listener_set + if len(racing_subset) > 1: + return (frozenset(racing_subset), or_listener) + + return None + + async def _execute_racing_listeners( + self, + racing_listeners: frozenset[FlowMethodName], + other_listeners: list[FlowMethodName], + result: Any, + triggering_event_id: str | None = None, + ) -> None: + """Execute racing listeners with first-wins semantics. + + Racing listeners are executed in parallel, but once the first one + completes, the others are cancelled. Non-racing listeners in the + same batch are executed normally in parallel. + + Args: + racing_listeners: Set of listener names that race for an OR condition. + other_listeners: Other listeners to execute in parallel (not racing). + result: The result from the triggering method. + triggering_event_id: The event_id of the event that triggered these listeners. + """ + racing_tasks = [ + asyncio.create_task( + self._execute_single_listener(name, result, triggering_event_id), + name=str(name), + ) + for name in racing_listeners + ] + + other_tasks = [ + asyncio.create_task( + self._execute_single_listener(name, result, triggering_event_id), + name=str(name), + ) + for name in other_listeners + ] + + if racing_tasks: + for coro in asyncio.as_completed(racing_tasks): + try: + await coro + except Exception as e: + logger.debug(f"Racing listener failed: {e}") + continue + break + + for task in racing_tasks: + if not task.done(): + task.cancel() + + if other_tasks: + await asyncio.gather(*other_tasks, return_exceptions=True) + + @classmethod + def from_pending( + cls, + flow_id: str, + persistence: FlowPersistence | None = None, + **kwargs: Any, + ) -> Flow[Any]: + """Create a Flow instance from a pending feedback state. + + This classmethod is used to restore a flow that was paused waiting + for async human feedback. It loads the persisted state and pending + feedback context, then returns a flow instance ready to resume. + + Args: + flow_id: The unique identifier of the paused flow (from state.id) + persistence: The persistence backend where the state was saved. + If not provided, defaults to SQLiteFlowPersistence(). + **kwargs: Additional keyword arguments passed to the Flow constructor + + Returns: + A new Flow instance with restored state, ready to call resume() + + Raises: + ValueError: If no pending feedback exists for the given flow_id + + Example: + ```python + # Simple usage with default persistence: + flow = MyFlow.from_pending("abc-123") + result = flow.resume("looks good!") + + # Or with custom persistence: + persistence = SQLiteFlowPersistence("custom.db") + flow = MyFlow.from_pending("abc-123", persistence) + result = flow.resume("looks good!") + ``` + """ + if persistence is None: + from crewai.flow.persistence import SQLiteFlowPersistence + + persistence = SQLiteFlowPersistence() + + loaded = persistence.load_pending_feedback(flow_id) + if loaded is None: + raise ValueError(f"No pending feedback found for flow_id: {flow_id}") + + state_data, pending_context = loaded + + instance = cls(persistence=persistence, **kwargs) + instance._initialize_state(state_data) + instance._pending_feedback_context = pending_context + instance._is_execution_resuming = True + + return instance + + @property + def pending_feedback(self) -> PendingFeedbackContext | None: + """Get the pending feedback context if this flow is waiting for feedback. + + Returns: + The PendingFeedbackContext if the flow is paused waiting for feedback, + None otherwise. + + Example: + ```python + flow = MyFlow.from_pending("abc-123", persistence) + if flow.pending_feedback: + print(f"Waiting for feedback on: {flow.pending_feedback.method_name}") + ``` + """ + return self._pending_feedback_context + + def resume(self, feedback: str = "") -> Any: + """Resume flow execution, optionally with human feedback. + + This method continues flow execution after a flow was paused for + async human feedback. It processes the feedback (including LLM-based + outcome collapsing if emit was specified), stores the result, and + triggers downstream listeners. + + Note: + If called from within an async context (running event loop), + use `await flow.resume_async(feedback)` instead. + + Args: + feedback: The human's feedback as a string. If empty, uses + default_outcome or the first emit option. + + Returns: + The final output from the flow execution, or HumanFeedbackPending + if another feedback point is reached. + + Raises: + ValueError: If no pending feedback context exists (flow wasn't paused) + RuntimeError: If called from within a running event loop (use resume_async instead) + + Example: + ```python + # In a sync webhook handler: + def handle_feedback(flow_id: str, feedback: str): + flow = MyFlow.from_pending(flow_id) + result = flow.resume(feedback) + return result + + + # In an async handler, use resume_async instead: + async def handle_feedback_async(flow_id: str, feedback: str): + flow = MyFlow.from_pending(flow_id) + result = await flow.resume_async(feedback) + return result + ``` + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is not None: + raise RuntimeError( + "resume() cannot be called from within an async context. " + "Use 'await flow.resume_async(feedback)' instead." + ) + + return asyncio.run(self.resume_async(feedback)) + + async def resume_async(self, feedback: str = "") -> Any: + """Async version of resume. + + Resume flow execution, optionally with human feedback asynchronously. + + Args: + feedback: The human's feedback as a string. If empty, uses + default_outcome or the first emit option. + + Returns: + The final output from the flow execution, or HumanFeedbackPending + if another feedback point is reached. + + Raises: + ValueError: If no pending feedback context exists + """ + from datetime import datetime + + from crewai.flow.human_feedback import HumanFeedbackResult + + if self._pending_feedback_context is None: + raise ValueError( + "No pending feedback context. Use from_pending() to restore a paused flow." + ) + + if get_current_parent_id() is None: + reset_emission_counter() + reset_last_event_id() + + if not self.suppress_flow_events: + future = crewai_event_bus.emit( + self, + FlowStartedEvent( + type="flow_started", + flow_name=self.name or self.__class__.__name__, + inputs=None, + ), + ) + if future and isinstance(future, Future): + try: + await asyncio.wrap_future(future) + except Exception: + logger.warning("FlowStartedEvent handler failed", exc_info=True) + + get_env_context() + + context = self._pending_feedback_context + emit = context.emit + default_outcome = context.default_outcome + + # Try to get the live LLM from the re-imported decorator first. + # This preserves the fully-configured object (credentials, safety_settings, etc.) + # for same-process resume. For cross-process resume, fall back to the + # serialized context.llm which is now a dict with full config (or a legacy string). + from crewai.flow.human_feedback import _deserialize_llm_from_context + + llm = None + method = self._methods.get(FlowMethodName(context.method_name)) + if method is not None: + live_llm = getattr(method, "_hf_llm", None) + if live_llm is not None: + from crewai.llms.base_llm import BaseLLM as BaseLLMClass + + if isinstance(live_llm, BaseLLMClass): + llm = live_llm + + if llm is None: + llm = _deserialize_llm_from_context(context.llm) + + collapsed_outcome: str | None = None + + if not feedback.strip(): + if default_outcome: + collapsed_outcome = default_outcome + elif emit: + collapsed_outcome = emit[0] + elif emit: + if llm is not None: + collapsed_outcome = self._collapse_to_outcome( + feedback=feedback, + outcomes=emit, + llm=llm, + ) + else: + collapsed_outcome = emit[0] + + result = HumanFeedbackResult( + output=context.method_output, + feedback=feedback, + outcome=collapsed_outcome, + timestamp=datetime.now(), + method_name=context.method_name, + metadata=context.metadata, + ) + + self.human_feedback_history.append(result) + self.last_human_feedback = result + + self._completed_methods.add(FlowMethodName(context.method_name)) + + self._pending_feedback_context = None + + if self.persistence: + self.persistence.clear_pending_feedback(context.flow_id) + + crewai_event_bus.emit( + self, + MethodExecutionFinishedEvent( + type="method_execution_finished", + flow_name=self.name or self.__class__.__name__, + method_name=context.method_name, + result=collapsed_outcome if emit else result, + state=self._state, + ), + ) + + # Clear resumption flag before triggering listeners + # This allows methods to re-execute in loops (e.g., implement_changes → suggest_changes → implement_changes) + self._is_execution_resuming = False + + if emit and collapsed_outcome is None: + collapsed_outcome = default_outcome or emit[0] + result.outcome = collapsed_outcome + + try: + if emit and collapsed_outcome: + self._method_outputs.append(collapsed_outcome) + await self._execute_listeners( + FlowMethodName(collapsed_outcome), + result, + ) + else: + await self._execute_listeners( + FlowMethodName(context.method_name), + result, + ) + except Exception as e: + # Check if flow was paused again for human feedback (loop case) + from crewai.flow.async_feedback.types import HumanFeedbackPending + + if isinstance(e, HumanFeedbackPending): + self._pending_feedback_context = e.context + + if self.persistence is None: + from crewai.flow.persistence import SQLiteFlowPersistence + + self.persistence = SQLiteFlowPersistence() + + state_data = ( + self._state + if isinstance(self._state, dict) + else self._state.model_dump() + ) + self.persistence.save_pending_feedback( + flow_uuid=e.context.flow_id, + context=e.context, + state_data=state_data, + ) + + crewai_event_bus.emit( + self, + FlowPausedEvent( + type="flow_paused", + flow_name=self.name or self.__class__.__name__, + flow_id=e.context.flow_id, + method_name=e.context.method_name, + state=self._copy_and_serialize_state(), + message=e.context.message, + emit=e.context.emit, + ), + ) + return e + raise + + final_result = self._method_outputs[-1] if self._method_outputs else result + + if self._event_futures: + await asyncio.gather( + *[ + asyncio.wrap_future(f) + for f in self._event_futures + if isinstance(f, Future) + ] + ) + self._event_futures.clear() + + if not self.suppress_flow_events: + future = crewai_event_bus.emit( + self, + FlowFinishedEvent( + type="flow_finished", + flow_name=self.name or self.__class__.__name__, + result=final_result, + state=self._copy_and_serialize_state(), + ), + ) + if future and isinstance(future, Future): + try: + await asyncio.wrap_future(future) + except Exception: + logger.warning("FlowFinishedEvent handler failed", exc_info=True) + + trace_listener = TraceCollectionListener() + if trace_listener.batch_manager.batch_owner_type == "flow": + if trace_listener.first_time_handler.is_first_time: + trace_listener.first_time_handler.mark_events_collected() + trace_listener.first_time_handler.handle_execution_completion() + else: + trace_listener.batch_manager.finalize_batch() + + return final_result + + def _create_initial_state(self) -> T: + """Create and initialize flow state with UUID and default values. + + Returns: + New state instance with UUID and default values initialized + + Raises: + ValueError: If structured state model lacks 'id' field + TypeError: If state is neither BaseModel nor dictionary + """ + init_state = self.initial_state + + if init_state is None and hasattr(self, "_initial_state_t"): + state_type = self._initial_state_t + if isinstance(state_type, type): + if issubclass(state_type, FlowState): + instance = state_type() + if not getattr(instance, "id", None): + object.__setattr__(instance, "id", str(uuid4())) + return cast(T, instance) + if issubclass(state_type, BaseModel): + + class StateWithId(FlowState, state_type): # type: ignore + pass + + instance = StateWithId() + if not getattr(instance, "id", None): + object.__setattr__(instance, "id", str(uuid4())) + return cast(T, instance) + if state_type is dict: + return cast(T, {"id": str(uuid4())}) + + if init_state is None: + return cast(T, {"id": str(uuid4())}) + + if isinstance(init_state, type): + state_class = init_state + if issubclass(state_class, FlowState): + return cast(T, state_class()) + if issubclass(state_class, BaseModel): + model_fields = getattr(state_class, "model_fields", None) + if not model_fields or "id" not in model_fields: + raise ValueError("Flow state model must have an 'id' field") + model_instance = state_class() + if not getattr(model_instance, "id", None): + object.__setattr__(model_instance, "id", str(uuid4())) + return cast(T, model_instance) + if init_state is dict: + return cast(T, {"id": str(uuid4())}) + + if isinstance(init_state, dict): + new_state = dict(init_state) # Copy to avoid mutations + if "id" not in new_state: + new_state["id"] = str(uuid4()) + return cast(T, new_state) + + if isinstance(init_state, BaseModel): + model = init_state + if hasattr(model, "id"): + state_dict = model.model_dump() + if not state_dict.get("id"): + state_dict["id"] = str(uuid4()) + model_class = type(model) + return cast(T, model_class(**state_dict)) + + class StateWithId(FlowState, type(model)): # type: ignore + pass + + state_dict = model.model_dump() + state_dict["id"] = str(uuid4()) + return cast(T, StateWithId(**state_dict)) + raise TypeError( + f"Initial state must be dict or BaseModel, got {type(self.initial_state)}" + ) + + def _copy_state(self) -> T: + """Create a copy of the current state. + + Returns: + A copy of the current state + """ + if isinstance(self._state, BaseModel): + try: + return cast(T, self._state.model_copy(deep=True)) + except (TypeError, AttributeError): + try: + state_dict = self._state.model_dump() + model_class = type(self._state) + return cast(T, model_class(**state_dict)) + except Exception: + return cast(T, self._state.model_copy(deep=False)) + else: + try: + return cast(T, copy.deepcopy(self._state)) + except (TypeError, AttributeError): + return cast(T, self._state.copy()) + + @property + def state(self) -> T: + return StateProxy(self._state, self._state_lock) # type: ignore[return-value] + + @property + def method_outputs(self) -> list[Any]: + """Returns the list of all outputs from executed methods.""" + return self._method_outputs + + @property + def flow_id(self) -> str: + """Returns the unique identifier of this flow instance. + + This property provides a consistent way to access the flow's unique identifier + regardless of the underlying state implementation (dict or BaseModel). + + Returns: + str: The flow's unique identifier, or an empty string if not found + + Note: + This property safely handles both dictionary and BaseModel state types, + returning an empty string if the ID cannot be retrieved rather than raising + an exception. + + Example: + ```python + flow = MyFlow() + print(f"Current flow ID: {flow.flow_id}") # Safely get flow ID + ``` + """ + try: + if not hasattr(self, "_state"): + return "" + + if isinstance(self._state, dict): + return str(self._state.get("id", "")) + if isinstance(self._state, BaseModel): + return str(getattr(self._state, "id", "")) + return "" + except (AttributeError, TypeError): + return "" # Safely handle any unexpected attribute access issues + + def _initialize_state(self, inputs: dict[str, Any]) -> None: + """Initialize or update flow state with new inputs. + + Args: + inputs: Dictionary of state values to set/update + + Raises: + ValueError: If validation fails for structured state + TypeError: If state is neither BaseModel nor dictionary + """ + if isinstance(self._state, dict): + # If inputs contains an id, use it (for restoring from persistence); + # otherwise preserve the current id or generate a new one. + current_id = self._state.get("id") + inputs_has_id = "id" in inputs + + for k, v in inputs.items(): + self._state[k] = v + + if not inputs_has_id: + if current_id: + self._state["id"] = current_id + elif "id" not in self._state: + self._state["id"] = str(uuid4()) + elif isinstance(self._state, BaseModel): + try: + model = self._state + if hasattr(model, "model_dump"): + current_state = model.model_dump() + elif hasattr(model, "dict"): + current_state = model.dict() + else: + current_state = { + k: v for k, v in model.__dict__.items() if not k.startswith("_") + } + + new_state = {**current_state, **inputs} + + model_class = type(model) + if hasattr(model_class, "model_validate"): + self._state = cast(T, model_class.model_validate(new_state)) + elif hasattr(model_class, "parse_obj"): + self._state = cast(T, model_class.parse_obj(new_state)) + else: + self._state = cast(T, model_class(**new_state)) + except ValidationError as e: + raise ValueError(f"Invalid inputs for structured state: {e}") from e + else: + raise TypeError("State must be a BaseModel instance or a dictionary.") + + def _restore_state(self, stored_state: dict[str, Any]) -> None: + """Restore flow state from persistence. + + Args: + stored_state: Previously stored state to restore + + Raises: + ValueError: If validation fails for structured state + TypeError: If state is neither BaseModel nor dictionary + """ + stored_id = stored_state.get("id") + if not stored_id: + raise ValueError("Stored state must have an 'id' field") + + if isinstance(self._state, dict): + self._state.clear() + self._state.update(stored_state) + elif isinstance(self._state, BaseModel): + model = self._state + if hasattr(model, "model_validate"): + self._state = cast(T, type(model).model_validate(stored_state)) + elif hasattr(model, "parse_obj"): + self._state = cast(T, type(model).parse_obj(stored_state)) + else: + self._state = cast(T, type(model)(**stored_state)) + else: + raise TypeError(f"State must be dict or BaseModel, got {type(self._state)}") + + def reload(self, execution_data: FlowExecutionData) -> None: + """Reloads the flow from an execution data dict. + + This method restores the flow's execution ID, completed methods, and state, + allowing it to resume from where it left off. + + Args: + execution_data: Flow execution data containing: + - id: Flow execution ID + - flow: Flow structure + - completed_methods: list of successfully completed methods + - execution_methods: All execution methods with their status + """ + flow_id = execution_data.get("id") + if flow_id: + self._update_state_field("id", flow_id) + + self._completed_methods = { + cast(FlowMethodName, name) + for method_data in execution_data.get("completed_methods", []) + if (name := method_data.get("flow_method", {}).get("name")) is not None + } + + execution_methods = execution_data.get("execution_methods", []) + if not execution_methods: + return + + sorted_methods = sorted( + execution_methods, + key=lambda m: m.get("started_at", ""), + ) + + state_to_apply = None + for method in reversed(sorted_methods): + if method.get("final_state"): + state_to_apply = method["final_state"] + break + + if not state_to_apply and sorted_methods: + last_method = sorted_methods[-1] + if last_method.get("initial_state"): + state_to_apply = last_method["initial_state"] + + if state_to_apply: + self._apply_state_updates(state_to_apply) + + for method in sorted_methods[:-1]: + method_name = cast( + FlowMethodName | None, method.get("flow_method", {}).get("name") + ) + if method_name: + self._completed_methods.add(method_name) + + def _update_state_field(self, field_name: str, value: Any) -> None: + """Update a single field in the state.""" + if isinstance(self._state, dict): + self._state[field_name] = value + elif hasattr(self._state, field_name): + object.__setattr__(self._state, field_name, value) + + def _apply_state_updates(self, updates: dict[str, Any]) -> None: + """Apply multiple state updates efficiently.""" + if isinstance(self._state, dict): + self._state.update(updates) + elif hasattr(self._state, "__dict__"): + for key, value in updates.items(): + if hasattr(self._state, key): + object.__setattr__(self._state, key, value) + + def kickoff( + self, + inputs: dict[str, Any] | None = None, + input_files: dict[str, FileInput] | None = None, + from_checkpoint: CheckpointConfig | None = None, + restore_from_state_id: str | None = None, + ) -> Any | FlowStreamingOutput: + """Start the flow execution in a synchronous context. + + This method wraps kickoff_async so that all state initialization and event + emission is handled in the asynchronous method. + + Args: + inputs: Optional dictionary containing input values and/or a state ID. + input_files: Optional dict of named file inputs for the flow. + from_checkpoint: Optional checkpoint config. If ``restore_from`` + is set, the flow resumes from that checkpoint. + restore_from_state_id: Optional UUID of a previously-persisted flow + whose latest snapshot should hydrate this run's state. The new + run is assigned a fresh ``state.id`` (or ``inputs["id"]`` if + pinned), so its ``@persist`` writes land under a separate + persistence key and the source flow's history is preserved. + If the referenced state is not found, the kickoff falls back + silently to baseline behavior. Cannot be combined with + ``from_checkpoint``; passing both raises ``ValueError``. + + Returns: + The final output from the flow or FlowStreamingOutput if streaming. + """ + if from_checkpoint is not None and restore_from_state_id is not None: + raise ValueError( + "Cannot combine `from_checkpoint` and `restore_from_state_id`. " + "These parameters target different state systems " + "(Checkpointing and @persist) and cannot be used together." + ) + restored = apply_checkpoint(self, from_checkpoint) + if restored is not None: + return restored.kickoff(inputs=inputs, input_files=input_files) + if self.stream: + result_holder: list[Any] = [] + current_task_info: TaskInfo = { + "index": 0, + "name": "", + "id": "", + "agent_role": "", + "agent_id": "", + } + + state = create_streaming_state( + current_task_info, result_holder, use_async=False + ) + output_holder: list[CrewStreamingOutput | FlowStreamingOutput] = [] + + def run_flow() -> None: + try: + self.stream = False + result = self.kickoff( + inputs=inputs, + input_files=input_files, + restore_from_state_id=restore_from_state_id, + ) + result_holder.append(result) + except Exception as e: + # HumanFeedbackPending is expected control flow, not an error + from crewai.flow.async_feedback.types import HumanFeedbackPending + + if isinstance(e, HumanFeedbackPending): + result_holder.append(e) + else: + signal_error(state, e) + finally: + self.stream = True + signal_end(state) + + streaming_output = FlowStreamingOutput( + sync_iterator=create_chunk_generator(state, run_flow, output_holder) + ) + register_cleanup(streaming_output, state) + output_holder.append(streaming_output) + + return streaming_output + + async def _run_flow() -> Any: + return await self.kickoff_async( + inputs, + input_files, + restore_from_state_id=restore_from_state_id, + ) + + try: + asyncio.get_running_loop() + ctx = contextvars.copy_context() + with ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(ctx.run, asyncio.run, _run_flow()).result() + except RuntimeError: + return asyncio.run(_run_flow()) + + async def kickoff_async( + self, + inputs: dict[str, Any] | None = None, + input_files: dict[str, FileInput] | None = None, + from_checkpoint: CheckpointConfig | None = None, + restore_from_state_id: str | None = None, + ) -> Any | FlowStreamingOutput: + """Start the flow execution asynchronously. + + This method performs state restoration (if an 'id' is provided and persistence is available) + and updates the flow state with any additional inputs. It then emits the FlowStartedEvent, + logs the flow startup, and executes all start methods. Once completed, it emits the + FlowFinishedEvent and returns the final output. + + Args: + inputs: Optional dictionary containing input values and/or a state ID for restoration. + input_files: Optional dict of named file inputs for the flow. + from_checkpoint: Optional checkpoint config. If ``restore_from`` + is set, the flow resumes from that checkpoint. + restore_from_state_id: Optional UUID of a previously-persisted flow + whose latest snapshot should hydrate this run's state. The new + run is assigned a fresh ``state.id`` (or ``inputs["id"]`` if + pinned), so subsequent ``@persist`` writes land under a + separate persistence key. If the referenced state is not + found, falls back silently to baseline. Cannot be combined + with ``from_checkpoint``; passing both raises ``ValueError``. + + Returns: + The final output from the flow, which is the result of the last executed method. + """ + if from_checkpoint is not None and restore_from_state_id is not None: + raise ValueError( + "Cannot combine `from_checkpoint` and `restore_from_state_id`. " + "These parameters target different state systems " + "(Checkpointing and @persist) and cannot be used together." + ) + restored = apply_checkpoint(self, from_checkpoint) + if restored is not None: + return await restored.kickoff_async(inputs=inputs, input_files=input_files) + if self.stream: + result_holder: list[Any] = [] + current_task_info: TaskInfo = { + "index": 0, + "name": "", + "id": "", + "agent_role": "", + "agent_id": "", + } + + state = create_streaming_state( + current_task_info, result_holder, use_async=True + ) + output_holder: list[CrewStreamingOutput | FlowStreamingOutput] = [] + + async def run_flow() -> None: + try: + self.stream = False + result = await self.kickoff_async( + inputs=inputs, + input_files=input_files, + restore_from_state_id=restore_from_state_id, + ) + result_holder.append(result) + except Exception as e: + # HumanFeedbackPending is expected control flow, not an error + from crewai.flow.async_feedback.types import HumanFeedbackPending + + if isinstance(e, HumanFeedbackPending): + result_holder.append(e) + else: + signal_error(state, e, is_async=True) + finally: + self.stream = True + signal_end(state, is_async=True) + + streaming_output = FlowStreamingOutput( + async_iterator=create_async_chunk_generator( + state, run_flow, output_holder + ) + ) + register_cleanup(streaming_output, state) + output_holder.append(streaming_output) + + return streaming_output + + ctx = baggage.set_baggage("flow_inputs", inputs or {}) + 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 + ) or self.checkpoint_completed_methods is not None + if not is_restoring: + # Clear completed methods and outputs for a fresh start + self._completed_methods.clear() + self._method_outputs.clear() + self._pending_and_listeners.clear() + self._clear_or_listeners() + self._method_call_counts.clear() + else: + # Only enter resumption mode if there are completed methods to + # replay. When _completed_methods is empty (e.g. a pure + # state-reload via kickoff(inputs={"id": ...})), the flow + # executes from scratch and the flag would incorrectly + # suppress cyclic re-execution on the second iteration. + if self._completed_methods: + self._is_execution_resuming = True + + # Fork hydration: when restore_from_state_id is set and persistence is + # available, hydrate self._state from the source UUID's latest snapshot + # and reassign state.id to a fresh value so subsequent @persist writes + # don't extend the source flow's history. If the source state is not + # found, fall through silently to the existing inputs handling. + fork_succeeded = False + if restore_from_state_id is not None and self.persistence is not None: + stored_state = self.persistence.load_state(restore_from_state_id) + if stored_state: + self._log_flow_event( + f"Forking flow state from UUID: {restore_from_state_id}" + ) + self._restore_state(stored_state) + # Pin to inputs["id"] when provided, otherwise mint a fresh + # UUID. NOTE: pinning inputs.id while forking shares a + # persistence key with another flow — usually you want only + # restore_from_state_id. + new_state_id = (inputs.get("id") if inputs else None) or str( + uuid4() + ) + if isinstance(self._state, dict): + self._state["id"] = new_state_id + elif isinstance(self._state, BaseModel): + setattr(self._state, "id", new_state_id) # noqa: B010 + fork_succeeded = True + else: + self._log_flow_event( + "No flow state found for restore_from_state_id: " + f"{restore_from_state_id}; proceeding without hydration", + color="yellow", + ) + + if inputs: + # Override the id in the state if it exists in inputs. + # Skip when the fork already assigned state.id above. + if "id" in inputs and not fork_succeeded: + if isinstance(self._state, dict): + self._state["id"] = inputs["id"] + elif isinstance(self._state, BaseModel): + setattr(self._state, "id", inputs["id"]) # noqa: B010 + + # If persistence is enabled, attempt to restore the stored state using the provided id. + # Skip when the fork already restored self._state above. + if ( + "id" in inputs + and self.persistence is not None + and not fork_succeeded + ): + restore_uuid = inputs["id"] + stored_state = self.persistence.load_state(restore_uuid) + if stored_state: + self._log_flow_event( + f"Loading flow state from memory for UUID: {restore_uuid}" + ) + self._restore_state(stored_state) + else: + self._log_flow_event( + f"No flow state found for UUID: {restore_uuid}", color="red" + ) + + # Update state with any additional inputs (ignoring the 'id' key) + filtered_inputs = {k: v for k, v in inputs.items() if k != "id"} + if filtered_inputs: + self._initialize_state(filtered_inputs) + + if get_current_parent_id() is None: + reset_emission_counter() + reset_last_event_id() + + if not self.suppress_flow_events: + future = crewai_event_bus.emit( + self, + FlowStartedEvent( + type="flow_started", + flow_name=self.name or self.__class__.__name__, + inputs=inputs, + ), + ) + if future: + try: + await asyncio.wrap_future(future) + except Exception: + logger.warning("FlowStartedEvent handler failed", exc_info=True) + self._log_flow_event( + f"Flow started with ID: {self.flow_id}", color="bold magenta" + ) + + # After FlowStarted (when not suppressed): env events must not pre-empt + # trace batch init with implicit "crew" execution_type. + get_env_context() + + if inputs is not None and "id" not in inputs: + self._initialize_state(inputs) + + if self._is_execution_resuming: + await self._replay_recorded_events() + + try: + # Determine which start methods to execute at kickoff + # Conditional start methods (with __trigger_methods__) are only triggered by their conditions + # UNLESS there are no unconditional starts (then all starts run as entry points) + unconditional_starts = [ + start_method + for start_method in self._start_methods + if not getattr( + self._methods.get(start_method), "__trigger_methods__", None + ) + ] + # If there are unconditional starts, only run those at kickoff + # If there are NO unconditional starts, run all starts (including conditional ones) + starts_to_execute = ( + unconditional_starts + if unconditional_starts + else self._start_methods + ) + tasks = [ + self._execute_start_method(start_method) + for start_method in starts_to_execute + ] + await asyncio.gather(*tasks) + except Exception as e: + # Check if flow was paused for human feedback + from crewai.flow.async_feedback.types import HumanFeedbackPending + + if isinstance(e, HumanFeedbackPending): + # Auto-save pending feedback (create default persistence if needed) + if self.persistence is None: + from crewai.flow.persistence import SQLiteFlowPersistence + + self.persistence = SQLiteFlowPersistence() + + state_data = ( + self._state + if isinstance(self._state, dict) + else self._state.model_dump() + ) + self.persistence.save_pending_feedback( + flow_uuid=e.context.flow_id, + context=e.context, + state_data=state_data, + ) + + # Emit flow paused event + future = crewai_event_bus.emit( + self, + FlowPausedEvent( + type="flow_paused", + flow_name=self.name or self.__class__.__name__, + flow_id=e.context.flow_id, + method_name=e.context.method_name, + state=self._copy_and_serialize_state(), + message=e.context.message, + emit=e.context.emit, + ), + ) + if future and isinstance(future, Future): + self._event_futures.append(future) + + # Wait for events to be processed + if self._event_futures: + await asyncio.gather( + *[ + asyncio.wrap_future(f) + for f in self._event_futures + if isinstance(f, Future) + ] + ) + self._event_futures.clear() + + # Return the pending exception instead of raising + # This allows the caller to handle the paused state gracefully + return e + + # Re-raise other exceptions + raise + + # Clear the resumption flag after initial execution completes + self._is_execution_resuming = False + + final_output = self._method_outputs[-1] if self._method_outputs else None + + if self._event_futures: + await asyncio.gather( + *[asyncio.wrap_future(f) for f in self._event_futures] + ) + self._event_futures.clear() + + if not self.suppress_flow_events: + future = crewai_event_bus.emit( + self, + FlowFinishedEvent( + type="flow_finished", + flow_name=self.name or self.__class__.__name__, + result=final_output, + state=self._copy_and_serialize_state(), + ), + ) + if future: + try: + await asyncio.wrap_future(future) + except Exception: + logger.warning( + "FlowFinishedEvent handler failed", exc_info=True + ) + + if not self.suppress_flow_events: + trace_listener = TraceCollectionListener() + if trace_listener.batch_manager.batch_owner_type == "flow": + if trace_listener.first_time_handler.is_first_time: + trace_listener.first_time_handler.mark_events_collected() + trace_listener.first_time_handler.handle_execution_completion() + else: + trace_listener.batch_manager.finalize_batch() + + return final_output + finally: + # Ensure all background memory saves complete before returning + if self.memory is not None and hasattr(self.memory, "drain_writes"): + self.memory.drain_writes() + 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( + self, + inputs: dict[str, Any] | None = None, + input_files: dict[str, FileInput] | None = None, + from_checkpoint: CheckpointConfig | None = None, + restore_from_state_id: str | None = None, + ) -> Any | FlowStreamingOutput: + """Native async method to start the flow execution. Alias for kickoff_async. + + Args: + inputs: Optional dictionary containing input values and/or a state ID for restoration. + input_files: Optional dict of named file inputs for the flow. + from_checkpoint: Optional checkpoint config. If ``restore_from`` + is set, the flow resumes from that checkpoint. + restore_from_state_id: Optional UUID of a previously-persisted flow + whose latest snapshot should hydrate this run's state. See + ``kickoff_async`` for full semantics. + + Returns: + The final output from the flow, which is the result of the last executed method. + """ + return await self.kickoff_async( + inputs, + input_files, + from_checkpoint, + restore_from_state_id=restore_from_state_id, + ) + + async def _replay_recorded_events(self) -> None: + """Dispatch recorded ``MethodExecution*`` events from the event record.""" + state = crewai_event_bus.runtime_state + if state is None: + return + record = state.event_record + if len(record) == 0: + return + + replayable = ( + MethodExecutionStartedEvent, + MethodExecutionFinishedEvent, + MethodExecutionFailedEvent, + ) + flow_name = self.name or self.__class__.__name__ + nodes = sorted( + ( + n + for n in record.all_nodes() + if isinstance(n.event, replayable) + and n.event.flow_name == flow_name + and n.event.method_name in self._completed_methods + ), + key=lambda n: n.event.emission_sequence or 0, + ) + + for node in nodes: + future = crewai_event_bus.replay(self, node.event) + if future is not None: + try: + await asyncio.wrap_future(future) + except Exception: + logger.warning( + "Replayed event handler failed: %s", + node.event.type, + exc_info=True, + ) + + async def _execute_start_method(self, start_method_name: FlowMethodName) -> None: + """Executes a flow's start method and its triggered listeners. + + This internal method handles the execution of methods marked with @start + decorator and manages the subsequent chain of listener executions. + + Args: + start_method_name: The name of the start method to execute. + + Note: + - Executes the start method and captures its result + - Triggers execution of any listeners waiting on this start method + - Part of the flow's initialization sequence + - Skips execution if method was already completed (e.g., after reload) + - Automatically injects crewai_trigger_payload if available in flow inputs + """ + if start_method_name in self._completed_methods: + if self._is_execution_resuming: + # During resumption, skip execution but continue listeners + last_output = self._method_outputs[-1] if self._method_outputs else None + await self._execute_listeners(start_method_name, last_output) + return + # For cyclic flows, clear from completed to allow re-execution + self._completed_methods.discard(start_method_name) + # Also clear fired OR listeners to allow them to fire again in new cycle + self._clear_or_listeners() + + method = self._methods[start_method_name] + enhanced_method = self._inject_trigger_payload_for_start_method(method) + + result, finished_event_id = await self._execute_method( + start_method_name, enhanced_method + ) + + # If start method is a router, use its result as an additional trigger + if start_method_name in self._routers and result is not None: + # Execute listeners for the start method name first + await self._execute_listeners(start_method_name, result, finished_event_id) + # Then execute listeners for the router result (e.g., "approved") + router_result_trigger = FlowMethodName(str(result)) + listener_result = ( + self.last_human_feedback + if self.last_human_feedback is not None + else result + ) + await self._execute_listeners( + router_result_trigger, listener_result, finished_event_id + ) + else: + await self._execute_listeners(start_method_name, result, finished_event_id) + + def _inject_trigger_payload_for_start_method( + self, original_method: Callable[..., Any] + ) -> Callable[..., Any]: + def prepare_kwargs( + *args: Any, **kwargs: Any + ) -> tuple[tuple[Any, ...], dict[str, Any]]: + inputs = cast(dict[str, Any], baggage.get_baggage("flow_inputs") or {}) + trigger_payload = inputs.get("crewai_trigger_payload") + + sig = inspect.signature(original_method) + accepts_trigger_payload = "crewai_trigger_payload" in sig.parameters + + if trigger_payload is not None and accepts_trigger_payload: + kwargs["crewai_trigger_payload"] = trigger_payload + elif trigger_payload is not None: + self._log_flow_event( + f"Trigger payload available but {original_method.__name__} doesn't accept crewai_trigger_payload parameter" + ) + return args, kwargs + + if asyncio.iscoroutinefunction(original_method): + + async def enhanced_method(*args: Any, **kwargs: Any) -> Any: + args, kwargs = prepare_kwargs(*args, **kwargs) + return await original_method(*args, **kwargs) + else: + + def enhanced_method(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] + args, kwargs = prepare_kwargs(*args, **kwargs) + return original_method(*args, **kwargs) + + enhanced_method.__name__ = original_method.__name__ + enhanced_method.__doc__ = original_method.__doc__ + + return enhanced_method + + async def _execute_method( + self, + method_name: FlowMethodName, + method: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> tuple[Any, str | None]: + """Execute a method and emit events. + + Returns: + A tuple of (result, finished_event_id) where finished_event_id is + the event_id of the MethodExecutionFinishedEvent, or None if events + are suppressed. + """ + try: + dumped_params = {f"_{i}": arg for i, arg in enumerate(args)} | ( + kwargs or {} + ) + + if not self.suppress_flow_events: + future = crewai_event_bus.emit( + self, + MethodExecutionStartedEvent( + type="method_execution_started", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + params=dumped_params, + state=self._copy_and_serialize_state(), + ), + ) + if future: + self._event_futures.append(future) + + # Set method name in context so ask() can read it without + # stack inspection. Must happen before copy_context() so the + # value propagates into the thread pool for sync methods. + from crewai.flow.flow_context import current_flow_method_name + + method_name_token = current_flow_method_name.set(method_name) + try: + if asyncio.iscoroutinefunction(method): + result = await method(*args, **kwargs) + else: + # Run sync methods in thread pool for isolation + # This allows Agent.kickoff() to work synchronously inside Flow methods + ctx = contextvars.copy_context() + result = await asyncio.to_thread(ctx.run, method, *args, **kwargs) + finally: + current_flow_method_name.reset(method_name_token) + + # Auto-await coroutines returned from sync methods (enables AgentExecutor pattern) + if asyncio.iscoroutine(result): + result = await result + + self._method_outputs.append(result) + + # For @human_feedback methods with emit, the result is the collapsed outcome + # (e.g., "approved") used for routing. But we want the actual method output + # to be the stored result (for final flow output). Replace the last entry + # if a stashed output exists. Dict-based stash is concurrency-safe and + # handles None return values (presence in dict = stashed, not value). + if method_name in self._human_feedback_method_outputs: + self._method_outputs[-1] = self._human_feedback_method_outputs.pop( + method_name + ) + + self._method_execution_counts[method_name] = ( + self._method_execution_counts.get(method_name, 0) + 1 + ) + + self._completed_methods.add(method_name) + + finished_event_id: str | None = None + if not self.suppress_flow_events: + finished_event = MethodExecutionFinishedEvent( + type="method_execution_finished", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + state=self._copy_and_serialize_state(), + result=result, + ) + finished_event_id = finished_event.event_id + future = crewai_event_bus.emit(self, finished_event) + if future: + self._event_futures.append(future) + + return result, finished_event_id + except Exception as e: + # Check if this is a HumanFeedbackPending exception (paused, not failed) + from crewai.flow.async_feedback.types import HumanFeedbackPending + + if isinstance(e, HumanFeedbackPending): + e.context.method_name = method_name + + if self.persistence is None: + from crewai.flow.persistence import SQLiteFlowPersistence + + self.persistence = SQLiteFlowPersistence() + + # Emit paused event (not failed) + if not self.suppress_flow_events: + future = crewai_event_bus.emit( + self, + MethodExecutionPausedEvent( + type="method_execution_paused", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + state=self._copy_and_serialize_state(), + flow_id=e.context.flow_id, + message=e.context.message, + emit=e.context.emit, + ), + ) + if future: + self._event_futures.append(future) + elif not self.suppress_flow_events: + # Regular failure - emit failed event + future = crewai_event_bus.emit( + self, + MethodExecutionFailedEvent( + type="method_execution_failed", + method_name=method_name, + flow_name=self.name or self.__class__.__name__, + error=e, + ), + ) + if future: + self._event_futures.append(future) + raise e + + def _copy_and_serialize_state(self) -> dict[str, Any]: + state_copy = self._copy_state() + if isinstance(state_copy, BaseModel): + try: + return state_copy.model_dump(mode="json") + except Exception: + return state_copy.model_dump() + else: + return state_copy + + async def _execute_listeners( + self, + trigger_method: FlowMethodName, + result: Any, + triggering_event_id: str | None = None, + ) -> None: + """Executes all listeners and routers triggered by a method completion. + + This internal method manages the execution flow by: + 1. First executing all triggered routers sequentially + 2. Then executing all triggered listeners in parallel + + Args: + trigger_method: The name of the method that triggered these listeners. + result: The result from the triggering method, passed to listeners that accept parameters. + triggering_event_id: The event_id of the MethodExecutionFinishedEvent that + triggered these listeners, used for causal chain tracking. + + Note: + - Routers are executed sequentially to maintain flow control + - Each router's result becomes a new trigger_method + - Normal listeners are executed in parallel for efficiency + - Listeners can receive the trigger method's result as a parameter + """ + # First, handle routers repeatedly until no router triggers anymore + router_results = [] + router_result_to_feedback: dict[ + str, Any + ] = {} # Map outcome -> HumanFeedbackResult + current_trigger = trigger_method + current_result = result # Track the result to pass to each router + current_triggering_event_id = triggering_event_id + + while True: + routers_triggered = self._find_triggered_methods( + current_trigger, router_only=True + ) + if not routers_triggered: + break + + for router_name in routers_triggered: + # For routers triggered by a router outcome, pass the HumanFeedbackResult + router_input = router_result_to_feedback.get( + str(current_trigger), current_result + ) + ( + router_result, + current_triggering_event_id, + ) = await self._execute_single_listener( + router_name, router_input, current_triggering_event_id + ) + if router_result: # Only add non-None results + router_result_str = ( + router_result.value + if isinstance(router_result, enum.Enum) + else str(router_result) + ) + router_results.append(FlowMethodName(router_result_str)) + # If this was a human_feedback router, map the outcome to the feedback + if self.last_human_feedback is not None: + router_result_to_feedback[router_result_str] = ( + self.last_human_feedback + ) + current_trigger = ( + FlowMethodName( + router_result.value + if isinstance(router_result, enum.Enum) + else str(router_result) + ) + if router_result is not None + else FlowMethodName("") # Update for next iteration of router chain + ) + + # Now execute normal listeners for all router results and the original trigger + all_triggers = [trigger_method, *router_results] + + for current_trigger in all_triggers: + if current_trigger: # Skip None results + listeners_triggered = self._find_triggered_methods( + current_trigger, router_only=False + ) + if listeners_triggered: + # Determine what result to pass to listeners + # For router outcomes, pass the HumanFeedbackResult if available + listener_result = router_result_to_feedback.get( + str(current_trigger), result + ) + racing_group = self._get_racing_group_for_listeners( + listeners_triggered + ) + if racing_group: + racing_members, _ = racing_group + other_listeners = [ + name + for name in listeners_triggered + if name not in racing_members + ] + await self._execute_racing_listeners( + racing_members, + other_listeners, + listener_result, + current_triggering_event_id, + ) + else: + tasks = [ + self._execute_single_listener( + listener_name, + listener_result, + current_triggering_event_id, + ) + for listener_name in listeners_triggered + ] + await asyncio.gather(*tasks) + + if current_trigger in router_results: + for method_name in self._start_methods: + if method_name in self._listeners: + condition_data = self._listeners[method_name] + should_trigger = False + if is_simple_flow_condition(condition_data): + _, trigger_methods = condition_data + should_trigger = current_trigger in trigger_methods + elif isinstance(condition_data, dict): + all_methods = _extract_all_methods(condition_data) + should_trigger = current_trigger in all_methods + + if should_trigger: + if method_name in self._completed_methods: + # Cyclic re-execution: temporarily clear resumption flag so the method actually re-runs + was_resuming = self._is_execution_resuming + self._is_execution_resuming = False + await self._execute_start_method(method_name) + self._is_execution_resuming = was_resuming + else: + await self._execute_start_method(method_name) + + def _evaluate_condition( + self, + condition: FlowMethodName | FlowCondition, + trigger_method: FlowMethodName, + listener_name: FlowMethodName, + ) -> bool: + """Recursively evaluate a condition (simple or nested). + + Args: + condition: Can be a string (method name) or dict (nested condition) + trigger_method: The method that just completed + listener_name: Name of the listener being evaluated + + Returns: + True if the condition is satisfied, False otherwise + """ + if is_flow_method_name(condition): + return condition == trigger_method + + if is_flow_condition_dict(condition): + normalized = _normalize_condition(condition) + cond_type = normalized.get("type", OR_CONDITION) + sub_conditions = normalized.get("conditions", []) + + if cond_type == OR_CONDITION: + return any( + self._evaluate_condition(sub_cond, trigger_method, listener_name) + for sub_cond in sub_conditions + ) + + if cond_type == AND_CONDITION: + pending_key = PendingListenerKey(f"{listener_name}:{id(condition)}") + + if pending_key not in self._pending_and_listeners: + all_methods = set(_extract_all_methods(condition)) + self._pending_and_listeners[pending_key] = all_methods + + if trigger_method in self._pending_and_listeners[pending_key]: + self._pending_and_listeners[pending_key].discard(trigger_method) + + direct_methods_satisfied = not self._pending_and_listeners[pending_key] + + nested_conditions_satisfied = all( + ( + self._evaluate_condition( + sub_cond, trigger_method, listener_name + ) + if is_flow_condition_dict(sub_cond) + else True + ) + for sub_cond in sub_conditions + ) + + if direct_methods_satisfied and nested_conditions_satisfied: + self._pending_and_listeners.pop(pending_key, None) + return True + + return False + + return False + + def _find_triggered_methods( + self, trigger_method: FlowMethodName, router_only: bool + ) -> list[FlowMethodName]: + """Finds all methods that should be triggered based on conditions. + + This internal method evaluates both OR and AND conditions to determine + which methods should be executed next in the flow. Supports nested conditions. + + Args: + trigger_method: The name of the method that just completed execution. + router_only: If True, only consider router methods. If False, only consider non-router methods. + + Returns: + Names of methods that should be triggered. + + Note: + - Handles both OR and AND conditions, including nested combinations + - Maintains state for AND conditions using _pending_and_listeners + - Separates router and normal listener evaluation + """ + triggered: list[FlowMethodName] = [] + + for listener_name, condition_data in self._listeners.items(): + is_router = listener_name in self._routers + + if router_only != is_router: + continue + + if not router_only and listener_name in self._start_methods: + continue + + if is_simple_flow_condition(condition_data): + condition_type, methods = condition_data + + if condition_type == OR_CONDITION: + # Only trigger multi-source OR listeners (or_(A, B, C)) once - skip if already fired + # Simple single-method listeners fire every time their trigger occurs + # Routers also fire every time - they're decision points + has_multiple_triggers = len(methods) > 1 + should_check_fired = has_multiple_triggers and not is_router + + if ( + not should_check_fired + or listener_name not in self._fired_or_listeners + ): + if trigger_method in methods: + triggered.append(listener_name) + # Only track multi-source OR listeners (not single-method or routers) + if should_check_fired: + self._fired_or_listeners.add(listener_name) + elif condition_type == AND_CONDITION: + pending_key = PendingListenerKey(listener_name) + if pending_key not in self._pending_and_listeners: + self._pending_and_listeners[pending_key] = set(methods) + if trigger_method in self._pending_and_listeners[pending_key]: + self._pending_and_listeners[pending_key].discard(trigger_method) + + if not self._pending_and_listeners[pending_key]: + triggered.append(listener_name) + self._pending_and_listeners.pop(pending_key, None) + + elif is_flow_condition_dict(condition_data): + # For complex conditions, check if top-level is OR and track accordingly + top_level_type = condition_data.get("type", OR_CONDITION) + is_or_based = top_level_type == OR_CONDITION + + # Only track multi-source OR conditions (multiple sub-conditions), not routers + sub_conditions = condition_data.get("conditions", []) + has_multiple_triggers = is_or_based and len(sub_conditions) > 1 + should_check_fired = has_multiple_triggers and not is_router + + # Skip compound OR-based listeners that have already fired + if should_check_fired and listener_name in self._fired_or_listeners: + continue + + if self._evaluate_condition( + condition_data, trigger_method, listener_name + ): + triggered.append(listener_name) + # Track compound OR-based listeners so they only fire once + if should_check_fired: + self._fired_or_listeners.add(listener_name) + + return triggered + + async def _execute_single_listener( + self, + listener_name: FlowMethodName, + result: Any, + triggering_event_id: str | None = None, + ) -> tuple[Any, str | None]: + """Executes a single listener method with proper event handling. + + This internal method manages the execution of an individual listener, + including parameter inspection, event emission, and error handling. + + Args: + listener_name: The name of the listener method to execute. + result: The result from the triggering method, which may be passed to the listener if it accepts parameters. + triggering_event_id: The event_id of the event that triggered this listener, + used for causal chain tracking. + + Returns: + A tuple of (listener_result, event_id) where listener_result is the return + value of the listener method and event_id is the MethodExecutionFinishedEvent + id, or (None, None) if skipped during resumption. + + Note: + - Inspects method signature to determine if it accepts the trigger result + - Emits events for method execution start and finish + - Handles errors gracefully with detailed logging + - Recursively triggers listeners of this listener + - Supports both parameterized and parameter-less listeners + - Skips execution if method was already completed (e.g., after reload) + - Catches and logs any exceptions during execution, preventing individual listener failures from breaking the entire flow + """ + count = self._method_call_counts.get(listener_name, 0) + 1 + if count > self.max_method_calls: + raise RecursionError( + f"Method '{listener_name}' has been called {self.max_method_calls} times in " + f"this flow execution, which indicates an infinite loop. " + f"This commonly happens when a @listen label matches the " + f"method's own name." + ) + self._method_call_counts[listener_name] = count + + if listener_name in self._completed_methods: + if self._is_execution_resuming: + # During resumption, skip execution but continue listeners + await self._execute_listeners(listener_name, None) + + # For routers, also check if any conditional starts they triggered are completed + # If so, continue their chains + if listener_name in self._routers: + for start_method_name in self._start_methods: + if ( + start_method_name in self._listeners + and start_method_name in self._completed_methods + ): + # This conditional start was executed, continue its chain + await self._execute_start_method(start_method_name) + return (None, None) + # For cyclic flows, clear from completed to allow re-execution + self._completed_methods.discard(listener_name) + # Clear ALL fired OR listeners so they can fire again in the new cycle. + # This mirrors what _execute_start_method does for start-method cycles. + # Only discarding the individual listener is insufficient because + # downstream or_() listeners (e.g., method_a listening to + # or_(handler_a, handler_b)) would remain suppressed across iterations. + self._clear_or_listeners() + + try: + method = self._methods[listener_name] + + sig = inspect.signature(method) + params = list(sig.parameters.values()) + method_params = [p for p in params if p.name != "self"] + + if triggering_event_id: + with triggered_by_scope(triggering_event_id): + if method_params: + listener_result, finished_event_id = await self._execute_method( + listener_name, method, result + ) + else: + listener_result, finished_event_id = await self._execute_method( + listener_name, method + ) + else: + if method_params: + listener_result, finished_event_id = await self._execute_method( + listener_name, method, result + ) + else: + listener_result, finished_event_id = await self._execute_method( + listener_name, method + ) + + await self._execute_listeners( + listener_name, listener_result, finished_event_id + ) + + return (listener_result, finished_event_id) + + except Exception as e: + # Don't log HumanFeedbackPending as an error - it's expected control flow + from crewai.flow.async_feedback.types import HumanFeedbackPending + + if not isinstance(e, HumanFeedbackPending): + if not getattr(e, "_flow_listener_logged", False): + logger.error(f"Error executing listener {listener_name}: {e}") + e._flow_listener_logged = True # type: ignore[attr-defined] + raise + + def _resolve_input_provider(self) -> InputProvider: + """Resolve the input provider using the priority chain. + + Resolution order: + 1. ``self.input_provider`` (per-flow override) + 2. ``flow_config.input_provider`` (global default) + 3. ``ConsoleInputProvider()`` (built-in fallback) + + Returns: + An object implementing the ``InputProvider`` protocol. + """ + from crewai.flow.async_feedback.providers import ConsoleProvider + from crewai.flow.flow_config import flow_config + + if self.input_provider is not None: + return self.input_provider + if flow_config.input_provider is not None: + return flow_config.input_provider + return ConsoleProvider() + + def _checkpoint_state_for_ask(self) -> None: + """Auto-checkpoint flow state before waiting for user input. + + If persistence is configured, saves the current state so that + ``self.state`` is recoverable even if the process crashes while + waiting for input. + + This is best-effort: if persistence is not configured, this is a no-op. + """ + if self.persistence is None: + return + try: + state_data = ( + self._state + if isinstance(self._state, dict) + else self._state.model_dump() + ) + self.persistence.save_state( + flow_uuid=self.flow_id, + method_name="_ask_checkpoint", + state_data=state_data, + ) + except Exception: + logger.debug("Failed to checkpoint state before ask()", exc_info=True) + + def ask( + self, + message: str, + timeout: float | None = None, + metadata: dict[str, Any] | None = None, + ) -> str | None: + """Request input from the user during flow execution. + + Blocks the current thread until the user provides input or the + timeout expires. Works in both sync and async flow methods (the + flow framework runs sync methods in a thread pool via + ``asyncio.to_thread``, so the event loop stays free). + + Timeout ensures flows always terminate. When timeout expires, + ``None`` is returned, enabling the pattern:: + + while (msg := self.ask("You: ", timeout=300)) is not None: + process(msg) + + Before waiting for input, the current ``self.state`` is automatically + checkpointed to persistence (if configured) for durability. + + Args: + message: The question or prompt to display to the user. + timeout: Maximum seconds to wait for input. ``None`` means + wait indefinitely. When timeout expires, returns ``None``. + Note: timeout is best-effort for the provider call -- + ``ask()`` returns ``None`` promptly, but the underlying + ``request_input()`` may continue running in a background + thread until it completes naturally. Network providers + should implement their own internal timeouts. + metadata: Optional metadata to send to the input provider, + such as user ID, channel, session context. The provider + can use this to route the question to the right recipient. + + Returns: + The user's input as a string, or ``None`` on timeout, disconnect, + or provider error. Empty string ``""`` means the user pressed + Enter without typing (intentional empty input). + + Example: + ```python + class MyFlow(Flow): + @start() + def gather_info(self): + topic = self.ask( + "What topic should we research?", + metadata={"user_id": "u123", "channel": "#research"}, + ) + if topic is None: + return "No input received" + return topic + ``` + """ + from concurrent.futures import ( + ThreadPoolExecutor, + TimeoutError as FuturesTimeoutError, + ) + from datetime import datetime + + from crewai.events.types.flow_events import ( + FlowInputReceivedEvent, + FlowInputRequestedEvent, + ) + from crewai.flow.flow_context import current_flow_method_name + from crewai.flow.input_provider import InputResponse + + method_name = current_flow_method_name.get("unknown") + + crewai_event_bus.emit( + self, + FlowInputRequestedEvent( + type="flow_input_requested", + flow_name=self.name or self.__class__.__name__, + method_name=method_name, + message=message, + metadata=metadata, + ), + ) + + self._checkpoint_state_for_ask() + + provider = self._resolve_input_provider() + raw: str | InputResponse | None = None + + try: + if timeout is not None: + # Manual executor management to avoid shutdown(wait=True) + # deadlock when the provider call outlives the timeout. + executor = ThreadPoolExecutor(max_workers=1) + ctx = contextvars.copy_context() + future = executor.submit( + ctx.run, provider.request_input, message, self, metadata + ) + try: + raw = future.result(timeout=timeout) + except FuturesTimeoutError: + future.cancel() + raw = None + finally: + # wait=False so we don't block if the provider is still + # running (e.g. input() stuck waiting for user). + # cancel_futures=True cleans up any queued-but-not-started tasks. + executor.shutdown(wait=False, cancel_futures=True) + else: + raw = provider.request_input(message, self, metadata=metadata) + except KeyboardInterrupt: + raise + except Exception: + logger.debug("Input provider error in ask()", exc_info=True) + raw = None + + response: str | None = None + response_metadata: dict[str, Any] | None = None + + if isinstance(raw, InputResponse): + response = raw.text + response_metadata = raw.metadata + elif isinstance(raw, str): + response = raw + else: + response = None + + self._input_history.append( + { + "message": message, + "response": response, + "method_name": method_name, + "timestamp": datetime.now(), + "metadata": metadata, + "response_metadata": response_metadata, + } + ) + + crewai_event_bus.emit( + self, + FlowInputReceivedEvent( + type="flow_input_received", + flow_name=self.name or self.__class__.__name__, + method_name=method_name, + message=message, + response=response, + metadata=metadata, + response_metadata=response_metadata, + ), + ) + + return response + + def _request_human_feedback( + self, + message: str, + output: Any, + metadata: dict[str, Any] | None = None, + emit: Sequence[str] | None = None, + ) -> str: + """Request feedback from a human. + Args: + message: The message to display when requesting feedback. + output: The method output to show the human for review. + metadata: Optional metadata for enterprise integrations. + emit: Optional list of possible outcomes for routing. + + Returns: + The human's feedback as a string. Empty string if no feedback provided. + """ + from crewai.events.event_listener import event_listener + from crewai.events.types.flow_events import ( + HumanFeedbackReceivedEvent, + HumanFeedbackRequestedEvent, + ) + + crewai_event_bus.emit( + self, + HumanFeedbackRequestedEvent( + type="human_feedback_requested", + flow_name=self.name or self.__class__.__name__, + method_name="", # Will be set by decorator if needed + output=output, + message=message, + emit=list(emit) if emit else None, + ), + ) + + formatter = event_listener.formatter + formatter.pause_live_updates() + + try: + formatter.console.print("\n" + "═" * 50, style="bold cyan") + formatter.console.print(" OUTPUT FOR REVIEW", style="bold cyan") + formatter.console.print("═" * 50 + "\n", style="bold cyan") + formatter.console.print(output) + formatter.console.print("\n" + "═" * 50 + "\n", style="bold cyan") + + formatter.console.print(message, style="yellow") + formatter.console.print( + "(Press Enter to skip, or type your feedback)\n", style="cyan" + ) + + feedback = input("Your feedback: ").strip() + + crewai_event_bus.emit( + self, + HumanFeedbackReceivedEvent( + type="human_feedback_received", + flow_name=self.name or self.__class__.__name__, + method_name="", # Will be set by decorator if needed + feedback=feedback, + outcome=None, # Will be determined after collapsing + ), + ) + + return feedback + finally: + formatter.resume_live_updates() + + def _collapse_to_outcome( + self, + feedback: str, + outcomes: Sequence[str], + llm: str | BaseLLM, + ) -> str: + """Collapse free-form feedback to a predefined outcome using LLM. + + This method uses the specified LLM to interpret the human's feedback + and map it to one of the predefined outcomes for routing purposes. + + Uses structured outputs (function calling) when supported by the LLM + to guarantee the response is one of the valid outcomes. Falls back + to simple prompting if structured outputs fail. + + Args: + feedback: The raw human feedback text. + outcomes: Sequence of valid outcome strings to choose from. + llm: The LLM model to use. Can be a model string or BaseLLM instance. + + Returns: + One of the outcome strings that best matches the feedback intent. + """ + from typing import Literal + + from pydantic import BaseModel, Field + + from crewai.llm import LLM + from crewai.llms.base_llm import BaseLLM as BaseLLMClass + from crewai.utilities.i18n import I18N_DEFAULT + + llm_instance: BaseLLMClass + if isinstance(llm, str): + llm_instance = LLM(model=llm) + elif isinstance(llm, BaseLLMClass): + llm_instance = llm + else: + raise ValueError(f"Invalid llm type: {type(llm)}. Expected str or BaseLLM.") + + outcomes_tuple = tuple(outcomes) + + class FeedbackOutcome(BaseModel): + """The outcome that best matches the human's feedback intent.""" + + outcome: Literal[outcomes_tuple] = Field( # type: ignore[valid-type] + description=f"The outcome that best matches the feedback. Must be one of: {', '.join(outcomes)}" + ) + + prompt_template = I18N_DEFAULT.slice("human_feedback_collapse") + + prompt = prompt_template.format( + feedback=feedback, + outcomes=", ".join(outcomes), + ) + + try: + # NOTE: LLM.call with response_model returns JSON string, not a Pydantic model + response = llm_instance.call( + messages=[{"role": "user", "content": prompt}], + response_model=FeedbackOutcome, + ) + + if isinstance(response, str): + import json + + try: + parsed = json.loads(response) + return str(parsed.get("outcome", outcomes[0])) + except json.JSONDecodeError: + response_clean = response.strip() + for outcome in outcomes: + if outcome.lower() == response_clean.lower(): + return outcome + return outcomes[0] + elif isinstance(response, FeedbackOutcome): + return str(response.outcome) + elif hasattr(response, "outcome"): + return str(response.outcome) + else: + logger.warning(f"Unexpected response type: {type(response)}") + return outcomes[0] + + except Exception as e: + logger.warning( + f"Structured output failed, falling back to simple prompting: {e}" + ) + try: + response = llm_instance.call( + messages=[{"role": "user", "content": prompt}], + ) + response_clean = str(response).strip() + + for outcome in outcomes: + if outcome.lower() == response_clean.lower(): + return outcome + + # Partial match (longest wins, first on length ties) + response_lower = response_clean.lower() + best_outcome: str | None = None + best_len = -1 + for outcome in outcomes: + if outcome.lower() in response_lower and len(outcome) > best_len: + best_outcome = outcome + best_len = len(outcome) + if best_outcome is not None: + return best_outcome + + logger.warning( + f"Could not match LLM response '{response_clean}' to outcomes {list(outcomes)}. " + f"Falling back to first outcome: {outcomes[0]}" + ) + return outcomes[0] + + except Exception as fallback_err: + logger.warning( + f"Simple prompting also failed: {fallback_err}. " + f"Falling back to first outcome: {outcomes[0]}" + ) + return outcomes[0] + + def _log_flow_event( + self, + message: str, + color: str = "yellow", + level: Literal["info", "warning"] = "info", + ) -> None: + """Centralized logging method for flow events. + + This method provides a consistent interface for logging flow-related events, + combining both console output with colors and proper logging levels. + + Args: + message: The message to log + color: Rich style for console output (default: "yellow") + Examples: "yellow", "red", "bold green", "bold magenta" + level: Log level to use (default: info) + Supported levels: info, warning + + Note: + This method uses the centralized Rich console formatter for output + and the standard logging module for log level support. + """ + from crewai.events.event_listener import event_listener + + event_listener.formatter.console.print(message, style=color) + if level == "info": + logger.info(message) + else: + logger.warning(message) + + def plot(self, filename: str = "crewai_flow.html", show: bool = True) -> str: + """Create interactive HTML visualization of Flow structure. + + Args: + filename: Output HTML filename (default: "crewai_flow.html"). + show: Whether to open in browser (default: True). + + Returns: + Absolute path to generated HTML file. + """ + crewai_event_bus.emit( + self, + FlowPlotEvent( + type="flow_plot", + flow_name=self.name or self.__class__.__name__, + ), + ) + structure = build_flow_structure(self) + return render_interactive(structure, filename=filename, show=show) + + @staticmethod + def _show_tracing_disabled_message() -> None: + """Show a message when tracing is disabled.""" + if should_suppress_tracing_messages(): + return + + console = Console() + + if has_user_declined_tracing(): + message = """Info: Tracing is disabled. + +To enable tracing, do any one of these: +• Set tracing=True in your Flow code +• Set CREWAI_TRACING_ENABLED=true in your project's .env file +• Run: crewai traces enable""" + else: + message = """Info: Tracing is disabled. + +To enable tracing, do any one of these: +• Set tracing=True in your Flow code +• Set CREWAI_TRACING_ENABLED=true in your project's .env file +• Run: crewai traces enable""" + + panel = Panel( + message, + title="Tracing Status", + border_style="blue", + padding=(1, 2), + ) + console.print(panel) diff --git a/lib/crewai/src/crewai/flow/utils.py b/lib/crewai/src/crewai/flow/utils.py index 8943bf531..e23354784 100644 --- a/lib/crewai/src/crewai/flow/utils.py +++ b/lib/crewai/src/crewai/flow/utils.py @@ -1,954 +1,53 @@ -""" -Utility functions for flow visualization and dependency analysis. +"""Backwards-compatible shim. The implementation moved to ``crewai.flow.flow_definition``. -This module provides core functionality for analyzing and manipulating flow structures, -including node level calculation, ancestor tracking, and return value analysis. -Functions in this module are primarily used by the visualization system to create -accurate and informative flow diagrams. - -Example -------- ->>> flow = Flow() ->>> node_levels = calculate_node_levels(flow) ->>> ancestors = build_ancestor_dict(flow) +Import from ``crewai.flow.flow_definition`` directly in new code. """ -from __future__ import annotations - -import ast -from collections import defaultdict, deque -from enum import Enum -import inspect -import textwrap -from typing import TYPE_CHECKING, Any - -from crewai_core.printer import PRINTER -from typing_extensions import TypeIs - -from crewai.flow.constants import AND_CONDITION, OR_CONDITION -from crewai.flow.flow_wrappers import ( - FlowCondition, - FlowConditions, - FlowMethod, - SimpleFlowCondition, +from crewai.flow.flow_definition import ( + _extract_all_methods, + _extract_all_methods_recursive, + _extract_string_literals_from_type_annotation, + _normalize_condition, + _unwrap_function, + build_ancestor_dict, + build_parent_children_dict, + calculate_node_levels, + count_outgoing_edges, + dfs_ancestors, + extract_flow_definition, + get_child_index, + get_possible_return_constants, + is_ancestor, + is_flow_condition_dict, + is_flow_condition_list, + is_flow_method, + is_flow_method_callable, + is_flow_method_name, + is_simple_flow_condition, + process_router_paths, ) -from crewai.flow.types import FlowMethodCallable, FlowMethodName -if TYPE_CHECKING: - from crewai.flow.flow import Flow - - -def _extract_string_literals_from_type_annotation( - node: ast.expr, - function_globals: dict[str, Any] | None = None, -) -> list[str]: - """Extract string literals from a type annotation AST node. - - Handles: - - Literal["a", "b", "c"] - - "a" | "b" | "c" (union of string literals) - - Just "a" (single string constant annotation) - - Enum types with string values (e.g., class MyEnum(str, Enum)) - - Args: - node: The AST node representing a type annotation. - function_globals: The globals dict from the function, used to resolve Enum types. - - Returns: - List of string literals found in the annotation. - """ - - strings: list[str] = [] - - if isinstance(node, ast.Constant) and isinstance(node.value, str): - strings.append(node.value) - - elif isinstance(node, ast.Name) and function_globals: - enum_class = function_globals.get(node.id) - if ( - enum_class is not None - and isinstance(enum_class, type) - and issubclass(enum_class, Enum) - ): - strings.extend( - member.value for member in enum_class if isinstance(member.value, str) - ) - - elif isinstance(node, ast.Attribute) and function_globals: - try: - if isinstance(node.value, ast.Name): - module = function_globals.get(node.value.id) - if module is not None: - enum_class = getattr(module, node.attr, None) - if ( - enum_class is not None - and isinstance(enum_class, type) - and issubclass(enum_class, Enum) - ): - strings.extend( - member.value - for member in enum_class - if isinstance(member.value, str) - ) - except (AttributeError, TypeError): - pass - - elif isinstance(node, ast.Subscript): - is_literal = False - if isinstance(node.value, ast.Name) and node.value.id == "Literal": - is_literal = True - elif isinstance(node.value, ast.Attribute) and node.value.attr == "Literal": - is_literal = True - - if is_literal: - if isinstance(node.slice, ast.Tuple): - strings.extend( - elt.value - for elt in node.slice.elts - if isinstance(elt, ast.Constant) and isinstance(elt.value, str) - ) - elif isinstance(node.slice, ast.Constant) and isinstance( - node.slice.value, str - ): - strings.append(node.slice.value) - - elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): - strings.extend( - _extract_string_literals_from_type_annotation(node.left, function_globals) - ) - strings.extend( - _extract_string_literals_from_type_annotation(node.right, function_globals) - ) - - return strings - - -def _unwrap_function(function: Any) -> Any: - """Unwrap a function to get the original function with correct globals. - - Flow methods are wrapped by decorators like @router, @listen, etc. - This function unwraps them to get the original function which has - the correct __globals__ for resolving type annotations like Enums. - - Args: - function: The potentially wrapped function. - - Returns: - The unwrapped original function. - """ - if hasattr(function, "__func__"): - function = function.__func__ - - if hasattr(function, "__wrapped__"): - wrapped = function.__wrapped__ - if hasattr(wrapped, "unwrap"): - return wrapped.unwrap() - return wrapped - - return function - - -def get_possible_return_constants( - function: Any, verbose: bool = True -) -> list[str] | None: - """Extract possible string return values from a function using AST parsing. - - This function analyzes the source code of a router method to identify - all possible string values it might return. It handles: - - Return type annotations: -> Literal["a", "b"] or -> "a" | "b" | "c" - - Enum type annotations: -> MyEnum (extracts string values from members) - - Direct string literals: return "value" - - Variable assignments: x = "value"; return x - - Dictionary lookups: d = {"k": "v"}; return d[key] - - Conditional returns: return "a" if cond else "b" - - State attributes: return self.state.attr (infers from class context) - - Args: - function: The function to analyze. - - Returns: - List of possible string return values, or None if analysis fails. - """ - unwrapped = _unwrap_function(function) - - try: - source = inspect.getsource(function) - except OSError: - return None - except Exception as e: - if verbose: - PRINTER.print( - f"Error retrieving source code for function {function.__name__}: {e}", - color="red", - ) - return None - - try: - source = textwrap.dedent(source) - code_ast = ast.parse(source) - except IndentationError as e: - if verbose: - PRINTER.print( - f"IndentationError while parsing source code of {function.__name__}: {e}", - color="red", - ) - PRINTER.print(f"Source code:\n{source}", color="yellow") - return None - except SyntaxError as e: - if verbose: - PRINTER.print( - f"SyntaxError while parsing source code of {function.__name__}: {e}", - color="red", - ) - PRINTER.print(f"Source code:\n{source}", color="yellow") - return None - except Exception as e: - if verbose: - PRINTER.print( - f"Unexpected error while parsing source code of {function.__name__}: {e}", - color="red", - ) - PRINTER.print(f"Source code:\n{source}", color="yellow") - return None - - return_values: set[str] = set() - - function_globals = getattr(unwrapped, "__globals__", None) - - for node in ast.walk(code_ast): - if isinstance(node, ast.FunctionDef): - if node.returns: - annotation_values = _extract_string_literals_from_type_annotation( - node.returns, function_globals - ) - return_values.update(annotation_values) - break # Only process the first function definition - dict_definitions: dict[str, list[str]] = {} - variable_values: dict[str, list[str]] = {} - state_attribute_values: dict[str, list[str]] = {} - - def extract_string_constants(node: ast.expr) -> list[str]: - """Recursively extract all string constants from an AST node.""" - strings: list[str] = [] - if isinstance(node, ast.Constant) and isinstance(node.value, str): - strings.append(node.value) - elif isinstance(node, ast.IfExp): - strings.extend(extract_string_constants(node.body)) - strings.extend(extract_string_constants(node.orelse)) - elif isinstance(node, ast.Call): - if ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "get" - and len(node.args) >= 2 - ): - default_arg = node.args[1] - if isinstance(default_arg, ast.Constant) and isinstance( - default_arg.value, str - ): - strings.append(default_arg.value) - return strings - - class VariableAssignmentVisitor(ast.NodeVisitor): - def visit_Assign(self, node: ast.Assign) -> None: - if isinstance(node.value, ast.Dict) and len(node.targets) == 1: - target = node.targets[0] - if isinstance(target, ast.Name): - var_name = target.id - dict_values = [ - val.value - for val in node.value.values - if isinstance(val, ast.Constant) and isinstance(val.value, str) - ] - if dict_values: - dict_definitions[var_name] = dict_values - - if len(node.targets) == 1: - target = node.targets[0] - var_name_alt: str | None = None - if isinstance(target, ast.Name): - var_name_alt = target.id - elif isinstance(target, ast.Attribute): - var_name_alt = f"{target.value.id if isinstance(target.value, ast.Name) else '_'}.{target.attr}" - - if var_name_alt: - strings = extract_string_constants(node.value) - if strings: - variable_values[var_name_alt] = strings - - self.generic_visit(node) - - def get_attribute_chain(node: ast.expr) -> str | None: - """Extract the full attribute chain from an AST node. - - Examples: - self.state.run_type -> "self.state.run_type" - x.y.z -> "x.y.z" - simple_var -> "simple_var" - """ - if isinstance(node, ast.Name): - return node.id - if isinstance(node, ast.Attribute): - base = get_attribute_chain(node.value) - if base: - return f"{base}.{node.attr}" - return None - - class ReturnVisitor(ast.NodeVisitor): - def visit_Return(self, node: ast.Return) -> None: - if ( - node.value - and isinstance(node.value, ast.Constant) - and isinstance(node.value.value, str) - ): - return_values.add(node.value.value) - elif node.value and isinstance(node.value, ast.Subscript): - if isinstance(node.value.value, ast.Name): - var_name_dict = node.value.value.id - if var_name_dict in dict_definitions: - for v in dict_definitions[var_name_dict]: - return_values.add(v) - elif node.value: - var_name_ret = get_attribute_chain(node.value) - - if var_name_ret and var_name_ret in variable_values: - for v in variable_values[var_name_ret]: - return_values.add(v) - elif var_name_ret and var_name_ret in state_attribute_values: - for v in state_attribute_values[var_name_ret]: - return_values.add(v) - - self.generic_visit(node) - - def visit_If(self, node: ast.If) -> None: - self.generic_visit(node) - - try: - if hasattr(function, "__self__"): - class_obj = function.__self__.__class__ - elif hasattr(function, "__qualname__") and "." in function.__qualname__: - class_name = function.__qualname__.rsplit(".", 1)[0] - if hasattr(function, "__globals__"): - class_obj = function.__globals__.get(class_name) - else: - class_obj = None - else: - class_obj = None - - if class_obj is not None: - try: - class_source = inspect.getsource(class_obj) - class_source = textwrap.dedent(class_source) - class_ast = ast.parse(class_source) - - class StateAttributeVisitor(ast.NodeVisitor): - def visit_Compare(self, node: ast.Compare) -> None: - """Find comparisons like: self.state.attr == "value" """ - left_attr = get_attribute_chain(node.left) - - if left_attr: - for comparator in node.comparators: - if isinstance(comparator, ast.Constant) and isinstance( - comparator.value, str - ): - if left_attr not in state_attribute_values: - state_attribute_values[left_attr] = [] - if ( - comparator.value - not in state_attribute_values[left_attr] - ): - state_attribute_values[left_attr].append( - comparator.value - ) - - for comparator in node.comparators: - right_attr = get_attribute_chain(comparator) - if ( - right_attr - and isinstance(node.left, ast.Constant) - and isinstance(node.left.value, str) - ): - if right_attr not in state_attribute_values: - state_attribute_values[right_attr] = [] - if ( - node.left.value - not in state_attribute_values[right_attr] - ): - state_attribute_values[right_attr].append( - node.left.value - ) - - self.generic_visit(node) - - StateAttributeVisitor().visit(class_ast) - except Exception as e: - if verbose: - PRINTER.print( - f"Could not analyze class context for {function.__name__}: {e}", - color="yellow", - ) - except Exception as e: - if verbose: - PRINTER.print( - f"Could not introspect class for {function.__name__}: {e}", - color="yellow", - ) - - VariableAssignmentVisitor().visit(code_ast) - ReturnVisitor().visit(code_ast) - - return list(return_values) if return_values else None - - -def calculate_node_levels(flow: Any) -> dict[str, int]: - """ - Calculate the hierarchical level of each node in the flow. - - Performs a breadth-first traversal of the flow graph to assign levels - to nodes, starting with start methods at level 0. - - Parameters - ---------- - flow : Any - The flow instance containing methods, listeners, and router configurations. - - Returns - ------- - Dict[str, int] - Dictionary mapping method names to their hierarchical levels. - - Notes - ----- - - Start methods are assigned level 0 - - Each subsequent connected node is assigned level = parent_level + 1 - - Handles both OR and AND conditions for listeners - - Processes router paths separately - """ - levels: dict[str, int] = {} - queue: deque[str] = deque() - visited: set[str] = set() - pending_and_listeners: dict[str, set[str]] = {} - - for method_name, method in flow._methods.items(): - if hasattr(method, "__is_start_method__"): - levels[method_name] = 0 - queue.append(method_name) - - or_listeners = defaultdict(list) - and_listeners = defaultdict(set) - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - condition_type, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive(condition_data, flow) - condition_type = condition_data.get("type", "OR") - else: - continue - - if condition_type == "OR": - for method in trigger_methods: - or_listeners[method].append(listener_name) - elif condition_type == "AND": - and_listeners[listener_name] = set(trigger_methods) - - while queue: - current = queue.popleft() - current_level = levels[current] - visited.add(current) - - for listener_name in or_listeners[current]: - if listener_name not in levels or levels[listener_name] > current_level + 1: - levels[listener_name] = current_level + 1 - if listener_name not in visited: - queue.append(listener_name) - - for listener_name, required_methods in and_listeners.items(): - if current in required_methods: - if listener_name not in pending_and_listeners: - pending_and_listeners[listener_name] = set() - pending_and_listeners[listener_name].add(current) - - if required_methods == pending_and_listeners[listener_name]: - if ( - listener_name not in levels - or levels[listener_name] > current_level + 1 - ): - levels[listener_name] = current_level + 1 - if listener_name not in visited: - queue.append(listener_name) - - process_router_paths(flow, current, current_level, levels, queue) - - max_level = max(levels.values()) if levels else 0 - for method_name in flow._methods: - if method_name not in levels: - levels[method_name] = max_level + 1 - - return levels - - -def count_outgoing_edges(flow: Any) -> dict[str, int]: - """ - Count the number of outgoing edges for each method in the flow. - - Parameters - ---------- - flow : Any - The flow instance to analyze. - - Returns - ------- - Dict[str, int] - Dictionary mapping method names to their outgoing edge count. - """ - counts = {} - for method_name in flow._methods: - counts[method_name] = 0 - for condition_data in flow._listeners.values(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive(condition_data, flow) - else: - continue - - for trigger in trigger_methods: - if trigger in flow._methods: - counts[trigger] += 1 - return counts - - -def build_ancestor_dict(flow: Any) -> dict[str, set[str]]: - """ - Build a dictionary mapping each node to its ancestor nodes. - - Parameters - ---------- - flow : Any - The flow instance to analyze. - - Returns - ------- - Dict[str, Set[str]] - Dictionary mapping each node to a set of its ancestor nodes. - """ - ancestors: dict[str, set[str]] = {node: set() for node in flow._methods} - visited: set[str] = set() - for node in flow._methods: - if node not in visited: - dfs_ancestors(node, ancestors, visited, flow) - return ancestors - - -def dfs_ancestors( - node: str, ancestors: dict[str, set[str]], visited: set[str], flow: Any -) -> None: - """ - Perform depth-first search to build ancestor relationships. - - Parameters - ---------- - node : str - Current node being processed. - ancestors : Dict[str, Set[str]] - Dictionary tracking ancestor relationships. - visited : Set[str] - Set of already visited nodes. - flow : Any - The flow instance being analyzed. - - Notes - ----- - This function modifies the ancestors dictionary in-place to build - the complete ancestor graph. - """ - if node in visited: - return - visited.add(node) - - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive(condition_data, flow) - else: - continue - - if node in trigger_methods: - ancestors[listener_name].add(node) - ancestors[listener_name].update(ancestors[node]) - dfs_ancestors(listener_name, ancestors, visited, flow) - - if node in flow._routers: - router_method_name = node - paths = flow._router_paths.get(router_method_name, []) - for path in paths: - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive( - condition_data, flow - ) - else: - continue - - if path in trigger_methods: - ancestors[listener_name].update(ancestors[node]) - dfs_ancestors(listener_name, ancestors, visited, flow) - - -def is_ancestor( - node: str, ancestor_candidate: str, ancestors: dict[str, set[str]] -) -> bool: - """ - Check if one node is an ancestor of another. - - Parameters - ---------- - node : str - The node to check ancestors for. - ancestor_candidate : str - The potential ancestor node. - ancestors : Dict[str, Set[str]] - Dictionary containing ancestor relationships. - - Returns - ------- - bool - True if ancestor_candidate is an ancestor of node, False otherwise. - """ - return ancestor_candidate in ancestors.get(node, set()) - - -def build_parent_children_dict(flow: Any) -> dict[str, list[str]]: - """ - Build a dictionary mapping parent nodes to their children. - - Parameters - ---------- - flow : Any - The flow instance to analyze. - - Returns - ------- - Dict[str, List[str]] - Dictionary mapping parent method names to lists of their child method names. - - Notes - ----- - - Maps listeners to their trigger methods - - Maps router methods to their paths and listeners - - Children lists are sorted for consistent ordering - """ - parent_children: dict[str, list[str]] = {} - - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive(condition_data, flow) - else: - continue - - for trigger in trigger_methods: - if trigger not in parent_children: - parent_children[trigger] = [] - if listener_name not in parent_children[trigger]: - parent_children[trigger].append(listener_name) - - for router_method_name, paths in flow._router_paths.items(): - for path in paths: - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive( - condition_data, flow - ) - else: - continue - - if path in trigger_methods: - if router_method_name not in parent_children: - parent_children[router_method_name] = [] - if listener_name not in parent_children[router_method_name]: - parent_children[router_method_name].append(listener_name) - - return parent_children - - -def get_child_index( - parent: str, child: str, parent_children: dict[str, list[str]] -) -> int: - """ - Get the index of a child node in its parent's sorted children list. - - Parameters - ---------- - parent : str - The parent node name. - child : str - The child node name to find the index for. - parent_children : Dict[str, List[str]] - Dictionary mapping parents to their children lists. - - Returns - ------- - int - Zero-based index of the child in its parent's sorted children list. - """ - children = parent_children.get(parent, []) - children.sort() - return children.index(child) - - -def process_router_paths( - flow: Any, - current: str, - current_level: int, - levels: dict[str, int], - queue: deque[str], -) -> None: - """Handle the router connections for the current node.""" - if current in flow._routers: - paths = flow._router_paths.get(current, []) - for path in paths: - for listener_name, condition_data in flow._listeners.items(): - if isinstance(condition_data, tuple): - _condition_type, trigger_methods = condition_data - elif isinstance(condition_data, dict): - trigger_methods = _extract_all_methods_recursive( - condition_data, flow - ) - else: - continue - - if path in trigger_methods: - if ( - listener_name not in levels - or levels[listener_name] > current_level + 1 - ): - levels[listener_name] = current_level + 1 - queue.append(listener_name) - - -def is_flow_method_name(obj: Any) -> TypeIs[FlowMethodName]: - """Check if the object is a valid flow method name. - - Args: - obj: The object to check. - Returns: - True if the object is a valid flow method name, False otherwise. - """ - return isinstance(obj, str) - - -def is_flow_method_callable(obj: Any) -> TypeIs[FlowMethodCallable[..., Any]]: - """Check if the object is a callable flow method. - - Args: - obj: The object to check. - - Returns: - True if the object is a callable, False otherwise. - """ - return callable(obj) and hasattr(obj, "__name__") - - -def is_flow_condition_list(obj: Any) -> TypeIs[FlowConditions]: - """Check if the object is a list of FlowCondition dictionaries. - - Args: - obj: The object to check. - - Returns: - True if the object is a list of FlowCondition dictionaries, False otherwise. - """ - if not isinstance(obj, list): - return False - - for item in obj: - if not (is_flow_method_name(item) or is_flow_condition_dict(item)): - return False - - return True - - -def is_simple_flow_condition(obj: Any) -> TypeIs[SimpleFlowCondition]: - """Check if the object is a simple flow condition tuple. - - Args: - obj: The object to check. - - Returns: - True if the object is a (condition_type, methods) tuple, False otherwise. - """ - return ( - isinstance(obj, tuple) - and len(obj) == 2 - and isinstance(obj[0], str) - and isinstance(obj[1], list) - ) - - -def is_flow_method(obj: Any) -> TypeIs[FlowMethod[Any, Any]]: - """Check if the object is a flow method wrapper. - - Checks for attributes added by @start, @listen, or @router decorators. - - Args: - obj: The object to check. - - Returns: - True if the object is a FlowMethod subclass (StartMethod, ListenMethod, or RouterMethod). - """ - return ( - hasattr(obj, "__is_flow_method__") - or hasattr(obj, "__is_start_method__") - or hasattr(obj, "__trigger_methods__") - or hasattr(obj, "__is_router__") - ) - - -def is_flow_condition_dict(obj: Any) -> TypeIs[FlowCondition]: - """Check if the object matches the FlowCondition structure. - - Args: - obj: The object to check. - - Returns: - True if the object is a valid FlowCondition dictionary, False otherwise. - """ - if not isinstance(obj, dict): - return False - - type_value = obj.get("type") - if type_value not in ("AND", "OR"): - return False - - if "conditions" in obj: - conditions = obj["conditions"] - if not isinstance(conditions, list): - return False - for cond in conditions: - if not ( - isinstance(cond, str) - or (isinstance(cond, dict) and is_flow_condition_dict(cond)) - ): - return False - - if "methods" in obj: - methods = obj["methods"] - if not (isinstance(methods, list) and all(isinstance(m, str) for m in methods)): - return False - - allowed_keys = {"type", "conditions", "methods"} - if not set(obj).issubset(allowed_keys): - return False - - return True - - -def _extract_all_methods_recursive( - condition: str | FlowCondition | dict[str, Any] | list[Any], - flow: Flow[Any] | None = None, -) -> list[FlowMethodName]: - """Extract ALL method names from a condition tree recursively. - - This function recursively extracts every method name from the entire - condition tree, regardless of nesting. Used for visualization and debugging. - - Note: Only extracts actual method names, not router output strings. - If flow is provided, it will filter out strings that are not in flow._methods. - - Args: - condition: Can be a string, dict, or list - flow: Optional flow instance to filter out non-method strings - - Returns: - List of all method names found in the condition tree - """ - if is_flow_method_name(condition): - if flow is not None: - if condition in flow._methods: - return [condition] - return [] - return [condition] - if is_flow_condition_dict(condition): - normalized = _normalize_condition(condition) - methods = [] - for sub_cond in normalized.get("conditions", []): - methods.extend(_extract_all_methods_recursive(sub_cond, flow)) - return methods - if isinstance(condition, list): - methods = [] - for item in condition: - methods.extend(_extract_all_methods_recursive(item, flow)) - return methods - return [] - - -def _normalize_condition( - condition: FlowConditions | FlowCondition | FlowMethodName, -) -> FlowCondition: - """Normalize a condition to standard format with 'conditions' key. - - Args: - condition: Can be a string (method name), dict (condition), or list - - Returns: - Normalized dict with 'type' and 'conditions' keys - """ - if is_flow_method_name(condition): - return {"type": OR_CONDITION, "conditions": [condition]} - if is_flow_condition_dict(condition): - if "conditions" in condition: - return condition - if "methods" in condition: - return {"type": condition["type"], "conditions": condition["methods"]} - return condition - if is_flow_condition_list(condition): - return {"type": OR_CONDITION, "conditions": condition} - - raise ValueError(f"Cannot normalize condition: {condition}") - - -def _extract_all_methods( - condition: str | FlowCondition | dict[str, Any] | list[Any], -) -> list[FlowMethodName]: - """Extract all method names from a condition (including nested). - - For AND conditions, this extracts methods that must ALL complete. - For OR conditions nested inside AND, we don't extract their methods - since only one branch of the OR needs to trigger, not all methods. - - This function is used for runtime execution logic, where we need to know - which methods must complete for AND conditions. For visualization purposes, - use _extract_all_methods_recursive() instead. - - Args: - condition: Can be a string, dict, or list - - Returns: - List of all method names in the condition tree that must complete - """ - if is_flow_method_name(condition): - return [condition] - if is_flow_condition_dict(condition): - normalized = _normalize_condition(condition) - cond_type = normalized.get("type", OR_CONDITION) - - if cond_type == AND_CONDITION: - return [ - sub_cond - for sub_cond in normalized.get("conditions", []) - if is_flow_method_name(sub_cond) - ] - return [] - if isinstance(condition, list): - methods = [] - for item in condition: - methods.extend(_extract_all_methods(item)) - return methods - return [] +__all__ = [ + "_extract_all_methods", + "_extract_all_methods_recursive", + "_extract_string_literals_from_type_annotation", + "_normalize_condition", + "_unwrap_function", + "build_ancestor_dict", + "build_parent_children_dict", + "calculate_node_levels", + "count_outgoing_edges", + "dfs_ancestors", + "extract_flow_definition", + "get_child_index", + "get_possible_return_constants", + "is_ancestor", + "is_flow_condition_dict", + "is_flow_condition_list", + "is_flow_method", + "is_flow_method_callable", + "is_flow_method_name", + "is_simple_flow_condition", + "process_router_paths", +] diff --git a/lib/crewai/tests/test_async_human_feedback.py b/lib/crewai/tests/test_async_human_feedback.py index 54a235f5d..fbd047ccf 100644 --- a/lib/crewai/tests/test_async_human_feedback.py +++ b/lib/crewai/tests/test_async_human_feedback.py @@ -569,13 +569,13 @@ class TestFlowResumeWithFeedback: flow = TestFlow.from_pending("async-direct-test", persistence) - with patch("crewai.flow.flow.crewai_event_bus.emit"): + with patch("crewai.flow.runtime.crewai_event_bus.emit"): result = await flow.resume_async("async feedback") assert flow.last_human_feedback is not None assert flow.last_human_feedback.feedback == "async feedback" - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_resume_basic(self, mock_emit: MagicMock) -> None: """Test basic resume functionality.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -615,7 +615,7 @@ class TestFlowResumeWithFeedback: assert persistence.load_pending_feedback("resume-test-123") is None - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_resume_routing(self, mock_emit: MagicMock) -> None: """Test resume with routing.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -697,7 +697,7 @@ class TestAsyncHumanFeedbackIntegration: assert hasattr(method, "__human_feedback_config__") assert method.__human_feedback_config__.provider is not None - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_async_provider_pauses_flow(self, mock_emit: MagicMock) -> None: """Test that async provider pauses flow execution.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -743,7 +743,7 @@ class TestAsyncHumanFeedbackIntegration: persisted = persistence.load_pending_feedback(flow_id) assert persisted is not None - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_full_async_flow_cycle(self, mock_emit: MagicMock) -> None: """Test complete async flow: start -> pause -> resume.""" with tempfile.TemporaryDirectory() as tmpdir: @@ -804,7 +804,7 @@ class TestAsyncHumanFeedbackIntegration: class TestAutoPersistence: """Tests for automatic persistence when no persistence is provided.""" - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_auto_persistence_when_none_provided(self, mock_emit: MagicMock) -> None: """Test that persistence is auto-created when HumanFeedbackPending is raised.""" @@ -925,7 +925,7 @@ class TestCollapseToOutcomeJsonParsing: class TestLLMObjectPreservedInContext: """Tests that BaseLLM objects have their model string preserved in PendingFeedbackContext.""" - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_basellm_object_model_string_survives_roundtrip(self, mock_emit: MagicMock) -> None: """Test that when llm is a BaseLLM object, its model string is stored in context so that outcome collapsing works after async pause/resume. @@ -1125,7 +1125,7 @@ class TestAsyncHumanFeedbackEdgeCases: flow = TestFlow.from_pending("default-test", persistence) - with patch("crewai.flow.flow.crewai_event_bus.emit"): + with patch("crewai.flow.runtime.crewai_event_bus.emit"): result = flow.resume("") assert flow.last_human_feedback.outcome == "approved" @@ -1159,7 +1159,7 @@ class TestAsyncHumanFeedbackEdgeCases: flow = TestFlow.from_pending("no-feedback-test", persistence) - with patch("crewai.flow.flow.crewai_event_bus.emit"): + with patch("crewai.flow.runtime.crewai_event_bus.emit"): result = flow.resume() assert flow.last_human_feedback.outcome == "approved" @@ -1213,7 +1213,7 @@ class TestLiveLLMPreservationOnResume: assert hasattr(method, "_hf_llm") assert method._hf_llm == "gpt-4o-mini" - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_resume_async_uses_live_basellm_over_serialized_string( self, mock_emit: MagicMock ) -> None: @@ -1286,7 +1286,7 @@ class TestLiveLLMPreservationOnResume: # And verify it's a BaseLLM instance, not a string assert isinstance(captured_llm[0], BaseLLM) - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_resume_async_falls_back_to_serialized_string_when_no_hf_llm( self, mock_emit: MagicMock ) -> None: @@ -1344,7 +1344,7 @@ class TestLiveLLMPreservationOnResume: assert isinstance(captured_llm[0], BaseLLMClass) assert captured_llm[0].model == "gpt-4o-mini" - @patch("crewai.flow.flow.crewai_event_bus.emit") + @patch("crewai.flow.runtime.crewai_event_bus.emit") def test_resume_async_uses_string_from_context_when_hf_llm_is_string( self, mock_emit: MagicMock ) -> None: