Files
crewAI/lib/crewai/tests/test_flow_from_definition.py
2026-06-16 12:59:48 -07:00

2469 lines
65 KiB
Python

from __future__ import annotations
import asyncio
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
import threading
from typing import Any, ClassVar
from unittest.mock import patch
import pytest
from pydantic import BaseModel, ValidationError
from crewai.events.event_bus import crewai_event_bus
from crewai.events.types.flow_events import (
FlowCreatedEvent,
FlowFinishedEvent,
FlowStartedEvent,
MethodExecutionFinishedEvent,
MethodExecutionStartedEvent,
)
from crewai.flow import Flow, and_, human_feedback, listen, or_, router, start
from crewai.flow.async_feedback import HumanFeedbackPending, PendingFeedbackContext
from crewai.flow.flow import FlowState
from crewai.flow.flow_config import flow_config
from crewai.flow.flow_definition import FlowConfigDefinition, FlowDefinition
from crewai.flow.persistence import persist
from crewai.flow.persistence.base import FlowPersistence
from crewai.state.checkpoint_config import CheckpointConfig
from crewai.tools import BaseTool
from crewai.types.streaming import FlowStreamingOutput
class StaticSearchTool(BaseTool):
name: str = "StaticSearchTool"
description: str = "Returns a deterministic search result."
def _run(self, search_query: str, prefix: str = "search") -> str:
return f"{prefix}:{search_query}"
class TypedInputsTool(BaseTool):
name: str = "TypedInputsTool"
description: str = "Returns typed input details."
def _run(self, count: int, include_domains: list[str]) -> str:
return f"{count}:{','.join(include_domains)}"
class AsyncResultTool(BaseTool):
name: str = "AsyncResultTool"
description: str = "Returns an async result from its sync entrypoint."
def _run(self, value: str) -> Any:
async def build_result() -> str:
await asyncio.sleep(0)
return f"async:{value}"
return build_result()
class CallableCodeAction:
def __call__(self, value: str) -> str:
return f"callable:{value}"
CALLABLE_CODE_ACTION = CallableCodeAction()
class ChainFlow(Flow):
@start()
def begin(self):
self.state["begin_ran"] = True
return "hello"
@listen(begin)
def shout(self, result):
return result.upper()
@listen(shout)
def confirm(self):
self.state["confirmed"] = True
return f"confirmed:{self.state['confirmed']}"
class ToolInputFlow(Flow):
@start()
def build_query(self):
self.state["prefix"] = "found"
return {"query": "ai agents", "suffix": " news"}
class EachActionFlow(Flow):
inner_thread_id: int | None = None
def normalize_row(self, row: str, prefix: str = "normalized") -> str:
return f"{prefix}:{row}"
def save_row(self, row: str, normalized: str) -> dict[str, str]:
return {"row": row, "normalized": normalized}
def keyword_code(self, name: str, punctuation: str) -> str:
return f"{name}{punctuation}"
def fail_on_bad_row(self, row: str) -> str:
if row == "bad":
raise RuntimeError("bad row")
return row
def require_threaded_context(self, row: str) -> str:
try:
asyncio.get_running_loop()
except RuntimeError:
pass
else:
raise RuntimeError("inner action ran on the event loop")
from crewai.flow.flow_context import current_flow_method_name
self.inner_thread_id = threading.get_ident()
return f"{current_flow_method_name.get()}:{row}"
def after_each(self) -> str:
self.state["after_count"] = self.state.get("after_count", 0) + 1
return f"after:{self.state['after_count']}"
CHAIN_YAML = f"""
schema: crewai.flow/v1
name: ChainFlow
methods:
begin:
do:
call: code
ref: {__name__}:ChainFlow.begin
start: true
shout:
do:
ref: {__name__}:ChainFlow.shout
listen: begin
confirm:
do:
ref: {__name__}:ChainFlow.confirm
listen: shout
"""
class MergeFlow(Flow):
@start()
def begin(self):
return "go"
@listen(begin)
def left(self):
return "left"
@listen(begin)
def right(self):
return "right"
@listen(or_(left, right))
def either(self):
self.state["either_ran"] = True
return "either"
@listen(and_(left, right, either))
def join(self):
self.state["joined"] = True
return "joined"
MERGE_YAML = f"""
schema: crewai.flow/v1
name: MergeFlow
methods:
begin:
do:
ref: {__name__}:MergeFlow.begin
start: true
left:
do:
ref: {__name__}:MergeFlow.left
listen: begin
right:
do:
ref: {__name__}:MergeFlow.right
listen: begin
either:
do:
ref: {__name__}:MergeFlow.either
listen:
or: [left, right]
join:
do:
ref: {__name__}:MergeFlow.join
listen:
and: [left, right, either]
"""
class RouteFlow(Flow):
@start()
def begin(self):
return "go"
@router(begin)
def decide(self):
return "left" if self.state.get("direction") == "left" else "right"
@listen("left")
def take_left(self):
return "took-left"
@listen("right")
def take_right(self):
return "took-right"
ROUTE_YAML = f"""
schema: crewai.flow/v1
name: RouteFlow
methods:
begin:
do:
ref: {__name__}:RouteFlow.begin
start: true
decide:
do:
ref: {__name__}:RouteFlow.decide
listen: begin
router: true
take_left:
do:
ref: {__name__}:RouteFlow.take_left
listen: left
take_right:
do:
ref: {__name__}:RouteFlow.take_right
listen: right
"""
class LoopFlow(Flow):
@start("retry")
def step(self):
self.state["count"] = self.state.get("count", 0) + 1
return self.state["count"]
@router(step)
def decide(self):
if self.state["count"] < 3:
return "retry"
return "done"
@listen("done")
def finish(self):
return f"finished:{self.state['count']}"
LOOP_YAML = f"""
schema: crewai.flow/v1
name: LoopFlow
methods:
step:
do:
ref: {__name__}:LoopFlow.step
start: retry
decide:
do:
ref: {__name__}:LoopFlow.decide
listen: step
router: true
finish:
do:
ref: {__name__}:LoopFlow.finish
listen: done
"""
class CounterState(FlowState):
count: int = 0
label: str = "none"
class PydanticStateFlow(Flow[CounterState]):
@start()
def begin(self):
self.state.count += 1
return self.state.count
@listen(begin)
def finish(self):
self.state.label = f"count={self.state.count}"
return self.state.label
PYDANTIC_STATE_YAML = f"""
schema: crewai.flow/v1
name: PydanticStateFlow
state:
type: pydantic
ref: {__name__}:CounterState
methods:
begin:
do:
ref: {__name__}:PydanticStateFlow.begin
start: true
finish:
do:
ref: {__name__}:PydanticStateFlow.finish
listen: begin
"""
PYDANTIC_STATE_OVERLAY_YAML = f"""
schema: crewai.flow/v1
name: PydanticStateFlow
state:
type: pydantic
ref: {__name__}:CounterState
default:
count: 5
methods:
begin:
do:
ref: {__name__}:PydanticStateFlow.begin
start: true
finish:
do:
ref: {__name__}:PydanticStateFlow.finish
listen: begin
"""
JSON_SCHEMA_STATE_YAML = f"""
schema: crewai.flow/v1
name: JsonSchemaStateFlow
state:
type: json_schema
json_schema:
title: CounterState
type: object
properties:
count:
type: integer
default: 0
label:
type: string
default: none
methods:
begin:
do:
ref: {__name__}:PydanticStateFlow.begin
start: true
finish:
do:
ref: {__name__}:PydanticStateFlow.finish
listen: begin
"""
PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML = f"""
schema: crewai.flow/v1
name: SchemaFallbackFlow
state:
type: pydantic
ref: definitely_not_a_module_xyz:MissingState
json_schema:
title: CounterState
type: object
properties:
count:
type: integer
default: 0
label:
type: string
default: none
methods:
begin:
do:
ref: {__name__}:PydanticStateFlow.begin
start: true
finish:
do:
ref: {__name__}:PydanticStateFlow.finish
listen: begin
"""
UNRESOLVABLE_STATE_YAML = f"""
schema: crewai.flow/v1
name: UnresolvableStateFlow
state:
type: pydantic
ref: definitely_not_a_module_xyz:MissingState
methods:
begin:
do:
ref: {__name__}:ChainFlow.begin
start: true
"""
DICT_STATE_YAML = f"""
schema: crewai.flow/v1
name: DictStateFlow
state:
type: dict
default:
count: 5
methods:
begin:
do:
ref: {__name__}:ChainFlow.begin
start: true
"""
UNKNOWN_STATE_YAML = f"""
schema: crewai.flow/v1
name: UnknownStateFlow
state:
type: unknown
ref: somewhere:Something
methods:
begin:
do:
ref: {__name__}:ChainFlow.begin
start: true
"""
def _run_with_events(flow, inputs=None):
events = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionStartedEvent)
def on_started(source, event):
events.append(event)
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def on_finished(source, event):
events.append(event)
result = flow.kickoff(inputs=inputs)
events.sort(key=lambda e: e.timestamp)
return result, [
(type(e).__name__, str(e.method_name), e.flow_name) for e in events
]
def _state_without_id(flow):
snapshot = dict(flow.state.model_dump())
snapshot.pop("id", None)
return snapshot
def assert_parity(flow_cls, yaml_str, inputs=None, ordered=True):
class_flow = flow_cls()
class_result, class_events = _run_with_events(class_flow, inputs)
definition = FlowDefinition.from_yaml(yaml_str)
definition_flow = Flow.from_definition(definition)
definition_result, definition_events = _run_with_events(definition_flow, inputs)
assert definition_result == class_result
assert _state_without_id(definition_flow) == _state_without_id(class_flow)
if ordered:
assert definition_flow.method_outputs == class_flow.method_outputs
assert definition_events == class_events
else:
assert sorted(map(repr, definition_flow.method_outputs)) == sorted(
map(repr, class_flow.method_outputs)
)
assert sorted(definition_events) == sorted(class_events)
return definition_flow, definition_result
def test_simple_chain_parity():
flow, result = assert_parity(ChainFlow, CHAIN_YAML)
assert result == "confirmed:True"
assert flow.method_outputs == ["hello", "HELLO", "confirmed:True"]
def test_and_or_merge_parity():
flow, _ = assert_parity(MergeFlow, MERGE_YAML, ordered=False)
assert flow.state["joined"] is True
assert flow.state["either_ran"] is True
def test_router_label_parity_for_each_branch():
left_flow, _ = assert_parity(RouteFlow, ROUTE_YAML, inputs={"direction": "left"})
assert "took-left" in left_flow.method_outputs
assert "took-right" not in left_flow.method_outputs
right_flow, _ = assert_parity(RouteFlow, ROUTE_YAML, inputs={"direction": "right"})
assert "took-right" in right_flow.method_outputs
def test_cyclic_flow_parity():
flow, result = assert_parity(LoopFlow, LOOP_YAML)
assert result == "finished:3"
assert flow.state["count"] == 3
def test_definition_flow_events_use_definition_name():
definition = FlowDefinition.from_yaml(CHAIN_YAML)
flow = Flow.from_definition(definition)
_, events = _run_with_events(flow)
assert events
assert all(flow_name == "ChainFlow" for _, _, flow_name in events)
def test_definition_method_without_action_is_invalid():
with pytest.raises(ValidationError, match="do"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "NoActions",
"methods": {"begin": {"start": True}},
}
)
def test_from_definition_unresolvable_ref_raises():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "BadRefs",
"methods": {
"begin": {
"start": True,
"do": {"ref": "definitely_not_a_module_xyz:nope"},
}
},
}
)
with pytest.raises(ValueError, match="unresolvable actions.*begin"):
Flow.from_definition(definition)
def test_from_definition_malformed_ref_raises():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "MalformedRefs",
"methods": {"begin": {"start": True, "do": {"ref": "no-colon-here"}}},
}
)
with pytest.raises(ValueError, match="expected 'module:qualname'"):
Flow.from_definition(definition)
def test_from_definition_local_scope_ref_raises():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "LocalRefs",
"methods": {
"begin": {
"start": True,
"do": {"ref": f"{__name__}:make.<locals>.LocalFlow.begin"},
}
},
}
)
with pytest.raises(ValueError, match="expected 'module:qualname'"):
Flow.from_definition(definition)
def test_flow_definition_stamps_refs():
definition = ChainFlow.flow_definition()
assert definition.methods["begin"].do.ref == f"{__name__}:ChainFlow.begin"
assert definition.methods["shout"].do.ref == f"{__name__}:ChainFlow.shout"
def test_from_definition_runs_tool_action_with_static_inputs():
yaml_str = f"""
schema: crewai.flow/v1
name: ToolFlow
methods:
search:
do:
call: tool
ref: {__name__}:StaticSearchTool
with:
search_query: ai agents
prefix: found
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff() == "found:ai agents"
def test_tool_action_round_trips_with_inputs():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
"methods": {
"search": {
"start": True,
"do": {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {"search_query": "ai agents"},
},
}
},
}
)
assert definition.to_dict()["methods"]["search"]["do"] == {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {"search_query": "ai agents"},
}
assert Flow.from_definition(definition).kickoff() == "search:ai agents"
def test_tool_action_renders_cel_inputs_at_runtime():
yaml_str = f"""
schema: crewai.flow/v1
name: ToolFlow
methods:
begin:
do:
call: code
ref: {__name__}:ChainFlow.begin
start: true
search:
do:
call: tool
ref: {__name__}:StaticSearchTool
with:
search_query: "${{state.begin_ran ? state.topic + ' agents' : 'missing'}}"
prefix: found
listen: begin
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"topic": "ai"}) == "found:ai agents"
def test_tool_action_rejects_braces_in_embedded_cel_input():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
"methods": {
"search": {
"start": True,
"do": {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {
"search_query": "wrapped ${'a}b'} value",
"prefix": "${'p}x'}",
},
},
}
},
}
)
with pytest.raises(ValueError, match="cannot contain braces"):
Flow.from_definition(definition).kickoff()
def test_tool_action_rejects_braces_in_full_cel_input():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
"methods": {
"search": {
"start": True,
"do": {
"call": "tool",
"ref": f"{__name__}:StaticSearchTool",
"with": {
"search_query": "${{'query': 'ai agents'}.query}",
"prefix": "found",
},
},
}
},
}
)
with pytest.raises(ValueError, match="cannot contain braces"):
Flow.from_definition(definition).kickoff()
def test_tool_action_renders_latest_output_by_method_name():
yaml_str = f"""
schema: crewai.flow/v1
name: ToolFlow
methods:
begin:
do:
call: code
ref: {__name__}:ChainFlow.begin
start: true
search:
do:
call: tool
ref: {__name__}:StaticSearchTool
with:
search_query: "${{outputs.begin + ' agents'}}"
listen: begin
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff() == "search:hello agents"
def test_tool_action_uses_state_and_outputs_in_full_yaml_example():
yaml_str = f"""
schema: crewai.flow/v1
name: ToolFlow
methods:
build_query:
do:
call: code
ref: {__name__}:ToolInputFlow.build_query
start: true
search:
do:
call: tool
ref: {__name__}:StaticSearchTool
with:
search_query: "${{outputs.build_query.query + outputs.build_query.suffix}}"
prefix: "${{state.prefix}}"
listen: build_query
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff() == "found:ai agents news"
def test_tool_action_preserves_whole_expression_value_types():
yaml_str = f"""
schema: crewai.flow/v1
name: ToolFlow
methods:
typed:
do:
call: tool
ref: {__name__}:TypedInputsTool
with:
count: "${{state.limit}}"
include_domains: "${{state.domains}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert (
flow.kickoff(inputs={"limit": 2, "domains": ["crewai.com", "example.com"]})
== "2:crewai.com,example.com"
)
def test_crew_action_runs_inline_yaml_definition(monkeypatch: pytest.MonkeyPatch):
from crewai import Crew
async def fake_kickoff_async(
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
) -> dict[str, Any]:
return {
"crew": self.name,
"agents": [agent.role for agent in self.agents],
"tasks": [task.description for task in self.tasks],
"inputs": inputs,
}
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
yaml_str = """
schema: crewai.flow/v1
name: CrewFlow
methods:
research:
do:
call: crew
with:
name: inline_research
agents:
researcher:
role: Researcher
goal: Research {topic}
backstory: Knows things.
tasks:
- name: research_task
description: Research {topic}
expected_output: Findings about {topic}
agent: researcher
inputs:
topic: "${state.topic}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"topic": "AI"}) == {
"crew": "inline_research",
"agents": ["Researcher"],
"tasks": ["Research {topic}"],
"inputs": {"topic": "AI"},
}
def test_crew_action_round_trips_with_inline_definition():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
"methods": {
"research": {
"start": True,
"do": {
"call": "crew",
"with": {
"name": "inline_research",
"agents": {
"researcher": {
"role": "Researcher",
"goal": "Research {topic}",
"backstory": "Knows things.",
}
},
"tasks": [
{
"name": "research_task",
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher",
}
],
"inputs": {"topic": "${state.topic}"},
},
},
}
},
}
)
assert definition.to_dict()["methods"]["research"]["do"]["call"] == "crew"
assert (
definition.to_dict()["methods"]["research"]["do"]["with"]["agents"][
"researcher"
]["role"]
== "Researcher"
)
def test_crew_action_normalizes_named_agent_list_definition():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
"methods": {
"research": {
"start": True,
"do": {
"call": "crew",
"with": {
"agents": [
{
"name": "researcher",
"role": "Researcher",
"goal": "Research {topic}",
"backstory": "Knows things.",
}
],
"tasks": [
{
"description": "Research {topic}",
"expected_output": "Findings about {topic}",
"agent": "researcher",
}
],
},
},
}
},
}
)
assert (
definition.to_dict()["methods"]["research"]["do"]["with"]["agents"][
"researcher"
]["role"]
== "Researcher"
)
def test_crew_action_json_schema_describes_inline_crew_definitions():
schema_defs = FlowDefinition.json_schema()["$defs"]
agents_schema = schema_defs["CrewDefinition"]["properties"]["agents"]
assert set(schema_defs["CrewDefinition"]["properties"]) >= {
"agents",
"tasks",
"inputs",
}
assert {option["type"] for option in agents_schema["anyOf"]} == {"array", "object"}
assert set(schema_defs["CrewAgentDefinition"]["properties"]) >= {
"role",
"goal",
"backstory",
"settings",
}
assert set(schema_defs["CrewTaskDefinition"]["properties"]) >= {
"description",
"expected_output",
"agent",
"context",
}
def test_crew_action_rejects_incomplete_inline_agent_definition():
with pytest.raises(ValidationError, match="goal"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
"methods": {
"research": {
"start": True,
"do": {
"call": "crew",
"with": {
"agents": {
"researcher": {
"role": "Researcher",
"backstory": "Knows things.",
}
},
"tasks": [
{
"description": "Research",
"expected_output": "Findings",
"agent": "researcher",
}
],
},
},
}
},
}
)
def test_crew_action_rejects_ref():
with pytest.raises(ValidationError, match="ref"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
"methods": {
"research": {
"start": True,
"do": {
"call": "crew",
"ref": "project.crew:build_crew",
"with": {"inputs": {"topic": "AI"}},
},
}
},
}
)
def test_crew_action_rejects_non_mapping_inputs_in_definition():
with pytest.raises(ValidationError, match="crew.inputs must be a mapping"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "CrewFlow",
"methods": {
"research": {
"start": True,
"do": {
"call": "crew",
"with": {
"agents": {
"researcher": {
"role": "Researcher",
"goal": "Research",
"backstory": "Knows things.",
}
},
"tasks": [
{
"description": "Research",
"expected_output": "Findings",
"agent": "researcher",
}
],
"inputs": "topic",
},
},
}
},
}
)
def test_tool_action_reports_invalid_cel_expression():
yaml_str = f"""
schema: crewai.flow/v1
name: ToolFlow
methods:
search:
do:
call: tool
ref: {__name__}:StaticSearchTool
with:
search_query: "${{state.}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(ValueError, match="failed to evaluate CEL expression"):
flow.kickoff()
def test_code_action_renders_keyword_inputs():
yaml_str = f"""
schema: crewai.flow/v1
name: CodeWithFlow
methods:
greet:
do:
call: code
ref: {__name__}:EachActionFlow.keyword_code
with:
name: "${{state.name}}"
punctuation: "!"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"name": "hello"}) == "hello!"
def test_code_action_supports_callable_instance_refs():
yaml_str = f"""
schema: crewai.flow/v1
name: CallableInstanceFlow
methods:
call_instance:
do:
call: code
ref: {__name__}:CALLABLE_CODE_ACTION
with:
value: "${{state.value}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"value": "ok"}) == "callable:ok"
def test_each_action_executes_one_nested_code_action():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
row: "${{item}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
"normalized:a",
"normalized:b",
]
def test_each_action_runs_sync_inner_actions_off_event_loop_with_context():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- threaded:
call: code
ref: {__name__}:EachActionFlow.require_threaded_context
with:
row: "${{item}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
caller_thread_id = threading.get_ident()
assert flow.kickoff(inputs={"rows": ["a"]}) == ["process_rows:a"]
assert flow.inner_thread_id is not None
assert flow.inner_thread_id != caller_thread_id
def test_each_action_runs_async_tool_results_from_sync_inner_actions():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- async_tool:
call: tool
ref: {__name__}:AsyncResultTool
with:
value: "${{item}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"]
def test_each_action_uses_iteration_outputs_between_nested_actions():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
row: "${{item}}"
prefix: saved
- save:
call: code
ref: {__name__}:EachActionFlow.save_row
with:
row: "${{item}}"
normalized: "${{outputs.normalize}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
{"row": "a", "normalized": "saved:a"},
{"row": "b", "normalized": "saved:b"},
]
def test_each_action_resets_inner_outputs_between_iterations():
yaml_str = """
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- leak_check:
call: expression
expr: "has(outputs.previous) ? outputs.previous : 'empty'"
- previous:
call: expression
expr: item
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["a", "b"]
assert flow._method_outputs == [
{"method": "process_rows", "output": ["a", "b"]}
]
def test_each_action_preserves_flow_outputs_and_prefers_inner_outputs():
yaml_str = """
schema: crewai.flow/v1
name: EachFlow
methods:
seed:
do:
call: expression
expr: "'global'"
start: true
process_rows:
do:
call: each
in: state.rows
do:
- before_shadow:
call: expression
expr: "outputs.seed + ':' + item"
- seed:
call: expression
expr: "'local:' + item"
- after_shadow:
call: expression
expr: "outputs.seed"
listen: seed
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
"local:a",
"local:b",
]
assert flow._method_outputs == [
{"method": "seed", "output": "global"},
{"method": "process_rows", "output": ["local:a", "local:b"]},
]
def test_each_action_empty_list_returns_empty_and_listener_runs_once():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- normalize:
call: code
ref: {__name__}:EachActionFlow.normalize_row
with:
row: "${{item}}"
start: true
after_each:
do:
call: code
ref: {__name__}:EachActionFlow.after_each
listen: process_rows
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
events = []
with crewai_event_bus.scoped_handlers():
@crewai_event_bus.on(MethodExecutionFinishedEvent)
def on_finished(source, event):
events.append(event.method_name)
result = flow.kickoff(inputs={"rows": []})
assert result == "after:1"
assert flow.method_outputs == [[], "after:1"]
assert flow.state["after_count"] == 1
assert events.count("process_rows") == 1
assert events.count("after_each") == 1
@pytest.mark.parametrize(
("expr", "inputs"),
[
("1", {}),
('"rows"', {}),
("state.rows", {"rows": {"a": 1}}),
],
)
def test_each_action_rejects_non_list_inputs(expr, inputs):
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
"methods": {
"process_rows": {
"start": True,
"do": {
"call": "each",
"in": expr,
"do": [{"value": {"call": "expression", "expr": "item"}}],
},
}
},
}
)
flow = Flow.from_definition(definition)
with pytest.raises(ValueError, match="each.in must evaluate to an array"):
flow.kickoff(inputs=inputs)
@pytest.mark.parametrize(
"action_do",
[
[],
[{"first": {"call": "expression", "expr": "item"}, "second": {"call": "expression", "expr": "item"}}],
[{"1bad": {"call": "expression", "expr": "item"}}],
[
{"same": {"call": "expression", "expr": "item"}},
{"same": {"call": "expression", "expr": "item"}},
],
],
)
def test_each_action_validates_inner_action_shape(action_do):
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
"methods": {
"process_rows": {
"start": True,
"do": {
"call": "each",
"in": "state.rows",
"do": action_do,
},
}
},
}
)
def test_each_action_rejects_nested_each_actions():
with pytest.raises(ValidationError):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "EachFlow",
"methods": {
"process_rows": {
"start": True,
"do": {
"call": "each",
"in": "state.rows",
"do": [
{
"nested": {
"call": "each",
"in": "state.children",
"do": [
{
"child": {
"call": "expression",
"expr": "item",
}
}
],
}
}
],
},
}
},
}
)
def test_each_action_failure_fails_outer_method():
yaml_str = f"""
schema: crewai.flow/v1
name: EachFlow
methods:
process_rows:
do:
call: each
in: state.rows
do:
- validate:
call: code
ref: {__name__}:EachActionFlow.fail_on_bad_row
with:
row: "${{item}}"
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(RuntimeError, match="bad row"):
flow.kickoff(inputs={"rows": ["ok", "bad"]})
def test_expression_action_round_trips():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ExpressionFlow",
"methods": {
"classify": {
"start": True,
"do": {
"call": "expression",
"expr": "state.score >= 80 ? 'qualified' : 'nurture'",
},
}
},
}
)
assert definition.to_dict()["methods"]["classify"]["do"] == {
"call": "expression",
"expr": "state.score >= 80 ? 'qualified' : 'nurture'",
}
assert Flow.from_definition(definition).kickoff(inputs={"score": 90}) == "qualified"
def test_expression_local_context_recurses_into_dataclass_values():
from crewai.flow.runtime._expressions import evaluate_expression
class Payload(BaseModel):
name: str
@dataclass
class Row:
payload: Payload
assert (
evaluate_expression(
Flow(),
"item.payload.name",
local_context={"item": Row(payload=Payload(name="qualified"))},
)
== "qualified"
)
def test_expression_action_can_route_like_if_else():
yaml_str = f"""
schema: crewai.flow/v1
name: ExpressionRouterFlow
methods:
begin:
do:
call: code
ref: {__name__}:ChainFlow.begin
start: true
decide:
do:
call: expression
expr: "state.direction == 'left' ? 'left' : 'right'"
listen: begin
router: true
emit: [left, right]
take_left:
do:
call: code
ref: {__name__}:RouteFlow.take_left
listen: left
take_right:
do:
call: code
ref: {__name__}:RouteFlow.take_right
listen: right
"""
definition = FlowDefinition.from_yaml(yaml_str)
assert Flow.from_definition(definition).kickoff(
inputs={"direction": "left"}
) == "took-left"
assert Flow.from_definition(definition).kickoff(
inputs={"direction": "right"}
) == "took-right"
def test_expression_action_reports_invalid_cel_expression():
yaml_str = """
schema: crewai.flow/v1
name: ExpressionFlow
methods:
classify:
do:
call: expression
expr: "state."
start: true
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(ValueError, match="failed to evaluate CEL expression"):
flow.kickoff()
def test_tool_action_requires_module_qualname_ref():
definition = FlowDefinition.from_dict(
{
"schema": "crewai.flow/v1",
"name": "ToolFlow",
"methods": {
"search": {
"start": True,
"do": {
"call": "tool",
"ref": f"{__name__}.StaticSearchTool",
"with": {"search_query": "ai agents"},
},
}
},
}
)
with pytest.raises(ValueError, match="expected 'module:qualname'"):
Flow.from_definition(definition)
def test_pydantic_state_from_ref_parity():
flow, result = assert_parity(PydanticStateFlow, PYDANTIC_STATE_YAML)
assert result == "count=1"
assert flow.state.count == 1
assert flow.state.label == "count=1"
assert flow.state.id
def test_pydantic_state_default_overlay():
flow = Flow.from_definition(FlowDefinition.from_yaml(PYDANTIC_STATE_OVERLAY_YAML))
result = flow.kickoff()
assert result == "count=6"
assert flow.state.count == 6
def test_json_schema_state():
flow = Flow.from_definition(FlowDefinition.from_yaml(JSON_SCHEMA_STATE_YAML))
result = flow.kickoff()
assert result == "count=1"
assert flow.state.count == 1
assert flow.state.label == "count=1"
assert flow.state.id
def test_json_schema_state_validates_inputs():
flow = Flow.from_definition(FlowDefinition.from_yaml(JSON_SCHEMA_STATE_YAML))
with pytest.raises(ValueError, match="Invalid inputs"):
flow.kickoff(inputs={"count": "not-a-number"})
def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable():
flow = Flow.from_definition(
FlowDefinition.from_yaml(PYDANTIC_REF_WITH_SCHEMA_FALLBACK_YAML)
)
result = flow.kickoff()
assert result == "count=1"
assert flow.state.count == 1
def test_pydantic_state_without_ref_or_schema_falls_back_to_dict(caplog):
with caplog.at_level("ERROR"):
flow = Flow.from_definition(FlowDefinition.from_yaml(UNRESOLVABLE_STATE_YAML))
assert "falling back to dict state" in caplog.text
result = flow.kickoff()
assert result == "hello"
assert flow.state["begin_ran"] is True
assert flow.state["id"]
def test_dict_state_is_a_copy_of_default_plus_id():
definition = FlowDefinition.from_yaml(DICT_STATE_YAML)
flow = Flow.from_definition(definition)
assert flow.state["count"] == 5
assert flow.state["id"]
flow.kickoff()
assert flow.state["begin_ran"] is True
second = Flow.from_definition(definition)
assert second.state["count"] == 5
assert "begin_ran" not in second.state
assert second.state["id"] != flow.state["id"]
assert definition.state.default == {"count": 5}
def test_unknown_state_type_falls_back_to_dict(caplog):
with caplog.at_level("WARNING"):
flow = Flow.from_definition(FlowDefinition.from_yaml(UNKNOWN_STATE_YAML))
assert "falling back to dict state" in caplog.text
result = flow.kickoff()
assert result == "hello"
assert flow.state["begin_ran"] is True
class StubInputProvider:
def request_input(self, message, flow, metadata=None):
return "stub"
class ConfiguredFlow(Flow):
suppress_flow_events = True
max_method_calls = 5
input_provider = StubInputProvider()
@start()
def begin(self):
return "configured"
SUPPRESSED_CHAIN_YAML = (
CHAIN_YAML
+ """
config:
suppress_flow_events: true
"""
)
CAPPED_LOOP_YAML = (
LOOP_YAML
+ """
config:
max_method_calls: 2
"""
)
STREAMING_CHAIN_YAML = (
CHAIN_YAML
+ """
config:
stream: true
"""
)
DEFERRED_CHAIN_YAML = (
CHAIN_YAML
+ """
config:
defer_trace_finalization: true
"""
)
INPUT_PROVIDER_CHAIN_YAML = (
CHAIN_YAML
+ f"""
config:
input_provider: {__name__}:StubInputProvider
"""
)
def _run_capturing_flow_lifecycle(yaml_str, event_types):
events = []
with crewai_event_bus.scoped_handlers():
for event_type in event_types:
@crewai_event_bus.on(event_type)
def capture(source, event):
events.append(event)
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
result = flow.kickoff()
return flow, result, events
_LIFECYCLE_EVENTS = [
FlowCreatedEvent,
FlowStartedEvent,
FlowFinishedEvent,
MethodExecutionStartedEvent,
MethodExecutionFinishedEvent,
]
def test_config_suppress_flow_events_from_yaml():
twin_events = []
with crewai_event_bus.scoped_handlers():
for event_type in _LIFECYCLE_EVENTS:
@crewai_event_bus.on(event_type)
def capture(source, event):
twin_events.append(type(event).__name__)
twin_result = ChainFlow(suppress_flow_events=True).kickoff()
flow, result, events = _run_capturing_flow_lifecycle(
SUPPRESSED_CHAIN_YAML, _LIFECYCLE_EVENTS
)
assert result == twin_result == "confirmed:True"
assert flow.suppress_flow_events is True
assert [type(e).__name__ for e in events] == twin_events
assert not any(
isinstance(e, (MethodExecutionStartedEvent, MethodExecutionFinishedEvent))
for e in events
)
def test_config_max_method_calls_from_yaml():
flow = Flow.from_definition(FlowDefinition.from_yaml(CAPPED_LOOP_YAML))
with pytest.raises(RecursionError, match="has been called 2 times"):
flow.kickoff()
def test_config_stream_from_yaml():
flow = Flow.from_definition(FlowDefinition.from_yaml(STREAMING_CHAIN_YAML))
streaming = flow.kickoff()
assert isinstance(streaming, FlowStreamingOutput)
for _ in streaming:
pass
assert streaming.result == "confirmed:True"
assert flow.stream is True
def test_config_defer_trace_finalization_from_yaml():
_, _, baseline_events = _run_capturing_flow_lifecycle(
CHAIN_YAML, [FlowFinishedEvent]
)
assert len(baseline_events) == 1
flow, result, deferred_events = _run_capturing_flow_lifecycle(
DEFERRED_CHAIN_YAML, [FlowFinishedEvent]
)
assert result == "confirmed:True"
assert flow.defer_trace_finalization is True
assert deferred_events == []
def test_config_checkpoint_from_yaml(tmp_path):
yaml_str = (
CHAIN_YAML
+ f"""
config:
checkpoint:
location: {tmp_path}
"""
)
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
assert isinstance(flow.checkpoint, CheckpointConfig)
assert flow.checkpoint.location == str(tmp_path)
def test_config_input_provider_from_yaml():
flow = Flow.from_definition(FlowDefinition.from_yaml(INPUT_PROVIDER_CHAIN_YAML))
assert isinstance(flow.input_provider, StubInputProvider)
def test_round_trip_config_equivalence():
class_flow = ConfiguredFlow()
definition = FlowDefinition.from_yaml(ConfiguredFlow.flow_definition().to_yaml())
definition_flow = Flow.from_definition(definition)
assert definition.config.suppress_flow_events is True
assert definition.config.max_method_calls == 5
assert definition.config.input_provider == f"{__name__}:StubInputProvider"
assert definition_flow.suppress_flow_events is class_flow.suppress_flow_events
assert definition_flow.max_method_calls == class_flow.max_method_calls
assert isinstance(definition_flow.input_provider, StubInputProvider)
class_result, class_events = _run_with_events(class_flow)
definition_result, definition_events = _run_with_events(definition_flow)
assert definition_result == class_result == "configured"
assert definition_events == class_events
def test_unknown_schema_rejected():
with pytest.raises(ValidationError, match="schema"):
FlowDefinition.from_dict(
{
"schema": "crewai.flow/v2",
"name": "FutureSchema",
"methods": {
"begin": {"start": True, "do": {"ref": f"{__name__}:ChainFlow.begin"}}
},
}
)
def test_flow_config_definition_mirrors_flow_fields():
for name, field in FlowConfigDefinition.model_fields.items():
assert name in Flow.model_fields
assert field.get_default(call_default_factory=True) == Flow.model_fields[
name
].get_default(call_default_factory=True)
class DefinitionStoreBackend(FlowPersistence):
persistence_type: str = "DefinitionStoreBackend"
store: str = "default"
saves: ClassVar[dict[str, list[tuple[str, dict[str, Any]]]]] = defaultdict(list)
pending: ClassVar[dict[str, tuple[dict[str, Any], PendingFeedbackContext]]] = {}
def init_db(self) -> None:
pass
def save_state(self, flow_uuid, method_name, state_data):
data = state_data if isinstance(state_data, dict) else state_data.model_dump()
DefinitionStoreBackend.saves[self.store].append((method_name, dict(data)))
def load_state(self, flow_uuid):
for _, data in reversed(DefinitionStoreBackend.saves[self.store]):
if data.get("id") == flow_uuid:
return data
return None
def save_pending_feedback(self, flow_uuid, context, state_data):
data = state_data if isinstance(state_data, dict) else state_data.model_dump()
DefinitionStoreBackend.pending[flow_uuid] = (dict(data), context)
def load_pending_feedback(self, flow_uuid):
return DefinitionStoreBackend.pending.get(flow_uuid)
def clear_pending_feedback(self, flow_uuid):
DefinitionStoreBackend.pending.pop(flow_uuid, None)
def _saved_methods(store):
return [name for name, _ in DefinitionStoreBackend.saves[store]]
class PersistedFlow(Flow):
@start()
def first(self):
self.state["count"] = self.state.get("count", 0) + 1
return "one"
@listen(first)
def second(self):
self.state["count"] += 1
return "two"
def _flow_level_persist_yaml(store):
return f"""
schema: crewai.flow/v1
name: PersistedFlow
persist:
enabled: true
persistence:
persistence_type: DefinitionStoreBackend
store: {store}
methods:
first:
do:
ref: {__name__}:PersistedFlow.first
start: true
second:
do:
ref: {__name__}:PersistedFlow.second
listen: first
"""
def _method_level_persist_yaml(store):
return f"""
schema: crewai.flow/v1
name: PersistedFlow
methods:
first:
do:
ref: {__name__}:PersistedFlow.first
start: true
persist:
enabled: true
persistence:
persistence_type: DefinitionStoreBackend
store: {store}
second:
do:
ref: {__name__}:PersistedFlow.second
listen: first
"""
_CLASS_LEVEL_BACKEND = DefinitionStoreBackend(store="class-decorator")
@persist(_CLASS_LEVEL_BACKEND)
class ClassPersistedFlow(Flow):
@start()
def first(self):
self.state["count"] = self.state.get("count", 0) + 1
return "one"
@listen(first)
def second(self):
self.state["count"] += 1
return "two"
_COMBINED_BACKEND = DefinitionStoreBackend(store="combined-decorator")
@persist(_COMBINED_BACKEND)
class CombinedPersistedFlow(Flow):
@start()
@persist(_COMBINED_BACKEND)
def first(self):
return "one"
@listen(first)
def second(self):
return "two"
class MethodPersistedFlow(Flow):
@start()
@persist(DefinitionStoreBackend(store="method-decorator"))
def first(self):
self.state["count"] = self.state.get("count", 0) + 1
return "one"
@listen(first)
def second(self):
self.state["count"] += 1
return "two"
def test_flow_level_persist_from_yaml_saves_once_per_method():
yaml_str = _flow_level_persist_yaml("yaml-flow-level")
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
result = flow.kickoff()
assert result == "two"
assert _saved_methods("yaml-flow-level") == ["first", "second"]
_, final_save = DefinitionStoreBackend.saves["yaml-flow-level"][-1]
assert final_save["count"] == 2
assert final_save["id"] == flow.state["id"]
def test_method_level_persist_from_yaml_saves_only_that_method():
yaml_str = _method_level_persist_yaml("yaml-method-level")
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow.kickoff()
assert _saved_methods("yaml-method-level") == ["first"]
_, save = DefinitionStoreBackend.saves["yaml-method-level"][0]
assert save["count"] == 1
def test_method_level_persist_disabled_wins_over_flow_level():
yaml_str = f"""
schema: crewai.flow/v1
name: PersistedFlow
persist:
enabled: true
persistence:
persistence_type: DefinitionStoreBackend
store: yaml-opt-out
methods:
first:
do:
ref: {__name__}:PersistedFlow.first
start: true
second:
do:
ref: {__name__}:PersistedFlow.second
listen: first
persist:
enabled: false
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow.kickoff()
assert _saved_methods("yaml-opt-out") == ["first"]
def test_persist_restore_by_id_from_yaml():
yaml_str = _flow_level_persist_yaml("yaml-restore")
flow1 = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow1.kickoff()
assert flow1.state["count"] == 2
flow2 = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow2.kickoff(inputs={"id": flow1.state["id"]})
assert flow2.state["count"] == 4
def test_combined_class_and_method_persist_saves_once_per_method():
before = len(DefinitionStoreBackend.saves["combined-decorator"])
CombinedPersistedFlow().kickoff()
assert _saved_methods("combined-decorator")[before:] == ["first", "second"]
def test_method_level_persist_decorator_saves_only_that_method():
before = len(DefinitionStoreBackend.saves["method-decorator"])
MethodPersistedFlow().kickoff()
assert _saved_methods("method-decorator")[before:] == ["first"]
def test_round_trip_persist_equivalence():
definition = FlowDefinition.from_yaml(ClassPersistedFlow.flow_definition().to_yaml())
before = len(DefinitionStoreBackend.saves["class-decorator"])
flow = Flow.from_definition(definition)
flow.kickoff()
assert _saved_methods("class-decorator")[before:] == ["first", "second"]
def test_method_persist_backend_overrides_flow_level_backend_from_yaml():
yaml_str = f"""
schema: crewai.flow/v1
name: PersistedFlow
persist:
enabled: true
persistence:
persistence_type: DefinitionStoreBackend
store: yaml-mixed-flow
methods:
first:
do:
ref: {__name__}:PersistedFlow.first
start: true
second:
do:
ref: {__name__}:PersistedFlow.second
listen: first
persist:
enabled: true
persistence:
persistence_type: DefinitionStoreBackend
store: yaml-mixed-method
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
flow.kickoff()
assert _saved_methods("yaml-mixed-flow") == ["first"]
assert _saved_methods("yaml-mixed-method") == ["second"]
def test_method_persist_decorator_overrides_class_level_backend():
@persist(DefinitionStoreBackend(store="mixed-class"))
class MixedPersistedFlow(Flow):
@start()
@persist(DefinitionStoreBackend(store="mixed-method"))
def first(self):
return "one"
@listen(first)
def second(self):
return "two"
MixedPersistedFlow().kickoff()
assert _saved_methods("mixed-method") == ["first"]
assert _saved_methods("mixed-class") == ["second"]
def test_instance_persistence_overrides_definition_backend():
before = len(DefinitionStoreBackend.saves["method-decorator"])
flow = MethodPersistedFlow(
persistence=DefinitionStoreBackend(store="instance-override")
)
flow.kickoff()
assert _saved_methods("instance-override") == ["first"]
assert len(DefinitionStoreBackend.saves["method-decorator"]) == before
def test_resume_synthetic_completion_persists():
backend = DefinitionStoreBackend(store="resume-synthetic")
class ResumableFlow(Flow):
@start()
@persist(DefinitionStoreBackend(store="resume-synthetic"))
@human_feedback(message="Review:")
def generate(self):
return "content"
@listen(generate)
def process(self, result):
return "done"
context = PendingFeedbackContext(
flow_id="resume-persist-1",
flow_class="ResumableFlow",
method_name="generate",
method_output="content",
message="Review:",
)
backend.save_pending_feedback(
"resume-persist-1", context, {"id": "resume-persist-1"}
)
flow = ResumableFlow.from_pending("resume-persist-1", backend)
result = flow.resume("looks good")
assert result == "done"
assert _saved_methods("resume-synthetic") == ["generate"]
class ReviewFlow(Flow):
@start()
@human_feedback(
message="Review the draft:",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
default_outcome="rejected",
)
def draft(self):
return "draft-content"
@listen("approved")
def publish(self):
return f"published:{self.last_human_feedback.feedback}"
@listen("rejected")
def discard(self):
return "discarded"
REVIEW_YAML = f"""
schema: crewai.flow/v1
name: ReviewFlow
methods:
draft:
do:
ref: {__name__}:ReviewFlow.draft
start: true
human_feedback:
message: "Review the draft:"
emit: [approved, rejected]
llm: gpt-4o-mini
default_outcome: rejected
publish:
do:
ref: {__name__}:ReviewFlow.publish
listen: approved
discard:
do:
ref: {__name__}:ReviewFlow.discard
listen: rejected
"""
def _pending_generate(flow):
return "content"
def _pending_process(flow, result):
return f"resumed:{result.feedback}"
class PausingProvider:
def request_feedback(self, context, flow):
raise HumanFeedbackPending(context=context)
PENDING_REVIEW_YAML = f"""
schema: crewai.flow/v1
name: PendingReviewFlow
persist:
enabled: true
persistence:
persistence_type: DefinitionStoreBackend
store: hitl-pending
methods:
generate:
do:
ref: {__name__}:_pending_generate
start: true
human_feedback:
message: "Review:"
provider: {__name__}:PausingProvider
process:
do:
ref: {__name__}:_pending_process
listen: generate
"""
def test_human_feedback_from_yaml_default_outcome_routes():
flow = Flow.from_definition(FlowDefinition.from_yaml(REVIEW_YAML))
with patch.object(flow, "_request_human_feedback", return_value="") as request:
result = flow.kickoff()
assert result == "discarded"
assert request.call_count == 1
assert flow.last_human_feedback.outcome == "rejected"
assert flow.last_human_feedback.output == "draft-content"
def test_human_feedback_from_yaml_collapses_and_routes():
flow = Flow.from_definition(FlowDefinition.from_yaml(REVIEW_YAML))
with (
patch.object(flow, "_request_human_feedback", return_value="ship it"),
patch.object(flow, "_collapse_to_outcome", return_value="approved"),
):
result = flow.kickoff()
assert result == "published:ship it"
assert [r.outcome for r in flow.human_feedback_history] == ["approved"]
def test_round_trip_human_feedback_equivalence():
class_flow = ReviewFlow()
with patch.object(class_flow, "_request_human_feedback", return_value=""):
class_result = class_flow.kickoff()
definition = FlowDefinition.from_yaml(ReviewFlow.flow_definition().to_yaml())
twin = Flow.from_definition(definition)
with patch.object(twin, "_request_human_feedback", return_value=""):
twin_result = twin.kickoff()
assert twin_result == class_result == "discarded"
assert (
twin.last_human_feedback.outcome
== class_flow.last_human_feedback.outcome
== "rejected"
)
def test_human_feedback_pending_and_resume_from_yaml():
definition = FlowDefinition.from_yaml(PENDING_REVIEW_YAML)
flow = Flow.from_definition(definition)
pending = flow.kickoff()
assert isinstance(pending, HumanFeedbackPending)
flow_id = pending.context.flow_id
assert flow_id in DefinitionStoreBackend.pending
resumed = Flow.from_pending(
flow_id,
DefinitionStoreBackend(store="hitl-pending"),
definition=definition,
)
result = resumed.resume("looks good")
assert result == "resumed:looks good"
assert resumed.last_human_feedback.feedback == "looks good"
assert flow_id not in DefinitionStoreBackend.pending
def test_flow_config_provider_fallback_from_yaml():
yaml_str = f"""
schema: crewai.flow/v1
name: ConfigProviderFlow
methods:
generate:
do:
ref: {__name__}:_pending_generate
start: true
human_feedback:
message: "Review:"
process:
do:
ref: {__name__}:_pending_process
listen: generate
"""
class RecordingProvider:
def __init__(self):
self.requests = []
def request_feedback(self, context, flow):
self.requests.append(context.method_name)
return "from-config"
provider = RecordingProvider()
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
previous = flow_config.hitl_provider
flow_config.hitl_provider = provider
try:
result = flow.kickoff()
finally:
flow_config.hitl_provider = previous
assert result == "resumed:from-config"
assert provider.requests == ["generate"]
# --- PR 7: one resolution story, inert decorator attrs, restore paths ---
def test_runtime_package_reads_no_decorator_attrs():
import crewai.flow.runtime as flow_runtime
runtime_dir = Path(flow_runtime.__file__).parent
forbidden = (
"__human_feedback_config__",
"__flow_persistence_config__",
"__flow_method_definition__",
"_human_feedback_llm",
)
offenders = [
f"{path.name}: {attr}"
for path in sorted(runtime_dir.rglob("*.py"))
for attr in forbidden
if attr in path.read_text(encoding="utf-8")
]
assert offenders == []
def test_stamped_decorator_attrs_are_inert_at_runtime():
class StampFreeFlow(Flow):
@start()
@persist(DefinitionStoreBackend(store="stamp-free"))
def first(self):
return "one"
@listen(first)
def second(self, result):
return f"{result}-two"
StampFreeFlow.flow_definition()
stamped = (
"__flow_method_definition__",
"__flow_persistence_config__",
"__human_feedback_config__",
)
for name in ("first", "second"):
wrapper = StampFreeFlow.__dict__[name]
for attr in stamped:
if attr in wrapper.__dict__:
delattr(wrapper, attr)
result = StampFreeFlow().kickoff()
assert result == "one-two"
assert _saved_methods("stamp-free") == ["first"]
def test_class_level_persist_without_instance_kwarg_saves_and_restores():
before = len(DefinitionStoreBackend.saves["class-decorator"])
flow = ClassPersistedFlow()
flow.kickoff()
assert _saved_methods("class-decorator")[before:] == ["first", "second"]
assert flow.state["count"] == 2
resumed = ClassPersistedFlow()
resumed.kickoff(inputs={"id": flow.state["id"]})
assert resumed.state["count"] == 4
def test_input_provider_bad_ref_names_field_and_ref():
with pytest.raises(ValidationError, match="unresolvable input_provider ref"):
Flow(input_provider="missing_module_xyz:Provider")
class _NeedsArgsProvider:
def __init__(self, channel):
self.channel = channel
def request_feedback(self, context, flow):
return "ok"
def test_provider_ref_requiring_ctor_args_fails_loudly():
yaml_str = f"""
schema: crewai.flow/v1
name: BadProviderFlow
methods:
generate:
do:
ref: {__name__}:_pending_generate
start: true
human_feedback:
message: "Review:"
provider: {__name__}:_NeedsArgsProvider
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(
ValueError, match="cannot instantiate human_feedback.provider ref"
):
flow.kickoff()
def test_unresolvable_provider_ref_names_field_and_ref():
yaml_str = f"""
schema: crewai.flow/v1
name: BadProviderFlow
methods:
generate:
do:
ref: {__name__}:_pending_generate
start: true
human_feedback:
message: "Review:"
provider: missing_module_xyz:Provider
"""
flow = Flow.from_definition(FlowDefinition.from_yaml(yaml_str))
with pytest.raises(
ValueError, match="unresolvable human_feedback.provider ref"
):
flow.kickoff()
def _checkpoint_chain_flow(tmp_path):
from crewai.state.provider.json_provider import JsonProvider
from crewai.state.runtime import RuntimeState
definition = FlowDefinition.from_yaml(CHAIN_YAML)
flow = Flow.from_definition(definition)
result = flow.kickoff()
assert result == "confirmed:True"
state = RuntimeState(root=[flow])
state._provider = JsonProvider()
location = state.checkpoint(str(tmp_path))
return definition, flow, CheckpointConfig(restore_from=location)
def test_from_checkpoint_with_definition_restores_yaml_flow(tmp_path):
definition, flow, config = _checkpoint_chain_flow(tmp_path)
restored = Flow.from_checkpoint(config, definition=definition)
assert restored.state["confirmed"] is True
assert restored.state["id"] == flow.state["id"]
assert restored.kickoff() == "confirmed:True"
def test_fork_with_definition_branches_yaml_flow(tmp_path):
definition, flow, config = _checkpoint_chain_flow(tmp_path)
forked = Flow.fork(config, branch="alt", definition=definition)
assert forked.state["id"] != flow.state["id"]
assert forked.kickoff() == "confirmed:True"
def test_non_dict_state_default_rejected_by_contract():
yaml_str = """
schema: crewai.flow/v1
name: BadStateFlow
state:
type: dict
default: 42
methods: {}
"""
with pytest.raises(ValidationError, match="default"):
FlowDefinition.from_yaml(yaml_str)
def test_definition_method_missing_from_class_fails_loudly():
class VanishingFlow(Flow):
@start()
def begin(self):
return "one"
VanishingFlow.flow_definition()
del VanishingFlow.begin
with pytest.raises(ValueError, match="does not provide: begin"):
VanishingFlow()