mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-01 13:18:10 +00:00
* Require explicit CrewAI project definitions
JSON crews and declarative flows now resolve from `[tool.crewai]`
metadata instead of implicit filename discovery. This makes project type
selection deterministic, prevents stray `crew.json(c)` files from changing
CLI behavior, and centralizes definition path validation for run, install,
deploy validation, plotting, and memory reset paths.
`[tool.crewai].definition` must be a project-local file path. Absolute
paths, `~`, missing files, directories, and paths escaping the project root
are rejected so deploy and runtime commands use the same contract.
Breaking changes and migration paths:
* JSON crew projects are no longer discovered from `crew.json` or
`crew.jsonc` alone. Add explicit metadata:
```toml
[tool.crewai]
type = "crew"
definition = "crew.jsonc"
```
* Declarative flow projects must use a valid project-local definition path:
```toml
[tool.crewai]
type = "flow"
definition = "flows/research.yaml"
```
* `Flow.from_definition(definition)` is removed. Use:
```python
Flow.from_declaration(contents=definition)
```
* `FlowDefinition.to_json()` and `FlowDefinition.to_yaml()` are removed.
Use `FlowDefinition.to_dict()` and serialize with the caller's JSON or
YAML library.
* `FlowDefinition.from_dict()` is removed. Use:
```python
FlowDefinition.from_declaration(contents=data)
```
* `FlowDefinition.json_schema()` is removed. Use Pydantic's schema API only
where schema generation is intentionally needed:
```python
FlowDefinition.model_json_schema(by_alias=True)
```
* `crewai_cli.run_crew.find_crew_json_file()` and `_has_json_crew()` are
removed. Use `configured_project_json_crew()` or the shared
`crewai_core.project.configured_project_definition("crew")` helper.
* `crewai reset-memories` now only loads JSON crews declared through
`[tool.crewai].definition`, and invalid declared JSON crew definitions
fail instead of silently falling back to classic crew discovery.
* Address code review comments
3244 lines
84 KiB
Python
3244 lines
84 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.flow.runtime._actions import FlowScriptExecutionDisabledError
|
|
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("each step 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
|
|
"""
|
|
|
|
JSON_SCHEMA_REQUIRED_INPUT_STATE_YAML = """
|
|
schema: crewai.flow/v1
|
|
name: JsonSchemaRequiredInputStateFlow
|
|
state:
|
|
type: json_schema
|
|
json_schema:
|
|
title: LeadState
|
|
type: object
|
|
required:
|
|
- lead_name
|
|
properties:
|
|
lead_name:
|
|
type: string
|
|
methods:
|
|
begin:
|
|
start: true
|
|
do:
|
|
call: expression
|
|
expr: state.lead_name
|
|
"""
|
|
|
|
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):
|
|
state = flow.state
|
|
snapshot = dict(state if isinstance(state, dict) else 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_declaration(contents=yaml_str)
|
|
definition_flow = Flow.from_declaration(contents=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_flow_from_declaration_builds_runnable_flow():
|
|
flow = Flow.from_declaration(contents=CHAIN_YAML)
|
|
|
|
assert flow.kickoff() == "confirmed:True"
|
|
assert flow.method_outputs == ["hello", "HELLO", "confirmed:True"]
|
|
|
|
|
|
def test_flow_from_declaration_accepts_flow_definition():
|
|
definition = FlowDefinition.from_declaration(contents=CHAIN_YAML)
|
|
flow = Flow.from_declaration(contents=definition)
|
|
|
|
assert flow.kickoff() == "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_declaration(contents=CHAIN_YAML)
|
|
flow = Flow.from_declaration(contents=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_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "NoActions",
|
|
"methods": {"begin": {"start": True}},
|
|
}
|
|
)
|
|
|
|
|
|
def test_from_declaration_unresolvable_ref_raises():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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_declaration(contents=definition)
|
|
|
|
|
|
def test_from_declaration_malformed_ref_raises():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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_declaration(contents=definition)
|
|
|
|
|
|
def test_from_declaration_local_scope_ref_raises():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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_declaration(contents=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_declaration_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_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff() == "found:ai agents"
|
|
|
|
|
|
def test_tool_action_round_trips_with_inputs():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "ToolFlow",
|
|
"methods": {
|
|
"search": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "tool",
|
|
"ref": f"{__name__}:StaticSearchTool",
|
|
"with": {"search_query": "ai agents"},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
action = definition.methods["search"].do
|
|
|
|
assert action.call == "tool"
|
|
assert action.ref == f"{__name__}:StaticSearchTool"
|
|
assert action.with_ == {"search_query": "ai agents"}
|
|
assert Flow.from_declaration(contents=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_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"topic": "ai"}) == "found:ai agents"
|
|
|
|
|
|
def test_tool_action_treats_embedded_cel_marker_as_literal():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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'}",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert Flow.from_declaration(contents=definition).kickoff() == "p}x:wrapped ${'a}b'} value"
|
|
|
|
|
|
def test_tool_action_treats_marker_with_trailing_text_as_literal():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "ToolFlow",
|
|
"methods": {
|
|
"search": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "tool",
|
|
"ref": f"{__name__}:StaticSearchTool",
|
|
"with": {
|
|
"search_query": "${state.topic} extra",
|
|
"prefix": "p",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert Flow.from_declaration(contents=definition).kickoff() == "p:${state.topic} extra"
|
|
|
|
|
|
def test_tool_action_rejects_adjacent_markers_as_invalid_cel():
|
|
with pytest.raises(ValidationError, match="invalid CEL expression"):
|
|
FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "ToolFlow",
|
|
"methods": {
|
|
"search": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "tool",
|
|
"ref": f"{__name__}:StaticSearchTool",
|
|
"with": {
|
|
"search_query": "${'a'}${'b'}",
|
|
"prefix": "p",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def test_tool_action_accepts_braces_in_full_cel_marker():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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": "${'p}x'}",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
assert Flow.from_declaration(contents=definition).kickoff() == "p}x:ai agents"
|
|
|
|
|
|
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_declaration(contents=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_declaration(contents=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_declaration(contents=yaml_str)
|
|
|
|
assert (
|
|
flow.kickoff(inputs={"limit": 2, "domains": ["crewai.com", "example.com"]})
|
|
== "2:crewai.com,example.com"
|
|
)
|
|
|
|
|
|
def test_agent_action_runs_inline_yaml_definition(monkeypatch: pytest.MonkeyPatch):
|
|
from crewai import Agent
|
|
|
|
async def fake_kickoff_async(
|
|
self: Agent, messages: str, **_kwargs: Any
|
|
) -> dict[str, Any]:
|
|
return {"agent": self.role, "input": messages}
|
|
|
|
monkeypatch.setattr(Agent, "kickoff_async", fake_kickoff_async)
|
|
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: AgentFlow
|
|
methods:
|
|
answer:
|
|
do:
|
|
call: agent
|
|
with:
|
|
role: Analyst
|
|
goal: Answer questions
|
|
backstory: Knows things.
|
|
input: "${state.question}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"question": "What is CrewAI?"}) == {
|
|
"agent": "Analyst",
|
|
"input": "What is CrewAI?",
|
|
}
|
|
|
|
|
|
def test_agent_action_runs_inside_each(monkeypatch: pytest.MonkeyPatch):
|
|
from crewai import Agent
|
|
|
|
async def fake_kickoff_async(
|
|
self: Agent, messages: str, **_kwargs: Any
|
|
) -> str:
|
|
return f"{self.role}:{messages}"
|
|
|
|
monkeypatch.setattr(Agent, "kickoff_async", fake_kickoff_async)
|
|
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: AgentEachFlow
|
|
methods:
|
|
answer_each:
|
|
do:
|
|
call: each
|
|
in: state.questions
|
|
do:
|
|
- name: answer
|
|
action:
|
|
call: agent
|
|
with:
|
|
role: Analyst
|
|
goal: Answer questions
|
|
backstory: Knows things.
|
|
input: "${item}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"questions": ["one", "two"]}) == [
|
|
"Analyst:one",
|
|
"Analyst:two",
|
|
]
|
|
|
|
|
|
def test_agent_action_round_trips_with_inline_definition():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "AgentFlow",
|
|
"methods": {
|
|
"answer": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "agent",
|
|
"with": {
|
|
"role": "Analyst",
|
|
"goal": "Answer questions",
|
|
"backstory": "Knows things.",
|
|
"settings": {"verbose": True},
|
|
"input": "${state.question}",
|
|
},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
action = definition.methods["answer"].do
|
|
|
|
assert action.call == "agent"
|
|
assert action.with_.role == "Analyst"
|
|
assert action.with_.input == "${state.question}"
|
|
assert action.with_.settings == {"verbose": True}
|
|
|
|
|
|
def test_agent_action_json_schema_describes_inline_agent_definitions():
|
|
schema_defs = FlowDefinition.model_json_schema(by_alias=True)["$defs"]
|
|
|
|
assert set(schema_defs["AgentDefinition"]["properties"]) >= {
|
|
"role",
|
|
"goal",
|
|
"backstory",
|
|
"settings",
|
|
"input",
|
|
"response_format",
|
|
}
|
|
|
|
|
|
def test_agent_action_rejects_non_string_input_in_definition():
|
|
with pytest.raises(ValidationError, match="agent.input must be a string"):
|
|
FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "AgentFlow",
|
|
"methods": {
|
|
"answer": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "agent",
|
|
"with": {
|
|
"role": "Analyst",
|
|
"goal": "Answer questions",
|
|
"backstory": "Knows things.",
|
|
"input": 123,
|
|
},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def test_agent_action_reports_invalid_cel_expression():
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: AgentFlow
|
|
methods:
|
|
answer:
|
|
do:
|
|
call: agent
|
|
with:
|
|
role: Analyst
|
|
goal: Answer questions
|
|
backstory: Knows things.
|
|
input: "${state.}"
|
|
start: true
|
|
"""
|
|
|
|
with pytest.raises(ValidationError, match="invalid CEL expression"):
|
|
FlowDefinition.from_declaration(contents=yaml_str)
|
|
|
|
|
|
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_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"topic": "AI"}) == {
|
|
"crew": "inline_research",
|
|
"agents": ["Researcher"],
|
|
"tasks": ["Research {topic}"],
|
|
"inputs": {"topic": "AI"},
|
|
}
|
|
|
|
|
|
def test_crew_action_runs_crew_from_declaration(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
):
|
|
from crewai import Crew
|
|
|
|
project_root = tmp_path / "project"
|
|
crew_root = project_root / "crews" / "research_crew"
|
|
agents_root = crew_root / "agents"
|
|
agents_root.mkdir(parents=True)
|
|
(agents_root / "researcher.jsonc").write_text(
|
|
"""
|
|
{
|
|
"role": "Researcher",
|
|
"goal": "Research {topic}",
|
|
"backstory": "Knows things."
|
|
}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
(crew_root / "crew.jsonc").write_text(
|
|
"""
|
|
{
|
|
"name": "referenced_research",
|
|
"agents": ["researcher"],
|
|
"tasks": [
|
|
{
|
|
"name": "research_task",
|
|
"description": "Research {topic}",
|
|
"expected_output": "Findings about {topic}",
|
|
"agent": "researcher"
|
|
}
|
|
],
|
|
"inputs": {
|
|
"topic": "Default topic",
|
|
"audience": "developers"
|
|
}
|
|
}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
async def fake_kickoff_async(
|
|
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"crew": self.name,
|
|
"tasks": [task.description for task in self.tasks],
|
|
"inputs": inputs,
|
|
}
|
|
|
|
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
|
|
monkeypatch.chdir(project_root)
|
|
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: CrewFlow
|
|
methods:
|
|
research:
|
|
do:
|
|
call: crew
|
|
from_declaration: crews/research_crew
|
|
inputs:
|
|
topic: "${state.topic}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"topic": "AI"}) == {
|
|
"crew": "referenced_research",
|
|
"tasks": ["Research {topic}"],
|
|
"inputs": {"topic": "AI", "audience": "developers"},
|
|
}
|
|
|
|
|
|
def test_crew_action_from_declaration_resolves_relative_to_flow_file(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
):
|
|
from crewai import Crew
|
|
|
|
project_root = tmp_path / "project"
|
|
crew_root = project_root / "crews" / "research_crew"
|
|
agents_root = crew_root / "agents"
|
|
agents_root.mkdir(parents=True)
|
|
(agents_root / "researcher.jsonc").write_text(
|
|
"""
|
|
{
|
|
"role": "Researcher",
|
|
"goal": "Research {topic}",
|
|
"backstory": "Knows things."
|
|
}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
(crew_root / "crew.jsonc").write_text(
|
|
"""
|
|
{
|
|
"name": "relative_research",
|
|
"agents": ["researcher"],
|
|
"tasks": [
|
|
{
|
|
"description": "Research {topic}",
|
|
"expected_output": "Findings about {topic}",
|
|
"agent": "researcher"
|
|
}
|
|
],
|
|
"inputs": {
|
|
"topic": "Default topic"
|
|
}
|
|
}
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
async def fake_kickoff_async(
|
|
self: Crew, inputs: dict[str, Any] | None = None, **_kwargs: Any
|
|
) -> dict[str, Any]:
|
|
return {"crew": self.name, "inputs": inputs}
|
|
|
|
monkeypatch.setattr(Crew, "kickoff_async", fake_kickoff_async)
|
|
|
|
flow_path = project_root / "flow.yaml"
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: CrewFlow
|
|
methods:
|
|
research:
|
|
do:
|
|
call: crew
|
|
from_declaration: crews/research_crew
|
|
inputs:
|
|
topic: "${state.topic}"
|
|
start: true
|
|
"""
|
|
flow_path.write_text(yaml_str, encoding="utf-8")
|
|
|
|
other_cwd = tmp_path / "other"
|
|
other_cwd.mkdir()
|
|
monkeypatch.chdir(other_cwd)
|
|
|
|
flow = Flow.from_declaration(path=flow_path)
|
|
|
|
assert flow.kickoff(inputs={"topic": "AI"}) == {
|
|
"crew": "relative_research",
|
|
"inputs": {"topic": "AI"},
|
|
}
|
|
|
|
|
|
def test_crew_action_from_declaration_rejects_paths_outside_flow_file(
|
|
tmp_path: Path,
|
|
):
|
|
flow_path = tmp_path / "project" / "flow.yaml"
|
|
flow_path.parent.mkdir()
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: CrewFlow
|
|
methods:
|
|
research:
|
|
do:
|
|
call: crew
|
|
from_declaration: ../outside/crew.jsonc
|
|
start: true
|
|
"""
|
|
flow_path.write_text(yaml_str, encoding="utf-8")
|
|
|
|
flow = Flow.from_declaration(path=flow_path)
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="crew declaration path must be within the flow definition directory",
|
|
):
|
|
flow.kickoff()
|
|
|
|
|
|
def test_crew_action_round_trips_with_inline_definition():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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}"},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
action = definition.methods["research"].do
|
|
|
|
assert action.call == "crew"
|
|
assert action.with_ is not None
|
|
assert action.with_.agents["researcher"].role == "Researcher"
|
|
assert action.inputs == {"topic": "${state.topic}"}
|
|
|
|
|
|
def test_crew_action_normalizes_named_agent_list_definition():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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",
|
|
}
|
|
],
|
|
},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
action = definition.methods["research"].do
|
|
|
|
assert action.call == "crew"
|
|
assert action.with_ is not None
|
|
assert action.with_.agents["researcher"].role == "Researcher"
|
|
|
|
|
|
def test_crew_action_json_schema_describes_inline_crew_definitions():
|
|
schema_defs = FlowDefinition.model_json_schema(by_alias=True)["$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_declaration(contents=
|
|
{
|
|
"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_python_ref_field():
|
|
with pytest.raises(ValidationError, match="ref"):
|
|
FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "CrewFlow",
|
|
"methods": {
|
|
"research": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "crew",
|
|
"ref": "project.crew:build_crew",
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def test_crew_action_rejects_non_mapping_inputs_in_definition():
|
|
with pytest.raises(ValidationError, match="crew.inputs must be a mapping"):
|
|
FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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
|
|
"""
|
|
|
|
with pytest.raises(ValidationError, match="invalid CEL expression"):
|
|
FlowDefinition.from_declaration(contents=yaml_str)
|
|
|
|
|
|
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_declaration(contents=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_declaration(contents=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:
|
|
- name: normalize
|
|
action:
|
|
call: code
|
|
ref: {__name__}:EachActionFlow.normalize_row
|
|
with:
|
|
row: "${{item}}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
|
|
"normalized:a",
|
|
"normalized:b",
|
|
]
|
|
|
|
|
|
def test_each_action_runs_sync_steps_off_event_loop_with_context():
|
|
yaml_str = f"""
|
|
schema: crewai.flow/v1
|
|
name: EachFlow
|
|
methods:
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: state.rows
|
|
do:
|
|
- name: threaded
|
|
action:
|
|
call: code
|
|
ref: {__name__}:EachActionFlow.require_threaded_context
|
|
with:
|
|
row: "${{item}}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=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_steps():
|
|
yaml_str = f"""
|
|
schema: crewai.flow/v1
|
|
name: EachFlow
|
|
methods:
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: state.rows
|
|
do:
|
|
- name: async_tool
|
|
action:
|
|
call: tool
|
|
ref: {__name__}:AsyncResultTool
|
|
with:
|
|
value: "${{item}}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == ["async:a", "async:b"]
|
|
|
|
|
|
def test_script_action_requires_explicit_opt_in():
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: ScriptFlow
|
|
methods:
|
|
normalize:
|
|
do:
|
|
call: script
|
|
code: |
|
|
return "blocked"
|
|
start: true
|
|
"""
|
|
|
|
with pytest.raises(
|
|
FlowScriptExecutionDisabledError,
|
|
match="CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION=1",
|
|
) as exc_info:
|
|
Flow.from_declaration(contents=yaml_str)
|
|
assert "methods with unresolvable actions" not in str(exc_info.value)
|
|
|
|
|
|
def test_script_action_runs_python_imports_mutates_state_and_returns_value(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
|
|
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: ScriptFlow
|
|
methods:
|
|
normalize:
|
|
do:
|
|
call: script
|
|
code: |
|
|
import math
|
|
|
|
state["rounded"] = math.ceil(state["raw_score"])
|
|
return f"rounded:{state['rounded']}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"raw_score": 3.2}) == "rounded:4"
|
|
assert flow.state["rounded"] == 4
|
|
|
|
|
|
def test_script_listener_reads_trigger_input_and_outputs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
|
|
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: ScriptFlow
|
|
methods:
|
|
seed:
|
|
do:
|
|
call: expression
|
|
expr: "'alpha'"
|
|
start: true
|
|
combine:
|
|
do:
|
|
call: script
|
|
code: |
|
|
state["input_matches_output"] = input == outputs["seed"]
|
|
return f"{outputs['seed']}:{input}"
|
|
listen: seed
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff() == "alpha:alpha"
|
|
assert flow.state["input_matches_output"] is True
|
|
|
|
|
|
def test_script_each_action_reads_item_and_step_outputs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
monkeypatch.setenv("CREWAI_ALLOW_FLOW_SCRIPT_EXECUTION", "1")
|
|
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: ScriptEachFlow
|
|
methods:
|
|
seed:
|
|
do:
|
|
call: expression
|
|
expr: "'global'"
|
|
start: true
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: state.rows
|
|
do:
|
|
- name: clean
|
|
action:
|
|
call: script
|
|
code: |
|
|
return item.strip()
|
|
- name: tag
|
|
action:
|
|
call: script
|
|
code: |
|
|
return f"{outputs['seed']}:{outputs['clean']}"
|
|
listen: seed
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"rows": [" a ", " b "]}) == ["global:a", "global:b"]
|
|
|
|
|
|
def test_each_action_uses_iteration_outputs_between_steps():
|
|
yaml_str = f"""
|
|
schema: crewai.flow/v1
|
|
name: EachFlow
|
|
methods:
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: state.rows
|
|
do:
|
|
- name: normalize
|
|
action:
|
|
call: code
|
|
ref: {__name__}:EachActionFlow.normalize_row
|
|
with:
|
|
row: "${{item}}"
|
|
prefix: saved
|
|
- name: save
|
|
action:
|
|
call: code
|
|
ref: {__name__}:EachActionFlow.save_row
|
|
with:
|
|
row: "${{item}}"
|
|
normalized: "${{outputs.normalize}}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"rows": ["a", "b"]}) == [
|
|
{"row": "a", "normalized": "saved:a"},
|
|
{"row": "b", "normalized": "saved:b"},
|
|
]
|
|
|
|
|
|
def test_each_action_resets_step_outputs_between_iterations():
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: EachFlow
|
|
methods:
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: state.rows
|
|
do:
|
|
- name: leak_check
|
|
action:
|
|
call: expression
|
|
expr: "has(outputs.previous) ? outputs.previous : 'empty'"
|
|
- name: previous
|
|
action:
|
|
call: expression
|
|
expr: item
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=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_step_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:
|
|
- name: before_shadow
|
|
action:
|
|
call: expression
|
|
expr: "outputs.seed + ':' + item"
|
|
- name: seed
|
|
action:
|
|
call: expression
|
|
expr: "'local:' + item"
|
|
- name: after_shadow
|
|
action:
|
|
call: expression
|
|
expr: "outputs.seed"
|
|
listen: seed
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=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_runs_simple_if_clauses():
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: EachIfFlow
|
|
methods:
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: state.rows
|
|
do:
|
|
- name: kind
|
|
action:
|
|
call: expression
|
|
expr: item.kind
|
|
- name: kept
|
|
if: "outputs.kind == 'keep'"
|
|
action:
|
|
call: expression
|
|
expr: "'kept:' + item.value"
|
|
- name: skipped
|
|
if: "outputs.kind != 'keep'"
|
|
action:
|
|
call: expression
|
|
expr: "'skipped:' + item.value"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(
|
|
inputs={
|
|
"rows": [
|
|
{"kind": "keep", "value": "a"},
|
|
{"kind": "drop", "value": "b"},
|
|
]
|
|
}
|
|
) == ["kept:a", "skipped:b"]
|
|
|
|
|
|
def test_each_action_accepts_expression_markers_in_explicit_cel_fields():
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: EachIfFlow
|
|
methods:
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: "${state.rows}"
|
|
do:
|
|
- name: kind
|
|
action:
|
|
call: expression
|
|
expr: "${item.kind}"
|
|
- name: kept
|
|
if: "${outputs.kind == 'keep'}"
|
|
action:
|
|
call: expression
|
|
expr: "${item.value}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(inputs={"rows": [{"kind": "keep", "value": "a"}]}) == ["a"]
|
|
|
|
|
|
def test_each_action_skipped_if_keeps_previous_output():
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: EachIfFlow
|
|
methods:
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: state.rows
|
|
do:
|
|
- name: original
|
|
action:
|
|
call: expression
|
|
expr: item.value
|
|
- name: maybe_included
|
|
if: item.include
|
|
action:
|
|
call: expression
|
|
expr: "'included:' + item.value"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
assert flow.kickoff(
|
|
inputs={
|
|
"rows": [
|
|
{"include": True, "value": "a"},
|
|
{"include": False, "value": "b"},
|
|
]
|
|
}
|
|
) == ["included:a", "b"]
|
|
|
|
|
|
def test_each_action_if_condition_must_be_boolean():
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: EachIfFlow
|
|
methods:
|
|
process_rows:
|
|
do:
|
|
call: each
|
|
in: state.rows
|
|
do:
|
|
- name: value
|
|
if: item.value
|
|
action:
|
|
call: expression
|
|
expr: item.value
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
with pytest.raises(ValueError, match="if expression must evaluate to a boolean"):
|
|
flow.kickoff(inputs={"rows": [{"value": "truthy"}]})
|
|
|
|
|
|
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:
|
|
- name: normalize
|
|
action:
|
|
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_declaration(contents=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_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "EachFlow",
|
|
"methods": {
|
|
"process_rows": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "each",
|
|
"in": expr,
|
|
"do": [
|
|
{
|
|
"name": "value",
|
|
"action": {"call": "expression", "expr": "item"},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
flow = Flow.from_declaration(contents=definition)
|
|
|
|
with pytest.raises(ValueError, match="each.in must evaluate to an array"):
|
|
flow.kickoff(inputs=inputs)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"action_do",
|
|
[
|
|
[],
|
|
[{"value": {"call": "expression", "expr": "item"}}],
|
|
[{"name": "1bad", "action": {"call": "expression", "expr": "item"}}],
|
|
[{"name": "missing_action"}],
|
|
[{"action": {"call": "expression", "expr": "item"}}],
|
|
[
|
|
{
|
|
"name": "value",
|
|
"if": "true",
|
|
"then": [],
|
|
"action": {"call": "expression", "expr": "item"},
|
|
}
|
|
],
|
|
[
|
|
{"name": "same", "action": {"call": "expression", "expr": "item"}},
|
|
{"name": "same", "action": {"call": "expression", "expr": "item"}},
|
|
],
|
|
],
|
|
)
|
|
def test_each_action_validates_step_shape(action_do):
|
|
with pytest.raises(ValidationError):
|
|
FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "EachFlow",
|
|
"methods": {
|
|
"process_rows": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "each",
|
|
"in": "state.rows",
|
|
"do": action_do,
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def test_if_clauses_are_rejected_at_method_level():
|
|
with pytest.raises(ValidationError):
|
|
FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "TopLevelIfFlow",
|
|
"methods": {
|
|
"process": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "expression",
|
|
"if": "true",
|
|
"expr": "'ok'",
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def test_each_action_rejects_nested_each_actions():
|
|
with pytest.raises(ValidationError):
|
|
FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "EachFlow",
|
|
"methods": {
|
|
"process_rows": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "each",
|
|
"in": "state.rows",
|
|
"do": [
|
|
{
|
|
"name": "nested",
|
|
"action": {
|
|
"call": "each",
|
|
"in": "state.children",
|
|
"do": [
|
|
{
|
|
"name": "child",
|
|
"action": {
|
|
"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:
|
|
- name: validate
|
|
action:
|
|
call: code
|
|
ref: {__name__}:EachActionFlow.fail_on_bad_row
|
|
with:
|
|
row: "${{item}}"
|
|
start: true
|
|
"""
|
|
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
|
|
with pytest.raises(RuntimeError, match="bad row"):
|
|
flow.kickoff(inputs={"rows": ["ok", "bad"]})
|
|
|
|
|
|
def test_expression_action_round_trips():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "ExpressionFlow",
|
|
"methods": {
|
|
"classify": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "expression",
|
|
"expr": "state.score >= 80 ? 'qualified' : 'nurture'",
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
action = definition.methods["classify"].do
|
|
|
|
assert action.call == "expression"
|
|
assert action.expr == "state.score >= 80 ? 'qualified' : 'nurture'"
|
|
assert Flow.from_declaration(contents=definition).kickoff(inputs={"score": 90}) == "qualified"
|
|
|
|
|
|
def test_explicit_cel_fields_accept_expression_markers():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"schema": "crewai.flow/v1",
|
|
"name": "ExpressionFlow",
|
|
"methods": {
|
|
"classify": {
|
|
"start": True,
|
|
"do": {
|
|
"call": "expression",
|
|
"expr": "${state.score >= 80 ? 'qualified' : 'nurture'}",
|
|
},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert Flow.from_declaration(contents=definition).kickoff(inputs={"score": 90}) == "qualified"
|
|
|
|
|
|
def test_expression_local_context_recurses_into_dataclass_values():
|
|
from crewai.flow.expressions import Expression
|
|
|
|
class Payload(BaseModel):
|
|
name: str
|
|
|
|
@dataclass
|
|
class Row:
|
|
payload: Payload
|
|
|
|
assert (
|
|
Expression.from_flow(
|
|
"item.payload.name",
|
|
Flow(),
|
|
local_context={"item": Row(payload=Payload(name="qualified"))},
|
|
).evaluate()
|
|
== "qualified"
|
|
)
|
|
|
|
|
|
def test_expression_empty_context_overrides_stored_context():
|
|
from crewai.flow.expressions import Expression, ExpressionError
|
|
|
|
expression = Expression("state.score", context={"state": {"score": 90}})
|
|
|
|
assert expression.evaluate() == 90
|
|
with pytest.raises(ExpressionError):
|
|
expression.evaluate({})
|
|
|
|
|
|
def test_expression_template_empty_context_overrides_stored_context():
|
|
from crewai.flow.expressions import Expression, ExpressionError
|
|
|
|
expression = Expression(
|
|
{"score": "${state.score}"}, context={"state": {"score": 90}}
|
|
)
|
|
|
|
assert expression.render_template() == {"score": 90}
|
|
with pytest.raises(ExpressionError):
|
|
expression.render_template({})
|
|
|
|
|
|
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_declaration(contents=yaml_str)
|
|
|
|
assert Flow.from_declaration(contents=definition).kickoff(
|
|
inputs={"direction": "left"}
|
|
) == "took-left"
|
|
assert Flow.from_declaration(contents=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
|
|
"""
|
|
|
|
with pytest.raises(ValidationError, match="invalid CEL expression"):
|
|
FlowDefinition.from_declaration(contents=yaml_str)
|
|
|
|
|
|
def test_expression_action_rejects_unknown_cel_root():
|
|
yaml_str = """
|
|
schema: crewai.flow/v1
|
|
name: ExpressionFlow
|
|
methods:
|
|
classify:
|
|
do:
|
|
call: expression
|
|
expr: "score >= 80"
|
|
start: true
|
|
"""
|
|
|
|
with pytest.raises(ValidationError, match="unknown CEL root"):
|
|
FlowDefinition.from_declaration(contents=yaml_str)
|
|
|
|
|
|
def test_tool_action_requires_module_qualname_ref():
|
|
definition = FlowDefinition.from_declaration(contents=
|
|
{
|
|
"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_declaration(contents=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_declaration(contents=
|
|
FlowDefinition.from_declaration(contents=PYDANTIC_STATE_OVERLAY_YAML)
|
|
)
|
|
result = flow.kickoff()
|
|
assert result == "count=6"
|
|
assert flow.state.count == 6
|
|
|
|
|
|
def test_json_schema_state():
|
|
flow = Flow.from_declaration(contents=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_declaration(contents=JSON_SCHEMA_STATE_YAML)
|
|
with pytest.raises(ValueError, match="Invalid inputs"):
|
|
flow.kickoff(inputs={"count": "not-a-number"})
|
|
|
|
|
|
def test_json_schema_state_required_fields_can_come_from_kickoff_inputs():
|
|
flow = Flow.from_declaration(contents=
|
|
FlowDefinition.from_declaration(contents=JSON_SCHEMA_REQUIRED_INPUT_STATE_YAML)
|
|
)
|
|
|
|
result = flow.kickoff(inputs={"lead_name": "Ada Lovelace"})
|
|
|
|
assert result == "Ada Lovelace"
|
|
assert flow.state.lead_name == "Ada Lovelace"
|
|
assert flow.state.id
|
|
|
|
|
|
def test_pydantic_state_falls_back_to_json_schema_when_ref_unimportable():
|
|
flow = Flow.from_declaration(contents=
|
|
FlowDefinition.from_declaration(contents=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_declaration(contents=
|
|
FlowDefinition.from_declaration(contents=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_declaration(contents=DICT_STATE_YAML)
|
|
|
|
flow = Flow.from_declaration(contents=definition)
|
|
assert flow.state["count"] == 5
|
|
assert flow.state["id"]
|
|
flow.kickoff()
|
|
assert flow.state["begin_ran"] is True
|
|
|
|
second = Flow.from_declaration(contents=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_declaration(contents=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_declaration(contents=yaml_str)
|
|
result = flow.kickoff()
|
|
return flow, result, events
|
|
|
|
|
|
_LIFECYCLE_EVENTS = [
|
|
FlowCreatedEvent,
|
|
FlowStartedEvent,
|
|
FlowFinishedEvent,
|
|
MethodExecutionStartedEvent,
|
|
MethodExecutionFinishedEvent,
|
|
]
|
|
|
|
|
|
def test_config_suppress_flow_events_from_declaration():
|
|
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_declaration():
|
|
flow = Flow.from_declaration(contents=CAPPED_LOOP_YAML)
|
|
with pytest.raises(RecursionError, match="has been called 2 times"):
|
|
flow.kickoff()
|
|
|
|
|
|
def test_config_stream_from_declaration():
|
|
flow = Flow.from_declaration(contents=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_declaration():
|
|
_, _, 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_declaration(tmp_path):
|
|
yaml_str = (
|
|
CHAIN_YAML
|
|
+ f"""
|
|
config:
|
|
checkpoint:
|
|
location: {tmp_path}
|
|
"""
|
|
)
|
|
flow = Flow.from_declaration(contents=yaml_str)
|
|
assert isinstance(flow.checkpoint, CheckpointConfig)
|
|
assert flow.checkpoint.location == str(tmp_path)
|
|
|
|
|
|
def test_config_input_provider_from_declaration():
|
|
flow = Flow.from_declaration(contents=
|
|
FlowDefinition.from_declaration(contents=INPUT_PROVIDER_CHAIN_YAML)
|
|
)
|
|
assert isinstance(flow.input_provider, StubInputProvider)
|
|
|
|
|
|
def test_definition_config_equivalence():
|
|
class_flow = ConfiguredFlow()
|
|
definition = FlowDefinition.from_declaration(
|
|
contents=ConfiguredFlow.flow_definition()
|
|
)
|
|
definition_flow = Flow.from_declaration(contents=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_declaration(contents=
|
|
{
|
|
"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_declaration_saves_once_per_method():
|
|
yaml_str = _flow_level_persist_yaml("yaml-flow-level")
|
|
flow = Flow.from_declaration(contents=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_declaration_saves_only_that_method():
|
|
yaml_str = _method_level_persist_yaml("yaml-method-level")
|
|
flow = Flow.from_declaration(contents=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_declaration(contents=yaml_str)
|
|
flow.kickoff()
|
|
|
|
assert _saved_methods("yaml-opt-out") == ["first"]
|
|
|
|
|
|
def test_persist_restore_by_id_from_declaration():
|
|
yaml_str = _flow_level_persist_yaml("yaml-restore")
|
|
|
|
flow1 = Flow.from_declaration(contents=yaml_str)
|
|
flow1.kickoff()
|
|
assert flow1.state["count"] == 2
|
|
|
|
flow2 = Flow.from_declaration(contents=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_definition_persist_equivalence():
|
|
definition = FlowDefinition.from_declaration(
|
|
contents=ClassPersistedFlow.flow_definition()
|
|
)
|
|
|
|
before = len(DefinitionStoreBackend.saves["class-decorator"])
|
|
flow = Flow.from_declaration(contents=definition)
|
|
flow.kickoff()
|
|
|
|
assert _saved_methods("class-decorator")[before:] == ["first", "second"]
|
|
|
|
|
|
def test_method_persist_backend_overrides_flow_level_backend_from_declaration():
|
|
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_declaration(contents=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_declaration_default_outcome_routes():
|
|
flow = Flow.from_declaration(contents=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_declaration_collapses_and_routes():
|
|
flow = Flow.from_declaration(contents=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_definition_human_feedback_equivalence():
|
|
class_flow = ReviewFlow()
|
|
with patch.object(class_flow, "_request_human_feedback", return_value=""):
|
|
class_result = class_flow.kickoff()
|
|
|
|
definition = FlowDefinition.from_declaration(contents=ReviewFlow.flow_definition())
|
|
twin = Flow.from_declaration(contents=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_declaration():
|
|
definition = FlowDefinition.from_declaration(contents=PENDING_REVIEW_YAML)
|
|
|
|
flow = Flow.from_declaration(contents=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_declaration():
|
|
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_declaration(contents=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_declaration(contents=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_declaration(contents=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_declaration(contents=CHAIN_YAML)
|
|
flow = Flow.from_declaration(contents=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_declaration(contents=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()
|